From bc59eb850483ae1ace1e5a24cbc7a88c684544fc Mon Sep 17 00:00:00 2001 From: "s.agibalov" Date: Tue, 5 May 2026 20:05:14 +0300 Subject: [PATCH] DEV-1442: sso refactoring init --- App_Start/ServicesConfig.cs | 89 +++- Authentication/TokenClaims.cs | 41 ++ Authentication/TokenClaimsAccessor.cs | 34 ++ Authentication/TokenVerifier.cs | 20 + Configuration.cs | 17 +- Constants.cs | 36 ++ Content/images/ssoResource.svg | 7 + Content/style/site.css | 8 + Controllers/AccountController.cs | 464 ++++++------------ Controllers/HomeController.cs | 43 +- Core/ApplicationGlobalValuesProvider.cs | 33 ++ .../AttributeClaimValueSource.cs | 20 + .../LiteralClaimValueSource.cs | 19 + .../ReservedValueClaimValueSource.cs | 20 + .../Description/AdditionalClaimDescriptor.cs | 35 ++ .../AdditionalClaimDescriptorsProvider.cs | 24 + .../Description/Conditions/ClaimCondition.cs | 18 + .../Conditions/ClaimConditionEvaluator.cs | 52 ++ .../Conditions/ClaimsConditionOperation.cs | 7 + .../IApplicationValuesContext.cs | 14 + .../AdditionalClaims/IClaimValueSource.cs | 14 + .../AuthenticationClaims/ClaimsProvider.cs | 30 ++ .../AuthenticationClaims/IClaimsSource.cs | 9 + Core/Caching/ApplicationCache.cs | 119 +++++ Core/Caching/IApplicationCache.cs | 18 + Core/ClassPropertyAccessor.cs | 25 + Core/DataProtection.cs | 27 + Core/Http/HttpClientAdapter.cs | 57 +++ Core/Http/HttpClientTokenProvider.cs | 38 ++ .../ILdapAttributesCache.cs | 11 + Core/LdapAttributesCaching/LdapAttribute.cs | 29 ++ .../LdapAttributesCache.cs | 23 + Core/Metadata/AdditionalClaimsMetadata.cs | 71 +++ .../GlobalValues/ApplicationGlobalValue.cs | 8 + .../ApplicationGlobalValuesMetadata.cs | 24 + Core/OrdinalIgnoreCaseStringComparer.cs | 20 + Dto/ScopeSupportInfoDto.cs | 18 + Exceptions/ModelStateErrorException.cs | 13 + Extensions/ConfigurationExtensions.cs | 30 ++ Extensions/HttpContextExtensions.cs | 64 +++ Extensions/PortalSettingsAccess.cs | 36 ++ .../SafeHttpContextAccessorExtensions.cs | 28 ++ Extensions/UrlExtensions.cs | 55 +++ Global.asax.cs | 6 + .../CredentialVerificationResult.cs | 184 +++++++ .../CredentialVerifierAdapter.cs | 51 ++ .../ICredentialVerifier.cs | 12 + .../ForgottenPasswordChanger.cs | 43 ++ .../PasswordChangingResult.cs | 15 + .../PasswordChanging/UserPasswordChanger.cs | 46 ++ Integrations/MultiFactorApi/Dto/AccessPage.cs | 10 + .../MultiFactorApi/Dto/ApiResponse.cs | 36 ++ .../MultiFactorApi/Dto/BypassPageDto.cs | 17 + .../MultiFactorApi/Dto/EnrollmentPageDto.cs | 12 + .../MultiFactorApi/Dto/ResetPasswordDto.cs | 12 + .../MultiFactorApi/Dto/ShowcaseSettingsDto.cs | 19 + .../MultiFactorApi/Dto/UnlockUserDto.cs | 12 + .../MultiFactorApi/Dto/UserProfileApiDto.cs | 39 ++ .../Dto/UserProfileAuthenticatorsApiDto.cs | 37 ++ .../Dto/UserProfileAuthenticatorsDto.cs | 22 + .../MultiFactorApi/Dto/UserProfileDto.cs | 29 ++ .../UnsuccessfulResponseException.cs | 14 + .../MultiFactorApi/IMultifactorApi.cs | 26 + Integrations/MultiFactorApi/MultifactorApi.cs | 304 ++++++++++++ .../MultifactorHttpClientAdapterFactory.cs | 27 + .../Dto/BypassOidcRequestDto.cs | 7 + .../Dto/BypassOidcResponseDto.cs | 7 + .../Dto/BypassSamlRequestDto.cs | 7 + .../Dto/BypassSamlResponseDto.cs | 7 + .../Dto/IdentityRequestDto.cs | 61 +++ .../Dto/IdentityResponseDto.cs | 28 ++ .../MultifactorIdpApi/Dto/IdpApiResponse.cs | 9 + .../Dto/LoginCompletedRequestDto.cs | 7 + .../Dto/LoginCompletedResponseDto.cs | 27 + .../MultifactorIdpApi/Dto/LoginRequestDto.cs | 31 ++ .../MultifactorIdpApi/Dto/LoginResponseDto.cs | 25 + .../MultifactorIdpApi/Dto/LogoutRequestDto.cs | 7 + .../Dto/LogoutResponseDto.cs | 14 + .../Dto/SsoMasterSessionDto.cs | 12 + .../MultifactorIdpApi/Dto/SspSettingsDto.cs | 13 + .../Dto/UserProfileIdpDto.cs | 18 + .../MultifactorIdpApi/Enums/IdentityAction.cs | 10 + .../MultifactorIdpApi/Enums/LoginAction.cs | 13 + .../Enums/LoginCompletedAction.cs | 11 + .../MultifactorIdpApi/IMultifactorIdpApi.cs | 27 + .../MultifactorIdpApi/MultifactorIdpApi.cs | 343 +++++++++++++ .../MultifactorIdpHttpClientAdapterFactory.cs | 27 + .../Binders/MultiFactorClaimsDtoBinder.cs | 25 + MultiFactor.SelfService.Windows.Portal.csproj | 108 ++++ Options/ShowcaseSettingsOptions.cs | 35 ++ Resources/SharedResource.cs | 7 + Resources/SharedResource.en.resx | 168 +++++++ Resources/SharedResource.ru.resx | 168 +++++++ Services/API/ApiClient.cs | 5 + Services/API/MultiFactorApiClient.cs | 5 + Services/API/MultiFactorClaims.cs | 5 +- .../API/MultiFactorSelfServiceApiClient.cs | 9 +- Services/Caching/ApplicationCache.cs | 1 + Services/Ldap/LdapProfile.cs | 1 - Services/ShowcaseSettingsUpdaterService.cs | 111 +++++ Services/TokenValidationService.cs | 67 ++- Settings/ShowcaseSettings.cs | 17 + .../Authenticate/AuthenticateSessionStory.cs | 100 ++++ .../ChangeExpiredPasswordStory.cs | 82 ++++ .../ChangeValidPasswordStory.cs | 47 ++ .../CheckExpiredPasswordSessionStory.cs | 51 ++ .../FilterShowcaseLinksStory.cs | 39 ++ Stories/LoadProfile/LoadIdpProfileStory.cs | 19 + Stories/LoadProfile/LoadProfileStory.cs | 19 + .../RecoverPassword/RecoverPasswordStory.cs | 91 ++++ Stories/RedirectToActionResult.cs | 33 ++ .../SearchExchangeActiveSyncDevicesStory.cs | 22 + Stories/SignIn/AuthnStory.cs | 112 +++++ .../ClaimsSources/AdditionalClaimsSource.cs | 75 +++ .../ClaimsSources/ClaimValuesContext.cs | 39 ++ .../ClaimsSources/MultiFactorClaimsSource.cs | 39 ++ .../SignIn/ClaimsSources/SsoClaimsSource.cs | 41 ++ Stories/SignIn/IdentityStory.cs | 207 ++++++++ .../RedirectToCredValidationAfter2FaStory.cs | 131 +++++ Stories/SignIn/SignInStory.cs | 248 ++++++++++ Stories/SignOut/SignOutStory.cs | 109 ++++ Stories/UnlockUser/UnlockUserStory.cs | 101 ++++ ViewModels/ChangeExpiredPasswordViewModel.cs | 17 + ViewModels/ChangePasswordViewModel.cs | 24 + ViewModels/ShowcaseViewModel.cs | 13 + Views/Account/ByPassSsoSession.cshtml | 18 + Views/Home/Index.cshtml | 52 +- Web.config | 1 + 128 files changed, 5543 insertions(+), 371 deletions(-) create mode 100644 Authentication/TokenClaims.cs create mode 100644 Authentication/TokenClaimsAccessor.cs create mode 100644 Authentication/TokenVerifier.cs create mode 100644 Content/images/ssoResource.svg create mode 100644 Core/ApplicationGlobalValuesProvider.cs create mode 100644 Core/Authentication/AdditionalClaims/AdditionalClaimValueSources/AttributeClaimValueSource.cs create mode 100644 Core/Authentication/AdditionalClaims/AdditionalClaimValueSources/LiteralClaimValueSource.cs create mode 100644 Core/Authentication/AdditionalClaims/AdditionalClaimValueSources/ReservedValueClaimValueSource.cs create mode 100644 Core/Authentication/AdditionalClaims/Description/AdditionalClaimDescriptor.cs create mode 100644 Core/Authentication/AdditionalClaims/Description/AdditionalClaimDescriptorsProvider.cs create mode 100644 Core/Authentication/AdditionalClaims/Description/Conditions/ClaimCondition.cs create mode 100644 Core/Authentication/AdditionalClaims/Description/Conditions/ClaimConditionEvaluator.cs create mode 100644 Core/Authentication/AdditionalClaims/Description/Conditions/ClaimsConditionOperation.cs create mode 100644 Core/Authentication/AdditionalClaims/IApplicationValuesContext.cs create mode 100644 Core/Authentication/AdditionalClaims/IClaimValueSource.cs create mode 100644 Core/Authentication/AuthenticationClaims/ClaimsProvider.cs create mode 100644 Core/Authentication/AuthenticationClaims/IClaimsSource.cs create mode 100644 Core/Caching/ApplicationCache.cs create mode 100644 Core/Caching/IApplicationCache.cs create mode 100644 Core/ClassPropertyAccessor.cs create mode 100644 Core/DataProtection.cs create mode 100644 Core/Http/HttpClientTokenProvider.cs create mode 100644 Core/LdapAttributesCaching/ILdapAttributesCache.cs create mode 100644 Core/LdapAttributesCaching/LdapAttribute.cs create mode 100644 Core/LdapAttributesCaching/LdapAttributesCache.cs create mode 100644 Core/Metadata/AdditionalClaimsMetadata.cs create mode 100644 Core/Metadata/GlobalValues/ApplicationGlobalValue.cs create mode 100644 Core/Metadata/GlobalValues/ApplicationGlobalValuesMetadata.cs create mode 100644 Core/OrdinalIgnoreCaseStringComparer.cs create mode 100644 Dto/ScopeSupportInfoDto.cs create mode 100644 Exceptions/ModelStateErrorException.cs create mode 100644 Extensions/ConfigurationExtensions.cs create mode 100644 Extensions/HttpContextExtensions.cs create mode 100644 Extensions/PortalSettingsAccess.cs create mode 100644 Extensions/SafeHttpContextAccessorExtensions.cs create mode 100644 Extensions/UrlExtensions.cs create mode 100644 Integrations/Ldap/CredentialVerification/CredentialVerificationResult.cs create mode 100644 Integrations/Ldap/CredentialVerification/CredentialVerifierAdapter.cs create mode 100644 Integrations/Ldap/CredentialVerification/ICredentialVerifier.cs create mode 100644 Integrations/Ldap/PasswordChanging/ForgottenPasswordChanger.cs create mode 100644 Integrations/Ldap/PasswordChanging/PasswordChangingResult.cs create mode 100644 Integrations/Ldap/PasswordChanging/UserPasswordChanger.cs create mode 100644 Integrations/MultiFactorApi/Dto/AccessPage.cs create mode 100644 Integrations/MultiFactorApi/Dto/ApiResponse.cs create mode 100644 Integrations/MultiFactorApi/Dto/BypassPageDto.cs create mode 100644 Integrations/MultiFactorApi/Dto/EnrollmentPageDto.cs create mode 100644 Integrations/MultiFactorApi/Dto/ResetPasswordDto.cs create mode 100644 Integrations/MultiFactorApi/Dto/ShowcaseSettingsDto.cs create mode 100644 Integrations/MultiFactorApi/Dto/UnlockUserDto.cs create mode 100644 Integrations/MultiFactorApi/Dto/UserProfileApiDto.cs create mode 100644 Integrations/MultiFactorApi/Dto/UserProfileAuthenticatorsApiDto.cs create mode 100644 Integrations/MultiFactorApi/Dto/UserProfileAuthenticatorsDto.cs create mode 100644 Integrations/MultiFactorApi/Dto/UserProfileDto.cs create mode 100644 Integrations/MultiFactorApi/Exceptions/UnsuccessfulResponseException.cs create mode 100644 Integrations/MultiFactorApi/IMultifactorApi.cs create mode 100644 Integrations/MultiFactorApi/MultifactorApi.cs create mode 100644 Integrations/MultiFactorApi/MultifactorHttpClientAdapterFactory.cs create mode 100644 Integrations/MultifactorIdpApi/Dto/BypassOidcRequestDto.cs create mode 100644 Integrations/MultifactorIdpApi/Dto/BypassOidcResponseDto.cs create mode 100644 Integrations/MultifactorIdpApi/Dto/BypassSamlRequestDto.cs create mode 100644 Integrations/MultifactorIdpApi/Dto/BypassSamlResponseDto.cs create mode 100644 Integrations/MultifactorIdpApi/Dto/IdentityRequestDto.cs create mode 100644 Integrations/MultifactorIdpApi/Dto/IdentityResponseDto.cs create mode 100644 Integrations/MultifactorIdpApi/Dto/IdpApiResponse.cs create mode 100644 Integrations/MultifactorIdpApi/Dto/LoginCompletedRequestDto.cs create mode 100644 Integrations/MultifactorIdpApi/Dto/LoginCompletedResponseDto.cs create mode 100644 Integrations/MultifactorIdpApi/Dto/LoginRequestDto.cs create mode 100644 Integrations/MultifactorIdpApi/Dto/LoginResponseDto.cs create mode 100644 Integrations/MultifactorIdpApi/Dto/LogoutRequestDto.cs create mode 100644 Integrations/MultifactorIdpApi/Dto/LogoutResponseDto.cs create mode 100644 Integrations/MultifactorIdpApi/Dto/SsoMasterSessionDto.cs create mode 100644 Integrations/MultifactorIdpApi/Dto/SspSettingsDto.cs create mode 100644 Integrations/MultifactorIdpApi/Dto/UserProfileIdpDto.cs create mode 100644 Integrations/MultifactorIdpApi/Enums/IdentityAction.cs create mode 100644 Integrations/MultifactorIdpApi/Enums/LoginAction.cs create mode 100644 Integrations/MultifactorIdpApi/Enums/LoginCompletedAction.cs create mode 100644 Integrations/MultifactorIdpApi/IMultifactorIdpApi.cs create mode 100644 Integrations/MultifactorIdpApi/MultifactorIdpApi.cs create mode 100644 Integrations/MultifactorIdpApi/MultifactorIdpHttpClientAdapterFactory.cs create mode 100644 ModelBinding/Binders/MultiFactorClaimsDtoBinder.cs create mode 100644 Options/ShowcaseSettingsOptions.cs create mode 100644 Resources/SharedResource.cs create mode 100644 Resources/SharedResource.en.resx create mode 100644 Resources/SharedResource.ru.resx create mode 100644 Services/ShowcaseSettingsUpdaterService.cs create mode 100644 Settings/ShowcaseSettings.cs create mode 100644 Stories/Authenticate/AuthenticateSessionStory.cs create mode 100644 Stories/ChangeExpiredPassword/ChangeExpiredPasswordStory.cs create mode 100644 Stories/ChangeValidPassword/ChangeValidPasswordStory.cs create mode 100644 Stories/CheckExpiredPasswordSession/CheckExpiredPasswordSessionStory.cs create mode 100644 Stories/FilterShowcaseLinks/FilterShowcaseLinksStory.cs create mode 100644 Stories/LoadProfile/LoadIdpProfileStory.cs create mode 100644 Stories/LoadProfile/LoadProfileStory.cs create mode 100644 Stories/RecoverPassword/RecoverPasswordStory.cs create mode 100644 Stories/RedirectToActionResult.cs create mode 100644 Stories/SearchExchangeActiveSyncDevices/SearchExchangeActiveSyncDevicesStory.cs create mode 100644 Stories/SignIn/AuthnStory.cs create mode 100644 Stories/SignIn/ClaimsSources/AdditionalClaimsSource.cs create mode 100644 Stories/SignIn/ClaimsSources/ClaimValuesContext.cs create mode 100644 Stories/SignIn/ClaimsSources/MultiFactorClaimsSource.cs create mode 100644 Stories/SignIn/ClaimsSources/SsoClaimsSource.cs create mode 100644 Stories/SignIn/IdentityStory.cs create mode 100644 Stories/SignIn/RedirectToCredValidationAfter2FaStory.cs create mode 100644 Stories/SignIn/SignInStory.cs create mode 100644 Stories/SignOut/SignOutStory.cs create mode 100644 Stories/UnlockUser/UnlockUserStory.cs create mode 100644 ViewModels/ChangeExpiredPasswordViewModel.cs create mode 100644 ViewModels/ChangePasswordViewModel.cs create mode 100644 ViewModels/ShowcaseViewModel.cs create mode 100644 Views/Account/ByPassSsoSession.cshtml diff --git a/App_Start/ServicesConfig.cs b/App_Start/ServicesConfig.cs index 1604003..7eea4ba 100644 --- a/App_Start/ServicesConfig.cs +++ b/App_Start/ServicesConfig.cs @@ -13,6 +13,29 @@ using System.Net; using System.Net.Http; using MultiFactor.SelfService.Windows.Portal.Services.Ldap; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi; +using MultiFactor.SelfService.Windows.Portal.Options; +using MultiFactor.SelfService.Windows.Portal.Stories.Authenticate; +using MultiFactor.SelfService.Windows.Portal.Stories.ChangeExpiredPassword; +using MultiFactor.SelfService.Windows.Portal.Stories.ChangeValidPassword; +using MultiFactor.SelfService.Windows.Portal.Stories.CheckExpiredPasswordSession; +using MultiFactor.SelfService.Windows.Portal.Stories.LoadProfile; +using MultiFactor.SelfService.Windows.Portal.Stories.LoadProfileStory; +using MultiFactor.SelfService.Windows.Portal.Stories.RecoverPassword; +using MultiFactor.SelfService.Windows.Portal.Stories.SearchExchangeActiveSyncDevices; +using MultiFactor.SelfService.Windows.Portal.Stories.SignIn; +using MultiFactor.SelfService.Windows.Portal.Stories.SignOut; +using MultiFactor.SelfService.Windows.Portal.Authentication; +using MultiFactor.SelfService.Windows.Portal.Core.Authentication.AuthenticationClaims; +using MultiFactor.SelfService.Windows.Portal.Integrations.Ldap.PasswordChanging; +using MultiFactor.SelfService.Windows.Portal.Core.Caching; +using MultiFactor.SelfService.Windows.Portal.Integrations.Ldap.CredentialVerification; +using MultiFactor.SelfService.Windows.Portal.Stories.SignIn.ClaimsSources; +using MultiFactor.SelfService.Windows.Portal.Core.Authentication.AdditionalClaims.Description; +using MultiFactor.SelfService.Windows.Portal.Core.Metadata; +using MultiFactor.SelfService.Windows.Portal.Core.Authentication.AdditionalClaims.Description.Conditions; +using MultiFactor.SelfService.Windows.Portal.Core.Authentication.AdditionalClaims; namespace MultiFactor.SelfService.Windows.Portal.App_Start { @@ -36,9 +59,12 @@ internal static void RegisterServices(ServiceCollection services) { services.AddSingleton(); ConfigureHttpClients(services); + ConfigureApplicationSerivces(services); ConfigureGoogleApi(services); ConfigureYandexCaptchaApi(services); ConfigureCaptchaVerifier(services); + ConfigureCloudConfiguration(services); + ConfigureStories(services); services.AddScoped(); services.AddSingleton(); @@ -52,6 +78,11 @@ internal static void RegisterServices(ServiceCollection services) services.AddSingleton(); services.AddApplicationCache(); + services.AddTransient() + .AddTransient(); + services.AddTransient() + .AddTransient(); + services.AddSingleton(); services.AddSingleton(); @@ -107,7 +138,10 @@ private static void ConfigureHttpClients(ServiceCollection services) .ConfigurePrimaryHttpMessageHandler(() => CreateHttpClientHandler(proxy)); services - .AddHttpClient(Constants.HttpClients.MultifactorIdpApi) + .AddHttpClient(Constants.HttpClients.MultifactorIdpApi, client => + { + client.BaseAddress = new Uri(Configuration.Current.MultiFactorIdpApiUrl); + }) .ConfigurePrimaryHttpMessageHandler(() => CreateHttpClientHandler(proxy)); } @@ -131,7 +165,58 @@ private static HttpClientHandler CreateHttpClientHandler(WebProxy webProxy = nul handler.Proxy = webProxy; return handler; } - + private static void ConfigureApplicationSerivces(ServiceCollection services) + { + services + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(); + + services.AddSingleton(); + + services + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + private static void ConfigureStories(ServiceCollection services) + { + services + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient(); + } + + private static void ConfigureCloudConfiguration(ServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + } + private static WebProxy BuildProxy(string proxyUri) { var uri = new Uri(proxyUri); diff --git a/Authentication/TokenClaims.cs b/Authentication/TokenClaims.cs new file mode 100644 index 0000000..308a096 --- /dev/null +++ b/Authentication/TokenClaims.cs @@ -0,0 +1,41 @@ +using System; + +namespace MultiFactor.SelfService.Windows.Portal.Authentication +{ + public class TokenClaims + { + public string Id { get; set; } + public string Identity { get; set; } + public string RawUserName { get; set; } + public bool MustChangePassword { get; set; } + public DateTime ValidTo { get; set; } + public bool MustResetPassword { get; set; } + public string SamlClaim { get; set; } + public string OidcClaim { get; set; } + public bool MustUnlockUser { get; set; } + + public TokenClaims() { } + + public TokenClaims( + string id, + string identity, + string rawUserName, + bool mustChangePassword, + DateTime validTo, + bool mustResetPassword, + string samlClaim, + string oidcClaim, + bool mustUnlockUser = false) + { + Id = id; + Identity = identity; + RawUserName = rawUserName; + MustChangePassword = mustChangePassword; + ValidTo = validTo; + MustResetPassword = mustResetPassword; + SamlClaim = samlClaim; + OidcClaim = oidcClaim; + MustUnlockUser = mustUnlockUser; + } + } +} diff --git a/Authentication/TokenClaimsAccessor.cs b/Authentication/TokenClaimsAccessor.cs new file mode 100644 index 0000000..be9aca3 --- /dev/null +++ b/Authentication/TokenClaimsAccessor.cs @@ -0,0 +1,34 @@ +using System; +using MultiFactor.SelfService.Windows.Portal.Core.Exceptions; +using MultiFactor.SelfService.Windows.Portal.Core.Http; + +namespace MultiFactor.SelfService.Windows.Portal.Authentication +{ + public class TokenClaimsAccessor + { + private readonly TokenVerifier _tokenVerifier; + private readonly SafeHttpContextAccessor _contextAccessor; + + public TokenClaimsAccessor(TokenVerifier tokenVerifier, SafeHttpContextAccessor contextAccessor) + { + _tokenVerifier = tokenVerifier ?? throw new ArgumentNullException(nameof(tokenVerifier)); + _contextAccessor = contextAccessor ?? throw new ArgumentNullException(nameof(contextAccessor)); + } + + public TokenClaims GetTokenClaims() + { + var token = ExtractBearerToken(_contextAccessor.HttpContext.Request.Headers["Authorization"]); + return _tokenVerifier.Verify(token); + } + + private static string ExtractBearerToken(string headerValue) + { + if (headerValue is null) throw new UnauthorizedException("Empty token"); + + const string bearer = "Bearer"; + if (!headerValue.StartsWith(bearer)) throw new UnauthorizedException("Invalid token"); + + return headerValue.Replace(bearer, "").Trim(); + } + } +} diff --git a/Authentication/TokenVerifier.cs b/Authentication/TokenVerifier.cs new file mode 100644 index 0000000..23d6878 --- /dev/null +++ b/Authentication/TokenVerifier.cs @@ -0,0 +1,20 @@ +using System; +using MultiFactor.SelfService.Windows.Portal.Services; + +namespace MultiFactor.SelfService.Windows.Portal.Authentication +{ + public class TokenVerifier + { + private readonly TokenValidationService _tokenValidationService; + + public TokenVerifier(TokenValidationService tokenValidationService) + { + _tokenValidationService = tokenValidationService ?? throw new ArgumentNullException(nameof(tokenValidationService)); + } + + public TokenClaims Verify(string accessToken) + { + return _tokenValidationService.Verify(accessToken); + } + } +} diff --git a/Configuration.cs b/Configuration.cs index 90820db..2668320 100644 --- a/Configuration.cs +++ b/Configuration.cs @@ -125,6 +125,11 @@ public bool IsPermittedDomain(string domain) /// public string MultiFactorApiSecret { get; private set; } + /// + /// Multifactor IDP API URL + /// + public string MultiFactorIdpApiUrl { get; private set; } + public bool PreAuthnMode { get; private set; } /// @@ -163,6 +168,9 @@ public bool IsPermittedDomain(string domain) public PasswordRequirements PasswordRequirements { get; set; } + public string TokenValidation { get; private set; } + public string Environment { get; private set; } + public static void Load() { var appSettings = PortalSettings; @@ -181,6 +189,7 @@ public static void Load() var apiKeySetting = GetRequiredValue(appSettings, ConfigurationConstants.General.MULTIFACTOR_API_KEY); var apiProxySetting = GetValue(appSettings, ConfigurationConstants.General.MULTIFACTOR_API_PROXY); var apiSecretSetting = GetRequiredValue(appSettings, ConfigurationConstants.General.MULTIFACTOR_API_SECRET); + var idpApiUrlSetting = GetRequiredValue(appSettings, ConfigurationConstants.General.MULTIFACTOR_IDP_API_URL); var logLevelSetting = GetRequiredValue(appSettings, ConfigurationConstants.General.LOGGING_LEVEL); var preAuthnMode = ParseBoolean(appSettings, ConfigurationConstants.General.PRE_AUTHN_MODE); @@ -198,6 +207,9 @@ public static void Load() var activeDirectoryGroupSetting = GetValue(appSettings, ConfigurationConstants.General.ACTIVE_DIRECTORY_GROUP); var nestedGroupsBaseDn = GetValue(appSettings, ConfigurationConstants.General.NESTED_GROUPS_BASE_DN); + var tokenValidation = GetValue(appSettings, ConfigurationConstants.General.TOKEN_VALIDATION); + var environment = GetValue(appSettings, ConfigurationConstants.General.ENVIRONMENT_KEY); + var useAttributeAsIdentitySetting = GetValue(appSettings, ConfigurationConstants.General.USE_ATTRIBUTE_AS_IDENTITY); if (useUpnAsIdentitySetting && !string.IsNullOrWhiteSpace(useAttributeAsIdentitySetting)) { @@ -214,6 +226,7 @@ public static void Load() MultiFactorApiKey = apiKeySetting, MultiFactorApiSecret = apiSecretSetting, MultiFactorApiProxy = apiProxySetting, + MultiFactorIdpApiUrl = idpApiUrlSetting, LogLevel = logLevelSetting, EnableExchangeActiveSyncDevicesManagement = enableExchangeActiveSyncServicesManagementSetting, EnablePasswordManagement = enablePasswordManagementSetting, @@ -226,7 +239,9 @@ public static void Load() PreAuthnMode = preAuthnMode, LoadActiveDirectoryNestedGroups = loadActiveDirectoryNestedGroups, PrivacyModeDescriptor = PrivacyModeDescriptor.Create(privacyMode), - PasswordRequirements = PasswordRequirementsSection.GetRequirements() + PasswordRequirements = PasswordRequirementsSection.GetRequirements(), + TokenValidation = tokenValidation, + Environment = environment }; if (!string.IsNullOrEmpty(activeDirectory2FaGroupSetting)) diff --git a/Constants.cs b/Constants.cs index d902088..6aea30e 100644 --- a/Constants.cs +++ b/Constants.cs @@ -10,8 +10,17 @@ public class Constants public const string SESSION_EXPIRED_PASSWORD_USER_KEY = "multifactor:expired-password:user"; public const string SESSION_EXPIRED_PASSWORD_CIPHER_KEY = "multifactor:expired-password:cipher"; public const string PREAUTHENTICATION_AUTHN_SUCCEED_KEY = "multifactor:preauthentication-authn-succesd:user"; + public const string TOKEN_VALIDATION = "TokenValidation:JsonWebKeySet"; + public const string ENVIRONMENT_KEY = "Environment"; + public const string PRODUCTION_ENV = "production"; public const string CAPTCHA_TOKEN = "responseToken"; + public const string PWD_RECOVERY_COOKIE = "PSession"; + public const string PWD_RENEWAL_PURPOSE = "PwdRenewal"; + + public const string CredentialVerificationResult = "CredentialVerificationResult"; + public const string SsoClaims = "SsoClaims"; + public const string LoadedLdapAttributes = "LoadedLdapAttributes"; public static readonly string WORKING_DIRECTORY = Path.GetDirectoryName(AppDomain.CurrentDomain.BaseDirectory); @@ -28,6 +37,7 @@ public static class General public const string MULTIFACTOR_API_KEY = "multifactor-api-key"; public const string MULTIFACTOR_API_PROXY = "multifactor-api-proxy"; public const string MULTIFACTOR_API_SECRET = "multifactor-api-secret"; + public const string MULTIFACTOR_IDP_API_URL = "multifactor-idp-api-url"; public const string LOGGING_LEVEL = "logging-level"; public const string USE_ACTIVE_DIRECTORY_USER_PHONE = "use-active-directory-user-phone"; public const string USE_ACTIVE_DIRECTORY_MOBILE_USER_PHONE = "use-active-directory-mobile-user-phone"; @@ -43,11 +53,37 @@ public static class General public const string ACTIVE_DIRECTORY_GROUP = "active-directory-group"; public const string NESTED_GROUPS_BASE_DN = "nested-groups-base-dn"; public const string USE_ATTRIBUTE_AS_IDENTITY = "use-attribute-as-identity"; + public const string TOKEN_VALIDATION = "token-validation"; + public const string ENVIRONMENT_KEY = "environment"; #if DEBUG public const string ACT_AS = "act-as"; # endif } + public static class MultiFactorClaims + { + public const string SamlSessionId = "samlSessionId"; + public const string OidcSessionId = "oidcSessionId"; + public const string AdditionSsoStep = "additionSsoStep"; + public const string ChangePassword = "changePassword"; + public const string PasswordExpirationDate = "passwordExpirationDate"; + public const string ResetPassword = "resetPassword"; + public const string RawUserName = "rawUserName"; + public const string UnlockUser = "unlockUser"; + public const string Name = "name"; + } + public static class AuthenticationClaims + { + public const string AUTHENTICATION_METHODS_REFERENCES = "amr"; + public const string PASSWORD_METHOD = "pwd"; + public const string KERBEROS_METHOD = "kerberos"; + } + public static class SsoMasterSessionTypes + { + public const string SamlSessionType = "saml"; + public const string OidcSessionType = "oidc"; + } + public static class ObsoleteCaptcha { public const string ENABLE_GOOGLE_RECAPTCHA = "enable-google-re-captcha"; diff --git a/Content/images/ssoResource.svg b/Content/images/ssoResource.svg new file mode 100644 index 0000000..bf55f2b --- /dev/null +++ b/Content/images/ssoResource.svg @@ -0,0 +1,7 @@ + + + + SSO + + resource + diff --git a/Content/style/site.css b/Content/style/site.css index 4875564..f9d6158 100644 --- a/Content/style/site.css +++ b/Content/style/site.css @@ -654,9 +654,17 @@ p { scrollbar-color: #579ad7 #fff; } +.showcase-link { + display: flex; + flex-direction: column; + align-items: center; +} + .showcase-link-image { max-width: 4em; max-height: 4em; + min-width: 4em; + min-height: 4em; margin-bottom: 0.5em; } diff --git a/Controllers/AccountController.cs b/Controllers/AccountController.cs index bc802a2..9d46a44 100644 --- a/Controllers/AccountController.cs +++ b/Controllers/AccountController.cs @@ -1,58 +1,69 @@ using System; using System.Collections.Generic; -using System.DirectoryServices.AccountManagement; -using System.IdentityModel.Tokens.Jwt; -using System.Linq; -using System.Net.Http; -using System.Net.Mime; -using System.Text; using System.Threading.Tasks; using System.Web.Configuration; using System.Web.Mvc; using MultiFactor.SelfService.Windows.Portal.Attributes; -using MultiFactor.SelfService.Windows.Portal.Core; using MultiFactor.SelfService.Windows.Portal.Models; using MultiFactor.SelfService.Windows.Portal.Services; using MultiFactor.SelfService.Windows.Portal.Services.API; using MultiFactor.SelfService.Windows.Portal.Services.Caching; -using MultiFactor.SelfService.Windows.Portal.Services.Ldap; -using Resources; +using MultiFactor.SelfService.Windows.Portal.Stories.Authenticate; +using MultiFactor.SelfService.Windows.Portal.Stories.SignOut; +using MultiFactor.SelfService.Windows.Portal.Extensions; using Serilog; +using MultiFactor.SelfService.Windows.Portal.Core.Exceptions; +using MultiFactor.SelfService.Windows.Portal.Stories; +using MultiFactor.SelfService.Windows.Portal.Stories.LoadProfile; +using MultiFactor.SelfService.Windows.Portal.Stories.SignIn; +using MultiFactor.SelfService.Windows.Portal.Exceptions; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Dto; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi; namespace MultiFactor.SelfService.Windows.Portal.Controllers { public class AccountController : ControllerBase { private readonly ApplicationCache _applicationCache; - private readonly AuthService _authService; private readonly MultiFactorApiClient _apiClient; - private readonly MultiFactorSelfServiceApiClient _selfServiceApiClient; - private readonly IHttpClientFactory _httpFactory; - private readonly ActiveDirectoryService _activeDirectoryService; - private readonly DataProtectionService _dataProtectionService; private readonly ILogger _logger; + private readonly IMultifactorIdpApi _multifactorIdpApi; + private readonly LoadProfileStory _loadProfileStory; + private readonly SignInStory _signInStory; + private readonly IdentityStory _identityStory; + private readonly AuthnStory _authnStory; + private readonly SignOutStory _signOutStory; + private readonly AuthenticateSessionStory _authenticateSessionStory; + private readonly RedirectToCredValidationAfter2FaStory _redirectToCredValidationAfter2FaStory; private const string CallbackFromMfa = "PostbackFromMfa"; public AccountController(ApplicationCache applicationCache, AuthService authService, MultiFactorApiClient apiClient, - MultiFactorSelfServiceApiClient selfServiceApiClient, - ActiveDirectoryService activeDirectoryService, - DataProtectionService dataProtectionService, - ILogger logger, IHttpClientFactory httpFactory) + LoadProfileStory loadProfileStory, + SignInStory signInStory, + IdentityStory identityStory, + AuthnStory AuthnStory, + SignOutStory signOutStory, + AuthenticateSessionStory authenticateSessionStory, + RedirectToCredValidationAfter2FaStory redirectToCredValidationAfter2FaStory, + ILogger logger) { _applicationCache = applicationCache ?? throw new ArgumentNullException(nameof(applicationCache)); - _authService = authService ?? throw new ArgumentNullException(nameof(authService)); _apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); - _selfServiceApiClient = selfServiceApiClient ?? throw new ArgumentNullException(nameof(selfServiceApiClient)); - _activeDirectoryService = - activeDirectoryService ?? throw new ArgumentNullException(nameof(activeDirectoryService)); - _dataProtectionService = - dataProtectionService ?? throw new ArgumentNullException(nameof(dataProtectionService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _httpFactory = httpFactory; + + _loadProfileStory = loadProfileStory; + _signInStory = signInStory; + _identityStory = identityStory; + _authnStory = AuthnStory; + _signOutStory = signOutStory; + _authenticateSessionStory = authenticateSessionStory; + _redirectToCredValidationAfter2FaStory = redirectToCredValidationAfter2FaStory; } + [HttpGet] public ActionResult Login(SingleSignOnDto sso) { if (Configuration.Current.PreAuthnMode) @@ -94,76 +105,17 @@ public async Task Login(LoginModel model, SingleSignOnDto sso) return View(model); } - if (Configuration.Current.RequiresUpn) - { - //AD requires UPN check - var userName = LdapIdentity.ParseUser(model.UserName); - if (userName.Type != IdentityType.UserPrincipalName) - { - ModelState.AddModelError(string.Empty, AccountLogin.UserNameUpnRequired); - return View(model); - } - } - - //AD credential check - var adValidationResult = - _activeDirectoryService.VerifyCredentialAndMembership(model.UserName.Trim(), model.Password.Trim()); - // credential is VALID - if (adValidationResult.IsAuthenticated) + try { - var identity = adValidationResult.GetIdentity(model.UserName); - - if (Configuration.Current.PreAuthnMode) - { - _applicationCache.SetPreauthenticationAuthn( - ApplicationCacheKeyFactory.CreatePreAuthenticationAuthnSucceedKey(identity), - true); - } + var headers = HttpContext.GetRequiredHeaders(); - if (sso.HasSamlSession() && adValidationResult.IsBypass) - { - return ByPassSamlSession(identity, sso.SamlSessionId); - } - - return RedirectToMfa( - identity: identity, - login: model.UserName, - documentUrl: model.MyUrl, - samlSessionId: sso.SamlSessionId, - oidcSessionId: sso.OidcSessionId, - validationResult: adValidationResult - ); + return await _signInStory.ExecuteAsync(model, headers); } - - if (adValidationResult.UserMustChangePassword) + catch (Exception ex) { - // because if we here - bind throw exception, so need verify - adValidationResult = _activeDirectoryService.VerifyMembership(LdapIdentity.ParseUser(model.UserName)); - var identity = adValidationResult.GetIdentity(model.UserName); - _logger.Warning("User's credentials are valid but user '{u:l}' must change password", identity); - - if (Configuration.Current.EnablePasswordManagement) - { - var encryptedPassword = _dataProtectionService.Protect(model.Password.Trim()); - _applicationCache.Set(ApplicationCacheKeyFactory.CreateExpiredPwdUserKey(model.UserName.Trim()), - model.UserName.Trim()); - _applicationCache.Set(ApplicationCacheKeyFactory.CreateExpiredPwdCipherKey(model.UserName.Trim()), - encryptedPassword); - - return RedirectToMfa(identity, model.UserName, model.MyUrl, null, null, adValidationResult); - } - - _logger.Warning("User '{u:l}' must change password but password management is not enabled", identity); + ModelState.AddModelError(string.Empty, ex.Message); + return View(model); } - - ModelState.AddModelError(string.Empty, AccountLogin.WrongUserNameOrPassword); - - // invalid credentials, freeze response for 2-5 seconds to prevent brute-force attacks - var rnd = new Random(); - int delay = rnd.Next(2, 6); - await Task.Delay(TimeSpan.FromSeconds(delay)); - - return View(model); } /// @@ -172,271 +124,108 @@ public async Task Login(LoginModel model, SingleSignOnDto sso) /// Model for sso integration. Can be empty. /// State for continuation user verification. /// - public ActionResult Identity(SingleSignOnDto sso, string requestId) + public async Task Identity(SingleSignOnDto sso, string requestId) { if (!Configuration.Current.PreAuthnMode) { return RedirectToAction("Login"); } - bool userAuthenticated = Request.IsAuthenticated; - //integrated windows authentication - bool authenticateWindowsUser = - Configuration.AuthenticationMode == AuthenticationMode.Windows && User.Identity != null; - bool negotiateAuthentication = !string.IsNullOrEmpty(User.Identity?.Name) && - User.Identity.AuthenticationType == "Negotiate"; + try + { + await _loadProfileStory.ExecuteAsync(); - if (!userAuthenticated || !authenticateWindowsUser || !negotiateAuthentication) + if (sso.HasSamlSession()) + { + return new RedirectToActionResult().ToActionResult("ByPassSamlSession", "Account", + new { samlSession = sso.SamlSessionId }); + } + + if (sso.HasOidcSession()) + { + return new RedirectToActionResult().ToActionResult("ByPassOidcSession", "Account", + new { oidcSession = sso.OidcSessionId }); + } + + return RedirectToAction("Index", "Home"); + } + catch (UnauthorizedException ex) { + if (!Configuration.Current.PreAuthnMode) + { + return RedirectToAction("Login", sso.ToString()); + } var identity = _applicationCache.GetIdentity(requestId); return !identity.IsEmpty ? View("Authn", identity.Value) : View(new IdentityModel()); } - - var userName = User.Identity.Name; - - _logger.Information("User '{user:l}' authenticated by NTLM/Kerberos", userName); - return RedirectToMfa( - identity: userName, - login: userName, - documentUrl: Request?.Url?.ToString(), - samlSessionId: sso.SamlSessionId, - oidcSessionId: sso.OidcSessionId - ); } [HttpPost] [VerifyCaptcha] [ValidateAntiForgeryToken] - public ActionResult Identity(IdentityModel model, SingleSignOnDto sso) + public async Task Identity(IdentityModel model, SingleSignOnDto sso) { - if (!Configuration.Current.PreAuthnMode) - { - return RedirectToAction("Login"); - } - if (!ModelState.IsValid) { return View(model); } - if (Configuration.Current.RequiresUpn) - { - //AD requires UPN check - var userName = LdapIdentity.ParseUser(model.UserName); - if (userName.Type != IdentityType.UserPrincipalName) - { - ModelState.AddModelError(string.Empty, AccountLogin.UserNameUpnRequired); - return View(model); - } - } - - // 2fa before authn - var identity = model.UserName; - var authenticatorsResponse = _selfServiceApiClient.GetUserAuthenticators(identity); - if (!authenticatorsResponse.Success || !authenticatorsResponse.Model.GetAuthenticators().Any()) + try { - return View("Login", new LoginModel() - { - UserName = identity - }); + return await _identityStory.ExecuteAsync(model, HttpContext.GetRequiredHeaders()); } - - // in common case - if (!Configuration.Current.NeedPrebindInfo()) + catch (ModelStateErrorException ex) { - - return RedirectToMfa( - identity: identity, - login: model.UserName, - documentUrl: model.MyUrl, - samlSessionId: sso.SamlSessionId, - oidcSessionId: sso.OidcSessionId - ); - } - - var adResult = _activeDirectoryService.VerifyMembership(LdapIdentity.ParseUser(model.UserName.Trim())); - - identity = adResult.GetIdentity(model.UserName.Trim()); - - // sso session can skip 2fa, so go to pass entered - if (adResult.IsBypass && sso.HasSamlSession()) - { - return View("Authn", model); + ModelState.AddModelError(string.Empty, ex.Message); + return View(model); } - - return RedirectToMfa( - identity: identity, - login: model.UserName, - documentUrl: model.MyUrl, - samlSessionId: sso.SamlSessionId, - oidcSessionId: sso.OidcSessionId, - validationResult: adResult - ); } [HttpPost] [ValidateAntiForgeryToken] public async Task Authn(IdentityModel model, SingleSignOnDto sso) { - if (!Configuration.Current.PreAuthnMode) - { - return RedirectToAction("Login"); - } - if (!ModelState.IsValid) { - return View("Authn", model); + return View(model); } - if (Configuration.Current.RequiresUpn) + if (!Configuration.Current.PreAuthnMode) { - //AD requires UPN check - var userName = LdapIdentity.ParseUser(model.UserName); - if (userName.Type != IdentityType.UserPrincipalName) - { - ModelState.AddModelError(string.Empty, AccountLogin.UserNameUpnRequired); - return View(model); - } + return RedirectToAction("Login"); } - // authn after 2fa - // AD credential check - var adValidationResult = _activeDirectoryService.VerifyCredentialAndMembership(model.UserName.Trim(), model.Password.Trim()); - // credential is VALID - if (adValidationResult.IsAuthenticated) + try { - var identity = adValidationResult.GetIdentity(model.UserName); - if (sso.HasSamlSession()) - { - if (adValidationResult.IsBypass) - { - return ByPassSamlSession(identity, sso.SamlSessionId); - } - - // go to idp, return and render html form with saml assertion - return await GetSamlAssertion(model.AccessToken); - } - - _authService.SignIn(model.AccessToken); - return RedirectToAction("Index", "Home"); + return await _authnStory.ExecuteAsync(model); } - - if (adValidationResult.UserMustChangePassword) + catch (ModelStateErrorException ex) { - // if we need upn or custom attribute, we MUST request it from AD one more time - // because for expired password bind is failed - if (Configuration.Current.UseUpnAsIdentity || !string.IsNullOrWhiteSpace(Configuration.Current.UseAttributeAsIdentity)) - { - adValidationResult = _activeDirectoryService.VerifyMembership(LdapIdentity.ParseUser(model.UserName)); - } - - var identity = adValidationResult.GetIdentity(model.UserName); - _logger.Warning("User's credentials are valid but user '{u:l}' must change password", identity); - - if (Configuration.Current.EnablePasswordManagement) - { - var encryptedPassword = _dataProtectionService.Protect(model.Password.Trim()); - _applicationCache.Set(ApplicationCacheKeyFactory.CreateExpiredPwdUserKey(model.UserName.Trim()), - model.UserName.Trim()); - _applicationCache.Set(ApplicationCacheKeyFactory.CreateExpiredPwdCipherKey(model.UserName.Trim()), - encryptedPassword); - // for change password redirect - _authService.SignIn(model.AccessToken); - - return RedirectToAction("Index", "Home"); - } - - _logger.Warning("User '{u:l}' must change password but password management is not enabled", - identity); + ModelState.AddModelError(string.Empty, ex.Message); + return View(model); } - - ModelState.AddModelError(string.Empty, AccountLogin.WrongUserNameOrPassword); - - // invalid credentials, freeze response for 2-5 seconds to prevent brute-force attacks - var rnd = new Random(); - int delay = rnd.Next(2, 6); - await Task.Delay(TimeSpan.FromSeconds(delay)); - - return View("Authn", model); } - public ActionResult Logout() + public async Task Logout() { - if (Configuration.AuthenticationMode == AuthenticationMode.Forms) - { - return SignOut(); - } + var headers = HttpContext.GetRequiredHeaders(); + await _signOutStory.ExecuteAsync(headers); - SignOut(); - return View(); + return RedirectToLoginOrIdentity(new SingleSignOnDto()); } [HttpPost] - public ActionResult PostbackFromMfa(string accessToken) + public async Task PostbackFromMfa(string accessToken) { - _logger.Debug($"Received MFA token: {accessToken}"); - - // 2fa before authn enable if (Configuration.Current.PreAuthnMode) { - // hence continue authentication flow - return RedirectToCredValidationAfter2FA(accessToken); - } - - // otherwise flow is (almost) finished - _authService.SignIn(accessToken); - return RedirectToAction("Index", "Home"); - } - - /* - * Now we know: username, the fact of successful confirmation of the 2fa and some info about user. - * Next step - enter password and verify user creds. - * For this we must correctly pass all known information using the cache and query params. - */ - private ActionResult RedirectToCredValidationAfter2FA(string accessToken) - { - var handler = new JwtSecurityTokenHandler(); - var token = handler.ReadJwtToken(accessToken); - - var authCacheResult = _applicationCache.GetPreauthenticationAuthn(ApplicationCacheKeyFactory.CreatePreAuthenticationAuthnSucceedKey(token.Subject)); - if (!authCacheResult.IsEmpty && authCacheResult.Value) - { - _applicationCache.Remove(ApplicationCacheKeyFactory.CreatePreAuthenticationAuthnSucceedKey(token.Subject)); - _authService.SignIn(accessToken); - return RedirectToAction("Index", "Home"); - } - - var usernameClaims = token.Claims.FirstOrDefault(claim => claim.Type == MultiFactorClaims.RawUserName); - - // for the password entry step - var requestId = token.Id; - _applicationCache.SetIdentity(requestId, - new IdentityModel { UserName = usernameClaims?.Value, AccessToken = accessToken }); - - object routeValue = new { requestId = requestId }; - - #region Process SSO session (if present) - - var oidcClaims = token.Claims.FirstOrDefault(claim => claim.Type == MultiFactorClaims.OidcSessionId); - var samlClaims = token.Claims.FirstOrDefault(claim => claim.Type == MultiFactorClaims.SamlSessionId); - if (!string.IsNullOrEmpty(samlClaims?.Value)) - { - routeValue = new { samlSessionId = samlClaims?.Value, requestId = requestId }; - return RedirectToAction("Identity", "Account", routeValue); - } - - if (!string.IsNullOrEmpty(oidcClaims?.Value)) - { - routeValue = new { oidcSessionId = oidcClaims?.Value, requestId = requestId }; - return RedirectToAction("Identity", "Account", routeValue); + return await _redirectToCredValidationAfter2FaStory.ExecuteAsync(accessToken); } - #endregion - - return RedirectToAction("Identity", "Account", routeValue); + return await _authenticateSessionStory.Execute(accessToken); } private ActionResult RedirectToMfa(string identity, string login, string documentUrl, string samlSessionId, string oidcSessionId, @@ -505,41 +294,78 @@ private ActionResult RedirectToMfa(string identity, string login, string documen return RedirectPermanent(accessPage.Url); } - private ActionResult ByPassSamlSession(string login, string samlSessionId) + [HttpGet] + public async Task ByPassSsoSession(string callbackUrl, string accessToken) { - var bypassPage = _apiClient.CreateSamlBypassRequest(login, samlSessionId); - return View("ByPassSamlSession", bypassPage); + var page = new BypassPageDto(callbackUrl, accessToken); + return View(page); } - private async Task GetSamlAssertion(string accessToken) + public async Task ByPassSamlSession(string login, string samlSession) { - // no token verification because 'aud'=api_key and ssp_api_key!=saml_api_key - // hence verification will fail. but it's ok, idp service make its own verification - // so security not broken - var handler = new JwtSecurityTokenHandler(); - var token = handler.ReadJwtToken(accessToken); - var idpUrl = token.Claims.FirstOrDefault(claim => claim.Type == MultiFactorClaims.AdditionSsoStep) - ?.Value; + try + { + var request = new BypassSamlRequestDto + { + SamlSessionId = samlSession + }; + + var response = await _multifactorIdpApi.BypassSamlAsync(request, HttpContext.GetRequiredHeaders()); + + if (!string.IsNullOrWhiteSpace(response.SamlResponseHtml)) + { + return Content(response.SamlResponseHtml, "text/html"); + } + return RedirectToAction("AccessDenied", "Error"); + } + catch (UnauthorizedException) + { + return RedirectToLoginOrIdentity(new SingleSignOnDto { SamlSessionId = samlSession }); + } + catch (Exception ex) + { + _logger.Error(ex, "SAML bypass failed for session '{Session}'", samlSession); + return RedirectToAction("AccessDenied", "Error"); + } + } + + [HttpGet] + public async Task ByPassOidcSession( + string oidcSession) + { try { - MultipartFormDataContent multipartContent = new MultipartFormDataContent + var request = new BypassOidcRequestDto { - { - new StringContent(accessToken, Encoding.UTF8, MediaTypeNames.Text.Plain), - "accessToken" - } + OidcSessionId = oidcSession }; - HttpClient httpClient = _httpFactory.CreateClient(Constants.HttpClients.MultifactorIdpApi); - var res = await httpClient.PostAsync(idpUrl, multipartContent); - var jsonResponse = await res.Content.ReadAsStringAsync(); - return Content(jsonResponse); + + var response = await _multifactorIdpApi.BypassOidcAsync(request, HttpContext.GetRequiredHeaders()); + + if (!string.IsNullOrWhiteSpace(response.RedirectUrl)) + { + return Redirect(response.RedirectUrl); + } + + return RedirectToAction("AccessDenied", "Error"); + } + catch (UnauthorizedException) + { + return RedirectToLoginOrIdentity(new SingleSignOnDto { OidcSessionId = oidcSession }); } catch (Exception ex) { - _logger.Error(ex, $"Unable to connect API {idpUrl}: {ex.Message}"); - throw; + _logger.Error(ex, "OIDC bypass failed for session '{Session}'", oidcSession); + return RedirectToAction("AccessDenied", "Error"); } } + + private ActionResult RedirectToLoginOrIdentity(SingleSignOnDto sso) + { + return Configuration.Current.PreAuthnMode + ? RedirectToAction("Identity", sso) + : RedirectToAction("Login", sso); + } } } \ No newline at end of file diff --git a/Controllers/HomeController.cs b/Controllers/HomeController.cs index 99c0e5c..0b89bca 100644 --- a/Controllers/HomeController.cs +++ b/Controllers/HomeController.cs @@ -1,6 +1,11 @@ using MultiFactor.SelfService.Windows.Portal.Attributes; -using MultiFactor.SelfService.Windows.Portal.Services.API; +using MultiFactor.SelfService.Windows.Portal.Models; +using MultiFactor.SelfService.Windows.Portal.Stories; +using MultiFactor.SelfService.Windows.Portal.Stories.LoadProfile; +using MultiFactor.SelfService.Windows.Portal.Stories.LoadProfileStory; +using MultiFactor.SelfService.Windows.Portal.ViewModels; using System; +using System.Threading.Tasks; using System.Web.Mvc; namespace MultiFactor.SelfService.Windows.Portal.Controllers @@ -8,35 +13,43 @@ namespace MultiFactor.SelfService.Windows.Portal.Controllers [IsAuthorized] public class HomeController : ControllerBase { - private readonly MultiFactorSelfServiceApiClient _api; + private readonly LoadProfileStory _loadProfileStory; + private readonly FilterShowcaseLinksStory _filterShowcaseLinksStory; - public HomeController(MultiFactorSelfServiceApiClient api) + public HomeController(LoadProfileStory loadProfileStory, + FilterShowcaseLinksStory filterShowcaseLinksStory) { - _api = api ?? throw new ArgumentNullException(nameof(api)); + _loadProfileStory = loadProfileStory; + _filterShowcaseLinksStory = filterShowcaseLinksStory; } - public ActionResult Index() + public async Task Index(SingleSignOnDto claims) { - if (Request.QueryString[MultiFactorClaims.SamlSessionId] != null) + var userProfile = await _loadProfileStory.ExecuteAsync(); + + if (claims.HasSamlSession()) { - //re-login for saml authentication - return SignOut(); + return new RedirectToActionResult().ToActionResult("ByPassSamlSession", "Account", new { username = userProfile.Identity, samlSession = claims.SamlSessionId }); } - if (Request.QueryString[MultiFactorClaims.OidcSessionId] != null) + + if (claims.HasOidcSession()) { - //re-login for oidc authentication - return SignOut(); + return new RedirectToActionResult().ToActionResult("ByPassOidcSession", "Account", new { username = userProfile.Identity, oidcSession = claims.OidcSessionId }); } - var userProfile = _api.LoadUserProfile(); - userProfile.EnablePasswordManagement = Configuration.Current.EnablePasswordManagement; - userProfile.EnableExchangeActiveSyncDevicesManagement = Configuration.Current.EnableExchangeActiveSyncDevicesManagement; var expiration = (DateTime?)HttpContext.Items["passwordExpirationDate"]; if (expiration != null) { userProfile.PasswordExpirationDaysLeft = (expiration - DateTime.Now).Value.Days; } - return View(userProfile); + + var showcaseLinks = _filterShowcaseLinksStory.Execute(userProfile.Policy); + var model = new ShowcaseViewModel() + { + Profile = userProfile, + ShowcaseLinks = showcaseLinks + }; + return View(model); } } } \ No newline at end of file diff --git a/Core/ApplicationGlobalValuesProvider.cs b/Core/ApplicationGlobalValuesProvider.cs new file mode 100644 index 0000000..3ca9d9a --- /dev/null +++ b/Core/ApplicationGlobalValuesProvider.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System; +using MultiFactor.SelfService.Windows.Portal.Core.Http; +using MultiFactor.SelfService.Windows.Portal.Core.Metadata.GlobalValues; +using MultiFactor.SelfService.Windows.Portal.Extensions; + +namespace MultiFactor.SelfService.Windows.Portal.Core +{ + public class ApplicationGlobalValuesProvider + { + private readonly SafeHttpContextAccessor _httpContextAccessor; + + public ApplicationGlobalValuesProvider(SafeHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); + } + + public IReadOnlyList GetValues(ApplicationGlobalValue key) + { + switch (key) + { + case ApplicationGlobalValue.UserName: + return new[] { _httpContextAccessor.SafeGetCredVerificationResult().Username ?? string.Empty }; + + case ApplicationGlobalValue.UserGroup: + return _httpContextAccessor.SafeGetLdapAttributes().GetValues("memberOf"); + + default: + return Array.Empty(); + } + } + } +} diff --git a/Core/Authentication/AdditionalClaims/AdditionalClaimValueSources/AttributeClaimValueSource.cs b/Core/Authentication/AdditionalClaims/AdditionalClaimValueSources/AttributeClaimValueSource.cs new file mode 100644 index 0000000..3a66044 --- /dev/null +++ b/Core/Authentication/AdditionalClaims/AdditionalClaimValueSources/AttributeClaimValueSource.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System; + +namespace MultiFactor.SelfService.Windows.Portal.Core.Authentication.AdditionalClaims.AdditionalClaimValueSources +{ + public class AttributeClaimValueSource : IClaimValueSource + { + public string Attribute { get; } + + public AttributeClaimValueSource(string attribute) + { + Attribute = attribute ?? throw new ArgumentNullException(nameof(attribute)); + } + + public IReadOnlyList GetValues(IApplicationValuesContext context) + { + return context[Attribute]; + } + } +} diff --git a/Core/Authentication/AdditionalClaims/AdditionalClaimValueSources/LiteralClaimValueSource.cs b/Core/Authentication/AdditionalClaims/AdditionalClaimValueSources/LiteralClaimValueSource.cs new file mode 100644 index 0000000..a2834be --- /dev/null +++ b/Core/Authentication/AdditionalClaims/AdditionalClaimValueSources/LiteralClaimValueSource.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace MultiFactor.SelfService.Windows.Portal.Core.Authentication.AdditionalClaims.AdditionalClaimValueSources +{ + public class LiteralClaimValueSource : IClaimValueSource + { + public string Value { get; } + + public LiteralClaimValueSource(string value) + { + Value = value; + } + + public IReadOnlyList GetValues(IApplicationValuesContext context) + { + return new[] { Value }; + } + } +} diff --git a/Core/Authentication/AdditionalClaims/AdditionalClaimValueSources/ReservedValueClaimValueSource.cs b/Core/Authentication/AdditionalClaims/AdditionalClaimValueSources/ReservedValueClaimValueSource.cs new file mode 100644 index 0000000..0b9542a --- /dev/null +++ b/Core/Authentication/AdditionalClaims/AdditionalClaimValueSources/ReservedValueClaimValueSource.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using MultiFactor.SelfService.Windows.Portal.Core.Metadata.GlobalValues; + +namespace MultiFactor.SelfService.Windows.Portal.Core.Authentication.AdditionalClaims.AdditionalClaimValueSources +{ + public class ReservedValueClaimValueSource : IClaimValueSource + { + public ApplicationGlobalValue Value { get; } + + public ReservedValueClaimValueSource(ApplicationGlobalValue value) + { + Value = value; + } + + public IReadOnlyList GetValues(IApplicationValuesContext context) + { + return context[Value.ToString()]; + } + } +} diff --git a/Core/Authentication/AdditionalClaims/Description/AdditionalClaimDescriptor.cs b/Core/Authentication/AdditionalClaims/Description/AdditionalClaimDescriptor.cs new file mode 100644 index 0000000..075dbfe --- /dev/null +++ b/Core/Authentication/AdditionalClaims/Description/AdditionalClaimDescriptor.cs @@ -0,0 +1,35 @@ +using System; +using System.Diagnostics; +using MultiFactor.SelfService.Windows.Portal.Core.Authentication.AdditionalClaims.Description.Conditions; + +namespace MultiFactor.SelfService.Windows.Portal.Core.Authentication.AdditionalClaims.Description +{ + /// + /// Object that describes additional claim properties. + /// + [DebuggerDisplay("Name = {Name}")] + public class AdditionalClaimDescriptor + { + /// + /// Claim name (type). + /// + public string Name { get; } + + /// + /// From where claim value will be consumed. + /// + public IClaimValueSource Source { get; } + + /// + /// Object described condition that can be evaluated. + /// + public ClaimCondition Condition { get; } + + public AdditionalClaimDescriptor(string name, IClaimValueSource source, ClaimCondition condition) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Source = source ?? throw new ArgumentNullException(nameof(source)); + Condition = condition; + } + } +} diff --git a/Core/Authentication/AdditionalClaims/Description/AdditionalClaimDescriptorsProvider.cs b/Core/Authentication/AdditionalClaims/Description/AdditionalClaimDescriptorsProvider.cs new file mode 100644 index 0000000..4e2ef68 --- /dev/null +++ b/Core/Authentication/AdditionalClaims/Description/AdditionalClaimDescriptorsProvider.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MultiFactor.SelfService.Windows.Portal.Core.Authentication.AdditionalClaims.Description +{ + public class AdditionalClaimDescriptorsProvider + { + private readonly Configuration _portalSettings; + + public AdditionalClaimDescriptorsProvider(Configuration portalSettings) + { + _portalSettings = portalSettings ?? throw new ArgumentNullException(nameof(portalSettings)); + } + + public IReadOnlyList GetDescriptors() + { + return Enumerable + .Empty() + .ToList() + .AsReadOnly(); + } + } +} diff --git a/Core/Authentication/AdditionalClaims/Description/Conditions/ClaimCondition.cs b/Core/Authentication/AdditionalClaims/Description/Conditions/ClaimCondition.cs new file mode 100644 index 0000000..ea087ce --- /dev/null +++ b/Core/Authentication/AdditionalClaims/Description/Conditions/ClaimCondition.cs @@ -0,0 +1,18 @@ +using System; + +namespace MultiFactor.SelfService.Windows.Portal.Core.Authentication.AdditionalClaims.Description.Conditions +{ + public class ClaimCondition + { + public IClaimValueSource LeftOperand { get; } + public IClaimValueSource RightOperand { get; } + public ClaimsConditionOperation Operation { get; } + + public ClaimCondition(IClaimValueSource left, IClaimValueSource right, ClaimsConditionOperation op) + { + LeftOperand = left ?? throw new ArgumentNullException(nameof(left)); + RightOperand = right ?? throw new ArgumentNullException(nameof(right)); + Operation = op; + } + } +} diff --git a/Core/Authentication/AdditionalClaims/Description/Conditions/ClaimConditionEvaluator.cs b/Core/Authentication/AdditionalClaims/Description/Conditions/ClaimConditionEvaluator.cs new file mode 100644 index 0000000..3d6bb50 --- /dev/null +++ b/Core/Authentication/AdditionalClaims/Description/Conditions/ClaimConditionEvaluator.cs @@ -0,0 +1,52 @@ +using System; +using Serilog; +using System.Linq; + +namespace MultiFactor.SelfService.Windows.Portal.Core.Authentication.AdditionalClaims.Description.Conditions +{ + public class ClaimConditionEvaluator + { + private readonly IApplicationValuesContext _claimValuesContext; + private readonly ILogger _logger; + + public ClaimConditionEvaluator(IApplicationValuesContext claimValuesContext, ILogger logger) + { + _claimValuesContext = claimValuesContext ?? throw new ArgumentNullException(nameof(claimValuesContext)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Evaluates condition and returns TRUE or FALSE. + /// + /// Claim condition + /// TRUE / FALSE + public bool Evaluate(ClaimCondition condition) + { + if (condition.Operation != ClaimsConditionOperation.Eq) + { + _logger.Debug("Not supported claim condition operation: {op}", condition.Operation); + return false; + } + var leftValue = condition.LeftOperand.GetValues(_claimValuesContext); + var rightValue = condition.RightOperand.GetValues(_claimValuesContext); + + if (!leftValue.Any() || !rightValue.Any()) + return false; + + if (leftValue.Count == 1 && rightValue.Count == 1) + { + return leftValue[0] == rightValue[0]; + } + + if (leftValue.Count > 1 && rightValue.Count > 1) + { + return leftValue.OrderBy(x => x).SequenceEqual( + rightValue.OrderBy(x => x)); + } + + return rightValue.Count > 1 && leftValue.Count == 1 ? + rightValue.Any(x => leftValue.Contains(x)) : + leftValue.Any(x => rightValue.Contains(x)); + } + } +} diff --git a/Core/Authentication/AdditionalClaims/Description/Conditions/ClaimsConditionOperation.cs b/Core/Authentication/AdditionalClaims/Description/Conditions/ClaimsConditionOperation.cs new file mode 100644 index 0000000..a0f74a9 --- /dev/null +++ b/Core/Authentication/AdditionalClaims/Description/Conditions/ClaimsConditionOperation.cs @@ -0,0 +1,7 @@ +namespace MultiFactor.SelfService.Windows.Portal.Core.Authentication.AdditionalClaims.Description.Conditions +{ + public enum ClaimsConditionOperation + { + Eq + } +} diff --git a/Core/Authentication/AdditionalClaims/IApplicationValuesContext.cs b/Core/Authentication/AdditionalClaims/IApplicationValuesContext.cs new file mode 100644 index 0000000..69457fb --- /dev/null +++ b/Core/Authentication/AdditionalClaims/IApplicationValuesContext.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace MultiFactor.SelfService.Windows.Portal.Core.Authentication.AdditionalClaims +{ + public interface IApplicationValuesContext + { + /// + /// Returns all available values for the specified key. + /// + /// Key identifier. + /// List of values. + IReadOnlyList this[string key] { get; } + } +} diff --git a/Core/Authentication/AdditionalClaims/IClaimValueSource.cs b/Core/Authentication/AdditionalClaims/IClaimValueSource.cs new file mode 100644 index 0000000..70c862f --- /dev/null +++ b/Core/Authentication/AdditionalClaims/IClaimValueSource.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace MultiFactor.SelfService.Windows.Portal.Core.Authentication.AdditionalClaims +{ + public interface IClaimValueSource + { + /// + /// Returns all available values for the claim. + /// + /// Context that consumes all available values. + /// List of values. + IReadOnlyList GetValues(IApplicationValuesContext context); + } +} diff --git a/Core/Authentication/AuthenticationClaims/ClaimsProvider.cs b/Core/Authentication/AuthenticationClaims/ClaimsProvider.cs new file mode 100644 index 0000000..37c40b9 --- /dev/null +++ b/Core/Authentication/AuthenticationClaims/ClaimsProvider.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System; + +namespace MultiFactor.SelfService.Windows.Portal.Core.Authentication.AuthenticationClaims +{ + public class ClaimsProvider + { + private readonly IEnumerable _claimsSources; + + public ClaimsProvider(IEnumerable claimsSources) + { + _claimsSources = claimsSources ?? throw new ArgumentNullException(nameof(claimsSources)); + } + + public IReadOnlyDictionary GetClaims() + { + var dict = new Dictionary(); + + foreach (var source in _claimsSources) + { + foreach (var claim in source.GetClaims()) + { + dict[claim.Key] = claim.Value; + } + } + + return dict; + } + } +} diff --git a/Core/Authentication/AuthenticationClaims/IClaimsSource.cs b/Core/Authentication/AuthenticationClaims/IClaimsSource.cs new file mode 100644 index 0000000..fb5ee2d --- /dev/null +++ b/Core/Authentication/AuthenticationClaims/IClaimsSource.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace MultiFactor.SelfService.Windows.Portal.Core.Authentication.AuthenticationClaims +{ + public interface IClaimsSource + { + IReadOnlyDictionary GetClaims(); + } +} diff --git a/Core/Caching/ApplicationCache.cs b/Core/Caching/ApplicationCache.cs new file mode 100644 index 0000000..1a05e6c --- /dev/null +++ b/Core/Caching/ApplicationCache.cs @@ -0,0 +1,119 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using System; +using MultiFactor.SelfService.Windows.Portal.Models; +using MultiFactor.SelfService.Windows.Portal.Services.Caching; + +namespace MultiFactor.SelfService.Windows.Portal.Core.Caching +{ + internal class ApplicationCache : IApplicationCache + { + private readonly IMemoryCache _cache; + private readonly ApplicationCacheConfig _config; + + public ApplicationCache(IMemoryCache cache, IOptions config) + { + _cache = cache; + _config = config.Value; + } + + public void Set(string key, string value) + { + var options = new MemoryCacheEntryOptions() + .SetAbsoluteExpiration(_config.AbsoluteExpiration) + .SetSize(GetDataSize(value)); + _cache.Set(key, value, options); + } + + public CachedItem Get(string key) + { + if (_cache.TryGetValue(key, out string value)) return new CachedItem(value); + return CachedItem.Empty; + } + + public void SetIdentity(string key, IdentityModel value) + { + var options = new MemoryCacheEntryOptions() + .SetAbsoluteExpiration(_config.AbsoluteExpiration) + .SetSize(GetDataSize(value)); + _cache.Set(key, value, options); + } + + public CachedItem GetIdentity(string key) + { + if (string.IsNullOrWhiteSpace(key)) + return CachedItem.Empty; + return _cache.TryGetValue(key, out IdentityModel value) + ? new CachedItem(value) + : CachedItem.Empty; + } + + public void SetPreauthenticationAuthn(string key, bool value) + { + var options = new MemoryCacheEntryOptions() + .SetAbsoluteExpiration(_config.PreauthenticationAuthnExpiration) + .SetSize(1); + _cache.Set(key, value, options); + } + + public CachedItem GetPreauthenticationAuthn(string key) + { + if (string.IsNullOrWhiteSpace(key)) + return CachedItem.Empty; + return _cache.TryGetValue(key, out bool value) + ? new CachedItem(value) + : CachedItem.Empty; + } + + public void Remove(string key) + { + _cache.Remove(key); + } + + private static long GetDataSize(string data) + { + return CalculateStringSize(data); + } + + private static long GetDataSize(IdentityModel data) + { + return CalculateStringSize(data.AccessToken) + + CalculateStringSize(data.UserName); + } + + public void SetSupportInfo(string key, SupportViewModel value) + { + var expiration = value.IsEmpty() + ? _config.SupportInfoEmptyExpiration + : _config.SupportInfoExpiration; + var options = new MemoryCacheEntryOptions() + .SetAbsoluteExpiration(expiration) + .SetSize(GetDataSize(value)); + _cache.Set(key, value, options); + } + + public CachedItem GetSupportInfo(string key) + { + if (string.IsNullOrWhiteSpace(key)) + return CachedItem.Empty; + return _cache.TryGetValue(key, out SupportViewModel value) + ? new CachedItem(value) + : CachedItem.Empty; + } + + private static long GetDataSize(SupportViewModel data) + { + return CalculateStringSize(data.AdminEmail) + + CalculateStringSize(data.AdminName) + + CalculateStringSize(data.AdminPhone); + } + + private static long CalculateStringSize(string s) + { + var headerSize = IntPtr.Size == 4 ? 14 : 22; // 32-bit ? 14 : 22 + var size = headerSize + 2L * s.Length; + var alignment = IntPtr.Size == 4 ? 4 : 8; // 32-bit ? 4 : 8 + return (long)Math.Ceiling(size / (double)alignment) * alignment; + } + } +} diff --git a/Core/Caching/IApplicationCache.cs b/Core/Caching/IApplicationCache.cs new file mode 100644 index 0000000..c37c0a8 --- /dev/null +++ b/Core/Caching/IApplicationCache.cs @@ -0,0 +1,18 @@ +using MultiFactor.SelfService.Windows.Portal.Models; +using MultiFactor.SelfService.Windows.Portal.Services.Caching; + +namespace MultiFactor.SelfService.Windows.Portal.Core.Caching +{ + public interface IApplicationCache + { + void Set(string key, string value); + CachedItem Get(string key); + void SetIdentity(string key, IdentityModel value); + CachedItem GetIdentity(string key); + void Remove(string key); + void SetSupportInfo(string key, SupportViewModel value); + CachedItem GetSupportInfo(string key); + void SetPreauthenticationAuthn(string key, bool value); + CachedItem GetPreauthenticationAuthn(string key); + } +} diff --git a/Core/ClassPropertyAccessor.cs b/Core/ClassPropertyAccessor.cs new file mode 100644 index 0000000..7aaafda --- /dev/null +++ b/Core/ClassPropertyAccessor.cs @@ -0,0 +1,25 @@ +using System.Linq; +using System; +using System.Linq.Expressions; + +namespace MultiFactor.SelfService.Windows.Portal.Core +{ + public static class ClassPropertyAccessor + { + public static string GetPropertyPath(Expression> propertySelector, string separator = ":") where TClass : class + { + if (propertySelector == null) + { + throw new ArgumentNullException(nameof(propertySelector)); + } + if (separator == null) + { + throw new ArgumentNullException(nameof(separator)); + } + if (propertySelector.Body.NodeType != ExpressionType.MemberAccess) throw new Exception("Invalid property name"); + + var path = propertySelector.ToString().Split('.').Skip(1) ?? Array.Empty(); + return string.Join(separator, path); + } + } +} diff --git a/Core/DataProtection.cs b/Core/DataProtection.cs new file mode 100644 index 0000000..41b65d6 --- /dev/null +++ b/Core/DataProtection.cs @@ -0,0 +1,27 @@ +using MultiFactor.SelfService.Windows.Portal.Services; + +namespace MultiFactor.SelfService.Windows.Portal.Core +{ + /// + /// Protect sensitive data. + /// + public class DataProtection + { + private readonly DataProtectionService _dataProtectionService; + + public DataProtection(DataProtectionService dataProtectionService) + { + _dataProtectionService = dataProtectionService; + } + + public string Protect(string data, string protectorName) + { + return _dataProtectionService.Protect(data); + } + + public string Unprotect(string data, string protectorName) + { + return _dataProtectionService.Unprotect(data); + } + } +} \ No newline at end of file diff --git a/Core/Http/HttpClientAdapter.cs b/Core/Http/HttpClientAdapter.cs index 6e91d48..39b9c85 100644 --- a/Core/Http/HttpClientAdapter.cs +++ b/Core/Http/HttpClientAdapter.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; using System.Threading.Tasks; namespace MultiFactor.SelfService.Windows.Portal.Integrations.Google.ReCaptcha @@ -33,6 +34,17 @@ public async Task GetAsync(string uri, IReadOnlyDictionary GetByteArrayAsync(string uri, IReadOnlyDictionary headers = null) + { + var message = new HttpRequestMessage(HttpMethod.Get, uri); + HttpClientUtils.AddHeadersIfExist(message, headers); + + var resp = await ExecuteHttpMethod(() => _client.SendAsync(message)); + if (resp.Content == null) return default; + + return await resp.Content.ReadAsByteArrayAsync(); + } + public async Task GetAsync(string uri, IReadOnlyDictionary headers = null) { var message = new HttpRequestMessage(HttpMethod.Get, uri); @@ -69,6 +81,51 @@ public async Task DeleteAsync(string uri, IReadOnlyDictionary(resp.Content, "Response from API"); } + public async Task PostFormAsync( + string uri, + IEnumerable> formData, + IReadOnlyDictionary headers = null, + bool deserializeWhenNonSuccessStatus = false) + { + var message = new HttpRequestMessage(HttpMethod.Post, uri); + HttpClientUtils.AddHeadersIfExist(message, headers); + + if (formData != null) + { + message.Content = new FormUrlEncodedContent(formData); + message.Content.Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded"); + } + + ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12; + + if (deserializeWhenNonSuccessStatus) + { + var resp = await _client.SendAsync(message); + + if (resp.StatusCode == HttpStatusCode.Unauthorized) + { + throw new UnauthorizedException(); + } + + if (resp.Content == null) + { + return default; + } + + var jsonResponse = await resp.Content.ReadAsStringAsync(); + if (string.IsNullOrWhiteSpace(jsonResponse)) + { + return default; + } + + return await _jsonDataSerializer.DeserializeAsync(resp.Content, "Response from API"); + } + + var successResp = await ExecuteHttpMethod(() => _client.SendAsync(message)); + if (successResp.Content == null) return default; + + return await _jsonDataSerializer.DeserializeAsync(successResp.Content, "Response from API"); + } private async Task ExecuteHttpMethod(Func> method) { diff --git a/Core/Http/HttpClientTokenProvider.cs b/Core/Http/HttpClientTokenProvider.cs new file mode 100644 index 0000000..e546cfa --- /dev/null +++ b/Core/Http/HttpClientTokenProvider.cs @@ -0,0 +1,38 @@ +using System; +using System.Web; +using MultiFactor.SelfService.Windows.Portal.Core.Exceptions; + +namespace MultiFactor.SelfService.Windows.Portal.Core.Http +{ + public class HttpClientTokenProvider + { + private readonly SafeHttpContextAccessor _contextAccessor; + + public HttpClientTokenProvider(SafeHttpContextAccessor contextAccessor) + { + _contextAccessor = contextAccessor; + } + + public string GetToken() + { + var cookie = _contextAccessor.HttpContext.Request.Cookies[Constants.COOKIE_NAME]; + + if (cookie == null) + throw new UnauthorizedException("HttpClient token not found"); + + return cookie.Value; + } + } + + public class SafeHttpContextAccessor + { + public HttpContext HttpContext => HttpContext.Current ?? throw new HttpContextNotDefinedException("HttpContext can't be null here"); + } + + internal class HttpContextNotDefinedException : Exception + { + public HttpContextNotDefinedException() { } + public HttpContextNotDefinedException(string message) : base(message) { } + public HttpContextNotDefinedException(string message, Exception inner) : base(message, inner) { } + } +} \ No newline at end of file diff --git a/Core/LdapAttributesCaching/ILdapAttributesCache.cs b/Core/LdapAttributesCaching/ILdapAttributesCache.cs new file mode 100644 index 0000000..e3f6d5a --- /dev/null +++ b/Core/LdapAttributesCaching/ILdapAttributesCache.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace MultiFactor.SelfService.Windows.Portal.Core.LdapAttributesCaching +{ + public interface ILdapAttributesCache + { + IReadOnlyList Entries { get; } + string GetValue(string name); + IReadOnlyList GetValues(string name); + } +} diff --git a/Core/LdapAttributesCaching/LdapAttribute.cs b/Core/LdapAttributesCaching/LdapAttribute.cs new file mode 100644 index 0000000..ed21a9c --- /dev/null +++ b/Core/LdapAttributesCaching/LdapAttribute.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System; +using System.Linq; + +namespace MultiFactor.SelfService.Windows.Portal.Core.LdapAttributesCaching +{ + public class LdapAttribute + { + public string Name { get; } + public IReadOnlyList Values { get; } + + private LdapAttribute(string name, IReadOnlyList values) + { + Name = name; + Values = values; + } + + public static LdapAttribute Create(string name, IEnumerable value) + { + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException($"'{nameof(name)}' cannot be null or whitespace.", nameof(name)); + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + return new LdapAttribute(name, value.ToList().AsReadOnly()); + } + } +} diff --git a/Core/LdapAttributesCaching/LdapAttributesCache.cs b/Core/LdapAttributesCaching/LdapAttributesCache.cs new file mode 100644 index 0000000..0d30903 --- /dev/null +++ b/Core/LdapAttributesCaching/LdapAttributesCache.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System; +using System.Linq; + +namespace MultiFactor.SelfService.Windows.Portal.Core.LdapAttributesCaching +{ + public class LdapAttributesCache : ILdapAttributesCache + { + private readonly List _attributes = new List(); + public IReadOnlyList Entries => _attributes.AsReadOnly(); + + public string GetValue(string name) => + _attributes.FirstOrDefault(a => a.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Values.FirstOrDefault(); + + public IReadOnlyList GetValues(string name) => + _attributes.FirstOrDefault(a => a.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Values ?? Array.Empty(); + + public void AddAttribute(string attr, IEnumerable values) + { + _attributes.Add(LdapAttribute.Create(attr, values)); + } + } +} diff --git a/Core/Metadata/AdditionalClaimsMetadata.cs b/Core/Metadata/AdditionalClaimsMetadata.cs new file mode 100644 index 0000000..e2d4784 --- /dev/null +++ b/Core/Metadata/AdditionalClaimsMetadata.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System; +using System.Linq; +using MultiFactor.SelfService.Windows.Portal.Core.Authentication.AdditionalClaims.Description; +using MultiFactor.SelfService.Windows.Portal.Core.Authentication.AdditionalClaims.AdditionalClaimValueSources; + +namespace MultiFactor.SelfService.Windows.Portal.Core.Metadata +{ + /// + /// Additional claims metadata storage and cache. + /// + public class AdditionalClaimsMetadata + { + private IReadOnlyList _additionalClaims; + private IReadOnlyList _requiredAttributes; + private readonly AdditionalClaimDescriptorsProvider _descriptorsProvider; + + /// + /// Returns all additional claims descriptors. + /// + public IReadOnlyList Descriptors => GetDescriptors(); + + /// + /// Consume and returns all attributes required by claim value getter or by claim condition evaluator. + /// + public IReadOnlyList RequiredAttributes => GetRequiredAttributes(); + + public AdditionalClaimsMetadata(AdditionalClaimDescriptorsProvider descriptorsProvider) + { + _descriptorsProvider = descriptorsProvider ?? throw new ArgumentNullException(nameof(descriptorsProvider)); + } + + /// + /// Preloads metadata. + /// + public void LoadMetadata() + { + GetDescriptors(); + GetRequiredAttributes(); + } + + private IReadOnlyList GetRequiredAttributes() + { + if (_requiredAttributes == null) + { + _requiredAttributes = GetDescriptors() + .Select(x => x.Source) + .Concat(GetDescriptors() + .Where(x => x.Condition != null) + .SelectMany(x => new[] { x.Condition.LeftOperand, x.Condition.RightOperand })) + .OfType() + .Select(x => x.Attribute) + .Distinct(new OrdinalIgnoreCaseStringComparer()) + .ToList() + .AsReadOnly(); + } + + return _requiredAttributes; + } + + private IReadOnlyList GetDescriptors() + { + if (_additionalClaims == null) + { + _additionalClaims = _descriptorsProvider.GetDescriptors(); + } + + return _additionalClaims; + } + } +} \ No newline at end of file diff --git a/Core/Metadata/GlobalValues/ApplicationGlobalValue.cs b/Core/Metadata/GlobalValues/ApplicationGlobalValue.cs new file mode 100644 index 0000000..50dc57b --- /dev/null +++ b/Core/Metadata/GlobalValues/ApplicationGlobalValue.cs @@ -0,0 +1,8 @@ +namespace MultiFactor.SelfService.Windows.Portal.Core.Metadata.GlobalValues +{ + public enum ApplicationGlobalValue + { + UserName, + UserGroup + } +} diff --git a/Core/Metadata/GlobalValues/ApplicationGlobalValuesMetadata.cs b/Core/Metadata/GlobalValues/ApplicationGlobalValuesMetadata.cs new file mode 100644 index 0000000..8d15e29 --- /dev/null +++ b/Core/Metadata/GlobalValues/ApplicationGlobalValuesMetadata.cs @@ -0,0 +1,24 @@ +using System.Linq; +using System; + +namespace MultiFactor.SelfService.Windows.Portal.Core.Metadata.GlobalValues +{ + public static class ApplicationGlobalValuesMetadata + { + private static readonly string[] _reservedValues; + + static ApplicationGlobalValuesMetadata() + { + _reservedValues = Enum.GetNames(typeof(ApplicationGlobalValue)); + } + + public static bool HasKey(string value) => _reservedValues.Any(x => x.Equals(value, StringComparison.OrdinalIgnoreCase)); + public static ApplicationGlobalValue ParseKey(string value) + { + return (ApplicationGlobalValue)Enum.Parse( + typeof(ApplicationGlobalValue), + value, + true); + } + } +} diff --git a/Core/OrdinalIgnoreCaseStringComparer.cs b/Core/OrdinalIgnoreCaseStringComparer.cs new file mode 100644 index 0000000..d340839 --- /dev/null +++ b/Core/OrdinalIgnoreCaseStringComparer.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System; + +namespace MultiFactor.SelfService.Windows.Portal.Core +{ + public class OrdinalIgnoreCaseStringComparer : IEqualityComparer + { + public bool Equals(string x, string y) + { + if (x == null && y == null) return true; + if (x == null || y == null) return false; + return x.Equals(y, StringComparison.OrdinalIgnoreCase); + } + + public int GetHashCode(string obj) + { + return obj.GetHashCode(); + } + } +} diff --git a/Dto/ScopeSupportInfoDto.cs b/Dto/ScopeSupportInfoDto.cs new file mode 100644 index 0000000..74065ae --- /dev/null +++ b/Dto/ScopeSupportInfoDto.cs @@ -0,0 +1,18 @@ +using MultiFactor.SelfService.Windows.Portal.Models; + +namespace MultiFactor.SelfService.Windows.Portal.Dto +{ + public class ScopeSupportInfoDto + { + public string AdminName { get; set; } + public string AdminEmail { get; set; } + public string AdminPhone { get; set; } + + public static SupportViewModel ToModel(ScopeSupportInfoDto dto) + { + return dto == null + ? SupportViewModel.EmptyModel() + : new SupportViewModel(dto.AdminName, dto.AdminEmail, dto.AdminPhone); + } + } +} \ No newline at end of file diff --git a/Exceptions/ModelStateErrorException.cs b/Exceptions/ModelStateErrorException.cs new file mode 100644 index 0000000..f1d3f56 --- /dev/null +++ b/Exceptions/ModelStateErrorException.cs @@ -0,0 +1,13 @@ +using System; + +namespace MultiFactor.SelfService.Windows.Portal.Exceptions +{ + internal class ModelStateErrorException : Exception + { + public ModelStateErrorException() { } + + public ModelStateErrorException(string message) : base(message) { } + + public ModelStateErrorException(string message, string viewName, Exception inner) : base(message, inner) { } + } +} diff --git a/Extensions/ConfigurationExtensions.cs b/Extensions/ConfigurationExtensions.cs new file mode 100644 index 0000000..4c6d302 --- /dev/null +++ b/Extensions/ConfigurationExtensions.cs @@ -0,0 +1,30 @@ +using System; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; + +namespace MultiFactor.SelfService.Windows.Portal.Extensions +{ + public static class ConfigurationExtensions + { + public static JsonWebKeySet GetJsonWebKeySet(this IConfiguration config) + { + if (config == null) + { + throw new ArgumentNullException(nameof(config)); + } + + var value = config.GetValue(Constants.TOKEN_VALIDATION); + return new JsonWebKeySet(value); + } + + public static string GetEnvironment(this IConfiguration config) + { + if (config == null) + { + throw new ArgumentNullException(nameof(config)); + } + + return config.GetValue(Constants.ENVIRONMENT_KEY); + } + } +} \ No newline at end of file diff --git a/Extensions/HttpContextExtensions.cs b/Extensions/HttpContextExtensions.cs new file mode 100644 index 0000000..42397bb --- /dev/null +++ b/Extensions/HttpContextExtensions.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Web; + +namespace MultiFactor.SelfService.Windows.Portal.Extensions +{ + public static class HttpContextExtensions + { + public static Dictionary GetRequiredHeaders(this HttpContext httpContext) + { + var headers = httpContext.Request.Headers.AllKeys + .Where(key => ShouldForwardHeader(key)) + .ToDictionary( + key => key, + key => httpContext.Request.Headers[key] + ); + + var clientIp = httpContext.Request.UserHostAddress; + if (!string.IsNullOrWhiteSpace(clientIp)) + { + headers["X-Original-Client-IP"] = clientIp; + } + + return headers; + } + + public static Dictionary GetRequiredHeaders(this HttpContextBase httpContext) + { + if (httpContext == null) + throw new ArgumentNullException(nameof(httpContext)); + + var request = httpContext.Request; + + var headers = request.Headers.AllKeys + .Where(key => ShouldForwardHeader(key)) + .ToDictionary( + key => key, + key => request.Headers[key] + ); + + var clientIp = request.UserHostAddress; + if (!string.IsNullOrWhiteSpace(clientIp)) + { + headers["X-Original-Client-IP"] = clientIp; + } + + return headers; + } + + private static bool ShouldForwardHeader(string key) + { + return AllowedForwardHeaders.Contains(key); + } + + private static readonly HashSet AllowedForwardHeaders = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Authorization", + "User-Agent", + "X-Device-Id", + "X-Device-Type" + }; + } +} \ No newline at end of file diff --git a/Extensions/PortalSettingsAccess.cs b/Extensions/PortalSettingsAccess.cs new file mode 100644 index 0000000..3c944ea --- /dev/null +++ b/Extensions/PortalSettingsAccess.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq.Expressions; +using Microsoft.Extensions.Configuration; +using MultiFactor.SelfService.Windows.Portal.Core; + +namespace MultiFactor.SelfService.Windows.Portal.Extensions +{ + public static class PortalSettingsAccess + { + public static TProperty GetPortalSettingsValue(this IConfiguration config, + Expression> propertySelector) + { + if (propertySelector == null) + { + throw new ArgumentNullException(nameof(propertySelector)); + } + + var key = ClassPropertyAccessor.GetPropertyPath(propertySelector, ":"); + return GetConfigValue(config, $"PortalSettings:{key}"); + } + + public static TProperty GetConfigValue(this IConfiguration config, string path) + { + if (config == null) + { + throw new ArgumentNullException(nameof(config)); + } + if (path == null) + { + throw new ArgumentNullException(nameof(path)); + } + + return config.GetValue(path); + } + } +} \ No newline at end of file diff --git a/Extensions/SafeHttpContextAccessorExtensions.cs b/Extensions/SafeHttpContextAccessorExtensions.cs new file mode 100644 index 0000000..8b9289d --- /dev/null +++ b/Extensions/SafeHttpContextAccessorExtensions.cs @@ -0,0 +1,28 @@ +using MultiFactor.SelfService.Windows.Portal.Core.Http; +using MultiFactor.SelfService.Windows.Portal.Integrations.Ldap.CredentialVerification; +using MultiFactor.SelfService.Windows.Portal.Core.LdapAttributesCaching; +using MultiFactor.SelfService.Windows.Portal.Models; + +namespace MultiFactor.SelfService.Windows.Portal.Extensions +{ + public static class SafeHttpContextAccessorExtensions + { + public static SingleSignOnDto SafeGetSsoClaims(this SafeHttpContextAccessor accessor) + { + return accessor.HttpContext.Items[Constants.SsoClaims] as SingleSignOnDto + ?? new SingleSignOnDto(); + } + + public static CredentialVerificationResult SafeGetCredVerificationResult(this SafeHttpContextAccessor accessor) + { + return accessor.HttpContext.Items[Constants.CredentialVerificationResult] as CredentialVerificationResult + ?? CredentialVerificationResult.CreateBuilder(false).Build(); + } + + public static ILdapAttributesCache SafeGetLdapAttributes(this SafeHttpContextAccessor accessor) + { + return accessor.HttpContext.Items[Constants.LoadedLdapAttributes] as ILdapAttributesCache + ?? new LdapAttributesCache(); + } + } +} diff --git a/Extensions/UrlExtensions.cs b/Extensions/UrlExtensions.cs new file mode 100644 index 0000000..f4b9c2f --- /dev/null +++ b/Extensions/UrlExtensions.cs @@ -0,0 +1,55 @@ +using System; + +namespace MultiFactor.SelfService.Windows.Portal.Extensions +{ + public static class UrlExtensions + { + public static string BuildRelativeUrl(this string currentPath, string relPath, int removeSegments = 0) + { + if (currentPath is null) + { + throw new ArgumentNullException(nameof(currentPath)); + } + if (relPath is null) + { + throw new ArgumentNullException(nameof(relPath)); + } + + // public url from browser if we behind nginx or other proxy + var currentUri = new Uri(currentPath); + var noLastSegment = $"{currentUri.Scheme}://{currentUri.Authority}"; + + for (int i = 0; i < currentUri.Segments.Length - removeSegments; i++) + { + noLastSegment += currentUri.Segments[i]; + } + + // remove trailing + return $"{noLastSegment.Trim("/".ToCharArray())}/{relPath}"; + } + + public static string BuildPostbackUrl(this string documentUrl) + { + // public url from browser if we behind nginx or other proxy + var currentUri = new Uri(documentUrl); + var noLastSegment = $"{currentUri.Scheme}://{currentUri.Authority}"; + + for (int i = 0; i < currentUri.Segments.Length - 1; i++) + { + noLastSegment += currentUri.Segments[i]; + } + + // remove trailing / + noLastSegment = noLastSegment.Trim("/".ToCharArray()); + + var postbackUrl = noLastSegment + "/PostbackFromMfa"; + return postbackUrl; + } + + public static string BuildAdConnectorBaseUrl(this string documentUrl) + { + var currentUri = new Uri(documentUrl); + return $"{currentUri.Scheme}://{currentUri.Authority}"; + } + } +} \ No newline at end of file diff --git a/Global.asax.cs b/Global.asax.cs index 8bb5fb1..441c43b 100644 --- a/Global.asax.cs +++ b/Global.asax.cs @@ -2,6 +2,7 @@ using MultiFactor.SelfService.Windows.Portal.App_Start; using MultiFactor.SelfService.Windows.Portal.Core; using MultiFactor.SelfService.Windows.Portal.Core.Exceptions; +using MultiFactor.SelfService.Windows.Portal.Services; using MultiFactor.SelfService.Windows.Portal.Syslog; using Serilog; using Serilog.Core; @@ -29,6 +30,8 @@ namespace MultiFactor.SelfService.Windows.Portal { public class MvcApplication : HttpApplication { + private static ShowcaseSettingsUpdater _showcaseSettingsUpdater; + protected void Application_Start() { try @@ -108,6 +111,9 @@ private void Start() ControllerBuilder.Current.SetControllerFactory(new CustomControllerFactory(provider)); RemoveSomeHeaders(); + + _showcaseSettingsUpdater = provider.GetRequiredService(); + _showcaseSettingsUpdater.Start(); } protected void Application_Error() diff --git a/Integrations/Ldap/CredentialVerification/CredentialVerificationResult.cs b/Integrations/Ldap/CredentialVerification/CredentialVerificationResult.cs new file mode 100644 index 0000000..2af5b32 --- /dev/null +++ b/Integrations/Ldap/CredentialVerification/CredentialVerificationResult.cs @@ -0,0 +1,184 @@ +using System.Text.RegularExpressions; +using System; + +namespace MultiFactor.SelfService.Windows.Portal.Integrations.Ldap.CredentialVerification +{ + public class CredentialVerificationResult + { + public bool IsAuthenticated { get; } + public string Reason { get; private set; } + + public bool IsBypass { get; private set; } + public bool UserMustChangePassword { get; private set; } + public DateTime PasswordExpirationDate { get; private set; } + public string DisplayName { get; private set; } + public string Email { get; private set; } + public string Phone { get; private set; } + public string Username { get; private set; } + public string UserPrincipalName { get; private set; } + public string CustomIdentity { get; private set; } + private CredentialVerificationResult(bool isAuthenticated) + { + IsAuthenticated = isAuthenticated; + } + + public static CredentialVerificationResultBuilder CreateBuilder(bool isAuthenticated) + { + return new CredentialVerificationResultBuilder(new CredentialVerificationResult(isAuthenticated)); + } + + /// + /// Result for bypass second factor (only ssp+sso) + /// + /// Raw username that was entered into the form when logging in + /// UPN from LDAP profile + /// + public static CredentialVerificationResult ByPass(string username, string upn, bool mustChangePassword) + { + return new CredentialVerificationResult(true) + { + IsBypass = true, + Username = username, + UserPrincipalName = upn, + UserMustChangePassword = mustChangePassword + }; + } + + public static CredentialVerificationResult BeforeAuthn(string username) + { + return new CredentialVerificationResult(false) + { + Username = username, + }; + } + + public static CredentialVerificationResult FromKnownError(string errorMessage, string username = null) + { + if (string.IsNullOrEmpty(errorMessage)) + { + return FromUnknownError(); + } + + var pattern = @"data ([0-9a-e]{3})"; + var match = Regex.Match(errorMessage, pattern); + + if (match.Success && match.Groups.Count == 2) + { + var data = match.Groups[1].Value; + + var resultBuilder = CreateBuilder(false); + if (username != null) + { + resultBuilder.SetUsername(username); + } + + switch (data) + { + case "525": + return new CredentialVerificationResult(false) { Reason = "User not found" }; + case "52e": + return new CredentialVerificationResult(false) { Reason = "Invalid credentials" }; + case "530": + return new CredentialVerificationResult(false) { Reason = "Not permitted to logon at this time" }; + case "531": + return new CredentialVerificationResult(false) { Reason = "Not permitted to logon at this workstation​" }; + case "532": + return resultBuilder + .SetReason("Password Expired") + .SetUserMustChangePassword(true) + .Build(); + case "533": + return new CredentialVerificationResult(false) { Reason = "Account disabled" }; + case "701": + return new CredentialVerificationResult(false) { Reason = "Account expired" }; + case "773": + return resultBuilder + .SetReason("User must change password") + .SetUserMustChangePassword(true) + .Build(); + case "775": + return new CredentialVerificationResult(false) { Reason = "User account locked" }; + } + } + + return FromUnknownError(errorMessage); + } + + public static CredentialVerificationResult FromUnknownError(string errorMessage = null) + { + return new CredentialVerificationResult(false) { Reason = errorMessage ?? "Unknown error" }; + } + + + + public class CredentialVerificationResultBuilder + { + private readonly CredentialVerificationResult _result; + + public CredentialVerificationResultBuilder(CredentialVerificationResult result) + { + _result = result ?? throw new ArgumentNullException(nameof(result)); + } + + public CredentialVerificationResultBuilder SetDisplayName(string displayName) + { + _result.DisplayName = displayName; + return this; + } + + public CredentialVerificationResultBuilder SetEmail(string email) + { + _result.Email = email; + return this; + } + + public CredentialVerificationResultBuilder SetPhone(string phone) + { + _result.Phone = phone; + return this; + } + + public CredentialVerificationResultBuilder SetUsername(string username) + { + _result.Username = username; + return this; + } + + public CredentialVerificationResultBuilder SetCustomIdentity(string identity) + { + _result.CustomIdentity = identity; + return this; + } + + public CredentialVerificationResultBuilder SetUserMustChangePassword(bool userMustChangePassword) + { + _result.UserMustChangePassword = userMustChangePassword; + return this; + } + + public CredentialVerificationResultBuilder SetPasswordExpirationDate(DateTime dt) + { + _result.PasswordExpirationDate = dt; + return this; + } + + public CredentialVerificationResultBuilder SetReason(string reason) + { + _result.Reason = reason; + return this; + } + + public CredentialVerificationResultBuilder SetUserPrincipalName(string upn) + { + _result.UserPrincipalName = upn; + return this; + } + + + public CredentialVerificationResult Build() + { + return _result; + } + } + } +} \ No newline at end of file diff --git a/Integrations/Ldap/CredentialVerification/CredentialVerifierAdapter.cs b/Integrations/Ldap/CredentialVerification/CredentialVerifierAdapter.cs new file mode 100644 index 0000000..766a300 --- /dev/null +++ b/Integrations/Ldap/CredentialVerification/CredentialVerifierAdapter.cs @@ -0,0 +1,51 @@ +using System.Threading.Tasks; +using MultiFactor.SelfService.Windows.Portal.Services; +using MultiFactor.SelfService.Windows.Portal.Services.Ldap; + +namespace MultiFactor.SelfService.Windows.Portal.Integrations.Ldap.CredentialVerification +{ + public class CredentialVerifierAdapter : ICredentialVerifier + { + private ActiveDirectoryService _activeDirectoryService; + + public CredentialVerifierAdapter(ActiveDirectoryService activeDirectoryService) + { + _activeDirectoryService = activeDirectoryService; + } + + public async Task VerifyCredentialAsync(string username, string password) + { + var adResult = _activeDirectoryService.VerifyCredentialAndMembership(username, password); + var result = BuildVerificationResult(adResult, username); + return result; + } + + public async Task VerifyCredentialOnlyAsync(string username, string password) + { + var adResult = _activeDirectoryService.VerifyCredentialAndMembership(username, password); + } + + public async Task VerifyMembership(string username) + { + var adResult = _activeDirectoryService.VerifyMembership(LdapIdentity.ParseUser(username.Trim())); + var result = BuildVerificationResult(adResult, username); + return result; + } + + private static CredentialVerificationResult BuildVerificationResult(ActiveDirectoryCredentialValidationResult adResult, string username) + { + var builder = CredentialVerificationResult.CreateBuilder(adResult.IsAuthenticated); + builder.SetPhone(adResult.Phone); + builder.SetEmail(adResult.Email); + builder.SetDisplayName(adResult.DisplayName); + builder.SetReason(adResult.Reason); + builder.SetCustomIdentity(adResult.GetIdentity(username)); + builder.SetPasswordExpirationDate(adResult.PasswordExpirationDate ?? System.DateTime.MaxValue); + builder.SetUserMustChangePassword(adResult.UserMustChangePassword); + builder.SetUsername(username); + builder.SetUserPrincipalName(adResult.Upn); + var result = builder.Build(); + return result; + } + } +} \ No newline at end of file diff --git a/Integrations/Ldap/CredentialVerification/ICredentialVerifier.cs b/Integrations/Ldap/CredentialVerification/ICredentialVerifier.cs new file mode 100644 index 0000000..431af5f --- /dev/null +++ b/Integrations/Ldap/CredentialVerification/ICredentialVerifier.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; + +namespace MultiFactor.SelfService.Windows.Portal.Integrations.Ldap.CredentialVerification +{ + + public interface ICredentialVerifier + { + Task VerifyCredentialAsync(string username, string password); + Task VerifyCredentialOnlyAsync(string username, string password); + Task VerifyMembership(string username); + } +} \ No newline at end of file diff --git a/Integrations/Ldap/PasswordChanging/ForgottenPasswordChanger.cs b/Integrations/Ldap/PasswordChanging/ForgottenPasswordChanger.cs new file mode 100644 index 0000000..d8c3485 --- /dev/null +++ b/Integrations/Ldap/PasswordChanging/ForgottenPasswordChanger.cs @@ -0,0 +1,43 @@ +using System.Threading.Tasks; +using Serilog; +using MultiFactor.SelfService.Windows.Portal.Services; + +namespace MultiFactor.SelfService.Windows.Portal.Integrations.Ldap.PasswordChanging +{ + public class ForgottenPasswordChanger + { + private readonly ActiveDirectoryService _activeDirectoryService; + private readonly PasswordPolicyService _passwordPolicyService; + private readonly ILogger _logger; + + public ForgottenPasswordChanger( + ILogger logger, + ActiveDirectoryService activeDirectoryService, + PasswordPolicyService passwordPolicyService) + { + _logger = logger; + _activeDirectoryService = activeDirectoryService; + _passwordPolicyService = passwordPolicyService; + } + + public async Task ChangePassword(string username, string newPassword) + { + var validationResult = _passwordPolicyService.ValidatePassword(newPassword); + if (!validationResult.IsValid) + { + _logger.Warning("Change/reset password for user '{username}' failed: {message:l}", username, validationResult); + return new PasswordChangingResult(false, validationResult.ToString()); + } + + if (!_activeDirectoryService.ResetPassword(username, newPassword, out var errorReason)) + { + _logger.Warning("Change/reset password for user '{username}' failed: {message:l}", + username, errorReason); + return new PasswordChangingResult(false, "AD.PasswordDoesNotMeetRequirements"); + } + + _logger.Information("Password changed/reset for user '{username}'", username); + return new PasswordChangingResult(true, string.Empty); + } + } +} \ No newline at end of file diff --git a/Integrations/Ldap/PasswordChanging/PasswordChangingResult.cs b/Integrations/Ldap/PasswordChanging/PasswordChangingResult.cs new file mode 100644 index 0000000..cff0461 --- /dev/null +++ b/Integrations/Ldap/PasswordChanging/PasswordChangingResult.cs @@ -0,0 +1,15 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.Ldap.PasswordChanging +{ + public class PasswordChangingResult + + { + public bool Success { get; set; } + public string ErrorReason { get; set; } + + public PasswordChangingResult(bool success, string errorReason) + { + Success = success; + ErrorReason = errorReason; + } + } +} diff --git a/Integrations/Ldap/PasswordChanging/UserPasswordChanger.cs b/Integrations/Ldap/PasswordChanging/UserPasswordChanger.cs new file mode 100644 index 0000000..bffa1d1 --- /dev/null +++ b/Integrations/Ldap/PasswordChanging/UserPasswordChanger.cs @@ -0,0 +1,46 @@ +using System.Threading.Tasks; +using Serilog; +using MultiFactor.SelfService.Windows.Portal.Services; + +namespace MultiFactor.SelfService.Windows.Portal.Integrations.Ldap.PasswordChanging +{ + public class UserPasswordChanger + { + private readonly ActiveDirectoryService _activeDirectoryService; + private readonly PasswordPolicyService _passwordPolicyService; + private readonly ILogger _logger; + + public UserPasswordChanger( + ILogger logger, + ActiveDirectoryService activeDirectoryService, + PasswordPolicyService passwordPolicyService) + { + _logger = logger; + _activeDirectoryService = activeDirectoryService; + _passwordPolicyService = passwordPolicyService; + } + + public async Task ChangePassword( + string username, + string currentPassword, + string newPassword) + { + var validationResult = _passwordPolicyService.ValidatePassword(newPassword); + if (!validationResult.IsValid) + { + _logger.Warning("Change/reset password for user '{username}' failed: {message:l}", username, validationResult); + return new PasswordChangingResult(false, validationResult.ToString()); + } + + if (!_activeDirectoryService.ChangeValidPassword(username, currentPassword, newPassword, out var errorReason)) + { + _logger.Warning("Change/reset password for user '{username}' failed: {message:l}", + username, errorReason); + return new PasswordChangingResult(false, "AD.PasswordDoesNotMeetRequirements"); + } + + _logger.Information("Password changed/reset for user '{username}'", username); + return new PasswordChangingResult(true, string.Empty); + } + } +} \ No newline at end of file diff --git a/Integrations/MultiFactorApi/Dto/AccessPage.cs b/Integrations/MultiFactorApi/Dto/AccessPage.cs new file mode 100644 index 0000000..8d31bb3 --- /dev/null +++ b/Integrations/MultiFactorApi/Dto/AccessPage.cs @@ -0,0 +1,10 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Dto +{ + /// + /// Url to mfa access page + /// + public class AccessPageDto + { + public string Url { get; set; } + } +} diff --git a/Integrations/MultiFactorApi/Dto/ApiResponse.cs b/Integrations/MultiFactorApi/Dto/ApiResponse.cs new file mode 100644 index 0000000..15f55c0 --- /dev/null +++ b/Integrations/MultiFactorApi/Dto/ApiResponse.cs @@ -0,0 +1,36 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Dto +{ + /// + /// Generic Api response + /// + public class ApiResponse + { + public bool Success { get; set; } + public string Message { get; set; } + + public ApiResponse(bool success, string message) + { + Success = success; + Message = message; + } + + public override string ToString() + { + return $"success: {Success}, message: {Message}"; + } + } + + /// + /// Api response with data + /// + public class ApiResponse : ApiResponse + { + public TModel Model { get; set; } + + public ApiResponse(TModel model, bool success, string message) + : base(success, message) + { + Model = model; + } + } +} diff --git a/Integrations/MultiFactorApi/Dto/BypassPageDto.cs b/Integrations/MultiFactorApi/Dto/BypassPageDto.cs new file mode 100644 index 0000000..02dd1fc --- /dev/null +++ b/Integrations/MultiFactorApi/Dto/BypassPageDto.cs @@ -0,0 +1,17 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Dto +{ + /// + /// Access token for user within non-mfa group + /// + public class BypassPageDto + { + public string CallbackUrl { get; set; } + public string AccessToken { get; set; } + + public BypassPageDto(string callbackUrl, string accessToken) + { + CallbackUrl = callbackUrl; + AccessToken = accessToken; + } + } +} \ No newline at end of file diff --git a/Integrations/MultiFactorApi/Dto/EnrollmentPageDto.cs b/Integrations/MultiFactorApi/Dto/EnrollmentPageDto.cs new file mode 100644 index 0000000..edc8efe --- /dev/null +++ b/Integrations/MultiFactorApi/Dto/EnrollmentPageDto.cs @@ -0,0 +1,12 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Dto +{ + public class EnrollmentPageDto + { + public string Url { get; set; } + + public EnrollmentPageDto(string url) + { + Url = url; + } + } +} diff --git a/Integrations/MultiFactorApi/Dto/ResetPasswordDto.cs b/Integrations/MultiFactorApi/Dto/ResetPasswordDto.cs new file mode 100644 index 0000000..7c0e94b --- /dev/null +++ b/Integrations/MultiFactorApi/Dto/ResetPasswordDto.cs @@ -0,0 +1,12 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Dto +{ + public class ResetPasswordDto + { + public string Url { get; set; } + + public ResetPasswordDto(string url) + { + Url = url; + } + } +} diff --git a/Integrations/MultiFactorApi/Dto/ShowcaseSettingsDto.cs b/Integrations/MultiFactorApi/Dto/ShowcaseSettingsDto.cs new file mode 100644 index 0000000..b2c4551 --- /dev/null +++ b/Integrations/MultiFactorApi/Dto/ShowcaseSettingsDto.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Dto +{ + public class ShowcaseSettingsDto + { + public bool Enabled { get; set; } + public IEnumerable ShowcaseLinks { get; set; } + } + + public class ShowcaseLinkDto + { + public string ResourceId { get; set; } + public string Url { get; set; } + public string Title { get; set; } + public string Image { get; set; } + public bool OpenInNewTab { get; set; } + } +} \ No newline at end of file diff --git a/Integrations/MultiFactorApi/Dto/UnlockUserDto.cs b/Integrations/MultiFactorApi/Dto/UnlockUserDto.cs new file mode 100644 index 0000000..47d6f73 --- /dev/null +++ b/Integrations/MultiFactorApi/Dto/UnlockUserDto.cs @@ -0,0 +1,12 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Dto +{ + public class UnlockUserDto + { + public string Url { get; set; } + + public UnlockUserDto(string url) + { + Url = url; + } + } +} \ No newline at end of file diff --git a/Integrations/MultiFactorApi/Dto/UserProfileApiDto.cs b/Integrations/MultiFactorApi/Dto/UserProfileApiDto.cs new file mode 100644 index 0000000..50bc3e2 --- /dev/null +++ b/Integrations/MultiFactorApi/Dto/UserProfileApiDto.cs @@ -0,0 +1,39 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Dto +{ + public class UserProfileApiDto + { + public string Id { get; set; } + public string Identity { get; set; } + public string Name { get; set; } + public string Email { get; set; } + public UserProfilePolicyApiDto Policy { get; set; } + + public UserProfileApiDto( + string id, + string identity, + string name, + string email, + UserProfilePolicyApiDto policy) + { + Id = id; + Identity = identity; + Name = name; + Email = email; + Policy = policy; + } + } + + public class UserProfilePolicyApiDto + { + public bool AllResourcesPermitted { get; set; } + public string[] PermittedResources { get; set; } + + public UserProfilePolicyApiDto( + bool allResourcesPermitted, + string[] permittedResources) + { + AllResourcesPermitted = allResourcesPermitted; + PermittedResources = permittedResources; + } + } +} \ No newline at end of file diff --git a/Integrations/MultiFactorApi/Dto/UserProfileAuthenticatorsApiDto.cs b/Integrations/MultiFactorApi/Dto/UserProfileAuthenticatorsApiDto.cs new file mode 100644 index 0000000..bcd9c35 --- /dev/null +++ b/Integrations/MultiFactorApi/Dto/UserProfileAuthenticatorsApiDto.cs @@ -0,0 +1,37 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Dto +{ + public class UserProfileAuthenticatorsApiDto + { + public UserProfileAuthenticatorDto[] TotpAuthenticators { get; set; } + public UserProfileAuthenticatorDto[] TelegramAuthenticators { get; set; } + public UserProfileAuthenticatorDto[] MobileAppAuthenticators { get; set; } + public UserProfileAuthenticatorDto[] PhoneAuthenticators { get; set; } + + public UserProfileAuthenticatorsApiDto( + UserProfileAuthenticatorDto[] totpAuthenticators, + UserProfileAuthenticatorDto[] telegramAuthenticators, + UserProfileAuthenticatorDto[] mobileAppAuthenticators, + UserProfileAuthenticatorDto[] phoneAuthenticators) + { + TotpAuthenticators = totpAuthenticators; + TelegramAuthenticators = telegramAuthenticators; + MobileAppAuthenticators = mobileAppAuthenticators; + PhoneAuthenticators = phoneAuthenticators; + } + } + + /// + /// MFA authenticator + /// + public class UserProfileAuthenticatorDto + { + public string Id { get; set; } + public string Label { get; set; } + + public UserProfileAuthenticatorDto(string id, string label) + { + Id = id; + Label = label; + } + } +} \ No newline at end of file diff --git a/Integrations/MultiFactorApi/Dto/UserProfileAuthenticatorsDto.cs b/Integrations/MultiFactorApi/Dto/UserProfileAuthenticatorsDto.cs new file mode 100644 index 0000000..920bd68 --- /dev/null +++ b/Integrations/MultiFactorApi/Dto/UserProfileAuthenticatorsDto.cs @@ -0,0 +1,22 @@ +using System.Linq; + +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Dto +{ + public class UserAuthenticatorsDto + { + public UserProfileAuthenticatorDto[] TotpAuthenticators { get; set; } + public UserProfileAuthenticatorDto[] TelegramAuthenticators { get; set; } + public UserProfileAuthenticatorDto[] MobileAppAuthenticators { get; set; } + public UserProfileAuthenticatorDto[] PhoneAuthenticators { get; set; } + + public UserProfileAuthenticatorDto[] GetAuthenticators() + { + return Enumerable.Empty() + .Concat(TotpAuthenticators ?? Enumerable.Empty()) + .Concat(TelegramAuthenticators ?? Enumerable.Empty()) + .Concat(MobileAppAuthenticators ?? Enumerable.Empty()) + .Concat(PhoneAuthenticators ?? Enumerable.Empty()) + .ToArray(); + } + } +} \ No newline at end of file diff --git a/Integrations/MultiFactorApi/Dto/UserProfileDto.cs b/Integrations/MultiFactorApi/Dto/UserProfileDto.cs new file mode 100644 index 0000000..3dfa089 --- /dev/null +++ b/Integrations/MultiFactorApi/Dto/UserProfileDto.cs @@ -0,0 +1,29 @@ +using System; + +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Dto +{ + public class UserProfileDto + { + public string Id { get; } + public string Identity { get; } + public string Name { get; set; } + public string Email { get; set; } + public UserProfilePolicyDto Policy { get; set; } + + public int PasswordExpirationDaysLeft { get; set; } + public bool EnablePasswordManagement { get; set; } + public bool EnableExchangeActiveSyncDevicesManagement { get; set; } + + public UserProfileDto(string id, string identity) + { + Id = id ?? throw new ArgumentNullException(nameof(id)); + Identity = identity ?? throw new ArgumentNullException(nameof(identity)); + } + } + + public class UserProfilePolicyDto + { + public bool AllResourcesPermitted { get; set; } + public string[] PermittedResources { get; set; } + } +} \ No newline at end of file diff --git a/Integrations/MultiFactorApi/Exceptions/UnsuccessfulResponseException.cs b/Integrations/MultiFactorApi/Exceptions/UnsuccessfulResponseException.cs new file mode 100644 index 0000000..f9f968a --- /dev/null +++ b/Integrations/MultiFactorApi/Exceptions/UnsuccessfulResponseException.cs @@ -0,0 +1,14 @@ +using System; + +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Exceptions +{ + /// + /// Indicates that the response payload property "Success" is "false"; + /// + internal class UnsuccessfulResponseException : Exception + { + public UnsuccessfulResponseException() { } + public UnsuccessfulResponseException(string message) : base(message) { } + public UnsuccessfulResponseException(string message, Exception inner) : base(message, inner) { } + } +} diff --git a/Integrations/MultiFactorApi/IMultifactorApi.cs b/Integrations/MultiFactorApi/IMultifactorApi.cs new file mode 100644 index 0000000..1b7392d --- /dev/null +++ b/Integrations/MultiFactorApi/IMultifactorApi.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using MultiFactor.SelfService.Windows.Portal.Settings; +using MultiFactor.SelfService.Windows.Portal.Dto; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Dto; + +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi +{ + public interface IMultiFactorApi + { + Task PingAsync(); + Task GetShowcaseSettingsAsync(); + Task GetShowcaseLogoAsync(string fileName); + Task CreateSamlBypassRequestAsync(UserProfileDto user, string samlSessionId); + Task CreateOidcBypassRequestAsync(UserProfileDto user, string oidcSessionId); + Task StartResetPassword(string twoFaIdentity, string ldapIdentity, string callbackUrl); + Task StartUnlockingUser(string identity, string callbackUrl); + + Task CreateAccessRequestAsync(string username, string displayName, string email, + string phone, string postbackUrl, IReadOnlyDictionary claims); + Task GetUserProfileAsync(); + Task GetUserAuthenticatorsAsync(string identity); + Task GetScopeSupportInfo(); + Task> CreateEnrollmentRequest(); + } +} diff --git a/Integrations/MultiFactorApi/MultifactorApi.cs b/Integrations/MultiFactorApi/MultifactorApi.cs new file mode 100644 index 0000000..ba501b3 --- /dev/null +++ b/Integrations/MultiFactorApi/MultifactorApi.cs @@ -0,0 +1,304 @@ +using MultiFactor.SelfService.Windows.Portal.Integrations.Google.ReCaptcha; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Dto; +using MultiFactor.SelfService.Windows.Portal.Settings; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using System.Threading; +using System; +using MultiFactor.SelfService.Windows.Portal.Dto; +using System.Linq; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Exceptions; +using MultiFactor.SelfService.Windows.Portal.Core.Http; +using static MultiFactor.SelfService.Windows.Portal.Constants.Configuration; + +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi +{ + internal class MultiFactorApi : IMultiFactorApi + { + private readonly HttpClientAdapter _clientAdapter; + private readonly HttpClientTokenProvider _tokenProvider; + private readonly Configuration _settings; + + public MultiFactorApi(MultifactorHttpClientAdapterFactory clientFactory, HttpClientTokenProvider tokenProvider, Configuration settings) + { + _clientAdapter = clientFactory.CreateClientAdapter(); + _tokenProvider = tokenProvider; + _settings = settings; + } + + public Task PingAsync() + { + return ExecuteAsync(() => _clientAdapter.GetAsync("ping")); + } + + public async Task GetShowcaseSettingsAsync() + { + var response = await _clientAdapter.GetAsync("self-service/settings/showcase", GetBasicAuthHeaders()); + return new ShowcaseSettings() + { + Enabled = response.Enabled, + Links = response.ShowcaseLinks + .Select(x => new ShowcaseLink() + { + ResourceId = x.ResourceId, + Url = x.Url, + Title = x.Title, + OpenInNewTab = x.OpenInNewTab, + Image = x.Image, + }) + .ToArray(), + }; + } + + public async Task GetShowcaseLogoAsync(string fileName) + { + var response = await _clientAdapter.GetByteArrayAsync( + $"self-service/settings/showcase/logo/{fileName}", + GetBasicAuthHeaders()); + return response; + } + + public Task CreateSamlBypassRequestAsync(UserProfileDto user, string samlSessionId) + { + var payload = new + { + Identity = user.Identity, + SamlSessionId = samlSessionId, + Claims = new Dictionary() + { + { "name", user.Name }, + { "email", user.Email } + } + }; + + return ExecuteAsync(() => _clientAdapter.PostAsync>("access/bypass/saml", payload, GetBasicAuthHeaders())); + } + + public Task CreateOidcBypassRequestAsync(UserProfileDto user, string oidcSessionId) + { + var payload = new + { + Identity = user.Identity, + OidcSessionId = oidcSessionId, + Claims = new Dictionary() + { + { "name", user.Name }, + { "email", user.Email } + } + }; + + return ExecuteAsync(() => _clientAdapter.PostAsync>("access/bypass/oidc", payload, GetBasicAuthHeaders())); + } + + /// + /// Sends a request to create an enrollment request for the self-service portal. + /// + /// + /// A task that represents the asynchronous operation, containing an object. + /// + public Task> CreateEnrollmentRequest() + { + return _clientAdapter.PostAsync>( + "/self-service/create-enrollment-request", + data: null, + GetBearerAuthHeaders()); + } + + /// + /// Returns user profile. + /// + /// + public async Task GetUserProfileAsync() + { + var response = await ExecuteAsync(() => _clientAdapter.GetAsync>("self-service", GetBearerAuthHeaders())); + return new UserProfileDto(response.Id, response.Identity) + { + Name = response.Name, + Email = response.Email, + EnablePasswordManagement = _settings.EnablePasswordManagement, + Policy = new UserProfilePolicyDto() + { + AllResourcesPermitted = response.Policy?.AllResourcesPermitted ?? false, + PermittedResources = response.Policy?.PermittedResources ?? new string[0], + }, + EnableExchangeActiveSyncDevicesManagement = _settings.EnableExchangeActiveSyncDevicesManagement, + }; + } + + /// + /// Returns user profile. + /// + /// + public async Task GetUserAuthenticatorsAsync(string identity) + { + var payload = new + { + Identity = identity + }; + + var response = await ExecuteAsync(() => _clientAdapter.PostAsync>("self-service/user-authenticators", payload, GetBasicAuthHeaders())); + return new UserAuthenticatorsDto() + { + TotpAuthenticators = response.TotpAuthenticators, + TelegramAuthenticators = response.TelegramAuthenticators, + MobileAppAuthenticators = response.MobileAppAuthenticators, + PhoneAuthenticators = response.PhoneAuthenticators + }; + } + + /// + /// Returns new access token. + /// + /// + /// + /// + /// + /// + /// + /// + /// + public Task CreateAccessRequestAsync(string username, string displayName, string email, + string phone, string postbackUrl, IReadOnlyDictionary claims) + { + if (username == null) + { + throw new ArgumentNullException(nameof(username)); + } + if (claims == null) + { + throw new ArgumentNullException(nameof(claims)); + } + + var payload = new + { + Identity = string.IsNullOrEmpty(_settings.NetBiosName) + ? username + : $"{_settings.NetBiosName}\\{username}", + Callback = new + { + Action = postbackUrl, + Target = "_self" + }, + Name = displayName, + Email = email, + Phone = phone, + Claims = claims, + Language = Thread.CurrentThread.CurrentCulture?.TwoLetterISOLanguageName, + GroupPolicyPreset = new + { + SignUpGroups = _settings.SignUpGroups + } + }; + + return ExecuteAsync(() => _clientAdapter.PostAsync>("access/requests", payload, GetBasicAuthHeaders())); + } + + public Task StartResetPassword(string twoFaIdentity, string ldapIdentity, string callbackUrl) + { + if (twoFaIdentity == null) + { + throw new ArgumentNullException(nameof(twoFaIdentity)); + } + if (callbackUrl == null) + { + throw new ArgumentNullException(nameof(callbackUrl)); + } + + // add netbios domain name to login if specified + + var payload = new + { + Identity = twoFaIdentity, + CallbackUrl = callbackUrl, + Claims = new Dictionary + { + { MultiFactorClaims.ResetPassword, "true" }, + { MultiFactorClaims.RawUserName, ldapIdentity } + } + }; + + return ExecuteAsync(() => _clientAdapter.PostAsync>("self-service/start-reset-password", payload, GetBasicAuthHeaders())); + } + + public Task StartUnlockingUser(string identity, string callbackUrl) + { + if (identity == null) + { + throw new ArgumentNullException(nameof(identity)); + } + if (callbackUrl == null) + { + throw new ArgumentNullException(nameof(callbackUrl)); + } + + var payload = new + { + Identity = identity, + CallbackUrl = callbackUrl, + Claims = new Dictionary + { + { MultiFactorClaims.UnlockUser, "true"} + } + }; + + return ExecuteAsync(() => _clientAdapter.PostAsync>("self-service/start-unlock-user", payload, GetBasicAuthHeaders())); + } + + public Task GetScopeSupportInfo() + { + return ExecuteAsync(() => _clientAdapter.GetAsync>("/self-service/support-info", GetBasicAuthHeaders())); + } + + private static async Task ExecuteAsync(Func> method) + { + var response = await method(); + + if (response == null) + { + throw new Exception("Response is null"); + } + if (!response.Success) + { + throw new UnsuccessfulResponseException(response.Message); + } + } + + private static async Task ExecuteAsync(Func>> method) + { + var response = await method(); + + if (response == null) + { + throw new Exception("Response is null"); + } + if (!response.Success) + { + throw new UnsuccessfulResponseException(response.Message); + } + if (response.Model == null) + { + throw new Exception("Response payload is null"); + } + + return response.Model; + } + + private IReadOnlyDictionary GetBearerAuthHeaders() + { + return new Dictionary + { + { "Authorization", $"Bearer {_tokenProvider.GetToken()}" } + }; + } + + private IReadOnlyDictionary GetBasicAuthHeaders() + { + var auth = Convert.ToBase64String(Encoding.ASCII.GetBytes(_settings.MultiFactorApiKey + ":" + _settings.MultiFactorApiSecret)); + return new Dictionary + { + { "Authorization", $"Basic {auth}" } + }; + } + } +} diff --git a/Integrations/MultiFactorApi/MultifactorHttpClientAdapterFactory.cs b/Integrations/MultiFactorApi/MultifactorHttpClientAdapterFactory.cs new file mode 100644 index 0000000..a06265a --- /dev/null +++ b/Integrations/MultiFactorApi/MultifactorHttpClientAdapterFactory.cs @@ -0,0 +1,27 @@ +using Serilog; +using MultiFactor.SelfService.Windows.Portal.Abstractions.Http; +using MultiFactor.SelfService.Windows.Portal.Integrations.Google.ReCaptcha; +using System.Net.Http; +using System; + +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi +{ + public class MultifactorHttpClientAdapterFactory + { + private readonly HttpClient _client; + private readonly IJsonDataSerializer _jsonDataSerializer; + private readonly ILogger _logger; + + public MultifactorHttpClientAdapterFactory(IHttpClientFactory httpClientFactory, IJsonDataSerializer jsonDataSerializer, ILogger logger) + { + _client = httpClientFactory.CreateClient(Constants.HttpClients.MultifactorApi); + _jsonDataSerializer = jsonDataSerializer ?? throw new ArgumentNullException(nameof(jsonDataSerializer)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public HttpClientAdapter CreateClientAdapter() + { + return new HttpClientAdapter(_client, _jsonDataSerializer); + } + } +} \ No newline at end of file diff --git a/Integrations/MultifactorIdpApi/Dto/BypassOidcRequestDto.cs b/Integrations/MultifactorIdpApi/Dto/BypassOidcRequestDto.cs new file mode 100644 index 0000000..142560a --- /dev/null +++ b/Integrations/MultifactorIdpApi/Dto/BypassOidcRequestDto.cs @@ -0,0 +1,7 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto +{ + public class BypassOidcRequestDto + { + public string OidcSessionId { get; set; } + } +} \ No newline at end of file diff --git a/Integrations/MultifactorIdpApi/Dto/BypassOidcResponseDto.cs b/Integrations/MultifactorIdpApi/Dto/BypassOidcResponseDto.cs new file mode 100644 index 0000000..bdefdfa --- /dev/null +++ b/Integrations/MultifactorIdpApi/Dto/BypassOidcResponseDto.cs @@ -0,0 +1,7 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto +{ + public class BypassOidcResponseDto + { + public string RedirectUrl { get; set; } + } +} diff --git a/Integrations/MultifactorIdpApi/Dto/BypassSamlRequestDto.cs b/Integrations/MultifactorIdpApi/Dto/BypassSamlRequestDto.cs new file mode 100644 index 0000000..a10911a --- /dev/null +++ b/Integrations/MultifactorIdpApi/Dto/BypassSamlRequestDto.cs @@ -0,0 +1,7 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto +{ + public class BypassSamlRequestDto + { + public string SamlSessionId { get; set; } + } +} \ No newline at end of file diff --git a/Integrations/MultifactorIdpApi/Dto/BypassSamlResponseDto.cs b/Integrations/MultifactorIdpApi/Dto/BypassSamlResponseDto.cs new file mode 100644 index 0000000..ab6a355 --- /dev/null +++ b/Integrations/MultifactorIdpApi/Dto/BypassSamlResponseDto.cs @@ -0,0 +1,7 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto +{ + public class BypassSamlResponseDto + { + public string SamlResponseHtml { get; set; } + } +} \ No newline at end of file diff --git a/Integrations/MultifactorIdpApi/Dto/IdentityRequestDto.cs b/Integrations/MultifactorIdpApi/Dto/IdentityRequestDto.cs new file mode 100644 index 0000000..6b52dda --- /dev/null +++ b/Integrations/MultifactorIdpApi/Dto/IdentityRequestDto.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; + +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto +{ + + /// + /// Identity request DTO with optionally pre-verified membership data. + /// Used for pre-authentication flow (MFA first, then password). + /// + public class IdentityRequestDto + { + public string Username { get; set; } + public VerifiedMembershipDto VerifiedMembership { get; set; } + public string SamlSessionId { get; set; } + public string OidcSessionId { get; set; } + public Dictionary AdditionalClaims { get; set; } + public string LoginCompletedCallbackUrl { get; set; } + public IdentitySspSettingsDto Settings { get; set; } + + //public IdentityRequestDto( + // string username, + // string loginCompletedCallbackUrl, + // IdentitySspSettingsDto settings) + //{ + // Username = username; + // LoginCompletedCallbackUrl = loginCompletedCallbackUrl; + // Settings = settings; + //} + } + + /// + /// Pre-verified membership from SSP's local AD verification (without password). + /// Used when NeedPrebindInfo is true. + /// + public class VerifiedMembershipDto + { + public bool IsBypass { get; set; } + public string DisplayName { get; set; } + public string Email { get; set; } + public string Phone { get; set; } + public string UserPrincipalName { get; set; } + public string CustomIdentity { get; set; } + + //public VerifiedMembershipDto(bool isBypass) + //{ + // IsBypass = isBypass; + //} + } + + public class IdentitySspSettingsDto + { + public bool PreAuthenticationMethod { get; set; } + public bool RequiresUserPrincipalName { get; set; } + public bool NeedPrebindInfo { get; set; } + public bool UseUpnAsIdentity { get; set; } + + public string PrivacyMode { get; set; } = "None"; + public string NetBiosName { get; set; } + public string SignUpGroups { get; set; } + } +} \ No newline at end of file diff --git a/Integrations/MultifactorIdpApi/Dto/IdentityResponseDto.cs b/Integrations/MultifactorIdpApi/Dto/IdentityResponseDto.cs new file mode 100644 index 0000000..633ffb0 --- /dev/null +++ b/Integrations/MultifactorIdpApi/Dto/IdentityResponseDto.cs @@ -0,0 +1,28 @@ +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Enums; + +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto +{ + + /// + /// Response DTO from Identity endpoint. + /// + public sealed class IdentityResponseDto + { + public bool Success { get; set; } + public IdentityAction Action { get; set; } + public string RedirectUrl { get; set; } + public string Username { get; set; } + public string ErrorMessage { get; set; } + + public static IdentityResponseDto Failed(string message) + { + return new IdentityResponseDto + { + Success = false, + Action = IdentityAction.Error, + ErrorMessage = message + }; + } + } +} + diff --git a/Integrations/MultifactorIdpApi/Dto/IdpApiResponse.cs b/Integrations/MultifactorIdpApi/Dto/IdpApiResponse.cs new file mode 100644 index 0000000..8bf9f25 --- /dev/null +++ b/Integrations/MultifactorIdpApi/Dto/IdpApiResponse.cs @@ -0,0 +1,9 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto +{ + public class IdpApiResponse + { + public bool Success { get; set; } + public string Message { get; set; } + public T Data { get; set; } + } +} \ No newline at end of file diff --git a/Integrations/MultifactorIdpApi/Dto/LoginCompletedRequestDto.cs b/Integrations/MultifactorIdpApi/Dto/LoginCompletedRequestDto.cs new file mode 100644 index 0000000..365427f --- /dev/null +++ b/Integrations/MultifactorIdpApi/Dto/LoginCompletedRequestDto.cs @@ -0,0 +1,7 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto +{ + public class LoginCompletedRequestDto + { + public string AccessToken { get; set; } + } +} \ No newline at end of file diff --git a/Integrations/MultifactorIdpApi/Dto/LoginCompletedResponseDto.cs b/Integrations/MultifactorIdpApi/Dto/LoginCompletedResponseDto.cs new file mode 100644 index 0000000..80207f9 --- /dev/null +++ b/Integrations/MultifactorIdpApi/Dto/LoginCompletedResponseDto.cs @@ -0,0 +1,27 @@ +using System; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Enums; + +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto +{ + public sealed class LoginCompletedResponseDto + { + public bool Success { get; set; } + public LoginCompletedAction Action { get; set; } + public string RedirectUrl { get; set; } + public string SessionId { get; set; } + public bool MustChangePassword { get; set; } + public string ErrorMessage { get; set; } + public DateTime TokenExpirationDate { get; set; } + public string Identity { get; set; } + public string SamlSessionId { get; set; } + public string OidcSessionId { get; set; } + public string RawUserName { get; set; } + + public static LoginCompletedResponseDto Failed(string message) => new LoginCompletedResponseDto() + { + Success = false, + Action = LoginCompletedAction.Error, + ErrorMessage = message + }; + } +} \ No newline at end of file diff --git a/Integrations/MultifactorIdpApi/Dto/LoginRequestDto.cs b/Integrations/MultifactorIdpApi/Dto/LoginRequestDto.cs new file mode 100644 index 0000000..803b352 --- /dev/null +++ b/Integrations/MultifactorIdpApi/Dto/LoginRequestDto.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; + +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto +{ + public class LoginRequestDto + { + // Credential verification results (already verified by SSP) + public VerifiedCredentialsDto VerifiedCredentials { get; set; } + public string SamlSessionId { get; set; } + public string OidcSessionId { get; set; } + public Dictionary AdditionalClaims { get; set; } + public string LoginCompletedCallbackUrl { get; set; } + public SspSettingsDto Settings { get; set; } + } + + public class VerifiedCredentialsDto + { + public bool IsAuthenticated { get; set; } + public bool IsBypass { get; set; } + public bool UserMustChangePassword { get; set; } + public DateTime? PasswordExpirationDate { get; set; } + public string DisplayName { get; set; } + public string Email { get; set; } + public string Phone { get; set; } + public string Username { get; set; } + public string UserPrincipalName { get; set; } + public string CustomIdentity { get; set; } + public string Reason { get; set; } + } +} \ No newline at end of file diff --git a/Integrations/MultifactorIdpApi/Dto/LoginResponseDto.cs b/Integrations/MultifactorIdpApi/Dto/LoginResponseDto.cs new file mode 100644 index 0000000..288eaf4 --- /dev/null +++ b/Integrations/MultifactorIdpApi/Dto/LoginResponseDto.cs @@ -0,0 +1,25 @@ +using System; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Enums; + +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto +{ + public sealed class LoginResponseDto + { + public bool Success { get; set; } + public LoginAction Action { get; set; } + public string RedirectUrl { get; set; } + public string SessionId { get; set; } + public string AccessToken { get; set; } + public string ErrorMessage { get; set; } + public bool MustChangePassword { get; set; } + public DateTime PasswordExpirationDate { get; set; } + public string Username { get; set; } + + public static LoginResponseDto Failed(string message) => new LoginResponseDto() + { + Success = false, + Action = LoginAction.Error, + ErrorMessage = message + }; + } +} \ No newline at end of file diff --git a/Integrations/MultifactorIdpApi/Dto/LogoutRequestDto.cs b/Integrations/MultifactorIdpApi/Dto/LogoutRequestDto.cs new file mode 100644 index 0000000..dd80a77 --- /dev/null +++ b/Integrations/MultifactorIdpApi/Dto/LogoutRequestDto.cs @@ -0,0 +1,7 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto +{ + public class LogoutRequestDto + { + public string Reason { get; set; } + } +} \ No newline at end of file diff --git a/Integrations/MultifactorIdpApi/Dto/LogoutResponseDto.cs b/Integrations/MultifactorIdpApi/Dto/LogoutResponseDto.cs new file mode 100644 index 0000000..f25fa35 --- /dev/null +++ b/Integrations/MultifactorIdpApi/Dto/LogoutResponseDto.cs @@ -0,0 +1,14 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto +{ + public sealed class LogoutResponseDto + { + public bool Success { get; set; } + public string ErrorMessage { get; set; } + + public static LogoutResponseDto Failed(string message) => new LogoutResponseDto() + { + Success = false, + ErrorMessage = message + }; + } +} \ No newline at end of file diff --git a/Integrations/MultifactorIdpApi/Dto/SsoMasterSessionDto.cs b/Integrations/MultifactorIdpApi/Dto/SsoMasterSessionDto.cs new file mode 100644 index 0000000..05eb7ec --- /dev/null +++ b/Integrations/MultifactorIdpApi/Dto/SsoMasterSessionDto.cs @@ -0,0 +1,12 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto +{ + public class SsoMasterSessionDto + { + public string MasterSessionId { get; } + + public SsoMasterSessionDto(string masterSessionId) + { + MasterSessionId = masterSessionId; + } + } +} diff --git a/Integrations/MultifactorIdpApi/Dto/SspSettingsDto.cs b/Integrations/MultifactorIdpApi/Dto/SspSettingsDto.cs new file mode 100644 index 0000000..36a358c --- /dev/null +++ b/Integrations/MultifactorIdpApi/Dto/SspSettingsDto.cs @@ -0,0 +1,13 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto +{ + public class SspSettingsDto + { + public bool PreAuthenticationMethod { get; set; } + public bool RequiresUserPrincipalName { get; set; } + public bool PasswordManagementEnabled { get; set; } + public bool NeedPrebindInfo { get; set; } + public string PrivacyMode { get; set; } = "None"; + public string NetBiosName { get; set; } + public string SignUpGroups { get; set; } + } +} \ No newline at end of file diff --git a/Integrations/MultifactorIdpApi/Dto/UserProfileIdpDto.cs b/Integrations/MultifactorIdpApi/Dto/UserProfileIdpDto.cs new file mode 100644 index 0000000..7971c96 --- /dev/null +++ b/Integrations/MultifactorIdpApi/Dto/UserProfileIdpDto.cs @@ -0,0 +1,18 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto +{ + public class UserProfileIdpDto + { + public string Id { get; } + public string Identity { get; } + public string Name { get; } + public string Email { get; } + + public UserProfileIdpDto(string id, string identity, string name, string email) + { + Id = id; + Identity = identity; + Name = name; + Email = email; + } + } +} \ No newline at end of file diff --git a/Integrations/MultifactorIdpApi/Enums/IdentityAction.cs b/Integrations/MultifactorIdpApi/Enums/IdentityAction.cs new file mode 100644 index 0000000..e450270 --- /dev/null +++ b/Integrations/MultifactorIdpApi/Enums/IdentityAction.cs @@ -0,0 +1,10 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Enums +{ + public enum IdentityAction + { + Error, + MfaRequired, + ShowAuthn, + AccessDenied + } +} diff --git a/Integrations/MultifactorIdpApi/Enums/LoginAction.cs b/Integrations/MultifactorIdpApi/Enums/LoginAction.cs new file mode 100644 index 0000000..491ccce --- /dev/null +++ b/Integrations/MultifactorIdpApi/Enums/LoginAction.cs @@ -0,0 +1,13 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Enums +{ + public enum LoginAction + { + Error, + Authenticated, + MfaRequired, + BypassSaml, + BypassOidc, + ChangePassword, + AccessDenied + } +} \ No newline at end of file diff --git a/Integrations/MultifactorIdpApi/Enums/LoginCompletedAction.cs b/Integrations/MultifactorIdpApi/Enums/LoginCompletedAction.cs new file mode 100644 index 0000000..0d82cad --- /dev/null +++ b/Integrations/MultifactorIdpApi/Enums/LoginCompletedAction.cs @@ -0,0 +1,11 @@ +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Enums +{ + public enum LoginCompletedAction + { + Error, + Authenticated, + BypassSaml, + BypassOidc, + ChangePassword + } +} \ No newline at end of file diff --git a/Integrations/MultifactorIdpApi/IMultifactorIdpApi.cs b/Integrations/MultifactorIdpApi/IMultifactorIdpApi.cs new file mode 100644 index 0000000..02b7c99 --- /dev/null +++ b/Integrations/MultifactorIdpApi/IMultifactorIdpApi.cs @@ -0,0 +1,27 @@ +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Dto; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi +{ + public interface IMultifactorIdpApi + { + Task LoginAsync(LoginRequestDto request, Dictionary headers); + Task IdentityAsync(IdentityRequestDto request, Dictionary headers); + Task LoginCompletedAsync(LoginCompletedRequestDto request, Dictionary headers); + Task LogoutAsync(LogoutRequestDto request, Dictionary headers); + + Task GetSsoMasterSession(); + + Task AddSamlToSsoMasterSession(string samlSessionId); + Task AddOidcToSsoMasterSession(string oidcSessionId); + + Task LogoutSsoMasterSession(); + + Task BypassSamlAsync(BypassSamlRequestDto request, Dictionary headers); + Task BypassOidcAsync(BypassOidcRequestDto request, Dictionary headers); + + Task GetUserProfileAsync(); + } +} \ No newline at end of file diff --git a/Integrations/MultifactorIdpApi/MultifactorIdpApi.cs b/Integrations/MultifactorIdpApi/MultifactorIdpApi.cs new file mode 100644 index 0000000..fe524c1 --- /dev/null +++ b/Integrations/MultifactorIdpApi/MultifactorIdpApi.cs @@ -0,0 +1,343 @@ +using MultiFactor.SelfService.Windows.Portal.Core.Http; +using MultiFactor.SelfService.Windows.Portal.Integrations.Google.ReCaptcha; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Dto; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Exceptions; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using System; +using System.Linq; +using static MultiFactor.SelfService.Windows.Portal.Constants.Configuration; + +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi +{ + public class MultifactorIdpApi : IMultifactorIdpApi + { + private readonly HttpClientAdapter _clientAdapter; + private readonly HttpClientTokenProvider _tokenProvider; + private readonly Configuration _settings; + + public MultifactorIdpApi(MultifactorIdpHttpClientAdapterFactory clientFactory, HttpClientTokenProvider tokenProvider, Configuration settings) + { + _clientAdapter = clientFactory.CreateClientAdapter(); + + _tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + } + + public async Task LoginAsync(LoginRequestDto request, Dictionary headers) + { + try + { + var auth = GetBasicAuthHeaders(); + if (!headers.Keys.Contains(auth.Keys.FirstOrDefault())) + { + headers.Add(auth.Keys.FirstOrDefault(), auth.Values.FirstOrDefault()); + } + + var response = await _clientAdapter.PostAsync>( + "api/v1/login", + request, + headers); + + if (response?.Data != null) + { + return response.Data; + } + + return LoginResponseDto.Failed(response?.Message ?? "Login request failed"); + } + catch (Exception ex) + { + return LoginResponseDto.Failed(ex.Message); + } + } + + /// + /// Identity verification for pre-authentication flow (MFA first, then password). + /// + public async Task IdentityAsync(IdentityRequestDto request, Dictionary headers) + { + try + { + var auth = GetBasicAuthHeaders(); + if (!headers.Keys.Contains(auth.Keys.FirstOrDefault())) + { + headers.Add(auth.Keys.FirstOrDefault(), auth.Values.FirstOrDefault()); + } + + var response = await _clientAdapter.PostAsync>( + "api/v1/identity", + request, + headers); + + if (response?.Data != null) + { + return response.Data; + } + + return IdentityResponseDto.Failed(response?.Message ?? "Identity request failed"); + } + catch (Exception ex) + { + return IdentityResponseDto.Failed(ex.Message); + } + } + + /// + /// Completes login after MFA verification. + /// + public async Task LoginCompletedAsync(LoginCompletedRequestDto request, Dictionary headers) + { + try + { + var auth = GetBasicAuthHeaders(); + if (!headers.Keys.Contains(auth.Keys.FirstOrDefault())) + { + headers.Add(auth.Keys.FirstOrDefault(), auth.Values.FirstOrDefault()); + } + + var formData = new[] + { + new KeyValuePair("accessToken", request.AccessToken) + }; + + var response = await _clientAdapter.PostFormAsync>( + "api/v1/login-completed", + formData, + headers, + deserializeWhenNonSuccessStatus: true); + + if (response?.Data != null) + { + return response.Data; + } + + return LoginCompletedResponseDto.Failed(response?.Message ?? "Login completed request failed"); + } + catch (Exception ex) + { + return LoginCompletedResponseDto.Failed(ex.Message); + } + } + + /// + /// Creates new SSO master session. + /// + public async Task CreateSsoMasterSession(string userIdentity, string requestId) + { + var payload = new + { + UserIdentity = userIdentity, + RequestId = requestId, + }; + + var response = await ExecuteAsync(() => _clientAdapter.PostAsync>( + "sso-master-session/create", + payload, + GetBasicAuthHeaders())); + + return new SsoMasterSessionDto(response.MasterSessionId); + } + + /// + /// Returns existing SSO master session. + /// + public async Task GetSsoMasterSession() + { + var response = await ExecuteAsync(() => _clientAdapter.GetAsync>("sso-master-session", GetBearerAuthHeaders())); + return new SsoMasterSessionDto(response.MasterSessionId); + } + + /// + /// Adds SAML session to SSO master session. + /// + /// + public async Task AddSamlToSsoMasterSession(string samlSessionId) + { + var payload = new + { + ChildSessionId = samlSessionId, + SessionType = SsoMasterSessionTypes.SamlSessionType + }; + + return await ExecuteAsync(() => _clientAdapter.PostAsync>( + "sso-master-session/add-child-session", + payload, + GetBearerAuthHeaders())); + } + + /// + /// Adds OIDC session to SSO master session. + /// + /// + public async Task AddOidcToSsoMasterSession(string oidcSessionId) + { + var payload = new + { + ChildSessionId = oidcSessionId, + SessionType = SsoMasterSessionTypes.OidcSessionType + }; + + return await ExecuteAsync(() => _clientAdapter.PostAsync>( + "sso-master-session/add-child-session", + payload, + GetBearerAuthHeaders())); + } + + /// + /// Logout from SSO master session (legacy method, kept for backward compatibility). + /// + public Task LogoutSsoMasterSession() + { + return ExecuteAsync(() => _clientAdapter.PostAsync( + "sso-master-session/logout", + data: null, + GetBearerAuthHeaders())); + } + + /// + /// Logout via new API endpoint. + /// + public async Task LogoutAsync(LogoutRequestDto request, Dictionary headers) + { + try + { + var auth = GetBearerAuthHeaders(); + if (!headers.Keys.Contains(auth.Keys.FirstOrDefault())) + { + headers.Add(auth.Keys.FirstOrDefault(), auth.Values.FirstOrDefault()); + } + + var response = await _clientAdapter.PostAsync>( + "api/v1/logout", + request, + headers); + + if (response?.Data != null) + { + return response.Data; + } + + return LogoutResponseDto.Failed(response?.Message ?? "Logout request failed"); + } + catch (Exception ex) + { + return LogoutResponseDto.Failed(ex.Message); + } + } + + /// + /// Creates SAML bypass via IdP. + /// + public async Task BypassSamlAsync(BypassSamlRequestDto request, Dictionary headers) + { + var auth = GetBearerAuthHeaders(); + if (!headers.Keys.Contains(auth.Keys.FirstOrDefault())) + { + headers.Add(auth.Keys.FirstOrDefault(), auth.Values.FirstOrDefault()); + } + + var response = await _clientAdapter.PostAsync>( + "api/v1/saml/bypass", + request, + headers); + + return response?.Data ?? new BypassSamlResponseDto(); + } + + public async Task BypassOidcAsync(BypassOidcRequestDto request, Dictionary headers) + { + var auth = GetBearerAuthHeaders(); + if (!headers.Keys.Contains(auth.Keys.FirstOrDefault())) + { + headers.Add(auth.Keys.FirstOrDefault(), auth.Values.FirstOrDefault()); + } + + var response = await _clientAdapter.PostAsync>( + "api/v1/oidc/bypass", + request, + headers); + + return response?.Data ?? new BypassOidcResponseDto(); + } + + public async Task GetUserProfileAsync() + { + return await ExecuteAsync(() => + _clientAdapter.GetAsync>("api/v1/users/load-profile", GetBearerAuthHeaders())); + } + + private static async Task ExecuteAsync(Func>> method) + { + var response = await method(); + + if (response == null) + { + throw new Exception("Response is null"); + } + if (!response.Success) + { + throw new UnsuccessfulResponseException(response.Message); + } + if (response.Data == null) + { + throw new Exception("Response payload is null"); + } + + return response.Data; + } + + private static async Task ExecuteAsync(Func> method) + { + var response = await method(); + + if (response == null) + { + throw new Exception("Response is null"); + } + if (!response.Success) + { + throw new UnsuccessfulResponseException(response.Message); + } + } + + private static async Task ExecuteAsync(Func>> method) + { + var response = await method(); + + if (response == null) + { + throw new Exception("Response is null"); + } + if (!response.Success) + { + throw new UnsuccessfulResponseException(response.Message); + } + if (response.Model == null) + { + throw new Exception("Response payload is null"); + } + + return response.Model; + } + + private IReadOnlyDictionary GetBearerAuthHeaders() + { + return new Dictionary + { + { "Authorization", $"Bearer {_tokenProvider.GetToken()}" } + }; + } + + private IReadOnlyDictionary GetBasicAuthHeaders() + { + var auth = Convert.ToBase64String(Encoding.ASCII.GetBytes(_settings.MultiFactorApiKey + ":" + _settings.MultiFactorApiSecret)); + return new Dictionary + { + { "Authorization", $"Basic {auth}" } + }; + } + } +} \ No newline at end of file diff --git a/Integrations/MultifactorIdpApi/MultifactorIdpHttpClientAdapterFactory.cs b/Integrations/MultifactorIdpApi/MultifactorIdpHttpClientAdapterFactory.cs new file mode 100644 index 0000000..154c064 --- /dev/null +++ b/Integrations/MultifactorIdpApi/MultifactorIdpHttpClientAdapterFactory.cs @@ -0,0 +1,27 @@ +using Serilog; +using MultiFactor.SelfService.Windows.Portal.Abstractions.Http; +using MultiFactor.SelfService.Windows.Portal.Integrations.Google.ReCaptcha; +using System.Net.Http; +using System; + +namespace MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi +{ + public class MultifactorIdpHttpClientAdapterFactory + { + private readonly HttpClient _client; + private readonly IJsonDataSerializer _jsonDataSerializer; + private readonly ILogger _logger; + + public MultifactorIdpHttpClientAdapterFactory(IHttpClientFactory httpClientFactory, IJsonDataSerializer jsonDataSerializer, ILogger logger) + { + _client = httpClientFactory.CreateClient(Constants.HttpClients.MultifactorIdpApi); + _jsonDataSerializer = jsonDataSerializer ?? throw new ArgumentNullException(nameof(jsonDataSerializer)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public HttpClientAdapter CreateClientAdapter() + { + return new HttpClientAdapter(_client, _jsonDataSerializer); + } + } +} \ No newline at end of file diff --git a/ModelBinding/Binders/MultiFactorClaimsDtoBinder.cs b/ModelBinding/Binders/MultiFactorClaimsDtoBinder.cs new file mode 100644 index 0000000..e240677 --- /dev/null +++ b/ModelBinding/Binders/MultiFactorClaimsDtoBinder.cs @@ -0,0 +1,25 @@ +using System; +using static MultiFactor.SelfService.Windows.Portal.Constants.Configuration; +using System.Web; +using MultiFactor.SelfService.Windows.Portal.Models; + +namespace MultiFactor.SelfService.Windows.Portal.ModelBinding.Binders +{ + public static class MultiFactorClaimsDtoBinder + { + public static SingleSignOnDto FromRequest(HttpRequest request) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + var saml = request[MultiFactorClaims.SamlSessionId]; + var oidc = request[MultiFactorClaims.OidcSessionId]; + + var sso = new SingleSignOnDto(); + sso.SamlSessionId = saml ?? string.Empty; + sso.OidcSessionId = oidc ?? string.Empty; + + return sso; + } + } +} diff --git a/MultiFactor.SelfService.Windows.Portal.csproj b/MultiFactor.SelfService.Windows.Portal.csproj index fadfc6e..ba310ad 100644 --- a/MultiFactor.SelfService.Windows.Portal.csproj +++ b/MultiFactor.SelfService.Windows.Portal.csproj @@ -249,6 +249,9 @@ + + + @@ -268,8 +271,29 @@ + + + + + + + + + + + + + + + + + + + + + @@ -282,12 +306,23 @@ + + + + + + + + + + + Global.asax @@ -302,6 +337,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -314,6 +393,7 @@ + True @@ -330,6 +410,7 @@ True PasswordPolicy.ru.resx + UserUnlock.resx True @@ -476,14 +557,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -562,6 +669,7 @@ + diff --git a/Options/ShowcaseSettingsOptions.cs b/Options/ShowcaseSettingsOptions.cs new file mode 100644 index 0000000..c873828 --- /dev/null +++ b/Options/ShowcaseSettingsOptions.cs @@ -0,0 +1,35 @@ +using MultiFactor.SelfService.Windows.Portal.Settings; + +namespace MultiFactor.SelfService.Windows.Portal.Options +{ + public interface IShowcaseSettingsOptions + { + ShowcaseSettings CurrentValue { get; } + void Update(ShowcaseSettings settings); + } + + public class ShowcaseSettingsOptions : IShowcaseSettingsOptions + { + private ShowcaseSettings _current; + private readonly object _lock = new object(); + + public ShowcaseSettings CurrentValue + { + get + { + lock (_lock) + { + return _current; + } + } + } + + public void Update(ShowcaseSettings settings) + { + lock (_lock) + { + _current = settings; + } + } + } +} diff --git a/Resources/SharedResource.cs b/Resources/SharedResource.cs new file mode 100644 index 0000000..a879efc --- /dev/null +++ b/Resources/SharedResource.cs @@ -0,0 +1,7 @@ +// Do not change this file! +namespace MultiFactor.SelfService.Windows.Portal +{ + public class SharedResource + { + } +} diff --git a/Resources/SharedResource.en.resx b/Resources/SharedResource.en.resx new file mode 100644 index 0000000..4df3d47 --- /dev/null +++ b/Resources/SharedResource.en.resx @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Password does not meet requirements + + + Unable to change password + + + Cancel + + + Form autofill check failed + + + Are you sure? + + + Enter + + + Ok + + + Please wait + + + {0} MFA Portal + + + Allowed + + + Blocked + + + Quarantined + + + Unable to set the new password + + + Please enter user name as user@domain.loc format + + + Wrong code + + + Wrong user name or password + + \ No newline at end of file diff --git a/Resources/SharedResource.ru.resx b/Resources/SharedResource.ru.resx new file mode 100644 index 0000000..64cded8 --- /dev/null +++ b/Resources/SharedResource.ru.resx @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Новый пароль не соответствует требованиям + + + Невозможно сменить пароль + + + Отмена + + + Не пройдена проверка от автоматического заполнения + + + Вы уверены? + + + Вход + + + Ok + + + Пожалуйста, подождите + + + Портал двухфакторной аутентификации {0} + + + Работает + + + Отключен + + + Ожидает подтверждения + + + Не удалось установить новый пароль + + + Пожалуйста, введите имя пользователя в формате user@domain.loc + + + Неверный код + + + Неверное имя пользователя или пароль + + \ No newline at end of file diff --git a/Services/API/ApiClient.cs b/Services/API/ApiClient.cs index f905349..e8a0786 100644 --- a/Services/API/ApiClient.cs +++ b/Services/API/ApiClient.cs @@ -11,22 +11,26 @@ namespace MultiFactor.SelfService.Windows.Portal.Services.API { + [Obsolete] public class ApiClient { private readonly Configuration _configuration; private readonly ILogger _logger; + [Obsolete] public ApiClient(Configuration configuration, ILogger logger) { _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } + [Obsolete] public TResponse Get(string path, Action configure = null) where TResponse : ApiResponse { return SendRequest(path, HttpMethod.Get, null, configure); } + [Obsolete] public TResponse Post(string path, object payload, Action configure = null) where TResponse : ApiResponse { var json = payload != null @@ -35,6 +39,7 @@ public TResponse Post(string path, object payload, Action(path, HttpMethod.Post, json, configure); } + [Obsolete] public TResponse Delete(string path, Action configure = null) where TResponse : ApiResponse { return SendRequest(path, HttpMethod.Delete, null, configure); diff --git a/Services/API/MultiFactorApiClient.cs b/Services/API/MultiFactorApiClient.cs index 927eb9c..0838157 100644 --- a/Services/API/MultiFactorApiClient.cs +++ b/Services/API/MultiFactorApiClient.cs @@ -13,6 +13,7 @@ namespace MultiFactor.SelfService.Windows.Portal.Services.API /// /// 2F Authentication API /// + [Obsolete] public class MultiFactorApiClient { private readonly Configuration _settings; @@ -24,6 +25,7 @@ public MultiFactorApiClient(Configuration settings, ILogger logger) _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } + [Obsolete] public AccessPage CreateAccessRequest(string login, string displayName, string email, string phone, string postbackUrl, IDictionary claims) { try @@ -73,6 +75,7 @@ public AccessPage CreateAccessRequest(string login, string displayName, string e } } + [Obsolete] public BypassPage CreateSamlBypassRequest(string login, string samlSessionId) { try @@ -95,6 +98,7 @@ public BypassPage CreateSamlBypassRequest(string login, string samlSessionId) } } + [Obsolete] public string RefreshAccessToken(string token, IDictionary claims) { if (token is null) throw new ArgumentNullException(nameof(token)); @@ -119,6 +123,7 @@ public string RefreshAccessToken(string token, IDictionary claim } } + [Obsolete] private TModel SendRequest(string path, string payload) { //make sure we can communicate securely diff --git a/Services/API/MultiFactorClaims.cs b/Services/API/MultiFactorClaims.cs index 7f3bab8..b82fe7c 100644 --- a/Services/API/MultiFactorClaims.cs +++ b/Services/API/MultiFactorClaims.cs @@ -1,5 +1,8 @@ -namespace MultiFactor.SelfService.Windows.Portal.Services.API +using System; + +namespace MultiFactor.SelfService.Windows.Portal.Services.API { + [Obsolete] public static class MultiFactorClaims { public const string SamlSessionId = "samlSessionId"; diff --git a/Services/API/MultiFactorSelfServiceApiClient.cs b/Services/API/MultiFactorSelfServiceApiClient.cs index 16add9a..21a620c 100644 --- a/Services/API/MultiFactorSelfServiceApiClient.cs +++ b/Services/API/MultiFactorSelfServiceApiClient.cs @@ -2,15 +2,14 @@ using MultiFactor.SelfService.Windows.Portal.Services.API.DTO; using System; using System.Collections.Generic; -using System.Security.Principal; using System.Text; -using System.Web.UI.WebControls; namespace MultiFactor.SelfService.Windows.Portal.Services.API { /// /// User self-service API /// + [Obsolete] public class MultiFactorSelfServiceApiClient { private readonly Configuration _settings; @@ -24,12 +23,14 @@ public MultiFactorSelfServiceApiClient(Configuration settings, JwtTokenProvider _apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); } + [Obsolete] public UserProfile LoadUserProfile() { var result = _apiClient.Get>("/self-service", x => x.Authorization = GetBearerAuth()); return result.Model; } + [Obsolete] public ApiResponse GetUserAuthenticators(string identity) { if (string.IsNullOrWhiteSpace(identity)) throw new ArgumentNullException(nameof(identity)); @@ -43,6 +44,7 @@ public ApiResponse GetUserAuthenticators(string id return result; } + [Obsolete] public ApiResponse StartResetPassword(string twoFaIdentity, string ldapIdentity, string callbackUrl) { if (twoFaIdentity is null) throw new ArgumentNullException(nameof(twoFaIdentity)); @@ -69,6 +71,7 @@ public ApiResponse StartResetPassword(string twoFaIdentity, string l return result; } + [Obsolete] public ApiResponse StartUnlockingUser(string identity, string callbackUrl) { if (identity is null) throw new ArgumentNullException(nameof(identity)); @@ -93,6 +96,7 @@ public ApiResponse StartUnlockingUser(string identity, string callba var result = _apiClient.Post>("/self-service/start-unlock-user", payload, x => x.Authorization = GetBasicAuth()); return result; } + [Obsolete] public ApiResponse CreateEnrollmentRequest() { @@ -102,6 +106,7 @@ public ApiResponse CreateEnrollmentRequest() x => x.Authorization = GetBearerAuth()); } + [Obsolete] public ApiResponse GetScopeSupportInfo() { return _apiClient.Get>("/self-service/support-info", x => x.Authorization = GetBasicAuth()); diff --git a/Services/Caching/ApplicationCache.cs b/Services/Caching/ApplicationCache.cs index d2b7bea..b34b4fb 100644 --- a/Services/Caching/ApplicationCache.cs +++ b/Services/Caching/ApplicationCache.cs @@ -5,6 +5,7 @@ namespace MultiFactor.SelfService.Windows.Portal.Services.Caching { + [Obsolete] public class ApplicationCache { private readonly IMemoryCache _cache; diff --git a/Services/Ldap/LdapProfile.cs b/Services/Ldap/LdapProfile.cs index 45561bc..9954235 100644 --- a/Services/Ldap/LdapProfile.cs +++ b/Services/Ldap/LdapProfile.cs @@ -1,6 +1,5 @@ using System; using System.Collections.ObjectModel; -using Microsoft.Extensions.Configuration; using Serilog; namespace MultiFactor.SelfService.Windows.Portal.Services.Ldap diff --git a/Services/ShowcaseSettingsUpdaterService.cs b/Services/ShowcaseSettingsUpdaterService.cs new file mode 100644 index 0000000..0709f80 --- /dev/null +++ b/Services/ShowcaseSettingsUpdaterService.cs @@ -0,0 +1,111 @@ +using Serilog; +using System.Threading.Tasks; +using System.Threading; +using System; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi; +using MultiFactor.SelfService.Windows.Portal.Options; +using MultiFactor.SelfService.Windows.Portal.Settings; +using System.IO; +using System.Net.Http; +using System.Linq; +using System.Web.Hosting; + +namespace MultiFactor.SelfService.Windows.Portal.Services +{ + public class ShowcaseSettingsUpdater + { + private readonly IMultiFactorApi _multiFactorApi; + private readonly IShowcaseSettingsOptions _options; + private readonly ILogger _logger; + + private readonly TimeSpan _period = TimeSpan.FromSeconds(90); + private CancellationTokenSource _cts; + + public ShowcaseSettingsUpdater( + IMultiFactorApi api, + IShowcaseSettingsOptions options, + ILogger logger) + { + _multiFactorApi = api; + _options = options; + _logger = logger; + } + + public void Start() + { + _cts = new CancellationTokenSource(); + Task.Run(() => Loop(_cts.Token)); + } + + public void Stop() + { + _cts.Cancel(); + } + + private async Task Loop(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try + { + var settings = await _multiFactorApi.GetShowcaseSettingsAsync(); + _options.Update(settings); + await UpdateLogos(settings); + + _logger.Information("Showcase settings updated"); + } + catch (Exception ex) + { + _logger.Error("Failed to update settings", ex); + } + + await Task.Delay(_period, ct); + } + } + + private async Task UpdateLogos(ShowcaseSettings settings) + { + if (settings == null) + { + return; + } + + var localFolder = HostingEnvironment.MapPath("~/content/images/showcase"); + + if (!Directory.Exists(localFolder)) + { + Directory.CreateDirectory(localFolder); + } + + var cloudFileNames = settings.Links.Select(x => x.Image).ToArray(); + var localFileNames = Directory.GetFiles(localFolder) + .Select(file => Path.GetFileName(file)) + .ToArray(); + + var missingFiles = cloudFileNames.Except(localFileNames).ToArray(); + foreach (var fileName in missingFiles) + { + try + { + var data = await _multiFactorApi.GetShowcaseLogoAsync(fileName); + if (data is null) + { + continue; + } + + File.WriteAllBytes(Path.Combine(localFolder, fileName), data); + } + catch (HttpRequestException ex) + { + _logger.Warning(ex, "Failed to load showcase logo '{fileName}'", fileName); + } + } + + var extraFiles = localFileNames.Except(cloudFileNames).ToArray(); + foreach (var filename in extraFiles) + { + File.Delete(Path.Combine(localFolder, filename)); + } + } + } +} \ No newline at end of file diff --git a/Services/TokenValidationService.cs b/Services/TokenValidationService.cs index 6188188..dc83ae8 100644 --- a/Services/TokenValidationService.cs +++ b/Services/TokenValidationService.cs @@ -1,4 +1,6 @@ using Microsoft.IdentityModel.Tokens; +using MultiFactor.SelfService.Windows.Portal.Authentication; +using MultiFactor.SelfService.Windows.Portal.Core.Exceptions; using MultiFactor.SelfService.Windows.Portal.Services.API; using Serilog; using System; @@ -54,16 +56,16 @@ public bool VerifyToken(string jwt, out Token token) var identity = jwtSecurityToken.Subject; var rawUserName = claimsPrincipal.Claims .SingleOrDefault(claim => claim.Type == MultiFactorClaims.RawUserName)?.Value; - + var unlockUser = claimsPrincipal.Claims .FirstOrDefault(claim => claim.Type == MultiFactorClaims.UnlockUser)?.Value?.ToLower() == "true"; - + var mustResetPassword = claimsPrincipal.Claims.Any(claim => claim.Type == MultiFactorClaims.ResetPassword); - + var mustChangePassword = claimsPrincipal.Claims.Any(claim => claim.Type == MultiFactorClaims.ChangePassword); - + token = new Token { Id = jwtSecurityToken.Id, @@ -92,6 +94,63 @@ public bool VerifyToken(string jwt, out Token token) return false; } + public TokenClaims Verify(string accessToken) + { + try + { + if (_jsonWebKeySet == null) + { + _jsonWebKeySet = FetchJwks(); + } + + var validationParameters = new TokenValidationParameters + { + IssuerSigningKeys = _jsonWebKeySet.Keys, + ValidAudience = _configuration.MultiFactorApiKey, + ValidateIssuer = false, + ValidateAudience = true, + ValidateIssuerSigningKey = true, + ValidateLifetime = true, + ValidateTokenReplay = true, + }; + + var handler = new JwtSecurityTokenHandler(); + var claimsPrincipal = handler.ValidateToken(accessToken, validationParameters, out var securityToken); + var jwtSecurityToken = (JwtSecurityToken)securityToken; + + var rawUserName = claimsPrincipal.Claims + .SingleOrDefault(claim => claim.Type == MultiFactorClaims.RawUserName)?.Value; + var identity = jwtSecurityToken.Subject; + var unlockUser = claimsPrincipal.Claims + .FirstOrDefault(claim => claim.Type == MultiFactorClaims.UnlockUser) + ?.Value?.ToLower() == "true"; + var mustChangePassword = + claimsPrincipal.Claims.Any(claim => claim.Type == MultiFactorClaims.ChangePassword); + var mustResetPassword = + claimsPrincipal.Claims.Any(claim => claim.Type == MultiFactorClaims.ResetPassword); + var samlClaim = claimsPrincipal.Claims.FirstOrDefault(claim => claim.Type == MultiFactorClaims.SamlSessionId)?.Value; + var oidcClaim = claimsPrincipal.Claims.FirstOrDefault(claim => claim.Type == MultiFactorClaims.OidcSessionId)?.Value; + // use raw user name when possible couse multifactor may transform identity depend by settings + return new TokenClaims() + { + Id = jwtSecurityToken.Id, + Identity = identity, + RawUserName = rawUserName, + MustChangePassword = mustChangePassword, + ValidTo = jwtSecurityToken.ValidTo, + MustResetPassword = mustResetPassword, + SamlClaim = samlClaim, + OidcClaim = oidcClaim, + MustUnlockUser = unlockUser + }; + } + catch (Exception ex) + { + _logger.Error(ex, "Error verifying token"); + throw new UnauthorizedException("Error verifying token", ex); + } + } + private JsonWebKeySet FetchJwks() { //load Json Web Key Set from MultiFactor API diff --git a/Settings/ShowcaseSettings.cs b/Settings/ShowcaseSettings.cs new file mode 100644 index 0000000..1c867ee --- /dev/null +++ b/Settings/ShowcaseSettings.cs @@ -0,0 +1,17 @@ +namespace MultiFactor.SelfService.Windows.Portal.Settings +{ + public class ShowcaseSettings + { + public bool Enabled { get; set; } + public ShowcaseLink[] Links { get; set; } = new ShowcaseLink[0]; + } + + public class ShowcaseLink + { + public string ResourceId { get; set; } + public string Url { get; set; } + public string Title { get; set; } + public string Image { get; set; } + public bool OpenInNewTab { get; set; } + } +} \ No newline at end of file diff --git a/Stories/Authenticate/AuthenticateSessionStory.cs b/Stories/Authenticate/AuthenticateSessionStory.cs new file mode 100644 index 0000000..128923b --- /dev/null +++ b/Stories/Authenticate/AuthenticateSessionStory.cs @@ -0,0 +1,100 @@ +using System.Threading.Tasks; +using System; +using Serilog; +using MultiFactor.SelfService.Windows.Portal.Core.Http; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Enums; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi; +using System.Web.Mvc; +using MultiFactor.SelfService.Windows.Portal.Extensions; +using System.Web; +using System.Web.Security; + +namespace MultiFactor.SelfService.Windows.Portal.Stories.Authenticate +{ + public class AuthenticateSessionStory + { + private readonly IMultifactorIdpApi _idpApi; + private readonly SafeHttpContextAccessor _contextAccessor; + private readonly ILogger _logger; + + public AuthenticateSessionStory( + IMultifactorIdpApi idpApi, + SafeHttpContextAccessor contextAccessor, + ILogger logger) + { + _idpApi = idpApi; + _contextAccessor = contextAccessor; + _logger = logger; + } + + public async Task Execute(string accessToken) + { + if (accessToken == null) + { + throw new ArgumentNullException(nameof(accessToken)); + } + _logger.Debug("Received MFA token: {accessToken:l}", accessToken); + + var request = new LoginCompletedRequestDto + { + AccessToken = accessToken + }; + + var response = await _idpApi.LoginCompletedAsync(request, _contextAccessor.HttpContext.GetRequiredHeaders()); + + return HandleLoginCompletedResponse(response, accessToken); + } + + private ActionResult HandleLoginCompletedResponse(LoginCompletedResponseDto response, string accessToken) + { + if (!response.Success) + { + _logger.Debug("LoginCompleted failed: {Error}", response.ErrorMessage); + return new RedirectToActionResult().ToActionResult("AccessDenied", "Error", default); + } + + if (response.TokenExpirationDate != null) + { + + var cookie = new HttpCookie(Constants.COOKIE_NAME) + { + Value = accessToken, + Expires = response.TokenExpirationDate, + HttpOnly = true, + Secure = true + }; + + if (HttpContext.Current.Response.Cookies[Constants.COOKIE_NAME] != null) + { + HttpContext.Current.Response.Cookies[Constants.COOKIE_NAME].Expires = DateTime.Now.AddDays(-1); + } + HttpContext.Current.Response.Cookies.Add(cookie); + FormsAuthentication.SetAuthCookie(response.Identity, false); + + _logger.Information("cookie set success " + response.Identity); + } + + if (response.Action == LoginCompletedAction.BypassSaml && !string.IsNullOrEmpty(response.SamlSessionId)) + { + _logger.Debug("Redirecting to SAML bypass for session '{Session}'", response.SamlSessionId); + return new RedirectToActionResult().ToActionResult("ByPassSamlSession", "Account", new { samlSession = response.SamlSessionId }); + } + + if (response.Action == LoginCompletedAction.BypassOidc && !string.IsNullOrEmpty(response.OidcSessionId)) + { + _logger.Debug("Redirecting to OIDC bypass for session '{Session}'", response.OidcSessionId); + return new RedirectToActionResult().ToActionResult("ByPassOidcSession", "Account", new { oidcSession = response.OidcSessionId }); + } + + if (response.Action == LoginCompletedAction.ChangePassword) + { + _logger.Debug("User '{User}' must change password", response.Identity); + return new RedirectToActionResult().ToActionResult("Change", "ExpiredPassword", default); + } + + _logger.Debug("User '{User}' authenticated successfully", response.Identity); + return new RedirectToActionResult().ToActionResult("Index", "Home", default); + } + } +} \ No newline at end of file diff --git a/Stories/ChangeExpiredPassword/ChangeExpiredPasswordStory.cs b/Stories/ChangeExpiredPassword/ChangeExpiredPasswordStory.cs new file mode 100644 index 0000000..ef3854a --- /dev/null +++ b/Stories/ChangeExpiredPassword/ChangeExpiredPasswordStory.cs @@ -0,0 +1,82 @@ +using System.Threading.Tasks; +using System; +using MultiFactor.SelfService.Windows.Portal.Core.Caching; +using MultiFactor.SelfService.Windows.Portal.Core.Http; +using MultiFactor.SelfService.Windows.Portal.Core; +using MultiFactor.SelfService.Windows.Portal.Exceptions; +using MultiFactor.SelfService.Windows.Portal.Integrations.Ldap.PasswordChanging; +using MultiFactor.SelfService.Windows.Portal.ViewModels; +using System.Web.Mvc; +using System.Linq; +using static MultiFactor.SelfService.Windows.Portal.Constants.Configuration; +using System.Security.Claims; + +namespace MultiFactor.SelfService.Windows.Portal.Stories.ChangeExpiredPassword +{ + public class ChangeExpiredPasswordStory + { + private readonly Configuration _settings; + private readonly SafeHttpContextAccessor _contextAccessor; + private readonly DataProtection _dataProtection; + private readonly UserPasswordChanger _passwordChanger; + private readonly IApplicationCache _applicationCache; + + public ChangeExpiredPasswordStory(Configuration settings, + SafeHttpContextAccessor contextAccessor, + DataProtection dataProtection, + UserPasswordChanger passwordChanger, + IApplicationCache applicationCache) + { + _settings = settings; + _contextAccessor = contextAccessor; + _dataProtection = dataProtection; + _passwordChanger = passwordChanger; + _applicationCache = applicationCache; + } + + public async Task ExecuteAsync(ChangeExpiredPasswordViewModel model) + { + if (model == null) + { + throw new ArgumentNullException(nameof(model)); + } + + if (!_settings.EnablePasswordManagement) + { + return new RedirectToActionResult().ToActionResult("Login", "Account", new { }); + } + + var principal = _contextAccessor.HttpContext.User as ClaimsPrincipal; + var rawUserName = principal?.Claims + .SingleOrDefault(c => c.Type == MultiFactorClaims.RawUserName)?.Value; + if (rawUserName is null) + { + return new RedirectToActionResult().ToActionResult("Login", "Account", new { }); + } + + var userName = _applicationCache.Get(ApplicationCacheKeyFactory.CreateExpiredPwdUserKey(rawUserName)); + var encryptedPwd = _applicationCache.Get(ApplicationCacheKeyFactory.CreateExpiredPwdCipherKey(rawUserName)); + + if (userName?.Value is null || encryptedPwd?.Value is null) + { + return new RedirectToActionResult().ToActionResult("Login", "Account", new { }); + } + + var currentPassword = _dataProtection.Unprotect(encryptedPwd.Value, Constants.PWD_RENEWAL_PURPOSE); + var pwdChangeResult = await _passwordChanger.ChangePassword( + userName.Value, + currentPassword, + model.NewPassword); + + if (!pwdChangeResult.Success) + { + throw new ModelStateErrorException(pwdChangeResult.ErrorReason); + } + + _applicationCache.Remove(ApplicationCacheKeyFactory.CreateExpiredPwdUserKey(rawUserName)); + _applicationCache.Remove(ApplicationCacheKeyFactory.CreateExpiredPwdCipherKey(rawUserName)); + + return new RedirectToActionResult().ToActionResult("Done", "ExpiredPassword", new { }); + } + } +} \ No newline at end of file diff --git a/Stories/ChangeValidPassword/ChangeValidPasswordStory.cs b/Stories/ChangeValidPassword/ChangeValidPasswordStory.cs new file mode 100644 index 0000000..abab5a9 --- /dev/null +++ b/Stories/ChangeValidPassword/ChangeValidPasswordStory.cs @@ -0,0 +1,47 @@ +using System.Threading.Tasks; +using System; +using MultiFactor.SelfService.Windows.Portal.Exceptions; +using MultiFactor.SelfService.Windows.Portal.Integrations.Ldap.PasswordChanging; +using MultiFactor.SelfService.Windows.Portal.Authentication; +using System.Web.Mvc; +using MultiFactor.SelfService.Windows.Portal.ViewModels; + +namespace MultiFactor.SelfService.Windows.Portal.Stories.ChangeValidPassword +{ + public class ChangeValidPasswordStory + { + private readonly Configuration _settings; + private readonly UserPasswordChanger _passwordChanger; + private readonly TokenClaimsAccessor _claimsAccessor; + + public ChangeValidPasswordStory(Configuration settings, UserPasswordChanger passwordChanger, TokenClaimsAccessor claimsAccessor) + { + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _passwordChanger = passwordChanger ?? throw new ArgumentNullException(nameof(passwordChanger)); + _claimsAccessor = claimsAccessor ?? throw new ArgumentNullException(nameof(claimsAccessor)); + } + + public async Task ExecuteAsync(ChangePasswordViewModel model) + { + if (model == null) + { + throw new ArgumentNullException(nameof(model)); + } + + if (!_settings.EnablePasswordManagement) + { + return new RedirectToActionResult().ToActionResult("Logout", "Account", new { }); + } + var username = _claimsAccessor.GetTokenClaims().RawUserName; + + var res = await _passwordChanger.ChangePassword( + username, + model.Password, + model.NewPassword); + + if (!res.Success) throw new ModelStateErrorException(res.ErrorReason); + + return new RedirectResult("/Password/Done"); + } + } +} diff --git a/Stories/CheckExpiredPasswordSession/CheckExpiredPasswordSessionStory.cs b/Stories/CheckExpiredPasswordSession/CheckExpiredPasswordSessionStory.cs new file mode 100644 index 0000000..ac6efc3 --- /dev/null +++ b/Stories/CheckExpiredPasswordSession/CheckExpiredPasswordSessionStory.cs @@ -0,0 +1,51 @@ +using System.Web.Mvc; +using MultiFactor.SelfService.Windows.Portal.Core.Caching; +using MultiFactor.SelfService.Windows.Portal.Core.Http; +using MultiFactor.SelfService.Windows.Portal.Core; +using System.Linq; +using static MultiFactor.SelfService.Windows.Portal.Constants.Configuration; +using System.Security.Claims; + +namespace MultiFactor.SelfService.Windows.Portal.Stories.CheckExpiredPasswordSession +{ + public class CheckExpiredPasswordSessionStory + { + private readonly SafeHttpContextAccessor _contextAccessor; + private readonly Configuration _settings; + + private readonly IApplicationCache _applicationCache; + + public CheckExpiredPasswordSessionStory(SafeHttpContextAccessor contextAccessor, Configuration settings, IApplicationCache applicationCache) + { + _contextAccessor = contextAccessor; + _settings = settings; + _applicationCache = applicationCache; + } + + public ActionResult Execute() + { + if (!_settings.EnablePasswordManagement) + { + return new RedirectToActionResult().ToActionResult("Login", "Account", new { }); + } + + var principal = _contextAccessor.HttpContext.User as ClaimsPrincipal; + var rawUserName = principal?.Claims + .SingleOrDefault(c => c.Type == MultiFactorClaims.RawUserName)?.Value; + if (rawUserName is null) + { + return new RedirectToActionResult().ToActionResult("Login", "Account", new { }); + } + + var userName = _applicationCache.Get(ApplicationCacheKeyFactory.CreateExpiredPwdUserKey(rawUserName)); + var encryptedPwd = _applicationCache.Get(ApplicationCacheKeyFactory.CreateExpiredPwdCipherKey(rawUserName)); + + if (userName.IsEmpty || encryptedPwd.IsEmpty) + { + return new RedirectToActionResult().ToActionResult("Login", "Account", new { }); + } + + return new ViewResult(); + } + } +} diff --git a/Stories/FilterShowcaseLinks/FilterShowcaseLinksStory.cs b/Stories/FilterShowcaseLinks/FilterShowcaseLinksStory.cs new file mode 100644 index 0000000..a05d660 --- /dev/null +++ b/Stories/FilterShowcaseLinks/FilterShowcaseLinksStory.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Dto; +using MultiFactor.SelfService.Windows.Portal.Settings; +using MultiFactor.SelfService.Windows.Portal.Options; +using System.Linq; + +namespace MultiFactor.SelfService.Windows.Portal.Stories.LoadProfileStory +{ + public class FilterShowcaseLinksStory + { + private readonly IShowcaseSettingsOptions _showcaseSettings; + + public FilterShowcaseLinksStory(IShowcaseSettingsOptions showcaseSettings) + { + _showcaseSettings = showcaseSettings; + } + + public IReadOnlyCollection Execute(UserProfilePolicyDto policy) + { + var allLinks = _showcaseSettings.CurrentValue?.Links ?? Array.Empty(); + + if (policy?.AllResourcesPermitted == true) + { + return allLinks; + } + + var permittedResources = policy?.PermittedResources != null + ? new HashSet(policy.PermittedResources) + : new HashSet(); + + var filtered = allLinks + .Where(x => !string.IsNullOrWhiteSpace(x.ResourceId) && permittedResources.Contains(x.ResourceId)) + .ToArray(); + + return filtered; + } + } +} diff --git a/Stories/LoadProfile/LoadIdpProfileStory.cs b/Stories/LoadProfile/LoadIdpProfileStory.cs new file mode 100644 index 0000000..5fabf31 --- /dev/null +++ b/Stories/LoadProfile/LoadIdpProfileStory.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; +using System; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Dto; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi; + +namespace MultiFactor.SelfService.Windows.Portal.Stories.LoadProfile +{ + public class LoadIdpProfileStory + { + private readonly IMultifactorIdpApi _api; + + public LoadIdpProfileStory(IMultifactorIdpApi api) + { + _api = api ?? throw new ArgumentNullException(nameof(api)); + } + + public Task ExecuteAsync() => _api.GetUserProfileAsync(); + } +} diff --git a/Stories/LoadProfile/LoadProfileStory.cs b/Stories/LoadProfile/LoadProfileStory.cs new file mode 100644 index 0000000..4ec2744 --- /dev/null +++ b/Stories/LoadProfile/LoadProfileStory.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; +using System; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Dto; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi; + +namespace MultiFactor.SelfService.Windows.Portal.Stories.LoadProfile +{ + public class LoadProfileStory + { + private readonly IMultiFactorApi _api; + + public LoadProfileStory(IMultiFactorApi api) + { + _api = api ?? throw new ArgumentNullException(nameof(api)); + } + + public Task ExecuteAsync() => _api.GetUserProfileAsync(); + } +} diff --git a/Stories/RecoverPassword/RecoverPasswordStory.cs b/Stories/RecoverPassword/RecoverPasswordStory.cs new file mode 100644 index 0000000..807dd13 --- /dev/null +++ b/Stories/RecoverPassword/RecoverPasswordStory.cs @@ -0,0 +1,91 @@ +using System.Threading.Tasks; +using System; +using Serilog; +using MultiFactor.SelfService.Windows.Portal.Exceptions; +using MultiFactor.SelfService.Windows.Portal.Integrations.Ldap.CredentialVerification; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi; +using MultiFactor.SelfService.Windows.Portal.Models.PasswordRecovery; +using MultiFactor.SelfService.Windows.Portal.Extensions; +using System.Web.Mvc; +using MultiFactor.SelfService.Windows.Portal.Integrations.Ldap.PasswordChanging; + +namespace MultiFactor.SelfService.Windows.Portal.Stories.RecoverPassword +{ + public class RecoverPasswordStory + { + private readonly IMultiFactorApi _apiClient; + private readonly Configuration _portalSettings; + private readonly ForgottenPasswordChanger _passwordChanger; + private readonly ICredentialVerifier _credentialVerifier; + private readonly ILogger _logger; + + public RecoverPasswordStory( + IMultiFactorApi apiClient, + Configuration portalSettings, + ForgottenPasswordChanger passwordChanger, + ILogger logger, + ICredentialVerifier credentialVerifier) + { + _apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + _portalSettings = portalSettings ?? throw new ArgumentNullException(nameof(portalSettings)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _credentialVerifier = credentialVerifier; + _passwordChanger = passwordChanger ?? throw new ArgumentNullException(nameof(passwordChanger)); + } + + public async Task StartRecoverAsync(EnterIdentityForm form) + { + var identity = await GetIdentity(form); + + var callback = form.MyUrl.BuildRelativeUrl("Reset", 1); + try + { + var response = await _apiClient.StartResetPassword(identity, form.Identity, callback); + return new RedirectResult(response.Url); + + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to recover password for user '{u:l}': {m:l}", form.Identity, ex.Message); + throw new ModelStateErrorException("AD.UnableToChangePassword"); + } + } + + private async Task GetIdentity(EnterIdentityForm form) + { + var attr = _portalSettings.UseAttributeAsIdentity; + var username = form.Identity.Trim(); + var verificationResult = await _credentialVerifier.VerifyMembership(username); + if (!string.IsNullOrWhiteSpace(attr)) + { + if (string.IsNullOrWhiteSpace(verificationResult.CustomIdentity)) + { + throw new InvalidOperationException($"Missing overridden identity (attribute '{attr}') for user {username}"); + } + + return verificationResult.CustomIdentity; + } + + if (!_portalSettings.UseUpnAsIdentity) + { + return username; + } + + if (string.IsNullOrEmpty(verificationResult.UserPrincipalName)) + { + throw new InvalidOperationException($"Null UPN for user {username}"); + } + + return verificationResult.UserPrincipalName; + } + + public async Task ResetPasswordAsync(ResetPasswordForm form) + { + var result = await _passwordChanger.ChangePassword(form.Identity, form.NewPassword); + if (!result.Success) + { + throw new ModelStateErrorException(result.ErrorReason); + } + } + } +} diff --git a/Stories/RedirectToActionResult.cs b/Stories/RedirectToActionResult.cs new file mode 100644 index 0000000..9b394fc --- /dev/null +++ b/Stories/RedirectToActionResult.cs @@ -0,0 +1,33 @@ +using System.Web.Mvc; +using System.Linq; +using System.Web.Routing; + +namespace MultiFactor.SelfService.Windows.Portal.Stories +{ + public class RedirectToActionResult + { + public RedirectToRouteResult ToActionResult(string actionName, string controllerName, params object[] values) + { + var routeValues = new RouteValueDictionary + { + { "controller", controllerName }, + { "action", actionName } + }; + + if (values != null) + { + foreach (var value in values.Where(v => v != null)) + { + var dict = new RouteValueDictionary(value); + + foreach (var kv in dict) + { + routeValues[kv.Key] = kv.Value; + } + } + } + + return new RedirectToRouteResult(routeValues); + } + } +} \ No newline at end of file diff --git a/Stories/SearchExchangeActiveSyncDevices/SearchExchangeActiveSyncDevicesStory.cs b/Stories/SearchExchangeActiveSyncDevices/SearchExchangeActiveSyncDevicesStory.cs new file mode 100644 index 0000000..fa4ea44 --- /dev/null +++ b/Stories/SearchExchangeActiveSyncDevices/SearchExchangeActiveSyncDevicesStory.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using MultiFactor.SelfService.Windows.Portal.Models; +using MultiFactor.SelfService.Windows.Portal.Services; + +namespace MultiFactor.SelfService.Windows.Portal.Stories.SearchExchangeActiveSyncDevices +{ + public class SearchExchangeActiveSyncDevicesStory + { + private readonly ActiveDirectoryService _activeDirectoryService; + + public SearchExchangeActiveSyncDevicesStory(ActiveDirectoryService activeDirectoryService) + { + _activeDirectoryService = activeDirectoryService; + } + + public async Task> ExecuteAsync(string identity) + { + return _activeDirectoryService.SearchExchangeActiveSyncDevices(identity); + } + } +} diff --git a/Stories/SignIn/AuthnStory.cs b/Stories/SignIn/AuthnStory.cs new file mode 100644 index 0000000..d862994 --- /dev/null +++ b/Stories/SignIn/AuthnStory.cs @@ -0,0 +1,112 @@ +using System.DirectoryServices.AccountManagement; +using System.Threading.Tasks; +using System; +using Serilog; +using MultiFactor.SelfService.Windows.Portal.Extensions; +using MultiFactor.SelfService.Windows.Portal.Integrations.Ldap.CredentialVerification; +using MultiFactor.SelfService.Windows.Portal.Core.Caching; +using MultiFactor.SelfService.Windows.Portal.Core.Http; +using MultiFactor.SelfService.Windows.Portal.Core; +using MultiFactor.SelfService.Windows.Portal.Exceptions; +using MultiFactor.SelfService.Windows.Portal.Services.Ldap; +using System.Web.Mvc; +using MultiFactor.SelfService.Windows.Portal.Stories.Authenticate; +using MultiFactor.SelfService.Windows.Portal.Models; + +namespace MultiFactor.SelfService.Windows.Portal.Stories.SignIn +{ + public class AuthnStory + { + private readonly ICredentialVerifier _credentialVerifier; + private readonly DataProtection _dataProtection; + private readonly SafeHttpContextAccessor _contextAccessor; + private readonly Configuration _settings; + private readonly ILogger _logger; + private readonly IApplicationCache _applicationCache; + private readonly AuthenticateSessionStory _authenticateSessionStory; + + public AuthnStory(ICredentialVerifier credentialVerifier, + DataProtection dataProtection, + SafeHttpContextAccessor contextAccessor, + Configuration settings, + IApplicationCache applicationCache, + ILogger logger, + AuthenticateSessionStory authenticateSessionStory) + { + _credentialVerifier = credentialVerifier; + _dataProtection = dataProtection; + _contextAccessor = contextAccessor; + _settings = settings; + _logger = logger; + _applicationCache = applicationCache; + _authenticateSessionStory = authenticateSessionStory; + } + + public async Task ExecuteAsync(IdentityModel model) + { + var userName = LdapIdentity.ParseUser(model.UserName); + if (_settings.RequiresUpn) + { + if (userName.Type != IdentityType.UserPrincipalName) + { + throw new ModelStateErrorException("UserNameUpnRequired"); + } + } + + // authn after 2fa + // AD credential check + var adValidationResult = await _credentialVerifier.VerifyCredentialAsync(model.UserName.Trim(), model.Password.Trim()); + + // credential is VALID + if (adValidationResult.IsAuthenticated) + { + _logger.Information("User '{user}' credential verified successfully in {domain:l}", userName, + _settings.Domain); + + await _authenticateSessionStory.Execute(model.AccessToken); + + var sso = _contextAccessor.SafeGetSsoClaims(); + if (sso.HasSamlSession()) + { + if (adValidationResult.IsBypass) + { + return new RedirectToActionResult().ToActionResult("ByPassSamlSession", "account", + new { username = model.UserName, samlSession = sso.SamlSessionId }); + } + + return new RedirectToActionResult().ToActionResult("ByPassSamlSession", "Account", new { samlSession = sso.SamlSessionId }); + } + + if (sso.HasOidcSession()) + { + return new RedirectToActionResult().ToActionResult("ByPassOidcSession", "Account", new { oidcSession = sso.OidcSessionId }); + } + + return new RedirectToActionResult().ToActionResult("Index", "Home", default); + } + + if (adValidationResult.UserMustChangePassword && _settings.EnablePasswordManagement) + { + var encryptedPassword = _dataProtection.Protect(model.Password.Trim(), Constants.PWD_RENEWAL_PURPOSE); + _applicationCache.Set(ApplicationCacheKeyFactory.CreateExpiredPwdUserKey(model.UserName), + model.UserName.Trim()); + _applicationCache.Set(ApplicationCacheKeyFactory.CreateExpiredPwdCipherKey(model.UserName), + encryptedPassword); + + return await _authenticateSessionStory.Execute(model.AccessToken); + } + + return await WrongAsync(); + } + + private async Task WrongAsync() + { + // Invalid credentials, freeze response for 2-5 seconds to prevent brute-force attacks. + var rnd = new Random(); + int delay = rnd.Next(2, 6); + await Task.Delay(TimeSpan.FromSeconds(delay)); + throw new ModelStateErrorException("WrongUserNameOrPassword"); + } + } + +} \ No newline at end of file diff --git a/Stories/SignIn/ClaimsSources/AdditionalClaimsSource.cs b/Stories/SignIn/ClaimsSources/AdditionalClaimsSource.cs new file mode 100644 index 0000000..56f1a2f --- /dev/null +++ b/Stories/SignIn/ClaimsSources/AdditionalClaimsSource.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Serilog; +using MultiFactor.SelfService.Windows.Portal.Core.Authentication.AdditionalClaims; +using MultiFactor.SelfService.Windows.Portal.Core.Authentication.AdditionalClaims.Description.Conditions; +using MultiFactor.SelfService.Windows.Portal.Core.Authentication.AuthenticationClaims; +using MultiFactor.SelfService.Windows.Portal.Core.Metadata; + +namespace MultiFactor.SelfService.Windows.Portal.Stories.SignIn.ClaimsSources +{ + public class AdditionalClaimsSource : IClaimsSource + { + private readonly AdditionalClaimsMetadata _additionalClaimsMetadata; + private readonly IApplicationValuesContext _claimValuesContext; + private readonly ClaimConditionEvaluator _conditionEvaluator; + private readonly ILogger _logger; + private readonly Configuration _settings; + + public AdditionalClaimsSource(AdditionalClaimsMetadata additionalClaimsMetadata, IApplicationValuesContext claimValuesContext, + ClaimConditionEvaluator conditionEvaluator, ILogger logger, Configuration settings) + { + _additionalClaimsMetadata = additionalClaimsMetadata ?? throw new ArgumentNullException(nameof(additionalClaimsMetadata)); + _claimValuesContext = claimValuesContext ?? throw new ArgumentNullException(nameof(claimValuesContext)); + _conditionEvaluator = conditionEvaluator ?? throw new ArgumentNullException(nameof(conditionEvaluator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + } + + public IReadOnlyDictionary GetClaims() + { + var claims = new Dictionary(); + Log($"Try to consume additional claims: {string.Join(", ", _additionalClaimsMetadata.Descriptors.Select(x => x.Name))}"); + + foreach (var descriptor in _additionalClaimsMetadata.Descriptors) + { + Log($"Getting {(descriptor.Condition != null ? "conditional" : "non conditional")} claim '{descriptor.Name}'..."); + + if (descriptor.Condition == null) + { + var value = GetValue(descriptor.Source.GetValues(_claimValuesContext)); + claims[descriptor.Name] = value; + Log($"Claim {{Type: '{descriptor.Name}', Value: '{value}'}} was added"); + continue; + } + + var result = _conditionEvaluator.Evaluate(descriptor.Condition); + Log($"Claim '{descriptor.Name}' condition evaluating result: '{result}'"); + if (result) + { + var value = GetValue(descriptor.Source.GetValues(_claimValuesContext)); + claims[descriptor.Name] = value; + Log($"Claim {{Type: '{descriptor.Name}', Value: '{value}'}} was added"); + } + } + + return claims; + } + + private string GetValue(IReadOnlyList values) + { + switch (values.Count) + { + case 0: return string.Empty; + case 1: return values[0]; + default: return System.Text.Json.JsonSerializer.Serialize(values); + } + } + + private void Log(string message) + { + _logger.Debug(message); + } + } +} \ No newline at end of file diff --git a/Stories/SignIn/ClaimsSources/ClaimValuesContext.cs b/Stories/SignIn/ClaimsSources/ClaimValuesContext.cs new file mode 100644 index 0000000..bc82614 --- /dev/null +++ b/Stories/SignIn/ClaimsSources/ClaimValuesContext.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using MultiFactor.SelfService.Windows.Portal.Core; +using MultiFactor.SelfService.Windows.Portal.Core.Authentication.AdditionalClaims; +using MultiFactor.SelfService.Windows.Portal.Core.Http; +using MultiFactor.SelfService.Windows.Portal.Core.Metadata.GlobalValues; +using MultiFactor.SelfService.Windows.Portal.Extensions; + +namespace MultiFactor.SelfService.Windows.Portal.Stories.SignIn.ClaimsSources +{ + public class ClaimValuesContext : IApplicationValuesContext + { + private readonly SafeHttpContextAccessor _httpContextAccessor; + private readonly ApplicationGlobalValuesProvider _globalValuesProvider; + + public ClaimValuesContext(SafeHttpContextAccessor httpContextAccessor, ApplicationGlobalValuesProvider globalValuesProvider) + { + _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); + _globalValuesProvider = globalValuesProvider ?? throw new ArgumentNullException(nameof(globalValuesProvider)); + } + + public IReadOnlyList this[string key] => GetValues(key); + + private IReadOnlyList GetValues(string key) + { + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException($"'{nameof(key)}' cannot be null or whitespace.", nameof(key)); + } + + if (ApplicationGlobalValuesMetadata.HasKey(key)) + { + return _globalValuesProvider.GetValues(ApplicationGlobalValuesMetadata.ParseKey(key)); + } + + return _httpContextAccessor.SafeGetLdapAttributes().GetValues(key); + } + } +} \ No newline at end of file diff --git a/Stories/SignIn/ClaimsSources/MultiFactorClaimsSource.cs b/Stories/SignIn/ClaimsSources/MultiFactorClaimsSource.cs new file mode 100644 index 0000000..c859e9a --- /dev/null +++ b/Stories/SignIn/ClaimsSources/MultiFactorClaimsSource.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Globalization; +using MultiFactor.SelfService.Windows.Portal.Core.Authentication.AuthenticationClaims; +using MultiFactor.SelfService.Windows.Portal.Core.Http; +using MultiFactor.SelfService.Windows.Portal.Extensions; +using static MultiFactor.SelfService.Windows.Portal.Constants.Configuration; + +namespace MultiFactor.SelfService.Windows.Portal.Stories.SignIn.ClaimsSources +{ + public class MultiFactorClaimsSource : IClaimsSource + { + private readonly SafeHttpContextAccessor _httpContextAccessor; + + public MultiFactorClaimsSource(SafeHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public IReadOnlyDictionary GetClaims() + { + var result = _httpContextAccessor.SafeGetCredVerificationResult(); + var claims = new Dictionary + { + { MultiFactorClaims.RawUserName, result.Username } + }; + + if (result.UserMustChangePassword) + { + claims.Add(MultiFactorClaims.ChangePassword, "true"); + return claims; + } + + claims.Add(MultiFactorClaims.PasswordExpirationDate, + result.PasswordExpirationDate.ToString(CultureInfo.InvariantCulture)); + + return claims; + } + } +} \ No newline at end of file diff --git a/Stories/SignIn/ClaimsSources/SsoClaimsSource.cs b/Stories/SignIn/ClaimsSources/SsoClaimsSource.cs new file mode 100644 index 0000000..d3f8460 --- /dev/null +++ b/Stories/SignIn/ClaimsSources/SsoClaimsSource.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using MultiFactor.SelfService.Windows.Portal.Core.Authentication.AuthenticationClaims; +using MultiFactor.SelfService.Windows.Portal.Core.Http; +using MultiFactor.SelfService.Windows.Portal.Extensions; +using static MultiFactor.SelfService.Windows.Portal.Constants.Configuration; + +namespace MultiFactor.SelfService.Windows.Portal.Stories.SignIn.ClaimsSources +{ + public class SsoClaimsSource : IClaimsSource + { + private readonly SafeHttpContextAccessor _httpContextAccessor; + private readonly Configuration _portalSettings; + + public SsoClaimsSource(SafeHttpContextAccessor httpContextAccessor, Configuration portalSettings) + { + _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); + _portalSettings = portalSettings; + } + + public IReadOnlyDictionary GetClaims() + { + var sso = _httpContextAccessor.SafeGetSsoClaims(); + var claims = new Dictionary(); + + if (sso.HasSamlSession()) + { + claims.Add(MultiFactorClaims.SamlSessionId, sso.SamlSessionId); + claims.Add(MultiFactorClaims.AdditionSsoStep, "true"); + } + + if (sso.HasOidcSession()) + { + claims.Add(MultiFactorClaims.OidcSessionId, sso.OidcSessionId); + claims.Add(MultiFactorClaims.AdditionSsoStep, "true"); + } + + return claims; + } + } +} \ No newline at end of file diff --git a/Stories/SignIn/IdentityStory.cs b/Stories/SignIn/IdentityStory.cs new file mode 100644 index 0000000..81725f2 --- /dev/null +++ b/Stories/SignIn/IdentityStory.cs @@ -0,0 +1,207 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Web.Mvc; +using System; +using Serilog; +using MultiFactor.SelfService.Windows.Portal.Extensions; +using MultiFactor.SelfService.Windows.Portal.Integrations.Ldap.CredentialVerification; +using MultiFactor.SelfService.Windows.Portal.Core.Authentication.AuthenticationClaims; +using MultiFactor.SelfService.Windows.Portal.Core.Http; +using MultiFactor.SelfService.Windows.Portal.Exceptions; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Enums; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi; +using System.Linq; +using static MultiFactor.SelfService.Windows.Portal.Constants.Configuration; +using System.Web.UI.WebControls; +using MultiFactor.SelfService.Windows.Portal.Models; + +namespace MultiFactor.SelfService.Windows.Portal.Stories.SignIn +{ + public class IdentityStory + { + private readonly IMultiFactorApi _multifactorApiClient; + private readonly IMultifactorIdpApi _idpApiClient; + private readonly SafeHttpContextAccessor _contextAccessor; + private readonly Configuration _settings; + private readonly ILogger _logger; + private readonly ClaimsProvider _claimsProvider; + private readonly ICredentialVerifier _credentialVerifier; + + public IdentityStory( + IMultiFactorApi multifactorApiClient, + IMultifactorIdpApi idpApiClient, + SafeHttpContextAccessor contextAccessor, + Configuration settings, + ILogger logger, + ClaimsProvider claimsProvider, + ICredentialVerifier credentialVerifier) + { + _multifactorApiClient = multifactorApiClient; + _idpApiClient = idpApiClient; + _contextAccessor = contextAccessor; + _settings = settings; + _logger = logger; + _claimsProvider = claimsProvider; + _credentialVerifier = credentialVerifier; + } + + public async Task ExecuteAsync(IdentityModel model, Dictionary headers) + { + if (model == null) + { + throw new ArgumentNullException(nameof(model)); + } + if (headers == null) + { + throw new ArgumentNullException(nameof(headers)); + } + + var username = model.UserName.Trim(); + + // Validate username format if UPN is required + if (_settings.RequiresUpn && !IsUserPrincipalName(username)) + { + _logger.Warning("UPN format required but not provided for user input"); + throw new ModelStateErrorException("WrongUserNameOrPassword"); + } + + VerifiedMembershipDto verifiedMembership = null; + var verifiedUsername = default(string); + // Verify membership locally if prebind info is needed + if (_settings.NeedPrebindInfo()) + { + _logger.Debug("Verifying membership locally for user '{User}'", username); + var membershipResult = await _credentialVerifier.VerifyMembership(username); + verifiedUsername = membershipResult.Username; + + if (!membershipResult.IsAuthenticated) + { + _logger.Warning("Membership verification failed for user '{User}': {Reason}", username, membershipResult.Reason); + throw new ModelStateErrorException("WrongUserNameOrPassword"); + } + + _logger.Information("User '{User}' membership verified successfully", username); + verifiedMembership = MapToVerifiedMembershipDto(membershipResult); + } + + var authenticators = await _multifactorApiClient.GetUserAuthenticatorsAsync(username); + if (!authenticators.GetAuthenticators().Any()) + { + return new ViewResult + { + ViewName = "Login", + ViewData = new ViewDataDictionary + { + Model = new LoginModel() + { + UserName = username + } + + } + }; + } + + var claims = _claimsProvider.GetClaims().ToDictionary(kv => kv.Key, kv => kv.Value); + claims[AuthenticationClaims.AUTHENTICATION_METHODS_REFERENCES] = AuthenticationClaims.PASSWORD_METHOD; + + var sso = _contextAccessor.SafeGetSsoClaims(); + var postbackUrl = model.MyUrl.BuildPostbackUrl(); + + var request = new IdentityRequestDto + { + Username = verifiedUsername, + VerifiedMembership = verifiedMembership, + SamlSessionId = sso.SamlSessionId, + OidcSessionId = sso.OidcSessionId, + LoginCompletedCallbackUrl = postbackUrl, + AdditionalClaims = claims.ToDictionary(x => x.Key, x => x.Value), + Settings = BuildSspSettings() + }; + + var response = await _idpApiClient.IdentityAsync(request, headers); + return HandleIdentityResponse(response, model); + } + + private IdentitySspSettingsDto BuildSspSettings() + { + return new IdentitySspSettingsDto + { + PreAuthenticationMethod = _settings.PreAuthnMode, + RequiresUserPrincipalName = _settings.RequiresUpn, + NeedPrebindInfo = _settings.NeedPrebindInfo(), + UseUpnAsIdentity = _settings.UseUpnAsIdentity, + NetBiosName = _settings.NetBiosName, + SignUpGroups = _settings.SignUpGroups + }; + } + + private ActionResult HandleIdentityResponse(IdentityResponseDto response, IdentityModel model) + { + if (response.Action == IdentityAction.AccessDenied) + { + _logger.Warning("Access denied for user '{User}'", model.UserName); + return new RedirectToActionResult().ToActionResult("AccessDenied", "Error", null); + } + + if (!response.Success) + { + _logger.Debug("Identity verification failed: {Error}", response.ErrorMessage); + throw new ModelStateErrorException("WrongUserNameOrPassword"); + } + + if (response.Action == IdentityAction.MfaRequired && !string.IsNullOrWhiteSpace(response.RedirectUrl)) + { + _logger.Debug("Redirecting user '{User}' to MFA page", model.UserName); + return new RedirectResult(response.RedirectUrl, true); + } + + if (response.Action == IdentityAction.ShowAuthn) + { + var identity = response.Username ?? model.UserName; + _logger.Information("Bypass second factor for user '{User}', showing password form", identity); + + return new ViewResult + { + ViewName = "Authn", + ViewData = new ViewDataDictionary + { + Model = new IdentityModel + { + UserName = identity, + Password = model.Password, + MyUrl = model.MyUrl, + AccessToken = model.AccessToken + } + } + }; + } + + if (!string.IsNullOrWhiteSpace(response.RedirectUrl)) + { + return new RedirectResult(response.RedirectUrl, true); + } + + throw new ModelStateErrorException("WrongUserNameOrPassword"); + } + + private static VerifiedMembershipDto MapToVerifiedMembershipDto(CredentialVerificationResult result) + { + return new VerifiedMembershipDto + { + IsBypass = result.IsBypass, + DisplayName = result.DisplayName, + Email = result.Email, + Phone = result.Phone, + UserPrincipalName = result.UserPrincipalName, + CustomIdentity = result.CustomIdentity + }; + } + + private static bool IsUserPrincipalName(string username) + { + return username.Contains('@'); + } + } +} \ No newline at end of file diff --git a/Stories/SignIn/RedirectToCredValidationAfter2FaStory.cs b/Stories/SignIn/RedirectToCredValidationAfter2FaStory.cs new file mode 100644 index 0000000..8bb227c --- /dev/null +++ b/Stories/SignIn/RedirectToCredValidationAfter2FaStory.cs @@ -0,0 +1,131 @@ +using System.Threading.Tasks; +using System; +using Serilog; +using MultiFactor.SelfService.Windows.Portal.Extensions; +using MultiFactor.SelfService.Windows.Portal.Core.Http; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi; +using MultiFactor.SelfService.Windows.Portal.Core.Caching; +using MultiFactor.SelfService.Windows.Portal.Core; +using MultiFactor.SelfService.Windows.Portal.Stories.Authenticate; +using System.IdentityModel.Tokens.Jwt; +using System.Web.Mvc; +using MultiFactor.SelfService.Windows.Portal.Models; + +namespace MultiFactor.SelfService.Windows.Portal.Stories.SignIn +{ + public class RedirectToCredValidationAfter2FaStory + { + private readonly ILogger _logger; + private readonly IApplicationCache _applicationCache; + private readonly IMultifactorIdpApi _idpApi; + private readonly SafeHttpContextAccessor _contextAccessor; + private readonly AuthenticateSessionStory _authenticateSessionStory; + + public RedirectToCredValidationAfter2FaStory( + IApplicationCache applicationCache, + ILogger logger, + IMultifactorIdpApi idpApi, + AuthenticateSessionStory authenticateSessionStory, + SafeHttpContextAccessor contextAccessor) + { + _logger = logger; + _applicationCache = applicationCache; + _idpApi = idpApi; + _authenticateSessionStory = authenticateSessionStory; + _contextAccessor = contextAccessor; + } + + public async Task ExecuteAsync(string accessToken) + { + if (accessToken == null) + { + throw new ArgumentNullException(nameof(accessToken)); + } + _logger.Debug("Extracting token information for PreAuthenticationMethod flow"); + + var handler = new JwtSecurityTokenHandler(); + JwtSecurityToken token; + try + { + token = handler.ReadJwtToken(accessToken); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to parse access token"); + return new RedirectToActionResult().ToActionResult("Login", "Account", null); + } + + var requestId = token.Id; + if (string.IsNullOrEmpty(requestId)) + { + _logger.Error("Token ID is missing"); + return new RedirectToActionResult().ToActionResult("Login", "Account", null); + } + + var request = new LoginCompletedRequestDto + { + AccessToken = accessToken + }; + + var authCacheResult = _applicationCache.GetPreauthenticationAuthn(ApplicationCacheKeyFactory.CreatePreAuthenticationAuthnSucceedKey(token.Subject)); + if (!authCacheResult.IsEmpty && authCacheResult.Value) + { + _applicationCache.Remove(ApplicationCacheKeyFactory.CreatePreAuthenticationAuthnSucceedKey(token.Subject)); + return await _authenticateSessionStory.Execute(accessToken); + } + + try + { + var response = await _idpApi.LoginCompletedAsync(request, _contextAccessor.HttpContext.GetRequiredHeaders()); + + if (!response.Success) + { + _logger.Warning("LoginCompleted failed after pre-auth MFA: {Error}", response.ErrorMessage); + return new RedirectToActionResult().ToActionResult("AccessDenied", "Error", null); + } + + var username = !string.IsNullOrWhiteSpace(response.RawUserName) + ? response.RawUserName + : response.Identity; + + if (string.IsNullOrEmpty(username)) + { + _logger.Error("Can't determine username from token"); + return new RedirectToActionResult().ToActionResult("Login", "Account", null); + } + + _applicationCache.SetIdentity(requestId, + new IdentityModel + { + UserName = username, + AccessToken = accessToken + }); + + object routeValue = new { requestId = requestId }; + + if (!string.IsNullOrEmpty(response.SamlSessionId)) + { + _logger.Debug("SAML session found, redirecting to Identity with SAML session"); + routeValue = new { samlSessionId = response.SamlSessionId, requestId = requestId }; + return new RedirectToActionResult().ToActionResult("Identity", "Account", routeValue); + } + + if (!string.IsNullOrEmpty(response.OidcSessionId)) + { + _logger.Debug("OIDC session found, redirecting to Identity with OIDC session"); + routeValue = new { oidcSessionId = response.OidcSessionId, requestId = requestId }; + return new RedirectToActionResult().ToActionResult("Identity", "Account", routeValue); + } + + _logger.Debug("Redirecting to Identity page for password entry"); + return new RedirectToActionResult().ToActionResult("Identity", "Account", routeValue); + } + catch (Exception e) + { + _logger.Error(e, "Failed to extract token information"); + return new RedirectToActionResult().ToActionResult("Login", "Account", null); + } + } + } +} \ No newline at end of file diff --git a/Stories/SignIn/SignInStory.cs b/Stories/SignIn/SignInStory.cs new file mode 100644 index 0000000..32fcc02 --- /dev/null +++ b/Stories/SignIn/SignInStory.cs @@ -0,0 +1,248 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Web.Mvc; +using System; +using Serilog; +using MultiFactor.SelfService.Windows.Portal.Core; +using MultiFactor.SelfService.Windows.Portal.Services.Ldap; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi; +using MultiFactor.SelfService.Windows.Portal.Core.Http; +using MultiFactor.SelfService.Windows.Portal.Core.Caching; +using MultiFactor.SelfService.Windows.Portal.Core.Authentication.AuthenticationClaims; +using MultiFactor.SelfService.Windows.Portal; +using MultiFactor.SelfService.Windows.Portal.Exceptions; +using System.Linq; +using static MultiFactor.SelfService.Windows.Portal.Constants.Configuration; +using MultiFactor.SelfService.Windows.Portal.Extensions; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Enums; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Dto; +using MultiFactor.SelfService.Windows.Portal.Integrations.Ldap.CredentialVerification; +using MultiFactor.SelfService.Windows.Portal.Stories; +using System.Web.UI.WebControls; +using MultiFactor.SelfService.Windows.Portal.Models; + +public class SignInStory +{ + private readonly IMultifactorIdpApi _idpApiClient; + private readonly IMultiFactorApi _apiClient; + private readonly DataProtection _dataProtection; + private readonly SafeHttpContextAccessor _contextAccessor; + private readonly Configuration _settings; + private readonly ILogger _logger; + private readonly IApplicationCache _applicationCache; + private readonly ClaimsProvider _claimsProvider; + private readonly ICredentialVerifier _credentialVerifier; + + public SignInStory( + IMultifactorIdpApi idpApiClient, + IMultiFactorApi apiClient, + DataProtection dataProtection, + SafeHttpContextAccessor contextAccessor, + Configuration settings, + IApplicationCache applicationCache, + ILogger logger, + ClaimsProvider claimsProvider, + ICredentialVerifier credentialVerifier + ) + { + _idpApiClient = idpApiClient; + _apiClient = apiClient; + _dataProtection = dataProtection; + _contextAccessor = contextAccessor; + _settings = settings; + _logger = logger; + _applicationCache = applicationCache; + _claimsProvider = claimsProvider; + _credentialVerifier = credentialVerifier; + } + + public async Task ExecuteAsync(LoginModel model, Dictionary headers) + { + if (model == null) + { + throw new ArgumentNullException(nameof(model)); + } + if (headers == null) + { + throw new ArgumentNullException(nameof(headers)); + } + + var username = model.UserName.Trim(); + var password = model.Password.Trim(); + + + if (_settings.RequiresUpn && !IsUserPrincipalName(username)) + { + _logger.Warning("UPN format required but not provided for user input"); + throw new ModelStateErrorException("WrongUserNameOrPassword"); + } + + var userName = LdapIdentity.ParseUser(username); + _logger.Debug("Verifying credentials locally for user '{User}'", username); + var credentialResult = await _credentialVerifier.VerifyCredentialAsync(username, password); + + if (!credentialResult.IsAuthenticated && !credentialResult.UserMustChangePassword) + { + _logger.Warning("Credential verification failed for user '{User}': {Reason}", username, credentialResult.Reason); + await DelayedFailureAsync(); + throw new ModelStateErrorException("WrongUserNameOrPassword"); + } + + _logger.Information("User '{User}' credentials verified successfully", username); + + var claims = _claimsProvider.GetClaims().ToDictionary(x => x.Key, x => x.Value); + claims.Add(AuthenticationClaims.AUTHENTICATION_METHODS_REFERENCES, AuthenticationClaims.PASSWORD_METHOD); + + var sso = _contextAccessor.SafeGetSsoClaims(); + var postbackUrl = model.MyUrl.BuildPostbackUrl(); + + var request = new LoginRequestDto + { + VerifiedCredentials = MapToVerifiedCredentialsDto(credentialResult), + SamlSessionId = sso.SamlSessionId, + OidcSessionId = sso.OidcSessionId, + LoginCompletedCallbackUrl = postbackUrl, + AdditionalClaims = claims.ToDictionary(x => x.Key, x => x.Value), + Settings = BuildSspSettings() + }; + + var response = await _idpApiClient.LoginAsync(request, headers); + + return await HandleLoginResponse(response, model, credentialResult); + } + + private SspSettingsDto BuildSspSettings() + { + return new SspSettingsDto + { + PreAuthenticationMethod = _settings.PreAuthnMode, + RequiresUserPrincipalName = _settings.RequiresUpn, + PasswordManagementEnabled = _settings.EnablePasswordManagement, + NeedPrebindInfo = _settings.NeedPrebindInfo(), + NetBiosName = _settings.NetBiosName, + SignUpGroups = _settings.SignUpGroups + }; + } + + private async Task HandleLoginResponse(LoginResponseDto response, LoginModel model, CredentialVerificationResult adValidationResult) + { + if (response.Action == LoginAction.AccessDenied) + { + _logger.Warning("Access denied for user '{User}'", model.UserName); + return new RedirectToActionResult().ToActionResult("AccessDenied", "Error", null); + } + + if (!response.Success) + { + _logger.Debug("Login failed: {Error}", response.ErrorMessage); + throw new ModelStateErrorException("WrongUserNameOrPassword"); + } + + if (response.Action == LoginAction.MfaRequired && !string.IsNullOrWhiteSpace(response.RedirectUrl)) + { + if (_settings.PreAuthnMode) + { + _applicationCache.SetPreauthenticationAuthn( + ApplicationCacheKeyFactory.CreatePreAuthenticationAuthnSucceedKey(adValidationResult.Username), + true); + } + + _logger.Debug("Redirecting user to MFA page"); + return new RedirectResult(response.RedirectUrl, true); + } + + if (response.Action == LoginAction.BypassSaml) + { + _logger.Debug("Bypass second factor for user '{User}' via SAML", model.UserName); + + var userIdentity = GetIdentity(adValidationResult); + + var sso = _contextAccessor.SafeGetSsoClaims(); + var user = new UserProfileDto(string.Empty, userIdentity) + { + Email = adValidationResult.Email, + Name = adValidationResult.DisplayName, + }; + + var page = await _apiClient.CreateSamlBypassRequestAsync(user, sso.SamlSessionId); + return new RedirectToActionResult().ToActionResult("ByPassSsoSession", "Account", + new { callbackUrl = page.CallbackUrl, accessToken = page.AccessToken }); + } + + if (response.Action == LoginAction.BypassOidc) + { + _logger.Debug("Bypass second factor for user '{User}' via OIDC", model.UserName); + var sso = _contextAccessor.SafeGetSsoClaims(); + return new RedirectToActionResult().ToActionResult("ByPassOidcSession", "Account", + new { oidcSession = sso.OidcSessionId }); + } + + if (response.Action == LoginAction.ChangePassword) + { + _logger.Information("User '{User}' must change password", model.UserName); + + var encryptedPassword = _dataProtection.Protect( + model.Password.Trim(), + Constants.PWD_RENEWAL_PURPOSE); + _applicationCache.Set( + ApplicationCacheKeyFactory.CreateExpiredPwdUserKey(model.UserName), + model.UserName.Trim()); + _applicationCache.Set( + ApplicationCacheKeyFactory.CreateExpiredPwdCipherKey(model.UserName), + encryptedPassword); + + if (!string.IsNullOrWhiteSpace(response.RedirectUrl)) + { + return new RedirectResult(response.RedirectUrl, true); + } + + return new RedirectToActionResult().ToActionResult("Change", "ExpiredPassword", null); + } + + if (!string.IsNullOrWhiteSpace(response.RedirectUrl)) + { + return new RedirectResult(response.RedirectUrl, true); + } + + throw new ModelStateErrorException("WrongUserNameOrPassword"); + } + + private static VerifiedCredentialsDto MapToVerifiedCredentialsDto(CredentialVerificationResult result) + { + return new VerifiedCredentialsDto + { + IsAuthenticated = result.IsAuthenticated, + IsBypass = result.IsBypass, + UserMustChangePassword = result.UserMustChangePassword, + PasswordExpirationDate = result.PasswordExpirationDate, + DisplayName = result.DisplayName, + Email = result.Email, + Phone = result.Phone, + Username = result.Username, + UserPrincipalName = result.UserPrincipalName, + CustomIdentity = result.CustomIdentity, + Reason = result.Reason + }; + } + + private static bool IsUserPrincipalName(string username) + { + return username.Contains('@'); + } + + private static async Task DelayedFailureAsync() + { + var rnd = new Random(); + var delay = rnd.Next(2, 6); + await Task.Delay(TimeSpan.FromSeconds(delay)); + } + + private string GetIdentity(CredentialVerificationResult verificationResult) + { + return !string.IsNullOrWhiteSpace(verificationResult.CustomIdentity) + ? verificationResult.CustomIdentity + : verificationResult.Username; + } +} diff --git a/Stories/SignOut/SignOutStory.cs b/Stories/SignOut/SignOutStory.cs new file mode 100644 index 0000000..e5dfd49 --- /dev/null +++ b/Stories/SignOut/SignOutStory.cs @@ -0,0 +1,109 @@ +using System.Collections.Generic; +using System; +using System.Text; +using System.Threading.Tasks; +using System.Web.Mvc; +using Serilog; +using MultiFactor.SelfService.Windows.Portal.Core.Http; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi.Dto; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorIdpApi; +using MultiFactor.SelfService.Windows.Portal.Extensions; +using MultiFactor.SelfService.Windows.Portal.ModelBinding.Binders; +using static MultiFactor.SelfService.Windows.Portal.Constants.Configuration; + +namespace MultiFactor.SelfService.Windows.Portal.Stories.SignOut +{ + + public class SignOutStory + { + private readonly SafeHttpContextAccessor _contextAccessor; + private readonly IMultifactorIdpApi _idpApi; + private readonly ILogger _logger; + + public SignOutStory(SafeHttpContextAccessor contextAccessor, IMultifactorIdpApi idpApi, ILogger logger) + { + _contextAccessor = contextAccessor; + _idpApi = idpApi; + _logger = logger; + } + + public ActionResult Execute() + { + _contextAccessor.HttpContext.Response.Cookies[Constants.COOKIE_NAME].Expires = DateTime.Now.AddDays(-1); + + var request = new LogoutRequestDto + { + Reason = "logout" + }; + + try + { + var headers = _contextAccessor.HttpContext.GetRequiredHeaders(); + var response = _idpApi.LogoutAsync(request, headers).GetAwaiter().GetResult(); + + if (!response.Success) + { + _logger.Warning("Logout failed: {Error}", response.ErrorMessage); + } + } + catch (Exception ex) + { + _logger.Error(ex, "Error during logout"); + } + + var redirectUrl = new StringBuilder("/account/login"); + var claimsDto = MultiFactorClaimsDtoBinder.FromRequest(_contextAccessor.HttpContext.Request); + if (claimsDto.HasSamlSession()) + { + redirectUrl.Append($"?{MultiFactorClaims.SamlSessionId}={claimsDto.SamlSessionId}"); + } + + if (claimsDto.HasOidcSession()) + { + redirectUrl.Append($"?{MultiFactorClaims.OidcSessionId}={claimsDto.OidcSessionId}"); + } + + var res = redirectUrl.ToString(); + + return new RedirectResult(res, false); + } + + public async Task ExecuteAsync(Dictionary headers) + { + if (headers == null) + { + throw new ArgumentNullException(nameof(headers)); + } + + _contextAccessor.HttpContext.Response.Cookies[Constants.COOKIE_NAME].Expires = DateTime.Now.AddDays(-1); + + var request = new LogoutRequestDto + { + Reason = "logout" + }; + + var response = await _idpApi.LogoutAsync(request, headers); + + if (!response.Success) + { + _logger.Warning("Logout failed: {Error}", response.ErrorMessage); + } + + var redirectUrl = new StringBuilder("/account/login"); + var claimsDto = MultiFactorClaimsDtoBinder.FromRequest(_contextAccessor.HttpContext.Request); + if (claimsDto.HasSamlSession()) + { + redirectUrl.Append($"?{MultiFactorClaims.SamlSessionId}={claimsDto.SamlSessionId}"); + } + + if (claimsDto.HasOidcSession()) + { + redirectUrl.Append($"?{MultiFactorClaims.OidcSessionId}={claimsDto.OidcSessionId}"); + } + + var res = redirectUrl.ToString(); + + return new RedirectResult(res, false); + } + } +} \ No newline at end of file diff --git a/Stories/UnlockUser/UnlockUserStory.cs b/Stories/UnlockUser/UnlockUserStory.cs new file mode 100644 index 0000000..0acc627 --- /dev/null +++ b/Stories/UnlockUser/UnlockUserStory.cs @@ -0,0 +1,101 @@ +using System.DirectoryServices.AccountManagement; +using System.Threading.Tasks; +using System.Web.Mvc; +using System; +using Serilog; +using MultiFactor.SelfService.Windows.Portal.Exceptions; +using MultiFactor.SelfService.Windows.Portal.Integrations.Ldap.CredentialVerification; +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi; +using MultiFactor.SelfService.Windows.Portal.Models.PasswordRecovery; +using MultiFactor.SelfService.Windows.Portal.Services.Ldap; +using MultiFactor.SelfService.Windows.Portal.Extensions; +using MultiFactor.SelfService.Windows.Portal.Services; + +namespace MultiFactor.SelfService.Windows.Portal.Stories +{ + + public class UnlockUserStory + { + private readonly ActiveDirectoryService _activeDirectoryService; + private readonly IMultiFactorApi _apiClient; + private readonly Configuration _portalSettings; + private readonly ICredentialVerifier _credentialVerifier; + private readonly ILogger _logger; + + public UnlockUserStory( + ActiveDirectoryService lockAttributeChanger, + IMultiFactorApi apiClient, + Configuration portalSettings, + ICredentialVerifier credentialVerifier, + ILogger logger) + { + _activeDirectoryService = lockAttributeChanger; + _apiClient = apiClient; + _portalSettings = portalSettings; + _credentialVerifier = credentialVerifier; + _logger = logger; + } + + public async Task CallSecondFactorAsync(EnterIdentityForm form) + { + if (form is null) + { + throw new ArgumentNullException(nameof(form)); + } + + if (string.IsNullOrWhiteSpace(form.Identity)) + { + throw new ArgumentNullException(nameof(form.Identity)); + } + + if (string.IsNullOrWhiteSpace(form.MyUrl)) + { + throw new ArgumentNullException(nameof(form.MyUrl)); + } + + if (!_portalSettings.AllowUserUnlock) + throw new InvalidOperationException(); + + if (_portalSettings.RequiresUpn) + { + // AD requires UPN check + var userName = LdapIdentity.ParseUser(form.Identity); + if (userName.Type != IdentityType.UserPrincipalName) + { + throw new ModelStateErrorException("UserNameUpnRequired"); + } + } + + var identity = form.Identity.Trim(); + if (_portalSettings.UseUpnAsIdentity) + { + var adValidationResult = await _credentialVerifier.VerifyMembership(identity); + identity = adValidationResult.UserPrincipalName; + } + + var callback = form.MyUrl.BuildRelativeUrl("Unlock/Complete", 2); + try + { + var response = await _apiClient.StartUnlockingUser(identity, callback); + return new RedirectResult(response.Url); + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to recover password for user '{u:l}': {m:l}", form.Identity, ex.Message); + throw new ModelStateErrorException("AD.UnableToChangePassword"); + } + } + + public async Task UnlockUserAsync(string identity) + { + if (!_portalSettings.AllowUserUnlock) + throw new InvalidOperationException(); + + if (string.IsNullOrWhiteSpace(identity)) + throw new ArgumentNullException(nameof(identity)); + + var result = _activeDirectoryService.UnlockUser(identity); + return result; + } + } +} \ No newline at end of file diff --git a/ViewModels/ChangeExpiredPasswordViewModel.cs b/ViewModels/ChangeExpiredPasswordViewModel.cs new file mode 100644 index 0000000..f5f4410 --- /dev/null +++ b/ViewModels/ChangeExpiredPasswordViewModel.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace MultiFactor.SelfService.Windows.Portal.ViewModels +{ + public class ChangeExpiredPasswordViewModel + { + [Required(ErrorMessage = "Required")] + [DataType(DataType.Password)] + [MinLength(1, ErrorMessage = "Required")] + public string NewPassword { get; set; } + + [Required(ErrorMessage = "Required")] + [Compare("NewPassword", ErrorMessage = "PasswordsDoNotMatch")] + [DataType(DataType.Password)] + public string NewPasswordAgain { get; set; } + } +} diff --git a/ViewModels/ChangePasswordViewModel.cs b/ViewModels/ChangePasswordViewModel.cs new file mode 100644 index 0000000..f857690 --- /dev/null +++ b/ViewModels/ChangePasswordViewModel.cs @@ -0,0 +1,24 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace MultiFactor.SelfService.Windows.Portal.ViewModels +{ + public class ChangePasswordViewModel + { + [Required(ErrorMessage = "Required")] + [DataType(DataType.Password)] + public string Password { get; set; } + + [Required(ErrorMessage = "Required")] + [DataType(DataType.Password)] + [MinLength(1, ErrorMessage = "Required")] + public string NewPassword { get; set; } + + [Required(ErrorMessage = "Required")] + [Compare("NewPassword", ErrorMessage = "PasswordsDoNotMatch")] + [DataType(DataType.Password)] + public string NewPasswordAgain { get; set; } + + public string[] Requirements { get; set; } = Array.Empty(); + } +} diff --git a/ViewModels/ShowcaseViewModel.cs b/ViewModels/ShowcaseViewModel.cs new file mode 100644 index 0000000..de2da73 --- /dev/null +++ b/ViewModels/ShowcaseViewModel.cs @@ -0,0 +1,13 @@ +using MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Dto; +using MultiFactor.SelfService.Windows.Portal.Settings; +using System.Collections.Generic; + +namespace MultiFactor.SelfService.Windows.Portal.ViewModels +{ + public class ShowcaseViewModel + { + public UserProfileDto Profile { get; set; } + + public IReadOnlyCollection ShowcaseLinks { get; set; } + } +} diff --git a/Views/Account/ByPassSsoSession.cshtml b/Views/Account/ByPassSsoSession.cshtml new file mode 100644 index 0000000..0d84547 --- /dev/null +++ b/Views/Account/ByPassSsoSession.cshtml @@ -0,0 +1,18 @@ +@model MultiFactor.SelfService.Windows.Portal.Integrations.MultiFactorApi.Dto.BypassPageDto + +@{ + Layout = null; +} + + + + + @Resources.Global.PleaseWait + + +
+ +
+ + + \ No newline at end of file diff --git a/Views/Home/Index.cshtml b/Views/Home/Index.cshtml index ef85fba..409c673 100644 --- a/Views/Home/Index.cshtml +++ b/Views/Home/Index.cshtml @@ -1,4 +1,4 @@ -@model MultiFactor.SelfService.Windows.Portal.Services.API.DTO.UserProfile +@model MultiFactor.SelfService.Windows.Portal.ViewModels.ShowcaseViewModel @{ ViewBag.Title = string.Format(Resources.Global.SiteName, Configuration.Current.CompanyName); @@ -6,48 +6,48 @@