From 56cfce50c060f49db8c971864829368ed8673dfe Mon Sep 17 00:00:00 2001 From: Tino Hager Date: Thu, 13 Jun 2024 23:09:14 +0200 Subject: [PATCH] add mfa (wip) --- .../Entities/UserEntity.cs | 5 + .../Models/AuthenticationRequest.cs | 14 ++ .../Models/AuthenticationStatus.cs | 1 + ...equest.cs => UserChangePasswordRequest.cs} | 2 +- .../Services/IUserAccountService.cs | 31 +++- .../Services/IUserAuthenticationService.cs | 5 + .../Controllers/AuthenticationController.cs | 103 ++++++++++++- .../Controllers/UserAccountController.cs | 57 +++++++- .../Dtos/TimeBasedOneTimeTokenRequestDto.cs | 7 + .../Program.cs | 9 +- .../Properties/launchSettings.json | 19 +-- .../appsettings.json | 2 +- .../wwwroot/account.html | 137 ++++++++++++++++++ .../wwwroot/index.html | 40 ++--- .../wwwroot/management.html | 4 +- .../wwwroot/test.html | 4 +- .../Nager.Authentication.csproj | 5 + .../Services/UserAccountService.cs | 62 +++++++- .../Services/UserAuthenticationService.cs | 42 ++++-- .../Services/UserManagementService.cs | 9 ++ 20 files changed, 488 insertions(+), 70 deletions(-) rename src/Nager.Authentication.Abstraction/Models/{UserUpdatePasswordRequest.cs => UserChangePasswordRequest.cs} (72%) create mode 100644 src/Nager.Authentication.AspNet/Dtos/TimeBasedOneTimeTokenRequestDto.cs create mode 100644 src/Nager.Authentication.TestProject.WebApp/wwwroot/account.html diff --git a/src/Nager.Authentication.Abstraction/Entities/UserEntity.cs b/src/Nager.Authentication.Abstraction/Entities/UserEntity.cs index 7017276..3a086b5 100644 --- a/src/Nager.Authentication.Abstraction/Entities/UserEntity.cs +++ b/src/Nager.Authentication.Abstraction/Entities/UserEntity.cs @@ -30,6 +30,11 @@ public class UserEntity public DateTime? LastSuccessfulValidationTimestamp { get; set; } + [MaxLength(32)] + public byte[]? mfaSecret { get; set; } + + public bool mfaActive { get; set; } + public bool IsLocked { get; set; } } } diff --git a/src/Nager.Authentication.Abstraction/Models/AuthenticationRequest.cs b/src/Nager.Authentication.Abstraction/Models/AuthenticationRequest.cs index 6c46a51..9072cba 100644 --- a/src/Nager.Authentication.Abstraction/Models/AuthenticationRequest.cs +++ b/src/Nager.Authentication.Abstraction/Models/AuthenticationRequest.cs @@ -1,9 +1,23 @@ namespace Nager.Authentication.Abstraction.Models { + /// + /// Authentication Request + /// public class AuthenticationRequest { + /// + /// Email Address + /// public string EmailAddress { get; set; } + + /// + /// Password + /// public string Password { get; set; } + + /// + /// IpAddress + /// public string? IpAddress { get; set; } } } diff --git a/src/Nager.Authentication.Abstraction/Models/AuthenticationStatus.cs b/src/Nager.Authentication.Abstraction/Models/AuthenticationStatus.cs index bed75cf..5873523 100644 --- a/src/Nager.Authentication.Abstraction/Models/AuthenticationStatus.cs +++ b/src/Nager.Authentication.Abstraction/Models/AuthenticationStatus.cs @@ -4,6 +4,7 @@ public enum AuthenticationStatus { Invalid, Valid, + MfaCodeRequired, TemporaryBlocked } } diff --git a/src/Nager.Authentication.Abstraction/Models/UserUpdatePasswordRequest.cs b/src/Nager.Authentication.Abstraction/Models/UserChangePasswordRequest.cs similarity index 72% rename from src/Nager.Authentication.Abstraction/Models/UserUpdatePasswordRequest.cs rename to src/Nager.Authentication.Abstraction/Models/UserChangePasswordRequest.cs index ca7986b..f938acc 100644 --- a/src/Nager.Authentication.Abstraction/Models/UserUpdatePasswordRequest.cs +++ b/src/Nager.Authentication.Abstraction/Models/UserChangePasswordRequest.cs @@ -1,6 +1,6 @@ namespace Nager.Authentication.Abstraction.Models { - public class UserUpdatePasswordRequest + public class UserChangePasswordRequest { public string Password { get; set; } } diff --git a/src/Nager.Authentication.Abstraction/Services/IUserAccountService.cs b/src/Nager.Authentication.Abstraction/Services/IUserAccountService.cs index 361d73a..6ea4c5d 100644 --- a/src/Nager.Authentication.Abstraction/Services/IUserAccountService.cs +++ b/src/Nager.Authentication.Abstraction/Services/IUserAccountService.cs @@ -9,9 +9,38 @@ namespace Nager.Authentication.Abstraction.Services /// public interface IUserAccountService { + /// + /// Change Password + /// + /// + /// + /// + /// Task ChangePasswordAsync( string emailAddress, - UserUpdatePasswordRequest userChangePasswordRequest, + UserChangePasswordRequest userChangePasswordRequest, + CancellationToken cancellationToken = default); + + /// + /// Get Mfa activation qr code + /// + /// + /// + /// + Task GetMfaActivationQrCodeAsync( + string emailAddress, + CancellationToken cancellationToken = default); + + /// + /// Activate Mfa + /// + /// + /// + /// + /// + Task ActivateMfaAsync( + string emailAddress, + string token, CancellationToken cancellationToken = default); } } diff --git a/src/Nager.Authentication.Abstraction/Services/IUserAuthenticationService.cs b/src/Nager.Authentication.Abstraction/Services/IUserAuthenticationService.cs index 8884d63..9c4fce3 100644 --- a/src/Nager.Authentication.Abstraction/Services/IUserAuthenticationService.cs +++ b/src/Nager.Authentication.Abstraction/Services/IUserAuthenticationService.cs @@ -19,6 +19,11 @@ Task ValidateCredentialsAsync( AuthenticationRequest authenticationRequest, CancellationToken cancellationToken = default); + Task ValidateTokenAsync( + string emailAddress, + string token, + CancellationToken cancellationToken = default); + /// /// Get UserInfo /// diff --git a/src/Nager.Authentication.AspNet/Controllers/AuthenticationController.cs b/src/Nager.Authentication.AspNet/Controllers/AuthenticationController.cs index cc00e51..a3cfb15 100644 --- a/src/Nager.Authentication.AspNet/Controllers/AuthenticationController.cs +++ b/src/Nager.Authentication.AspNet/Controllers/AuthenticationController.cs @@ -49,7 +49,7 @@ public AuthenticationController( } private async Task CreateTokenAsync( - AuthenticationRequestDto request) + string emailAddress) { var issuer = this._configuration["Authentication:Tokens:Issuer"]; var audience = this._configuration["Authentication:Tokens:Audience"]; @@ -68,7 +68,7 @@ private async Task CreateTokenAsync( throw new MissingConfigurationException($"{nameof(signingKey)} is missing"); } - var userInfo = await this._userAuthenticationService.GetUserInfoAsync(request.EmailAddress); + var userInfo = await this._userAuthenticationService.GetUserInfoAsync(emailAddress); if (userInfo == null) { throw new UnknownUserException(); @@ -76,10 +76,11 @@ private async Task CreateTokenAsync( var claims = new List { - new(JwtRegisteredClaimNames.UniqueName, request.EmailAddress), - new(JwtRegisteredClaimNames.Email, request.EmailAddress) + new(JwtRegisteredClaimNames.UniqueName, emailAddress), + new(JwtRegisteredClaimNames.Email, emailAddress) }; + if (!string.IsNullOrEmpty(userInfo.Firstname)) { claims.Add(new Claim(JwtRegisteredClaimNames.GivenName, userInfo.Firstname)); @@ -108,6 +109,43 @@ private async Task CreateTokenAsync( signingCredentials: credentials); } + private JwtSecurityToken CreateTotpCheckToken( + AuthenticationRequestDto request) + { + var issuer = this._configuration["Authentication:Tokens:Issuer"]; + var audience = this._configuration["Authentication:Tokens:Audience"]; + var signingKey = this._configuration["Authentication:Tokens:SigningKey"]; + + var expiresAt = DateTime.UtcNow.AddMinutes(5); + + if (string.IsNullOrEmpty(issuer)) + { + throw new MissingConfigurationException($"{nameof(issuer)} is missing"); + } + + if (string.IsNullOrEmpty(signingKey)) + { + throw new MissingConfigurationException($"{nameof(signingKey)} is missing"); + } + + var temporaryIdentity = $"totp:{request.EmailAddress}"; + + var claims = new List + { + new(JwtRegisteredClaimNames.UniqueName, temporaryIdentity), + new(JwtRegisteredClaimNames.Email, temporaryIdentity) + }; + + var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)); + var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + + return new JwtSecurityToken(issuer, + audience, + claims, + expires: expiresAt, + signingCredentials: credentials); + } + /// /// Authenticate via Email and Password /// @@ -142,6 +180,8 @@ public async Task> AuthenticateAsync( var authenticationStatus = await this._userAuthenticationService.ValidateCredentialsAsync(authenticationRequest, cancellationToken); this._logger.LogInformation($"{nameof(AuthenticateAsync)} - EmailAddress:{request.EmailAddress}, AuthenticationStatus:{authenticationStatus}"); + var tokenHandler = new JwtSecurityTokenHandler(); + switch (authenticationStatus) { case AuthenticationStatus.Invalid: @@ -149,8 +189,7 @@ public async Task> AuthenticateAsync( case AuthenticationStatus.Valid: try { - var jwtSecurityToken = await this.CreateTokenAsync(request); - var tokenHandler = new JwtSecurityTokenHandler(); + var jwtSecurityToken = await this.CreateTokenAsync(request.EmailAddress); var token = tokenHandler.WriteToken(jwtSecurityToken); return StatusCode(StatusCodes.Status200OK, new AuthenticationResponseDto @@ -164,6 +203,15 @@ public async Task> AuthenticateAsync( this._logger.LogError(exception, $"{nameof(AuthenticateAsync)}"); return StatusCode(StatusCodes.Status500InternalServerError); } + case AuthenticationStatus.MfaCodeRequired: + var jwtTotpCheckToken = CreateTotpCheckToken(request); + var totpToken = tokenHandler.WriteToken(jwtTotpCheckToken); + + return StatusCode(StatusCodes.Status200OK, new AuthenticationResponseDto + { + Token = totpToken, + Expiration = jwtTotpCheckToken.ValidTo + }); case AuthenticationStatus.TemporaryBlocked: return StatusCode(StatusCodes.Status429TooManyRequests); default: @@ -171,6 +219,49 @@ public async Task> AuthenticateAsync( } } + /// + /// Second Step Time-based one-time password + /// + /// + /// + /// + ///// Authentication successful + ///// Invalid credential + ///// Credential check temporarily locked + ///// Unexpected error + [HttpPost] + [Route("Token")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status406NotAcceptable)] + [ProducesResponseType(StatusCodes.Status429TooManyRequests)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> SecondStepTotpAsync( + [Required][FromBody] TimeBasedOneTimeTokenRequestDto request, + CancellationToken cancellationToken = default) + { + + var emailAddress = HttpContext.User.Identity?.Name; + + var validEmailAddress = emailAddress.Substring(5); + + var isTokenValid = await this._userAuthenticationService.ValidateTokenAsync(validEmailAddress, request.Token, cancellationToken); + if (!isTokenValid) + { + return StatusCode(StatusCodes.Status400BadRequest); + } + + var jwtSecurityToken = await this.CreateTokenAsync(validEmailAddress); + + var tokenHandler = new JwtSecurityTokenHandler(); + var token = tokenHandler.WriteToken(jwtSecurityToken); + + return StatusCode(StatusCodes.Status200OK, new AuthenticationResponseDto + { + Token = token, + Expiration = jwtSecurityToken.ValidTo + }); + } + /// /// Validate Authentication /// diff --git a/src/Nager.Authentication.AspNet/Controllers/UserAccountController.cs b/src/Nager.Authentication.AspNet/Controllers/UserAccountController.cs index 65cb012..3ac91ab 100644 --- a/src/Nager.Authentication.AspNet/Controllers/UserAccountController.cs +++ b/src/Nager.Authentication.AspNet/Controllers/UserAccountController.cs @@ -58,7 +58,7 @@ public async Task ChangePasswordAsync( return StatusCode(StatusCodes.Status500InternalServerError); } - var request = new UserUpdatePasswordRequest + var request = new UserChangePasswordRequest { Password = changePasswordRequest.Password, }; @@ -70,5 +70,60 @@ public async Task ChangePasswordAsync( return StatusCode(StatusCodes.Status500InternalServerError); } + + /// + /// Get Mfa Activation + /// + /// + /// + /// Password changed + /// Unexpected error + [HttpGet] + [Route("MfaActivationInfo")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task GetMfaActivationAsync( + CancellationToken cancellationToken = default) + { + var emailAddress = HttpContext.User.Identity?.Name; + if (string.IsNullOrEmpty(emailAddress)) + { + return StatusCode(StatusCodes.Status500InternalServerError); + } + + var image = await this._userAccountService.GetMfaActivationQrCodeAsync(emailAddress, cancellationToken); + + return StatusCode(StatusCodes.Status200OK, new { image = image}); + } + + /// + /// Activate mfa + /// + /// + /// + /// + /// Password changed + /// Unexpected error + [HttpPost] + [Route("MfaActivate")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task ActivateMfaAsync( + [Required][FromBody] TimeBasedOneTimeTokenRequestDto request, + CancellationToken cancellationToken = default) + { + var emailAddress = HttpContext.User.Identity?.Name; + if (string.IsNullOrEmpty(emailAddress)) + { + return StatusCode(StatusCodes.Status500InternalServerError); + } + + if (await this._userAccountService.ActivateMfaAsync(emailAddress, request.Token, cancellationToken)) + { + return StatusCode(StatusCodes.Status204NoContent); + } + + return StatusCode(StatusCodes.Status500InternalServerError); + } } } diff --git a/src/Nager.Authentication.AspNet/Dtos/TimeBasedOneTimeTokenRequestDto.cs b/src/Nager.Authentication.AspNet/Dtos/TimeBasedOneTimeTokenRequestDto.cs new file mode 100644 index 0000000..ce869b2 --- /dev/null +++ b/src/Nager.Authentication.AspNet/Dtos/TimeBasedOneTimeTokenRequestDto.cs @@ -0,0 +1,7 @@ +namespace Nager.Authentication.AspNet.Dtos +{ + public class TimeBasedOneTimeTokenRequestDto + { + public string Token { get; set; } + } +} diff --git a/src/Nager.Authentication.TestProject.WebApp/Program.cs b/src/Nager.Authentication.TestProject.WebApp/Program.cs index f111608..a51c27a 100644 --- a/src/Nager.Authentication.TestProject.WebApp/Program.cs +++ b/src/Nager.Authentication.TestProject.WebApp/Program.cs @@ -63,7 +63,8 @@ IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)), ValidateLifetime = true, ValidateAudience = true, - ValidateIssuer = true + ValidateIssuer = true, + ClockSkew = TimeSpan.Zero }; }); builder.Services.AddAuthorization(); @@ -161,9 +162,7 @@ .WithGroupName("general") .WithName("GetTime"); -app.UseEndpoints(endpoints => -{ - endpoints.MapControllers(); -}); +app.MapFallbackToFile("index.html"); +app.MapControllers(); app.Run(); diff --git a/src/Nager.Authentication.TestProject.WebApp/Properties/launchSettings.json b/src/Nager.Authentication.TestProject.WebApp/Properties/launchSettings.json index f05ec8c..29f72fa 100644 --- a/src/Nager.Authentication.TestProject.WebApp/Properties/launchSettings.json +++ b/src/Nager.Authentication.TestProject.WebApp/Properties/launchSettings.json @@ -1,6 +1,6 @@ { "profiles": { - "Nager.Authentication.TestProject.WebApp6": { + "Nager.Authentication.TestProject.WebApp": { "commandName": "Project", "launchBrowser": true, "environmentVariables": { @@ -8,23 +8,8 @@ }, "dotnetRunMessages": true, "applicationUrl": "http://localhost:5234" - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "swagger", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } } }, - "$schema": "https://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:60638", - "sslPort": 0 - } + "$schema": "https://json.schemastore.org/launchsettings.json" } } \ No newline at end of file diff --git a/src/Nager.Authentication.TestProject.WebApp/appsettings.json b/src/Nager.Authentication.TestProject.WebApp/appsettings.json index 146147f..2eb3820 100644 --- a/src/Nager.Authentication.TestProject.WebApp/appsettings.json +++ b/src/Nager.Authentication.TestProject.WebApp/appsettings.json @@ -13,7 +13,7 @@ "Tokens": { "Issuer": "Issuer.PLEASE.CHANGE.ME", "Audience": "Audience.PLEASE.CHANGE.ME", - "SigningKey": "SigningKey.PLEASE.CHANGE.ME" + "SigningKey": "SigningKey.PLEASE.CHANGE.THIS.CODE.BEFORE.USE" } } } diff --git a/src/Nager.Authentication.TestProject.WebApp/wwwroot/account.html b/src/Nager.Authentication.TestProject.WebApp/wwwroot/account.html new file mode 100644 index 0000000..a099365 --- /dev/null +++ b/src/Nager.Authentication.TestProject.WebApp/wwwroot/account.html @@ -0,0 +1,137 @@ + + + Test Authentication App + + + + + + +
+ +
+
+
+ Change password + + +
+ +
+
+ +
+
+ Activate MFA + + +
+ +
+
+ +
+ + + +
+ +
+
+ StatusCode: {{ responseStatusCode }} +
+
+ +
+
+ Token
+ {{ responseData.token }}
+
+ Expiration
+ {{ responseData.expiration }} +
+
+ +
+
+
{{ responseError }}
+
+
+ +
+ + + + + \ No newline at end of file diff --git a/src/Nager.Authentication.TestProject.WebApp/wwwroot/index.html b/src/Nager.Authentication.TestProject.WebApp/wwwroot/index.html index f61b03b..9913caf 100644 --- a/src/Nager.Authentication.TestProject.WebApp/wwwroot/index.html +++ b/src/Nager.Authentication.TestProject.WebApp/wwwroot/index.html @@ -8,6 +8,7 @@ @@ -21,19 +22,11 @@
Token Content
{{ tokenInfo }}
+ + +
- -
-
- Change password - - -
- -
-
-
@@ -85,12 +78,13 @@ createApp({ data() { return { + token: undefined, + mfaToken: undefined, emailAddress: '', password: '', responseData: undefined, responseError: undefined, - responseStatusCode: undefined, - token: undefined + responseStatusCode: undefined } }, created() { @@ -141,20 +135,32 @@ this.responseError = await response.json() } }, - async changePassword() { - const response = await fetch('/api/v1/UserAccount/ChangePassword', { + async sendToken() { + const response = await fetch('/api/v1/Authentication/Token', { method: "POST", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": 'application/json' }, body: JSON.stringify({ - password: this.password + token: this.mfaToken }) }) this.responseStatusCode = response.status - } + + + if (response.status === 200) { + this.responseData = await response.json() + const token = this.responseData.token + + localStorage.setItem(tokenKey, token) + this.token = token + + } else if (response.status === 400) { + this.responseError = await response.json() + } + }, } }).mount('#app') diff --git a/src/Nager.Authentication.TestProject.WebApp/wwwroot/management.html b/src/Nager.Authentication.TestProject.WebApp/wwwroot/management.html index 2b42985..a945c29 100644 --- a/src/Nager.Authentication.TestProject.WebApp/wwwroot/management.html +++ b/src/Nager.Authentication.TestProject.WebApp/wwwroot/management.html @@ -8,6 +8,7 @@ @@ -78,6 +79,7 @@