Files
NewSzxcn-Email/apps/api/internal/app/router_auth.go
T
LanQin be8cd4be31 feat(mailbox): 支持用户自助申请邮箱
- 后端新增邮箱申请配置与接口,限制可申请域名并校验保留前缀。
- 管理后台系统设置增加自助申请邮箱开关、开放域名和禁止前缀配置。
- 个人中心新增申请邮箱入口,并在邮箱页优化当前邮箱切换与空列表处理。
- 补充相关测试与环境变量示例配置。
2026-06-16 00:51:41 +08:00

449 lines
17 KiB
Go

package app
import (
"context"
"database/sql"
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"golang.org/x/crypto/bcrypt"
)
type contextKey string
const userContextKey contextKey = "user"
func (a *App) Router() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(a.corsMiddleware)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "time": a.now().UTC()})
})
r.Route("/api", func(r chi.Router) {
r.Get("/public/settings", a.handlePublicSettings)
r.Post("/auth/register", a.handleRegister)
r.Post("/auth/login", a.handleLogin)
r.Post("/auth/logout", a.handleLogout)
r.With(a.requireAuth).Get("/me", a.handleMe)
r.With(a.requireAuth).Post("/me/profile", a.handleUpdateProfile)
r.With(a.requireAuth).Post("/me/password", a.handleChangePassword)
r.With(a.requireAuth).Get("/me/mailbox-apply-options", a.handleMailboxApplyOptions)
r.With(a.requireAuth).Post("/me/mailboxes/apply", a.handleApplyMailbox)
r.With(a.requireAuth).Post("/me/2fa/setup", a.handleTwoFactorSetup)
r.With(a.requireAuth).Post("/me/2fa/enable", a.handleTwoFactorEnable)
r.With(a.requireAuth).Post("/me/2fa/disable", a.handleTwoFactorDisable)
r.With(a.requireAuth).Get("/me/contacts", a.handleListContacts)
r.With(a.requireAuth).Post("/me/contacts", a.handleCreateContact)
r.With(a.requireAuth).Delete("/me/contacts/{id}", a.handleDeleteContact)
r.With(a.requireAuth).Get("/me/rules", a.handleListRules)
r.With(a.requireAuth).Post("/me/rules", a.handleCreateRule)
r.With(a.requireAuth).Delete("/me/rules/{id}", a.handleDeleteRule)
r.With(a.requireAuth).Get("/me/blocked-senders", a.handleListBlockedSenders)
r.With(a.requireAuth).Post("/me/blocked-senders", a.handleCreateBlockedSender)
r.With(a.requireAuth).Delete("/me/blocked-senders/{id}", a.handleDeleteBlockedSender)
r.With(a.requireAuth).Get("/me/stats", a.handleMailStats)
r.With(a.requireAuth).Post("/me/cleanup", a.handleMailCleanup)
r.With(a.requireAuth).Get("/events", a.handleEvents)
r.Group(func(r chi.Router) {
r.Use(a.requireAuth)
r.Get("/mail/mailboxes", a.handleMyMailboxes)
r.Get("/mail/folders", a.handleMailFolders)
r.Get("/mail/labels", a.handleMailLabels)
r.Post("/mail/labels", a.handleCreateMailLabel)
r.Get("/mail/messages", a.handleMailMessages)
r.Get("/mail/starred", a.handleStarredMessages)
r.Get("/mail/messages/{id}", a.handleMailMessage)
r.Post("/mail/send", a.handleMailSend)
r.Post("/mail/messages/{id}/mark-read", a.handleMarkRead)
r.Post("/mail/messages/{id}/star", a.handleStar)
r.Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
r.Delete("/mail/messages/{id}/labels/{labelID}", a.handleRemoveMessageLabel)
r.Post("/mail/messages/{id}/move", a.handleMove)
r.Delete("/mail/messages/{id}", a.handleDeleteMessage)
r.Get("/mail/attachments/{id}", a.handleAttachment)
})
r.Group(func(r chi.Router) {
r.Use(a.requireAuth)
r.Use(a.requireAdmin)
r.Get("/admin/overview", a.handleAdminOverview)
r.Get("/admin/users", a.handleListUsers)
r.Post("/admin/users", a.handleCreateUser)
r.Post("/admin/users/{id}", a.handleUpdateUser)
r.Post("/admin/users/{id}/password", a.handleResetUserPassword)
r.Delete("/admin/users/{id}", a.handleDeleteUser)
r.Get("/admin/domains", a.handleListDomains)
r.Post("/admin/domains", a.handleCreateDomain)
r.Post("/admin/domains/{id}", a.handleUpdateDomain)
r.Delete("/admin/domains/{id}", a.handleDeleteDomain)
r.Get("/admin/mailboxes", a.handleListMailboxes)
r.Post("/admin/mailboxes", a.handleCreateMailbox)
r.Post("/admin/mailboxes/{id}", a.handleUpdateMailbox)
r.Delete("/admin/mailboxes/{id}", a.handleDeleteMailbox)
r.Get("/admin/aliases", a.handleListAliases)
r.Post("/admin/aliases", a.handleCreateAlias)
r.Post("/admin/aliases/{id}", a.handleUpdateAlias)
r.Delete("/admin/aliases/{id}", a.handleDeleteAlias)
r.Get("/admin/messages", a.handleAdminMessages)
r.Get("/admin/messages/{id}", a.handleAdminMessage)
r.Get("/admin/attachments/{id}", a.handleAdminAttachment)
r.Get("/admin/settings", a.handleGetSystemSettings)
r.Post("/admin/settings", a.handleUpdateSystemSettings)
r.Post("/admin/settings/test-smtp", a.handleTestSMTP)
r.Get("/admin/mail-templates", a.handleListMailTemplates)
r.Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate)
r.Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate)
r.Get("/admin/domains/{id}/dns-records", a.handleDNSRecords)
r.Post("/admin/domains/{id}/check-dns", a.handleDNSCheck)
})
})
return r
}
func (a *App) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && (strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") || origin == a.cfg.PublicBaseURL) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
TurnstileToken string `json:"turnstileToken"`
ChallengeToken string `json:"challengeToken"`
TwoFactorCode string `json:"twoFactorCode"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
if strings.TrimSpace(req.ChallengeToken) != "" {
challenge, err := a.loginChallengeByToken(r.Context(), req.ChallengeToken)
if err != nil {
respondError(w, http.StatusUnauthorized, "invalid verification challenge")
return
}
user, secret, err := a.loadUserAuthByID(r.Context(), challenge.UserID)
if err != nil || user.Disabled || !user.TwoFactorEnabled || strings.TrimSpace(secret) == "" {
a.deleteLoginChallenge(r.Context(), challenge.ID)
respondError(w, http.StatusUnauthorized, "invalid verification challenge")
return
}
if !verifyTOTP(secret, req.TwoFactorCode, a.now().UTC()) {
respondError(w, http.StatusUnauthorized, "invalid verification code")
return
}
a.deleteLoginChallenge(r.Context(), challenge.ID)
if err := a.issueSession(w, r, user.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to create session")
return
}
respondJSON(w, http.StatusOK, map[string]any{"user": user})
return
}
if err := a.verifyTurnstile(r.Context(), req.TurnstileToken, r.RemoteAddr); err != nil {
respondError(w, http.StatusUnauthorized, "human verification failed")
return
}
email := normalizeEmail(req.Email)
user, passwordHash, err := a.userByEmail(r.Context(), email)
if err != nil || user.Disabled {
respondError(w, http.StatusUnauthorized, "invalid email or password")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
respondError(w, http.StatusUnauthorized, "invalid email or password")
return
}
if a.cfg.TwoFactorEnabled && user.TwoFactorEnabled {
challengeToken, err := a.createLoginChallenge(r.Context(), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create verification challenge")
return
}
respondJSON(w, http.StatusOK, map[string]any{"twoFactorRequired": true, "challengeToken": challengeToken})
return
}
if err := a.issueSession(w, r, user.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to create session")
return
}
respondJSON(w, http.StatusOK, map[string]any{"user": user})
}
func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
if !a.cfg.OpenRegistration {
respondError(w, http.StatusForbidden, "registration is closed")
return
}
var req struct {
Email string `json:"email"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
TurnstileToken string `json:"turnstileToken"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
if err := a.verifyTurnstile(r.Context(), req.TurnstileToken, r.RemoteAddr); err != nil {
respondError(w, http.StatusUnauthorized, "human verification failed")
return
}
email := normalizeEmail(req.Email)
if email == "" || !strings.Contains(email, "@") {
badRequest(w, errors.New("invalid email"))
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
return
}
displayName := strings.TrimSpace(req.DisplayName)
if displayName == "" {
displayName = strings.Split(email, "@")[0]
}
if len([]rune(displayName)) > 80 {
badRequest(w, errors.New("displayName must be at most 80 characters"))
return
}
if _, _, err := a.userByEmail(r.Context(), email); err == nil {
respondError(w, http.StatusConflict, "email already registered")
return
} else if !errors.Is(err, errNotFound) {
respondError(w, http.StatusInternalServerError, "failed to check user")
return
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to hash password")
return
}
now := a.now().UTC().Format(time.RFC3339Nano)
userID := newID("usr")
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`, userID, email, displayName, "user", string(passwordHash), 0, now, now); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "email already registered")
return
}
respondError(w, http.StatusInternalServerError, "failed to create user")
return
}
user, err := a.userByID(r.Context(), userID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load user")
return
}
if err := a.issueSession(w, r, user.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to create session")
return
}
respondJSON(w, http.StatusCreated, map[string]any{"user": user})
}
func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(a.cfg.CookieName); err == nil {
_, _ = a.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, hashToken(cookie.Value))
}
http.SetCookie(w, &http.Cookie{Name: a.cfg.CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) handleMe(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"user": currentUser(r)})
}
func (a *App) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
var req struct {
DisplayName string `json:"displayName"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
displayName := strings.TrimSpace(req.DisplayName)
if displayName == "" {
badRequest(w, errors.New("displayName is required"))
return
}
if len([]rune(displayName)) > 80 {
badRequest(w, errors.New("displayName must be at most 80 characters"))
return
}
_, err := a.db.ExecContext(r.Context(), `UPDATE users SET display_name=?, updated_at=? WHERE id=?`,
displayName, a.now().UTC().Format(time.RFC3339Nano), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update profile")
return
}
updated, err := a.userByID(r.Context(), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load profile")
return
}
respondJSON(w, http.StatusOK, map[string]any{"user": updated})
}
func (a *App) handleChangePassword(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
var req struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
if len(req.NewPassword) < 8 {
badRequest(w, errors.New("newPassword must be at least 8 characters"))
return
}
row := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=?`, user.ID)
var currentHash string
if err := row.Scan(&currentHash); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load user")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(req.CurrentPassword)); err != nil {
respondError(w, http.StatusUnauthorized, "current password is incorrect")
return
}
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to hash password")
return
}
now := a.now().UTC().Format(time.RFC3339Nano)
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to start transaction")
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET password_hash=?, updated_at=? WHERE id=?`, string(newHash), now, user.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update password")
return
}
if _, err := tx.ExecContext(r.Context(), `UPDATE mailboxes SET password_hash=?, updated_at=? WHERE user_id=?`, string(newHash), now, user.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update mailbox password")
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to save password")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := a.authenticateRequest(r)
if err != nil {
respondError(w, http.StatusUnauthorized, "authentication required")
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
})
}
func (a *App) requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
if user == nil || user.Role != "admin" {
respondError(w, http.StatusForbidden, "admin role required")
return
}
next.ServeHTTP(w, r)
})
}
func currentUser(r *http.Request) *User {
user, _ := r.Context().Value(userContextKey).(*User)
return user
}
func (a *App) authenticateRequest(r *http.Request) (*User, error) {
cookie, err := r.Cookie(a.cfg.CookieName)
if err != nil || cookie.Value == "" {
return nil, errors.New("no session")
}
row := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at
FROM sessions s JOIN users u ON u.id=s.user_id
WHERE s.token_hash=? AND s.expires_at > ?`, hashToken(cookie.Value), a.now().UTC().Format(time.RFC3339Nano))
var u User
var disabled, twoFactorEnabled int
var created string
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &created); err != nil {
return nil, err
}
u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled)
u.CreatedAt = parseTime(created)
if u.Disabled {
return nil, errors.New("disabled")
}
return &u, nil
}
func (a *App) userByEmail(ctx context.Context, email string) (*User, string, error) {
row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,password_hash,disabled,two_factor_enabled,created_at FROM users WHERE email=?`, email)
var u User
var passwordHash string
var disabled, twoFactorEnabled int
var created string
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &passwordHash, &disabled, &twoFactorEnabled, &created); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, "", errNotFound
}
return nil, "", err
}
u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled)
u.CreatedAt = parseTime(created)
return &u, passwordHash, nil
}
func (a *App) userByID(ctx context.Context, id string) (*User, error) {
row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,disabled,two_factor_enabled,created_at FROM users WHERE id=?`, id)
var u User
var disabled, twoFactorEnabled int
var created string
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &created); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errNotFound
}
return nil, err
}
u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled)
u.CreatedAt = parseTime(created)
return &u, nil
}