diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs index fcf8b8e..e396b9c 100644 --- a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs @@ -30,7 +30,7 @@ public class EmailAuthController( if (!req.Email.Contains('@')) throw new ApiError.BadRequest("Email is invalid", "email", req.Email); var state = await keyCacheService.GenerateRegisterEmailStateAsync(req.Email, userId: null, ct); - + // If there's already a user with that email address, pretend we sent an email but actually ignore it if (await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Email && a.RemoteId == req.Email, ct)) return NoContent(); @@ -40,55 +40,53 @@ public class EmailAuthController( } [HttpPost("callback")] - public async Task CallbackAsync([FromBody] CallbackRequest req, CancellationToken ct = default) + public async Task CallbackAsync([FromBody] CallbackRequest req) { CheckRequirements(); - var state = await keyCacheService.GetRegisterEmailStateAsync(req.State, ct); + var state = await keyCacheService.GetRegisterEmailStateAsync(req.State); if (state == null) throw new ApiError.BadRequest("Invalid state", "state", req.State); - + // If this callback is for an existing user, add the email address to their auth methods if (state.ExistingUserId != null) { var authMethod = - await authService.AddAuthMethodAsync(state.ExistingUserId.Value, AuthType.Email, state.Email, ct: ct); + await authService.AddAuthMethodAsync(state.ExistingUserId.Value, AuthType.Email, state.Email); _logger.Debug("Added email auth {AuthId} for user {UserId}", authMethod.Id, state.ExistingUserId); return NoContent(); } var ticket = AuthUtils.RandomToken(); - await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20), ct); + await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20)); return Ok(new AuthController.CallbackResponse(false, ticket, state.Email)); } [HttpPost("complete-registration")] - public async Task CompleteRegistrationAsync([FromBody] CompleteRegistrationRequest req, - CancellationToken ct = default) + public async Task CompleteRegistrationAsync([FromBody] CompleteRegistrationRequest req) { - var email = await keyCacheService.GetKeyAsync($"email:{req.Ticket}", ct: ct); + var email = await keyCacheService.GetKeyAsync($"email:{req.Ticket}"); if (email == null) throw new ApiError.BadRequest("Unknown ticket", "ticket", req.Ticket); // Check if username is valid at all ValidationUtils.Validate([("username", ValidationUtils.ValidateUsername(req.Username))]); // Check if username is already taken - if (await db.Users.AnyAsync(u => u.Username == req.Username, ct)) + if (await db.Users.AnyAsync(u => u.Username == req.Username)) throw new ApiError.BadRequest("Username is already taken", "username", req.Username); - var user = await authService.CreateUserWithPasswordAsync(req.Username, email, req.Password, ct); - var frontendApp = await db.GetFrontendApplicationAsync(ct); + var user = await authService.CreateUserWithPasswordAsync(req.Username, email, req.Password); + var frontendApp = await db.GetFrontendApplicationAsync(); var (tokenStr, token) = authService.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); db.Add(token); - await db.SaveChangesAsync(ct); + await db.SaveChangesAsync(); - // Specifically do *not* pass the CancellationToken so we don't cancel the rendering after creating the user account. - await keyCacheService.DeleteKeyAsync($"email:{req.Ticket}", ct: default); + await keyCacheService.DeleteKeyAsync($"email:{req.Ticket}"); return Ok(new AuthController.AuthResponse( - await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false, ct: default), + await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false), tokenStr, token.ExpiresAt )); diff --git a/Foxnouns.Backend/Controllers/InternalController.cs b/Foxnouns.Backend/Controllers/InternalController.cs index 859c2fe..eb22881 100644 --- a/Foxnouns.Backend/Controllers/InternalController.cs +++ b/Foxnouns.Backend/Controllers/InternalController.cs @@ -40,16 +40,10 @@ public partial class InternalController(DatabaseContext db, IClock clock) : Cont template = GetCleanedTemplate(template); // If no token was supplied, or it isn't valid base 64, return a null user ID (limiting by IP) - if (req.Token == null) return Ok(new RequestDataResponse(null, template)); - if (!AuthUtils.TryFromBase64String(req.Token, out var rawToken)) + if (!AuthUtils.TryParseToken(req.Token, out var rawToken)) return Ok(new RequestDataResponse(null, template)); - var hash = SHA512.HashData(rawToken); - var oauthToken = await db.Tokens - .Include(t => t.Application) - .Include(t => t.User) - .FirstOrDefaultAsync(t => t.Hash == hash && t.ExpiresAt > clock.GetCurrentInstant() && !t.ManuallyExpired); - + var oauthToken = await db.GetToken(rawToken); return Ok(new RequestDataResponse(oauthToken?.UserId, template)); } diff --git a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs index 76043d7..6fe4c58 100644 --- a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs +++ b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs @@ -105,4 +105,18 @@ public static class DatabaseQueryExtensions await context.SaveChangesAsync(ct); return app; } + + public static async Task GetToken(this DatabaseContext context, byte[] rawToken, + CancellationToken ct = default) + { + var hash = SHA512.HashData(rawToken); + var oauthToken = await context.Tokens + .Include(t => t.Application) + .Include(t => t.User) + .FirstOrDefaultAsync( + t => t.Hash == hash && t.ExpiresAt > SystemClock.Instance.GetCurrentInstant() && !t.ManuallyExpired, + ct); + + return oauthToken; + } } \ No newline at end of file diff --git a/Foxnouns.Backend/Middleware/AuthenticationMiddleware.cs b/Foxnouns.Backend/Middleware/AuthenticationMiddleware.cs index 516813b..36cbcb3 100644 --- a/Foxnouns.Backend/Middleware/AuthenticationMiddleware.cs +++ b/Foxnouns.Backend/Middleware/AuthenticationMiddleware.cs @@ -20,18 +20,13 @@ public class AuthenticationMiddleware(DatabaseContext db, IClock clock) : IMiddl return; } - var header = ctx.Request.Headers.Authorization.ToString(); - if (!AuthUtils.TryFromBase64String(header, out var rawToken)) + if (!AuthUtils.TryParseToken(ctx.Request.Headers.Authorization.ToString(), out var rawToken)) { await next(ctx); return; } - var hash = SHA512.HashData(rawToken); - var oauthToken = await db.Tokens - .Include(t => t.Application) - .Include(t => t.User) - .FirstOrDefaultAsync(t => t.Hash == hash && t.ExpiresAt > clock.GetCurrentInstant() && !t.ManuallyExpired); + var oauthToken = await db.GetToken(rawToken); if (oauthToken == null) { await next(ctx); diff --git a/Foxnouns.Backend/Services/AuthService.cs b/Foxnouns.Backend/Services/AuthService.cs index 22a28d2..f69441a 100644 --- a/Foxnouns.Backend/Services/AuthService.cs +++ b/Foxnouns.Backend/Services/AuthService.cs @@ -16,7 +16,8 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s /// Creates a new user with the given email address and password. /// This method does not save the resulting user, the caller must still call . /// - public async Task CreateUserWithPasswordAsync(string username, string email, string password, CancellationToken ct = default) + public async Task CreateUserWithPasswordAsync(string username, string email, string password, + CancellationToken ct = default) { var user = new User { @@ -76,16 +77,19 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s /// A tuple of the authenticated user and whether multi-factor authentication is required /// Thrown if the email address is not associated with any user /// or if the password is incorrect - public async Task<(User, EmailAuthenticationResult)> AuthenticateUserAsync(string email, string password, CancellationToken ct = default) + public async Task<(User, EmailAuthenticationResult)> AuthenticateUserAsync(string email, string password, + CancellationToken ct = default) { var user = await db.Users.FirstOrDefaultAsync(u => u.AuthMethods.Any(a => a.AuthType == AuthType.Email && a.RemoteId == email), ct); if (user == null) - throw new ApiError.NotFound("No user with that email address found, or password is incorrect", ErrorCode.UserNotFound); + throw new ApiError.NotFound("No user with that email address found, or password is incorrect", + ErrorCode.UserNotFound); var pwResult = await Task.Run(() => _passwordHasher.VerifyHashedPassword(user, user.Password!, password), ct); if (pwResult == PasswordVerificationResult.Failed) // TODO: this seems to fail on some valid passwords? - throw new ApiError.NotFound("No user with that email address found, or password is incorrect", ErrorCode.UserNotFound); + throw new ApiError.NotFound("No user with that email address found, or password is incorrect", + ErrorCode.UserNotFound); if (pwResult == PasswordVerificationResult.SuccessRehashNeeded) { user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct); diff --git a/Foxnouns.Backend/Utils/AuthUtils.cs b/Foxnouns.Backend/Utils/AuthUtils.cs index badc19b..26965e2 100644 --- a/Foxnouns.Backend/Utils/AuthUtils.cs +++ b/Foxnouns.Backend/Utils/AuthUtils.cs @@ -79,6 +79,17 @@ public static class AuthUtils return false; } } + + public static bool TryParseToken(string? input, out byte[] rawToken) + { + rawToken = []; + + if (string.IsNullOrWhiteSpace(input)) return false; + if (input.StartsWith("bearer ", StringComparison.InvariantCultureIgnoreCase)) + input = input["bearer ".Length..]; + + return TryFromBase64String(input, out rawToken); + } public static string RandomToken(int bytes = 48) => Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).Trim('=');