Skip to content

Commit

Permalink
Add MFA logic
Browse files Browse the repository at this point in the history
  • Loading branch information
tinohager committed Jun 14, 2024
1 parent 56cfce5 commit e50d46d
Show file tree
Hide file tree
Showing 23 changed files with 505 additions and 181 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Nager.Authentication.Abstraction.Models
{
public class AuthenticationResult
{
public AuthenticationStatus Status { get; set; }
public string MfaIdentifier { get; set; }
}
}
33 changes: 33 additions & 0 deletions src/Nager.Authentication.Abstraction/Models/MfaActivationResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace Nager.Authentication.Abstraction.Models
{
/// <summary>
/// Mfa Activation Result
/// </summary>
public enum MfaActivationResult
{
/// <summary>
/// MFA activation was successful
/// </summary>
Success,

/// <summary>
/// MFA activation failed
/// </summary>
Failed,

/// <summary>
/// MFA is already activated
/// </summary>
AlreadyActive,

/// <summary>
/// The provided authentication code is invalid
/// </summary>
InvalidCode,

/// <summary>
/// The user could not be found
/// </summary>
UserNotFound
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace Nager.Authentication.Abstraction.Models
{
/// <summary>
/// Mfa Deactivation Result
/// </summary>
public enum MfaDeactivationResult
{
/// <summary>
/// MFA activation was successful
/// </summary>
Success,

/// <summary>
/// MFA activation failed
/// </summary>
Failed,

/// <summary>
/// MFA was not active
/// </summary>
NotActive,

/// <summary>
/// The provided authentication code is invalid
/// </summary>
InvalidCode,

/// <summary>
/// The user could not be found
/// </summary>
UserNotFound
}
}
8 changes: 8 additions & 0 deletions src/Nager.Authentication.Abstraction/Models/MfaInformation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Nager.Authentication.Abstraction.Models
{
public class MfaInformation
{
public bool IsActive { get; set; }
public string ActivationQrCode { get; set; }

Check warning on line 6 in src/Nager.Authentication.Abstraction/Models/MfaInformation.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'ActivationQrCode' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Nager.Authentication.Abstraction.Models
{
public class ValidateTokenResult
{
public bool Success { get; set; }
public string EmailAddress { get; set; }

Check warning on line 6 in src/Nager.Authentication.Abstraction/Models/ValidateTokenResult.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'EmailAddress' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ Task<bool> ChangePasswordAsync(
CancellationToken cancellationToken = default);

/// <summary>
/// Get Mfa activation qr code
/// Get Mfa Information
/// </summary>
/// <param name="emailAddress"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<string> GetMfaActivationQrCodeAsync(
Task<MfaInformation> GetMfaInformationAsync(
string emailAddress,
CancellationToken cancellationToken = default);

Expand All @@ -38,7 +38,19 @@ Task<string> GetMfaActivationQrCodeAsync(
/// <param name="token"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<bool> ActivateMfaAsync(
Task<MfaActivationResult> ActivateMfaAsync(
string emailAddress,
string token,
CancellationToken cancellationToken = default);

/// <summary>
/// Deactivate Mfa
/// </summary>
/// <param name="emailAddress"></param>
/// <param name="token"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<MfaDeactivationResult> DeactivateMfaAsync(
string emailAddress,
string token,
CancellationToken cancellationToken = default);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ public interface IUserAuthenticationService
/// <param name="authenticationRequest"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<AuthenticationStatus> ValidateCredentialsAsync(
Task<AuthenticationResult> ValidateCredentialsAsync(
AuthenticationRequest authenticationRequest,
CancellationToken cancellationToken = default);

Task<bool> ValidateTokenAsync(
string emailAddress,
Task<ValidateTokenResult> ValidateTokenAsync(
string mfaIdentifier,
string token,
CancellationToken cancellationToken = default);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,57 +109,22 @@ private async Task<JwtSecurityToken> 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<Claim>
{
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);
}

/// <summary>
/// Authenticate via Email and Password
/// </summary>
/// <param name="request"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <response code="200">Authentication successful</response>
/// <response code="401">Mfa required</response>
/// <response code="406">Invalid credential</response>
/// <response code="429">Credential check temporarily locked</response>
/// <response code="500">Unexpected error</response>
[AllowAnonymous]
[HttpPost]
[Route("")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status406NotAcceptable)]
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
Expand All @@ -177,12 +142,12 @@ public async Task<ActionResult<AuthenticationResponseDto>> 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);
Expand All @@ -204,13 +169,10 @@ public async Task<ActionResult<AuthenticationResponseDto>> 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);
Expand All @@ -225,32 +187,26 @@ public async Task<ActionResult<AuthenticationResponseDto>> AuthenticateAsync(
/// <param name="request"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
///// <response code="200">Authentication successful</response>
///// <response code="406">Invalid credential</response>
///// <response code="429">Credential check temporarily locked</response>
///// <response code="500">Unexpected error</response>
/// <response code="200">Authentication successful</response>
/// <response code="406">Invalid credential</response>
/// <response code="500">Unexpected error</response>
[AllowAnonymous]
[HttpPost]
[Route("Token")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status406NotAcceptable)]
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<AuthenticationResponseDto>> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public async Task<ActionResult> ChangePasswordAsync(
/// <response code="204">Password changed</response>
/// <response code="500">Unexpected error</response>
[HttpGet]
[Route("MfaActivationInfo")]
[Route("Mfa")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<ActionResult> GetMfaActivationAsync(
Expand All @@ -91,21 +91,25 @@ public async Task<ActionResult> 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);
}

/// <summary>
/// Activate mfa
/// Activate Mfa
/// </summary>
/// <param name="request"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <response code="204">Password changed</response>
/// <response code="500">Unexpected error</response>
[HttpPost]
[Route("MfaActivate")]
[Route("Mfa/Activate")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<ActionResult> ActivateMfaAsync(
Expand All @@ -118,12 +122,66 @@ public async Task<ActionResult> 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);
}
}

/// <summary>
/// Deactivate Mfa
/// </summary>
/// <param name="request"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <response code="204">Password changed</response>
/// <response code="500">Unexpected error</response>
[HttpPost]
[Route("Mfa/Deactivate")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<ActionResult> 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);
}
}
}
}
Loading

0 comments on commit e50d46d

Please sign in to comment.