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 " + replyTo: "" + timeout: 10s + tls: + mode: "starttls" + insecureSkipVerify: false diff --git a/account/config/config.go b/account/config/config.go index f71303c..b8b9b17 100644 --- a/account/config/config.go +++ b/account/config/config.go @@ -23,6 +23,7 @@ type Config struct { Server Server `yaml:"server"` Store Store `yaml:"store"` Session Session `yaml:"session"` + SMTP SMTP `yaml:"smtp"` } // Server defines HTTP server configuration. @@ -66,6 +67,24 @@ type Session struct { TTL time.Duration `yaml:"ttl"` } +// SMTP defines outbound SMTP configuration used for transactional email. +type SMTP struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Username string `yaml:"username"` + Password string `yaml:"password"` + From string `yaml:"from"` + ReplyTo string `yaml:"replyTo"` + Timeout time.Duration `yaml:"timeout"` + TLS SMTPTLS `yaml:"tls"` +} + +// SMTPTLS describes TLS settings for SMTP connections. +type SMTPTLS struct { + Mode string `yaml:"mode"` + InsecureSkipVerify bool `yaml:"insecureSkipVerify"` +} + // Load reads the configuration file at the provided path. When path is empty, // it defaults to account/config/account.yaml. If the file does not exist an // empty configuration is returned. diff --git a/account/internal/mailer/mailer.go b/account/internal/mailer/mailer.go new file mode 100644 index 0000000..bd35ce0 --- /dev/null +++ b/account/internal/mailer/mailer.go @@ -0,0 +1,324 @@ +package mailer + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "mime" + "mime/quotedprintable" + "net" + "net/mail" + "net/smtp" + "strings" + "time" +) + +// TLSMode describes how TLS is negotiated with the SMTP server. +type TLSMode string + +const ( + // TLSModeNone disables TLS. + TLSModeNone TLSMode = "none" + // TLSModeStartTLS upgrades a plain connection via STARTTLS. + TLSModeStartTLS TLSMode = "starttls" + // TLSModeImplicit establishes the connection over TLS immediately. + TLSModeImplicit TLSMode = "implicit" +) + +// Config contains the information required to send email via SMTP. +type Config struct { + Host string + Port int + Username string + Password string + From string + ReplyTo string + Timeout time.Duration + TLSMode TLSMode + InsecureSkipVerify bool +} + +// Message represents an outbound email. +type Message struct { + To []string + Subject string + PlainBody string + HTMLBody string +} + +// Sender sends email messages over SMTP. +type Sender interface { + Send(ctx context.Context, msg Message) error +} + +type smtpSender struct { + host string + port int + username string + password string + from *mail.Address + replyTo *mail.Address + timeout time.Duration + tlsMode TLSMode + insecureSkipVerify bool +} + +// New constructs a Sender based on the provided configuration. +func New(cfg Config) (Sender, error) { + host := strings.TrimSpace(cfg.Host) + if host == "" { + return nil, errors.New("smtp host is required") + } + if cfg.Port <= 0 { + cfg.Port = 587 + } + if cfg.Timeout <= 0 { + cfg.Timeout = 10 * time.Second + } + from := strings.TrimSpace(cfg.From) + if from == "" { + return nil, errors.New("smtp from address is required") + } + fromAddr, err := mail.ParseAddress(from) + if err != nil { + return nil, fmt.Errorf("invalid from address: %w", err) + } + var replyAddr *mail.Address + if reply := strings.TrimSpace(cfg.ReplyTo); reply != "" { + replyAddr, err = mail.ParseAddress(reply) + if err != nil { + return nil, fmt.Errorf("invalid reply-to address: %w", err) + } + } + + mode := TLSMode(strings.ToLower(strings.TrimSpace(string(cfg.TLSMode)))) + if mode == "" { + mode = TLSModeStartTLS + } + + sender := &smtpSender{ + host: host, + port: cfg.Port, + username: strings.TrimSpace(cfg.Username), + password: cfg.Password, + from: fromAddr, + replyTo: replyAddr, + timeout: cfg.Timeout, + tlsMode: mode, + insecureSkipVerify: cfg.InsecureSkipVerify, + } + return sender, nil +} + +func (s *smtpSender) Send(ctx context.Context, msg Message) error { + recipients, headerTo, err := s.parseRecipients(msg.To) + if err != nil { + return err + } + if len(recipients) == 0 { + return errors.New("no recipients specified") + } + + data, err := s.buildMessage(msg, headerTo) + if err != nil { + return err + } + + addr := net.JoinHostPort(s.host, fmt.Sprintf("%d", s.port)) + dialer := &net.Dialer{Timeout: s.timeout} + if deadline, ok := ctx.Deadline(); ok { + dialer.Deadline = deadline + } + + var conn net.Conn + if s.tlsMode == TLSModeImplicit { + tlsCfg := s.tlsConfig() + conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsCfg) + } else { + conn, err = dialer.DialContext(ctx, "tcp", addr) + } + if err != nil { + return err + } + defer conn.Close() + + client, err := smtp.NewClient(conn, s.host) + if err != nil { + return err + } + defer client.Close() + + if s.tlsMode == TLSModeStartTLS { + tlsCfg := s.tlsConfig() + if err := client.StartTLS(tlsCfg); err != nil { + return err + } + } + + if s.username != "" { + auth := smtp.PlainAuth("", s.username, s.password, s.host) + if err := client.Auth(auth); err != nil { + return err + } + } + + if err := client.Mail(s.from.Address); err != nil { + return err + } + for _, rcpt := range recipients { + if err := client.Rcpt(rcpt.Address); err != nil { + return err + } + } + + writer, err := client.Data() + if err != nil { + return err + } + if _, err := writer.Write(data); err != nil { + writer.Close() + return err + } + if err := writer.Close(); err != nil { + return err + } + + if err := client.Quit(); err != nil { + return err + } + return nil +} + +func (s *smtpSender) parseRecipients(addresses []string) ([]*mail.Address, []string, error) { + parsed := make([]*mail.Address, 0, len(addresses)) + headerValues := make([]string, 0, len(addresses)) + for _, addr := range addresses { + value := strings.TrimSpace(addr) + if value == "" { + continue + } + parsedAddr, err := mail.ParseAddress(value) + if err != nil { + return nil, nil, fmt.Errorf("invalid recipient address %q: %w", addr, err) + } + parsed = append(parsed, parsedAddr) + headerValues = append(headerValues, parsedAddr.String()) + } + return parsed, headerValues, nil +} + +func (s *smtpSender) buildMessage(msg Message, headerTo []string) ([]byte, error) { + if len(headerTo) == 0 { + return nil, errors.New("no recipients specified") + } + var builder strings.Builder + builder.Grow(512 + len(msg.PlainBody) + len(msg.HTMLBody)) + + subject := encodeHeader(msg.Subject) + headers := []string{ + fmt.Sprintf("From: %s", s.from.String()), + fmt.Sprintf("To: %s", strings.Join(headerTo, ", ")), + fmt.Sprintf("Subject: %s", subject), + "MIME-Version: 1.0", + } + if s.replyTo != nil { + headers = append(headers, fmt.Sprintf("Reply-To: %s", s.replyTo.String())) + } + + htmlBody := strings.TrimSpace(msg.HTMLBody) + plainBody := strings.TrimSpace(msg.PlainBody) + + if htmlBody != "" { + boundary, err := randomBoundary() + if err != nil { + return nil, err + } + headers = append(headers, fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"", boundary)) + for _, header := range headers { + builder.WriteString(header) + builder.WriteString("\r\n") + } + builder.WriteString("\r\n") + builder.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") + builder.WriteString("Content-Transfer-Encoding: 7bit\r\n\r\n") + builder.WriteString(normalizeNewlines(plainBody)) + builder.WriteString("\r\n\r\n") + builder.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + builder.WriteString("Content-Type: text/html; charset=UTF-8\r\n") + builder.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") + builder.WriteString(toQuotedPrintable(htmlBody)) + builder.WriteString("\r\n\r\n") + builder.WriteString(fmt.Sprintf("--%s--\r\n", boundary)) + } else { + headers = append(headers, "Content-Type: text/plain; charset=UTF-8") + headers = append(headers, "Content-Transfer-Encoding: 7bit") + for _, header := range headers { + builder.WriteString(header) + builder.WriteString("\r\n") + } + builder.WriteString("\r\n") + builder.WriteString(normalizeNewlines(plainBody)) + builder.WriteString("\r\n") + } + + return []byte(builder.String()), nil +} + +func (s *smtpSender) tlsConfig() *tls.Config { + return &tls.Config{ + ServerName: s.host, + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: s.insecureSkipVerify, + } +} + +func encodeHeader(value string) string { + if value == "" { + return "" + } + if isASCII(value) { + return value + } + return mime.QEncoding.Encode("utf-8", value) +} + +func isASCII(value string) bool { + for i := 0; i < len(value); i++ { + if value[i] >= 128 { + return false + } + } + return true +} + +func normalizeNewlines(value string) string { + value = strings.ReplaceAll(value, "\r\n", "\n") + value = strings.ReplaceAll(value, "\r", "\n") + return strings.ReplaceAll(value, "\n", "\r\n") +} + +func randomBoundary() (string, error) { + buf := make([]byte, 12) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +func toQuotedPrintable(value string) string { + normalized := normalizeNewlines(value) + var buf bytes.Buffer + writer := quotedprintable.NewWriter(&buf) + if _, err := writer.Write([]byte(normalized)); err != nil { + return normalized + } + if err := writer.Close(); err != nil { + return normalized + } + return buf.String() +} diff --git a/account/internal/store/postgres.go b/account/internal/store/postgres.go index af72811..1ac4898 100644 --- a/account/internal/store/postgres.go +++ b/account/internal/store/postgres.go @@ -98,14 +98,15 @@ func (s *postgresStore) CreateUser(ctx context.Context, user *User) error { } } - query := `INSERT INTO users (username, email, password) - VALUES ($1, $2, $3) - RETURNING uuid, coalesce(created_at, now()), coalesce(updated_at, now())` + query := `INSERT INTO users (username, email, password, email_verified) + VALUES ($1, $2, $3, $4) + RETURNING uuid, coalesce(created_at, now()), coalesce(updated_at, now()), email_verified` var idValue any var createdAt time.Time var updatedAt time.Time - err = s.db.QueryRowContext(ctx, query, normalizedName, normalizedEmail, user.PasswordHash).Scan(&idValue, &createdAt, &updatedAt) + var emailVerified sql.NullBool + err = s.db.QueryRowContext(ctx, query, normalizedName, normalizedEmail, user.PasswordHash, user.EmailVerified).Scan(&idValue, &createdAt, &updatedAt, &emailVerified) if err != nil { if errors.Is(err, sql.ErrNoRows) { return ErrUserNotFound @@ -134,6 +135,7 @@ func (s *postgresStore) CreateUser(ctx context.Context, user *User) error { user.Email = normalizedEmail user.CreatedAt = createdAt.UTC() user.UpdatedAt = updatedAt.UTC() + user.EmailVerified = emailVerified.Bool return nil } @@ -143,7 +145,7 @@ func (s *postgresStore) GetUserByEmail(ctx context.Context, email string) (*User return nil, ErrUserNotFound } - query := `SELECT uuid, username, email, password, mfa_totp_secret, coalesce(mfa_enabled, false), + query := `SELECT uuid, username, email, email_verified, password, mfa_totp_secret, coalesce(mfa_enabled, false), mfa_secret_issued_at, mfa_confirmed_at, coalesce(created_at, now()), coalesce(updated_at, now()) FROM users WHERE lower(email) = $1 LIMIT 1` @@ -157,7 +159,7 @@ func (s *postgresStore) GetUserByName(ctx context.Context, name string) (*User, return nil, ErrUserNotFound } - query := `SELECT uuid, username, email, password, mfa_totp_secret, coalesce(mfa_enabled, false), + query := `SELECT uuid, username, email, email_verified, password, mfa_totp_secret, coalesce(mfa_enabled, false), mfa_secret_issued_at, mfa_confirmed_at, coalesce(created_at, now()), coalesce(updated_at, now()) FROM users WHERE lower(username) = lower($1) LIMIT 1` @@ -166,7 +168,7 @@ func (s *postgresStore) GetUserByName(ctx context.Context, name string) (*User, } func (s *postgresStore) GetUserByID(ctx context.Context, id string) (*User, error) { - query := `SELECT uuid, username, email, password, mfa_totp_secret, coalesce(mfa_enabled, false), + query := `SELECT uuid, username, email, email_verified, password, mfa_totp_secret, coalesce(mfa_enabled, false), mfa_secret_issued_at, mfa_confirmed_at, coalesce(created_at, now()), coalesce(updated_at, now()) FROM users WHERE uuid = $1` @@ -215,6 +217,7 @@ func scanUser(row rowScanner) (*User, error) { idValue any username sql.NullString email sql.NullString + emailVerified sql.NullBool password sql.NullString mfaSecret sql.NullString mfaEnabled sql.NullBool @@ -224,7 +227,7 @@ func scanUser(row rowScanner) (*User, error) { updatedAt time.Time ) - if err := row.Scan(&idValue, &username, &email, &password, &mfaSecret, &mfaEnabled, &mfaSecretIssued, &mfaConfirmed, &createdAt, &updatedAt); err != nil { + if err := row.Scan(&idValue, &username, &email, &emailVerified, &password, &mfaSecret, &mfaEnabled, &mfaSecretIssued, &mfaConfirmed, &createdAt, &updatedAt); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrUserNotFound } @@ -240,6 +243,7 @@ func scanUser(row rowScanner) (*User, error) { ID: identifier, Name: strings.TrimSpace(username.String), Email: strings.ToLower(strings.TrimSpace(email.String)), + EmailVerified: emailVerified.Bool, PasswordHash: password.String, MFATOTPSecret: strings.TrimSpace(mfaSecret.String), MFAEnabled: mfaEnabled.Bool, @@ -270,18 +274,19 @@ func (s *postgresStore) UpdateUser(ctx context.Context, user *User) error { query := `UPDATE users SET username = $1, email = $2, - password = $3, - mfa_totp_secret = $4, - mfa_enabled = $5, - mfa_secret_issued_at = $6, - mfa_confirmed_at = $7, + email_verified = $3, + password = $4, + mfa_totp_secret = $5, + mfa_enabled = $6, + mfa_secret_issued_at = $7, + mfa_confirmed_at = $8, updated_at = now() - WHERE uuid = $8 + WHERE uuid = $9 RETURNING coalesce(created_at, now()), coalesce(updated_at, now())` var createdAt time.Time var updatedAt time.Time - err := s.db.QueryRowContext(ctx, query, normalizedName, normalizedEmail, user.PasswordHash, nullForEmpty(user.MFATOTPSecret), user.MFAEnabled, issuedAt, confirmedAt, user.ID).Scan(&createdAt, &updatedAt) + err := s.db.QueryRowContext(ctx, query, normalizedName, normalizedEmail, user.EmailVerified, user.PasswordHash, nullForEmpty(user.MFATOTPSecret), user.MFAEnabled, issuedAt, confirmedAt, user.ID).Scan(&createdAt, &updatedAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return ErrUserNotFound diff --git a/account/internal/store/store.go b/account/internal/store/store.go index 02ed43f..842de8b 100644 --- a/account/internal/store/store.go +++ b/account/internal/store/store.go @@ -15,6 +15,7 @@ type User struct { ID string Name string Email string + EmailVerified bool PasswordHash string MFATOTPSecret string MFAEnabled bool @@ -197,6 +198,7 @@ func (s *memoryStore) UpdateUser(ctx context.Context, user *User) error { updated := *existing updated.Name = normalizedName updated.Email = loweredEmail + updated.EmailVerified = user.EmailVerified updated.PasswordHash = user.PasswordHash updated.MFATOTPSecret = user.MFATOTPSecret updated.MFAEnabled = user.MFAEnabled diff --git a/account/sql/schema.sql b/account/sql/schema.sql index c9cdf02..587643e 100644 --- a/account/sql/schema.sql +++ b/account/sql/schema.sql @@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS users ( username TEXT NOT NULL UNIQUE, password TEXT NOT NULL, email TEXT, + email_verified BOOLEAN NOT NULL DEFAULT FALSE, mfa_totp_secret TEXT, mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE, mfa_secret_issued_at TIMESTAMPTZ, diff --git a/docs/account-service-configuration.md b/docs/account-service-configuration.md index ea784b5..b9914c5 100644 --- a/docs/account-service-configuration.md +++ b/docs/account-service-configuration.md @@ -41,6 +41,18 @@ store: session: ttl: 24h # 登录会话有效期 + +smtp: + host: "smtp.example.com" # SMTP 服务地址 + port: 587 # 端口,587 对应 STARTTLS,465 可用于 SMTPS + username: "apikey" # 登录用户名或 API Key + p: "s" # 登录密码,生产环境建议使用 Secret 管理 + from: "XControl " # 发件人展示名称+地址 + replyTo: "" # (可选)Reply-To 地址 + timeout: 10s # 连接与发送超时 + tls: + mode: "starttls" # 可选 starttls 或 implicit(SMTPS) + insecureSkipVerify: false # 是否跳过证书校验,默认 false ``` **TLS 提示**:当 `tls.enabled` 显式为 `true` 时或 `certFile` 与 `keyFile` 均提供时,`accountsvc` 会调用 `ListenAndServeTLS` 启动 HTTPS。需要在开发环境暂时关闭 TLS,可将 `tls.enabled` 设为 `false`,此时服务会忽略证书路径并仅监听 HTTP。如果同时希望保留 80 端口,可将 `redirectHttp` 置为 `true`,服务会开启一个额外的明文监听,将请求 301 重定向到 HTTPS。 @@ -98,6 +110,7 @@ session: ## 5. 与其他模块的协同 - 登录会话 TTL 会同步影响 `/api/auth/login`、`/api/auth/session` 等接口返回的 cookie 过期时间。 +- `smtp` 配置用于注册验证、密码重置等事务性邮件发送,支持 STARTTLS 与 SMTPS(将 `mode` 设为 `implicit` 并将端口改为 465)。在生产环境建议关闭 `insecureSkipVerify` 并使用专用发信账户或 API Key。 - 新增的 MFA 接口(`/api/auth/mfa/totp/provision`、`/api/auth/mfa/totp/verify`、`/api/auth/mfa/status`)在 HTTPS 环境下可与前端 MFA 向导配合使用,保证首次登录后必须完成绑定。 - 如果部署了前端 Next.js 应用,请确保其 `.env` 中的 `ACCOUNT_API_BASE` 指向启用了 TLS 的账号服务地址。