From e50d46db3346a3bff9b5b203baf908d87a51ac1e Mon Sep 17 00:00:00 2001 From: Tino Hager Date: Fri, 14 Jun 2024 12:15:21 +0200 Subject: [PATCH] Add MFA logic --- .../Models/AuthenticationResult.cs | 8 ++ .../Models/MfaActivationResult.cs | 33 ++++++ .../Models/MfaDeactivationResult.cs | 33 ++++++ .../Models/MfaInformation.cs | 8 ++ .../Models/ValidateTokenResult.cs | 8 ++ .../Services/IUserAccountService.cs | 18 ++- .../Services/IUserAuthenticationService.cs | 6 +- .../Controllers/AuthenticationController.cs | 78 +++--------- .../Controllers/UserAccountController.cs | 74 ++++++++++-- .../Dtos/AuthenticationMfaTokenRequestDto.cs | 13 ++ .../Dtos/MfaRequiredResponseDto.cs | 8 ++ .../wwwroot/account.html | 77 ++++++++---- .../wwwroot/index.html | 57 +++++---- .../wwwroot/management.html | 8 +- .../wwwroot/test.html | 4 +- .../UserServiceTest.cs | 12 +- .../Helpers/ByteHelper.cs | 23 ++++ .../Helpers/InitialUserHelper.cs | 2 +- .../Helpers/PasswordHelper.cs | 15 +-- .../Helpers/RoleHelper.cs | 2 +- .../Services/UserAccountService.cs | 111 +++++++++++++++--- .../Services/UserAuthenticationService.cs | 77 ++++++++++-- .../Services/UserManagementService.cs | 11 +- 23 files changed, 505 insertions(+), 181 deletions(-) create mode 100644 src/Nager.Authentication.Abstraction/Models/AuthenticationResult.cs create mode 100644 src/Nager.Authentication.Abstraction/Models/MfaActivationResult.cs create mode 100644 src/Nager.Authentication.Abstraction/Models/MfaDeactivationResult.cs create mode 100644 src/Nager.Authentication.Abstraction/Models/MfaInformation.cs create mode 100644 src/Nager.Authentication.Abstraction/Models/ValidateTokenResult.cs create mode 100644 src/Nager.Authentication.AspNet/Dtos/AuthenticationMfaTokenRequestDto.cs create mode 100644 src/Nager.Authentication.AspNet/Dtos/MfaRequiredResponseDto.cs create mode 100644 src/Nager.Authentication/Helpers/ByteHelper.cs diff --git a/src/Nager.Authentication.Abstraction/Models/AuthenticationResult.cs b/src/Nager.Authentication.Abstraction/Models/AuthenticationResult.cs new file mode 100644 index 0000000..16c999f --- /dev/null +++ b/src/Nager.Authentication.Abstraction/Models/AuthenticationResult.cs @@ -0,0 +1,8 @@ +namespace Nager.Authentication.Abstraction.Models +{ + public class AuthenticationResult + { + public AuthenticationStatus Status { get; set; } + public string MfaIdentifier { get; set; } + } +} diff --git a/src/Nager.Authentication.Abstraction/Models/MfaActivationResult.cs b/src/Nager.Authentication.Abstraction/Models/MfaActivationResult.cs new file mode 100644 index 0000000..1994b8a --- /dev/null +++ b/src/Nager.Authentication.Abstraction/Models/MfaActivationResult.cs @@ -0,0 +1,33 @@ +namespace Nager.Authentication.Abstraction.Models +{ + /// + /// Mfa Activation Result + /// + public enum MfaActivationResult + { + /// + /// MFA activation was successful + /// + Success, + + /// + /// MFA activation failed + /// + Failed, + + /// + /// MFA is already activated + /// + AlreadyActive, + + /// + /// The provided authentication code is invalid + /// + InvalidCode, + + /// + /// The user could not be found + /// + UserNotFound + } +} diff --git a/src/Nager.Authentication.Abstraction/Models/MfaDeactivationResult.cs b/src/Nager.Authentication.Abstraction/Models/MfaDeactivationResult.cs new file mode 100644 index 0000000..a45923f --- /dev/null +++ b/src/Nager.Authentication.Abstraction/Models/MfaDeactivationResult.cs @@ -0,0 +1,33 @@ +namespace Nager.Authentication.Abstraction.Models +{ + /// + /// Mfa Deactivation Result + /// + public enum MfaDeactivationResult + { + /// + /// MFA activation was successful + /// + Success, + + /// + /// MFA activation failed + /// + Failed, + + /// + /// MFA was not active + /// + NotActive, + + /// + /// The provided authentication code is invalid + /// + InvalidCode, + + /// + /// The user could not be found + /// + UserNotFound + } +} diff --git a/src/Nager.Authentication.Abstraction/Models/MfaInformation.cs b/src/Nager.Authentication.Abstraction/Models/MfaInformation.cs new file mode 100644 index 0000000..13868d7 --- /dev/null +++ b/src/Nager.Authentication.Abstraction/Models/MfaInformation.cs @@ -0,0 +1,8 @@ +namespace Nager.Authentication.Abstraction.Models +{ + public class MfaInformation + { + public bool IsActive { get; set; } + public string ActivationQrCode { get; set; } + } +} diff --git a/src/Nager.Authentication.Abstraction/Models/ValidateTokenResult.cs b/src/Nager.Authentication.Abstraction/Models/ValidateTokenResult.cs new file mode 100644 index 0000000..e9b96ab --- /dev/null +++ b/src/Nager.Authentication.Abstraction/Models/ValidateTokenResult.cs @@ -0,0 +1,8 @@ +namespace Nager.Authentication.Abstraction.Models +{ + public class ValidateTokenResult + { + public bool Success { get; set; } + public string EmailAddress { get; set; } + } +} diff --git a/src/Nager.Authentication.Abstraction/Services/IUserAccountService.cs b/src/Nager.Authentication.Abstraction/Services/IUserAccountService.cs index 6ea4c5d..5430469 100644 --- a/src/Nager.Authentication.Abstraction/Services/IUserAccountService.cs +++ b/src/Nager.Authentication.Abstraction/Services/IUserAccountService.cs @@ -22,12 +22,12 @@ Task ChangePasswordAsync( CancellationToken cancellationToken = default); /// - /// Get Mfa activation qr code + /// Get Mfa Information /// /// /// /// - Task GetMfaActivationQrCodeAsync( + Task GetMfaInformationAsync( string emailAddress, CancellationToken cancellationToken = default); @@ -38,7 +38,19 @@ Task GetMfaActivationQrCodeAsync( /// /// /// - Task ActivateMfaAsync( + Task ActivateMfaAsync( + string emailAddress, + string token, + CancellationToken cancellationToken = default); + + /// + /// Deactivate Mfa + /// + /// + /// + /// + /// + Task DeactivateMfaAsync( 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 9c4fce3..f144059 100644 --- a/src/Nager.Authentication.Abstraction/Services/IUserAuthenticationService.cs +++ b/src/Nager.Authentication.Abstraction/Services/IUserAuthenticationService.cs @@ -15,12 +15,12 @@ public interface IUserAuthenticationService /// /// /// - Task ValidateCredentialsAsync( + Task ValidateCredentialsAsync( AuthenticationRequest authenticationRequest, CancellationToken cancellationToken = default); - Task ValidateTokenAsync( - string emailAddress, + Task ValidateTokenAsync( + string mfaIdentifier, string token, CancellationToken cancellationToken = default); diff --git a/src/Nager.Authentication.AspNet/Controllers/AuthenticationController.cs b/src/Nager.Authentication.AspNet/Controllers/AuthenticationController.cs index a3cfb15..2eb536b 100644 --- a/src/Nager.Authentication.AspNet/Controllers/AuthenticationController.cs +++ b/src/Nager.Authentication.AspNet/Controllers/AuthenticationController.cs @@ -109,43 +109,6 @@ 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 /// @@ -153,6 +116,7 @@ private JwtSecurityToken CreateTotpCheckToken( /// /// /// Authentication successful + /// Mfa required /// Invalid credential /// Credential check temporarily locked /// Unexpected error @@ -160,6 +124,7 @@ private JwtSecurityToken CreateTotpCheckToken( [HttpPost] [Route("")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status406NotAcceptable)] [ProducesResponseType(StatusCodes.Status429TooManyRequests)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] @@ -177,12 +142,12 @@ public async Task> AuthenticateAsync( IpAddress = ipAddress }; - var authenticationStatus = await this._userAuthenticationService.ValidateCredentialsAsync(authenticationRequest, cancellationToken); - this._logger.LogInformation($"{nameof(AuthenticateAsync)} - EmailAddress:{request.EmailAddress}, AuthenticationStatus:{authenticationStatus}"); + var authenticationResult = await this._userAuthenticationService.ValidateCredentialsAsync(authenticationRequest, cancellationToken); + this._logger.LogInformation($"{nameof(AuthenticateAsync)} - EmailAddress:{request.EmailAddress}, AuthenticationStatus:{authenticationResult}"); var tokenHandler = new JwtSecurityTokenHandler(); - switch (authenticationStatus) + switch (authenticationResult.Status) { case AuthenticationStatus.Invalid: return StatusCode(StatusCodes.Status406NotAcceptable); @@ -204,13 +169,10 @@ public async Task> AuthenticateAsync( return StatusCode(StatusCodes.Status500InternalServerError); } case AuthenticationStatus.MfaCodeRequired: - var jwtTotpCheckToken = CreateTotpCheckToken(request); - var totpToken = tokenHandler.WriteToken(jwtTotpCheckToken); - - return StatusCode(StatusCodes.Status200OK, new AuthenticationResponseDto + return StatusCode(StatusCodes.Status401Unauthorized, new MfaRequiredResponseDto { - Token = totpToken, - Expiration = jwtTotpCheckToken.ValidTo + MfaType = "TOTP", + MfaIdentifier = authenticationResult.MfaIdentifier }); case AuthenticationStatus.TemporaryBlocked: return StatusCode(StatusCodes.Status429TooManyRequests); @@ -225,32 +187,26 @@ public async Task> AuthenticateAsync( /// /// /// - ///// Authentication successful - ///// Invalid credential - ///// Credential check temporarily locked - ///// Unexpected error + /// Authentication successful + /// Invalid credential + /// Unexpected error + [AllowAnonymous] [HttpPost] [Route("Token")] [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status406NotAcceptable)] - [ProducesResponseType(StatusCodes.Status429TooManyRequests)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task> SecondStepTotpAsync( - [Required][FromBody] TimeBasedOneTimeTokenRequestDto request, + [Required][FromBody] AuthenticationMfaTokenRequestDto 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) + var validateTokenResult = await this._userAuthenticationService.ValidateTokenAsync(request.MfaIdentifier, request.Token, cancellationToken); + if (!validateTokenResult.Success) { return StatusCode(StatusCodes.Status400BadRequest); } - var jwtSecurityToken = await this.CreateTokenAsync(validEmailAddress); + var jwtSecurityToken = await this.CreateTokenAsync(validateTokenResult.EmailAddress); var tokenHandler = new JwtSecurityTokenHandler(); var token = tokenHandler.WriteToken(jwtSecurityToken); diff --git a/src/Nager.Authentication.AspNet/Controllers/UserAccountController.cs b/src/Nager.Authentication.AspNet/Controllers/UserAccountController.cs index 3ac91ab..f889ac1 100644 --- a/src/Nager.Authentication.AspNet/Controllers/UserAccountController.cs +++ b/src/Nager.Authentication.AspNet/Controllers/UserAccountController.cs @@ -79,7 +79,7 @@ public async Task ChangePasswordAsync( /// Password changed /// Unexpected error [HttpGet] - [Route("MfaActivationInfo")] + [Route("Mfa")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task GetMfaActivationAsync( @@ -91,13 +91,17 @@ public async Task GetMfaActivationAsync( return StatusCode(StatusCodes.Status500InternalServerError); } - var image = await this._userAccountService.GetMfaActivationQrCodeAsync(emailAddress, cancellationToken); + var information = await this._userAccountService.GetMfaInformationAsync(emailAddress, cancellationToken); + if (information == null) + { + return StatusCode(StatusCodes.Status500InternalServerError); + } - return StatusCode(StatusCodes.Status200OK, new { image = image}); + return StatusCode(StatusCodes.Status200OK, information); } /// - /// Activate mfa + /// Activate Mfa /// /// /// @@ -105,7 +109,7 @@ public async Task GetMfaActivationAsync( /// Password changed /// Unexpected error [HttpPost] - [Route("MfaActivate")] + [Route("Mfa/Activate")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task ActivateMfaAsync( @@ -118,12 +122,66 @@ public async Task ActivateMfaAsync( return StatusCode(StatusCodes.Status500InternalServerError); } - if (await this._userAccountService.ActivateMfaAsync(emailAddress, request.Token, cancellationToken)) + var mfaResult = await this._userAccountService.ActivateMfaAsync(emailAddress, request.Token, cancellationToken); + + switch (mfaResult) { - return StatusCode(StatusCodes.Status204NoContent); + case MfaActivationResult.Success: + return StatusCode(StatusCodes.Status204NoContent); + + case MfaActivationResult.UserNotFound: + case MfaActivationResult.Failed: + return StatusCode(StatusCodes.Status500InternalServerError); + + case MfaActivationResult.AlreadyActive: + case MfaActivationResult.InvalidCode: + return StatusCode(StatusCodes.Status400BadRequest, new { error = mfaResult.ToString() }); + + default: + return StatusCode(StatusCodes.Status500InternalServerError); + } + } + + /// + /// Deactivate Mfa + /// + /// + /// + /// + /// Password changed + /// Unexpected error + [HttpPost] + [Route("Mfa/Deactivate")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task DeactivateMfaAsync( + [Required][FromBody] TimeBasedOneTimeTokenRequestDto request, + CancellationToken cancellationToken = default) + { + var emailAddress = HttpContext.User.Identity?.Name; + if (string.IsNullOrEmpty(emailAddress)) + { + return StatusCode(StatusCodes.Status500InternalServerError); } - return StatusCode(StatusCodes.Status500InternalServerError); + var mfaResult = await this._userAccountService.DeactivateMfaAsync(emailAddress, request.Token, cancellationToken); + + switch (mfaResult) + { + case MfaDeactivationResult.Success: + return StatusCode(StatusCodes.Status204NoContent); + + case MfaDeactivationResult.UserNotFound: + case MfaDeactivationResult.Failed: + return StatusCode(StatusCodes.Status500InternalServerError); + + case MfaDeactivationResult.NotActive: + case MfaDeactivationResult.InvalidCode: + return StatusCode(StatusCodes.Status400BadRequest, new { error = mfaResult.ToString() }); + + default: + return StatusCode(StatusCodes.Status500InternalServerError); + } } } } diff --git a/src/Nager.Authentication.AspNet/Dtos/AuthenticationMfaTokenRequestDto.cs b/src/Nager.Authentication.AspNet/Dtos/AuthenticationMfaTokenRequestDto.cs new file mode 100644 index 0000000..62b48d3 --- /dev/null +++ b/src/Nager.Authentication.AspNet/Dtos/AuthenticationMfaTokenRequestDto.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Nager.Authentication.AspNet.Dtos +{ + public class AuthenticationMfaTokenRequestDto + { + [Required] + public string MfaIdentifier { get; set; } + + [Required] + public string Token { get; set; } + } +} diff --git a/src/Nager.Authentication.AspNet/Dtos/MfaRequiredResponseDto.cs b/src/Nager.Authentication.AspNet/Dtos/MfaRequiredResponseDto.cs new file mode 100644 index 0000000..e3cf890 --- /dev/null +++ b/src/Nager.Authentication.AspNet/Dtos/MfaRequiredResponseDto.cs @@ -0,0 +1,8 @@ +namespace Nager.Authentication.AspNet.Dtos +{ + public class MfaRequiredResponseDto + { + public string MfaIdentifier { get; set; } + public string MfaType { get; set; } + } +} diff --git a/src/Nager.Authentication.TestProject.WebApp/wwwroot/account.html b/src/Nager.Authentication.TestProject.WebApp/wwwroot/account.html index a099365..326584f 100644 --- a/src/Nager.Authentication.TestProject.WebApp/wwwroot/account.html +++ b/src/Nager.Authentication.TestProject.WebApp/wwwroot/account.html @@ -15,31 +15,37 @@
-
-
+
+
Change password
- +
- Activate MFA + MFA + + + {{ mfaInformation.isActive }}
+ + +
+
-
- +
+ +
- -
@@ -74,8 +80,8 @@ createApp({ data() { return { - test: '', - activationToken: '', + mfaInformation: '', + totpCode: '', password: '', responseData: undefined, responseError: undefined, @@ -83,17 +89,11 @@ } }, async created() { - const response = await fetch('/api/v1/UserAccount/MfaActivationInfo', { - headers: { - "Authorization": `Bearer ${this.token}`, - "Content-Type": 'application/json' - } - }) - this.test = await response.json(); + this.getMfaInformation() }, computed: { - token() { - return localStorage.getItem(tokenKey); + authenticationToken() { + return localStorage.getItem(tokenKey) } }, methods: { @@ -102,11 +102,20 @@ this.responseData = undefined this.responseError = undefined }, + async getMfaInformation() { + const response = await fetch('/api/v1/UserAccount/Mfa', { + headers: { + "Authorization": `Bearer ${this.authenticationToken}`, + "Content-Type": 'application/json' + } + }) + this.mfaInformation = await response.json() + }, async changePassword() { const response = await fetch('/api/v1/UserAccount/ChangePassword', { method: "POST", headers: { - "Authorization": `Bearer ${this.token}`, + "Authorization": `Bearer ${this.authenticationToken}`, "Content-Type": 'application/json' }, body: JSON.stringify({ @@ -117,18 +126,40 @@ this.responseStatusCode = response.status }, async activateMfa() { - const response = await fetch('/api/v1/UserAccount/MfaActivate', { + const response = await fetch('/api/v1/UserAccount/Mfa/Activate', { method: "POST", headers: { - "Authorization": `Bearer ${this.token}`, + "Authorization": `Bearer ${this.authenticationToken}`, "Content-Type": 'application/json' }, body: JSON.stringify({ - token: this.activationToken + token: this.totpCode }) }) this.responseStatusCode = response.status + + if (response.status === 204) { + await this.getMfaInformation() + } + }, + async deactivateMfa() { + const response = await fetch('/api/v1/UserAccount/Mfa/Deactivate', { + method: "POST", + headers: { + "Authorization": `Bearer ${this.authenticationToken}`, + "Content-Type": 'application/json' + }, + body: JSON.stringify({ + token: this.totpCode + }) + }) + + this.responseStatusCode = response.status + + if (response.status === 204) { + await this.getMfaInformation() + } } } }).mount('#app') diff --git a/src/Nager.Authentication.TestProject.WebApp/wwwroot/index.html b/src/Nager.Authentication.TestProject.WebApp/wwwroot/index.html index 9913caf..b11b919 100644 --- a/src/Nager.Authentication.TestProject.WebApp/wwwroot/index.html +++ b/src/Nager.Authentication.TestProject.WebApp/wwwroot/index.html @@ -15,32 +15,39 @@
-
+
Token Content
{{ tokenInfo }}
- - -
-
- EmailAddress
- -
-
- Password
- -
+
+
+ EmailAddress
+ +
+
+ Password
+ +
-
- -
+
+ +
+
+
+ +
+

MFA - Second step required

+
+ + +

@@ -78,8 +85,10 @@ createApp({ data() { return { - token: undefined, + authenticationToken: undefined, mfaToken: undefined, + mfaIdentifier: undefined, + showMfaTokenBox: false, emailAddress: '', password: '', responseData: undefined, @@ -88,11 +97,11 @@ } }, created() { - this.token = localStorage.getItem(tokenKey) + this.authenticationToken = localStorage.getItem(tokenKey) }, computed: { tokenInfo() { - const parts = this.token.split('.') + const parts = this.authenticationToken.split('.') if (parts.length !== 3) { return @@ -109,7 +118,7 @@ }, logout() { localStorage.removeItem(tokenKey) - this.token = undefined + this.authenticationToken = undefined this.clearLastRequest() }, @@ -129,20 +138,24 @@ const token = this.responseData.token localStorage.setItem(tokenKey, token) - this.token = token + this.authenticationToken = token } else if (response.status === 400) { this.responseError = await response.json() + } else if (response.status === 401) { + const mfa = await response.json() + this.mfaIdentifier = mfa.mfaIdentifier + this.showMfaTokenBox = true } }, async sendToken() { const response = await fetch('/api/v1/Authentication/Token', { method: "POST", headers: { - "Authorization": `Bearer ${this.token}`, "Content-Type": 'application/json' }, body: JSON.stringify({ + mfaIdentifier: this.mfaIdentifier, token: this.mfaToken }) }) @@ -155,7 +168,7 @@ const token = this.responseData.token localStorage.setItem(tokenKey, token) - this.token = token + this.authenticationToken = token } else if (response.status === 400) { this.responseError = await response.json() diff --git a/src/Nager.Authentication.TestProject.WebApp/wwwroot/management.html b/src/Nager.Authentication.TestProject.WebApp/wwwroot/management.html index a945c29..4c2269c 100644 --- a/src/Nager.Authentication.TestProject.WebApp/wwwroot/management.html +++ b/src/Nager.Authentication.TestProject.WebApp/wwwroot/management.html @@ -94,7 +94,7 @@ } }, computed: { - token() { + authenticationToken() { return localStorage.getItem(tokenKey) } }, @@ -110,7 +110,7 @@ const response = await fetch(`/api/v1/UserManagement/${userId}`, { method: 'DELETE', headers: { - "Authorization": `Bearer ${this.token}`, + "Authorization": `Bearer ${this.authenticationToken}`, "Content-Type": 'application/json' } }) @@ -127,7 +127,7 @@ const response = await fetch('/api/v1/UserManagement/', { method: 'POST', headers: { - "Authorization": `Bearer ${this.token}`, + "Authorization": `Bearer ${this.authenticationToken}`, "Content-Type": 'application/json' }, body: JSON.stringify({ @@ -150,7 +150,7 @@ const response = await fetch('/api/v1/UserManagement', { headers: { - "Authorization": `Bearer ${this.token}`, + "Authorization": `Bearer ${this.authenticationToken}`, "Content-Type": 'application/json' } }) diff --git a/src/Nager.Authentication.TestProject.WebApp/wwwroot/test.html b/src/Nager.Authentication.TestProject.WebApp/wwwroot/test.html index f0c6da9..b7b29a6 100644 --- a/src/Nager.Authentication.TestProject.WebApp/wwwroot/test.html +++ b/src/Nager.Authentication.TestProject.WebApp/wwwroot/test.html @@ -55,7 +55,7 @@ } }, computed: { - token() { + authenticationToken() { return localStorage.getItem(tokenKey); } }, @@ -67,7 +67,7 @@ const response = await fetch('/api/v1/UtcTime', { headers: { - "Authorization": `Bearer ${this.token}`, + "Authorization": `Bearer ${this.authenticationToken}`, "Content-Type": 'application/json' } }) diff --git a/src/Nager.Authentication.UnitTest/UserServiceTest.cs b/src/Nager.Authentication.UnitTest/UserServiceTest.cs index e64b3e9..7fa41f4 100644 --- a/src/Nager.Authentication.UnitTest/UserServiceTest.cs +++ b/src/Nager.Authentication.UnitTest/UserServiceTest.cs @@ -47,12 +47,12 @@ public async Task ValidateCredentialsAsync_10Retrys_Block() for (var i = 0; i <= 10; i++) { - var authenticationStatus = await userService.ValidateCredentialsAsync(authenticationRequest); - Assert.AreEqual(AuthenticationStatus.Invalid, authenticationStatus, $"Retry: {i}"); + var authenticationResult = await userService.ValidateCredentialsAsync(authenticationRequest); + Assert.AreEqual(AuthenticationStatus.Invalid, authenticationResult.Status, $"Retry: {i}"); } - var authenticationStatus1 = await userService.ValidateCredentialsAsync(authenticationRequest); - Assert.AreEqual(AuthenticationStatus.TemporaryBlocked, authenticationStatus1); + var authenticationResult1 = await userService.ValidateCredentialsAsync(authenticationRequest); + Assert.AreEqual(AuthenticationStatus.TemporaryBlocked, authenticationResult1.Status); } [TestMethod] @@ -87,8 +87,8 @@ public async Task ValidateCredentialsAsync_UpperCaseUsername_Allow() Password = "secretPassword" }; - var authenticationStatus = await userService.ValidateCredentialsAsync(authenticationRequest); - Assert.AreEqual(AuthenticationStatus.Valid, authenticationStatus); + var authenticationResult = await userService.ValidateCredentialsAsync(authenticationRequest); + Assert.AreEqual(AuthenticationStatus.Valid, authenticationResult.Status); } } } diff --git a/src/Nager.Authentication/Helpers/ByteHelper.cs b/src/Nager.Authentication/Helpers/ByteHelper.cs new file mode 100644 index 0000000..fd7c6e8 --- /dev/null +++ b/src/Nager.Authentication/Helpers/ByteHelper.cs @@ -0,0 +1,23 @@ +using System.Security.Cryptography; + +namespace Nager.Authentication.Helpers +{ + /// + /// Byte Helper + /// + public static class ByteHelper + { + /// + /// Generate a secure Pseudo-Random Number Generator + /// + /// + public static byte[] CreatePseudoRandomNumber(int bit = 128) + { + var temp = new byte[bit / 8]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(temp); + + return temp; + } + } +} diff --git a/src/Nager.Authentication/Helpers/InitialUserHelper.cs b/src/Nager.Authentication/Helpers/InitialUserHelper.cs index 2a1fcce..6dd1575 100644 --- a/src/Nager.Authentication/Helpers/InitialUserHelper.cs +++ b/src/Nager.Authentication/Helpers/InitialUserHelper.cs @@ -5,7 +5,7 @@ namespace Nager.Authentication.Helpers { /// - /// InitialUserHelper + /// Initial User Helper /// public static class InitialUserHelper { diff --git a/src/Nager.Authentication/Helpers/PasswordHelper.cs b/src/Nager.Authentication/Helpers/PasswordHelper.cs index 3908b90..e82e33f 100644 --- a/src/Nager.Authentication/Helpers/PasswordHelper.cs +++ b/src/Nager.Authentication/Helpers/PasswordHelper.cs @@ -5,25 +5,12 @@ namespace Nager.Authentication.Helpers { /// - /// PasswordHelper + /// Password Helper /// public static class PasswordHelper { private const int IterationCount = 10_000; - /// - /// Generate a 128-bit salt using a secure PRNG - /// - /// - public static byte[] CreateSalt() - { - var salt = new byte[128 / 8]; - using var rng = RandomNumberGenerator.Create(); - rng.GetBytes(salt); - - return salt; - } - /// /// Derive a 256-bit subkey (use HMACSHA1 with 10,000 iterations) /// diff --git a/src/Nager.Authentication/Helpers/RoleHelper.cs b/src/Nager.Authentication/Helpers/RoleHelper.cs index c8c099c..060d0e2 100644 --- a/src/Nager.Authentication/Helpers/RoleHelper.cs +++ b/src/Nager.Authentication/Helpers/RoleHelper.cs @@ -4,7 +4,7 @@ namespace Nager.Authentication.Helpers { /// - /// RoleHelper + /// Role Helper /// public static class RoleHelper { diff --git a/src/Nager.Authentication/Services/UserAccountService.cs b/src/Nager.Authentication/Services/UserAccountService.cs index ae95460..9938ccf 100644 --- a/src/Nager.Authentication/Services/UserAccountService.cs +++ b/src/Nager.Authentication/Services/UserAccountService.cs @@ -12,7 +12,7 @@ namespace Nager.Authentication.Services { /// - /// UserAccount Service + /// User Account Service /// public class UserAccountService : IUserAccountService { @@ -20,6 +20,12 @@ public class UserAccountService : IUserAccountService private readonly IUserRepository _userRepository; private readonly string _issuer; + /// + /// User Account Service + /// + /// + /// + /// public UserAccountService( IUserRepository userRepository, IConfiguration configuration, @@ -46,40 +52,64 @@ public async Task ChangePasswordAsync( var passwordHash = PasswordHelper.HashPasword(userUpdatePasswordRequest.Password, userEntity.PasswordSalt); userEntity.PasswordHash = passwordHash; - await this._userRepository.UpdateAsync(userEntity); + await this._userRepository.UpdateAsync(userEntity, cancellationToken); return true; } - /// - public async Task GetMfaActivationQrCodeAsync( + private async Task CreateMfaSecretAsync( string emailAddress, CancellationToken cancellationToken = default) { var userEntity = await this._userRepository.GetAsync(o => o.EmailAddress.Equals(emailAddress), cancellationToken); if (userEntity == null) { - return null; + return false; } if (userEntity.mfaSecret == null) { - userEntity.mfaSecret = PasswordHelper.CreateSalt(); - if (!await this._userRepository.UpdateAsync(userEntity)) - { - this._logger.LogError("Cannot update mfaSecret"); - return null; - } + userEntity.mfaSecret = ByteHelper.CreatePseudoRandomNumber(); + return await this._userRepository.UpdateAsync(userEntity, cancellationToken); + } + + return true; + } + + /// + public async Task ActivateMfaAsync( + string emailAddress, + string token, + CancellationToken cancellationToken = default) + { + var userEntity = await this._userRepository.GetAsync(o => o.EmailAddress.Equals(emailAddress), cancellationToken); + if (userEntity == null) + { + return MfaActivationResult.UserNotFound; + } + + if (userEntity.mfaActive) + { + return MfaActivationResult.AlreadyActive; } var twoFactorAuthenticator = new TwoFactorAuthenticator(); - var setupCode = twoFactorAuthenticator.GenerateSetupCode(this._issuer, emailAddress, userEntity.mfaSecret); + if (twoFactorAuthenticator.ValidateTwoFactorPIN(userEntity.mfaSecret, token)) + { + userEntity.mfaActive = true; + if (await this._userRepository.UpdateAsync(userEntity, cancellationToken)) + { + return MfaActivationResult.Success; + } - return setupCode.QrCodeSetupImageUrl; + return MfaActivationResult.Failed; + } + + return MfaActivationResult.InvalidCode; } /// - public async Task ActivateMfaAsync( + public async Task DeactivateMfaAsync( string emailAddress, string token, CancellationToken cancellationToken = default) @@ -87,17 +117,62 @@ public async Task ActivateMfaAsync( var userEntity = await this._userRepository.GetAsync(o => o.EmailAddress.Equals(emailAddress), cancellationToken); if (userEntity == null) { - return false; + return MfaDeactivationResult.UserNotFound; + } + + if (!userEntity.mfaActive) + { + return MfaDeactivationResult.NotActive; } var twoFactorAuthenticator = new TwoFactorAuthenticator(); if (twoFactorAuthenticator.ValidateTwoFactorPIN(userEntity.mfaSecret, token)) { - userEntity.mfaActive = true; - return await this._userRepository.UpdateAsync(userEntity); + userEntity.mfaActive = false; + userEntity.mfaSecret = null; + + if (await this._userRepository.UpdateAsync(userEntity, cancellationToken)) + { + return MfaDeactivationResult.Success; + } + + return MfaDeactivationResult.Failed; + } + + return MfaDeactivationResult.InvalidCode; + } + + public async Task GetMfaInformationAsync( + string emailAddress, + CancellationToken cancellationToken = default) + { + var userEntity = await this._userRepository.GetAsync(o => o.EmailAddress.Equals(emailAddress), cancellationToken); + if (userEntity == null) + { + return null; + } + + if (userEntity.mfaActive) + { + return new MfaInformation + { + IsActive = true + }; } - return false; + if (!await this.CreateMfaSecretAsync(emailAddress, cancellationToken)) + { + return null; + } + + var twoFactorAuthenticator = new TwoFactorAuthenticator(); + var setupCode = twoFactorAuthenticator.GenerateSetupCode(this._issuer, emailAddress, userEntity.mfaSecret); + + return new MfaInformation + { + IsActive = false, + ActivationQrCode = setupCode.QrCodeSetupImageUrl + }; } } } diff --git a/src/Nager.Authentication/Services/UserAuthenticationService.cs b/src/Nager.Authentication/Services/UserAuthenticationService.cs index 26e422c..908bdf8 100644 --- a/src/Nager.Authentication/Services/UserAuthenticationService.cs +++ b/src/Nager.Authentication/Services/UserAuthenticationService.cs @@ -116,7 +116,7 @@ private async Task IsIdentifierBlockedAsync(string identifier) } /// - public async Task ValidateCredentialsAsync( + public async Task ValidateCredentialsAsync( AuthenticationRequest authenticationRequest, CancellationToken cancellationToken = default) { @@ -133,13 +133,21 @@ public async Task ValidateCredentialsAsync( if (await this.IsIdentifierBlockedAsync(authenticationRequest.IpAddress)) { this._logger.LogWarning($"{nameof(ValidateCredentialsAsync)} - Block {authenticationRequest.IpAddress}"); - return AuthenticationStatus.TemporaryBlocked; + + return new AuthenticationResult + { + Status = AuthenticationStatus.TemporaryBlocked + }; } if (await this.IsIdentifierBlockedAsync(authenticationRequest.EmailAddress)) { this._logger.LogWarning($"{nameof(ValidateCredentialsAsync)} - Block {authenticationRequest.EmailAddress}"); - return AuthenticationStatus.TemporaryBlocked; + + return new AuthenticationResult + { + Status = AuthenticationStatus.TemporaryBlocked + }; } var userEntity = await this._userRepository.GetAsync(o => o.EmailAddress == authenticationRequest.EmailAddress, cancellationToken); @@ -148,13 +156,20 @@ public async Task ValidateCredentialsAsync( this.SetInvalidLogin(authenticationRequest.IpAddress); this.SetInvalidLogin(authenticationRequest.EmailAddress); - return AuthenticationStatus.Invalid; + return new AuthenticationResult + { + Status = AuthenticationStatus.Invalid + }; } if (userEntity.IsLocked) { this._logger.LogWarning($"{nameof(ValidateCredentialsAsync)} - User is locked {authenticationRequest.EmailAddress}"); - return AuthenticationStatus.Invalid; + + return new AuthenticationResult + { + Status = AuthenticationStatus.Invalid + }; } if (userEntity.PasswordHash == null) @@ -169,7 +184,19 @@ public async Task ValidateCredentialsAsync( { if (userEntity.mfaActive) { - return AuthenticationStatus.MfaCodeRequired; + var mfaIdentifier = Guid.NewGuid().ToString(); + var cacheKey = this.GetCacheKey(mfaIdentifier); + + this._memoryCache.Set(cacheKey, authenticationRequest.EmailAddress, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(120) + }); + + return new AuthenticationResult + { + Status = AuthenticationStatus.MfaCodeRequired, + MfaIdentifier = mfaIdentifier + }; } this.SetValidLogin(authenticationRequest.IpAddress); @@ -177,7 +204,10 @@ public async Task ValidateCredentialsAsync( await this._userRepository.SetLastSuccessfulValidationTimestampAsync(o => o.Id == userEntity.Id, cancellationTokenSource.Token); - return AuthenticationStatus.Valid; + return new AuthenticationResult + { + Status = AuthenticationStatus.Valid + }; } this.SetInvalidLogin(authenticationRequest.IpAddress); @@ -185,7 +215,10 @@ public async Task ValidateCredentialsAsync( await this._userRepository.SetLastValidationTimestampAsync(o => o.Id == userEntity.Id, cancellationTokenSource.Token); - return AuthenticationStatus.Invalid; + return new AuthenticationResult + { + Status = AuthenticationStatus.Invalid + }; } /// @@ -210,21 +243,41 @@ public async Task ValidateCredentialsAsync( } /// - public async Task ValidateTokenAsync( - string emailAddress, + public async Task ValidateTokenAsync( + string mfaIdentifier, string token, CancellationToken cancellationToken = default) { + var cacheKey = this.GetCacheKey(mfaIdentifier); + if (!this._memoryCache.TryGetValue(cacheKey, out var emailAddress)) + { + return new ValidateTokenResult + { + Success = false + }; + } + + this._memoryCache.Remove(cacheKey); + var timeTolerance = TimeSpan.FromSeconds(20); var userEntity = await this._userRepository.GetAsync(o => o.EmailAddress == emailAddress); if (userEntity == null) { - return false; + return new ValidateTokenResult + { + Success = false + }; } var twoFactorAuthenticator = new TwoFactorAuthenticator(); - return twoFactorAuthenticator.ValidateTwoFactorPIN(userEntity.mfaSecret, token, timeTolerance); + var isTokenValid = twoFactorAuthenticator.ValidateTwoFactorPIN(userEntity.mfaSecret, token, timeTolerance); + + return new ValidateTokenResult + { + Success = isTokenValid, + EmailAddress = emailAddress + }; } } } diff --git a/src/Nager.Authentication/Services/UserManagementService.cs b/src/Nager.Authentication/Services/UserManagementService.cs index c76fa64..4bf28dc 100644 --- a/src/Nager.Authentication/Services/UserManagementService.cs +++ b/src/Nager.Authentication/Services/UserManagementService.cs @@ -12,13 +12,18 @@ namespace Nager.Authentication.Services { /// - /// UserManagement Service + /// User Management Service /// public class UserManagementService : IUserManagementService { private readonly ILogger _logger; private readonly IUserRepository _userRepository; + /// + /// User Management Service + /// + /// + /// public UserManagementService( ILogger logger, IUserRepository userRepository) @@ -93,7 +98,7 @@ public async Task ResetPasswordAsync( var randomPassword = PasswordHelper.CreateRandomPassword(10); - var passwordSalt = PasswordHelper.CreateSalt(); + var passwordSalt = ByteHelper.CreatePseudoRandomNumber(); var passwordHash = PasswordHelper.HashPasword(randomPassword, passwordSalt); userEntity.PasswordSalt = passwordSalt; @@ -121,7 +126,7 @@ public async Task CreateAsync( var userId = Guid.NewGuid().ToString(); - var passwordSalt = PasswordHelper.CreateSalt(); + var passwordSalt = ByteHelper.CreatePseudoRandomNumber(); var passwordHash = PasswordHelper.HashPasword(createUserRequest.Password, passwordSalt); var userEntity = new UserEntity