feat(account): add email verification and password reset (#378)
This commit is contained in:
parent
6ca7f6f81a
commit
9ceff86df5
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
36
account/api/email.go
Normal 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
|
||||
})
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
324
account/internal/mailer/mailer.go
Normal file
324
account/internal/mailer/mailer.go
Normal 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()
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 <no-reply@example.com>" # 发件人展示名称+地址
|
||||
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 的账号服务地址。
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user