From d38da357d293123a7f0ce296d2da8745f4285a6d Mon Sep 17 00:00:00 2001 From: "s.naidenov" Date: Wed, 5 Mar 2025 17:55:55 +0700 Subject: [PATCH 1/2] DEV-454 --- .editorconfig | 176 +++++++++++++++++++ ActiveSync/Module.cs | 227 +++++++++++++++++++++++++ Core/PersonalData.cs | 16 ++ MultiFactor.IIS.Adapter.csproj | 4 + 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 | 65 +++++++ WebUtil.cs | 19 ++- 12 files changed, 578 insertions(+), 7 deletions(-) create mode 100644 .editorconfig 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/Module.cs b/ActiveSync/Module.cs new file mode 100644 index 0000000..b3e54b2 --- /dev/null +++ b/ActiveSync/Module.cs @@ -0,0 +1,227 @@ +using System; +using System.Linq; +using System.Net; +using System.Security.Principal; +using System.Web; +using MultiFactor.IIS.Adapter.Extensions; +using MultiFactor.IIS.Adapter.Owa; +using MultiFactor.IIS.Adapter.Services; +using System.Runtime.Caching; +using MultiFactor.IIS.Adapter.Core; + +namespace MultiFactor.IIS.Adapter.ActiveSync +{ + public class Module : IHttpModule + { + private MemoryCache _memoryCache; + private readonly string _applicationName = "/microsoft-server-activesync"; + private HttpApplication _httpApplication; + public void Init(HttpApplication context) + { + context.PostAuthorizeRequest += HandlePostAuthorizeRequest; + + _memoryCache = MemoryCache.Default; + _httpApplication = context; + } + + private void HandlePostAuthorizeRequest(object sender, EventArgs e) + { + OnPostAuthorizeRequest(new HttpContextWrapper(((HttpApplication)sender).Context)); + } + + private void OnPostAuthorizeRequest(HttpContextBase context) + { + var path = context.Request.Url?.GetComponents(UriComponents.Path, UriFormat.Unescaped); + + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + if (context.Request.ApplicationPath?.ToLower().StartsWith(_applicationName) != true) + { + return; + } + + //static resources + if (WebUtil.IsStaticResourceRequest(context.Request.Url)) + { + return; + } + + if (!context.User.Identity.IsAuthenticated) + //not yet authenticated with login/pwd + { + return; + } + + var user = context.User.Identity.Name; + if (user.StartsWith("S-1-5-21")) //SID + { + user = TryGetUpnFromSid(context.User.Identity); + } + + var canonicalUserName = Util.CanonicalizeUserName(user); + if (Constants.EXCHANGE_SYSTEM_MAILBOX_PREFIX.Any(sm => canonicalUserName.StartsWith(sm))) + //system mailbox + { + return; + } + + var ad = new ActiveDirectoryService(context.GetCacheAdapter(), Logger.ActiveSync); + var secondFactorRequired = new UserRequiredSecondFactor(ad); + if (!secondFactorRequired.Execute(canonicalUserName)) + //bypass 2fa + { + return; + } + + if (WebUtil.IsXhrRequest(context.Request)) + { + AccessDenied(context); + return; + } + + if (WebUtil.IsInitialProvisionRequest(context.Request)) + { + var cacheKey = BuildCacheKey(context); + var cachedValue = _memoryCache.Get(cacheKey); + var val = cachedValue as int?; + + if (val == 0) + { + AccessDenied(context); + return; + } + + if (val == 1 || context.HasApiUnreachableFlag()) + { + return; + } + + //call to mfa + var secondFactorIsSuccessed = StartSecondFactorAuth(context); + if (secondFactorIsSuccessed) + { + _memoryCache.Set(cacheKey, 1, DateTimeOffset.Now.AddSeconds(5)); + return; + } + + _memoryCache.Set(cacheKey, 0, DateTimeOffset.Now.AddSeconds(5)); + AccessDenied(context); + } + } + + private bool StartSecondFactorAuth(HttpContextBase context) + { + var ad = new ActiveDirectoryService(context.GetCacheAdapter(), Logger.ActiveSync); + + var userName = context.User.Identity.Name; + + 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" }; + } + + public static string TryGetUpnFromSid(IIdentity identity) + { + //for download domains exchange uses OAuthIdentity with SID name + //lets try find UPN with reflection + var actAsUser = GetPropValue(identity, "ActAsUser"); + var upn = GetPropValue(actAsUser, "UserPrincipalName"); + return upn as string; + } + + public static object GetPropValue(object src, string propName) + { + try + { + if (src == null) + { + return null; + } + + return src.GetType().GetProperty(propName).GetValue(src, null); + } + catch + { + return null; + } + } + + private void AccessDenied(HttpContextBase context) + { + context.Response.StatusCode = 440; + context.Response.End(); + } + + private string BuildCacheKey(HttpContextBase context) + { + var userName = context.User.Identity.Name; + var deviceId = context.Request.Params["DeviceId"]; + return $"{userName}-{deviceId}"; + } + + public void Dispose() + { + _httpApplication.PostAuthorizeRequest -= HandlePostAuthorizeRequest; + } + } +} 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..b5a2b96 100644 --- a/MultiFactor.IIS.Adapter.csproj +++ b/MultiFactor.IIS.Adapter.csproj @@ -61,13 +61,16 @@ + + + @@ -96,6 +99,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..0fe51e5 --- /dev/null +++ b/Services/RequestLoggerBuilder.cs @@ -0,0 +1,65 @@ +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: "); + + 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}"); + + string user = context.Request.Params["User"]; + builder.AppendLine("User = " + user); + + string deviceId = context.Request.Params["DeviceId"]; + 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); + + var cmd = context.Request.Params["Cmd"]; + 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]}"); + } + } + } +} \ No newline at end of file diff --git a/WebUtil.cs b/WebUtil.cs index 86b2497..7d45fdf 100644 --- a/WebUtil.cs +++ b/WebUtil.cs @@ -11,6 +11,23 @@ public static bool IsXhrRequest(HttpRequestBase request) return request.Headers["x-requested-with"] == "XMLHttpRequest"; } + /// + /// 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"]; + + if (cmd?.ToLower() == "provision" && xMsPolicyKey == "0") + { + return true; + } + + return false; + } + public static bool IsStaticResourceRequest(Uri uri) { var staticContent = new[] @@ -26,4 +43,4 @@ public static bool IsStaticResourceRequest(Uri uri) return staticContent.Any(c => path.EndsWith(c)); } } -} \ No newline at end of file +} From 1047eef0d212b6d581937fdbc351e9aa5c747824 Mon Sep 17 00:00:00 2001 From: "s.naidenov" Date: Wed, 19 Mar 2025 14:19:38 +0700 Subject: [PATCH 2/2] DEV-580 --- HttpContextBaseExtensions.cs | 34 +++++ .../MultiFactor.IIS.Adapter.Tests.csproj | 1 - .../OwaModuleTests.cs | 27 ---- MultiFactor.IIS.Adapter.csproj | 6 +- Owa/Module.cs | 116 +++++++++++++----- Services/AuthChecker.cs | 6 +- Services/MfaApiRequestExecutor.cs | 6 +- Services/MfaApiRequestExecutorFactory.cs | 6 +- ...tLoggerBuilder.cs => RequestLogBuilder.cs} | 13 +- 9 files changed, 142 insertions(+), 73 deletions(-) create mode 100644 HttpContextBaseExtensions.cs delete mode 100644 MultiFactor.IIS.Adapter.Tests/OwaModuleTests.cs rename Services/{RequestLoggerBuilder.cs => RequestLogBuilder.cs} (83%) diff --git a/HttpContextBaseExtensions.cs b/HttpContextBaseExtensions.cs new file mode 100644 index 0000000..b18ef36 --- /dev/null +++ b/HttpContextBaseExtensions.cs @@ -0,0 +1,34 @@ +using System; +using System.Web; + +namespace MultiFactor.IIS.Adapter +{ + public static class HttpContextBaseExtensions + { + public static void RemoveCookie( + this HttpContextBase context, + string cookieName) + { + HttpCookie cookie = context.Response.Cookies[cookieName]; + if (cookie == null) + { + return; + } + + cookie.Expires = DateTime.UtcNow.AddDays(-1); + } + + public static void AddCookie(this HttpContextBase context, HttpCookie cookie) + { + context.Response.Cookies.Set(cookie); + } + + public static void RemoveAllCookie(this HttpContextBase context) + { + foreach (string allKey in context.Request.Cookies.AllKeys) + { + context.RemoveCookie(allKey); + } + } + } +} diff --git a/MultiFactor.IIS.Adapter.Tests/MultiFactor.IIS.Adapter.Tests.csproj b/MultiFactor.IIS.Adapter.Tests/MultiFactor.IIS.Adapter.Tests.csproj index db07d53..c29661d 100644 --- a/MultiFactor.IIS.Adapter.Tests/MultiFactor.IIS.Adapter.Tests.csproj +++ b/MultiFactor.IIS.Adapter.Tests/MultiFactor.IIS.Adapter.Tests.csproj @@ -53,7 +53,6 @@ - diff --git a/MultiFactor.IIS.Adapter.Tests/OwaModuleTests.cs b/MultiFactor.IIS.Adapter.Tests/OwaModuleTests.cs deleted file mode 100644 index 17cb84b..0000000 --- a/MultiFactor.IIS.Adapter.Tests/OwaModuleTests.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Moq; -using System; -using Xunit; - -namespace MultiFactor.IIS.Adapter.Tests -{ - public class OwaModuleTests - { - [Fact] - public void OnBeginRequest_ContainsToken_ShouldSetupCookieAndRedirect() - { - const string expectedRedirect = "https://exchange/owa"; - var context = HttpContextMockBuilder.Create(x => - { - x.Request.SetupGet(r => r.Url).Returns(new Uri("https://exchange/owa/mfa.aspx")); - x.Request.SetupGet(r => r.ApplicationPath).Returns(expectedRedirect); - x.Form.Add("AccessToken", "MFA TOKEN"); - }); - var module = new Owa.Module(); - - module.OnBeginRequest(context.Object); - - Assert.Single(context.Object.Response.Cookies.AllKeys, Constants.COOKIE_NAME); - context.Verify(x => x.Response.Redirect(expectedRedirect, true), Times.Once); - } - } -} diff --git a/MultiFactor.IIS.Adapter.csproj b/MultiFactor.IIS.Adapter.csproj index b5a2b96..a52410d 100644 --- a/MultiFactor.IIS.Adapter.csproj +++ b/MultiFactor.IIS.Adapter.csproj @@ -72,9 +72,11 @@ + + True True @@ -85,7 +87,6 @@ - @@ -99,7 +100,8 @@ - + + diff --git a/Owa/Module.cs b/Owa/Module.cs index 875975a..5507836 100644 --- a/Owa/Module.cs +++ b/Owa/Module.cs @@ -1,18 +1,29 @@ -using MultiFactor.IIS.Adapter.Core; using MultiFactor.IIS.Adapter.Extensions; -using MultiFactor.IIS.Adapter.Properties; using MultiFactor.IIS.Adapter.Services; using System; using System.Linq; +using System.Runtime.Caching; using System.Security.Principal; using System.Web; namespace MultiFactor.IIS.Adapter.Owa { - public class Module : HttpModuleBase + public class Module : IHttpModule { - public override void OnBeginRequest(HttpContextBase context) + private MemoryCache _memoryCache; + private HttpApplication _httpApplication; + + public void Init(HttpApplication context) + { + _httpApplication = context; + _memoryCache = MemoryCache.Default; + context.BeginRequest += OnBeginRequest; + context.PostAuthorizeRequest += OnPostAuthorizeRequest; + } + + public void OnBeginRequest(object sender, EventArgs e) { + HttpContextBase context = new HttpContextWrapper(((HttpApplication)sender).Context); var path = context.Request.Url.GetComponents(UriComponents.Path, UriFormat.Unescaped); if (path.Contains("lang.owa")) { @@ -25,12 +36,14 @@ public override void OnBeginRequest(HttpContextBase context) return; } - var complete2FaHtml = Resources.complete_2fa_html.Replace("%MULTIFACTOR_COOKIE%", token); - SendPage(context, complete2FaHtml); + var cookie = new HttpCookie(BuildCookieName(context), token) { Path = context.Request.ApplicationPath }; + context.AddCookie(cookie); + context.Response.Redirect(context.Request.ApplicationPath ?? "/"); } - public override void OnPostAuthorizeRequest(HttpContextBase context) + public void OnPostAuthorizeRequest(object sender, EventArgs e) { + HttpContextBase context = new HttpContextWrapper(((HttpApplication)sender).Context); var path = context.Request.Url.GetComponents(UriComponents.Path, UriFormat.Unescaped); //static resources @@ -40,9 +53,17 @@ public override void OnPostAuthorizeRequest(HttpContextBase context) } //logoff page - if (path.Contains("logoff.owa")) + if (path.ToLower().Contains("logoff")) { - SendPage(context, Resources.complete_logout_html); + var cookieName = BuildCookieName(context); + var cookie = context.Request.Cookies[cookieName]; + if (cookie != null) + { + context.RemoveCookie(cookieName); + _memoryCache.Remove(cookie?.Value); + } + + context.Response.Redirect(context.Request.ApplicationPath); return; } @@ -53,9 +74,12 @@ public override void OnPostAuthorizeRequest(HttpContextBase context) } //language selection page - if (path.Contains("languageselection.aspx") || path.Contains("lang.owa")) return; + if (path.Contains("languageselection.aspx") || path.Contains("lang.owa")) + { + return; + } - if (!context.User.Identity.IsAuthenticated) + if (context.User?.Identity?.IsAuthenticated != true) { //not yet authenticated with login/pwd return; @@ -79,7 +103,24 @@ public override void OnPostAuthorizeRequest(HttpContextBase context) { if (context.Request.HttpMethod == "POST") { - ProcessMultifactorRequest(context); + string identityKey = context.Request.Cookies["identity"]?.Value; + + if (string.IsNullOrWhiteSpace(identityKey)) + { + AccessDenied(context); + } + + var identity = _memoryCache.Get(identityKey)?.ToString(); + + if (string.IsNullOrWhiteSpace(identity)) + { + AccessDenied(context); + } + + context.RemoveCookie("identity"); + _memoryCache.Remove(identityKey); + + ProcessMultifactorRequest(context, identity); } return; @@ -96,7 +137,7 @@ public override void OnPostAuthorizeRequest(HttpContextBase context) //mfa var valSrv = new TokenValidationService(Logger.Owa); var checker = new AuthChecker(context, valSrv); - var isAuthenticatedByMultifactor = checker.IsAuthenticated(user); + var isAuthenticatedByMultifactor = checker.IsAuthenticated(user, BuildCookieName(context)); if (isAuthenticatedByMultifactor || context.HasApiUnreachableFlag()) { return; @@ -112,20 +153,18 @@ public override void OnPostAuthorizeRequest(HttpContextBase context) } //redirect to mfa - var redirectUrl = $"{context.Request.ApplicationPath}/{Constants.MULTIFACTOR_PAGE}"; - context.Response.Redirect(redirectUrl); - } - - private void SendPage(HttpContextBase context, string html) - { - context.Response.Clear(); - context.Response.ClearContent(); - context.Response.Write(html); - context.Response.Flush(); - context.Response.End(); + if (canonicalUserName != "system") + { + var redirectUrl = $"{context.Request.ApplicationPath}/{Constants.MULTIFACTOR_PAGE}"; + var cookieName = "identity"; + var cacheKey = Guid.NewGuid().ToString(); + _memoryCache.Set(cacheKey, canonicalUserName, DateTime.Now.AddMinutes(6)); + context.AddCookie(new HttpCookie(cookieName, cacheKey) { Expires = DateTime.Now.AddMinutes(5) }); + context.Response.Redirect(redirectUrl); + } } - private void ProcessMultifactorRequest(HttpContextBase context) + private void ProcessMultifactorRequest(HttpContextBase context, string forceIdentity = null) { //check if user session timed-out if (!context.User.Identity.IsAuthenticated) @@ -140,13 +179,7 @@ private void ProcessMultifactorRequest(HttpContextBase context) return; } - //mfa request - if (url.IndexOf("#") == -1) - { - url += "#path=/mail"; - } - - var executor = MfaApiRequestExecutorFactory.CreateOwa(context); + var executor = MfaApiRequestExecutorFactory.CreateOwa(context, forceIdentity); executor.Execute(url, context.Request.ApplicationPath); } @@ -175,5 +208,22 @@ public static object GetPropValue(object src, string propName) return null; } } + + private void AccessDenied(HttpContextBase context) + { + context.Response.StatusCode = 440; + context.Response.End(); + } + + private string BuildCookieName(HttpContextBase context) + { + return Constants.COOKIE_NAME + context.Request.ApplicationPath; + } + + public void Dispose() + { + _httpApplication.BeginRequest -= OnBeginRequest; + _httpApplication.PostAuthorizeRequest -= OnPostAuthorizeRequest; + } } -} \ No newline at end of file +} diff --git a/Services/AuthChecker.cs b/Services/AuthChecker.cs index 2e28e26..9be381f 100644 --- a/Services/AuthChecker.cs +++ b/Services/AuthChecker.cs @@ -14,9 +14,9 @@ public AuthChecker(HttpContextBase context, TokenValidationService validationSer _validationService = validationService ?? throw new ArgumentNullException(nameof(validationService)); } - public bool IsAuthenticated(string rawUsername) + public bool IsAuthenticated(string rawUsername, string cookieName = Constants.COOKIE_NAME) { - var multifactorCookie = _context.Request.Cookies[Constants.COOKIE_NAME]; + var multifactorCookie = _context.Request.Cookies[cookieName]; if (multifactorCookie == null) { return false; @@ -31,4 +31,4 @@ public bool IsAuthenticated(string rawUsername) return Util.CanonicalizeUserName(userName) == Util.CanonicalizeUserName(rawUsername); } } -} \ No newline at end of file +} diff --git a/Services/MfaApiRequestExecutor.cs b/Services/MfaApiRequestExecutor.cs index dfe4cf8..c7f346e 100644 --- a/Services/MfaApiRequestExecutor.cs +++ b/Services/MfaApiRequestExecutor.cs @@ -9,19 +9,21 @@ internal class MfaApiRequestExecutor private readonly HttpContextBase _context; private readonly AccessUrl _accessUrl; private readonly Logger _logger; + private readonly string _forcedIdentity; - public MfaApiRequestExecutor(HttpContextBase context, AccessUrl accessUrl, Logger logger) + public MfaApiRequestExecutor(HttpContextBase context, AccessUrl accessUrl, Logger logger, string forcedIdentity = null) { _context = context ?? throw new ArgumentNullException(nameof(context)); _accessUrl = accessUrl ?? throw new ArgumentNullException(nameof(accessUrl)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _forcedIdentity = forcedIdentity; } public void Execute(string postbackUrl, string appRootPath) { try { - var multiFactorAccessUrl = _accessUrl.Get(_context.User.Identity.Name, postbackUrl); + var multiFactorAccessUrl = _accessUrl.Get(_forcedIdentity ?? _context.User.Identity.Name, postbackUrl); _context.Response.Redirect(multiFactorAccessUrl, true); } catch (Exception ex) when (NeedToBypass(ex)) diff --git a/Services/MfaApiRequestExecutorFactory.cs b/Services/MfaApiRequestExecutorFactory.cs index eaa6670..a87b633 100644 --- a/Services/MfaApiRequestExecutorFactory.cs +++ b/Services/MfaApiRequestExecutorFactory.cs @@ -6,7 +6,7 @@ namespace MultiFactor.IIS.Adapter.Services { internal static class MfaApiRequestExecutorFactory { - public static MfaApiRequestExecutor CreateOwa(HttpContextBase context) + public static MfaApiRequestExecutor CreateOwa(HttpContextBase context, string forcedIdentity = null) { if (context is null) { @@ -17,7 +17,7 @@ public static MfaApiRequestExecutor CreateOwa(HttpContextBase context) var api = new MultiFactorApiClient(Logger.API, MfTraceIdFactory.CreateTraceOwa); var getter = new AccessUrl(ad, api); - return new MfaApiRequestExecutor(context, getter, Logger.Owa); + return new MfaApiRequestExecutor(context, getter, Logger.Owa, forcedIdentity); } public static MfaApiRequestExecutor CreateCrm(HttpContextBase context) @@ -34,4 +34,4 @@ public static MfaApiRequestExecutor CreateCrm(HttpContextBase context) return new MfaApiRequestExecutor(context, getter, Logger.IIS); } } -} \ No newline at end of file +} diff --git a/Services/RequestLoggerBuilder.cs b/Services/RequestLogBuilder.cs similarity index 83% rename from Services/RequestLoggerBuilder.cs rename to Services/RequestLogBuilder.cs index 0fe51e5..6459650 100644 --- a/Services/RequestLoggerBuilder.cs +++ b/Services/RequestLogBuilder.cs @@ -16,6 +16,9 @@ public static string BuildRequestLog(HttpContextBase context) var requestQueryString = context.Request.QueryString; var requestHeaders = context.Request.Headers; var appPath = context.Request.ApplicationPath; + var identity = context.User?.Identity?.Name; + var authType = context.User?.Identity?.AuthenticationType; + var isAuthenticated = context.User?.Identity?.IsAuthenticated ?? false; var builder = new StringBuilder("Request: "); builder.AppendLine($"{nameof(context.Request.HttpMethod)} = {messageHttpMethod}"); @@ -24,6 +27,10 @@ public static string BuildRequestLog(HttpContextBase context) builder.AppendLine($"{nameof(context.Request.QueryString)} = {requestQueryString}"); builder.AppendLine($"{nameof(context.Request.ApplicationPath)} = {appPath}"); + builder.AppendLine($"{nameof(context.User.Identity.Name)} = {identity}"); + builder.AppendLine($"{nameof(context.User.Identity.IsAuthenticated)} = {isAuthenticated}"); + builder.AppendLine($"{nameof(context.User.Identity.AuthenticationType)} = {authType}"); + string user = context.Request.Params["User"]; builder.AppendLine("User = " + user); @@ -37,8 +44,10 @@ public static string BuildRequestLog(HttpContextBase context) var cmd = context.Request.Params["Cmd"]; if (!string.IsNullOrWhiteSpace(cmd)) + { builder.AppendLine("Cmd = " + cmd); - + } + builder.AppendLine("--------------------------"); builder.AppendLine($"Headers:"); AddParams(builder, requestHeaders); @@ -62,4 +71,4 @@ private static void AddParams(StringBuilder builder, NameValueCollection valueCo } } } -} \ No newline at end of file +}