feat: unify email identity and administrator security
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions

This commit is contained in:
zxyszx
2026-08-05 02:55:36 +08:00
parent a11e1cd2f1
commit 86773c64ca
32 changed files with 1816 additions and 402 deletions
+68 -45
View File
@@ -1,6 +1,7 @@
package app
import (
"database/sql"
"errors"
"net/http"
"strings"
@@ -35,8 +36,11 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
if !verifyTOTP(secret, req.TwoFactorCode, a.now().UTC()) {
respondError(w, http.StatusUnauthorized, "验证码错误")
return
ok, consumeErr := a.consumeTwoFactorRecoveryCode(r.Context(), user.ID, req.TwoFactorCode)
if consumeErr != nil || !ok {
respondError(w, http.StatusUnauthorized, "验证码或恢复码错误")
return
}
}
a.deleteLoginChallenge(r.Context(), challenge.ID)
if err := a.issueSession(w, r, user.ID); err != nil {
@@ -50,18 +54,16 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
return
}
var loginName string
var err error
if strings.TrimSpace(req.LoginName) != "" {
loginName, err = cleanUsername(req.LoginName)
} else {
loginName, err = cleanLoginName(req.Email)
emailInput := req.Email
if strings.TrimSpace(emailInput) == "" && strings.Contains(strings.TrimSpace(req.LoginName), "@") {
emailInput = req.LoginName
}
email, err := cleanPrimaryEmail(emailInput)
if err != nil {
respondError(w, http.StatusUnauthorized, "账号或密码错误")
return
}
user, passwordHash, err := a.userByEmail(r.Context(), loginName)
user, passwordHash, err := a.userByEmail(r.Context(), email)
if err != nil || user.Disabled {
respondError(w, http.StatusUnauthorized, "账号或密码错误")
return
@@ -107,8 +109,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
return
}
email := normalizeEmail(req.Email)
if email == "" || !strings.Contains(email, "@") {
email, err := cleanPrimaryEmail(req.Email)
if err != nil {
badRequest(w, errors.New("邮箱地址无效"))
return
}
@@ -118,12 +120,43 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
}
displayName := strings.TrimSpace(req.DisplayName)
if displayName == "" {
displayName = strings.Split(email, "@")[0]
badRequest(w, errors.New("请输入显示名称"))
return
}
if len([]rune(displayName)) > 80 {
badRequest(w, errors.New("显示名称不能超过 80 个字符"))
return
}
parts := strings.SplitN(email, "@", 2)
mailboxLocalPart := normalizeLocalPart(req.LocalPart)
if mailboxLocalPart == "" {
mailboxLocalPart = normalizeLocalPart(parts[0])
}
mailboxDomainID := strings.TrimSpace(req.DomainID)
var mailboxDomain string
if mailboxDomainID != "" {
err = a.db.QueryRowContext(r.Context(), `SELECT name FROM domains WHERE id=? AND status='active'`, mailboxDomainID).Scan(&mailboxDomain)
} else {
err = a.db.QueryRowContext(r.Context(), `SELECT id,name FROM domains WHERE lower(name)=? AND status='active' ORDER BY created_at LIMIT 1`, normalizeDomain(parts[1])).Scan(&mailboxDomainID, &mailboxDomain)
}
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
badRequest(w, errors.New("所选邮箱域名不可用"))
} else {
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
}
return
}
if mailboxLocalPart == "" || !strings.EqualFold(email, mailboxLocalPart+"@"+normalizeDomain(mailboxDomain)) {
badRequest(w, errors.New("邮箱地址与所选前缀和域名不一致"))
return
}
for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
if item == mailboxLocalPart {
respondError(w, http.StatusForbidden, "该前缀已被保留,请使用其他前缀")
return
}
}
if _, _, err := a.userByEmail(r.Context(), email); err == nil {
respondError(w, http.StatusConflict, "该邮箱已被注册")
return
@@ -138,7 +171,13 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
}
now := a.now().UTC().Format(time.RFC3339Nano)
userID := newID("usr")
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, now, now); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱已被注册")
@@ -147,6 +186,18 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
return
}
if _, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
respondError(w, http.StatusConflict, "该邮箱已被注册")
} else {
respondError(w, http.StatusInternalServerError, "邮箱创建失败,请稍后重试")
}
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "注册失败,请稍后重试")
return
}
user, err := a.userByID(r.Context(), userID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load user")
@@ -156,38 +207,6 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "登录失败,请稍后重试")
return
}
// Create a mailbox for the registered user
var mailboxDomainID string
var mailboxLocalPart string
if strings.TrimSpace(req.DomainID) != "" && strings.TrimSpace(req.LocalPart) != "" {
// User selected a specific domain and local part
mailboxDomainID = strings.TrimSpace(req.DomainID)
mailboxLocalPart = normalizeLocalPart(req.LocalPart)
} else {
// Auto-detect: use the first active domain and email local part
if err := a.db.QueryRowContext(r.Context(), `SELECT id FROM domains WHERE status='active' ORDER BY created_at ASC LIMIT 1`).Scan(&mailboxDomainID); err != nil {
mailboxDomainID = ""
}
if mailboxDomainID != "" {
mailboxLocalPart = strings.SplitN(email, "@", 2)[0]
}
}
if mailboxDomainID != "" && mailboxLocalPart != "" {
// Check reserved prefixes
reserved := map[string]bool{}
for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
reserved[item] = true
}
if reserved[mailboxLocalPart] {
respondError(w, http.StatusForbidden, "该前缀已被保留,请使用其他前缀")
return
}
if _, mbErr := a.createMailboxWithPasswordHash(r.Context(), user.ID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); mbErr != nil {
a.log.Warn("failed to create mailbox for registered user", "error", mbErr, "email", email)
}
}
respondJSON(w, http.StatusCreated, map[string]any{"user": user})
}
@@ -205,6 +224,10 @@ func (a *App) handleMe(w http.ResponseWriter, r *http.Request) {
func (a *App) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
if user == nil || user.Role != "admin" {
respondError(w, http.StatusForbidden, "显示名称注册后不可自行修改,如需更换请联系管理员")
return
}
var req struct {
DisplayName string `json:"displayName"`
}