mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
d76f06d199
* feat(store): push device register/delete, SessionInfo.ID, one-tx session rotation (TASK-3938) RegisterPushDevice is one transaction: delete rows on either unique key, evict the oldest at the 20-device cap, insert. RotateUserSessions mints the replacement, rebinds the caller's own devices, deletes the other sessions, and fails closed to revoke-all. Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * feat(server): push device and notification-settings routes, push_available (TASK-3938) Session-only PUT/DELETE /me/push-devices/{connection_id}, GET/PUT /me/notification-settings, push_available on /auth/session, PAD_PUSH_GATEWAY_URL, and all credential-change rotations through RotateUserSessions. docs/api.md updated. Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * test(server): classify GET /me/notification-settings in the compress census (TASK-3938) Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * fix(server): the pinned-token refusal on notification settings takes BUG-3982's shape (TASK-3938 r1) * fix(server): notification settings leave the pinned-token refusal to the BUG-3982 gate (TASK-3938 r1) * test(store): concurrent registers at 19 devices hold the push-device cap (TASK-3938) Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * test(store): make the concurrent-register cap test able to fail on Postgres (TASK-3938) Reuse sessions across rounds and widen the count-to-insert window with a test-owned slow insert trigger, so removing the user-row lock overshoots the cap on every run. Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP * docs(server): a workspace-pinned token on /me/push-devices gets the BUG-3982 refusal, not session_required (TASK-3938 r1) * fix(server): push_available goes to a signed-in caller only (TASK-3938 r2)
2208 lines
87 KiB
Go
2208 lines
87 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"mime"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
"github.com/PerpetualSoftware/pad/internal/store"
|
|
)
|
|
|
|
const (
|
|
webSessionTTL = 7 * 24 * time.Hour // 7 days for web sessions
|
|
cliSessionTTL = 30 * 24 * time.Hour // 30 days for CLI tokens
|
|
|
|
authMethodPassword = "password"
|
|
authMethodCloud = "cloud"
|
|
setupMethodLocalCLI = "local_cli"
|
|
setupMethodDockerExec = "docker_exec"
|
|
setupMethodCloud = "cloud"
|
|
// setupMethodLogsToken is returned by handleSessionCheck when a
|
|
// first-run bootstrap token is loaded (self-host, UserCount==0). It
|
|
// tells the frontend's SetupRequiredNotice to render the "paste your
|
|
// bootstrap token from the container logs" branch instead of the
|
|
// CLI-only instructions. See TASK-1167 / PLAN-1166.
|
|
setupMethodLogsToken = "logs_token"
|
|
// setupMethodOpen is returned by handleSessionCheck when the
|
|
// operator has enabled PAD_BYPASS_SETUP_TOKEN on a self-host
|
|
// deployment with no users yet. The frontend renders the bootstrap
|
|
// form directly — no paste-token prompt — and the bootstrap POST
|
|
// is accepted without an X-Bootstrap-Token header. Cloud mode
|
|
// never advertises this value.
|
|
setupMethodOpen = "open"
|
|
)
|
|
|
|
var emailRegexp = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
|
|
|
// sessionCookieName returns the session cookie name. When running over TLS
|
|
// (secureCookies=true), the __Host- prefix is used to prevent subdomain
|
|
// cookie injection attacks.
|
|
func sessionCookieName(secure bool) string {
|
|
if secure {
|
|
return "__Host-pad_session"
|
|
}
|
|
return "pad_session"
|
|
}
|
|
|
|
// csrfCookieName returns the CSRF cookie name. Uses the same __Host- prefix
|
|
// strategy as the session cookie.
|
|
func csrfCookieName(secure bool) string {
|
|
if secure {
|
|
return "__Host-pad_csrf"
|
|
}
|
|
return "pad_csrf"
|
|
}
|
|
|
|
func sessionUserPayload(user *models.User) map[string]interface{} {
|
|
if user == nil {
|
|
return nil
|
|
}
|
|
return map[string]interface{}{
|
|
"id": user.ID,
|
|
"email": user.Email,
|
|
"username": user.Username,
|
|
"name": user.Name,
|
|
"role": user.Role,
|
|
"totp_enabled": user.TOTPEnabled,
|
|
"plan": user.EffectivePlan(time.Now()), // expiry enforced (BUG-3356)
|
|
"email_verified": user.IsEmailVerified(),
|
|
}
|
|
}
|
|
|
|
// handleCheckUsername checks if a username is available for registration.
|
|
// GET /api/v1/auth/check-username?username=foo
|
|
func (s *Server) handleCheckUsername(w http.ResponseWriter, r *http.Request) {
|
|
username := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("username")))
|
|
|
|
if username == "" {
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"available": false,
|
|
"reason": "invalid",
|
|
"message": "Username is required",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Format/reserved validation
|
|
if err := ValidateUsername(username); err != nil {
|
|
reason := "invalid"
|
|
if IsReservedUsername(username) {
|
|
reason = "reserved"
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"available": false,
|
|
"reason": reason,
|
|
"message": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Uniqueness check
|
|
existing, err := s.store.GetUserByUsername(username)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
if existing != nil {
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"available": false,
|
|
"reason": "taken",
|
|
"message": "Username is already taken",
|
|
})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"available": true,
|
|
"reason": nil,
|
|
"message": nil,
|
|
})
|
|
}
|
|
|
|
func (s *Server) setupStatePayload(setupMethod string) map[string]interface{} {
|
|
mcp := s.sessionMCP()
|
|
payload := map[string]interface{}{
|
|
"authenticated": false,
|
|
"setup_required": true,
|
|
"setup_method": setupMethod,
|
|
"auth_method": authMethodPassword,
|
|
"cloud_mode": s.cloudMode,
|
|
"email_configured": s.email != nil,
|
|
"mcp_public_url": mcp.URL,
|
|
"mcp_auth": mcp.Auth,
|
|
// PLAN-2310 DR-7: the web UI keys Connected Apps on oauth_available
|
|
// rather than cloud_mode. Both are evaluated per request.
|
|
"mcp_available": mcp.Available,
|
|
"oauth_available": mcp.OAuth,
|
|
"version": s.version,
|
|
}
|
|
s.addBillingFields(payload)
|
|
return payload
|
|
}
|
|
|
|
// addBillingFields sets billing_available and billing_offer (TASK-3577):
|
|
// whether Pad Pro can be bought now, and its intervals, prices and trial as
|
|
// the sidecar serves them. billing_offer is null whenever billing_available
|
|
// is false. Both are always emitted.
|
|
func (s *Server) addBillingFields(payload map[string]interface{}) {
|
|
available, offer := s.billingNow()
|
|
payload["billing_available"] = available
|
|
if offer == nil {
|
|
payload["billing_offer"] = nil
|
|
return
|
|
}
|
|
payload["billing_offer"] = map[string]interface{}{
|
|
"intervals": offer.Intervals,
|
|
"trial_days": offer.TrialDays,
|
|
}
|
|
}
|
|
|
|
// webMCPEnabled reports whether the WebMCP browser surface is opted in via the
|
|
// webmcp_enabled platform setting. Fails closed: any read error or an unset/
|
|
// non-"true" value yields false (PLAN-1888 DR-6).
|
|
func (s *Server) webMCPEnabled() bool {
|
|
v, err := s.store.GetPlatformSetting(settingWebMCPEnabled)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return v == "true"
|
|
}
|
|
|
|
func (s *Server) sessionStatePayload(authenticated bool, user *models.User) map[string]interface{} {
|
|
// mcp_public_url is the URL clients paste into their MCP-capable agent
|
|
// (e.g. "https://mcp.getpad.dev"), and mcp_auth the methods it accepts:
|
|
// ["oauth","pat"], or ["pat"] on an http self-host. While MCP is not
|
|
// available the URL is "" and the methods are empty (PLAN-2310 DR-8,
|
|
// sessionMCP). The web UI's connect modal keys on both. Always emitted,
|
|
// never omitted, so the frontend can rely on a string and an array.
|
|
//
|
|
// billing_available and billing_offer: see addBillingFields (TASK-3577).
|
|
mcp := s.sessionMCP()
|
|
payload := map[string]interface{}{
|
|
"authenticated": authenticated,
|
|
"setup_required": false,
|
|
"auth_method": authMethodPassword,
|
|
"cloud_mode": s.cloudMode,
|
|
// email_configured tells the web UI whether transactional email is
|
|
// wired. The /forgot-password page uses it to swap its "we emailed
|
|
// you a link" copy for host-recovery guidance when false (self-host
|
|
// with no Maileroo key). Low-sensitivity deployment config, same
|
|
// class as cloud_mode/mcp_public_url.
|
|
"email_configured": s.email != nil,
|
|
"mcp_public_url": mcp.URL,
|
|
"mcp_auth": mcp.Auth,
|
|
// PLAN-2310 DR-7: whether MCP and its OAuth path are available now
|
|
// (the setting, the configured origin and its scheme; always on
|
|
// cloud). The console keys Connected Apps on oauth_available.
|
|
"mcp_available": mcp.Available,
|
|
"oauth_available": mcp.OAuth,
|
|
// webmcp_enabled gates the browser-side WebMCP surface. Read from the
|
|
// platform_settings kv table; default false when unset/absent or on
|
|
// any read error (fail closed). The web client uses it to decide
|
|
// whether to register tools via document.modelContext (PLAN-1888 DR-6).
|
|
"webmcp_enabled": s.webMCPEnabled(),
|
|
// version is the server build version (same source as /health),
|
|
// surfaced here so the mobile shells can read it in the
|
|
// /auth/session call they already make on connect and warn when a
|
|
// server is below their minimum supported version (IDEA-1826).
|
|
// Empty string only on builds with no version stamped; release
|
|
// builds carry a semver, dev builds carry "dev".
|
|
"version": s.version,
|
|
}
|
|
s.addBillingFields(payload)
|
|
if authenticated {
|
|
payload["user"] = sessionUserPayload(user)
|
|
// push_available: whether this server can send pushes (TASK-3938). A
|
|
// JSON bool for a SIGNED-IN caller only: the app reads it by type with
|
|
// its session, and whether a gateway exists is server configuration an
|
|
// anonymous caller does not need (absent reads as off).
|
|
payload["push_available"] = s.pushAvailable()
|
|
}
|
|
return payload
|
|
}
|
|
|
|
// requestIsLoopback reports whether the request came from a local CLI
|
|
// running on the same machine as the Pad server. The check is intentionally
|
|
// strict: it must be satisfiable ONLY by a direct loopback-TCP connection,
|
|
// never by a request relayed through a proxy (local or remote).
|
|
//
|
|
// Two conditions must hold:
|
|
//
|
|
// 1. The untampered TCP peer (captured by CapturePeerAddr before any
|
|
// RealIP rewrite) is a loopback address. This defeats X-Forwarded-For
|
|
// spoofing from a non-loopback attacker — TrustedProxyRealIP already
|
|
// ignores XFF from untrusted peers, but we re-check the raw peer so
|
|
// a proxy misconfigured to trust 127.0.0.0/8 still can't be fooled
|
|
// into rewriting the peer itself.
|
|
//
|
|
// 2. Neither X-Forwarded-For nor X-Real-IP is set. A legitimate local CLI
|
|
// talking directly to the Pad port never sets these headers. A reverse
|
|
// proxy forwarding public traffic always does — so this rejects the
|
|
// regression Codex flagged on PR #175: a local Caddy/nginx proxying
|
|
// public traffic to Pad on 127.0.0.1 would otherwise make every
|
|
// request look loopback and reopen the bootstrap gate.
|
|
//
|
|
// The rule denies some unusual legitimate setups (e.g. a local proxy that
|
|
// deliberately strips forwarding headers) in exchange for a simple,
|
|
// sound invariant. Operators in that narrow case can call
|
|
// `pad auth setup` from the host CLI instead of through their proxy.
|
|
// isPlausibleEmail is a cheap pre-filter used to decide whether an email
|
|
// is worth creating a per-email rate-limiter bucket for. NOT a full RFC
|
|
// 5322 validator — it only rejects two easy kinds of junk: (1)
|
|
// excessively long strings, (2) strings with no '@' at all. It does not
|
|
// bound the bucket map; the limiter's cap does (BUG-3308). Anything shape-like-an-email passes and
|
|
// the real validation happens in the store's password check.
|
|
func isPlausibleEmail(s string) bool {
|
|
// RFC 5321 §4.5.3.1.3 caps the full address at 254 octets.
|
|
if s == "" || len(s) > 254 {
|
|
return false
|
|
}
|
|
at := strings.IndexByte(s, '@')
|
|
// Require an '@' that isn't at position 0 or the last char, so
|
|
// neither side of the address is empty.
|
|
return at > 0 && at < len(s)-1
|
|
}
|
|
|
|
func requestIsLoopback(r *http.Request) bool {
|
|
// (2) Reject any proxied request.
|
|
if r.Header.Get("X-Forwarded-For") != "" || r.Header.Get("X-Real-IP") != "" {
|
|
return false
|
|
}
|
|
// (1) The TCP peer must be a loopback address.
|
|
peer := rawPeerAddr(r)
|
|
host := peer
|
|
if parsedHost, _, err := net.SplitHostPort(peer); err == nil {
|
|
host = parsedHost
|
|
}
|
|
ip := net.ParseIP(strings.Trim(host, "[]"))
|
|
return ip != nil && ip.IsLoopback()
|
|
}
|
|
|
|
// validateSessionCookie validates a session cookie and returns the user if
|
|
// valid, nil otherwise. A User-Agent change is logged but NOT rejected — this
|
|
// must stay in lockstep with the TokenAuth/SessionAuth middleware, which also
|
|
// treats UA binding as log-only (see middleware_auth.go and BUG-1815).
|
|
// Enforcing it here while the middleware allows it would split auth semantics:
|
|
// the same session would be accepted on middleware-protected routes but
|
|
// rejected on the helper-based routes (CLI-auth approval, account/2FA setup,
|
|
// session check) that call this.
|
|
func (s *Server) validateSessionCookie(r *http.Request) *models.User {
|
|
cookie, err := r.Cookie(sessionCookieName(s.secureCookies))
|
|
if err != nil {
|
|
// Fallback: check the unprefixed name for sessions created before the upgrade
|
|
cookie, err = r.Cookie("pad_session")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
}
|
|
session, _ := s.store.ValidateSession(cookie.Value)
|
|
if session == nil || session.User == nil || !cookieKindAccepted(session) {
|
|
return nil
|
|
}
|
|
// Session binding: a User-Agent change is logged for visibility but not
|
|
// rejected. UA is client-supplied and weak as a binding, and false-positives
|
|
// on routine client churn (browser/WebView updates, DevTools device
|
|
// emulation, mobile-app rebuilds).
|
|
if session.UAHash != "" && sha256hex(r.UserAgent()) != session.UAHash {
|
|
slog.Warn("session binding mismatch: User-Agent changed (logged, request allowed)",
|
|
"session_ip", session.IPAddress,
|
|
"client_ip", clientIP(r))
|
|
}
|
|
return session.User
|
|
}
|
|
|
|
// rotateSessionsAfterCredentialChange invalidates every existing session
|
|
// for the user (forcing sign-out on all other devices) and then re-issues
|
|
// a fresh session for the current request so the caller stays logged in.
|
|
// Call this after any action that changes the credentials or auth surface
|
|
// tied to the account: password change, TOTP disable, OAuth provider
|
|
// unlink, etc. Without it a stolen cookie stays valid forever — defeating
|
|
// the point of letting a user "kick everyone else out" by rotating their
|
|
// password.
|
|
//
|
|
// Sets a fresh session cookie on the response (for browser callers) AND
|
|
// returns the new token string (for CLI / API callers using
|
|
// Authorization: Bearer padsess_… who never read cookies). Handlers
|
|
// should embed the returned token in their response body so both
|
|
// transport styles stay authenticated.
|
|
//
|
|
// The rotation is one store transaction (RotateUserSessions): it fails
|
|
// closed, revoking every session when it cannot complete, and then this
|
|
// writes a 500 and returns ok=false — the caller should return immediately.
|
|
// The caller's own push devices move onto the replacement session; devices
|
|
// on the user's other sessions die with those sessions (TASK-3938).
|
|
func (s *Server) rotateSessionsAfterCredentialChange(w http.ResponseWriter, r *http.Request, user *models.User) (string, bool) {
|
|
// TASK-3365: every session is revoked below, so the user's live
|
|
// connections re-check their credential now, not on their next tick.
|
|
defer s.invalidateUserAccess(user.ID)
|
|
info := s.requestSessionInfo(r)
|
|
// The session being replaced dates the new one (BUG-3336). Every caller
|
|
// is a session-only door, so a request without a readable session of
|
|
// this user is not a state to mint from: every session is still revoked
|
|
// and the caller signs in again, rather than receiving a session that
|
|
// would read as freshly signed in.
|
|
if info == nil || info.User.ID != user.ID {
|
|
if err := s.store.DeleteUserSessions(user.ID); err != nil {
|
|
slog.Error("failed to invalidate sessions after credential change",
|
|
"user_id", user.ID, "error", err)
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error",
|
|
"Credentials updated but failed to refresh session. Please sign in again.")
|
|
return "", false
|
|
}
|
|
// The replacement keeps the replaced session's kind (BUG-3350).
|
|
device := "web"
|
|
if info.Kind == store.SessionKindCLI {
|
|
device = "cli"
|
|
}
|
|
// The replacement keeps the replaced session's own sliding window too
|
|
// (BUG-3978): a rotation changes no session's lifetime, so a 30-day CLI
|
|
// token stays 30 days and nothing is lengthened. A legacy row with no
|
|
// window falls back to the web TTL, as every rotation did before.
|
|
ttl := info.RenewTTL
|
|
if ttl <= 0 {
|
|
ttl = webSessionTTL
|
|
}
|
|
// One store transaction (TASK-3938): mint the replacement, fenced on the
|
|
// epoch of the user the caller passes, which is the credential state this
|
|
// rotation follows (BUG-3382); move the CALLER's push devices onto it;
|
|
// delete every other session, whose devices go with them. Any failure
|
|
// inside revokes every session, so nothing stays half-rotated.
|
|
token, err := s.store.RotateUserSessions(user.ID, user.CredentialEpoch, info.ID, device, clientIP(r), r.UserAgent(), ttl, info.CreatedAt)
|
|
if errors.Is(err, store.ErrUserDisabled) {
|
|
writeError(w, http.StatusForbidden, "account_disabled", "Your account has been disabled. Contact an administrator.")
|
|
return "", false
|
|
}
|
|
if err != nil {
|
|
slog.Error("failed to rotate sessions after credential change",
|
|
"user_id", user.ID, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error",
|
|
"Credentials updated but failed to refresh session. Please sign in again.")
|
|
return "", false
|
|
}
|
|
if device == "cli" {
|
|
return token, true // a bearer; no cookie (BUG-3350)
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName(s.secureCookies),
|
|
Value: token,
|
|
Path: "/",
|
|
MaxAge: int(ttl.Seconds()),
|
|
HttpOnly: true,
|
|
Secure: s.secureCookies,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
// The cookies carry the session's window, as createAuthSession sets them:
|
|
// a 30-day web session (a bootstrap from /setup) keeps 30-day cookies.
|
|
setCSRFCookie(w, int(ttl.Seconds()), s.secureCookies)
|
|
return token, true
|
|
}
|
|
|
|
func (s *Server) createAuthSession(w http.ResponseWriter, r *http.Request, user *models.User, ttl time.Duration) (string, error) {
|
|
// The CLI's sign-in mints a CLI session and no cookie (BUG-3350): its
|
|
// token travels as a bearer, and as a cookie it would be refused anyway.
|
|
device := "web"
|
|
if isCLIClient(r) {
|
|
device = "cli"
|
|
}
|
|
// Fenced on the credential epoch read when the sign-in checked its
|
|
// credential (BUG-3382): user is the account as that check loaded it.
|
|
token, err := s.store.CreateSessionFenced(user.ID, user.CredentialEpoch, device, clientIP(r), r.UserAgent(), ttl, time.Time{})
|
|
if errors.Is(err, store.ErrCredentialsChanged) {
|
|
writeError(w, http.StatusUnauthorized, "credentials_changed",
|
|
"Your sign-in details changed while you were signing in. Sign in again.")
|
|
return "", err
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create session")
|
|
return "", err
|
|
}
|
|
if device == "cli" {
|
|
return token, nil
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName(s.secureCookies),
|
|
Value: token,
|
|
Path: "/",
|
|
MaxAge: int(ttl.Seconds()),
|
|
HttpOnly: true,
|
|
Secure: s.secureCookies,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
|
|
// Set CSRF cookie alongside the session cookie
|
|
setCSRFCookie(w, int(ttl.Seconds()), s.secureCookies)
|
|
|
|
s.destroyReplacedSessions(r)
|
|
|
|
return token, nil
|
|
}
|
|
|
|
// destroyReplacedSessions deletes the session rows named by the session
|
|
// cookies this request presented, which the sign-in that just minted a new
|
|
// row is replacing (BUG-3011). Nothing else holds such a row: sessions are
|
|
// per mint, so other devices and CLI padsess_ bearers have rows of their
|
|
// own, and other tabs in this browser share the cookie jar this response
|
|
// overwrites. Left alone, the row stays a valid credential until its TTL
|
|
// that no UI can reach or end, and a connection it opened stays authorized
|
|
// past a sign-in as someone else (BUG-3007 checks credential liveness).
|
|
//
|
|
// Deletion is by the PRESENTED token only, never by user id, so another
|
|
// session of the same user is untouched. Both cookie names count: in
|
|
// secure mode an unprefixed legacy cookie is shadowed by the new
|
|
// __Host- one from here on (validateSessionCookie prefers the prefixed
|
|
// name), which orphans it the same way. Best-effort: a sign-in never fails
|
|
// over this, since the caller already holds the new session.
|
|
func (s *Server) destroyReplacedSessions(r *http.Request) {
|
|
names := []string{sessionCookieName(s.secureCookies)}
|
|
if s.secureCookies {
|
|
names = append(names, sessionCookieName(false))
|
|
}
|
|
for _, name := range names {
|
|
cookie, err := r.Cookie(name)
|
|
if err != nil || cookie.Value == "" {
|
|
continue
|
|
}
|
|
// TASK-3365: the replaced session may be another user's, still
|
|
// streaming in this browser; kick whoever held it.
|
|
var replacedUserID string
|
|
if info, verr := s.store.ValidateSession(cookie.Value); verr == nil && info != nil && info.User != nil {
|
|
replacedUserID = info.User.ID
|
|
}
|
|
if err := s.store.DeleteSession(cookie.Value); err != nil {
|
|
slog.Warn("sign-in could not destroy the session it replaced",
|
|
"cookie", name, "error", err)
|
|
}
|
|
s.invalidateUserAccess(replacedUserID)
|
|
}
|
|
}
|
|
|
|
// handleBootstrap creates the first admin account for a fresh instance.
|
|
//
|
|
// Default gate is loopback-only: setup must happen on the server host
|
|
// or from inside the container.
|
|
//
|
|
// Self-host (non-cloud) mode also accepts a one-time first-run token
|
|
// supplied via the X-Bootstrap-Token header — the "logs token" path that
|
|
// makes Docker / Unraid bootstrapping possible without `docker exec`.
|
|
// See TASK-1167 / PLAN-1166 and the bootstrap.go file for details.
|
|
//
|
|
// Cloud mode NEVER accepts the token bypass (D2/D10 — F2 from codex
|
|
// review). A cloud bootstrap must come over loopback from the same
|
|
// host as part of the operator's provisioning workflow.
|
|
//
|
|
// The mutex wraps the entire validate → UserCount-check → CreateUser →
|
|
// consume sequence (F5). Without it, two simultaneous valid-token
|
|
// requests with different emails could each pass validation and end up
|
|
// creating two admins from one token.
|
|
func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
|
// Bootstrap signs the browser into the admin it creates, and is
|
|
// CSRF-exempt. On a fresh instance a cross-site form from a browser that
|
|
// can reach it (loopback, the LAN in open-bootstrap mode) could plant the
|
|
// attacker's admin and sign the visitor in (BUG-3680).
|
|
if s.refuseCrossSiteSignIn(w, r) {
|
|
return
|
|
}
|
|
s.bootstrapMu.Lock()
|
|
defer s.bootstrapMu.Unlock()
|
|
|
|
if s.cloudMode {
|
|
// Allow bootstrap in cloud mode ONLY when no users exist yet.
|
|
// A fresh cloud instance needs at least one admin before OAuth can work.
|
|
count, err := s.store.UserCount()
|
|
if err != nil || count > 0 {
|
|
writeError(w, http.StatusForbidden, "forbidden", "Bootstrap is disabled in cloud mode — users register via OAuth or invitation")
|
|
return
|
|
}
|
|
if !requestIsLoopback(r) {
|
|
writeError(w, http.StatusForbidden, "forbidden", "Bootstrap is only allowed from localhost on the server host")
|
|
return
|
|
}
|
|
} else {
|
|
// Self-host: loopback OR valid first-run token (header-only) OR
|
|
// open-bootstrap mode (PAD_BYPASS_SETUP_TOKEN=true). Open mode
|
|
// is gated to !cloudMode by openBootstrapEnabled(); the
|
|
// UserCount==0 check below is the second invariant that keeps
|
|
// the bypass from being a permanent open-registration door.
|
|
if !requestIsLoopback(r) && !s.checkBootstrapToken(r) && !s.openBootstrapEnabled() {
|
|
writeError(w, http.StatusForbidden, "forbidden", "Bootstrap is only allowed from localhost or with a valid bootstrap token")
|
|
return
|
|
}
|
|
}
|
|
|
|
var input struct {
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
input.Email = strings.TrimSpace(input.Email)
|
|
input.Name = strings.TrimSpace(input.Name)
|
|
|
|
// The bot principals' reserved domain is no person's address (TASK-3392).
|
|
if input.Email == "" || !emailRegexp.MatchString(input.Email) || models.IsReservedAppEmail(input.Email) {
|
|
writeError(w, http.StatusBadRequest, "validation_error", "Valid email is required")
|
|
return
|
|
}
|
|
if input.Name == "" {
|
|
writeError(w, http.StatusBadRequest, "validation_error", "Name is required")
|
|
return
|
|
}
|
|
if err := validatePasswordStrength(input.Password, input.Email, input.Name); err != nil {
|
|
writeError(w, http.StatusBadRequest, "validation_error", err.Error())
|
|
return
|
|
}
|
|
|
|
count, err := s.store.UserCount()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check user count")
|
|
return
|
|
}
|
|
if count > 0 {
|
|
writeError(w, http.StatusConflict, "conflict", "This Pad instance has already been initialized")
|
|
return
|
|
}
|
|
|
|
existing, err := s.store.GetUserByEmail(input.Email)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check email")
|
|
return
|
|
}
|
|
if existing != nil {
|
|
writeError(w, http.StatusConflict, "conflict", "A user with this email already exists")
|
|
return
|
|
}
|
|
|
|
// Auto-generate username from name (D1: no prompt for bootstrap)
|
|
username, err := s.store.EnsureUniqueUsername(store.GenerateUsername(input.Name, input.Email))
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to generate username")
|
|
return
|
|
}
|
|
|
|
user, err := s.store.CreateUser(models.UserCreate{
|
|
Email: input.Email,
|
|
Username: username,
|
|
Name: input.Name,
|
|
Password: input.Password,
|
|
Role: "admin",
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create user")
|
|
return
|
|
}
|
|
|
|
// Consume the first-run bootstrap token. We hold s.bootstrapMu, so
|
|
// this serializes with any concurrent bootstrap attempt — that
|
|
// goroutine will see an empty in-memory token and bail with 403.
|
|
// Cloud mode never loaded one in the first place; the no-op cost
|
|
// of calling it there is a single mutex-locked nil string write +
|
|
// stat-failure rm. File-removal failure is logged but does not
|
|
// surface to the caller; the bootstrap itself succeeded, and a
|
|
// stale token file is cleaned up on the next startup (D4).
|
|
if cerr := s.consumeBootstrapToken(); cerr != nil {
|
|
slog.Warn("bootstrap token consume: file removal failed (in-memory token cleared regardless)", "error", cerr)
|
|
}
|
|
|
|
token, err := s.createAuthSession(w, r, user, cliSessionTTL)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
s.logAuditEventForUser(models.ActionBootstrap, r, user.ID, auditMeta(map[string]string{"email": user.Email}))
|
|
|
|
// Auto-create default workspace in cloud mode
|
|
s.autoCreateWorkspace(user)
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
|
"user": sessionUserPayload(user),
|
|
"token": token,
|
|
})
|
|
}
|
|
|
|
// handleRegister creates a new user account.
|
|
// invitationNotJoined is the additive register response member for a signup
|
|
// that was kept without its invitation (BUG-3438): open registration, where
|
|
// the invitation was not the admission and failed at its claim.
|
|
type invitationNotJoined struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// rollbackSignup removes an account this signup created moments ago and is
|
|
// now refusing (BUG-3438). It is the full account deletion, not a bare
|
|
// DELETE FROM users: the account exists from CreateUser on, so a sign-in, a
|
|
// /forgot-password or the signup's own verification mint can add rows that
|
|
// reference it before the refusal, and DeleteAccountAtomic removes or
|
|
// detaches every such row and retries one that lands late.
|
|
//
|
|
// Like every account deletion it then kicks the account's connections
|
|
// (TASK-3365): a sign-in in the same window can have opened a stream, which
|
|
// would otherwise run until its next revalidation tick (codex r6). The kick
|
|
// runs on an error too: a lost commit acknowledgement can land the deletion,
|
|
// and a kick for an account that survived only makes its connections
|
|
// re-check and keep going (codex r7).
|
|
func (s *Server) rollbackSignup(userID string) error {
|
|
del := s.store.DeleteAccountAtomic
|
|
if s.signupRollbackDelete != nil {
|
|
del = s.signupRollbackDelete
|
|
}
|
|
err := del(userID)
|
|
s.invalidateUserAccess(userID)
|
|
return err
|
|
}
|
|
|
|
// Registration is restricted to admins or users with a valid invitation code
|
|
// so invitees can create an account via the /join/[code] flow.
|
|
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|
// An anonymous signup signs the browser into the new account, and the
|
|
// path is CSRF-exempt without a session: refuse a cross-site form, as
|
|
// login does, or a form could sign a visitor into an account the attacker
|
|
// made (BUG-3680).
|
|
if s.refuseCrossSiteSignIn(w, r) {
|
|
return
|
|
}
|
|
var input struct {
|
|
Email string `json:"email"`
|
|
Username string `json:"username"`
|
|
Name string `json:"name"`
|
|
Password string `json:"password"`
|
|
InvitationCode string `json:"invitation_code"`
|
|
// InvitationProof is the mailbox-only secret from the invitation
|
|
// EMAIL's link (TASK-3352). It, never the code, verifies the address.
|
|
InvitationProof string `json:"invitation_proof"`
|
|
}
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
// Validate
|
|
input.Email = strings.TrimSpace(input.Email)
|
|
input.Username = strings.TrimSpace(strings.ToLower(input.Username))
|
|
input.Name = strings.TrimSpace(input.Name)
|
|
input.InvitationCode = strings.TrimSpace(input.InvitationCode)
|
|
|
|
// The bot principals' reserved domain is no person's address (TASK-3392).
|
|
if input.Email == "" || !emailRegexp.MatchString(input.Email) || models.IsReservedAppEmail(input.Email) {
|
|
writeError(w, http.StatusBadRequest, "validation_error", "Valid email is required")
|
|
return
|
|
}
|
|
if input.Name == "" {
|
|
writeError(w, http.StatusBadRequest, "validation_error", "Name is required")
|
|
return
|
|
}
|
|
if err := validatePasswordStrength(input.Password, input.Email, input.Name, input.Username); err != nil {
|
|
writeError(w, http.StatusBadRequest, "validation_error", err.Error())
|
|
return
|
|
}
|
|
|
|
count, err := s.store.UserCount()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check user count")
|
|
return
|
|
}
|
|
|
|
// Validate invitation code if provided (look it up before the auth gate
|
|
// so we can give a clear error for invalid codes).
|
|
var invitation *models.WorkspaceInvitation
|
|
if input.InvitationCode != "" {
|
|
inv, err := s.store.GetInvitationByCode(input.InvitationCode)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to validate invitation")
|
|
return
|
|
}
|
|
if inv == nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_invitation", "Invalid or expired invitation code")
|
|
return
|
|
}
|
|
if inv.IsExpired() {
|
|
// Distinct status from the "not found" case so the UI can show
|
|
// a useful message ("ask the inviter to send a new one") rather
|
|
// than a generic retry-the-code prompt.
|
|
writeError(w, http.StatusGone, "expired", "This invitation has expired. Ask the inviter to send a new one.")
|
|
return
|
|
}
|
|
// An invitation is bound to the email it was sent to. If the signup
|
|
// form supplies a different address, the attacker probably intercepted
|
|
// the link — reject before creating the account. Use the same lowercase,
|
|
// byte-exact comparison as authenticated acceptance and invite storage.
|
|
if !invitationEmailMatches(input.Email, inv.Email) {
|
|
writeError(w, http.StatusForbidden, "invitation_email_mismatch",
|
|
"This invitation was sent to a different email address. Sign in or register with the invited address.")
|
|
return
|
|
}
|
|
invitation = inv
|
|
}
|
|
|
|
if count == 0 {
|
|
writeError(w, http.StatusForbidden, "forbidden", "This Pad instance must be initialized with pad auth setup")
|
|
return
|
|
}
|
|
|
|
// When users exist, allow registration if:
|
|
// 1. The requester is an admin, OR
|
|
// 2. A valid invitation code was provided, OR
|
|
// 3. Cloud self-serve signup (PLAN-1933 DR-6): on Pad Cloud, when the
|
|
// instance can actually deliver a verification email (sender wired +
|
|
// usable public base URL), anyone may register. The created account
|
|
// starts UNVERIFIED and must confirm via the emailed link before it
|
|
// can mutate anything (RequireVerifiedEmail, Wave 3a). Self-hosted and
|
|
// email-unconfigured cloud stay locked to admin/invitation only.
|
|
//
|
|
// selfServe is the ONLY path that creates an unverified user (DR-3): it
|
|
// flips UserCreate.Unverified below. Admin-created and invited signups
|
|
// leave it false, inheriting the verified default — so a missed branch
|
|
// fails SAFE (verified), never write-locked.
|
|
selfServe := false
|
|
adminCreated := false
|
|
reqUser := currentUser(r)
|
|
isAdmin := reqUser != nil && reqUser.Role == "admin"
|
|
// inviteIsAdmission: the invitation is what let this signup in, i.e. it
|
|
// would have been refused without one. Only then does an invitation that
|
|
// fails at its claim take the account with it; where registration is open
|
|
// anyway, the account is kept and the invitation reported not applied
|
|
// (BUG-3438, lead ruling).
|
|
inviteIsAdmission := invitation != nil &&
|
|
!(isAdmin && (!isAPITokenAuth(r) || s.adminAcceptsAPITokens())) &&
|
|
!(s.cloudMode && s.emailConfigured())
|
|
if invitation == nil {
|
|
switch {
|
|
case isAdmin && isAPITokenAuth(r) && !s.adminAcceptsAPITokens():
|
|
// Creating an account is platform administration (BUG-3361):
|
|
// with a PAT, it was the first step of minting a second admin.
|
|
writeError(w, http.StatusForbidden, "session_required",
|
|
"Creating an account requires an interactive session, not an API token")
|
|
return
|
|
case isAdmin:
|
|
// Admin-created account — stays verified.
|
|
adminCreated = true
|
|
case s.cloudMode && s.emailConfigured():
|
|
selfServe = true
|
|
default:
|
|
writeError(w, http.StatusForbidden, "forbidden", "Registration is restricted")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check for duplicate email
|
|
existing, err := s.store.GetUserByEmail(input.Email)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check email")
|
|
return
|
|
}
|
|
if existing != nil {
|
|
writeError(w, http.StatusConflict, "conflict", "A user with this email already exists")
|
|
return
|
|
}
|
|
|
|
// Username: validate if provided, auto-generate if not
|
|
if input.Username != "" {
|
|
if err := ValidateUsername(input.Username); err != nil {
|
|
writeError(w, http.StatusBadRequest, "validation_error", err.Error())
|
|
return
|
|
}
|
|
existingUser, err := s.store.GetUserByUsername(input.Username)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check username")
|
|
return
|
|
}
|
|
if existingUser != nil {
|
|
writeError(w, http.StatusConflict, "conflict", "Username is already taken")
|
|
return
|
|
}
|
|
} else {
|
|
candidate := store.GenerateUsername(input.Name, input.Email)
|
|
unique, err := s.store.EnsureUniqueUsername(candidate)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to generate username")
|
|
return
|
|
}
|
|
input.Username = unique
|
|
}
|
|
|
|
// BUG-3098: an invitation whose workspace is at its member cap is refused
|
|
// BEFORE the account exists. It runs after the duplicate-email and username
|
|
// checks, so an existing account still gets the duplicate-email 409 rather
|
|
// than a message about the cap. The reconcile below rolls back an invited
|
|
// account whose membership is known to be absent, so refusing first is what
|
|
// keeps an over-cap signup from creating anything. Advisory: a member that
|
|
// lands after this check is refused by AddWorkspaceMember below, whose
|
|
// "absent" branch rolls the account back.
|
|
if invitation != nil && !s.checkMemberLimitForAccept(w, r, invitation.WorkspaceID) {
|
|
return
|
|
}
|
|
|
|
// Create user. Only the cloud self-serve branch starts UNVERIFIED (DR-3);
|
|
// admin-created and invited signups inherit the verified default.
|
|
// BUG-3348: an invitation code does not prove the address — the inviter
|
|
// is handed the same code in the invite response. Where the instance can
|
|
// prove an address (cloud with a deliverable verification email), an
|
|
// invited signup starts unverified and confirms by the emailed link, like
|
|
// self-serve. Elsewhere every account is verified, as before.
|
|
needsVerification := selfServe || (invitation != nil && s.cloudMode && s.emailConfigured())
|
|
|
|
user, err := s.store.CreateUser(models.UserCreate{
|
|
Email: input.Email,
|
|
Username: input.Username,
|
|
Name: input.Name,
|
|
Password: input.Password,
|
|
Role: "member",
|
|
Unverified: needsVerification,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create user")
|
|
return
|
|
}
|
|
|
|
// TASK-3352: an invited signup that came through the invitation EMAIL
|
|
// carries its mailbox-only proof, which proves the address the code
|
|
// cannot. Spending it verifies the new account, so no separate
|
|
// verification email is needed. A wrong or spent proof changes nothing:
|
|
// the signup proceeds exactly as it would without one.
|
|
if needsVerification && invitation != nil && input.InvitationProof != "" {
|
|
ok, perr := s.store.ConsumeInvitationProof(invitation.ID, user.ID, strings.TrimSpace(input.InvitationProof))
|
|
if perr != nil {
|
|
// NOT counted as spent, even though a lost commit acknowledgement
|
|
// could have spent it. Restoring a proof the caller merely SENT
|
|
// would install an unproven secret on any pending invitation whose
|
|
// proof was already empty (codex r4). The residual is the safe
|
|
// direction: a refused signup after such an error leaves the proof
|
|
// spent, and the retry is verified by the email link instead.
|
|
slog.Error("invitation proof: consume failed; continuing unverified", "error", perr, "user_id", user.ID)
|
|
} else if ok {
|
|
needsVerification = false
|
|
if fresh, ferr := s.store.GetUser(user.ID); ferr == nil && fresh != nil {
|
|
user = fresh
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cloud self-serve or invited signup: mint + send the email-verification
|
|
// link. needsVerification is only true when emailConfigured() holds
|
|
// (sender wired + usable base URL), so the link is deliverable.
|
|
//
|
|
// Token creation is REQUIRED to complete signup (invariant: never leave a
|
|
// user who can't verify). If minting the token fails we roll the user back
|
|
// and 500 — otherwise the duplicate-email 409 would block a retry and the
|
|
// account would be write-locked with no link. Only the async SEND is
|
|
// best-effort: a send failure keeps the account (the token exists) and the
|
|
// user recovers via POST /auth/resend-verification.
|
|
//
|
|
// It runs BEFORE the invitation's membership and accept writes (BUG-3348
|
|
// codex r2): once a membership row exists a bare user delete cannot roll the
|
|
// account back, and the invitation path's own early returns would skip
|
|
// a later send, stranding an unverified account with no link. A rollback
|
|
// further down can leave an already-sent link pointing at a deleted
|
|
// account, which is inert.
|
|
if needsVerification {
|
|
vtoken, verr := s.store.CreateEmailVerification(user.ID)
|
|
if verr != nil {
|
|
slog.Error("failed to create email verification token; rolling back signup", "error", verr, "user_id", user.ID)
|
|
if derr := s.rollbackSignup(user.ID); derr != nil {
|
|
slog.Error("failed to roll back user after verification-token error", "error", derr, "user_id", user.ID)
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start email verification")
|
|
return
|
|
}
|
|
verifyURL := s.baseURL + "/verify-email/" + vtoken
|
|
toEmail, toName := user.Email, user.Name
|
|
s.goAsync(func() {
|
|
if err := s.email.SendEmailVerification(context.Background(), toEmail, toName, verifyURL); err != nil {
|
|
slog.Error("failed to send verification email", "error", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// If registering via invitation, automatically add the user to the
|
|
// workspace and mark the invitation as accepted.
|
|
// The role the account holds in the invited workspace, as the claim
|
|
// reports it: an existing membership keeps its own (codex r5).
|
|
joinedRole := ""
|
|
// notJoined is set when the signup is kept but its invitation was not
|
|
// applied: open registration only (inviteIsAdmission false).
|
|
var notJoined *invitationNotJoined
|
|
if invitation != nil {
|
|
if s.registerInvitationPreClaimHook != nil {
|
|
s.registerInvitationPreClaimHook(invitation.ID)
|
|
}
|
|
// THE MEMBERSHIP WRITE IS FATAL (BUG-2715), AND IT CLAIMS THE
|
|
// INVITATION IN THE SAME TRANSACTION (BUG-3438).
|
|
//
|
|
// Refusing the registration leaves the invitation OPEN, which is the
|
|
// recoverable state: the user retries with the same code. A failure
|
|
// that burned the code while leaving the user with no access needed an
|
|
// admin to undo.
|
|
//
|
|
// The invitation was read before the account was created, and it can
|
|
// be declined, cancelled or replaced since. The claim (accepted_at on a
|
|
// row still pending) commits with the membership, and a claim that
|
|
// finds no row rolls the membership back and answers ErrInvitationGone:
|
|
// the account is removed too, so the retry this refusal permits exists
|
|
// — when the invitation was the admission. Where registration is open
|
|
// anyway the account is kept and the invitation reported not applied.
|
|
// This is the store call the two accept doors make
|
|
// (acceptInvitationCore), so the three doors agree.
|
|
_, heldRole, addErr := s.store.AcceptWorkspaceInvitation(invitation.ID, invitation.WorkspaceID, user.ID, invitation.Role, s.workspaceLimitMintOpts()...)
|
|
if errors.Is(addErr, store.ErrInvitationGone) && !inviteIsAdmission {
|
|
slog.Info("invitation signup: the invitation was gone at its claim; registration is open, so the "+
|
|
"account is kept and joins nothing",
|
|
"workspace_id", invitation.WorkspaceID, "user_id", user.ID, "invitation_id", invitation.ID)
|
|
notJoined = &invitationNotJoined{Code: "invitation_gone",
|
|
Message: "This invitation is no longer valid. Your account was created, but you were not added to the workspace."}
|
|
addErr = nil
|
|
} else if errors.Is(addErr, store.ErrInviterScopeTooNarrow) && !inviteIsAdmission {
|
|
// The inviter has been restricted since (BUG-3634). Registration
|
|
// is open, so the account stands and joins nothing, as above.
|
|
slog.Info("invitation signup: the inviter is now restricted; registration is open, so the "+
|
|
"account is kept and joins nothing",
|
|
"workspace_id", invitation.WorkspaceID, "user_id", user.ID, "invitation_id", invitation.ID)
|
|
notJoined = &invitationNotJoined{Code: inviterScopeChangedCode,
|
|
Message: "Your account was created, but you were not added to the workspace. " + inviterScopeChangedMessage}
|
|
addErr = nil
|
|
} else if errors.Is(addErr, store.ErrInviterScopeTooNarrow) {
|
|
// The invitation was the admission, so the account goes with it,
|
|
// as for a gone invitation below.
|
|
slog.Info("invitation signup: the inviter is now restricted; rolling back the account",
|
|
"workspace_id", invitation.WorkspaceID, "user_id", user.ID, "invitation_id", invitation.ID)
|
|
if derr := s.rollbackSignup(user.ID); derr != nil {
|
|
slog.Error("invitation signup: failed to roll back the account; the email is held by an "+
|
|
"account with no workspace access",
|
|
"user_id", user.ID, "error", derr)
|
|
writeInternalError(w, derr)
|
|
return
|
|
}
|
|
writeError(w, http.StatusForbidden, inviterScopeChangedCode, inviterScopeChangedMessage)
|
|
return
|
|
} else if errors.Is(addErr, store.ErrInvitationGone) {
|
|
slog.Info("invitation signup: the invitation was gone at its claim; rolling back the account",
|
|
"workspace_id", invitation.WorkspaceID, "user_id", user.ID, "invitation_id", invitation.ID)
|
|
if derr := s.rollbackSignup(user.ID); derr != nil {
|
|
// Not rolled back: the account still holds the email, so the
|
|
// rolled-back 404 would be false (codex r9).
|
|
slog.Error("invitation signup: failed to roll back the account; the email is held by an "+
|
|
"account with no workspace access",
|
|
"user_id", user.ID, "error", derr)
|
|
writeInternalError(w, derr)
|
|
return
|
|
}
|
|
writeError(w, http.StatusNotFound, "not_found", "Invitation not found or already accepted")
|
|
return
|
|
}
|
|
if notJoined == nil {
|
|
joinedRole = heldRole
|
|
}
|
|
if addErr != nil {
|
|
// RECONCILE BEFORE DESTROYING — here too (codex round 2).
|
|
//
|
|
// Round 1 added an account-delete rollback here and got the ORDER of
|
|
// reasoning wrong: the store returns the raw tx.Commit() error, so a
|
|
// lost acknowledgement LANDS THE MEMBERSHIP (and, since BUG-3438, the
|
|
// claim in the same transaction) and reports failure. Deleting the account on that error destroys a signup that
|
|
// actually succeeded — the BUG-3026 shape, reintroduced forty lines
|
|
// from the helper written to prevent it, by a fix for a different
|
|
// finding in the same unit.
|
|
//
|
|
// So ask what the row says before touching anything.
|
|
// Through the same seam the helper uses, so both arms of this
|
|
// reconcile are reachable from a test. A branch that decides
|
|
// whether to delete an account should not be the one branch no
|
|
// instrument can drive.
|
|
membershipCheck := s.store.GetWorkspaceMember
|
|
if s.membershipCheck != nil {
|
|
membershipCheck = s.membershipCheck
|
|
}
|
|
// The store returns the held role only with a COMMIT error; a claim
|
|
// it refused before committing (heldRole empty) is definitely not
|
|
// landed, so it needs no reconcile read, and a failing read must
|
|
// not turn it into a 500 (codex r9).
|
|
var member *models.WorkspaceMember
|
|
var cerr error
|
|
refusedBeforeCommit := heldRole == ""
|
|
if !refusedBeforeCommit {
|
|
member, cerr = membershipCheck(invitation.WorkspaceID, user.ID)
|
|
}
|
|
switch {
|
|
case cerr != nil:
|
|
// UNREADABLE — cannot tell an ack-lost success from a genuine
|
|
// failure. KEEP the account: deleting on an unknown state is the
|
|
// unrecoverable direction, and an account whose membership may
|
|
// exist is recoverable by an admin. Still refuse, because we
|
|
// cannot claim the signup worked.
|
|
slog.Error("invitation signup: the membership write failed and the membership read also failed; "+
|
|
"KEEPING the account because its state is unknown",
|
|
"workspace_id", invitation.WorkspaceID, "user_id", user.ID,
|
|
"error", addErr, "check_error", cerr)
|
|
writeInternalError(w, addErr)
|
|
return
|
|
case member != nil:
|
|
// The membership alone does not prove THIS transaction landed:
|
|
// it also claimed the invitation, and a membership can come from
|
|
// elsewhere (an admin's add in the window). So the claim decides,
|
|
// as it does at the accept doors (codex r1).
|
|
stored, ierr := s.store.GetInvitation(invitation.ID)
|
|
if ierr == nil && stored != nil && stored.AcceptedAt != nil {
|
|
joinedRole = member.Role
|
|
// LANDED despite the reported error: the user HAS access and
|
|
// the signup succeeded; refusing would delete a working account.
|
|
slog.Warn("invitation signup: the claim reported an error but the membership and the "+
|
|
"claim are present; reconciled to success",
|
|
"workspace_id", invitation.WorkspaceID, "user_id", user.ID, "error", addErr)
|
|
break
|
|
}
|
|
// Not this signup's join. A membership exists, so the account is
|
|
// KEPT (deleting it cannot be undone, and it has access through
|
|
// whatever added it), and the signup is refused.
|
|
slog.Error("invitation signup: the claim failed beside a membership it did not write; "+
|
|
"KEEPING the account and refusing the signup",
|
|
"workspace_id", invitation.WorkspaceID, "user_id", user.ID,
|
|
"error", addErr, "check_error", ierr)
|
|
if s.writeStoreMemberLimitError(w, r, invitation.WorkspaceID, addErr) {
|
|
return
|
|
}
|
|
writeInternalError(w, addErr)
|
|
return
|
|
case !inviteIsAdmission:
|
|
// ABSENT, on open registration: the account did not need the
|
|
// invitation, so it is KEPT and joins nothing; the invitation
|
|
// stays pending, and the account can accept it from its own
|
|
// list once whatever refused it clears (lead ruling).
|
|
slog.Warn("invitation signup: the claim was refused; registration is open, so the account is "+
|
|
"kept and joins nothing",
|
|
"workspace_id", invitation.WorkspaceID, "user_id", user.ID, "error", addErr)
|
|
notJoined = &invitationNotJoined{Code: "invitation_not_applied",
|
|
Message: "Your account was created, but the invitation could not be applied, so you were not added to the workspace."}
|
|
default:
|
|
// ABSENT — no membership. Refuse, and roll the account back so
|
|
// the retry this refusal exists to permit actually exists:
|
|
// leaving the invitation open is only half a recoverable state,
|
|
// because the account holds the email and the retry would meet a
|
|
// duplicate-email conflict. Same precedent as the
|
|
// verification-token rollback above.
|
|
slog.Error("invitation signup: member was not added; rolling back the account and leaving the "+
|
|
"invitation redeemable",
|
|
"workspace_id", invitation.WorkspaceID, "user_id", user.ID, "error", addErr)
|
|
if derr := s.rollbackSignup(user.ID); derr != nil {
|
|
slog.Error("invitation signup: failed to roll back the account; the email is held by an "+
|
|
"account with no workspace access",
|
|
"user_id", user.ID, "error", derr)
|
|
writeInternalError(w, derr)
|
|
return
|
|
}
|
|
// BUG-3098: a member cap reached after the pre-check is a
|
|
// refusal the invitee can act on, not a server fault.
|
|
if s.writeStoreMemberLimitError(w, r, invitation.WorkspaceID, addErr) {
|
|
return
|
|
}
|
|
writeInternalError(w, addErr)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// An admin creating someone else's account is not signing in as them, so
|
|
// that path mints no session (BUG-3232). It used to: a live session for
|
|
// the NEW user, its token returned to the admin and, for a cookie caller,
|
|
// set as the admin's cookie. Nobody held it; the new user signs in on
|
|
// their own. The invitation and self-serve paths are the new user signing
|
|
// up, and keep their session.
|
|
if adminCreated {
|
|
s.logAuditEventForUser(models.ActionRegister, r, user.ID, auditMeta(map[string]string{"email": user.Email}))
|
|
s.autoCreateWorkspace(user)
|
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
|
"user": sessionUserPayload(user),
|
|
})
|
|
return
|
|
}
|
|
|
|
token, err := s.createAuthSession(w, r, user, webSessionTTL)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
s.logAuditEventForUser(models.ActionRegister, r, user.ID, auditMeta(map[string]string{"email": user.Email}))
|
|
// A self-serve or invited signup, now signed in and kept: every rollback
|
|
// is above. The admin-created branch returned earlier and is not a
|
|
// signup; OAuth signups are counted by pad-cloud (TASK-3926).
|
|
s.recordSignup(r, "email")
|
|
|
|
// Auto-create default workspace in cloud mode
|
|
created := s.autoCreateWorkspace(user)
|
|
|
|
resp := map[string]interface{}{
|
|
"user": sessionUserPayload(user),
|
|
"token": token,
|
|
}
|
|
// Where a first signup lands (TASK-3595): inside the workspace just made
|
|
// for it, not the console's list. Additive; absent off cloud and when the
|
|
// auto-create did not produce a usable workspace. An accepted invitation
|
|
// below still wins on the client, as an explicit ?redirect= does.
|
|
if p := createdWorkspacePath(user, created); p != "" {
|
|
resp["workspace_path"] = p
|
|
}
|
|
// A signup with an invitation code has already joined the workspace, so
|
|
// the client can land IN it, as an accept does (BUG-3284, PLAN-3002 Q5).
|
|
// Additive; absent without an invitation.
|
|
if notJoined != nil {
|
|
resp["invitation_not_joined"] = notJoined
|
|
} else if invitation != nil {
|
|
if joinedRole == "" {
|
|
joinedRole = invitation.Role
|
|
}
|
|
resp["accepted_invitation"] = s.invitationAcceptedFields(invitation, joinedRole)
|
|
}
|
|
writeJSON(w, http.StatusCreated, resp)
|
|
}
|
|
|
|
// handleLogin validates email/password and creates a session.
|
|
// refuseCrossSiteSignIn guards the sign-in doors CSRF exempts (BUG-3350).
|
|
// Without it, a cross-site text/plain form could post a JSON-shaped body and
|
|
// sign the victim's browser into the ATTACKER's account (login CSRF). A
|
|
// sign-in must be application/json, which a cross-site form cannot send
|
|
// without a CORS preflight, and when the browser names an Origin it must be
|
|
// this server or an allowed CORS origin. Headless clients (curl, the CLI)
|
|
// send no Origin and keep working. Reports whether it refused.
|
|
func (s *Server) refuseCrossSiteSignIn(w http.ResponseWriter, r *http.Request) bool {
|
|
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
|
if err != nil || mediaType != "application/json" {
|
|
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media_type", "Sign-in requires a JSON request body")
|
|
return true
|
|
}
|
|
if origin := r.Header.Get("Origin"); origin != "" && !s.signInOriginAllowed(origin, r) {
|
|
writeError(w, http.StatusForbidden, "forbidden", "Cross-site sign-in is not allowed")
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// signInOriginAllowed accepts this server's own origin (the request's own
|
|
// scheme and Host, or the configured base URL) and the configured CORS
|
|
// origins, including the localhost defaults the dev server relies on.
|
|
// Everything is compared as parsed, lower-cased components (scheme, host
|
|
// name, effective port), never as string prefixes. An origin carrying
|
|
// userinfo, a path, a query or a fragment is not a browser's serialized
|
|
// Origin and is refused. A pattern ending ":*" allows any port, as
|
|
// parseCORSOrigins writes them, matching go-chi/cors.
|
|
func (s *Server) signInOriginAllowed(origin string, r *http.Request) bool {
|
|
o, ok := parseOriginParts(origin)
|
|
if !ok {
|
|
return false
|
|
}
|
|
reqScheme := cliAuthScheme(r, s.trustedProxyCIDRs)
|
|
if self, ok := parseOriginParts(reqScheme + "://" + r.Host); ok && o.same(self) {
|
|
return true
|
|
}
|
|
// TLS terminated upstream with no trusted proxy configured: the request
|
|
// reads as http, but the browser's page is https on the same host. An
|
|
// https Origin for an http request is never a downgrade; the reverse
|
|
// (an http page posting to an https server) stays refused.
|
|
if reqScheme == "http" && o.scheme == "https" && !strings.Contains(r.Host, ":") {
|
|
if self, ok := parseOriginParts("https://" + r.Host); ok && o.same(self) {
|
|
return true
|
|
}
|
|
}
|
|
if s.baseURL != "" {
|
|
if base, ok := parseOriginParts(s.baseURL); ok && o.same(base) {
|
|
return true
|
|
}
|
|
}
|
|
for _, allowed := range parseCORSOrigins(s.corsOrigins) {
|
|
pattern := strings.TrimSpace(allowed)
|
|
anyPort := strings.HasSuffix(pattern, ":*")
|
|
p, ok := parseOriginParts(strings.TrimSuffix(pattern, ":*"))
|
|
if !ok {
|
|
continue
|
|
}
|
|
if o.scheme == p.scheme && o.host == p.host && (anyPort || o.port == p.port) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type originParts struct{ scheme, host, port string }
|
|
|
|
func (a originParts) same(b originParts) bool { return a == b }
|
|
|
|
// parseOriginParts splits a serialized origin into lower-cased scheme, host
|
|
// name and effective port (the scheme's default when none is written).
|
|
func parseOriginParts(raw string) (originParts, bool) {
|
|
u, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil || u.User != nil || u.Host == "" || u.Opaque != "" ||
|
|
(u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
|
|
return originParts{}, false
|
|
}
|
|
scheme := strings.ToLower(u.Scheme)
|
|
if scheme != "http" && scheme != "https" {
|
|
return originParts{}, false
|
|
}
|
|
host := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".")
|
|
// The forms a browser serializes: an IP literal in canonical form
|
|
// ([0:0:0:0:0:0:0:1] is [::1]) and a port without leading zeros.
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
host = ip.String()
|
|
}
|
|
port := u.Port()
|
|
if port != "" {
|
|
n, err := strconv.Atoi(port)
|
|
if err != nil || n < 0 || n > 65535 {
|
|
return originParts{}, false
|
|
}
|
|
port = strconv.Itoa(n)
|
|
}
|
|
if port == "" {
|
|
port = map[string]string{"http": "80", "https": "443"}[scheme]
|
|
}
|
|
return originParts{scheme: scheme, host: host, port: port}, host != ""
|
|
}
|
|
|
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
if s.refuseCrossSiteSignIn(w, r) {
|
|
return
|
|
}
|
|
// If no users exist, no login needed
|
|
count, err := s.store.UserCount()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check user count")
|
|
return
|
|
}
|
|
if count == 0 {
|
|
writeError(w, http.StatusConflict, "setup_required", "This Pad instance must be initialized with pad auth setup")
|
|
return
|
|
}
|
|
|
|
var input struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
// Per-email rate limit: catches credential spraying from a botnet that
|
|
// evades the per-IP limit by rotating source addresses. 10 attempts/hour
|
|
// per lowercased email address. The limiter is consumed on every attempt
|
|
// (success or failure) so an attacker can't use successful guesses as a
|
|
// "reset" — but a legitimate user who remembers their password on try 1
|
|
// or 2 will never notice the limit.
|
|
//
|
|
// Only create a bucket for syntactically plausible emails: the
|
|
// pre-filter keeps over-long and '@'-less junk out of the map (RFC 5321
|
|
// max length 254, at least an '@'). It does not bound the map, since
|
|
// any "a@b" passes; the limiter's own cap does (BUG-3308), and its
|
|
// eviction order keeps a sprayed address's drained bucket to the last.
|
|
// Invalid input still gets the ordinary 401 from the password check
|
|
// below, just without producing a new limiter entry.
|
|
if s.rateLimiters != nil && s.rateLimiters.AuthEmail != nil {
|
|
emailKey := strings.ToLower(strings.TrimSpace(input.Email))
|
|
if isPlausibleEmail(emailKey) {
|
|
if !s.rateLimiters.AuthEmail.allow(emailKey) {
|
|
slog.Warn("rate limited", "email", emailKey, "limiter", "auth_email")
|
|
// Audit even the blocked attempt so an admin can see the
|
|
// sprayed account in the log.
|
|
s.logAuditEvent(models.ActionLoginFailed, r, auditMeta(map[string]string{
|
|
"email": input.Email,
|
|
"reason": "email_rate_limited",
|
|
}))
|
|
writeRateLimitResponse(w, s.rateLimiters.AuthEmail.config)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
user, err := s.store.ValidatePassword(input.Email, input.Password)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Authentication failed")
|
|
return
|
|
}
|
|
if user == nil {
|
|
// Slow down brute force attempts
|
|
time.Sleep(500 * time.Millisecond)
|
|
s.logAuditEvent(models.ActionLoginFailed, r, auditMeta(map[string]string{"email": input.Email}))
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid email or password")
|
|
return
|
|
}
|
|
|
|
// Reject disabled accounts
|
|
if user.IsDisabled() {
|
|
writeError(w, http.StatusForbidden, "account_disabled", "Your account has been disabled. Contact an administrator.")
|
|
return
|
|
}
|
|
|
|
// If 2FA is enabled, return a challenge token instead of a full session.
|
|
// The challenge token is HMAC-signed, IP-bound, and expires in 5 minutes.
|
|
if user.TOTPEnabled {
|
|
challenge := generateTwoFAChallengeAt(user.ID, clientIP(r), user.CredentialEpoch, s.twoFAChallengeSecret)
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"requires_2fa": true,
|
|
"challenge_token": challenge,
|
|
})
|
|
return
|
|
}
|
|
|
|
token, err := s.createAuthSession(w, r, user, webSessionTTL)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
s.logAuditEventForUser(models.ActionLogin, r, user.ID, auditMeta(map[string]string{"email": user.Email}))
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"user": sessionUserPayload(user),
|
|
"token": token,
|
|
})
|
|
}
|
|
|
|
// handleSessionCheck returns current auth status.
|
|
func (s *Server) handleSessionCheck(w http.ResponseWriter, r *http.Request) {
|
|
// Check if any users exist
|
|
count, err := s.store.UserCount()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check user count")
|
|
return
|
|
}
|
|
|
|
// No users → needs setup (first-time experience). Self-host
|
|
// surfaces a more specific setup_method when the operator has
|
|
// chosen one of the homelab-friendly bootstrap modes:
|
|
//
|
|
// - PAD_BYPASS_SETUP_TOKEN=true → "open" (form works directly,
|
|
// no token required). Checked first because bypass is the
|
|
// more deliberate operator opt-in; if both are configured the
|
|
// open path is the relevant one for the user.
|
|
// - bootstrap-token loaded → "logs_token" (paste the token from
|
|
// `docker logs`).
|
|
// - neither → "local_cli" (run `pad auth setup` on the host).
|
|
//
|
|
// Cloud mode never advertises "open" or "logs_token" (D10), so it
|
|
// falls through to local_cli regardless of env-var state.
|
|
if count == 0 {
|
|
method := setupMethodLocalCLI
|
|
if s.openBootstrapEnabled() {
|
|
method = setupMethodOpen
|
|
} else if !s.cloudMode && s.hasBootstrapToken() {
|
|
method = setupMethodLogsToken
|
|
}
|
|
writeJSON(w, http.StatusOK, s.setupStatePayload(method))
|
|
return
|
|
}
|
|
|
|
// Try to resolve user from context (set by middleware), then the session
|
|
// cookie directly (auth endpoints are exempt from middleware).
|
|
user := currentUser(r)
|
|
if user == nil {
|
|
user = s.validateSessionCookie(r)
|
|
}
|
|
if user != nil {
|
|
if user.IsDisabled() {
|
|
// Not signed in, and why (BUG-3349): the web app shows the
|
|
// disabled notice instead of a sign-in form that cannot work.
|
|
payload := s.sessionStatePayload(false, nil)
|
|
payload["account_disabled"] = true
|
|
writeJSON(w, http.StatusOK, payload)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, s.sessionStatePayload(true, user))
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, s.sessionStatePayload(false, nil))
|
|
}
|
|
|
|
// handleLogout destroys the session and clears the cookie.
|
|
// It handles both cookie-based sessions (web) and Bearer token sessions (CLI).
|
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
// TASK-3365: the sessions presented here end below, so their OWNERS' live
|
|
// connections re-check their credential now. Resolved per session, not
|
|
// taken from the request's principal: a request can carry one user's
|
|
// bearer and another's cookie, and both are deleted (codex r1 on PR2).
|
|
var loggedOut []string
|
|
defer func() {
|
|
for _, id := range loggedOut {
|
|
s.invalidateUserAccess(id)
|
|
}
|
|
}()
|
|
sessionOwner := func(token string) {
|
|
if info, err := s.store.ValidateSession(token); err == nil && info != nil && info.User != nil {
|
|
loggedOut = append(loggedOut, info.User.ID)
|
|
}
|
|
}
|
|
if cookie, err := r.Cookie(sessionCookieName(s.secureCookies)); err == nil {
|
|
sessionOwner(cookie.Value)
|
|
}
|
|
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
|
if token := strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")); strings.HasPrefix(token, "padsess_") {
|
|
sessionOwner(token)
|
|
}
|
|
}
|
|
// Revoke cookie-based session
|
|
if cookie, err := r.Cookie(sessionCookieName(s.secureCookies)); err == nil {
|
|
_ = s.store.DeleteSession(cookie.Value)
|
|
}
|
|
|
|
// Revoke Bearer session token (CLI auth uses Authorization: Bearer padsess_...)
|
|
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
|
token := strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))
|
|
if strings.HasPrefix(token, "padsess_") {
|
|
_ = s.store.DeleteSession(token)
|
|
}
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName(s.secureCookies),
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
Secure: s.secureCookies,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
|
|
// Clear CSRF cookie on logout
|
|
clearCSRFCookie(w)
|
|
|
|
s.logAuditEvent(models.ActionLogout, r, "")
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
})
|
|
}
|
|
|
|
// handleGetCurrentUser returns the full profile of the authenticated user.
|
|
func (s *Server) handleGetCurrentUser(w http.ResponseWriter, r *http.Request) {
|
|
user := currentUser(r)
|
|
if user == nil {
|
|
// Try cookie directly (auth endpoints are exempt from middleware)
|
|
user = s.validateSessionCookie(r)
|
|
}
|
|
if user == nil {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Not logged in")
|
|
return
|
|
}
|
|
|
|
resp := map[string]interface{}{
|
|
"id": user.ID,
|
|
"email": user.Email,
|
|
"username": user.Username,
|
|
"name": user.Name,
|
|
"role": user.Role,
|
|
"avatar_url": user.AvatarURL,
|
|
"totp_enabled": user.TOTPEnabled,
|
|
"password_set": user.HasPassword(),
|
|
"created_at": user.CreatedAt,
|
|
"updated_at": user.UpdatedAt,
|
|
}
|
|
|
|
// Include Stripe customer ID when present (used by pad-cloud sidecar
|
|
// to create billing portal sessions without accepting customer_id from
|
|
// the client, preventing users from accessing other users' portals).
|
|
if user.StripeCustomerID != "" {
|
|
resp["stripe_customer_id"] = user.StripeCustomerID
|
|
}
|
|
|
|
// Include linked OAuth providers (used by settings UI for link/unlink)
|
|
if providers := user.GetOAuthProviders(); len(providers) > 0 {
|
|
resp["oauth_providers"] = providers
|
|
} else {
|
|
resp["oauth_providers"] = []string{}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// handleUpdateCurrentUser updates the authenticated user's profile.
|
|
// Supports updating name and/or password. Password changes require the
|
|
// current password for verification.
|
|
func (s *Server) handleUpdateCurrentUser(w http.ResponseWriter, r *http.Request) {
|
|
user := currentUser(r)
|
|
if user == nil {
|
|
// Try cookie directly (auth endpoints are exempt from middleware)
|
|
user = s.validateSessionCookie(r)
|
|
}
|
|
if user == nil {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Not logged in")
|
|
return
|
|
}
|
|
|
|
var input struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Username *string `json:"username,omitempty"`
|
|
CurrentPassword string `json:"current_password,omitempty"`
|
|
NewPassword string `json:"new_password,omitempty"`
|
|
}
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
// A password change rotates every session and mints a new one, so it is
|
|
// an interactive-session action (BUG-3336, BUG-3349): an API token may
|
|
// still edit the profile.
|
|
if input.NewPassword != "" && isAPITokenAuth(r) {
|
|
writeError(w, http.StatusForbidden, "session_required",
|
|
"Changing your password requires an interactive session, not an API token")
|
|
return
|
|
}
|
|
|
|
// Validate name if provided
|
|
if input.Name != nil {
|
|
trimmed := strings.TrimSpace(*input.Name)
|
|
if trimmed == "" {
|
|
writeError(w, http.StatusBadRequest, "validation_error", "Name cannot be empty")
|
|
return
|
|
}
|
|
input.Name = &trimmed
|
|
}
|
|
|
|
// Validate username if provided
|
|
if input.Username != nil {
|
|
trimmed := strings.ToLower(strings.TrimSpace(*input.Username))
|
|
input.Username = &trimmed
|
|
|
|
if err := ValidateUsername(trimmed); err != nil {
|
|
writeError(w, http.StatusBadRequest, "validation_error", err.Error())
|
|
return
|
|
}
|
|
// Check uniqueness (skip if unchanged)
|
|
if trimmed != user.Username {
|
|
existing, err := s.store.GetUserByUsername(trimmed)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check username")
|
|
return
|
|
}
|
|
if existing != nil {
|
|
writeError(w, http.StatusConflict, "conflict", "Username is already taken")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate password change
|
|
var passwordEpoch int64
|
|
if input.NewPassword != "" {
|
|
if input.CurrentPassword == "" {
|
|
writeError(w, http.StatusBadRequest, "validation_error", "Current password is required to set a new password")
|
|
return
|
|
}
|
|
// Validate against the POST-UPDATE identity — if the same PATCH
|
|
// also changes name/username, a password derived from the new
|
|
// values must be penalized too. Otherwise a caller could set
|
|
// name = "Zaphod" + password = "Zaphod2026" in one request and
|
|
// slip past the context-aware check because we'd be comparing to
|
|
// the PREVIOUS name.
|
|
nameCtx := user.Name
|
|
if input.Name != nil {
|
|
nameCtx = *input.Name
|
|
}
|
|
usernameCtx := user.Username
|
|
if input.Username != nil {
|
|
usernameCtx = *input.Username
|
|
}
|
|
if err := validatePasswordStrength(input.NewPassword, user.Email, nameCtx, usernameCtx); err != nil {
|
|
writeError(w, http.StatusBadRequest, "validation_error", err.Error())
|
|
return
|
|
}
|
|
|
|
// Verify current password
|
|
valid, err := s.store.ValidatePassword(user.Email, input.CurrentPassword)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to validate password")
|
|
return
|
|
}
|
|
if valid == nil {
|
|
time.Sleep(500 * time.Millisecond) // Slow down brute force
|
|
writeError(w, http.StatusForbidden, "invalid_password", "Current password is incorrect")
|
|
return
|
|
}
|
|
passwordEpoch = valid.CredentialEpoch
|
|
}
|
|
|
|
// Build update
|
|
update := models.UserUpdate{
|
|
Name: input.Name,
|
|
Username: input.Username,
|
|
}
|
|
if input.NewPassword != "" {
|
|
update.Password = &input.NewPassword
|
|
// Fenced on the epoch the current password was checked under, so a
|
|
// claim or reset that lands in between is not overwritten (BUG-3382).
|
|
update.ExpectedEpoch = &passwordEpoch
|
|
}
|
|
|
|
updated, err := s.store.UpdateUser(user.ID, update)
|
|
if errors.Is(err, store.ErrCredentialsChanged) {
|
|
writeError(w, http.StatusUnauthorized, "credentials_changed",
|
|
"Your sign-in details changed while this request was in progress. Sign in again.")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update profile")
|
|
return
|
|
}
|
|
|
|
resp := map[string]interface{}{
|
|
"id": updated.ID,
|
|
"email": updated.Email,
|
|
"username": updated.Username,
|
|
"name": updated.Name,
|
|
"role": updated.Role,
|
|
"avatar_url": updated.AvatarURL,
|
|
"created_at": updated.CreatedAt,
|
|
"updated_at": updated.UpdatedAt,
|
|
}
|
|
if input.NewPassword != "" {
|
|
s.logAuditEvent(models.ActionPasswordChanged, r, "")
|
|
// Sign out every OTHER session — an attacker who sniffed a cookie
|
|
// before the password change shouldn't stay logged in afterwards.
|
|
// Re-issue a fresh session for the caller so they don't get
|
|
// kicked out of the tab they just changed the password in.
|
|
token, ok := s.rotateSessionsAfterCredentialChange(w, r, updated)
|
|
if !ok {
|
|
return
|
|
}
|
|
// Expose the fresh token for Bearer-only callers (CLI / API) who
|
|
// won't see the Set-Cookie header.
|
|
resp["token"] = token
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// handleForgotPassword generates a password reset token and sends it via email.
|
|
// Always returns 200 regardless of whether the email exists (prevents enumeration).
|
|
func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
|
|
var input struct {
|
|
Email string `json:"email"`
|
|
}
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
input.Email = strings.TrimSpace(input.Email)
|
|
|
|
// Always return the same response to prevent email enumeration
|
|
okResponse := map[string]interface{}{
|
|
"ok": true,
|
|
"message": "If an account with that email exists, a password reset link has been sent.",
|
|
}
|
|
|
|
if input.Email == "" || !emailRegexp.MatchString(input.Email) {
|
|
writeJSON(w, http.StatusOK, okResponse)
|
|
return
|
|
}
|
|
|
|
user, err := s.store.GetUserByEmail(input.Email)
|
|
// A bot gets the unknown-address answer (TASK-3392).
|
|
if err != nil || user == nil || user.IsApp() {
|
|
// Don't reveal whether the email exists
|
|
writeJSON(w, http.StatusOK, okResponse)
|
|
return
|
|
}
|
|
|
|
// Generate reset token
|
|
token, err := s.store.CreatePasswordReset(user.ID)
|
|
if err != nil {
|
|
slog.Error("failed to create password reset", "error", err)
|
|
writeJSON(w, http.StatusOK, okResponse)
|
|
return
|
|
}
|
|
|
|
// Send reset email
|
|
if s.email != nil && s.baseURL != "" {
|
|
resetURL := s.baseURL + "/reset-password/" + token
|
|
s.goAsync(func() {
|
|
if err := s.email.SendPasswordReset(context.Background(), user.Email, user.Name, resetURL); err != nil {
|
|
slog.Error("failed to send password reset email", "error", err)
|
|
}
|
|
})
|
|
} else if !s.cloudMode {
|
|
// Self-host with no email provider: the server log is the recovery
|
|
// channel. Emit the path an operator pastes after the base URL to
|
|
// complete the reset by hand. Gated on !cloudMode so a cloud
|
|
// deployment never writes a live reset token to its logs.
|
|
slog.Info("password reset generated (email not configured) — open this path on the server to finish",
|
|
"reset_path", "/reset-password/"+token)
|
|
} else {
|
|
slog.Info("password reset token generated (email not configured)")
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, okResponse)
|
|
}
|
|
|
|
// handleLocalReset is the self-host account-recovery escape hatch for an
|
|
// operator who is locked out — forgot the only admin password and has no
|
|
// email provider configured. It is the password-reset analogue of
|
|
// bootstrap: authorization IS proof of access to the server host,
|
|
// established by the strict loopback check, so it deliberately requires no
|
|
// session (the whole point is that the caller cannot log in).
|
|
//
|
|
// Two hard gates, both required:
|
|
//
|
|
// - NOT cloud mode. On Pad Cloud the host process must never be able to
|
|
// reset an arbitrary tenant's password; cloud always has email plus
|
|
// admin tooling, so the escape hatch is pure downside there.
|
|
// - requestIsLoopback — a direct loopback TCP connection with no proxy
|
|
// headers. A reverse proxy forwarding public traffic always sets
|
|
// X-Forwarded-For / X-Real-IP and is rejected, so this cannot be
|
|
// reached from off-box. Same invariant bootstrap relies on.
|
|
//
|
|
// POST /api/v1/auth/local-reset {email, temp_password?}
|
|
// Default: returns a single-use reset token+path the operator opens in a
|
|
// browser to choose a new password. temp_password=true instead force-sets
|
|
// a random temporary password and returns it (headless-friendly).
|
|
func (s *Server) handleLocalReset(w http.ResponseWriter, r *http.Request) {
|
|
if s.cloudMode {
|
|
writeError(w, http.StatusForbidden, "forbidden", "Local password reset is disabled in cloud mode")
|
|
return
|
|
}
|
|
if !requestIsLoopback(r) {
|
|
writeError(w, http.StatusForbidden, "forbidden", "Local password reset is only allowed from localhost on the server host")
|
|
return
|
|
}
|
|
|
|
var input struct {
|
|
Email string `json:"email"`
|
|
TempPassword bool `json:"temp_password"`
|
|
}
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
input.Email = strings.TrimSpace(input.Email)
|
|
if input.Email == "" {
|
|
writeError(w, http.StatusBadRequest, "validation_error", "Email is required")
|
|
return
|
|
}
|
|
|
|
// Unlike forgot-password, we DO reveal whether the account exists: the
|
|
// caller already holds shell-equivalent access to the host, so there is
|
|
// no enumeration boundary left to defend, and a clear "no such account"
|
|
// beats a silent no-op for an operator mid-recovery.
|
|
user, err := s.store.GetUserByEmail(input.Email)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
// A bot is no account to recover (TASK-3392).
|
|
if user == nil || user.IsApp() {
|
|
writeError(w, http.StatusNotFound, "not_found", "No account found with that email")
|
|
return
|
|
}
|
|
|
|
if input.TempPassword {
|
|
tempPassword, err := generateTempPassword()
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
pwd := tempPassword
|
|
if _, err := s.store.UpdateUser(user.ID, models.UserUpdate{Password: &pwd}); err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
// Force re-login everywhere with the new credential.
|
|
if err := s.store.DeleteUserSessions(user.ID); err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
// TASK-3365: their live connections re-check now.
|
|
s.invalidateUserAccess(user.ID)
|
|
s.logAuditEvent(models.ActionPasswordResetByAdmin, r, auditMeta(map[string]string{
|
|
"target_user_id": user.ID,
|
|
"method": "localhost_temp_password",
|
|
}))
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
"method": "temp_password",
|
|
"temp_password": tempPassword,
|
|
"email": user.Email,
|
|
})
|
|
return
|
|
}
|
|
|
|
token, err := s.store.CreatePasswordReset(user.ID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
s.logAuditEvent(models.ActionPasswordResetByAdmin, r, auditMeta(map[string]string{
|
|
"target_user_id": user.ID,
|
|
"method": "localhost_reset_link",
|
|
}))
|
|
resetPath := "/reset-password/" + token
|
|
resp := map[string]interface{}{
|
|
"ok": true,
|
|
"method": "reset_url",
|
|
"reset_path": resetPath,
|
|
"email": user.Email,
|
|
}
|
|
// The request reaches us over loopback, but the operator may need to
|
|
// open or share the link from the instance's real hostname. When the
|
|
// server knows its public base URL, hand back a ready-to-use absolute
|
|
// link so the CLI doesn't have to print a loopback-only one.
|
|
if s.baseURL != "" {
|
|
resp["reset_url"] = s.baseURL + resetPath
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// handleResetPassword validates a reset token and sets a new password.
|
|
func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
|
// A completed reset signs the browser in (below), so it is a sign-in
|
|
// door, CSRF-exempt like login: refuse a cross-site form (BUG-3680).
|
|
if s.refuseCrossSiteSignIn(w, r) {
|
|
return
|
|
}
|
|
var input struct {
|
|
Token string `json:"token"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
if input.Token == "" {
|
|
writeError(w, http.StatusBadRequest, "validation_error", "Reset token is required")
|
|
return
|
|
}
|
|
// Two-phase token handling: look up the user non-destructively so we
|
|
// can run the full identity-aware strength check (email + name +
|
|
// username) against the CURRENT password, then consume the token
|
|
// atomically only if validation passes. Failing pre-consume means a
|
|
// user who typed a weak password can just try again with the same
|
|
// reset link instead of having to request a fresh email.
|
|
preUser, err := s.store.LookupPasswordReset(input.Token)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to validate reset token")
|
|
return
|
|
}
|
|
if preUser == nil {
|
|
writeError(w, http.StatusBadRequest, "validation_error", "Reset token is invalid or expired")
|
|
return
|
|
}
|
|
if err := validatePasswordStrength(input.Password, preUser.Email, preUser.Name, preUser.Username); err != nil {
|
|
writeError(w, http.StatusBadRequest, "validation_error", err.Error())
|
|
return
|
|
}
|
|
|
|
// Atomically validate and consume the reset token (prevents race conditions)
|
|
user, err := s.store.ConsumePasswordReset(input.Token)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to validate reset token")
|
|
return
|
|
}
|
|
if user == nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_token", "Invalid or expired reset link. Please request a new one.")
|
|
return
|
|
}
|
|
|
|
// Reject disabled accounts
|
|
if user.IsDisabled() {
|
|
writeError(w, http.StatusForbidden, "account_disabled", "Your account has been disabled. Contact an administrator.")
|
|
return
|
|
}
|
|
|
|
// Update password
|
|
// Fenced on the epoch read as the token was spent (BUG-3382): a claim or
|
|
// another password change that landed since wins, and this reset writes
|
|
// nothing.
|
|
password := input.Password
|
|
update := models.UserUpdate{Password: &password, ExpectedEpoch: &user.CredentialEpoch}
|
|
updated, err := s.store.UpdateUser(user.ID, update)
|
|
if errors.Is(err, store.ErrCredentialsChanged) {
|
|
writeError(w, http.StatusConflict, "credentials_changed",
|
|
"This account's sign-in details changed after this link was opened. Request a new reset link.")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update password")
|
|
return
|
|
}
|
|
|
|
// Invalidate all existing sessions (force logout everywhere)
|
|
if err := s.store.DeleteUserSessions(user.ID); err != nil {
|
|
slog.Error("failed to invalidate sessions after password reset", "error", err)
|
|
}
|
|
// TASK-3365: their live connections re-check now.
|
|
s.invalidateUserAccess(user.ID)
|
|
|
|
// Two-factor (BUG-3322): a reset link proves the mailbox, which is the
|
|
// FIRST factor's recovery, not the second. A user with TOTP on gets the
|
|
// new password and no session, and signs in through /login, where the
|
|
// TOTP step applies. This used to mint a full session here, so access to
|
|
// the inbox alone defeated 2FA.
|
|
if user.TOTPEnabled {
|
|
s.logAuditEventForUser(models.ActionPasswordReset, r, user.ID, auditMeta(map[string]string{"email": user.Email}))
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
"requires_login": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Create a fresh session so the user is logged in, fenced on the epoch
|
|
// this reset produced (BUG-3382).
|
|
sessionToken, err := s.store.CreateSessionFenced(updated.ID, updated.CredentialEpoch, "web", clientIP(r), r.UserAgent(), webSessionTTL, time.Time{})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Password updated but failed to create session")
|
|
return
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName(s.secureCookies),
|
|
Value: sessionToken,
|
|
Path: "/",
|
|
MaxAge: int(webSessionTTL.Seconds()),
|
|
HttpOnly: true,
|
|
Secure: s.secureCookies,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
|
|
// Set CSRF cookie alongside the new session
|
|
setCSRFCookie(w, int(webSessionTTL.Seconds()), s.secureCookies)
|
|
|
|
// DeleteUserSessions above covers the reset user's rows only. A reset
|
|
// completed in a browser signed in as someone else replaces THAT
|
|
// cookie too, and orphans its row the same way a sign-in does.
|
|
s.destroyReplacedSessions(r)
|
|
|
|
s.logAuditEventForUser(models.ActionPasswordReset, r, user.ID, auditMeta(map[string]string{"email": user.Email}))
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
"user": map[string]interface{}{
|
|
"id": user.ID,
|
|
"email": user.Email,
|
|
"username": user.Username,
|
|
"name": user.Name,
|
|
"role": user.Role,
|
|
},
|
|
"token": sessionToken,
|
|
})
|
|
}
|
|
|
|
// handleVerifyEmail consumes an email-verification token and flips the owning
|
|
// user's email_verified_at to now (PLAN-1933 DR-5). It backs the link mailed
|
|
// by the cloud self-serve signup flow.
|
|
//
|
|
// Session freshness (the load-bearing invariant): currentUser is NOT cached in
|
|
// the session row — SessionAuth/TokenAuth call Store.ValidateSession on every
|
|
// request, which re-reads the user fresh from the DB via GetUser. So flipping
|
|
// email_verified_at here immediately unblocks the SAME session's subsequent
|
|
// mutating requests under RequireVerifiedEmail (Wave 3a) — no session rewrite
|
|
// needed. We also return the freshly-verified user payload so the SPA's auth
|
|
// store updates without a second round-trip.
|
|
func (s *Server) handleVerifyEmail(w http.ResponseWriter, r *http.Request) {
|
|
var input struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
input.Token = strings.TrimSpace(input.Token)
|
|
if input.Token == "" {
|
|
writeError(w, http.StatusBadRequest, "validation_error", "Verification token is required")
|
|
return
|
|
}
|
|
|
|
pending, err := s.store.LookupEmailVerification(input.Token)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to verify email")
|
|
return
|
|
}
|
|
// A disabled account does not act, here either (BUG-3349): refuse before
|
|
// the token is consumed, so it still works if the account is re-enabled.
|
|
if pending != nil && pending.IsDisabled() {
|
|
writeError(w, http.StatusForbidden, "account_disabled", "Your account has been disabled. Contact an administrator.")
|
|
return
|
|
}
|
|
// BUG-3382: the link verifies only for a request signed in as the
|
|
// account it names. Registration mails this link to the ADDRESS, and the
|
|
// address may belong to someone other than whoever registered it: a
|
|
// squatter's account, verified by the real owner's click, would keep the
|
|
// squatter's password and sessions. A clicker who is not signed in as
|
|
// that account is told to sign in, or, if it was not them, to claim the
|
|
// address (handleClaimByVerification). The token is not spent.
|
|
if pending != nil {
|
|
if cu := currentUser(r); cu == nil || cu.ID != pending.ID {
|
|
writeError2(w, http.StatusConflict, "verify_needs_session",
|
|
"Sign in to the account registered with this address to verify it. If you didn't register it, you can claim the address.",
|
|
map[string]interface{}{"email": pending.Email, "can_claim": !pending.IsEmailVerified()})
|
|
return
|
|
}
|
|
}
|
|
user, err := s.store.ConsumeEmailVerification(input.Token)
|
|
if errors.Is(err, store.ErrUserDisabled) {
|
|
writeError(w, http.StatusForbidden, "account_disabled", "Your account has been disabled. Contact an administrator.")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to verify email")
|
|
return
|
|
}
|
|
if user == nil {
|
|
// Invalid, expired, or already-used token. This is the token secret
|
|
// itself (256-bit), not an account identifier, so a distinct error is
|
|
// not an enumeration signal.
|
|
writeError(w, http.StatusBadRequest, "invalid_token",
|
|
"This verification link is invalid or has expired. Request a new one.")
|
|
return
|
|
}
|
|
|
|
s.logAuditEventForUser(models.ActionEmailVerified, r, user.ID, auditMeta(map[string]string{"email": user.Email}))
|
|
|
|
// No session rewrite is required to unblock the user: sessions do NOT
|
|
// cache the user row — TokenAuth/SessionAuth call Store.ValidateSession on
|
|
// every request, which re-reads the user fresh via GetUser (and the
|
|
// pad_/padsess_ bearer paths do the same). ConsumeEmailVerification above
|
|
// already flipped email_verified_at in the DB, so this session's very next
|
|
// mutating request reads verified=true and passes RequireVerifiedEmail. We
|
|
// return the freshly-verified user so the SPA can update its auth store
|
|
// without a second round-trip.
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
"user": sessionUserPayload(user),
|
|
})
|
|
}
|
|
|
|
// handleClaimByVerification: POST /api/v1/auth/verify-email/claim {token}.
|
|
//
|
|
// The verification link was mailed to the address, so whoever holds it owns
|
|
// the mailbox. When the account registered with that address was never
|
|
// verified, its credentials may be a squatter's (BUG-3382): the claim resets
|
|
// every one of them (store.ClaimAccountByVerification) and answers with a
|
|
// password-reset path, where the claimant sets a password and is signed in.
|
|
// The previous holder's live connections are kicked now.
|
|
func (s *Server) handleClaimByVerification(w http.ResponseWriter, r *http.Request) {
|
|
var input struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
input.Token = strings.TrimSpace(input.Token)
|
|
if input.Token == "" {
|
|
writeError(w, http.StatusBadRequest, "validation_error", "Verification token is required")
|
|
return
|
|
}
|
|
claim, err := s.store.ClaimAccountByVerification(input.Token)
|
|
if errors.Is(err, store.ErrClaimNotEligible) {
|
|
writeError(w, http.StatusBadRequest, "invalid_token",
|
|
"This link is invalid or has expired, or the address is already verified. Request a new link, or sign in.")
|
|
return
|
|
}
|
|
if errors.Is(err, store.ErrClaimNeedsSupport) {
|
|
writeError(w, http.StatusConflict, "account_claim_needs_support",
|
|
"This account has billing attached, so it can't be claimed automatically. Contact support@getpad.dev and we'll sort it out.")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
// Every session of the account is gone; its live connections end now.
|
|
s.invalidateUserAccess(claim.UserID)
|
|
s.logAuditEventForUser(models.ActionAccountClaimed, r, claim.UserID, auditMeta(map[string]string{
|
|
"stripped_workspaces": strconv.Itoa(len(claim.StrippedWorkspaces)),
|
|
"deleted_workspaces": strconv.Itoa(len(claim.DeletedWorkspaces)),
|
|
}))
|
|
|
|
resetToken, err := s.store.CreatePasswordReset(claim.UserID)
|
|
if err != nil {
|
|
// The account is claimed and safe; the claimant can still use
|
|
// forgot-password to set a password.
|
|
slog.Error("account claim: could not mint the set-password link", "error", err, "user_id", claim.UserID)
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"claimed": true,
|
|
"reset_path": "",
|
|
"stripped_workspaces": nonNilStrings(claim.StrippedWorkspaces),
|
|
"deleted_workspaces": nonNilStrings(claim.DeletedWorkspaces),
|
|
})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"claimed": true,
|
|
"reset_path": "/reset-password/" + resetToken,
|
|
"stripped_workspaces": nonNilStrings(claim.StrippedWorkspaces),
|
|
"deleted_workspaces": nonNilStrings(claim.DeletedWorkspaces),
|
|
})
|
|
}
|
|
|
|
// handleResendVerification re-sends an email-verification link for an
|
|
// UNVERIFIED account (PLAN-1933 DR-5).
|
|
//
|
|
// Enumeration-safe: it ALWAYS returns 200 with the same body whether or not a
|
|
// matching unverified account exists, so it can't be used to probe which
|
|
// emails are registered (or which are still unverified). Minting a fresh token
|
|
// invalidates any prior unused one (CreateEmailVerification burns previous
|
|
// links), so only the most recent link stays live.
|
|
func (s *Server) handleResendVerification(w http.ResponseWriter, r *http.Request) {
|
|
var input struct {
|
|
Email string `json:"email"`
|
|
}
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
input.Email = strings.TrimSpace(input.Email)
|
|
|
|
// Uniform response for every outcome (unknown email, already verified,
|
|
// send failure) — no account-existence signal leaks.
|
|
okResponse := map[string]interface{}{
|
|
"ok": true,
|
|
"message": "If your account still needs verification, a new link has been sent.",
|
|
}
|
|
|
|
if input.Email == "" || !emailRegexp.MatchString(input.Email) {
|
|
writeJSON(w, http.StatusOK, okResponse)
|
|
return
|
|
}
|
|
|
|
user, err := s.store.GetUserByEmail(input.Email)
|
|
if err != nil || user == nil || user.IsEmailVerified() || user.IsApp() {
|
|
// Unknown email, an already-verified account or a bot (TASK-3392):
|
|
// no-op, same response.
|
|
writeJSON(w, http.StatusOK, okResponse)
|
|
return
|
|
}
|
|
|
|
// Only mint + send when the instance can actually deliver the link. A
|
|
// self-hosted instance never creates unverified users, so in practice this
|
|
// is cloud-only; the emailConfigured() guard ensures we never mint a token
|
|
// whose link we can't send.
|
|
if s.emailConfigured() {
|
|
if vtoken, verr := s.store.CreateEmailVerification(user.ID); verr != nil {
|
|
slog.Error("failed to create verification token on resend", "error", verr, "user_id", user.ID)
|
|
} else {
|
|
verifyURL := s.baseURL + "/verify-email/" + vtoken
|
|
toEmail, toName := user.Email, user.Name
|
|
s.goAsync(func() {
|
|
if err := s.email.SendEmailVerification(context.Background(), toEmail, toName, verifyURL); err != nil {
|
|
slog.Error("failed to send verification email on resend", "error", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, okResponse)
|
|
}
|