| | | 1 | | using Anichron.API.Infrastructure; |
| | | 2 | | using Anichron.API.Services; |
| | | 3 | | using Anichron.API.Settings; |
| | | 4 | | using Microsoft.AspNetCore.Mvc; |
| | | 5 | | using Microsoft.Extensions.Options; |
| | | 6 | | |
| | | 7 | | namespace Anichron.API.Endpoints; |
| | | 8 | | |
| | | 9 | | public static class AuthEndpoints |
| | | 10 | | { |
| | | 11 | | public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app) |
| | 0 | 12 | | { |
| | 0 | 13 | | var group = app.MapGroup(ApiPaths.Auth.Group).WithTags("Auth"); |
| | | 14 | | |
| | 0 | 15 | | group.MapPost(ApiPaths.Auth.Register, RegisterAsync).AllowAnonymous().RequireRateLimiting(AuthRateLimitPolicies. |
| | 0 | 16 | | group.MapPost(ApiPaths.Auth.Login, LoginWebAsync).AllowAnonymous().RequireRateLimiting(AuthRateLimitPolicies.Sen |
| | 0 | 17 | | group.MapPost(ApiPaths.Auth.LoginMobile, LoginMobileAsync).AllowAnonymous().RequireRateLimiting(AuthRateLimitPol |
| | 0 | 18 | | group.MapPost(ApiPaths.Auth.Refresh, RefreshAsync).AllowAnonymous().RequireRateLimiting(AuthRateLimitPolicies.Re |
| | 0 | 19 | | group.MapPost(ApiPaths.Auth.Logout, LogoutAsync).RequireAuthorization(); |
| | 0 | 20 | | group.MapPost(ApiPaths.Auth.PasswordResetRequest, PasswordResetRequest).AllowAnonymous().RequireRateLimiting(Aut |
| | 0 | 21 | | group.MapPost(ApiPaths.Auth.PasswordResetConfirm, PasswordResetConfirm).AllowAnonymous().RequireRateLimiting(Aut |
| | | 22 | | |
| | 0 | 23 | | return app; |
| | 0 | 24 | | } |
| | | 25 | | |
| | | 26 | | internal static async Task<IResult> RegisterAsync( |
| | | 27 | | RegisterRequest request, |
| | | 28 | | IAuthService auth, |
| | | 29 | | IAuthResponseMapper mapper, |
| | | 30 | | HttpContext http, |
| | | 31 | | IOptions<PasswordPolicy> passwordPolicy, |
| | | 32 | | IOptions<UsernamePolicy> usernamePolicy, |
| | | 33 | | CancellationToken ct) |
| | 2 | 34 | | { |
| | 2 | 35 | | var result = await auth.RegisterAsync(request.Username, request.Email, request.Password, request.InviteToken, ct |
| | 2 | 36 | | return mapper.GetRegistrationResult(result, http, passwordPolicy.Value, usernamePolicy.Value); |
| | 2 | 37 | | } |
| | | 38 | | |
| | | 39 | | internal static Task<IResult> LoginWebAsync( |
| | | 40 | | LoginRequest request, IAuthService auth, IAuthResponseMapper mapper, HttpContext http, CancellationToken ct) |
| | 2 | 41 | | => HandleLoginAsync(request, auth, mapper, http, setCookie: true, ct); |
| | | 42 | | |
| | | 43 | | internal static Task<IResult> LoginMobileAsync( |
| | | 44 | | LoginRequest request, IAuthService auth, IAuthResponseMapper mapper, HttpContext http, CancellationToken ct) |
| | 2 | 45 | | => HandleLoginAsync(request, auth, mapper, http, setCookie: false, ct); |
| | | 46 | | |
| | | 47 | | private static async Task<IResult> HandleLoginAsync( |
| | | 48 | | LoginRequest request, IAuthService auth, IAuthResponseMapper mapper, HttpContext http, bool setCookie, Cancellat |
| | 4 | 49 | | { |
| | 4 | 50 | | var result = await auth.LoginAsync(request.UsernameOrEmail, request.Password, ct); |
| | 4 | 51 | | return mapper.GetLoginResult(result, http, setCookie); |
| | 4 | 52 | | } |
| | | 53 | | |
| | | 54 | | internal static async Task<IResult> RefreshAsync( |
| | | 55 | | HttpContext http, |
| | | 56 | | IAuthService auth, |
| | | 57 | | IAuthResponseMapper mapper, |
| | | 58 | | // [FromBody] is explicit here because the request is nullable — |
| | | 59 | | // web clients send no body (token arrives via cookie), mobile clients send it in the body. |
| | | 60 | | [FromBody] RefreshRequest? request, |
| | | 61 | | CancellationToken ct) |
| | 3 | 62 | | { |
| | 3 | 63 | | var rawToken = http.Request.Cookies[AuthMessages.RefreshTokenCookieName] ?? request?.RefreshToken; |
| | 3 | 64 | | if (rawToken is null) |
| | 1 | 65 | | { |
| | 1 | 66 | | return Results.Json( |
| | 1 | 67 | | data: new { error = AuthMessages.RefreshTokenRequired }, |
| | 1 | 68 | | statusCode: StatusCodes.Status401Unauthorized); |
| | | 69 | | } |
| | | 70 | | |
| | 2 | 71 | | var result = await auth.RefreshAsync(rawToken, ct); |
| | 2 | 72 | | var setCookie = http.Request.Cookies.ContainsKey(AuthMessages.RefreshTokenCookieName); |
| | 2 | 73 | | return mapper.GetRefreshResult(result, http, setCookie); |
| | 3 | 74 | | } |
| | | 75 | | |
| | | 76 | | internal static async Task<IResult> LogoutAsync( |
| | | 77 | | HttpContext http, IAuthService auth, IAuthResponseMapper mapper, [FromBody] RefreshRequest? request, Cancellatio |
| | 3 | 78 | | { |
| | 3 | 79 | | var rawToken = http.Request.Cookies[AuthMessages.RefreshTokenCookieName] ?? request?.RefreshToken; |
| | 3 | 80 | | mapper.ClearRefreshCookie(http); |
| | 3 | 81 | | if (rawToken is not null) |
| | 2 | 82 | | await auth.RevokeAsync(rawToken, ct); |
| | 3 | 83 | | return Results.NoContent(); |
| | 3 | 84 | | } |
| | | 85 | | |
| | | 86 | | // Not yet implemented — planned for Epic 8 (email notifications with deep links). |
| | 0 | 87 | | private static IResult PasswordResetRequest() => Results.StatusCode(StatusCodes.Status501NotImplemented); |
| | | 88 | | |
| | 0 | 89 | | private static IResult PasswordResetConfirm() => Results.StatusCode(StatusCodes.Status501NotImplemented); |
| | | 90 | | } |
| | | 91 | | |
| | | 92 | | public sealed record RegisterRequest(string Username, string Email, string Password, string InviteToken); |
| | | 93 | | public sealed record LoginRequest(string UsernameOrEmail, string Password); |
| | | 94 | | public sealed record RefreshRequest(string RefreshToken); |