Skip to content

Commit

Permalink
Merge pull request #26 from neozhu/feature/totpauthenticatorapp
Browse files Browse the repository at this point in the history
Add Two-Factor Authentication (2FA) with Authenticator App Support in Blazor WebAssembly
  • Loading branch information
neozhu authored Dec 28, 2024
2 parents 87b8b0a + 6de300f commit 33bb6dd
Show file tree
Hide file tree
Showing 30 changed files with 1,338 additions and 114 deletions.
2 changes: 1 addition & 1 deletion CleanAspire.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<Project Path="src/CleanAspire.ServiceDefaults/CleanAspire.ServiceDefaults.csproj" />
<Project Path="src/CleanAspire.WebApp/CleanAspire.WebApp.csproj" />
</Folder>
<Folder Name="/src/Migrators/">
<Folder Name="/src/Migrators/" Id="44da19c8-5b9b-6524-cf69-55296739ffa1">
<Project Path="src/Migrators/Migrators.MSSQL/Migrators.MSSQL.csproj" />
<Project Path="src/Migrators/Migrators.PostgreSQL/Migrators.PostgreSQL.csproj" />
<Project Path="src/Migrators/Migrators.SQLite/Migrators.SQLite.csproj" />
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ By incorporating robust offline capabilities, CleanAspire empowers developers to
version: '3.8'
services:
apiservice:
image: blazordevlab/cleanaspire-api:0.0.56
image: blazordevlab/cleanaspire-api:0.0.57
environment:
- ASPNETCORE_ENVIRONMENT=Development
- AllowedHosts=*
Expand All @@ -110,7 +110,7 @@ services:


blazorweb:
image: blazordevlab/cleanaspire-webapp:0.0.56
image: blazordevlab/cleanaspire-webapp:0.0.57
environment:
- ASPNETCORE_ENVIRONMENT=Production
- AllowedHosts=*
Expand Down
4 changes: 2 additions & 2 deletions src/CleanAspire.Api/CleanAspire.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="9.0.0" />
<PackageReference Include="Scalar.AspNetCore" Version="1.2.66" />
<PackageReference Include="Scrutor" Version="5.0.2" />
<PackageReference Include="Scalar.AspNetCore" Version="1.2.72" />
<PackageReference Include="Scrutor" Version="5.1.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.6" />
<PackageReference Include="StrongGrid" Version="0.110.0" />
</ItemGroup>
Expand Down
221 changes: 219 additions & 2 deletions src/CleanAspire.Api/IdentityApiAdditionalEndpointsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
using Microsoft.AspNetCore.Identity.Data;
using Google.Apis.Auth;
using CleanAspire.Infrastructure.Persistence;
using System.Globalization;
namespace CleanAspire.Api;

public static class IdentityApiAdditionalEndpointsExtensions
Expand Down Expand Up @@ -165,7 +166,7 @@ public static IEndpointRouteBuilder MapIdentityApiAdditionalEndpoints<TUser>(thi
.WithDescription("Allows a new user to sign up by providing required details such as email, password, and tenant-specific information. This endpoint creates a new user account and sends a confirmation email for verification.");

routeGroup.MapDelete("/deleteOwnerAccount", async Task<Results<Ok, ValidationProblem, NotFound>>
(ClaimsPrincipal claimsPrincipal, SignInManager<TUser> signInManager, HttpContext context,[FromBody] DeleteUserRequest request) =>
(ClaimsPrincipal claimsPrincipal, SignInManager<TUser> signInManager, HttpContext context, [FromBody] DeleteUserRequest request) =>
{
var userManager = context.RequestServices.GetRequiredService<UserManager<TUser>>();
if (await userManager.GetUserAsync(claimsPrincipal) is not { } user)
Expand Down Expand Up @@ -421,6 +422,187 @@ public static IEndpointRouteBuilder MapIdentityApiAdditionalEndpoints<TUser>(thi
.WithSummary("External Login with Google OAuth")
.WithDescription("Handles external login using Google OAuth 2.0. Exchanges an authorization code for tokens, validates the user's identity, and signs the user in.");


routeGroup.MapGet("/generateAuthenticator", async Task<Results<Ok<AuthenticatorResponse>, ValidationProblem, NotFound>>
(ClaimsPrincipal claimsPrincipal, HttpContext context, [FromQuery] string appName) =>
{
var userManager = context.RequestServices.GetRequiredService<UserManager<TUser>>();
var urlEncoder = context.RequestServices.GetRequiredService<UrlEncoder>();
if (await userManager.GetUserAsync(claimsPrincipal) is not { } user)
{
return TypedResults.NotFound();
}
if (string.IsNullOrEmpty(appName)) appName = "Blazor Aspire";
var unformattedKey = await userManager.GetAuthenticatorKeyAsync(user);
if (string.IsNullOrEmpty(unformattedKey))
{
await userManager.ResetAuthenticatorKeyAsync(user);
unformattedKey = await userManager.GetAuthenticatorKeyAsync(user);
}
var sharedKey = FormatKey(unformattedKey!);

var email = await userManager.GetEmailAsync(user);
var authenticatorUri = string.Format(
CultureInfo.InvariantCulture,
AuthenticatorUriFormat,
urlEncoder.Encode(appName),
urlEncoder.Encode(email!),
unformattedKey);
return TypedResults.Ok(new AuthenticatorResponse(sharedKey, authenticatorUri));
}).RequireAuthorization()
.Produces<AuthenticatorResponse>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status500InternalServerError)
.WithSummary("Generate an Authenticator URI and shared key")
.WithDescription("Generates a shared key and an Authenticator URI for a logged-in user. This endpoint is typically used to configure a TOTP authenticator app, such as Microsoft Authenticator or Google Authenticator.");


routeGroup.MapPost("/enable2fa", async Task<Results<Ok, ValidationProblem, NotFound, BadRequest<string>>>
(ClaimsPrincipal claimsPrincipal, HttpContext context, [FromBody] Enable2faRequest request) =>
{
var userManager = context.RequestServices.GetRequiredService<UserManager<TUser>>();
var urlEncoder = context.RequestServices.GetRequiredService<UrlEncoder>();
var user = await userManager.GetUserAsync(claimsPrincipal);
if (user is null)
{
return TypedResults.NotFound();
}
var unformattedKey = await userManager.GetAuthenticatorKeyAsync(user);
if (string.IsNullOrEmpty(unformattedKey))
{
await userManager.ResetAuthenticatorKeyAsync(user);
unformattedKey = await userManager.GetAuthenticatorKeyAsync(user);
}
var sharedKey = FormatKey(unformattedKey!);
var email = await userManager.GetEmailAsync(user);
var authenticatorUri = string.Format(
CultureInfo.InvariantCulture,
AuthenticatorUriFormat,
urlEncoder.Encode(request.AppName ?? "Blazor Aspire"), // 使用用户提供的 appName 或默认值
urlEncoder.Encode(email!),
unformattedKey);

var isValid = await userManager.VerifyTwoFactorTokenAsync(user, userManager.Options.Tokens.AuthenticatorTokenProvider, request.VerificationCode);
if (isValid)
{
await userManager.SetTwoFactorEnabledAsync(user, true);
logger.LogInformation("User has enabled 2fa.");
return TypedResults.Ok();
}
else
{
return TypedResults.BadRequest("Invalid verification code");
}
}).RequireAuthorization()
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status400BadRequest)
.WithSummary("Enable Authenticator for the user")
.WithDescription("This endpoint enables Two-Factor Authentication (TOTP) for a logged-in user. The user must first scan the provided QR code using an authenticator app, and then verify the generated code to complete the process.");

routeGroup.MapGet("/disable2fa", async Task<Results<Ok, NotFound, BadRequest>>
(ClaimsPrincipal claimsPrincipal, HttpContext context) =>
{
var userManager = context.RequestServices.GetRequiredService<UserManager<TUser>>();
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("Disable2FA");
var user = await userManager.GetUserAsync(claimsPrincipal);
if (user is null)
{
return TypedResults.NotFound();
}
var isTwoFactorEnabled = await userManager.GetTwoFactorEnabledAsync(user);
if (!isTwoFactorEnabled)
{
return TypedResults.BadRequest();
}
var result = await userManager.SetTwoFactorEnabledAsync(user, false);
if (!result.Succeeded)
{
logger.LogError("Failed to disable 2FA");
return TypedResults.BadRequest();
}

logger.LogInformation("User has disabled 2FA.");
return TypedResults.Ok();
})
.RequireAuthorization()
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status400BadRequest)
.WithSummary("Disable Two-Factor Authentication for the user")
.WithDescription("This endpoint disables Two-Factor Authentication (TOTP) for a logged-in user. The user must already have 2FA enabled for this operation to be valid.");

routeGroup.MapPost("/login2fa", async Task<Results<Ok, ProblemHttpResult, NotFound>>
([FromBody] LoginRequest login, [FromQuery] bool? useCookies, [FromQuery] bool? useSessionCookies, HttpContext context) =>
{
var signInManager = context.RequestServices.GetRequiredService<SignInManager<TUser>>();
var userManager = context.RequestServices.GetRequiredService<UserManager<TUser>>();
var useCookieScheme = (useCookies == true) || (useSessionCookies == true);
var isPersistent = (useCookies == true) && (useSessionCookies != true);
signInManager.AuthenticationScheme = useCookieScheme ? IdentityConstants.ApplicationScheme : IdentityConstants.BearerScheme;

var user = await userManager.FindByNameAsync(login.Email);
if (user == null)
{
return TypedResults.NotFound();
}

var result = await signInManager.PasswordSignInAsync(login.Email, login.Password, isPersistent, lockoutOnFailure: true);

if (result.RequiresTwoFactor)
{
if (!string.IsNullOrEmpty(login.TwoFactorCode))
{
result = await signInManager.TwoFactorAuthenticatorSignInAsync(login.TwoFactorCode, isPersistent, rememberClient: isPersistent);
}
else if (!string.IsNullOrEmpty(login.TwoFactorRecoveryCode))
{
result = await signInManager.TwoFactorRecoveryCodeSignInAsync(login.TwoFactorRecoveryCode);
}
}

if (!result.Succeeded)
{
return TypedResults.Problem(result.ToString(), statusCode: StatusCodes.Status401Unauthorized);
}

// The signInManager already produced the needed response in the form of a cookie or bearer token.
return TypedResults.Ok();
}).AllowAnonymous()
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status400BadRequest)
.WithSummary("Login with optional two-factor authentication")
.WithDescription("This endpoint allows users to log in with their email and password. If two-factor authentication is enabled, the user must provide a valid two-factor code or recovery code. Supports persistent cookies or bearer tokens.");


routeGroup.MapGet("generateRecoveryCodes", async Task<Results<Ok<RecoveryCodesResponse>, NotFound, BadRequest>>
(ClaimsPrincipal claimsPrincipal, HttpContext context) =>
{
var userManager = context.RequestServices.GetRequiredService<UserManager<TUser>>();
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("Disable2FA");
var user = await userManager.GetUserAsync(claimsPrincipal);
if (user is null)
{
return TypedResults.NotFound();
}
var isTwoFactorEnabled = await userManager.GetTwoFactorEnabledAsync(user);
if (!isTwoFactorEnabled)
{
return TypedResults.BadRequest();
}
int codeCount = 8;
var recoveryCodes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, codeCount);
return TypedResults.Ok(new RecoveryCodesResponse(recoveryCodes));
}).RequireAuthorization()
.Produces<RecoveryCodesResponse>(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status400BadRequest)
.WithSummary("Generate recovery codes for two-factor authentication.")
.WithDescription("Generates new recovery codes if two-factor authentication is enabled. "
+ "Returns 404 if the user is not found or 400 if 2FA is not enabled.");

async Task SendConfirmationEmailAsync(TUser user, UserManager<TUser> userManager, HttpContext context, string email, bool isChange = false)
{
var configuration = context.RequestServices.GetRequiredService<IConfiguration>();
Expand Down Expand Up @@ -472,12 +654,14 @@ async Task SendConfirmationEmailAsync(TUser user, UserManager<TUser> userManager
.WithSummary("Request a password reset link")
.WithDescription("Generates and sends a password reset link to the user's email if the email is registered and confirmed.");
return endpoints;

}
private static async Task<ProfileResponse> CreateInfoResponseAsync<TUser>(TUser user, UserManager<TUser> userManager)
where TUser : class
{
if (user is not ApplicationUser appUser)
throw new InvalidCastException($"The provided user must be of type {nameof(ApplicationUser)}.");
var isTwoFactorEnabled = await userManager.GetTwoFactorEnabledAsync(user);
return new()
{
UserId = await userManager.GetUserIdAsync(user) ?? throw new NotSupportedException("Users must have an ID."),
Expand All @@ -490,7 +674,8 @@ private static async Task<ProfileResponse> CreateInfoResponseAsync<TUser>(TUser
Provider = appUser.Provider,
SuperiorId = appUser.SuperiorId,
TimeZoneId = appUser.TimeZoneId,
AvatarUrl = appUser.AvatarUrl
AvatarUrl = appUser.AvatarUrl,
IsTwoFactorEnabled = isTwoFactorEnabled
};
}

Expand Down Expand Up @@ -624,6 +809,25 @@ static string GenerateChangeEmailContent(string confirmEmailUrl)
</body>
</html>";
}

private static string FormatKey(string unformattedKey)
{
var result = new StringBuilder();
int currentPosition = 0;
while (currentPosition + 4 < unformattedKey.Length)
{
result.Append(unformattedKey.AsSpan(currentPosition, 4)).Append(' ');
currentPosition += 4;
}
if (currentPosition < unformattedKey.Length)
{
result.Append(unformattedKey.AsSpan(currentPosition));
}

return result.ToString().ToLowerInvariant();
}
private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6";

}

public class UpdateEmailRequest
Expand Down Expand Up @@ -689,6 +893,7 @@ public sealed class ProfileResponse
public string? TimeZoneId { get; init; }
public string? LanguageCode { get; init; }
public string? SuperiorId { get; init; }
public bool IsTwoFactorEnabled { get; init; }
}
public sealed class SignupRequest
{
Expand Down Expand Up @@ -743,3 +948,15 @@ internal sealed record GoogleAuthResponse(
string Email,
string? ProfilePicture
);

internal sealed record AuthenticatorResponse(
string SharedKey,
string AuthenticatorUri
);
internal sealed record Enable2faRequest(
string? AppName,
string VerificationCode
);
internal sealed record RecoveryCodesResponse(
IEnumerable<string> Codes
);
3 changes: 2 additions & 1 deletion src/CleanAspire.ClientApp/CleanAspire.ClientApp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
<PackageReference Include="Microsoft.Kiota.Serialization.Json" Version="1.16.1" />
<PackageReference Include="Microsoft.Kiota.Serialization.Multipart" Version="1.16.1" />
<PackageReference Include="Microsoft.Kiota.Serialization.Text" Version="1.16.1" />
<PackageReference Include="MudBlazor" Version="8.0.0-preview.6" />
<PackageReference Include="MudBlazor" Version="8.0.0-preview.7" />
<PackageReference Include="Net.Codecrete.QrCodeGenerator" Version="2.0.6" />
<PackageReference Include="OneOf" Version="3.0.271" />
</ItemGroup>

Expand Down
5 changes: 5 additions & 0 deletions src/CleanAspire.ClientApp/Client/.kiota/workspace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"version": "1.0.0",
"clients": {},
"plugins": {}
}
Loading

0 comments on commit 33bb6dd

Please sign in to comment.