feat(account): add email verification and password reset (#378)

This commit is contained in:
shenlan 2025-10-02 17:56:30 +08:00 committed by GitHub
parent 6ca7f6f81a
commit 9ceff86df5
11 changed files with 1143 additions and 30 deletions

View File

@ -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("<p>Hello %s,</p><p>Use the following token to verify your XControl account:</p><p><strong>%s</strong></p><p>This token expires at %s UTC.</p><p>If you did not request this email you can ignore it.</p>", 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("<p>Hello %s,</p><p>Use the following token to reset your XControl account password:</p><p><strong>%s</strong></p><p>This token expires at %s UTC.</p><p>If you did not request a reset you can ignore this email.</p>", 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),
}
}

View File

@ -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")
}
}

36
account/api/email.go Normal file
View File

@ -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
})

View File

@ -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 == "" {

View File

@ -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 <no-reply@example.com>"
replyTo: ""
timeout: 10s
tls:
mode: "starttls"
insecureSkipVerify: false

View File

@ -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.

View File

@ -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()
}

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -41,6 +41,18 @@ store:
session:
ttl: 24h # 登录会话有效期
smtp:
host: "smtp.example.com" # SMTP 服务地址
port: 587 # 端口587 对应 STARTTLS465 可用于 SMTPS
username: "apikey" # 登录用户名或 API Key
p: "s" # 登录密码,生产环境建议使用 Secret 管理
from: "XControl <no-reply@example.com>" # 发件人展示名称+地址
replyTo: "" # 可选Reply-To 地址
timeout: 10s # 连接与发送超时
tls:
mode: "starttls" # 可选 starttls 或 implicitSMTPS
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 的账号服务地址。