feat(auth): 重构认证流程并补充前端登录态保护
- 拆分后端认证处理与会话创建逻辑,支持登录、注册、退出、个人资料和改密接口 - 增加二次验证挑战、Turnstile 人机校验与旧版邮箱迁移清理 - 新增前端认证守卫、管理员访问控制、退出 Hook 与验证工具 - 统一抽离接口类型,优化登录页、注册页和个人资料页的认证交互
This commit is contained in:
@@ -305,19 +305,73 @@ func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
|
||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
_, err := a.db.ExecContext(ctx, `
|
||||
UPDATE mailboxes
|
||||
SET status='disabled', updated_at=?
|
||||
WHERE address=?
|
||||
AND display_name='LanQin Admin'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM users
|
||||
WHERE users.id=mailboxes.user_id
|
||||
AND users.email=?
|
||||
AND users.role='admin'
|
||||
)`, now, adminEmail, adminEmail)
|
||||
return err
|
||||
rows, err := a.db.QueryContext(ctx, `
|
||||
SELECT mb.id, mb.domain_id
|
||||
FROM mailboxes mb
|
||||
JOIN users u ON u.id=mb.user_id
|
||||
WHERE mb.address=?
|
||||
AND mb.display_name='LanQin Admin'
|
||||
AND u.email=?
|
||||
AND u.role='admin'`, adminEmail, adminEmail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type legacyMailbox struct {
|
||||
id string
|
||||
domainID string
|
||||
}
|
||||
items := []legacyMailbox{}
|
||||
for rows.Next() {
|
||||
var item legacyMailbox
|
||||
if err := rows.Scan(&item.id, &item.domainID); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
messageRows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE mailbox_id=?`, item.id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messageIDs := []string{}
|
||||
for messageRows.Next() {
|
||||
var messageID string
|
||||
if err := messageRows.Scan(&messageID); err != nil {
|
||||
messageRows.Close()
|
||||
return err
|
||||
}
|
||||
messageIDs = append(messageIDs, messageID)
|
||||
}
|
||||
if err := messageRows.Err(); err != nil {
|
||||
messageRows.Close()
|
||||
return err
|
||||
}
|
||||
if err := messageRows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, messageID := range messageIDs {
|
||||
a.deleteMessageFiles(ctx, messageID)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `DELETE FROM mailboxes WHERE id=?`, item.id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `
|
||||
DELETE FROM domains
|
||||
WHERE id=?
|
||||
AND NOT EXISTS (SELECT 1 FROM mailboxes WHERE domain_id=domains.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM aliases WHERE domain_id=domains.id)`, item.domainID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migrateMailRulesBuilder(ctx context.Context) error {
|
||||
|
||||
@@ -332,6 +332,54 @@ func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyBootstrapMailboxMigrationRemovesImplicitAdminMailbox(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := Config{
|
||||
Addr: ":0",
|
||||
DBPath: filepath.Join(dir, "lanqin.db"),
|
||||
DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test",
|
||||
SessionTTLHours: 24,
|
||||
AdminEmail: "lanqinnet@gmail.com",
|
||||
AdminPassword: "ChangeMe123!",
|
||||
PublicHostname: "mail.example.test",
|
||||
PublicBaseURL: "http://localhost:5173",
|
||||
AllowInsecureHTTP: true,
|
||||
}
|
||||
a, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = a.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
var adminID string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT id FROM users WHERE email=?`, cfg.AdminEmail).Scan(&adminID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
domainID, err := a.createDomainTx(ctx, nil, "gmail.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.createMailbox(ctx, adminID, domainID, "lanqinnet", "LanQin Admin", "Password123!", 1024, "active"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.migrateLegacyBootstrapMailbox(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE email=? AND role='admin'`, cfg.AdminEmail).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("admin user count=%d err=%v", count, err)
|
||||
}
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM mailboxes WHERE address=?`, cfg.AdminEmail).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("legacy mailbox count=%d err=%v", count, err)
|
||||
}
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM domains WHERE id=?`, domainID).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("legacy domain count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserMailboxApplicationUsesAllowedDomainsAndReservedPrefixes(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
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(¤tHash); 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})
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
@@ -130,238 +129,6 @@ func (a *App) corsMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
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(¤tHash); 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)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (a *App) issueSession(w http.ResponseWriter, r *http.Request, userID string) error {
|
||||
token := randomToken()
|
||||
sessionID := newID("ses")
|
||||
expires := a.now().UTC().Add(time.Duration(a.cfg.SessionTTLHours) * time.Hour)
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO sessions(id,user_id,token_hash,expires_at,created_at) VALUES(?,?,?,?,?)`,
|
||||
sessionID, userID, hashToken(token), expires.Format(time.RFC3339Nano), a.now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return err
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: a.cfg.CookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Expires: expires,
|
||||
MaxAge: int(time.Until(expires).Seconds()),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: !a.cfg.AllowInsecureHTTP,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -90,27 +90,6 @@ func generateTOTPForCounter(key []byte, counter int64) string {
|
||||
return fmt.Sprintf("%06d", value%1000000)
|
||||
}
|
||||
|
||||
func (a *App) issueSession(w http.ResponseWriter, r *http.Request, userID string) error {
|
||||
token := randomToken()
|
||||
sessionID := newID("ses")
|
||||
expires := a.now().UTC().Add(time.Duration(a.cfg.SessionTTLHours) * time.Hour)
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO sessions(id,user_id,token_hash,expires_at,created_at) VALUES(?,?,?,?,?)`,
|
||||
sessionID, userID, hashToken(token), expires.Format(time.RFC3339Nano), a.now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return err
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: a.cfg.CookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Expires: expires,
|
||||
MaxAge: int(time.Until(expires).Seconds()),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: !a.cfg.AllowInsecureHTTP,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) createLoginChallenge(ctx context.Context, userID string) (string, error) {
|
||||
token := randomToken()
|
||||
now := a.now().UTC()
|
||||
|
||||
Reference in New Issue
Block a user