diff --git a/account/api/api.go b/account/api/api.go index 84f8912..2520ed0 100644 --- a/account/api/api.go +++ b/account/api/api.go @@ -5,6 +5,9 @@ import ( "crypto/rand" "encoding/hex" "errors" + "fmt" + "html" + "log/slog" "net/http" "strings" "sync" @@ -21,6 +24,8 @@ import ( const defaultSessionTTL = 24 * time.Hour const defaultMFAChallengeTTL = 10 * time.Minute const defaultTOTPIssuer = "XControl Account" +const defaultEmailVerificationTTL = 24 * time.Hour +const defaultPasswordResetTTL = 30 * time.Minute type session struct { userID string @@ -36,6 +41,13 @@ type handler struct { mfaMu sync.RWMutex mfaChallengeTTL time.Duration totpIssuer string + emailSender EmailSender + verificationTTL time.Duration + verifications map[string]emailVerification + verificationMu sync.RWMutex + resetTTL time.Duration + passwordResets map[string]passwordReset + resetMu sync.RWMutex } type mfaChallenge struct { @@ -43,6 +55,18 @@ type mfaChallenge struct { expiresAt time.Time } +type emailVerification struct { + userID string + email string + expiresAt time.Time +} + +type passwordReset struct { + userID string + email string + expiresAt time.Time +} + // Option configures handler behaviour when registering routes. type Option func(*handler) @@ -64,6 +88,33 @@ func WithSessionTTL(ttl time.Duration) Option { } } +// WithEmailSender configures the handler to use the provided EmailSender for outbound notifications. +func WithEmailSender(sender EmailSender) Option { + return func(h *handler) { + if sender != nil { + h.emailSender = sender + } + } +} + +// WithEmailVerificationTTL overrides the default TTL for email verification tokens. +func WithEmailVerificationTTL(ttl time.Duration) Option { + return func(h *handler) { + if ttl > 0 { + h.verificationTTL = ttl + } + } +} + +// WithPasswordResetTTL overrides the default TTL for password reset tokens. +func WithPasswordResetTTL(ttl time.Duration) Option { + return func(h *handler) { + if ttl > 0 { + h.resetTTL = ttl + } + } +} + // RegisterRoutes attaches account service endpoints to the router. func RegisterRoutes(r *gin.Engine, opts ...Option) { h := &handler{ @@ -73,6 +124,11 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) { mfaChallenges: make(map[string]mfaChallenge), mfaChallengeTTL: defaultMFAChallengeTTL, totpIssuer: defaultTOTPIssuer, + emailSender: noopEmailSender, + verificationTTL: defaultEmailVerificationTTL, + verifications: make(map[string]emailVerification), + resetTTL: defaultPasswordResetTTL, + passwordResets: make(map[string]passwordReset), } for _, opt := range opts { @@ -85,12 +141,15 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) { auth := r.Group("/api/auth") auth.POST("/register", h.register) + auth.POST("/register/verify", h.verifyEmail) auth.POST("/login", h.login) auth.GET("/session", h.session) auth.DELETE("/session", h.deleteSession) auth.POST("/mfa/totp/provision", h.provisionTOTP) auth.POST("/mfa/totp/verify", h.verifyTOTP) auth.GET("/mfa/status", h.mfaStatus) + auth.POST("/password/reset", h.requestPasswordReset) + auth.POST("/password/reset/confirm", h.confirmPasswordReset) } type registerRequest struct { @@ -107,6 +166,19 @@ type loginRequest struct { TOTPCode string `json:"totpCode"` } +type tokenRequest struct { + Token string `json:"token"` +} + +type passwordResetRequestBody struct { + Email string `json:"email"` +} + +type passwordResetConfirmRequest struct { + Token string `json:"token"` + Password string `json:"password"` +} + func hasQueryParameter(c *gin.Context, keys ...string) bool { if len(keys) == 0 { return false @@ -187,13 +259,197 @@ func (h *handler) register(c *gin.Context) { } } + if err := h.enqueueEmailVerification(c.Request.Context(), user); err != nil { + slog.Error("failed to send verification email", "err", err, "email", user.Email) + respondError(c, http.StatusInternalServerError, "verification_email_failed", "failed to send verification email") + return + } + response := gin.H{ - "message": "user registered successfully", + "message": "verification email sent", "user": sanitizeUser(user), } c.JSON(http.StatusCreated, response) } +func (h *handler) verifyEmail(c *gin.Context) { + if hasQueryParameter(c, "token") { + respondError(c, http.StatusBadRequest, "token_in_query", "verification token must be sent in the request body") + return + } + + var req tokenRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "invalid_request", "invalid request payload") + return + } + + token := strings.TrimSpace(req.Token) + if token == "" { + respondError(c, http.StatusBadRequest, "invalid_token", "verification token is required") + return + } + + verification, ok := h.lookupEmailVerification(token) + if !ok { + respondError(c, http.StatusBadRequest, "invalid_token", "verification token is invalid or expired") + return + } + + user, err := h.store.GetUserByID(c.Request.Context(), verification.userID) + if err != nil { + slog.Error("failed to load user for email verification", "err", err, "userID", verification.userID) + respondError(c, http.StatusInternalServerError, "verification_failed", "failed to verify email") + return + } + + if !strings.EqualFold(strings.TrimSpace(user.Email), verification.email) { + h.removeEmailVerification(token) + respondError(c, http.StatusBadRequest, "invalid_token", "verification token is invalid or expired") + return + } + + if !user.EmailVerified { + user.EmailVerified = true + if err := h.store.UpdateUser(c.Request.Context(), user); err != nil { + slog.Error("failed to update user during email verification", "err", err, "userID", user.ID) + respondError(c, http.StatusInternalServerError, "verification_failed", "failed to verify email") + return + } + } + + h.removeEmailVerification(token) + + sessionToken, expiresAt, err := h.createSession(user.ID) + if err != nil { + respondError(c, http.StatusInternalServerError, "session_creation_failed", "failed to create session") + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "email verified", + "token": sessionToken, + "expiresAt": expiresAt.UTC(), + "user": sanitizeUser(user), + }) +} + +func (h *handler) requestPasswordReset(c *gin.Context) { + if hasQueryParameter(c, "email") { + respondError(c, http.StatusBadRequest, "email_in_query", "email must be sent in the request body") + return + } + + var req passwordResetRequestBody + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "invalid_request", "invalid request payload") + return + } + + email := strings.ToLower(strings.TrimSpace(req.Email)) + if email == "" { + respondError(c, http.StatusBadRequest, "email_required", "email is required") + return + } + + user, err := h.store.GetUserByEmail(c.Request.Context(), email) + if err != nil { + if errors.Is(err, store.ErrUserNotFound) { + c.JSON(http.StatusAccepted, gin.H{"message": "if the account exists a reset email will be sent"}) + return + } + respondError(c, http.StatusInternalServerError, "password_reset_failed", "failed to initiate password reset") + return + } + + if strings.TrimSpace(user.Email) == "" || !user.EmailVerified { + c.JSON(http.StatusAccepted, gin.H{"message": "if the account exists a reset email will be sent"}) + return + } + + if err := h.enqueuePasswordReset(c.Request.Context(), user); err != nil { + slog.Error("failed to send password reset email", "err", err, "email", user.Email) + respondError(c, http.StatusInternalServerError, "password_reset_failed", "failed to initiate password reset") + return + } + + c.JSON(http.StatusAccepted, gin.H{"message": "if the account exists a reset email will be sent"}) +} + +func (h *handler) confirmPasswordReset(c *gin.Context) { + if hasQueryParameter(c, "token", "password") { + respondError(c, http.StatusBadRequest, "credentials_in_query", "sensitive credentials must not be sent in the query string") + return + } + + var req passwordResetConfirmRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "invalid_request", "invalid request payload") + return + } + + token := strings.TrimSpace(req.Token) + password := strings.TrimSpace(req.Password) + + if token == "" || password == "" { + respondError(c, http.StatusBadRequest, "invalid_request", "token and password are required") + return + } + + if len(password) < 8 { + respondError(c, http.StatusBadRequest, "password_too_short", "password must be at least 8 characters") + return + } + + reset, ok := h.lookupPasswordReset(token) + if !ok { + respondError(c, http.StatusBadRequest, "invalid_token", "reset token is invalid or expired") + return + } + + user, err := h.store.GetUserByID(c.Request.Context(), reset.userID) + if err != nil { + slog.Error("failed to load user for password reset", "err", err, "userID", reset.userID) + respondError(c, http.StatusInternalServerError, "password_reset_failed", "failed to reset password") + return + } + + if !strings.EqualFold(strings.TrimSpace(user.Email), reset.email) { + h.removePasswordReset(token) + respondError(c, http.StatusBadRequest, "invalid_token", "reset token is invalid or expired") + return + } + + hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + respondError(c, http.StatusInternalServerError, "password_reset_failed", "failed to reset password") + return + } + + user.PasswordHash = string(hashed) + user.EmailVerified = true + if err := h.store.UpdateUser(c.Request.Context(), user); err != nil { + slog.Error("failed to update user during password reset", "err", err, "userID", user.ID) + respondError(c, http.StatusInternalServerError, "password_reset_failed", "failed to reset password") + return + } + + h.removePasswordReset(token) + + sessionToken, expiresAt, err := h.createSession(user.ID) + if err != nil { + respondError(c, http.StatusInternalServerError, "session_creation_failed", "failed to create session") + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "password reset successful", + "token": sessionToken, + "expiresAt": expiresAt.UTC(), + "user": sanitizeUser(user), + }) +} + func (h *handler) login(c *gin.Context) { if hasQueryParameter(c, "username", "password", "identifier", "totp") { respondError(c, http.StatusBadRequest, "credentials_in_query", "sensitive credentials must not be sent in the query string") @@ -248,6 +504,11 @@ func (h *handler) login(c *gin.Context) { } } + if strings.TrimSpace(user.Email) != "" && !user.EmailVerified { + respondError(c, http.StatusUnauthorized, "email_not_verified", "email must be verified before login") + return + } + if !user.MFAEnabled { challengeToken, err := h.createMFAChallenge(user.ID) if err != nil { @@ -447,6 +708,162 @@ func (h *handler) refreshMFAChallenge(token string) (mfaChallenge, bool) { return challenge, true } +func (h *handler) enqueueEmailVerification(ctx context.Context, user *store.User) error { + email := strings.TrimSpace(user.Email) + if email == "" { + return errors.New("user email is empty") + } + + token, err := h.newRandomToken() + if err != nil { + return err + } + + ttl := h.verificationTTL + if ttl <= 0 { + ttl = defaultEmailVerificationTTL + } + + expiresAt := time.Now().Add(ttl) + verification := emailVerification{ + userID: user.ID, + email: strings.ToLower(email), + expiresAt: expiresAt, + } + + h.verificationMu.Lock() + h.verifications[token] = verification + h.verificationMu.Unlock() + + name := strings.TrimSpace(user.Name) + if name == "" { + name = "there" + } + + subject := "Verify your XControl account" + plainBody := fmt.Sprintf("Hello %s,\n\nUse the following token to verify your XControl account: %s\n\nThis token expires at %s UTC.\nIf you did not request this email you can ignore it.\n", name, token, expiresAt.UTC().Format(time.RFC3339)) + htmlBody := fmt.Sprintf("
Hello %s,
Use the following token to verify your XControl account:
%s
This token expires at %s UTC.
If you did not request this email you can ignore it.
", html.EscapeString(name), token, expiresAt.UTC().Format(time.RFC3339)) + + msg := EmailMessage{ + To: []string{email}, + Subject: subject, + PlainBody: plainBody, + HTMLBody: htmlBody, + } + + if err := h.emailSender.Send(ctx, msg); err != nil { + h.removeEmailVerification(token) + return err + } + + return nil +} + +func (h *handler) lookupEmailVerification(token string) (emailVerification, bool) { + token = strings.TrimSpace(token) + if token == "" { + return emailVerification{}, false + } + + h.verificationMu.RLock() + verification, ok := h.verifications[token] + h.verificationMu.RUnlock() + if !ok { + return emailVerification{}, false + } + + if time.Now().After(verification.expiresAt) { + h.removeEmailVerification(token) + return emailVerification{}, false + } + + return verification, true +} + +func (h *handler) removeEmailVerification(token string) { + h.verificationMu.Lock() + delete(h.verifications, strings.TrimSpace(token)) + h.verificationMu.Unlock() +} + +func (h *handler) enqueuePasswordReset(ctx context.Context, user *store.User) error { + email := strings.TrimSpace(user.Email) + if email == "" { + return errors.New("user email is empty") + } + + token, err := h.newRandomToken() + if err != nil { + return err + } + + ttl := h.resetTTL + if ttl <= 0 { + ttl = defaultPasswordResetTTL + } + + expiresAt := time.Now().Add(ttl) + reset := passwordReset{ + userID: user.ID, + email: strings.ToLower(email), + expiresAt: expiresAt, + } + + h.resetMu.Lock() + h.passwordResets[token] = reset + h.resetMu.Unlock() + + name := strings.TrimSpace(user.Name) + if name == "" { + name = "there" + } + + subject := "Reset your XControl password" + plainBody := fmt.Sprintf("Hello %s,\n\nUse the following token to reset your XControl account password: %s\n\nThis token expires at %s UTC.\nIf you did not request a reset you can ignore this email.\n", name, token, expiresAt.UTC().Format(time.RFC3339)) + htmlBody := fmt.Sprintf("Hello %s,
Use the following token to reset your XControl account password:
%s
This token expires at %s UTC.
If you did not request a reset you can ignore this email.
", html.EscapeString(name), token, expiresAt.UTC().Format(time.RFC3339)) + + msg := EmailMessage{ + To: []string{email}, + Subject: subject, + PlainBody: plainBody, + HTMLBody: htmlBody, + } + + if err := h.emailSender.Send(ctx, msg); err != nil { + h.removePasswordReset(token) + return err + } + + return nil +} + +func (h *handler) lookupPasswordReset(token string) (passwordReset, bool) { + token = strings.TrimSpace(token) + if token == "" { + return passwordReset{}, false + } + + h.resetMu.RLock() + reset, ok := h.passwordResets[token] + h.resetMu.RUnlock() + if !ok { + return passwordReset{}, false + } + + if time.Now().After(reset.expiresAt) { + h.removePasswordReset(token) + return passwordReset{}, false + } + + return reset, true +} + +func (h *handler) removePasswordReset(token string) { + h.resetMu.Lock() + delete(h.passwordResets, strings.TrimSpace(token)) + h.resetMu.Unlock() +} + func (h *handler) removeMFAChallenge(token string) { h.mfaMu.Lock() delete(h.mfaChallenges, token) @@ -668,13 +1085,14 @@ func (h *handler) mfaStatus(c *gin.Context) { func sanitizeUser(user *store.User) gin.H { identifier := strings.TrimSpace(user.ID) return gin.H{ - "id": identifier, - "uuid": identifier, - "name": user.Name, - "username": user.Name, - "email": user.Email, - "mfaEnabled": user.MFAEnabled, - "mfa": buildMFAState(user), + "id": identifier, + "uuid": identifier, + "name": user.Name, + "username": user.Name, + "email": user.Email, + "emailVerified": user.EmailVerified, + "mfaEnabled": user.MFAEnabled, + "mfa": buildMFAState(user), } } diff --git a/account/api/api_test.go b/account/api/api_test.go index 2ed2965..f3190c5 100644 --- a/account/api/api_test.go +++ b/account/api/api_test.go @@ -2,9 +2,13 @@ package api import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" + "regexp" + "strings" + "sync" "testing" "time" @@ -25,6 +29,55 @@ type apiResponse struct { ExpiresAt string `json:"expiresAt"` } +type capturedEmail struct { + To []string + Subject string + PlainBody string + HTMLBody string +} + +type testEmailSender struct { + mu sync.Mutex + messages []capturedEmail +} + +func (s *testEmailSender) Send(ctx context.Context, msg EmailMessage) error { + _ = ctx + s.mu.Lock() + defer s.mu.Unlock() + copyTo := make([]string, len(msg.To)) + copy(copyTo, msg.To) + s.messages = append(s.messages, capturedEmail{ + To: copyTo, + Subject: msg.Subject, + PlainBody: msg.PlainBody, + HTMLBody: msg.HTMLBody, + }) + return nil +} + +func (s *testEmailSender) last() (capturedEmail, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.messages) == 0 { + return capturedEmail{}, false + } + return s.messages[len(s.messages)-1], true +} + +func extractTokenFromMessage(t *testing.T, msg capturedEmail) string { + t.Helper() + re := regexp.MustCompile(`[a-f0-9]{64}`) + if match := re.FindString(msg.PlainBody); match != "" { + return match + } + if match := re.FindString(msg.HTMLBody); match != "" { + return match + } + t.Fatalf("failed to extract token from email body: %q", msg.PlainBody) + return "" +} + func decodeResponse(t *testing.T, rr *httptest.ResponseRecorder) apiResponse { t.Helper() var resp apiResponse @@ -51,7 +104,8 @@ func TestRegisterEndpoint(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() - RegisterRoutes(router) + mailer := &testEmailSender{} + RegisterRoutes(router, WithEmailSender(mailer)) payload := map[string]string{ "name": "Test User", @@ -79,6 +133,10 @@ func TestRegisterEndpoint(t *testing.T) { t.Fatalf("expected user object in response") } + if verified, ok := resp.User["emailVerified"].(bool); !ok || verified { + t.Fatalf("expected emailVerified to be false after registration, got %#v", resp.User["emailVerified"]) + } + if email, ok := resp.User["email"].(string); !ok || email != payload["email"] { t.Fatalf("expected email %q, got %#v", payload["email"], resp.User["email"]) } @@ -103,13 +161,46 @@ func TestRegisterEndpoint(t *testing.T) { if pending, ok := mfaData["totpPending"].(bool); !ok || pending { t.Fatalf("expected totpPending to be false, got %#v", mfaData["totpPending"]) } + + msg, ok := mailer.last() + if !ok { + t.Fatalf("expected verification email to be sent") + } + if !strings.Contains(strings.ToLower(msg.Subject), "verify") { + t.Fatalf("expected verification subject, got %q", msg.Subject) + } + + token := extractTokenFromMessage(t, msg) + verifyPayload := map[string]string{"token": token} + verifyBody, err := json.Marshal(verifyPayload) + if err != nil { + t.Fatalf("failed to marshal verification payload: %v", err) + } + + req = httptest.NewRequest(http.MethodPost, "/api/auth/register/verify", bytes.NewReader(verifyBody)) + req.Header.Set("Content-Type", "application/json") + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected verification success, got %d: %s", rr.Code, rr.Body.String()) + } + + resp = decodeResponse(t, rr) + if resp.User == nil { + t.Fatalf("expected user in verification response") + } + if verified, ok := resp.User["emailVerified"].(bool); !ok || !verified { + t.Fatalf("expected emailVerified true after verification, got %#v", resp.User["emailVerified"]) + } } func TestMFATOTPFlow(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() - RegisterRoutes(router) + mailer := &testEmailSender{} + RegisterRoutes(router, WithEmailSender(mailer)) registerPayload := map[string]string{ "name": "Login User", @@ -129,6 +220,25 @@ func TestMFATOTPFlow(t *testing.T) { t.Fatalf("expected registration to succeed, got %d", rr.Code) } + msg, ok := mailer.last() + if !ok { + t.Fatalf("expected verification email during registration") + } + token := extractTokenFromMessage(t, msg) + verifyPayload := map[string]string{"token": token} + verifyBody, err := json.Marshal(verifyPayload) + if err != nil { + t.Fatalf("failed to marshal verify payload: %v", err) + } + + req = httptest.NewRequest(http.MethodPost, "/api/auth/register/verify", bytes.NewReader(verifyBody)) + req.Header.Set("Content-Type", "application/json") + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected verification success, got %d: %s", rr.Code, rr.Body.String()) + } + loginPayload := map[string]string{ "identifier": "Login User", "password": registerPayload["password"], @@ -194,16 +304,16 @@ func TestMFATOTPFlow(t *testing.T) { waitForStableTOTPWindow(t) code := generateCode(-30 * time.Second) - verifyPayload := map[string]string{ + totpVerifyPayload := map[string]string{ "token": resp.MFAToken, "code": code, } - verifyBody, err := json.Marshal(verifyPayload) + totpVerifyBody, err := json.Marshal(totpVerifyPayload) if err != nil { t.Fatalf("failed to marshal verify payload: %v", err) } - req = httptest.NewRequest(http.MethodPost, "/api/auth/mfa/totp/verify", bytes.NewReader(verifyBody)) + req = httptest.NewRequest(http.MethodPost, "/api/auth/mfa/totp/verify", bytes.NewReader(totpVerifyBody)) req.Header.Set("Content-Type", "application/json") rr = httptest.NewRecorder() router.ServeHTTP(rr, req) @@ -292,3 +402,134 @@ func TestMFATOTPFlow(t *testing.T) { t.Fatalf("expected email+totp login success, got %d: %s", rr.Code, rr.Body.String()) } } + +func TestPasswordResetFlow(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := gin.New() + mailer := &testEmailSender{} + RegisterRoutes(router, WithEmailSender(mailer)) + + registerPayload := map[string]string{ + "name": "Reset User", + "email": "reset@example.com", + "password": "originalPass1", + } + registerBody, err := json.Marshal(registerPayload) + if err != nil { + t.Fatalf("failed to marshal registration payload: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(registerBody)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("expected registration success, got %d: %s", rr.Code, rr.Body.String()) + } + + msg, ok := mailer.last() + if !ok { + t.Fatalf("expected verification email during registration") + } + verifyToken := extractTokenFromMessage(t, msg) + verifyPayload := map[string]string{"token": verifyToken} + verifyBody, err := json.Marshal(verifyPayload) + if err != nil { + t.Fatalf("failed to marshal verification payload: %v", err) + } + + req = httptest.NewRequest(http.MethodPost, "/api/auth/register/verify", bytes.NewReader(verifyBody)) + req.Header.Set("Content-Type", "application/json") + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected verification success, got %d: %s", rr.Code, rr.Body.String()) + } + + resetPayload := map[string]string{"email": registerPayload["email"]} + resetBody, err := json.Marshal(resetPayload) + if err != nil { + t.Fatalf("failed to marshal reset payload: %v", err) + } + + req = httptest.NewRequest(http.MethodPost, "/api/auth/password/reset", bytes.NewReader(resetBody)) + req.Header.Set("Content-Type", "application/json") + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusAccepted { + t.Fatalf("expected password reset request to return 202, got %d: %s", rr.Code, rr.Body.String()) + } + + msg, ok = mailer.last() + if !ok { + t.Fatalf("expected password reset email to be sent") + } + if !strings.Contains(strings.ToLower(msg.Subject), "reset") { + t.Fatalf("expected reset subject, got %q", msg.Subject) + } + resetToken := extractTokenFromMessage(t, msg) + + confirmPayload := map[string]string{ + "token": resetToken, + "password": "newSecurePass2", + } + confirmBody, err := json.Marshal(confirmPayload) + if err != nil { + t.Fatalf("failed to marshal confirm payload: %v", err) + } + + req = httptest.NewRequest(http.MethodPost, "/api/auth/password/reset/confirm", bytes.NewReader(confirmBody)) + req.Header.Set("Content-Type", "application/json") + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected password reset confirmation success, got %d: %s", rr.Code, rr.Body.String()) + } + + resp := decodeResponse(t, rr) + if resp.User == nil { + t.Fatalf("expected user in reset confirmation response") + } + if verified, ok := resp.User["emailVerified"].(bool); !ok || !verified { + t.Fatalf("expected email to remain verified after reset") + } + + loginPayload := map[string]string{ + "identifier": registerPayload["name"], + "password": confirmPayload["password"], + } + loginBody, err := json.Marshal(loginPayload) + if err != nil { + t.Fatalf("failed to marshal login payload: %v", err) + } + + req = httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(loginBody)) + req.Header.Set("Content-Type", "application/json") + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected login to prompt for mfa setup, got %d: %s", rr.Code, rr.Body.String()) + } + resp = decodeResponse(t, rr) + if resp.Error != "mfa_setup_required" { + t.Fatalf("expected mfa_setup_required after password reset, got %q", resp.Error) + } + + loginPayload["password"] = registerPayload["password"] + loginBody, err = json.Marshal(loginPayload) + if err != nil { + t.Fatalf("failed to marshal old password payload: %v", err) + } + req = httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(loginBody)) + req.Header.Set("Content-Type", "application/json") + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected login with old password to fail, got %d", rr.Code) + } + resp = decodeResponse(t, rr) + if resp.Error == "" { + t.Fatalf("expected error when logging in with old password") + } +} diff --git a/account/api/email.go b/account/api/email.go new file mode 100644 index 0000000..02b7f03 --- /dev/null +++ b/account/api/email.go @@ -0,0 +1,36 @@ +package api + +import ( + "context" + "log/slog" +) + +// EmailMessage represents the contents of an email notification. +type EmailMessage struct { + To []string + Subject string + PlainBody string + HTMLBody string +} + +// EmailSender sends email notifications. +type EmailSender interface { + Send(ctx context.Context, msg EmailMessage) error +} + +// EmailSenderFunc adapts a function so it can be used as an EmailSender. +type EmailSenderFunc func(ctx context.Context, msg EmailMessage) error + +// Send implements EmailSender. +func (f EmailSenderFunc) Send(ctx context.Context, msg EmailMessage) error { + if f == nil { + return nil + } + return f(ctx, msg) +} + +var noopEmailSender EmailSender = EmailSenderFunc(func(ctx context.Context, msg EmailMessage) error { + _ = ctx + slog.Warn("email sender not configured; suppressing email delivery", "subject", msg.Subject) + return nil +}) diff --git a/account/cmd/accountsvc/main.go b/account/cmd/accountsvc/main.go index bacaf62..b6d2321 100644 --- a/account/cmd/accountsvc/main.go +++ b/account/cmd/accountsvc/main.go @@ -19,6 +19,7 @@ import ( "xcontrol/account/api" "xcontrol/account/config" + "xcontrol/account/internal/mailer" "xcontrol/account/internal/store" ) @@ -27,6 +28,23 @@ var ( logLevel string ) +type mailerAdapter struct { + sender mailer.Sender +} + +func (m mailerAdapter) Send(ctx context.Context, msg api.EmailMessage) error { + if m.sender == nil { + return nil + } + mail := mailer.Message{ + To: append([]string(nil), msg.To...), + Subject: msg.Subject, + PlainBody: msg.PlainBody, + HTMLBody: msg.HTMLBody, + } + return m.sender.Send(ctx, mail) +} + var rootCmd = &cobra.Command{ Use: "xcontrol-account", Short: "Start the xcontrol account service", @@ -81,10 +99,34 @@ var rootCmd = &cobra.Command{ } }() - api.RegisterRoutes(r, + var emailSender api.EmailSender + if strings.TrimSpace(cfg.SMTP.Host) != "" { + tlsMode := mailer.TLSMode(strings.ToLower(strings.TrimSpace(cfg.SMTP.TLS.Mode))) + sender, err := mailer.New(mailer.Config{ + Host: cfg.SMTP.Host, + Port: cfg.SMTP.Port, + Username: cfg.SMTP.Username, + Password: cfg.SMTP.Password, + From: cfg.SMTP.From, + ReplyTo: cfg.SMTP.ReplyTo, + Timeout: cfg.SMTP.Timeout, + TLSMode: tlsMode, + InsecureSkipVerify: cfg.SMTP.TLS.InsecureSkipVerify, + }) + if err != nil { + return err + } + emailSender = mailerAdapter{sender: sender} + } + + options := []api.Option{ api.WithStore(st), api.WithSessionTTL(cfg.Session.TTL), - ) + } + if emailSender != nil { + options = append(options, api.WithEmailSender(emailSender)) + } + api.RegisterRoutes(r, options...) addr := strings.TrimSpace(cfg.Server.Addr) if addr == "" { diff --git a/account/config/account.yaml b/account/config/account.yaml index 63567f4..b0167c7 100644 --- a/account/config/account.yaml +++ b/account/config/account.yaml @@ -25,3 +25,15 @@ session: redis: addr: "127.0.0.1:6379" password: "" + +smtp: + host: "smtp.example.com" + port: 587 + username: "apikey" + p: "s" + from: "XControl Account