diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs index 41eab25..593e20f 100644 --- a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs @@ -183,10 +183,40 @@ public class EmailAuthController( [HttpPost("add")] [Authorize("*")] - public async Task AddEmailAddressAsync() + public async Task AddEmailAddressAsync([FromBody] AddEmailAddressRequest req) { - _logger.Information("beep"); + var emails = await db + .AuthMethods.Where(m => m.UserId == CurrentUser!.Id && m.AuthType == AuthType.Email) + .ToListAsync(); + if (emails.Count > AuthUtils.MaxAuthMethodsPerType) + { + throw new ApiError.BadRequest( + "Too many email addresses, maximum of 3 per account.", + "email", + null + ); + } + var validPassword = await authService.ValidatePasswordAsync(CurrentUser!, req.Password); + if (!validPassword) + { + throw new ApiError.Forbidden("Invalid password"); + } + + var state = await keyCacheService.GenerateRegisterEmailStateAsync( + req.Email, + userId: CurrentUser!.Id + ); + + var emailExists = await db + .AuthMethods.Where(m => m.AuthType == AuthType.Email && m.RemoteId == req.Email) + .AnyAsync(); + if (emailExists) + { + return NoContent(); + } + + mailService.QueueAddEmailAddressEmail(req.Email, state, CurrentUser.Username); return NoContent(); } diff --git a/Foxnouns.Backend/ExpectedError.cs b/Foxnouns.Backend/ExpectedError.cs index 0630892..fdd0b5d 100644 --- a/Foxnouns.Backend/ExpectedError.cs +++ b/Foxnouns.Backend/ExpectedError.cs @@ -42,7 +42,7 @@ public class ApiError( IReadOnlyDictionary>? errors = null ) : ApiError(message, statusCode: HttpStatusCode.BadRequest) { - public BadRequest(string message, string field, object actualValue) + public BadRequest(string message, string field, object? actualValue) : this( "Error validating input", new Dictionary> diff --git a/Foxnouns.Backend/Mailables/AddEmailMailable.cs b/Foxnouns.Backend/Mailables/AddEmailMailable.cs new file mode 100644 index 0000000..ee5792d --- /dev/null +++ b/Foxnouns.Backend/Mailables/AddEmailMailable.cs @@ -0,0 +1,18 @@ +using Coravel.Mailer.Mail; + +namespace Foxnouns.Backend.Mailables; + +public class AddEmailMailable(Config config, AddEmailMailableView view) + : Mailable +{ + public override void Build() + { + To(view.To).From(config.EmailAuth.From!).View("~/Views/Mail/AddEmail.cshtml", view); + } +} + +public class AddEmailMailableView : BaseView +{ + public required string Code { get; init; } + public required string Username { get; init; } +} diff --git a/Foxnouns.Backend/Services/AuthService.cs b/Foxnouns.Backend/Services/AuthService.cs index 1aaa5e4..6226223 100644 --- a/Foxnouns.Backend/Services/AuthService.cs +++ b/Foxnouns.Backend/Services/AuthService.cs @@ -129,6 +129,30 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s return (user, EmailAuthenticationResult.AuthSuccessful); } + /// + /// Validates a user's password outside an authentication context, for when a password is required for changing + /// a setting, such as adding a new email address or changing passwords. + /// + public async Task ValidatePasswordAsync( + User user, + string password, + CancellationToken ct = default + ) + { + if (user.Password == null) + { + throw new FoxnounsError("Password for user supplied to ValidatePasswordAsync was null"); + } + + var pwResult = await Task.Run( + () => _passwordHasher.VerifyHashedPassword(user, user.Password!, password), + ct + ); + return pwResult + is PasswordVerificationResult.SuccessRehashNeeded + or PasswordVerificationResult.Success; + } + public enum EmailAuthenticationResult { AuthSuccessful, diff --git a/Foxnouns.Backend/Services/MailService.cs b/Foxnouns.Backend/Services/MailService.cs index c605866..888f5fb 100644 --- a/Foxnouns.Backend/Services/MailService.cs +++ b/Foxnouns.Backend/Services/MailService.cs @@ -33,4 +33,31 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co } }); } + + public void QueueAddEmailAddressEmail(string to, string code, string username) + { + _logger.Debug("Sending add email address email to {ToEmail}", to); + queue.QueueAsyncTask(async () => + { + try + { + await mailer.SendAsync( + new AddEmailMailable( + config, + new AddEmailMailableView + { + BaseUrl = config.BaseUrl, + To = to, + Code = code, + Username = username, + } + ) + ); + } + catch (Exception exc) + { + _logger.Error(exc, "Sending add email address email"); + } + }); + } } diff --git a/Foxnouns.Backend/Views/Mail/AddEmail.cshtml b/Foxnouns.Backend/Views/Mail/AddEmail.cshtml new file mode 100644 index 0000000..dabef6c --- /dev/null +++ b/Foxnouns.Backend/Views/Mail/AddEmail.cshtml @@ -0,0 +1,12 @@ +@model Foxnouns.Backend.Mailables.AddEmailMailableView + +

+ Hello @@@Model.Username, please confirm adding this email address to your account by using the following link: +
+ Confirm your email address +
+ Note that this link will expire in one hour. +

+

+ If you didn't mean to link this email address to @@@Model.Username, feel free to ignore this email. +

\ No newline at end of file