From 92a1e9cbe85a38fffbb6127113a972e26aa7714c Mon Sep 17 00:00:00 2001 From: "s.naidenov" Date: Wed, 5 Mar 2025 17:55:55 +0700 Subject: [PATCH] DEV-454 --- .editorconfig | 176 +++++++++++++++++ ActiveSync/AsyncLocker/AsyncLocker.cs | 18 ++ ActiveSync/AsyncLocker/DisposableAction.cs | 16 ++ ActiveSync/AsyncLocker/LazyDictionary.cs | 15 ++ ActiveSync/Module.cs | 214 +++++++++++++++++++++ Configuration.cs | 47 ++++- ConfigurationKeys.cs | 4 +- Core/PersonalData.cs | 16 ++ MultiFactor.IIS.Adapter.csproj | 7 + Services/Ldap/Profile/ILdapProfile.cs | 3 +- Services/Ldap/Profile/LdapProfile.cs | 10 +- Services/Ldap/Profile/ProfileLoader.cs | 4 +- Services/Logger.cs | 3 +- Services/MfTraceIdFactory.cs | 3 +- Services/MultiFactorApiClient.cs | 55 +++++- Services/RequestLoggerBuilder.cs | 66 +++++++ Util.cs | 25 ++- WebUtil.cs | 18 +- 18 files changed, 688 insertions(+), 12 deletions(-) create mode 100644 .editorconfig create mode 100644 ActiveSync/AsyncLocker/AsyncLocker.cs create mode 100644 ActiveSync/AsyncLocker/DisposableAction.cs create mode 100644 ActiveSync/AsyncLocker/LazyDictionary.cs create mode 100644 ActiveSync/Module.cs create mode 100644 Core/PersonalData.cs create mode 100644 Services/RequestLoggerBuilder.cs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..621522c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,176 @@ +root = true + +[*.{cs,vb}] +dotnet_style_operator_placement_when_wrapping = beginning_of_line +tab_width = 4 +indent_style = space +indent_size = 4 +end_of_line = crlf +max_line_length = 160 +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_object_initializer = true:suggestion +dotnet_style_prefer_collection_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = false:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:warning +dotnet_style_namespace_match_folder = true:suggestion +dotnet_diagnostic.CA1851.severity = error +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = warning +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = warning +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_readonly_field = true:error +dotnet_style_predefined_type_for_member_access = true:silent +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent +dotnet_style_allow_statement_immediately_after_block_experimental = false:suggestion +dotnet_style_allow_multiple_blank_lines_experimental = true:silent +dotnet_code_quality_unused_parameters = all:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_event = false:silent +insert_final_newline = true +dotnet_diagnostic.CA1069.severity = warning + +[*.cs] + +# CA1822: Mark members as static +resharper_redundant_empty_object_creation_argument_list_highlighting=error +dotnet_diagnostic.CA1822.severity = none +csharp_indent_labels = one_less_than_current +csharp_space_around_binary_operators = before_and_after +csharp_using_directive_placement = outside_namespace:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_prefer_braces = true:error +csharp_style_namespace_declarations = block_scoped:silent +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_throw_expression = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true +csharp_new_line_before_open_brace = all +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.private_or_internal_field_should_be__fieldname.severity = suggestion +dotnet_naming_rule.private_or_internal_field_should_be__fieldname.symbols = private_or_internal_field +dotnet_naming_rule.private_or_internal_field_should_be__fieldname.style = _fieldname + +# Symbol specifications + +dotnet_naming_symbols.private_or_internal_field.applicable_kinds = field +dotnet_naming_symbols.private_or_internal_field.applicable_accessibilities = internal, private, private_protected +dotnet_naming_symbols.private_or_internal_field.required_modifiers = + +# Naming styles + +dotnet_naming_style._fieldname.required_prefix = _ +dotnet_naming_style._fieldname.word_separator = +dotnet_naming_style._fieldname.capitalization = camel_case +dotnet_diagnostic.xUnit2008.severity = error +dotnet_diagnostic.xUnit2009.severity = error +dotnet_diagnostic.ASP0000.severity = error +csharp_style_unused_value_expression_statement_preference = discard_variable:silent +csharp_style_prefer_readonly_struct = true:suggestion +csharp_prefer_static_local_function = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent +csharp_style_allow_embedded_statements_on_same_line_experimental = false:error +csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent +csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent +csharp_style_prefer_switch_expression = false:silent +csharp_style_conditional_delegate_call = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_extended_property_pattern = true:suggestion +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = false:silent +csharp_style_var_elsewhere = false:silent +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true +csharp_space_before_dot = false +csharp_space_after_dot = false +dotnet_diagnostic.xUnit2000.severity = error + +#### Custom rules #### +dotnet_diagnostic.Multifactor_EnforceNamedArgumentsForLiterals.severity = warning diff --git a/ActiveSync/AsyncLocker/AsyncLocker.cs b/ActiveSync/AsyncLocker/AsyncLocker.cs new file mode 100644 index 0000000..e16bf5f --- /dev/null +++ b/ActiveSync/AsyncLocker/AsyncLocker.cs @@ -0,0 +1,18 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace MultiFactor.IIS.Adapter.ActiveSync.AsyncLocker +{ + public class AsyncLocker + { + private readonly LazyDictionary _semaphoreDictionary = new LazyDictionary(); + + public async Task LockAsync(T key) + { + SemaphoreSlim semaphore = _semaphoreDictionary.GetOrAdd(key, () => new SemaphoreSlim(1, 1)); + await semaphore.WaitAsync(); + return new DisposableAction(() => semaphore.Release()); + } + } +} diff --git a/ActiveSync/AsyncLocker/DisposableAction.cs b/ActiveSync/AsyncLocker/DisposableAction.cs new file mode 100644 index 0000000..8fae188 --- /dev/null +++ b/ActiveSync/AsyncLocker/DisposableAction.cs @@ -0,0 +1,16 @@ +using System; + +namespace MultiFactor.IIS.Adapter.ActiveSync.AsyncLocker +{ + public sealed class DisposableAction : IDisposable + { + private readonly Action _action; + + public DisposableAction(Action action) => _action = action; + + public void Dispose() + { + _action?.Invoke(); + } + } +} diff --git a/ActiveSync/AsyncLocker/LazyDictionary.cs b/ActiveSync/AsyncLocker/LazyDictionary.cs new file mode 100644 index 0000000..3784b2b --- /dev/null +++ b/ActiveSync/AsyncLocker/LazyDictionary.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Concurrent; + +namespace MultiFactor.IIS.Adapter.ActiveSync.AsyncLocker +{ + public class LazyDictionary + { + private readonly ConcurrentDictionary> _dictionary = new ConcurrentDictionary>(); + + public TValue GetOrAdd(TKey key, Func valueGenerator) + { + return _dictionary.GetOrAdd(key, k => new Lazy(valueGenerator)).Value; + } + } +} diff --git a/ActiveSync/Module.cs b/ActiveSync/Module.cs new file mode 100644 index 0000000..f79ccd0 --- /dev/null +++ b/ActiveSync/Module.cs @@ -0,0 +1,214 @@ +using System; +using System.Linq; +using System.Net; +using System.Runtime.Caching; +using System.Threading.Tasks; +using System.Web; +using MultiFactor.IIS.Adapter.ActiveSync.AsyncLocker; +using MultiFactor.IIS.Adapter.Core; +using MultiFactor.IIS.Adapter.Extensions; +using MultiFactor.IIS.Adapter.Owa; +using MultiFactor.IIS.Adapter.Services; + +namespace MultiFactor.IIS.Adapter.ActiveSync +{ + public class Module : IHttpModule + { + private MemoryCache _memoryCache; + private static readonly AsyncLocker _locker = new AsyncLocker(); + + public void Init(HttpApplication context) + { + EventHandlerTaskAsyncHelper handler = new EventHandlerTaskAsyncHelper(HandleRequest); + context.AddOnBeginRequestAsync(handler.BeginEventHandler, handler.EndEventHandler); + _memoryCache = MemoryCache.Default; + } + + private async Task HandleRequest(object sender, EventArgs e) + { + await OnProvision(new HttpContextWrapper(((HttpApplication)sender).Context)); + } + + private async Task OnProvision(HttpContextBase context) + { + if (ShouldSkipRequest(context)) + { + return; + } + + var canonicalUserName = GetCanonicalizedUserName(context); + using (await _locker.LockAsync(canonicalUserName)) + { + if (!SecondFactorIsRequired(context, canonicalUserName)) + { + //bypass 2fa + Logger.ActiveSync.Info($"Bypass for user '{canonicalUserName}'."); + return; + } + var cacheKey = BuildCacheKey(canonicalUserName,context); + + var val = GetCacheValue(cacheKey); + if (val == 0) + { + AccessDenied(context); + return; + } + + if (val == 1 || context.HasApiUnreachableFlag()) + { + return; + } + + Logger.ActiveSync.Info("2fa begin for user " + canonicalUserName); + var isSecondFactorSuccessful = StartSecondFactorAuth(context, canonicalUserName); + if (isSecondFactorSuccessful) + { + _memoryCache.Set(cacheKey, 1, DateTimeOffset.Now.AddSeconds(45)); + Logger.ActiveSync.Info("2fa is successful for user" + canonicalUserName); + return; + } + + _memoryCache.Set(cacheKey, 0, DateTimeOffset.Now.AddSeconds(60)); + AccessDenied(context); + } + } + + private bool ShouldSkipRequest(HttpContextBase context) + { + var method = context.Request.HttpMethod.ToLowerInvariant(); + var userName = context.Request.Params["User"] ?? string.Empty; + var isPost = method == "post"; + var isSystemMailbox = Constants.EXCHANGE_SYSTEM_MAILBOX_PREFIX.Any(sm => userName.ToLowerInvariant().Contains(sm)); + var isProvision = WebUtil.IsInitialProvisionRequest(context.Request); + return !isPost || !isProvision || isSystemMailbox; + } + + private string GetCanonicalizedUserName(HttpContextBase context) + { + string rawUserName = context.Request.Params["User"]; + string userName = string.IsNullOrWhiteSpace(rawUserName) ? null : Util.CanonicalizeUserName(rawUserName); + string userDomain = string.IsNullOrWhiteSpace(rawUserName) ? null : Util.GetUserDomain(rawUserName); + string httpXEasProxyParam = context.Request.Params["HTTP_X_EAS_PROXY"]; + string[] chunks; + if (!string.IsNullOrWhiteSpace(httpXEasProxyParam)) + { + chunks = httpXEasProxyParam.Split(','); + } + else + { + chunks = null; + } + + string lastChunk = chunks == null || chunks.Length <= 1 ? null : chunks.LastOrDefault(); + string userNameFromProxy = string.IsNullOrWhiteSpace(lastChunk) ? null : Util.CanonicalizeUserName(lastChunk); + string userDomainFromProxy = string.IsNullOrWhiteSpace(lastChunk) ? null : Util.GetUserDomain(lastChunk); + if (string.IsNullOrWhiteSpace(userDomain)) + { + if (string.IsNullOrWhiteSpace(userDomainFromProxy)) + { + return userName; + } + + return userDomainFromProxy + "\\" + userNameFromProxy; + } + + return userDomain + "\\" + userName; + } + + private bool SecondFactorIsRequired(HttpContextBase context, string userName) + { + var ad = new ActiveDirectoryService(context.GetCacheAdapter(), Logger.ActiveSync); + var secondFactorRequired = new UserRequiredSecondFactor(ad); + var secondFactorUsername = Util.CanonicalizeUserName(userName); + return secondFactorRequired.Execute(secondFactorUsername); + } + + private int? GetCacheValue(string key) + { + var cachedValue = _memoryCache.Get(key); + return cachedValue as int?; + } + + private bool StartSecondFactorAuth(HttpContextBase context, string userName) + { + var ad = new ActiveDirectoryService(context.GetCacheAdapter(), Logger.ActiveSync); + + var identity = Util.CanonicalizeUserName(userName); + Logger.ActiveSync.Info($"Applying identity canonicalization: {userName}->{identity}"); + + var profile = ad.GetProfile(identity); + + if (profile == null) + { + Logger.ActiveSync.Error($"No profile found for identity: {identity}"); + // redirect to (custom?) error page + throw new Exception($"Profile {identity} not found"); + } + + if (Configuration.Current.HasTwoFaIdentityAttribute && !string.IsNullOrEmpty(profile.TwoFAIdentity)) + { + Logger.ActiveSync.Info($"Applying 2fa identity attribute: {identity}->{profile.TwoFAIdentity}"); + identity = profile.TwoFAIdentity; + } + + var response = CreateAccessRequest(context, identity, profile?.Phone, profile?.Email); + + return response.Granted; + } + + private MultiFactorAccessRequest CreateAccessRequest(HttpContextBase context, string identity, string phone, string email) + { + var api = new MultiFactorApiClient(Logger.ActiveSync, MfTraceIdFactory.CreateTraceActiveSync); + + try + { + var response = api.CreateNonInteractiveAccessRequest("/access/requests/ex", new PersonalData(identity, email, phone)); + return response; + } + catch (WebException wex) when (Configuration.Current.BypassSecondFactorWhenApiUnreachable) + { + var errmsg = $"Multifactor API host unreachable: {Configuration.Current.ApiUrl}. Reason: {wex}"; + Logger.ActiveSync.Error(errmsg); + + if (wex.Response != null) + { + var httpStatusCode = ((HttpWebResponse)wex.Response).StatusCode; + if ((int)httpStatusCode == 429) + { + Logger.ActiveSync.Error($"Too many requests. Please try again later."); + } + + return new MultiFactorAccessRequest { Status = "Denied" }; + } + + Logger.ActiveSync.Warn($"Bypassing the second factor for user '{identity}'."); + context + .GetCacheAdapter() + .SetApiUnreachable(Util.CanonicalizeUserName(identity), true); + return new MultiFactorAccessRequest { Status = "Granted" }; + } + catch (Exception ex) + { + Logger.ActiveSync.Error(ex.ToString()); + } + + return new MultiFactorAccessRequest { Status = "Denied" }; + } + + private void AccessDenied(HttpContextBase context) + { + context.Response.StatusCode = 440; + context.Response.End(); + } + + private string BuildCacheKey(string userName, HttpContextBase context) + { + var deviceId = context.Request.Params["DeviceId"]; + return $"{userName}-{deviceId}"; + } + + public void Dispose() + { + } + } +} diff --git a/Configuration.cs b/Configuration.cs index b5e7383..14cfddc 100644 --- a/Configuration.cs +++ b/Configuration.cs @@ -8,6 +8,8 @@ namespace MultiFactor.IIS.Adapter { public class Configuration { + private const int _defaultSessionLifeTimeInHours = 1; + private const int _reRequestDelayInMinutes = 5; private readonly string _activeDirectoryDomain; public string[] ActiveDirectoryDomains => (_activeDirectoryDomain ?? string.Empty).Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) @@ -29,7 +31,8 @@ public class Configuration public bool HasTwoFaIdentityAttribute => !string.IsNullOrWhiteSpace(TwoFaIdentityAttribute); public string TwoFaIdentityAttribute { get; private set; } public string[] PhoneAttributes { get; private set; } = new string[0]; - + public int SessionLifeTimeInHours { get; private set; } + public int ReRequestDelayInMinutes { get; private set; } private static readonly Lazy _current = new Lazy(Load); public static Configuration Current => _current.Value; @@ -56,6 +59,8 @@ protected static Configuration Load() var activeDirectory2FaGroupSetting = appSettings[ConfigurationKeys.ActiveDirectory2FAGroup]; var activeDirectoryDomain = appSettings[ConfigurationKeys.ActiveDirectoryDomain]; + var sessionLifeTime = appSettings[ConfigurationKeys.SessionLifeTimeInHours]; + var reRequestDelay = appSettings[ConfigurationKeys.SecondFactorReRequestDelayInMinutes]; var domain = GetDomain(activeDirectoryDomain); @@ -84,7 +89,9 @@ protected static Configuration Load() ReadPhoneAttributeSetting(appSettings, config); ReadBypassWhenApiUnreachableSetting(appSettings, config); ReadApiLifeCheckIntervalSetting(appSettings, config); - + SetSessionLifeTime(sessionLifeTime, config); + SetReRequestDelay(reRequestDelay, config); + return config; } @@ -194,5 +201,39 @@ private static void ReadBypassWhenApiUnreachableSetting(NameValueCollection appS configuration.BypassSecondFactorWhenApiUnreachable = parsed; } + + private static void SetSessionLifeTime(string value, Configuration configuration) + { + if (int.TryParse(value, out int parsed)) + { + if (parsed <= 0) + { + configuration.SessionLifeTimeInHours = _defaultSessionLifeTimeInHours; + } + + configuration.SessionLifeTimeInHours = parsed; + } + else + { + configuration.SessionLifeTimeInHours = _defaultSessionLifeTimeInHours; + } + } + + private static void SetReRequestDelay(string value, Configuration configuration) + { + if (int.TryParse(value, out int parsed)) + { + if (parsed <= 0) + { + configuration.ReRequestDelayInMinutes = _reRequestDelayInMinutes; + } + + configuration.ReRequestDelayInMinutes = parsed; + } + else + { + configuration.ReRequestDelayInMinutes = _reRequestDelayInMinutes; + } + } } -} \ No newline at end of file +} diff --git a/ConfigurationKeys.cs b/ConfigurationKeys.cs index 4f24b74..c63eb02 100644 --- a/ConfigurationKeys.cs +++ b/ConfigurationKeys.cs @@ -19,5 +19,7 @@ public static class ConfigurationKeys public static readonly string UseUpnAsIdentity = $"{_prefix}:use-upn-as-identity"; public static readonly string TwoFAIdentityAttribyte = $"{_prefix}:use-attribute-as-identity"; public static readonly string PhoneAttribute = $"{_prefix}:phone-attribute"; + public static readonly string SessionLifeTimeInHours = $"{_prefix}:session-life-time"; + public static readonly string SecondFactorReRequestDelayInMinutes = $"{_prefix}:second-factor-re-request-delay"; } -} \ No newline at end of file +} diff --git a/Core/PersonalData.cs b/Core/PersonalData.cs new file mode 100644 index 0000000..5b2cb88 --- /dev/null +++ b/Core/PersonalData.cs @@ -0,0 +1,16 @@ +namespace MultiFactor.IIS.Adapter.Core +{ + public class PersonalData + { + public string Email { get; } + public string Phone { get; } + public string Identity { get; } + + public PersonalData(string identity, string email, string phone) + { + Identity = identity; + Email = email; + Phone = phone; + } + } +} diff --git a/MultiFactor.IIS.Adapter.csproj b/MultiFactor.IIS.Adapter.csproj index 86d1f5d..78fde2e 100644 --- a/MultiFactor.IIS.Adapter.csproj +++ b/MultiFactor.IIS.Adapter.csproj @@ -61,13 +61,19 @@ + + + + + + @@ -96,6 +102,7 @@ + diff --git a/Services/Ldap/Profile/ILdapProfile.cs b/Services/Ldap/Profile/ILdapProfile.cs index 350a27e..1b3d118 100644 --- a/Services/Ldap/Profile/ILdapProfile.cs +++ b/Services/Ldap/Profile/ILdapProfile.cs @@ -5,5 +5,6 @@ public interface ILdapProfile string SamAccountName { get; } string TwoFAIdentity { get; } string Phone { get; } + string Email { get; } } -} \ No newline at end of file +} diff --git a/Services/Ldap/Profile/LdapProfile.cs b/Services/Ldap/Profile/LdapProfile.cs index 31b00bd..ee04740 100644 --- a/Services/Ldap/Profile/LdapProfile.cs +++ b/Services/Ldap/Profile/LdapProfile.cs @@ -30,6 +30,14 @@ public string Phone } } + public string Email + { + get + { + return GetAttr("mail").FirstOrDefault() ?? GetAttr("email").FirstOrDefault(); + } + } + public LdapProfile(string samAccountName, Configuration configuration) { if (string.IsNullOrWhiteSpace(samAccountName)) @@ -101,4 +109,4 @@ private string[] GetAttr(string key) : new string[0]; } } -} \ No newline at end of file +} diff --git a/Services/Ldap/Profile/ProfileLoader.cs b/Services/Ldap/Profile/ProfileLoader.cs index 61d4c5b..5a50ee9 100644 --- a/Services/Ldap/Profile/ProfileLoader.cs +++ b/Services/Ldap/Profile/ProfileLoader.cs @@ -28,6 +28,8 @@ public ILdapProfile Load(string samAccountName) var queryAttributes = new List(); queryAttributes.AddRange(_config.PhoneAttributes); + queryAttributes.Add("mail"); + queryAttributes.Add("email"); if (_config.HasTwoFaIdentityAttribute) { queryAttributes.Add(_config.TwoFaIdentityAttribute); @@ -55,4 +57,4 @@ public ILdapProfile Load(string samAccountName) return profile; } } -} \ No newline at end of file +} diff --git a/Services/Logger.cs b/Services/Logger.cs index d373efb..70efc97 100644 --- a/Services/Logger.cs +++ b/Services/Logger.cs @@ -18,7 +18,8 @@ public Logger(string source) public static Logger Owa => new Logger("Multifactor OWA"); public static Logger API => new Logger("Multifactor API"); public static Logger IIS => new Logger("Multifactor IIS"); - + public static Logger ActiveSync => new Logger("Multifactor ActiveSync"); + public void Info(string message) { WriteEvent(message, EventLogEntryType.Information); diff --git a/Services/MfTraceIdFactory.cs b/Services/MfTraceIdFactory.cs index 20fe469..af61302 100644 --- a/Services/MfTraceIdFactory.cs +++ b/Services/MfTraceIdFactory.cs @@ -6,5 +6,6 @@ public static class MfTraceIdFactory { public static string CreateTraceOwa() => $"iis-owa-{Guid.NewGuid()}"; public static string CreateTraceCrm() => $"iis-crm-{Guid.NewGuid()}"; + public static string CreateTraceActiveSync() => $"iis-eas-{Guid.NewGuid()}"; } -} \ No newline at end of file +} diff --git a/Services/MultiFactorApiClient.cs b/Services/MultiFactorApiClient.cs index ff00f2f..2810e57 100644 --- a/Services/MultiFactorApiClient.cs +++ b/Services/MultiFactorApiClient.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Net; using System.Text; +using MultiFactor.IIS.Adapter.Core; namespace MultiFactor.IIS.Adapter.Services { @@ -97,6 +98,48 @@ public string CreateRequest(string identity, string rawUserName, string postback throw new Exception($"{errmsg}", ex); } } + + public MultiFactorAccessRequest CreateNonInteractiveAccessRequest(string methodPath, PersonalData personalData) + { + var url = $"{Configuration.Current.ApiUrl}{methodPath}"; + var payload = new + { + Identity = personalData.Identity, + Phone = personalData.Phone, + Email = personalData.Email + }; + + var str = Util.JsonSerialize(payload); + var requestData = Encoding.UTF8.GetBytes(str); + var auth = Convert.ToBase64String( + Encoding.ASCII.GetBytes($"{Configuration.Current.ApiKey}:{Configuration.Current.ApiSecret}")); + byte[] responseData = null; + + using (var web = new WebClient()) + { + web.Headers.Add("Content-Type", "application/json"); + web.Headers.Add("Authorization", $"Basic {auth}"); + web.Headers.Add("mf-trace-id", _getTraceId()); + + if (!string.IsNullOrEmpty(Configuration.Current.ApiProxy)) + { + web.Proxy = new WebProxy(Configuration.Current.ApiProxy); + } + + responseData = web.UploadData(url, "POST", requestData); + } + + var responseJson = Encoding.UTF8.GetString(responseData); + + var response = Util.JsonDeserialize>(responseJson); + + if (!response.Success) + { + throw new Exception($"Got unsuccessful response from API: {responseJson}"); + } + + return response.Model; + } } public class MultiFactorWebResponse @@ -112,4 +155,14 @@ public class MultiFactorAccessPage { public string Url { get; set; } } -} \ No newline at end of file + + public class MultiFactorAccessRequest + { + public string Id { get; set; } + public string Identity { get; set; } + public string Status { get; set; } + public string Message { get; set; } + public bool Granted => Status == "Granted"; + public bool Denied => Status == "Denied"; + } +} diff --git a/Services/RequestLoggerBuilder.cs b/Services/RequestLoggerBuilder.cs new file mode 100644 index 0000000..641b468 --- /dev/null +++ b/Services/RequestLoggerBuilder.cs @@ -0,0 +1,66 @@ +using System.Collections.Specialized; +using System.Text; +using System.Web; + +namespace MultiFactor.IIS.Adapter.Services +{ + public class RequestLoggerBuilder + { + public static string BuildRequestLog(HttpContextBase context) + { + var messageHttpMethod = context.Request.HttpMethod; + var requestUrl = context.Request.Url?.AbsoluteUri; + var requestParams = context.Request.Params; + var form = context.Request.Form; + var requestPath = context.Request.Path; + var requestQueryString = context.Request.QueryString; + var requestHeaders = context.Request.Headers; + var appPath = context.Request.ApplicationPath; + var builder = new StringBuilder("Request: "); + string deviceId = context.Request.Params["DeviceId"]; + string user = context.Request.Params["User"]; + var cmd = context.Request.Params["Cmd"]; + builder.AppendLine($"User: {user}, DeviceId: {deviceId}, Cmd: {cmd}"); + builder.AppendLine($"{nameof(context.Request.HttpMethod)} = {messageHttpMethod}"); + builder.AppendLine($"{nameof(context.Request.Url.AbsoluteUri)} = {requestUrl}"); + builder.AppendLine($"{nameof(context.Request.Path)} = {requestPath}"); + builder.AppendLine($"{nameof(context.Request.QueryString)} = {requestQueryString}"); + builder.AppendLine($"{nameof(context.Request.ApplicationPath)} = {appPath}"); + + builder.AppendLine("User = " + user); + + builder.AppendLine("Device Id = " + deviceId); + + string deviceType = context.Request.Params["DeviceType"]; + string userAgent = context.Request.Params["HTTP_USER_AGENT"]; + builder.AppendLine("Device Type = " + deviceType); + builder.AppendLine("Device UserAgent = " + userAgent); + + + if (!string.IsNullOrWhiteSpace(cmd)) + builder.AppendLine("Cmd = " + cmd); + + builder.AppendLine("--------------------------"); + builder.AppendLine($"Headers:"); + AddParams(builder, requestHeaders); + + builder.AppendLine("--------------------------"); + builder.AppendLine($"Form:"); + AddParams(builder, form); + + builder.AppendLine("--------------------------"); + builder.AppendLine($"Parameters:"); + AddParams(builder, requestParams); + + return builder.ToString(); + } + + private static void AddParams(StringBuilder builder, NameValueCollection valueCollection) + { + foreach (var key in valueCollection.AllKeys) + { + builder.AppendLine($"{key} = {valueCollection[key]}"); + } + } + } +} diff --git a/Util.cs b/Util.cs index 88544db..5b2034e 100644 --- a/Util.cs +++ b/Util.cs @@ -103,5 +103,28 @@ public static string CanonicalizeUserName(string userName) return identity; } + + public static string GetUserDomain(string userName) + { + if (string.IsNullOrEmpty(userName) || !userName.Contains("\\") && !userName.Contains("@")) + { + return null; + } + + string domain = userName; + int length = domain.IndexOf("\\"); + if (length > 0) + { + domain = domain.Substring(0, length); + } + + int num = domain.IndexOf("@"); + if (num > 0) + { + domain = domain.Substring(num + 1); + } + + return domain.ToLower(); + } } -} \ No newline at end of file +} diff --git a/WebUtil.cs b/WebUtil.cs index 86b2497..78f069b 100644 --- a/WebUtil.cs +++ b/WebUtil.cs @@ -25,5 +25,21 @@ public static bool IsStaticResourceRequest(Uri uri) var path = uri.LocalPath; return staticContent.Any(c => path.EndsWith(c)); } + + /// + /// Determines whether the request is the first provisioning request + /// https://learn.microsoft.com/en-us/previous-versions/office/developer/exchange-server-interoperability-guidance/hh531590(v=exchg.140) + /// + public static bool IsInitialProvisionRequest(HttpRequestBase request) + { + var cmd = request.Params["Cmd"]; + var xMsPolicyKey = request.Headers["X-MS-PolicyKey"]?.Trim(); + if (cmd?.ToLower() == "provision" && (string.IsNullOrWhiteSpace(xMsPolicyKey) || xMsPolicyKey == "0")) + { + return true; + } + + return false; + } } -} \ No newline at end of file +}