mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 19:32:10 +00:00
0fd5d0cdfb
* fix(store): swap plainto_tsquery → websearch_to_tsquery for PG FTS (BUG-842)
`TestListItems_FTS_HyphenatedSearchTerm/task-five` has been failing on
every Go (PostgreSQL) CI run because `plainto_tsquery('english',
'task-five')` doesn't match the asciihword lexeme(s) the english parser
produces for an indexed `task-five-distinctive`. The result is that
every PG full-text search for hyphenated terms returns zero rows.
`websearch_to_tsquery` (Postgres 11+) is purpose-built for arbitrary
user input and tokenizes hyphenated terms the same way `to_tsvector`
does for the indexed document, so the query intersects the index
correctly. Swapped in three spots in the postgres dialect — FTSMatch,
FTSSnippet, FTSRank — and updated the caller-side comments that
referenced plainto_tsquery. SQLite path is unchanged: it goes through
items_fts MATCH with sanitizeFTSQuery, never through these methods.
* fix(server): drain background goroutines on Stop() (BUG-842)
`TestAdminBillingStats_SidecarSidecarError_DegradesToLocalOnly` (and
other server tests) have been flaking on the Go (PostgreSQL) CI runner
with `TempDir RemoveAll cleanup: directory not empty`. Root cause:
several request handlers spawned bare `go func() { ... }()` goroutines
that touched the SQLite WAL DB after the test function returned.
testServer's t.Cleanup closed the store but had no way to drain those
goroutines first, so a fire-and-forget WAL write could re-create the
`-wal`/`-shm` files between Close() and t.TempDir's RemoveAll.
Add a Server.bg sync.WaitGroup, a Server.goAsync helper that wraps a
WaitGroup-tracked goroutine, and a Server.Stop() that blocks until
every goAsync closure has finished. Convert the four known
fire-and-forget sites to goAsync:
- middleware_auth.go (TouchUserActivity)
- handlers_auth.go (password reset email)
- handlers_cloud.go (stripe_processed_events pruning)
- handlers_members.go (workspace invitation email)
Wire `srv.Stop()` into both testServer (server_test.go) and
newMetricsTestServer (metrics_auth_test.go) so cleanup order is
Stop → Close → TempDir RemoveAll. Add
TestServer_Stop_DrainsBackgroundGoroutines to pin the contract: a
goAsync goroutine must block Stop until it returns.
* fix(store): correct PG FTS hyphenation via OR-combined plainto_tsquery (BUG-842)
The previous attempt swapped plainto_tsquery → websearch_to_tsquery,
which was wrong: websearch_to_tsquery treats `-` as a NEGATION operator
(Google-style), so `task-five` becomes `task & !five` and the search
returns 0 rows for the same reason as before. This commit reverts the
swap and applies the actual fix.
PG's english parser indexes `task-five-distinctive` as
`{task-five-distinct, task, five, distinct}` — the asciihword AND its
parts. plainto_tsquery applied to the partial query `task-five`
produces `task-fiv & task & five`: the stemmed asciihword for the
PARTIAL query (`task-fiv`) is NOT in the vector, so the AND fails.
Replacing the hyphen with a space makes plainto emit `task & five`,
which DOES match — but doing that unconditionally breaks `BUG-842`-
style queries: PG indexes the `-842` suffix as a negative-number
lexeme, so `plainto_tsquery('BUG-842')` matches via `-842`, while
`plainto_tsquery('BUG 842')` searches for `842` and misses.
The fix ORs the two query variants together so the search vector is
matched against either the raw user query OR its hyphen-as-space form.
Both `task-five` (against `task-five-distinctive`) and `BUG-842`
(against `BUG-842 fix the cleanup race`) hit. Verified locally against
postgres:17-alpine via PAD_TEST_POSTGRES_URL — both 10x stress and
race-detector runs are green.
Surfaces:
- dialect.go: FTSMatch / FTSSnippet / FTSRank now consume TWO
placeholders each in the PG dialect.
- items.go: listItemsFTS PG branch + SearchItems PG branch update
args to pass (raw, sanitized) for every PG `?` placeholder.
- search.go: SearchItems main / count / facets PG branches updated
likewise. New sanitizePGFTSQuery helper alongside sanitizeFTSQuery.
- documents.go: ListDocuments PG branch updated.
Tests:
- TestListItems_FTS_HyphenatedSearchTerm extended with a `BUG-842`
case to pin the OR-combined logic — naive hyphen-stripping would
silently regress this.
- New TestSanitizePGFTSQuery unit test.
* chore: gofmt 11 files with import-order issues (BUG-842 PR cleanup)
The Go (SQLite) CI job has been failing on `main` (and every PR built
against it) because golangci-lint flags 11 files whose third-party
imports are intermixed with internal imports — the import-grouping
rule that gofmt enforces. None of these were introduced by the
BUG-842 PR; they're pre-existing on main. The PR can't go green
without this cleanup, though, so it's bundled here.
Pure mechanical change — `gofmt -w <files>` only re-orders import
groups; no logic changes. Files touched:
cmd/pad/configure.go
cmd/pad/main.go
internal/cli/format.go
internal/server/handlers_admin_invitations.go
internal/server/handlers_admin_users.go
internal/server/handlers_grants.go
internal/server/handlers_share_links.go
internal/server/handlers_stars.go
internal/server/middleware_auth.go
internal/store/store.go
internal/store/store_test.go
After this commit `gofmt -l ./cmd ./internal` returns clean.
1044 lines
34 KiB
Go
1044 lines
34 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"
|
|
)
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
}
|
|
|
|
func (s *Server) sessionStatePayload(authenticated bool, user *models.User) map[string]interface{} {
|
|
payload := map[string]interface{}{
|
|
"authenticated": authenticated,
|
|
"setup_required": false,
|
|
"auth_method": authMethodPassword,
|
|
"cloud_mode": s.cloudMode,
|
|
}
|
|
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 including session binding
|
|
// (User-Agent check). Returns the user if valid, nil otherwise. This must be
|
|
// used instead of calling ValidateSession directly to ensure binding is enforced.
|
|
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: reject if User-Agent has changed
|
|
if session.UAHash != "" && sha256hex(r.UserAgent()) != session.UAHash {
|
|
return nil
|
|
}
|
|
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.
|
|
// It is only allowed from loopback-local requests so setup must happen
|
|
// on the server host or from inside the container.
|
|
func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
if invitation == nil {
|
|
reqUser := currentUser(r)
|
|
if reqUser == nil || reqUser.Role != "admin" {
|
|
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
|
|
user, err := s.store.CreateUser(models.UserCreate{
|
|
Email: input.Email,
|
|
Username: input.Username,
|
|
Name: input.Name,
|
|
Password: input.Password,
|
|
Role: "member",
|
|
})
|
|
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)
|
|
}
|
|
|
|
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)
|
|
if count == 0 {
|
|
writeJSON(w, http.StatusOK, s.setupStatePayload(setupMethodLocalCLI))
|
|
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,
|
|
"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 {
|
|
slog.Info("password reset token generated (email not configured)")
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, okResponse)
|
|
}
|
|
|
|
// 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,
|
|
})
|
|
}
|