+
-
- 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