mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
2ed6e71ad3
* feat(store): transactional event outbox + events/1 item taxonomy (TASK-2658, SPEC-3)
Phase-0 unit 2 of PLAN-2656, store half. Events are now written to an
outbox in the SAME transaction as the mutation that produced them, so a
committed mutation cannot lose its event and a rolled-back one cannot
leak one. Nothing drains the outbox yet — behaviour is unchanged.
- migrations 081 / pgmigrations 059: event_outbox. Deliberately no FKs on
workspace_id / subject_id: an outbox row must outlive its subject, or
item.deleted cascades away exactly when it matters. Retention, not
referential integrity, bounds the table.
- internal/kernelevents: the closed events/1 name set (SPEC-3 v1.3) with
IsCanonical enforcing the closure rule at the choke point.
- store/event_outbox.go: writeOutboxTx (tx-scoped, hard-fails the
mutation rather than degrading to best-effort), the item payload shape
(snapshot EMBEDDED so query/1 predicates apply verbatim, prior_status
alongside as the envelope pseudo-field), and the drain-side primitives.
- item.created / updated / status_changed / moved / deleted / restored
emitted from inside their mutations' transactions, from in-tx snapshot
read-backs rather than caller input.
- SPEC-3 v1.3 disjoint-delta rule: canonical events partition a
mutation's delta and a mutation emits every event whose slice changed.
The seam diffs slices rather than branching on "was this a status
update" — branching drops the item.updated half of a mixed update.
- ImportWorkspace stays silent per the SPEC-3 ruling, commented at the
INSERT so it reads as a decision. insertItemTx's "every creation side
effect lives in this one place" comment corrected: it is API-path only,
and import is the counterexample two units have now been misled by.
* feat(store): comment / attachment / member events on the outbox (TASK-2658)
Completes the store half of the choke point. Same rule throughout: the
event is written on the mutation's own transaction, from an in-tx
read-back rather than caller input.
- comment.created / comment.updated. GetComment gains a Queryer form so
the emit reads through the tx: a pool read takes a different
connection and cannot see the uncommitted write, so it would return
the PRE-write row and the event would describe a state that is not the
one committing (mutation-verified).
- attachment.added, gated to user-visible originals. Variants are
attachment rows too — a thumbnail carries parent_id plus a variant tag
— so an ungated emit announces three events per image upload, two for
files no user added. Transform outputs stay admitted: no parent, and a
user did add them.
- member.joined. AddWorkspaceMember becomes transactional to carry it; a
self-committing INSERT plus a separate emit is the shape that loses
events on a crash.
item.bulk_updated is NOT here, and not by omission: bulk is a handler
loop over per-item store mutations, each already emitting canonically
from its own transaction. There is no bulk transaction to write it in,
so the batch event is delivery-side aggregation — it belongs with the
drain in TASK-2714, where SPEC-3's per-member binding evaluation is
already satisfied by the per-item rows.
* fix(store): emit item.deleted for a cross-workspace move's source archive (TASK-2658)
Self-caught during the diff review. archiveItemForCopyTx deliberately
REPRODUCES DeleteItem's UPDATE inside the copy's transaction rather than
calling it, so it did not inherit DeleteItem's new emit: a cross-workspace
move archived the source silently while an ordinary archive of the same
item announced itself. Invisible until something drains the outbox, at
which point moves would just stop being observable.
Same ordering as DeleteItem — snapshot in-tx BEFORE the UPDATE, while the
row is still live, because SPEC-3 requires the final pre-archive state.
Also amends the file's DR-14 header. DR-14 says no fanout inside the
transaction because a rollback would leak the event; an outbox row written
on the SAME transaction rolls back WITH the copy, so that rationale does
not reach it. The three things DR-14 actually names — activity row, SSE
publish, webhook — still happen post-commit at the caller, unchanged. A
documented decision should not be silently contradicted by the code.
* fix(store): compare the move's event slices against an in-tx pre-move snapshot (TASK-2658)
Codex round 1, P2 — a defect in my own round-1 code. MoveItemWithPreCheck
refreshes `existing` in-tx only on the precheck path; on the no-precheck
path it stays the PRE-LOCK pool read. The emit block compared it against
the post-move in-tx snapshot, violating a precondition documented on
itemUpdatedSliceChanged itself (both snapshots must come from getItemTx,
or rendering differences read as changes), and a stale CollectionID makes
the item.moved decision wrong outright.
Adds a dedicated `preMove` in-tx snapshot and tightens the read: it used
to tolerate a failure by silently keeping the pre-lock value, which only
degraded from_status. It now also decides which events fire, so a
degraded read is no longer an acceptable outcome — under a held lock on a
row just resolved live, an error or missing row means something is wrong.
* feat(store): item.bulk_updated for store-side bulk mutations; purge the outbox (TASK-2658)
Codex rounds 1 and 2. Two more item-mutation write paths emitted nothing,
and both are single-transaction bulk mutations, so their emits are WRITES
and belong in this unit rather than with the drain:
- collections.go: renaming a select OPTION rewrites items.fields on every
row carrying the old value.
- wiki_links.go: renaming an item rewrites the CONTENT of every item that
links to it by title.
Each emits ONE in-tx item.bulk_updated rather than per-row item.updated:
the user performed one action, and per-row fan-out is the flood TASK-1668
already decided against. Per-member snapshots keep item-level bindings
evaluable, which is what makes batching safe (SPEC-3 v1.1). Payload size
is deliberately unbounded in v1 — capping members silently drops binding
evaluation for the tail, and dropping `content` would break exactly the
bindings the wiki cascade exists for.
Also from round 2:
- Workspace purge now deletes event_outbox. It has no FK by design (a row
must outlive its subject), so nothing deleted it on the purge's behalf,
and payloads hold full item content and comment bodies — a purged
workspace's text would have stayed readable indefinitely. Added to
wsChildTables so the exhaustive-purge test covers it.
- Documented that ListPendingOutboxEvents is deliberately cross-workspace
and unauthorized, and must never be reachable from a request path.
- The two callers that discarded AddWorkspaceMember's error now log it.
Not fatal (that is BUG-2715), but this unit made the call transactional
and so gave it a new way to fail; widening a swallowed error without
making it visible is how a failure mode goes unnoticed.
* fix(store): classification correctness + dialect-neutral payload validation (TASK-2658)
Codex round 3, five findings.
A REAL SILENT-EVENT BUG in the classifier. The done-key mask ran
unconditionally, but the status machinery (extractFieldValue) only reads a
done-key value when it is a JSON STRING. So on a collection whose done
field holds a number, `{"stage":1}` → `{"stage":2}` produced NO EVENT AT
ALL: status_changed could not see it, and the mask deleted the key from
both snapshots so item.updated could not either. Now the key is masked
only when both sides hold a string there — exactly the condition under
which status_changed will describe it. When it will not, the change falls
back to item.updated's slice, where something can.
Payload JSON is now validated in Go. The column types DISAGREED: Postgres
JSONB rejects malformed JSON at the INSERT, SQLite's TEXT accepts it, so
the same bad payload failed a mutation on one backend and silently
persisted an undeliverable event on the other.
Corrected an overclaim of my own: the exclusion-list comment said a new
column is compared by default. True only of columns that reach
models.Item's JSON — last_restore_seq and the content-flush watermarks are
invisible to the diff no matter what the list says. Unreachable today
(every caller that moves them also writes content or fields), but not
structurally guaranteed, and now written down as a constraint on adding
persisted columns.
Tests: a custom done-field key (every previous classification test used
"status", so a classifier hard-coded to that key would have passed them
all), non-string and non-object blobs, malformed payload rejection, and
the bulk test now asserts member IDENTITY and the delta rather than a
count and a substring.
* fix: comment-accuracy sweep + no-op comment gate + enumerate the remaining discards (TASK-2658)
Codex round 4, aimed at the claims my own comments make. Three of them
were false or overclaiming, which is the point of pointing a review round
at your own prose.
- taxonomy.go and migration 081 described the END STATE — a drain loop, a
unified SSE/webhook vocabulary — as if it existed. Both now say plainly
that nothing drains the table, that the legacy hand-calls still fire
unchanged, and that the mapping and retirement are TASK-2714. A comment
describing the intended end state in the present tense is how a reader
concludes a feature is broken.
- The hop bound and the §L5 quota text read as running behaviour. Nothing
propagates a hop yet (no binding kernel), so every production write
leaves it 0 and the depth check is exercised only by tests. Said so,
and recorded the surfacing obligation as an obligation.
- The re-delete comment was wrong TWICE. The zero-row return exits before
the nil-snapshot guard, so that guard does not participate in re-delete
at all — it is what keeps this correct if the order or predicate ever
changes. My round-3 "correction" swapped one wrong mechanism for
another because I reasoned from a mutation result instead of the code.
Real behaviour fixes in the same round:
- A no-op comment edit no longer emits. The UPDATE matches on id alone,
so re-saving an identical body touched the row and emitted
comment.updated; the row-count check never suppressed it. Comparing the
body does, which also makes comment.updated consistent with the item
events.
- applyFieldMigrationsTx returns 0, not totalAffected, when emission
fails. Every error there rolls the caller's transaction back, so the
count described writes that never committed.
- Two MORE callers still discarded AddWorkspaceMember's error (the JSON
import and bundle import paths). Round 2 named two; I fixed those two
and did not enumerate. All nine call sites checked this time; the two
remaining discards now log.
Filed BUG-2716: the activity row commits before the comment and cannot be
reordered (the comment carries its id), so a failed comment write leaves
an orphan "commented" activity. Documented at the call site.
* fix(store): partition item.bulk_updated by the members' own workspace (TASK-2658)
Found in my own multi-tenancy probe while round 5 ran, not by the oracle.
emitBulkItemEventTx published every member under the workspace the CALLER
passed. For the collection-option rename that is right. The wiki-title
cascade is not so obviously safe: its source query selects on
target_item_id alone and carries each source row's workspace_id per-row
rather than assuming the renamed item's, so a member in another workspace
is not excluded by construction. That would have put one workspace's item
content on another workspace's webhook.
Whether it is reachable through today's queries is not the question worth
answering — "unreachable" is a property of the current query, not of this
function. Partitioning costs one map and makes it impossible.
Population, per CONVE-18: five emit helpers. Four derive the workspace
from the subject row itself (item, comment, attachment) or from the
membership being written (member.joined), so they are correct by
construction. One — bulk — took a caller-supplied id, and is fixed.
* fix(store): prior_status must be present on a transition FROM an empty status (TASK-2658)
Codex round 6, spec-conformance angle. SPEC-3 §Bindings makes prior_status
the envelope pseudo-field that lets a predicate filter "nonterminal →
terminal". An item can transition FROM no status at all — "" → "open" is a
real status change and item.status_changed fires for it — but `omitempty`
on a plain string dropped the key entirely, leaving a predicate unable to
tell "the prior status was empty" from "this event carries no prior
status".
Now a *string: nil on every event that has no prior status, and
present-and-possibly-empty on item.status_changed, where the empty value
is data. My original reasoning — that an empty string should never appear
"where a prior status is meaningless" — was right about the events where
it is meaningless and wrong about the one where it is not.
Also documents the bulk-snapshot read cost at itemSnapshotsTx rather than
leaving it to be discovered: N sequential joined reads under the caller's
lock, which roughly doubles an already-N-long hold (the migration loop it
serves already issues N sequential UPDATEs under that lock by design).
Batching it is BUG-2718; BUG-2717 covers the redundant post-commit re-read
on move and restore. Both spun off rather than folded, because each adds
an unreviewed path to a change that has been through six review rounds.
* fix(store): keep assignee name and email out of event payloads (TASK-2658)
Found in my own privacy-lifecycle probe while round 7 ran; round 7
independently reported the wider class.
An outbox payload is a frozen snapshot that outlives its subject by
design. Account deletion's de-identify pass (DeleteAccountAtomic) nulls
identity on LIVE rows so a departed user stops being legible — it cannot
reach a frozen payload. Every item event for an assigned item was
carrying the assignee's NAME AND EMAIL, and nothing drains or prunes the
table today, so those stayed readable indefinitely.
The rule applied, stated as a rule rather than a proxy: remove directly
identifying personal data, keep opaque identifiers and row state.
assigned_user_id stays — a predicate filters on it, and once the account
is gone it is a dangling reference to nobody.
Population enumerated rather than fixed one instance at a time (CONVE-18):
five payload shapes reach the outbox. Item-single and item-bulk carried
JOIN-populated name + email and are scrubbed. Comment (`author`),
attachment (`uploaded_by`) and member.joined (`user_id`) carry only their
own row's columns. Exactly one shape needed it, and what made it stand out
is that it was the only one carrying a join rather than the row.
* feat(store): comment.deleted + attachment.removed, ref-only (TASK-2658, SPEC-3 v1.4)
Round 7's privacy-lifecycle findings, resolved by adding the vocabulary
the conflict was missing rather than by deleting rows.
Without a delete marker, a hard-deleted subject's undispatched
created/updated rows were the ONLY record it ever existed — forcing a
false choice between dropping committed events (breaking the outbox
guarantee) and delivering deleted content forever. With one: the create
event still delivers, the deletion is announced REF-ONLY, and retention
prunes both. Privacy of a frozen payload is temporal, which makes the
drain load-bearing for privacy and not only for delivery (TASK-2714).
REF-ONLY is the contract, not a detail. A deletion event must not re-ship
what it deletes — the consumer needs to reconcile its model, not receive a
copy of what the user removed. Sharper for attachments, whose full
snapshot carries filename, content hash and STORAGE KEY: a locator for
bytes the system just reclaimed. Deliberately asymmetric with
item.deleted, whose subject is an archive and stays addressable.
- DeleteComment becomes transactional and emits comment.deleted. Refs are
read before the DELETE, because afterwards there is no row to read.
- ClaimSoftDeletedAttachment emits attachment.removed. The transaction
does not weaken the BUG-2415 claim protocol: the claim's conditionality
lives in the DELETE's WHERE clause, unchanged.
- ClaimNeverAttachedAttachment stays SILENT, deliberately. It reclaims
rows that were never attached to an item, and attachment.added fires
only for attachments written against a live item — so those rows never
announced their arrival, and announcing their removal would hand a
consumer a deletion for an id it has never seen. Tested as an asymmetry,
not left to inference.
- HardDeleteAttachment has no production caller; not wired.
No outbox row is ever deleted on subject death. That was my first
instinct and it was wrong: it trades a real durability guarantee for a
partial privacy one, through the privacy door.
* fix(store): make the attachment.removed gate symmetric with attachment.added (TASK-2658)
Codex round 8, and it falsified a claim I had written into the code as
verified one commit earlier.
I checked that never-attached implies never-announced — true, and the
verification stands: no path sets attachments.item_id back to NULL, and
every birth path producing a NULL item_id is non-emitting. Then I stated
the conclusion for BOTH directions, which does not follow. Rows reach
ClaimSoftDeletedAttachment having never emitted attachment.added by at
least three routes: VARIANTS (written silently because they carry a
parent, then tombstoned by their original's cascade), attachments cloned
by a cross-workspace copy, and attachments created by workspace import.
So the path announced removals for subjects no consumer had ever seen.
The emit now carries the SAME gate as attachment.added — a user-visible
original, attached to an item — so the two are symmetric by construction
rather than by argument. That closes the variant route, which is the
systematic one, and the test asserts the premise (the variant emitted
nothing on creation) before asserting the conclusion.
Residue, stated rather than papered over: an import- or copy-created
attachment still passes the gate while never having announced itself. The
failure mode is noise rather than harm — an unknown id in a delete is
ignorable, where announced-but-never-retracted would leave stale state —
and the cause is the deliberate silence of the import and copy paths.
Round 8 returned CLEAN on the ref-only payloads, the transaction wrapping
(contractually — it does broaden the SQLite writer-lock window, which is
inherent to making the delete and the emit atomic), scrubItemPII, and the
prior_status pointer.
* fix(store): derive subject_kind from the taxonomy instead of trusting the caller (TASK-2658)
Codex round 9, run explicitly as a convergence round — asked to find what
eight rounds would systematically miss rather than to re-check what they
covered. It found this, which is a fair answer to that question.
writeOutboxTx derived subject_kind only when the caller left it blank, so
a non-empty value was taken as given. subject_kind is a pure function of
the event name: a caller-supplied value can only agree with the taxonomy
or be wrong, and a wrong one persists silently and misroutes the event at
drain time — item.created stored as subject_kind "comment" would be routed
as a comment. Every existing test passed either the correct value or none,
which is exactly the blind spot that lets a defect survive review rounds
aimed elsewhere.
Now derived unconditionally. A caller that supplied a DIFFERENT kind
believes something false about the taxonomy, so that is an error rather
than a silent overwrite: correcting the row quietly would fix one write
and leave the belief in place.
* fix(store): stamp occurred_at rather than accepting it, and enumerate the rest of the class (TASK-2658)
Round 9 found that subject_kind was caller-trusted. Rather than fix the
named instance and wait for a review to name the next one (CONVE-18), I
enumerated the class: of the eight fields on OutboxEvent, event_type is
validated against the closed set, payload is validated as non-empty JSON,
hop is bounded, subject_kind is now derived, and id defaults but fails
LOUDLY on a duplicate. occurred_at was the remaining member with the same
shape of silent harm — SPEC-3 pins time-relative `within` predicates to
it, so a supplied value quietly changes how a predicate evaluates. It is
now stamped at write time; no caller sets it, and "the moment the event
was written" is the only honest value while the write is transactional
with the mutation.
That leaves workspace_id and subject_id as genuine caller inputs. Neither
is derivable, both are checked at their own call sites, and the bulk
emitter partitions by member workspace rather than trusting the one it is
handed. The enumeration is in the code so the next reader does not redo it.
* refactor(store): payload families, an honest helper name, proportionate comments (TASK-2658)
Codex round 10, run as a maintainability convergence round — read the diff
as someone who has to live with it for two years and did not write it.
Three findings, all fair.
PAYLOAD FAMILIES. The emitter helpers take an arbitrary event name and
writeOutboxTx validated only canonical MEMBERSHIP — so a caller could pair
item.created with a ref-only deletion payload and the write would be
accepted, having validated the half that was already obviously correct.
Each canonical event now declares its payload shape in the taxonomy, every
emit site declares what it marshalled, and the two are checked against each
other. The declaration is write-side only and never stored: the event name
already determines the shape, and persisting it would create a second
source of truth that could disagree with the first. A test walks the
canonical set so the two maps cannot drift.
HONEST NAME. itemSnapshotsTx is now outboxMemberSnapshotsTx, because it is
not a general "read these items" helper: it de-duplicates, silently skips
rows that no longer resolve, and scrubs assignee identity. Any of those
makes a general-purpose caller's result quietly incomplete rather than
wrong-looking, and the old name invited exactly that reuse.
PROPORTIONATE COMMENTS. Every canonical event now carries compact contract
documentation — comment.*, member.joined and pack.* had none, and pack.*
now says plainly that nothing emits it yet so a reader does not hunt for a
producer. In the other direction, three comments that had grown into
accounts of how I got something wrong are trimmed to the invariant and the
counterexample. The process belongs on the task trail and the identity
doc; the code should carry what is true.
* fix(kernelevents): one taxonomy table — round 10's family map could fail open (TASK-2658)
Codex round 11 BLOCKED on a defect round 10 introduced, which is the
review loop doing exactly what my own rule says it should: when a fix
introduces a mechanism, the mechanism needs the next round's attention
more than the original bug did.
The defect: writeOutboxTx discarded the ok from PayloadFamily. A canonical
event missing from the separate family map would resolve to the empty
family — which a caller declaring nothing then MATCHES. The check would
pass precisely when it had no idea what the answer should be, and the two
maps keyed on the same names were free to drift into that state.
Fixed structurally rather than by adding the missing ok test: subject kind
and payload family now live in ONE canonical table entry per event. A
second map is a second source of truth; co-locating makes the drift
unrepresentable instead of tested-for, and the compiler requires both
fields so a new event cannot arrive half-declared.
The fail-closed arm stays as a guard for a future table that separates
them again, and its comment says plainly that it is UNREACHABLE today —
verified by mutation: disabling it changes no test, because the mismatch
check catches every reachable case. A guard whose comment implies it is
the protection, when something else is doing the work, is the kind of
claim this unit has cost me several times.
The test now checks both directions: every canonical event resolves a
subject kind AND a family, and a non-canonical name resolves neither —
the second leg being the one that matters, since an unknown name must
report ok=false rather than an empty string a caller would match.
1462 lines
53 KiB
Go
1462 lines
53 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 {
|
|
// Not fatal (see BUG-2715), but no longer silent: TASK-2658 made
|
|
// AddWorkspaceMember transactional, so it can now fail for a reason
|
|
// unrelated to the membership row itself — and the invitation is
|
|
// consumed on the next line either way.
|
|
if err := s.store.AddWorkspaceMember(invitation.WorkspaceID, user.ID, invitation.Role); err != nil {
|
|
slog.Error("invitation accepted but member was not added",
|
|
"workspace_id", invitation.WorkspaceID, "user_id", user.ID, "error", err)
|
|
}
|
|
_ = 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)
|
|
}
|