mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 11:52:08 +00:00
1d8b04e279
The delete-account UI must branch between a password prompt (self-host or any user with a password) and a confirm-only flow (OAuth-only users with no password). The client had no signal for this: oauth_providers is not a valid proxy since a user can have both a password and linked OAuth. Add "password_set": user.HasPassword() to the /auth/me response map and password_set?: boolean to the TS User interface. A handler test asserts the field for both a password user (true, via bootstrap) and an OAuth-only user (false, via CreateOAuthUser). Closes TASK-1957. Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
1455 lines
52 KiB
Go
1455 lines
52 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"regexp"
|
|
"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.Plan,
|
|
"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{} {
|
|
return 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": s.mcpPublicURL,
|
|
"billing_available": s.cloudMode && s.billingAvailable,
|
|
"version": s.version,
|
|
}
|
|
}
|
|
|
|
// 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 canonical URL clients paste into their MCP-capable
|
|
// agent (e.g. "https://mcp.getpad.dev"). Empty string when PAD_MCP_PUBLIC_URL
|
|
// is unset — the web UI uses presence/absence as the gate that drives the
|
|
// connect banner mode (Remote MCP vs CLI install). Always emitted, never
|
|
// omitted, so the frontend can rely on a string value.
|
|
//
|
|
// billing_available is true when PAD_BILLING_AVAILABLE=true and the
|
|
// deployment is in cloud mode. Used by the web UI to show/hide Stripe
|
|
// Checkout CTAs. TASK-800.
|
|
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": s.mcpPublicURL,
|
|
"billing_available": s.cloudMode && s.billingAvailable,
|
|
// 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,
|
|
}
|
|
if authenticated {
|
|
payload["user"] = sessionUserPayload(user)
|
|
}
|
|
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 the two easy ways an attacker could
|
|
// flood the limiter's bucket map: (1) excessively long strings, (2)
|
|
// strings with no '@' at all. 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 {
|
|
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.
|
|
//
|
|
// On DeleteUserSessions error we log and continue; on CreateSession
|
|
// error we write a 500 response and return ok=false — the caller should
|
|
// return immediately.
|
|
func (s *Server) rotateSessionsAfterCredentialChange(w http.ResponseWriter, r *http.Request, user *models.User) (string, bool) {
|
|
if err := s.store.DeleteUserSessions(user.ID); err != nil {
|
|
// Best-effort: even if deletion fails we must still mint a new
|
|
// session for the caller, but log loudly so the operator knows
|
|
// stale cookies may persist until expiry.
|
|
slog.Error("failed to invalidate sessions after credential change",
|
|
"user_id", user.ID, "error", err)
|
|
}
|
|
|
|
token, err := s.store.CreateSession(user.ID, "web", clientIP(r), r.UserAgent(), webSessionTTL)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error",
|
|
"Credentials updated but failed to refresh session. Please sign in again.")
|
|
return "", false
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName(s.secureCookies),
|
|
Value: token,
|
|
Path: "/",
|
|
MaxAge: int(webSessionTTL.Seconds()),
|
|
HttpOnly: true,
|
|
Secure: s.secureCookies,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
setCSRFCookie(w, int(webSessionTTL.Seconds()), s.secureCookies)
|
|
return token, true
|
|
}
|
|
|
|
func (s *Server) createAuthSession(w http.ResponseWriter, r *http.Request, user *models.User, ttl time.Duration) (string, error) {
|
|
token, err := s.store.CreateSession(user.ID, "web", clientIP(r), r.UserAgent(), ttl)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create session")
|
|
return "", err
|
|
}
|
|
|
|
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)
|
|
|
|
return token, nil
|
|
}
|
|
|
|
// 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) {
|
|
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)
|
|
|
|
if input.Email == "" || !emailRegexp.MatchString(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.
|
|
// 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) {
|
|
var input struct {
|
|
Email string `json:"email"`
|
|
Username string `json:"username"`
|
|
Name string `json:"name"`
|
|
Password string `json:"password"`
|
|
InvitationCode string `json:"invitation_code"`
|
|
}
|
|
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)
|
|
|
|
if input.Email == "" || !emailRegexp.MatchString(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. Case-insensitive per
|
|
// RFC 5321 §2.4 (local-parts are technically case-sensitive but mail
|
|
// providers universally normalize them; EqualFold matches the store's
|
|
// own ToLower() normalization).
|
|
if !strings.EqualFold(strings.TrimSpace(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
|
|
if invitation == nil {
|
|
reqUser := currentUser(r)
|
|
isAdmin := reqUser != nil && reqUser.Role == "admin"
|
|
switch {
|
|
case isAdmin:
|
|
// Admin-created account — stays verified.
|
|
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
|
|
}
|
|
|
|
// Create user. Only the cloud self-serve branch starts UNVERIFIED (DR-3);
|
|
// admin-created and invited signups inherit the verified default.
|
|
user, err := s.store.CreateUser(models.UserCreate{
|
|
Email: input.Email,
|
|
Username: input.Username,
|
|
Name: input.Name,
|
|
Password: input.Password,
|
|
Role: "member",
|
|
Unverified: selfServe,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create user")
|
|
return
|
|
}
|
|
|
|
// If registering via invitation, automatically add the user to the
|
|
// workspace and mark the invitation as accepted.
|
|
if invitation != nil {
|
|
_ = s.store.AddWorkspaceMember(invitation.WorkspaceID, user.ID, invitation.Role)
|
|
_ = s.store.AcceptInvitation(invitation.ID)
|
|
}
|
|
|
|
// Cloud self-serve signup: mint + send the email-verification link. The
|
|
// selfServe gate above already guaranteed emailConfigured() (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.
|
|
if selfServe {
|
|
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.store.DeleteUser(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)
|
|
}
|
|
})
|
|
}
|
|
|
|
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}))
|
|
|
|
// Auto-create default workspace in cloud mode
|
|
s.autoCreateWorkspace(user)
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
|
"user": sessionUserPayload(user),
|
|
"token": token,
|
|
})
|
|
}
|
|
|
|
// handleLogin validates email/password and creates a session.
|
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
// 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. Inserting
|
|
// every attacker-supplied string would let a distributed attacker grow
|
|
// the bucket map without bound (retention = 2h), which is a memory-DoS
|
|
// vector — so we pre-filter by RFC 5321 max length (254) and require
|
|
// at least an '@'. 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) {
|
|
limiter := s.rateLimiters.AuthEmail.getLimiter(emailKey)
|
|
if !limiter.Allow() {
|
|
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 := generateTwoFAChallenge(user.ID, clientIP(r), 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)
|
|
user := currentUser(r)
|
|
if user != nil {
|
|
writeJSON(w, http.StatusOK, s.sessionStatePayload(true, user))
|
|
return
|
|
}
|
|
|
|
// Try session cookie directly (since auth endpoints are exempt from middleware)
|
|
if user := s.validateSessionCookie(r); user != nil {
|
|
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) {
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
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
|
|
}
|
|
}
|
|
|
|
// Build update
|
|
update := models.UserUpdate{
|
|
Name: input.Name,
|
|
Username: input.Username,
|
|
}
|
|
if input.NewPassword != "" {
|
|
update.Password = &input.NewPassword
|
|
}
|
|
|
|
updated, err := s.store.UpdateUser(user.ID, update)
|
|
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)
|
|
if err != nil || user == nil {
|
|
// 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
|
|
}
|
|
if user == nil {
|
|
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
|
|
}
|
|
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) {
|
|
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
|
|
password := input.Password
|
|
update := models.UserUpdate{Password: &password}
|
|
if _, err := s.store.UpdateUser(user.ID, update); 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)
|
|
}
|
|
|
|
// Create a fresh session so the user is logged in
|
|
sessionToken, err := s.store.CreateSession(user.ID, "web", clientIP(r), r.UserAgent(), webSessionTTL)
|
|
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)
|
|
|
|
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
|
|
}
|
|
|
|
user, err := s.store.ConsumeEmailVerification(input.Token)
|
|
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),
|
|
})
|
|
}
|
|
|
|
// 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() {
|
|
// Unknown email or an already-verified account: 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)
|
|
}
|