update
This commit is contained in:
+755
-19
@@ -9,11 +9,14 @@ package webmail
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -26,6 +29,7 @@ import (
|
||||
"gomail/internal/mailstore"
|
||||
"gomail/internal/oauth2"
|
||||
"gomail/internal/totp"
|
||||
"gomail/internal/webauthn"
|
||||
"gomail/internal/webtoken"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -38,11 +42,15 @@ type Handler struct {
|
||||
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 {
|
||||
@@ -51,11 +59,21 @@ type oauthStateEntry struct {
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func NewHandler(database *db.DB, store *mailstore.Store, mk *crypto.MasterKey, jwtSecret string, oauthConfigs map[string]*oauth2.Config) *Handler {
|
||||
// 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,
|
||||
oauthConfigs: oauthConfigs,
|
||||
oauthState: make(map[string]oauthStateEntry),
|
||||
database: database, store: store, mk: mk, jwtSecret: jwtSecret, hostname: hostname,
|
||||
oauthConfigs: oauthConfigs,
|
||||
oauthState: make(map[string]oauthStateEntry),
|
||||
webauthnState: make(map[string]webauthnChallengeEntry),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +86,12 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
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))
|
||||
@@ -75,11 +99,16 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -255,19 +284,46 @@ func (h *Handler) withAuth(next func(http.ResponseWriter, *http.Request, *db.Use
|
||||
}
|
||||
|
||||
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": user.ID, "email": user.Email, "display_name": user.DisplayName, "role": user.Role,
|
||||
"id": fresh.ID, "email": fresh.Email, "display_name": fresh.DisplayName, "role": fresh.Role,
|
||||
"mfa_enabled": fresh.MFAEnabled, "recovery_email": fresh.RecoveryEmail,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Folders & messages ──────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) provider(user *db.User) *accounts.GoMailProvider {
|
||||
return accounts.NewGoMailProvider(h.database, h.store, user)
|
||||
// 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) {
|
||||
folders, err := h.provider(user).ListFolders(r.Context())
|
||||
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
|
||||
@@ -293,7 +349,12 @@ func (h *Handler) listMessages(w http.ResponseWriter, r *http.Request, user *db.
|
||||
opts.Offset, _ = strconv.Atoi(o)
|
||||
}
|
||||
|
||||
headers, err := h.provider(user).ListMessages(r.Context(), folderID, opts)
|
||||
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
|
||||
@@ -321,8 +382,13 @@ func (h *Handler) sendOrListMessages(w http.ResponseWriter, r *http.Request, use
|
||||
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 := h.provider(user).SendMessage(r.Context(), msg); err != nil {
|
||||
if err := p.SendMessage(r.Context(), msg); err != nil {
|
||||
writeErr(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -342,7 +408,11 @@ func (h *Handler) messageByID(w http.ResponseWriter, r *http.Request, user *db.U
|
||||
if len(parts) >= 3 {
|
||||
action = parts[2]
|
||||
}
|
||||
p := h.provider(user)
|
||||
p, err := h.provider(r, user)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case r.Method == http.MethodGet && action == "":
|
||||
@@ -389,6 +459,306 @@ func (h *Handler) messageByID(w http.ResponseWriter, r *http.Request, user *db.U
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -530,6 +900,44 @@ func (h *Handler) deleteAccount(w http.ResponseWriter, r *http.Request, user *db
|
||||
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
|
||||
@@ -622,14 +1030,11 @@ func (h *Handler) oauthCallback(w http.ResponseWriter, r *http.Request, provider
|
||||
dbProvider = db.ProviderM365
|
||||
}
|
||||
|
||||
// Note: a real implementation would call the provider's userinfo/profile
|
||||
// endpoint here to learn the account's actual email address rather than
|
||||
// require it as a query param — deferred; for now the display name is
|
||||
// generic and the operator/user can rename it, matching the minimum
|
||||
// needed to prove the OAuth2 flow itself is correct end-to-end.
|
||||
email := r.URL.Query().Get("email")
|
||||
if email == "" {
|
||||
email = provider + "-account"
|
||||
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)
|
||||
@@ -764,9 +1169,340 @@ func (h *Handler) mfaDisable(w http.ResponseWriter, r *http.Request, user *db.Us
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user