Skip to content

Commit

Permalink
add mfa (wip)
Browse files Browse the repository at this point in the history
  • Loading branch information
tinohager committed Jun 13, 2024
1 parent 62df56e commit 56cfce5
Show file tree
Hide file tree
Showing 20 changed files with 488 additions and 70 deletions.
5 changes: 5 additions & 0 deletions src/Nager.Authentication.Abstraction/Entities/UserEntity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
namespace Nager.Authentication.Abstraction.Models
{
/// <summary>
/// Authentication Request
/// </summary>
public class AuthenticationRequest
{
/// <summary>
/// Email Address
/// </summary>
public string EmailAddress { get; set; }

Check warning on line 11 in src/Nager.Authentication.Abstraction/Models/AuthenticationRequest.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.

/// <summary>
/// Password
/// </summary>
public string Password { get; set; }

Check warning on line 16 in src/Nager.Authentication.Abstraction/Models/AuthenticationRequest.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'Password' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.

/// <summary>
/// IpAddress
/// </summary>
public string? IpAddress { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ public enum AuthenticationStatus
{
Invalid,
Valid,
MfaCodeRequired,
TemporaryBlocked
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace Nager.Authentication.Abstraction.Models
{
public class UserUpdatePasswordRequest
public class UserChangePasswordRequest
{
public string Password { get; set; }

Check warning on line 5 in src/Nager.Authentication.Abstraction/Models/UserChangePasswordRequest.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'Password' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,38 @@ namespace Nager.Authentication.Abstraction.Services
/// </summary>
public interface IUserAccountService
{
/// <summary>
/// Change Password
/// </summary>
/// <param name="emailAddress"></param>
/// <param name="userChangePasswordRequest"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<bool> ChangePasswordAsync(
string emailAddress,
UserUpdatePasswordRequest userChangePasswordRequest,
UserChangePasswordRequest userChangePasswordRequest,
CancellationToken cancellationToken = default);

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

/// <summary>
/// Activate Mfa
/// </summary>
/// <param name="emailAddress"></param>
/// <param name="token"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<bool> ActivateMfaAsync(
string emailAddress,
string token,
CancellationToken cancellationToken = default);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ Task<AuthenticationStatus> ValidateCredentialsAsync(
AuthenticationRequest authenticationRequest,
CancellationToken cancellationToken = default);

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

/// <summary>
/// Get UserInfo
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public AuthenticationController(
}

private async Task<JwtSecurityToken> CreateTokenAsync(
AuthenticationRequestDto request)
string emailAddress)
{
var issuer = this._configuration["Authentication:Tokens:Issuer"];
var audience = this._configuration["Authentication:Tokens:Audience"];
Expand All @@ -68,18 +68,19 @@ private async Task<JwtSecurityToken> 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();
}

var claims = new List<Claim>
{
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));
Expand Down Expand Up @@ -108,6 +109,43 @@ 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>
Expand Down Expand Up @@ -142,15 +180,16 @@ public async Task<ActionResult<AuthenticationResponseDto>> 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:
return StatusCode(StatusCodes.Status406NotAcceptable);
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
Expand All @@ -164,13 +203,65 @@ public async Task<ActionResult<AuthenticationResponseDto>> 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:
return StatusCode(StatusCodes.Status501NotImplemented);
}
}

/// <summary>
/// Second Step Time-based one-time password
/// </summary>
/// <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>
[HttpPost]
[Route("Token")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status406NotAcceptable)]
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<AuthenticationResponseDto>> 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
});
}

/// <summary>
/// Validate Authentication
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public async Task<ActionResult> ChangePasswordAsync(
return StatusCode(StatusCodes.Status500InternalServerError);
}

var request = new UserUpdatePasswordRequest
var request = new UserChangePasswordRequest
{
Password = changePasswordRequest.Password,
};
Expand All @@ -70,5 +70,60 @@ public async Task<ActionResult> ChangePasswordAsync(

return StatusCode(StatusCodes.Status500InternalServerError);
}

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

/// <summary>
/// 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")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<ActionResult> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Nager.Authentication.AspNet.Dtos
{
public class TimeBasedOneTimeTokenRequestDto
{
public string Token { get; set; }
}
}
9 changes: 4 additions & 5 deletions src/Nager.Authentication.TestProject.WebApp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -161,9 +162,7 @@
.WithGroupName("general")
.WithName("GetTime");

app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
app.MapFallbackToFile("index.html");
app.MapControllers();

app.Run();
Original file line number Diff line number Diff line change
@@ -1,30 +1,15 @@
{
"profiles": {
"Nager.Authentication.TestProject.WebApp6": {
"Nager.Authentication.TestProject.WebApp": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"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"
}
}
Loading

0 comments on commit 56cfce5

Please sign in to comment.