1695 lines
57 KiB
Go
1695 lines
57 KiB
Go
// Package webmail implements the REST API and embedded SPA for GoMail's own
|
|
// webmail client. The API wraps internal/accounts.GoMailProvider for message
|
|
// operations — direct local access, no JMAP dependency — so this phase isn't
|
|
// blocked on Phase 9's JMAP server. When JMAP lands, only this package's
|
|
// internals need to change; the REST contract (and therefore the frontend)
|
|
// stays the same.
|
|
package webmail
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/mail"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gomail/internal/accounts"
|
|
"gomail/internal/auth"
|
|
"gomail/internal/crypto"
|
|
"gomail/internal/db"
|
|
"gomail/internal/mailstore"
|
|
"gomail/internal/oauth2"
|
|
"gomail/internal/totp"
|
|
"gomail/internal/webauthn"
|
|
"gomail/internal/webtoken"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
const sessionTTL = 24 * time.Hour
|
|
|
|
type Handler struct {
|
|
database *db.DB
|
|
store *mailstore.Store
|
|
mk *crypto.MasterKey
|
|
jwtSecret string
|
|
hostname string // WebAuthn RP ID; origin is "https://"+hostname
|
|
|
|
oauthConfigs map[string]*oauth2.Config // keyed by "google" / "microsoft", nil entries if not configured
|
|
|
|
oauthStateMu sync.Mutex
|
|
oauthState map[string]oauthStateEntry // CSRF state -> pending link request
|
|
|
|
webauthnMu sync.Mutex
|
|
webauthnState map[string]webauthnChallengeEntry // challenge -> pending registration/assertion, same shape as oauthState
|
|
}
|
|
|
|
type oauthStateEntry struct {
|
|
UserID string
|
|
Provider string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// webauthnChallengeEntry tracks one issued challenge. UserID is set for
|
|
// both registration (the already-authenticated user registering a new
|
|
// passkey) and login (the user identified by the mfa_pending token before
|
|
// the passkey assertion completes it).
|
|
type webauthnChallengeEntry struct {
|
|
UserID string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
func NewHandler(database *db.DB, store *mailstore.Store, mk *crypto.MasterKey, jwtSecret, hostname string, oauthConfigs map[string]*oauth2.Config) *Handler {
|
|
return &Handler{
|
|
database: database, store: store, mk: mk, jwtSecret: jwtSecret, hostname: hostname,
|
|
oauthConfigs: oauthConfigs,
|
|
oauthState: make(map[string]oauthStateEntry),
|
|
webauthnState: make(map[string]webauthnChallengeEntry),
|
|
}
|
|
}
|
|
|
|
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("/api/auth/login", h.login)
|
|
mux.HandleFunc("/api/auth/mfa-verify", h.mfaVerify)
|
|
mux.HandleFunc("/api/auth/forgot-password", h.forgotPassword)
|
|
mux.HandleFunc("/api/auth/reset-password", h.resetPassword)
|
|
mux.HandleFunc("/api/me", h.withAuth(h.getMe))
|
|
mux.HandleFunc("/api/me/mfa/setup", h.withAuth(h.mfaSetup))
|
|
mux.HandleFunc("/api/me/mfa/confirm", h.withAuth(h.mfaConfirm))
|
|
mux.HandleFunc("/api/me/mfa/disable", h.withAuth(h.mfaDisable))
|
|
mux.HandleFunc("/api/me/passkeys/register/start", h.withAuth(h.passkeyRegisterStart))
|
|
mux.HandleFunc("/api/me/passkeys/register/finish", h.withAuth(h.passkeyRegisterFinish))
|
|
mux.HandleFunc("/api/me/passkeys", h.withAuth(h.passkeys))
|
|
mux.HandleFunc("/api/me/passkeys/", h.withAuth(h.passkeyByID))
|
|
mux.HandleFunc("/api/auth/passkey/start", h.passkeyLoginStart)
|
|
mux.HandleFunc("/api/auth/passkey/finish", h.passkeyLoginFinish)
|
|
mux.HandleFunc("/api/me/recovery-email", h.withAuth(h.setRecoveryEmail))
|
|
mux.HandleFunc("/api/me/app-passwords", h.withAuth(h.appPasswords))
|
|
mux.HandleFunc("/api/me/app-passwords/", h.withAuth(h.appPasswordByID))
|
|
mux.HandleFunc("/api/folders", h.withAuth(h.listFolders))
|
|
mux.HandleFunc("/api/folders/", h.withAuth(h.listMessages))
|
|
mux.HandleFunc("/api/messages", h.withAuth(h.sendOrListMessages))
|
|
mux.HandleFunc("/api/messages/", h.withAuth(h.messageByID))
|
|
mux.HandleFunc("/api/inbox/unified", h.withAuth(h.unifiedInbox))
|
|
mux.HandleFunc("/api/search", h.withAuth(h.search))
|
|
mux.HandleFunc("/api/calendar/events", h.withAuth(h.calendarEvents))
|
|
mux.HandleFunc("/api/contacts", h.withAuth(h.contacts))
|
|
mux.HandleFunc("/api/quarantine", h.withAuth(h.listQuarantine))
|
|
mux.HandleFunc("/api/quarantine/", h.withAuth(h.releaseQuarantine))
|
|
mux.HandleFunc("/api/events", h.withAuth(h.sseEvents))
|
|
mux.HandleFunc("/api/accounts", h.withAuth(h.listAccounts))
|
|
mux.HandleFunc("/api/accounts/oauth/", h.oauthDispatch) // start needs auth (checked inline), callback doesn't (browser redirect)
|
|
mux.HandleFunc("/api/accounts/imap", h.withAuth(h.linkIMAPAccount))
|
|
mux.HandleFunc("/api/accounts/", h.withAuth(h.deleteAccount))
|
|
}
|
|
|
|
// ── JSON helpers ──────────────────────────────────────────────────────────────
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeErr(w http.ResponseWriter, code int, msg string) {
|
|
writeJSON(w, code, map[string]string{"error": msg})
|
|
}
|
|
|
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
|
|
|
// titleCase upper-cases s's first byte — used only for the ASCII provider
|
|
// names ("google", "microsoft") in display strings; strings.Title is
|
|
// deprecated and its Unicode word-boundary handling is unneeded here.
|
|
func titleCase(s string) string {
|
|
if s == "" {
|
|
return s
|
|
}
|
|
return strings.ToUpper(s[:1]) + s[1:]
|
|
}
|
|
|
|
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var req struct{ Email, Password string }
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
user, ok := auth.Authenticate(h.database, req.Email, req.Password, auth.ScopeIMAP)
|
|
if !ok {
|
|
slog.Info("webmail login failed", "email", req.Email)
|
|
writeErr(w, http.StatusUnauthorized, "invalid credentials")
|
|
return
|
|
}
|
|
|
|
if user.MFAEnabled {
|
|
// Password alone is not enough — issue a short-lived, narrowly-scoped
|
|
// pre-auth token instead of a real session. It can only be redeemed
|
|
// at /api/auth/mfa-verify, and only with a correct TOTP or backup code.
|
|
mfaToken, err := webtoken.IssueWithPurpose(h.jwtSecret, user.ID, user.TenantID, string(user.Role), "mfa_pending", 5*time.Minute)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "token generation failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"mfa_required": true, "mfa_token": mfaToken})
|
|
return
|
|
}
|
|
|
|
token, err := webtoken.Issue(h.jwtSecret, user.ID, user.TenantID, string(user.Role), sessionTTL)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "token generation failed")
|
|
return
|
|
}
|
|
h.database.Exec(`UPDATE users SET last_login_at = ? WHERE id = ?`, time.Now().UTC(), user.ID)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"token": token,
|
|
"user": map[string]any{"id": user.ID, "email": user.Email, "display_name": user.DisplayName},
|
|
})
|
|
}
|
|
|
|
// mfaVerify completes login for an MFA-enabled account — redeems the
|
|
// pre-auth token from login() plus a valid TOTP or backup code for a real
|
|
// session token.
|
|
func (h *Handler) mfaVerify(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var req struct{ MFAToken, Code string }
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
claims, err := webtoken.Verify(h.jwtSecret, req.MFAToken)
|
|
if err != nil || claims.Purpose != "mfa_pending" {
|
|
writeErr(w, http.StatusUnauthorized, "invalid or expired MFA session")
|
|
return
|
|
}
|
|
|
|
user, err := h.database.GetUser(claims.Subject)
|
|
if err != nil || !user.Active {
|
|
writeErr(w, http.StatusUnauthorized, "user not found or inactive")
|
|
return
|
|
}
|
|
|
|
verified := false
|
|
if user.TOTPSecretEnc != nil {
|
|
plain, decErr := crypto.Decrypt(h.mk, user.ID, "totp-secret", user.TOTPSecretEnc)
|
|
if decErr == nil {
|
|
if ok, _ := totp.Validate(string(plain), req.Code); ok {
|
|
verified = true
|
|
}
|
|
}
|
|
}
|
|
if !verified {
|
|
// Fall back to a backup code — hashed the same way app passwords are.
|
|
hash := sha256Hex(req.Code)
|
|
if used, _ := h.database.ConsumeBackupCode(user.ID, hash); used {
|
|
verified = true
|
|
}
|
|
}
|
|
if !verified {
|
|
writeErr(w, http.StatusUnauthorized, "invalid code")
|
|
return
|
|
}
|
|
|
|
token, err := webtoken.Issue(h.jwtSecret, user.ID, user.TenantID, string(user.Role), sessionTTL)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "token generation failed")
|
|
return
|
|
}
|
|
h.database.Exec(`UPDATE users SET last_login_at = ? WHERE id = ?`, time.Now().UTC(), user.ID)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"token": token,
|
|
"user": map[string]any{"id": user.ID, "email": user.Email, "display_name": user.DisplayName},
|
|
})
|
|
}
|
|
|
|
func (h *Handler) withAuth(next func(http.ResponseWriter, *http.Request, *db.User)) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
tokenStr := ""
|
|
if authHeader := r.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Bearer ") {
|
|
tokenStr = strings.TrimPrefix(authHeader, "Bearer ")
|
|
} else if cookie, err := r.Cookie("gomail_token"); err == nil {
|
|
tokenStr = cookie.Value
|
|
}
|
|
if tokenStr == "" {
|
|
writeErr(w, http.StatusUnauthorized, "missing token")
|
|
return
|
|
}
|
|
|
|
claims, err := webtoken.Verify(h.jwtSecret, tokenStr)
|
|
if err != nil {
|
|
writeErr(w, http.StatusUnauthorized, "invalid or expired token")
|
|
return
|
|
}
|
|
if claims.Purpose != "" {
|
|
// A purpose-scoped token (mfa_pending, password_reset) is not a
|
|
// session — accepting it here would let it bypass whatever the
|
|
// purpose was gating (e.g. MFA).
|
|
writeErr(w, http.StatusUnauthorized, "invalid or expired token")
|
|
return
|
|
}
|
|
|
|
// claims.Subject is the user's ID (set at Issue time in login), not
|
|
// an email — look up directly by ID.
|
|
row := h.database.QueryRow(`SELECT id, tenant_id, domain_id, email, display_name, role, active FROM users WHERE id = ?`, claims.Subject)
|
|
var user db.User
|
|
if err := row.Scan(&user.ID, &user.TenantID, &user.DomainID, &user.Email, &user.DisplayName, &user.Role, &user.Active); err != nil {
|
|
writeErr(w, http.StatusUnauthorized, "user not found")
|
|
return
|
|
}
|
|
if !user.Active {
|
|
writeErr(w, http.StatusForbidden, "account disabled")
|
|
return
|
|
}
|
|
|
|
next(w, r, &user)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) getMe(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
// withAuth's own row fetch doesn't select mfa_enabled/recovery_email
|
|
// (most callers don't need them) — GetUser is the canonical full-row
|
|
// fetch that does.
|
|
fresh, err := h.database.GetUser(user.ID)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to load user")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"id": fresh.ID, "email": fresh.Email, "display_name": fresh.DisplayName, "role": fresh.Role,
|
|
"mfa_enabled": fresh.MFAEnabled, "recovery_email": fresh.RecoveryEmail,
|
|
})
|
|
}
|
|
|
|
// ── Folders & messages ──────────────────────────────────────────────────────────
|
|
|
|
// provider resolves which mailbox a request operates on. With no ?account=
|
|
// query param it's the user's own local mailbox (today's only behavior,
|
|
// unchanged). With ?account=<linked-account-id>, it's that account's
|
|
// provider — after confirming the account actually belongs to this user,
|
|
// since the ID otherwise comes straight from client input.
|
|
func (h *Handler) provider(r *http.Request, user *db.User) (accounts.MailProvider, error) {
|
|
id := r.URL.Query().Get("account")
|
|
if id == "" {
|
|
return accounts.NewGoMailProvider(h.database, h.store, user), nil
|
|
}
|
|
acct, err := h.database.GetLinkedAccount(id)
|
|
if err != nil || acct.UserID != user.ID {
|
|
return nil, fmt.Errorf("account not found")
|
|
}
|
|
return accounts.ProviderFor(acct, h.mk, h.database, h.oauthConfigs)
|
|
}
|
|
|
|
func (h *Handler) listFolders(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
p, err := h.provider(r, user)
|
|
if err != nil {
|
|
writeErr(w, http.StatusForbidden, err.Error())
|
|
return
|
|
}
|
|
folders, err := p.ListFolders(r.Context())
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, folders)
|
|
}
|
|
|
|
// listMessages handles GET /api/folders/{folderID}/messages
|
|
func (h *Handler) listMessages(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/folders/")
|
|
parts := strings.SplitN(path, "/", 2)
|
|
if len(parts) != 2 || parts[1] != "messages" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
folderID := parts[0]
|
|
|
|
opts := accounts.ListOpts{}
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
opts.Limit, _ = strconv.Atoi(l)
|
|
}
|
|
if o := r.URL.Query().Get("offset"); o != "" {
|
|
opts.Offset, _ = strconv.Atoi(o)
|
|
}
|
|
|
|
p, err := h.provider(r, user)
|
|
if err != nil {
|
|
writeErr(w, http.StatusForbidden, err.Error())
|
|
return
|
|
}
|
|
headers, err := p.ListMessages(r.Context(), folderID, opts)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, headers)
|
|
}
|
|
|
|
func (h *Handler) sendOrListMessages(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var req struct {
|
|
To []string `json:"to"`
|
|
CC []string `json:"cc"`
|
|
Subject string `json:"subject"`
|
|
Body string `json:"body"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if len(req.To) == 0 {
|
|
writeErr(w, http.StatusBadRequest, "at least one recipient required")
|
|
return
|
|
}
|
|
|
|
p, err := h.provider(r, user)
|
|
if err != nil {
|
|
writeErr(w, http.StatusForbidden, err.Error())
|
|
return
|
|
}
|
|
msg := &accounts.OutgoingMessage{From: user.Email, To: req.To, CC: req.CC, Subject: req.Subject, Body: req.Body}
|
|
if err := p.SendMessage(r.Context(), msg); err != nil {
|
|
writeErr(w, http.StatusBadGateway, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"message": "sent"})
|
|
}
|
|
|
|
// messageByID handles GET/PUT(flags)/DELETE/move on /api/messages/{folderID}/{messageID}[/flags|/move]
|
|
func (h *Handler) messageByID(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/messages/")
|
|
parts := strings.Split(path, "/")
|
|
if len(parts) < 2 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
folderID, messageID := parts[0], parts[1]
|
|
action := ""
|
|
if len(parts) >= 3 {
|
|
action = parts[2]
|
|
}
|
|
p, err := h.provider(r, user)
|
|
if err != nil {
|
|
writeErr(w, http.StatusForbidden, err.Error())
|
|
return
|
|
}
|
|
|
|
switch {
|
|
case r.Method == http.MethodGet && action == "":
|
|
full, err := p.GetMessage(r.Context(), folderID, messageID)
|
|
if err != nil {
|
|
writeErr(w, http.StatusNotFound, "message not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, full)
|
|
|
|
case r.Method == http.MethodPut && action == "flags":
|
|
var req struct{ Flags []string }
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid body")
|
|
return
|
|
}
|
|
if err := p.SetFlags(r.Context(), folderID, messageID, req.Flags); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"message": "updated"})
|
|
|
|
case r.Method == http.MethodPost && action == "move":
|
|
var req struct{ DestFolder string `json:"dest_folder"` }
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid body")
|
|
return
|
|
}
|
|
if err := p.Move(r.Context(), folderID, messageID, req.DestFolder); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"message": "moved"})
|
|
|
|
case r.Method == http.MethodDelete && action == "":
|
|
if err := p.Delete(r.Context(), folderID, messageID); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"message": "deleted"})
|
|
|
|
default:
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
type unifiedMessage struct {
|
|
accounts.MessageHeader
|
|
AccountID string `json:"account_id"` // "" means the local account — matches provider()'s ?account= convention
|
|
AccountLabel string `json:"account_label"`
|
|
}
|
|
|
|
// unifiedInbox handles GET /api/inbox/unified — merges the Inbox folder
|
|
// across the user's local mailbox and every linked account into one
|
|
// newest-first list. A linked account that fails (unreachable server,
|
|
// expired token) is skipped and reported in "warnings", not allowed to
|
|
// fail the whole request — the same negative-path standard this project
|
|
// applies elsewhere (see GOMAIL_HANDOVER.md).
|
|
func (h *Handler) unifiedInbox(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
const perAccountLimit = 30
|
|
const overallLimit = 100
|
|
|
|
type source struct {
|
|
id, label string
|
|
p accounts.MailProvider
|
|
}
|
|
sources := []source{{label: user.Email, p: accounts.NewGoMailProvider(h.database, h.store, user)}}
|
|
|
|
linked, err := h.database.ListLinkedAccounts(user.ID)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
for i := range linked {
|
|
acct := &linked[i]
|
|
p, perr := accounts.ProviderFor(acct, h.mk, h.database, h.oauthConfigs)
|
|
if perr != nil {
|
|
continue // unsupported provider type — a config-time issue, not a per-request failure worth reporting
|
|
}
|
|
label := acct.DisplayName
|
|
if label == "" {
|
|
label = acct.EmailAddress
|
|
}
|
|
sources = append(sources, source{id: acct.ID, label: label, p: p})
|
|
}
|
|
|
|
var merged []unifiedMessage
|
|
var warnings []string
|
|
for _, src := range sources {
|
|
folders, ferr := src.p.ListFolders(r.Context())
|
|
if ferr != nil {
|
|
warnings = append(warnings, fmt.Sprintf("%s: %v", src.label, ferr))
|
|
continue
|
|
}
|
|
var inboxID string
|
|
for _, f := range folders {
|
|
if f.Type == "inbox" {
|
|
inboxID = f.ID
|
|
break
|
|
}
|
|
}
|
|
if inboxID == "" {
|
|
continue
|
|
}
|
|
headers, merr := src.p.ListMessages(r.Context(), inboxID, accounts.ListOpts{Limit: perAccountLimit})
|
|
if merr != nil {
|
|
warnings = append(warnings, fmt.Sprintf("%s: %v", src.label, merr))
|
|
continue
|
|
}
|
|
for _, hdr := range headers {
|
|
merged = append(merged, unifiedMessage{MessageHeader: hdr, AccountID: src.id, AccountLabel: src.label})
|
|
}
|
|
}
|
|
|
|
sort.Slice(merged, func(i, j int) bool {
|
|
ti, _ := mail.ParseDate(merged[i].Date)
|
|
tj, _ := mail.ParseDate(merged[j].Date)
|
|
return ti.After(tj)
|
|
})
|
|
if len(merged) > overallLimit {
|
|
merged = merged[:overallLimit]
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"messages": merged, "warnings": warnings})
|
|
}
|
|
|
|
// searchResult tags a match with the folder it lives in — a search spans
|
|
// every folder in the account, so (unlike a single-folder listing) the
|
|
// client needs to know which folder to open the message from. AccountID/
|
|
// AccountLabel follow unifiedMessage's convention ("" means the local
|
|
// account) — populated when a search fans out across every linked account
|
|
// (see search's doc comment), left blank for a single-account search.
|
|
type searchResult struct {
|
|
accounts.MessageHeader
|
|
FolderName string `json:"folder_name"`
|
|
AccountID string `json:"account_id,omitempty"`
|
|
AccountLabel string `json:"account_label,omitempty"`
|
|
}
|
|
|
|
// search handles GET /api/search?q=...&body=1&folder=...&account=... —
|
|
// reuses ListFolders/ListMessages/GetMessage exactly like every other
|
|
// endpoint (same account-scoping via provider(), same header-cache-or-
|
|
// fallback decrypt ListMessages already does) rather than a separate
|
|
// index. Header matching (from/to/subject) is effectively free — it reuses
|
|
// the same decrypt ListMessages already pays for a folder view. Body
|
|
// matching is opt-in and live-decrypts on demand, capped at
|
|
// maxBodySearchScans messages total (shared across every account searched,
|
|
// not per-account) so one search can't force-decrypt an entire large
|
|
// mailbox — see this project's "everything encrypted at rest" guarantee,
|
|
// which an index over message content would weaken.
|
|
//
|
|
// With no ?account= and no ?folder=, the search fans out across the local
|
|
// mailbox and every linked account — same source list, same "skip and warn
|
|
// on a broken account" behavior as unifiedInbox — since a specific account
|
|
// or folder ID otherwise pins the search to one provider's namespace.
|
|
func (h *Handler) search(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
if r.Method != http.MethodGet {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
const maxResults = 100
|
|
const maxBodySearchScans = 500
|
|
|
|
query := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q")))
|
|
if query == "" {
|
|
writeErr(w, http.StatusBadRequest, "q is required")
|
|
return
|
|
}
|
|
searchBody := r.URL.Query().Get("body") == "1" || r.URL.Query().Get("body") == "true"
|
|
onlyFolder := r.URL.Query().Get("folder")
|
|
accountID := r.URL.Query().Get("account")
|
|
|
|
type source struct {
|
|
id, label string
|
|
p accounts.MailProvider
|
|
}
|
|
var sources []source
|
|
singleAccount := accountID != "" || onlyFolder != ""
|
|
if singleAccount {
|
|
p, err := h.provider(r, user)
|
|
if err != nil {
|
|
writeErr(w, http.StatusForbidden, err.Error())
|
|
return
|
|
}
|
|
sources = []source{{id: accountID, p: p}}
|
|
} else {
|
|
sources = append(sources, source{label: user.Email, p: accounts.NewGoMailProvider(h.database, h.store, user)})
|
|
linked, err := h.database.ListLinkedAccounts(user.ID)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
for i := range linked {
|
|
acct := &linked[i]
|
|
p, perr := accounts.ProviderFor(acct, h.mk, h.database, h.oauthConfigs)
|
|
if perr != nil {
|
|
continue // unsupported provider type — a config-time issue, not a per-request failure worth reporting
|
|
}
|
|
label := acct.DisplayName
|
|
if label == "" {
|
|
label = acct.EmailAddress
|
|
}
|
|
sources = append(sources, source{id: acct.ID, label: label, p: p})
|
|
}
|
|
}
|
|
|
|
var results []searchResult
|
|
var warnings []string
|
|
bodyScans := 0
|
|
truncated := false
|
|
for _, src := range sources {
|
|
var folders []accounts.Folder
|
|
if onlyFolder != "" {
|
|
folders = []accounts.Folder{{ID: onlyFolder}}
|
|
} else {
|
|
var ferr error
|
|
folders, ferr = src.p.ListFolders(r.Context())
|
|
if ferr != nil {
|
|
if singleAccount {
|
|
writeErr(w, http.StatusInternalServerError, ferr.Error())
|
|
return
|
|
}
|
|
if src.label != "" {
|
|
warnings = append(warnings, fmt.Sprintf("%s: %v", src.label, ferr))
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
for _, f := range folders {
|
|
headers, err := src.p.ListMessages(r.Context(), f.ID, accounts.ListOpts{})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, hdr := range headers {
|
|
matched := strings.Contains(strings.ToLower(hdr.From), query) ||
|
|
strings.Contains(strings.ToLower(hdr.To), query) ||
|
|
strings.Contains(strings.ToLower(hdr.Subject), query)
|
|
|
|
if !matched && searchBody {
|
|
if bodyScans >= maxBodySearchScans {
|
|
truncated = true
|
|
} else {
|
|
bodyScans++
|
|
if full, ferr := src.p.GetMessage(r.Context(), f.ID, hdr.ID); ferr == nil {
|
|
matched = strings.Contains(strings.ToLower(string(full.Raw)), query)
|
|
}
|
|
}
|
|
}
|
|
if matched {
|
|
results = append(results, searchResult{MessageHeader: hdr, FolderName: f.DisplayName, AccountID: src.id, AccountLabel: src.label})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
sort.Slice(results, func(i, j int) bool {
|
|
ti, _ := mail.ParseDate(results[i].Date)
|
|
tj, _ := mail.ParseDate(results[j].Date)
|
|
return ti.After(tj)
|
|
})
|
|
if len(results) > maxResults {
|
|
results = results[:maxResults]
|
|
truncated = true
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"messages": results, "truncated": truncated, "warnings": warnings})
|
|
}
|
|
|
|
// ── Calendar/contacts (linked accounts only — local calendar/contacts are
|
|
// served by internal/dav's CalDAV/CardDAV server, a separate protocol) ──────
|
|
|
|
// linkedProviderFor looks up id, checks it belongs to user (same ownership
|
|
// check provider()/unifiedInbox already use), and builds its MailProvider.
|
|
// Unlike provider(), there is no "local account" fallback here — local
|
|
// calendar/contacts have no REST path, only CalDAV/CardDAV.
|
|
func (h *Handler) linkedProviderFor(user *db.User, id string) (accounts.MailProvider, error) {
|
|
if id == "" {
|
|
return nil, fmt.Errorf("account is required")
|
|
}
|
|
acct, err := h.database.GetLinkedAccount(id)
|
|
if err != nil || acct.UserID != user.ID {
|
|
return nil, fmt.Errorf("account not found")
|
|
}
|
|
return accounts.ProviderFor(acct, h.mk, h.database, h.oauthConfigs)
|
|
}
|
|
|
|
func (h *Handler) calendarEvents(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
if r.Method != http.MethodGet {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
p, err := h.linkedProviderFor(user, r.URL.Query().Get("account"))
|
|
if err != nil {
|
|
writeErr(w, http.StatusForbidden, err.Error())
|
|
return
|
|
}
|
|
cp, ok := p.(accounts.CalendarProvider)
|
|
if !ok {
|
|
writeErr(w, http.StatusBadRequest, "this account type does not support calendar access")
|
|
return
|
|
}
|
|
|
|
from := time.Now().UTC().AddDate(0, 0, -30)
|
|
to := time.Now().UTC().AddDate(0, 0, 30)
|
|
if v := r.URL.Query().Get("from"); v != "" {
|
|
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
|
from = t
|
|
}
|
|
}
|
|
if v := r.URL.Query().Get("to"); v != "" {
|
|
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
|
to = t
|
|
}
|
|
}
|
|
|
|
events, err := cp.ListEvents(r.Context(), from, to)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadGateway, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, events)
|
|
}
|
|
|
|
func (h *Handler) contacts(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
if r.Method != http.MethodGet {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
p, err := h.linkedProviderFor(user, r.URL.Query().Get("account"))
|
|
if err != nil {
|
|
writeErr(w, http.StatusForbidden, err.Error())
|
|
return
|
|
}
|
|
cp, ok := p.(accounts.ContactProvider)
|
|
if !ok {
|
|
writeErr(w, http.StatusBadRequest, "this account type does not support contacts access")
|
|
return
|
|
}
|
|
list, err := cp.ListContacts(r.Context())
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadGateway, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, list)
|
|
}
|
|
|
|
// ── Quarantine ────────────────────────────────────────────────────────────────
|
|
|
|
func (h *Handler) listQuarantine(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
entries, err := h.database.QuarantineEntriesForUser(user.Email, time.Now().AddDate(0, 0, -30))
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, entries)
|
|
}
|
|
|
|
func (h *Handler) releaseQuarantine(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/quarantine/"), "/release")
|
|
|
|
entry, err := h.database.GetQuarantineEntry(id)
|
|
if err != nil {
|
|
writeErr(w, http.StatusNotFound, "quarantine entry not found")
|
|
return
|
|
}
|
|
|
|
var toAddr string
|
|
if err := h.database.QueryRow(`SELECT to_address FROM messages WHERE id = ?`, entry.MessageID).Scan(&toAddr); err != nil {
|
|
writeErr(w, http.StatusNotFound, "underlying message not found")
|
|
return
|
|
}
|
|
if toAddr != user.Email {
|
|
writeErr(w, http.StatusForbidden, "not your message")
|
|
return
|
|
}
|
|
|
|
raw, err := h.store.ReadQuarantineFile(entry.MessageID, entry.EMLPath)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to read quarantined message")
|
|
return
|
|
}
|
|
if _, err := h.store.Deliver(user.ID, user.Email, "INBOX", raw); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to deliver released message")
|
|
return
|
|
}
|
|
if err := h.database.ReleaseQuarantineEntry(id, user.Email); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"message": "released"})
|
|
}
|
|
|
|
// ── SSE ───────────────────────────────────────────────────────────────────────
|
|
|
|
// sseEvents streams a countUpdate event whenever the INBOX message count
|
|
// changes, polling every few seconds — a real push mechanism (fsnotify-style
|
|
// instant delivery) is a natural follow-up once IMAP IDLE's polling loop is
|
|
// generalized; this establishes the wire contract webmail's UI codes against
|
|
// today.
|
|
func (h *Handler) sseEvents(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
writeErr(w, http.StatusInternalServerError, "streaming unsupported")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
|
|
ctx := r.Context()
|
|
ticker := time.NewTicker(3 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
lastCount := -1
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
entries, err := h.database.ListMailboxEntries(user.ID, "INBOX")
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if len(entries) != lastCount {
|
|
lastCount = len(entries)
|
|
fmt.Fprintf(w, "event: countUpdate\ndata: {\"mailbox\":\"INBOX\",\"total\":%d}\n\n", len(entries))
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Linked accounts ──────────────────────────────────────────────────────────
|
|
|
|
func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
accts, err := h.database.ListLinkedAccounts(user.ID)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
// Never expose CredentialEnc — even encrypted, there's no reason to send
|
|
// it to the client at all.
|
|
type safeAccount struct {
|
|
ID string `json:"id"`
|
|
Provider string `json:"provider"`
|
|
DisplayName string `json:"display_name"`
|
|
EmailAddress string `json:"email_address"`
|
|
LastSyncAt string `json:"last_sync_at,omitempty"`
|
|
}
|
|
out := make([]safeAccount, 0, len(accts))
|
|
for _, a := range accts {
|
|
sa := safeAccount{ID: a.ID, Provider: string(a.Provider), DisplayName: a.DisplayName, EmailAddress: a.EmailAddress}
|
|
if a.LastSyncAt != nil {
|
|
sa.LastSyncAt = a.LastSyncAt.Format(time.RFC3339)
|
|
}
|
|
out = append(out, sa)
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (h *Handler) deleteAccount(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
if r.Method != http.MethodDelete {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
id := strings.TrimPrefix(r.URL.Path, "/api/accounts/")
|
|
if id == "" || strings.Contains(id, "/") {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
account, err := h.database.GetLinkedAccount(id)
|
|
if err != nil || account.UserID != user.ID {
|
|
writeErr(w, http.StatusNotFound, "account not found")
|
|
return
|
|
}
|
|
if err := h.database.DeactivateLinkedAccount(id); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"message": "unlinked"})
|
|
}
|
|
|
|
// linkIMAPAccount handles POST /api/accounts/imap — the password-based
|
|
// counterpart to the OAuth linking flow, for a generic IMAP/SMTP provider.
|
|
// Wraps accounts.LinkIMAPAccount, which already existed and was already
|
|
// fully wired for encrypted credential storage; this was the only missing
|
|
// piece, an HTTP entry point for it.
|
|
func (h *Handler) linkIMAPAccount(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var req struct {
|
|
DisplayName string
|
|
Email string
|
|
Password string
|
|
IMAPHost string
|
|
IMAPPort int
|
|
IMAPTLS string
|
|
SMTPHost string
|
|
SMTPPort int
|
|
SMTPTLS string
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil ||
|
|
req.Email == "" || req.Password == "" || req.IMAPHost == "" || req.SMTPHost == "" {
|
|
writeErr(w, http.StatusBadRequest, "email, password, imap_host, and smtp_host are required")
|
|
return
|
|
}
|
|
if req.DisplayName == "" {
|
|
req.DisplayName = req.Email
|
|
}
|
|
account, err := accounts.LinkIMAPAccount(h.database, h.mk, user.ID, req.DisplayName, req.Email, req.Password,
|
|
req.IMAPHost, req.IMAPPort, req.IMAPTLS, req.SMTPHost, req.SMTPPort, req.SMTPTLS)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]string{"id": account.ID})
|
|
}
|
|
|
|
// oauthDispatch routes /api/accounts/oauth/{provider}/start and .../callback.
|
|
// start requires an authenticated session (checked inline, not via withAuth,
|
|
// since callback intentionally does NOT require one — it's a plain browser
|
|
// redirect from the provider with no Authorization header available).
|
|
func (h *Handler) oauthDispatch(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/accounts/oauth/")
|
|
parts := strings.SplitN(path, "/", 2)
|
|
if len(parts) != 2 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
provider, action := parts[0], parts[1]
|
|
|
|
switch action {
|
|
case "start":
|
|
h.withAuth(func(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
h.oauthStart(w, r, user, provider)
|
|
})(w, r)
|
|
case "callback":
|
|
h.oauthCallback(w, r, provider)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) oauthStart(w http.ResponseWriter, r *http.Request, user *db.User, provider string) {
|
|
cfg, ok := h.oauthConfigs[provider]
|
|
if !ok || cfg == nil {
|
|
writeErr(w, http.StatusServiceUnavailable, fmt.Sprintf("%s OAuth is not configured on this server", provider))
|
|
return
|
|
}
|
|
|
|
state, err := randomState()
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to generate state")
|
|
return
|
|
}
|
|
|
|
h.oauthStateMu.Lock()
|
|
h.pruneExpiredState()
|
|
h.oauthState[state] = oauthStateEntry{UserID: user.ID, Provider: provider, ExpiresAt: time.Now().UTC().Add(10 * time.Minute)}
|
|
h.oauthStateMu.Unlock()
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"auth_url": cfg.BuildAuthURL(state)})
|
|
}
|
|
|
|
func (h *Handler) oauthCallback(w http.ResponseWriter, r *http.Request, provider string) {
|
|
code := r.URL.Query().Get("code")
|
|
state := r.URL.Query().Get("state")
|
|
if code == "" || state == "" {
|
|
writeErr(w, http.StatusBadRequest, "missing code or state")
|
|
return
|
|
}
|
|
|
|
h.oauthStateMu.Lock()
|
|
entry, ok := h.oauthState[state]
|
|
if ok {
|
|
delete(h.oauthState, state) // one-time use
|
|
}
|
|
h.oauthStateMu.Unlock()
|
|
|
|
if !ok {
|
|
writeErr(w, http.StatusBadRequest, "invalid or expired state (possible CSRF attempt)")
|
|
return
|
|
}
|
|
if entry.Provider != provider {
|
|
writeErr(w, http.StatusBadRequest, "state/provider mismatch")
|
|
return
|
|
}
|
|
if time.Now().UTC().After(entry.ExpiresAt) {
|
|
writeErr(w, http.StatusBadRequest, "state expired, please try linking again")
|
|
return
|
|
}
|
|
|
|
cfg, ok := h.oauthConfigs[provider]
|
|
if !ok || cfg == nil {
|
|
writeErr(w, http.StatusServiceUnavailable, "provider not configured")
|
|
return
|
|
}
|
|
|
|
token, err := cfg.ExchangeCode(r.Context(), code)
|
|
if err != nil {
|
|
slog.Error("oauth2 code exchange failed", "provider", provider, "err", err)
|
|
writeErr(w, http.StatusBadGateway, "failed to exchange authorization code")
|
|
return
|
|
}
|
|
|
|
dbProvider := db.ProviderGmail
|
|
if provider == "microsoft" {
|
|
dbProvider = db.ProviderM365
|
|
}
|
|
|
|
email, err := accounts.FetchOAuth2Email(r.Context(), provider, token.AccessToken)
|
|
if err != nil {
|
|
slog.Error("failed to look up account email from provider", "provider", provider, "err", err)
|
|
writeErr(w, http.StatusBadGateway, "failed to look up account email")
|
|
return
|
|
}
|
|
|
|
account, err := accounts.LinkOAuth2Account(h.database, h.mk, entry.UserID, titleCase(provider)+" Account", email, dbProvider, token)
|
|
if err != nil {
|
|
slog.Error("failed to store linked OAuth2 account", "err", err)
|
|
writeErr(w, http.StatusInternalServerError, "failed to link account")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"message": "linked", "account_id": account.ID})
|
|
}
|
|
|
|
func (h *Handler) pruneExpiredState() {
|
|
now := time.Now().UTC()
|
|
for k, v := range h.oauthState {
|
|
if now.After(v.ExpiresAt) {
|
|
delete(h.oauthState, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func randomState() (string, error) {
|
|
b := make([]byte, 24)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|
|
|
|
// ── MFA setup/confirm/disable ────────────────────────────────────────────────
|
|
|
|
func sha256Hex(s string) string {
|
|
sum := sha256.Sum256([]byte(strings.TrimSpace(strings.ToUpper(s))))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// mfaSetup generates a new TOTP secret and stores it encrypted but NOT yet
|
|
// enabled — the user must confirm one valid code (mfaConfirm) before MFA
|
|
// actually takes effect, so an abandoned setup never locks anyone out.
|
|
func (h *Handler) mfaSetup(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
secret, err := totp.GenerateSecret()
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to generate secret")
|
|
return
|
|
}
|
|
encSecret, err := crypto.Encrypt(h.mk, user.ID, "totp-secret", []byte(secret))
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to encrypt secret")
|
|
return
|
|
}
|
|
if err := h.database.SetPendingTOTPSecret(user.ID, encSecret); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
uri := totp.ProvisioningURI(secret, user.Email, "GoMail")
|
|
writeJSON(w, http.StatusOK, map[string]string{"secret": secret, "provisioning_uri": uri})
|
|
}
|
|
|
|
// mfaConfirm verifies one code against the pending secret and, on success,
|
|
// enables MFA and generates backup codes (shown to the user exactly once).
|
|
func (h *Handler) mfaConfirm(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var req struct{ Code string }
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
fresh, err := h.database.GetUser(user.ID)
|
|
if err != nil || fresh.TOTPSecretEnc == nil {
|
|
writeErr(w, http.StatusBadRequest, "no pending MFA setup — call /api/me/mfa/setup first")
|
|
return
|
|
}
|
|
plain, err := crypto.Decrypt(h.mk, user.ID, "totp-secret", fresh.TOTPSecretEnc)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to decrypt pending secret")
|
|
return
|
|
}
|
|
ok, err := totp.Validate(string(plain), req.Code)
|
|
if err != nil || !ok {
|
|
writeErr(w, http.StatusBadRequest, "invalid code")
|
|
return
|
|
}
|
|
|
|
backupCodes := make([]string, 8)
|
|
hashes := make([]string, 8)
|
|
for i := range backupCodes {
|
|
raw := make([]byte, 5)
|
|
rand.Read(raw)
|
|
code := strings.ToUpper(hex.EncodeToString(raw)) // 10 hex chars, easy to type
|
|
backupCodes[i] = code
|
|
hashes[i] = sha256Hex(code)
|
|
}
|
|
if err := h.database.ReplaceBackupCodes(user.ID, hashes); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if err := h.database.SetMFAEnabled(user.ID, true); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"message": "MFA enabled", "backup_codes": backupCodes})
|
|
}
|
|
|
|
func (h *Handler) mfaDisable(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var req struct{ Password string }
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
// Require the password again — disabling MFA is high-stakes enough that
|
|
// a hijacked-but-still-logged-in session shouldn't be able to do it
|
|
// with just the session token.
|
|
if _, ok := auth.Authenticate(h.database, user.Email, req.Password, auth.ScopeIMAP); !ok {
|
|
writeErr(w, http.StatusUnauthorized, "incorrect password")
|
|
return
|
|
}
|
|
if err := h.database.ClearTOTPSecret(user.ID); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if err := h.database.RecomputeMFAEnabled(user.ID); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"message": "MFA disabled"})
|
|
}
|
|
|
|
// ── Passkeys (WebAuthn) ──────────────────────────────────────────────────────
|
|
// See internal/webauthn's package doc comment for the two deliberate scope
|
|
// decisions (no attestation verification, ES256/P-256 only). Passkeys are
|
|
// an alternative second factor alongside TOTP/backup codes — they redeem
|
|
// the same mfa_pending token mfaVerify does, not a separate login flow.
|
|
|
|
func (h *Handler) origin() string { return "https://" + h.hostname }
|
|
|
|
func loadPasskeys(user *db.User) []webauthn.StoredCredential {
|
|
var creds []webauthn.StoredCredential
|
|
if user.PasskeyCredentialsJSON != "" {
|
|
json.Unmarshal([]byte(user.PasskeyCredentialsJSON), &creds)
|
|
}
|
|
return creds
|
|
}
|
|
|
|
func savePasskeys(database *db.DB, userID string, creds []webauthn.StoredCredential) error {
|
|
if creds == nil {
|
|
creds = []webauthn.StoredCredential{}
|
|
}
|
|
b, err := json.Marshal(creds)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := database.SetPasskeyCredentials(userID, string(b)); err != nil {
|
|
return err
|
|
}
|
|
return database.RecomputeMFAEnabled(userID)
|
|
}
|
|
|
|
func (h *Handler) passkeyRegisterStart(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
challenge, err := webauthn.NewChallenge()
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to generate challenge")
|
|
return
|
|
}
|
|
|
|
h.webauthnMu.Lock()
|
|
h.pruneExpiredWebauthnState()
|
|
h.webauthnState[challenge] = webauthnChallengeEntry{UserID: user.ID, ExpiresAt: time.Now().UTC().Add(5 * time.Minute)}
|
|
h.webauthnMu.Unlock()
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"rp": map[string]string{"id": h.hostname, "name": "GoMail"},
|
|
"user": map[string]string{"id": base64.RawURLEncoding.EncodeToString([]byte(user.ID)), "name": user.Email, "displayName": user.DisplayName},
|
|
"challenge": challenge,
|
|
"pubKeyCredParams": []map[string]any{
|
|
{"alg": -7, "type": "public-key"}, // ES256 — see internal/webauthn's scope doc comment
|
|
},
|
|
"timeout": 60000,
|
|
"attestation": "none",
|
|
"authenticatorSelection": map[string]string{"userVerification": "preferred"},
|
|
})
|
|
}
|
|
|
|
func (h *Handler) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var req struct {
|
|
Challenge string
|
|
Name string
|
|
ClientDataJSON string
|
|
AttestationObject string
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
h.webauthnMu.Lock()
|
|
entry, ok := h.webauthnState[req.Challenge]
|
|
if ok {
|
|
delete(h.webauthnState, req.Challenge) // one-time use
|
|
}
|
|
h.webauthnMu.Unlock()
|
|
if !ok || entry.UserID != user.ID || time.Now().UTC().After(entry.ExpiresAt) {
|
|
writeErr(w, http.StatusBadRequest, "invalid or expired registration challenge")
|
|
return
|
|
}
|
|
|
|
clientDataJSON, err1 := base64.RawURLEncoding.DecodeString(req.ClientDataJSON)
|
|
attestationObject, err2 := base64.RawURLEncoding.DecodeString(req.AttestationObject)
|
|
if err1 != nil || err2 != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid base64url encoding")
|
|
return
|
|
}
|
|
|
|
authData, err := webauthn.VerifyRegistration(clientDataJSON, attestationObject, req.Challenge, h.hostname, h.origin())
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "passkey registration failed: "+err.Error())
|
|
return
|
|
}
|
|
|
|
name := req.Name
|
|
if name == "" {
|
|
name = "Passkey"
|
|
}
|
|
cred := webauthn.StoredCredential{
|
|
ID: base64.RawURLEncoding.EncodeToString(authData.CredentialID),
|
|
PublicKey: webauthn.EncodePublicKey(authData.PublicKey),
|
|
SignCount: authData.SignCount,
|
|
Name: name,
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
// withAuth's own row fetch doesn't select passkey_credentials_json
|
|
// (most callers don't need it) — GetUser is the canonical full-row
|
|
// fetch that does; using the withAuth-provided user here would silently
|
|
// discard every previously registered passkey on each new one.
|
|
fresh, err := h.database.GetUser(user.ID)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to load user")
|
|
return
|
|
}
|
|
creds := append(loadPasskeys(fresh), cred)
|
|
if err := savePasskeys(h.database, user.ID, creds); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"message": "passkey added"})
|
|
}
|
|
|
|
func (h *Handler) passkeys(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
if r.Method != http.MethodGet {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
type safeCred struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
fresh, err := h.database.GetUser(user.ID)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to load user")
|
|
return
|
|
}
|
|
creds := loadPasskeys(fresh)
|
|
out := make([]safeCred, 0, len(creds))
|
|
for _, c := range creds {
|
|
out = append(out, safeCred{ID: c.ID, Name: c.Name, CreatedAt: c.CreatedAt})
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (h *Handler) passkeyByID(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
if r.Method != http.MethodDelete {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
id := strings.TrimPrefix(r.URL.Path, "/api/me/passkeys/")
|
|
fresh, err := h.database.GetUser(user.ID)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to load user")
|
|
return
|
|
}
|
|
creds := loadPasskeys(fresh)
|
|
kept := make([]webauthn.StoredCredential, 0, len(creds))
|
|
found := false
|
|
for _, c := range creds {
|
|
if c.ID == id {
|
|
found = true
|
|
continue
|
|
}
|
|
kept = append(kept, c)
|
|
}
|
|
if !found {
|
|
writeErr(w, http.StatusNotFound, "passkey not found")
|
|
return
|
|
}
|
|
if err := savePasskeys(h.database, user.ID, kept); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"message": "passkey removed"})
|
|
}
|
|
|
|
func (h *Handler) pruneExpiredWebauthnState() {
|
|
now := time.Now().UTC()
|
|
for k, v := range h.webauthnState {
|
|
if now.After(v.ExpiresAt) {
|
|
delete(h.webauthnState, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// passkeyLoginStart handles POST /api/auth/passkey/start — takes the same
|
|
// mfa_pending token login() issues, returns a WebAuthn assertion challenge
|
|
// listing the user's registered credentials.
|
|
func (h *Handler) passkeyLoginStart(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var req struct{ MFAToken string }
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
claims, err := webtoken.Verify(h.jwtSecret, req.MFAToken)
|
|
if err != nil || claims.Purpose != "mfa_pending" {
|
|
writeErr(w, http.StatusUnauthorized, "invalid or expired MFA session")
|
|
return
|
|
}
|
|
user, err := h.database.GetUser(claims.Subject)
|
|
if err != nil || !user.Active {
|
|
writeErr(w, http.StatusUnauthorized, "user not found or inactive")
|
|
return
|
|
}
|
|
creds := loadPasskeys(user)
|
|
if len(creds) == 0 {
|
|
writeErr(w, http.StatusBadRequest, "no passkeys registered for this account")
|
|
return
|
|
}
|
|
|
|
challenge, err := webauthn.NewChallenge()
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to generate challenge")
|
|
return
|
|
}
|
|
h.webauthnMu.Lock()
|
|
h.pruneExpiredWebauthnState()
|
|
h.webauthnState[challenge] = webauthnChallengeEntry{UserID: user.ID, ExpiresAt: time.Now().UTC().Add(5 * time.Minute)}
|
|
h.webauthnMu.Unlock()
|
|
|
|
allow := make([]map[string]string, 0, len(creds))
|
|
for _, c := range creds {
|
|
allow = append(allow, map[string]string{"id": c.ID, "type": "public-key"})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"rpId": h.hostname, "challenge": challenge, "timeout": 60000,
|
|
"userVerification": "preferred", "allowCredentials": allow,
|
|
})
|
|
}
|
|
|
|
// passkeyLoginFinish handles POST /api/auth/passkey/finish — verifies the
|
|
// assertion and, on success, redeems the mfa_pending token for a real
|
|
// session exactly like mfaVerify does.
|
|
func (h *Handler) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var req struct {
|
|
MFAToken string
|
|
Challenge string
|
|
CredentialID string
|
|
ClientDataJSON string
|
|
AuthenticatorData string
|
|
Signature string
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
claims, err := webtoken.Verify(h.jwtSecret, req.MFAToken)
|
|
if err != nil || claims.Purpose != "mfa_pending" {
|
|
writeErr(w, http.StatusUnauthorized, "invalid or expired MFA session")
|
|
return
|
|
}
|
|
|
|
h.webauthnMu.Lock()
|
|
entry, ok := h.webauthnState[req.Challenge]
|
|
if ok {
|
|
delete(h.webauthnState, req.Challenge) // one-time use
|
|
}
|
|
h.webauthnMu.Unlock()
|
|
if !ok || entry.UserID != claims.Subject || time.Now().UTC().After(entry.ExpiresAt) {
|
|
writeErr(w, http.StatusUnauthorized, "invalid or expired passkey challenge")
|
|
return
|
|
}
|
|
|
|
user, err := h.database.GetUser(claims.Subject)
|
|
if err != nil || !user.Active {
|
|
writeErr(w, http.StatusUnauthorized, "user not found or inactive")
|
|
return
|
|
}
|
|
creds := loadPasskeys(user)
|
|
idx := -1
|
|
for i, c := range creds {
|
|
if c.ID == req.CredentialID {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx == -1 {
|
|
writeErr(w, http.StatusUnauthorized, "unknown credential")
|
|
return
|
|
}
|
|
|
|
clientDataJSON, err1 := base64.RawURLEncoding.DecodeString(req.ClientDataJSON)
|
|
authenticatorData, err2 := base64.RawURLEncoding.DecodeString(req.AuthenticatorData)
|
|
signature, err3 := base64.RawURLEncoding.DecodeString(req.Signature)
|
|
if err1 != nil || err2 != nil || err3 != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid base64url encoding")
|
|
return
|
|
}
|
|
|
|
newSignCount, err := webauthn.VerifyAssertion(creds[idx], clientDataJSON, authenticatorData, signature, req.Challenge, h.hostname, h.origin())
|
|
if err != nil {
|
|
writeErr(w, http.StatusUnauthorized, "passkey verification failed: "+err.Error())
|
|
return
|
|
}
|
|
creds[idx].SignCount = newSignCount
|
|
if err := savePasskeys(h.database, user.ID, creds); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
token, err := webtoken.Issue(h.jwtSecret, user.ID, user.TenantID, string(user.Role), sessionTTL)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "token generation failed")
|
|
return
|
|
}
|
|
h.database.Exec(`UPDATE users SET last_login_at = ? WHERE id = ?`, time.Now().UTC(), user.ID)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"token": token,
|
|
"user": map[string]any{"id": user.ID, "email": user.Email, "display_name": user.DisplayName},
|
|
})
|
|
}
|
|
|
|
// ── App passwords ─────────────────────────────────────────────────────────────
|
|
|
|
func (h *Handler) appPasswords(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
rows, err := h.database.Query(`SELECT id, label, scopes, last_used_at, expires_at, created_at FROM app_passwords WHERE user_id = ? ORDER BY created_at DESC`, user.ID)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
type entry struct {
|
|
ID, Label, Scopes string
|
|
LastUsedAt, ExpiresAt *time.Time
|
|
CreatedAt time.Time
|
|
}
|
|
var out []entry
|
|
for rows.Next() {
|
|
var e entry
|
|
if err := rows.Scan(&e.ID, &e.Label, &e.Scopes, &e.LastUsedAt, &e.ExpiresAt, &e.CreatedAt); err != nil {
|
|
continue
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
|
|
case http.MethodPost:
|
|
var req struct {
|
|
Label string
|
|
Scopes string
|
|
ExpiresIn string // e.g. "30d", "" = never
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Label == "" {
|
|
writeErr(w, http.StatusBadRequest, "label is required")
|
|
return
|
|
}
|
|
if req.Scopes == "" {
|
|
req.Scopes = "smtp,imap"
|
|
}
|
|
|
|
raw := make([]byte, 24)
|
|
rand.Read(raw)
|
|
token := strings.ToUpper(hex.EncodeToString(raw))
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(token), 12)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "hashing failed")
|
|
return
|
|
}
|
|
|
|
var expiresAt *time.Time
|
|
if req.ExpiresIn != "" {
|
|
d, err := parseDuration(req.ExpiresIn)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid expires_in format (use e.g. '30d', '90d')")
|
|
return
|
|
}
|
|
t := time.Now().UTC().Add(d)
|
|
expiresAt = &t
|
|
}
|
|
|
|
id := uuid.NewString()
|
|
_, err = h.database.Exec(`INSERT INTO app_passwords (id, user_id, label, password_hash, scopes, expires_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
id, user.ID, req.Label, string(hash), req.Scopes, expiresAt)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]string{"id": id, "token": token}) // token shown exactly once
|
|
|
|
default:
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) appPasswordByID(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
if r.Method != http.MethodDelete {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
id := strings.TrimPrefix(r.URL.Path, "/api/me/app-passwords/")
|
|
res, err := h.database.Exec(`DELETE FROM app_passwords WHERE id = ? AND user_id = ?`, id, user.ID)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
writeErr(w, http.StatusNotFound, "app password not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"message": "revoked"})
|
|
}
|
|
|
|
func parseDuration(s string) (time.Duration, error) {
|
|
if strings.HasSuffix(s, "d") {
|
|
var days int
|
|
if _, err := fmt.Sscanf(s, "%dd", &days); err != nil {
|
|
return 0, err
|
|
}
|
|
return time.Duration(days) * 24 * time.Hour, nil
|
|
}
|
|
return time.ParseDuration(s)
|
|
}
|
|
|
|
// ── Password reset (recovery-email based) ────────────────────────────────────
|
|
|
|
// forgotPassword always returns 200 regardless of whether the email
|
|
// matches an account or that account has a recovery email configured —
|
|
// leaking account existence via response differences is exactly what this
|
|
// guards against.
|
|
func (h *Handler) forgotPassword(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var req struct{ Email string }
|
|
json.NewDecoder(r.Body).Decode(&req)
|
|
|
|
user, err := h.database.LookupUserByEmail(req.Email)
|
|
if err == nil && user.RecoveryEmail != "" {
|
|
fingerprint := webtoken.Fingerprint(user.PasswordHash)
|
|
resetToken, tokErr := webtoken.IssueResetToken(h.jwtSecret, user.ID, user.TenantID, string(user.Role), fingerprint, 1*time.Hour)
|
|
if tokErr == nil {
|
|
body := fmt.Sprintf("A password reset was requested for your GoMail account (%s).\r\n\r\n"+
|
|
"Reset token (valid 1 hour): %s\r\n\r\n"+
|
|
"If you didn't request this, you can safely ignore this message.\r\n", user.Email, resetToken)
|
|
raw := []byte(fmt.Sprintf("From: noreply@gomail\r\nTo: %s\r\nSubject: GoMail password reset\r\n\r\n%s", user.RecoveryEmail, body))
|
|
if _, queuePath, qErr := h.store.WriteQueueFile(raw); qErr == nil {
|
|
h.database.InsertOutboundQueueEntry(&db.OutboundQueueEntry{
|
|
ID: uuid.NewString(), UserID: user.ID, FromAddress: "noreply@" + strings.SplitN(user.Email, "@", 2)[1],
|
|
ToAddress: user.RecoveryEmail, EMLPath: queuePath, NextAttemptAt: time.Now().UTC(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"message": "if an account with recovery email configured exists, a reset link has been sent"})
|
|
}
|
|
|
|
func (h *Handler) resetPassword(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var req struct{ Token, NewPassword string }
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.NewPassword) < 8 {
|
|
writeErr(w, http.StatusBadRequest, "new_password must be at least 8 characters")
|
|
return
|
|
}
|
|
claims, err := webtoken.Verify(h.jwtSecret, req.Token)
|
|
if err != nil || claims.Purpose != "password_reset" {
|
|
writeErr(w, http.StatusBadRequest, "invalid or expired reset token")
|
|
return
|
|
}
|
|
current, err := h.database.GetUser(claims.Subject)
|
|
if err != nil || !webtoken.FingerprintMatches(claims, current.PasswordHash) {
|
|
// Either the user no longer exists, or the password has already
|
|
// been changed since this token was issued (including via a prior
|
|
// use of this same token) — reject either way, single-use enforced.
|
|
writeErr(w, http.StatusBadRequest, "invalid or expired reset token")
|
|
return
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), 12)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "hashing failed")
|
|
return
|
|
}
|
|
if err := h.database.SetUserPassword(claims.Subject, string(hash)); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"message": "password reset successful"})
|
|
}
|
|
|
|
func (h *Handler) setRecoveryEmail(w http.ResponseWriter, r *http.Request, user *db.User) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var req struct{ RecoveryEmail string }
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if err := h.database.SetRecoveryEmail(user.ID, req.RecoveryEmail); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"message": "recovery email updated"})
|
|
}
|