Config-flag → email user 1 an enrolment link (D-033), with the approve/deny actions and dual control.
450 lines
16 KiB
Go
450 lines
16 KiB
Go
package recovery
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth"
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/mail"
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web"
|
|
)
|
|
|
|
// genericNotice is the ONLY answer POST /access ever gives, whether or not the
|
|
// email matched (SPEC-007 AC-4, A3): the recovery entry must not be an
|
|
// enumeration oracle.
|
|
const genericNotice = "If a record exists, we have emailed you."
|
|
|
|
// errHandled marks a decision that already wrote its own HTTP response (a
|
|
// client error such as 403/404/409/410). Callers return nil so the door does
|
|
// NOT also write a 500 on top the response.
|
|
var errHandled = errors.New("recovery: response already written")
|
|
|
|
// Users is the narrow slice of the user service the recovery flow needs.
|
|
type Users interface {
|
|
Get(ctx context.Context, id int64) (users.User, error)
|
|
ByEmail(ctx context.Context, email string) (users.User, error)
|
|
// Manager returns the chain link above id. The approval authority is the
|
|
// CHAIN (R-7): the flow walks this link upward from the requester.
|
|
Manager(ctx context.Context, id int64) (users.Optional[users.UserID], error)
|
|
}
|
|
|
|
// maxChainDepth bounds the manager-line walk (R-7) so a corrupted chain (a
|
|
// cycle) can never spin: it fails closed well before the bound.
|
|
const maxChainDepth = 64
|
|
|
|
// Requests is the recovery-request service the flow drives.
|
|
type Requests interface {
|
|
// Create opens (or reuses) a pending request. created reports whether a
|
|
// NEW row was inserted — only a new request triggers notification, so a
|
|
// repeated POST cannot spam the chain.
|
|
Create(ctx context.Context, userID int64, clientIP string) (Request, bool, error)
|
|
Get(ctx context.Context, id int64) (Request, error)
|
|
Decide(ctx context.Context, id, decidedBy int64, status Status) error
|
|
}
|
|
|
|
// Deps assembles the recovery flow's dependencies.
|
|
type Deps struct {
|
|
// Requests owns the recovery_requests table.
|
|
Requests Requests
|
|
// Users resolves the email-on-record and the chain (the approver).
|
|
Users Users
|
|
// Identities rotates the identity on approval (D-007).
|
|
Identities auth.Identities
|
|
// Mailer delivers the notifications and the enrolment link.
|
|
Mailer mail.Mailer
|
|
// CSRF issues the signed pre-session token for the anonymous /access form
|
|
// (A15).
|
|
CSRF *httpx.PreSessionCSRF
|
|
// Audit receives the request/approve/deny/rotation events (A11).
|
|
Audit audit.Audit
|
|
// BaseURL prefixes the enrolment link in the emailed recovery.
|
|
BaseURL string
|
|
// PendingTTL bounds the approvable window (default DefaultPendingTTL).
|
|
PendingTTL time.Duration
|
|
// Logger receives structured logs. Defaults to slog.Default().
|
|
Logger *slog.Logger
|
|
// Now is the clock (tests). Defaults to time.Now.
|
|
Now func() time.Time
|
|
}
|
|
|
|
// Actions is the SPEC-007 recovery flow.
|
|
type Actions struct {
|
|
d Deps
|
|
}
|
|
|
|
// New validates and builds the flow.
|
|
func New(d Deps) (*Actions, error) {
|
|
switch {
|
|
case d.Requests == nil:
|
|
return nil, errors.New("recovery: Requests is required")
|
|
case d.Users == nil:
|
|
return nil, errors.New("recovery: Users is required")
|
|
case d.Identities == nil:
|
|
return nil, errors.New("recovery: Identities is required")
|
|
case d.CSRF == nil:
|
|
return nil, errors.New("recovery: CSRF is required")
|
|
}
|
|
if d.Mailer == nil {
|
|
d.Mailer = mail.Nop{}
|
|
}
|
|
if d.Logger == nil {
|
|
d.Logger = slog.Default()
|
|
}
|
|
if d.Now == nil {
|
|
d.Now = time.Now
|
|
}
|
|
if d.PendingTTL <= 0 {
|
|
d.PendingTTL = DefaultPendingTTL
|
|
}
|
|
return &Actions{d: d}, nil
|
|
}
|
|
|
|
// ── the anonymous /access entry ─────────────────────────────────────────────
|
|
|
|
// Page renders the recovery entry (GET /access). It runs before any session,
|
|
// so the form carries the signed pre-session double-submit token (A15).
|
|
func (a *Actions) Page(w http.ResponseWriter, r *http.Request) error {
|
|
token, err := a.d.CSRF.Ensure(w, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return renderHTML(w, http.StatusOK, accessPage(token))
|
|
}
|
|
|
|
// Request handles POST /access?do=auth&action=recover. It ALWAYS answers with
|
|
// the same generic notice (AC-4): a match opens a pending request and notifies
|
|
// the chain; a miss does nothing observable.
|
|
func (a *Actions) Request(w http.ResponseWriter, r *http.Request) error {
|
|
ctx := r.Context()
|
|
email := strings.TrimSpace(r.FormValue("email"))
|
|
|
|
if email != "" {
|
|
u, err := a.d.Users.ByEmail(ctx, email)
|
|
switch {
|
|
case err == nil:
|
|
a.openRequest(ctx, u)
|
|
case errors.Is(err, users.ErrNotFound):
|
|
// Miss: deliberately indistinguishable from a hit (AC-4).
|
|
default:
|
|
return err // a real fault is a 500, not an enumeration signal
|
|
}
|
|
}
|
|
return renderHTML(w, http.StatusOK, noticePage())
|
|
}
|
|
|
|
// openRequest opens the pending request and notifies the chain and the
|
|
// employee. A reused request is NOT re-notified (no email spam). Failures are
|
|
// logged, never surfaced — the response is generic.
|
|
func (a *Actions) openRequest(ctx context.Context, requester users.User) {
|
|
req, created, err := a.d.Requests.Create(ctx, requester.ID, clientIP(ctx))
|
|
if err != nil {
|
|
if !errors.Is(err, ErrTooManyPending) {
|
|
a.d.Logger.Error("recovery: open request failed", "user_id", requester.ID, "error", err)
|
|
}
|
|
return
|
|
}
|
|
if created {
|
|
a.notify(ctx, requester, req)
|
|
}
|
|
}
|
|
|
|
// notify emails the approver (the requester's manager, the chain link above
|
|
// them) and the employee (SPEC-007 §4: "notify the approver chain + employee").
|
|
func (a *Actions) notify(ctx context.Context, requester users.User, req Request) {
|
|
if requester.Manager.Set {
|
|
if mgr, err := a.d.Users.Get(ctx, requester.Manager.Value); err == nil && mgr.Email != "" {
|
|
a.send(ctx, mail.Message{
|
|
To: mgr.Email,
|
|
Subject: "MyDay: a recovery request awaits your decision",
|
|
Body: a.approverBody(requester, req),
|
|
})
|
|
}
|
|
}
|
|
if requester.Email != "" {
|
|
a.send(ctx, mail.Message{
|
|
To: requester.Email,
|
|
Subject: "MyDay: your access recovery request",
|
|
Body: a.employeeBody(requester, req),
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── the approver actions (session-gated by the door) ────────────────────────
|
|
|
|
// Approve decides a pending request: an approver on the requester's chain
|
|
// (an ancestor, or the admin) and never the requester (AC-2/R-7). On success it
|
|
// revokes the OLD identity and every session, mints a NEW GUID and emails the
|
|
// enrolment link (AC-3, D-007).
|
|
func (a *Actions) Approve(w http.ResponseWriter, r *http.Request, actorID int64) error {
|
|
req, requester, approver, err := a.resolveDecision(w, r, actorID)
|
|
switch {
|
|
case errors.Is(err, errHandled):
|
|
return nil
|
|
case err != nil:
|
|
return err
|
|
}
|
|
ctx := r.Context()
|
|
|
|
// Claim the request ATOMICALLY first (a status-conditional UPDATE): when
|
|
// two approvers race, exactly one wins and only the winner rotates. The
|
|
// decision is audited by the store.
|
|
if err := a.d.Requests.Decide(ctx, req.ID, approver.ID, StatusApproved); err != nil {
|
|
writeError(w, http.StatusConflict, "the request is no longer pending")
|
|
return nil
|
|
}
|
|
|
|
// Revoke the OLD identity + every session and mint a NEW GUID (D-007). If
|
|
// this fails the request stays approved and audited; the operator can
|
|
// intervene — we never rotate twice for one request.
|
|
guid, err := a.d.Identities.Rotate(ctx, requester.ID)
|
|
if err != nil {
|
|
a.d.Logger.Error("recovery: approved but rotation failed",
|
|
"request", req.ID, "user_id", requester.ID, "error", err)
|
|
return err
|
|
}
|
|
|
|
a.send(ctx, mail.Message{
|
|
To: requester.Email,
|
|
Subject: "MyDay: your access recovery was approved",
|
|
Body: a.enrolmentBody(requester, guid),
|
|
})
|
|
writeJSON(w, http.StatusOK, `{"status":"approved"}`)
|
|
return nil
|
|
}
|
|
|
|
// Deny rejects a pending request. No rotation happens.
|
|
func (a *Actions) Deny(w http.ResponseWriter, r *http.Request, actorID int64) error {
|
|
req, requester, approver, err := a.resolveDecision(w, r, actorID)
|
|
switch {
|
|
case errors.Is(err, errHandled):
|
|
return nil
|
|
case err != nil:
|
|
return err
|
|
}
|
|
ctx := r.Context()
|
|
|
|
// The store's conditional UPDATE both decides the request and audits it
|
|
// (A11); a request already decided by someone else is refused.
|
|
if err := a.d.Requests.Decide(ctx, req.ID, approver.ID, StatusDenied); err != nil {
|
|
writeError(w, http.StatusConflict, "the request is no longer pending")
|
|
return nil
|
|
}
|
|
|
|
if requester.Email != "" {
|
|
a.send(ctx, mail.Message{
|
|
To: requester.Email,
|
|
Subject: "MyDay: your access recovery request was denied",
|
|
Body: "Your access recovery request was denied. Contact your manager if this was unexpected.\n",
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, `{"status":"denied"}`)
|
|
return nil
|
|
}
|
|
|
|
// resolveDecision loads and validates a decision: the request (pending, in
|
|
// window), the requester, and the approver's authority (an ancestor in the
|
|
// chain, or the admin — never the requester, R-7). A client-side rejection
|
|
// returns errHandled (the response is already written); a genuine fault returns
|
|
// the raw error so the door answers 500.
|
|
func (a *Actions) resolveDecision(w http.ResponseWriter, r *http.Request, actorID int64) (Request, users.User, users.User, error) {
|
|
ctx := r.Context()
|
|
approver, err := a.d.Users.Get(ctx, actorID)
|
|
if errors.Is(err, users.ErrNotFound) {
|
|
writeError(w, http.StatusForbidden, "forbidden")
|
|
return Request{}, users.User{}, users.User{}, errHandled
|
|
}
|
|
if err != nil {
|
|
return Request{}, users.User{}, users.User{}, err
|
|
}
|
|
|
|
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("request")), 10, 64)
|
|
if err != nil || id <= 0 {
|
|
writeError(w, http.StatusBadRequest, "bad request")
|
|
return Request{}, users.User{}, users.User{}, errHandled
|
|
}
|
|
req, err := a.d.Requests.Get(ctx, id)
|
|
if errors.Is(err, ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return Request{}, users.User{}, users.User{}, errHandled
|
|
}
|
|
if err != nil {
|
|
return Request{}, users.User{}, users.User{}, err
|
|
}
|
|
if req.Status != StatusPending {
|
|
writeError(w, http.StatusConflict, "the request is no longer pending")
|
|
return Request{}, users.User{}, users.User{}, errHandled
|
|
}
|
|
if !req.ExpiredAt(a.d.PendingTTL).After(a.d.Now().UTC()) {
|
|
_ = a.d.Requests.Decide(ctx, req.ID, 0, StatusExpired)
|
|
writeError(w, http.StatusGone, "the request has expired")
|
|
return Request{}, users.User{}, users.User{}, errHandled
|
|
}
|
|
|
|
requester, err := a.d.Users.Get(ctx, req.UserID)
|
|
if errors.Is(err, users.ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return Request{}, users.User{}, users.User{}, errHandled
|
|
}
|
|
if err != nil {
|
|
return Request{}, users.User{}, users.User{}, err
|
|
}
|
|
// AC-2 / R-7: the authority is the CHAIN — the approver must be an ancestor
|
|
// (on the requester's manager line, upward) or the admin, and never the
|
|
// requester themselves.
|
|
if !users.CanApprove(approver, requester, a.isAncestor(ctx, approver.ID, requester.ID)) {
|
|
a.record(ctx, "recovery.forbidden", requester.ID, fmt.Sprintf("request:%d actor:%d", req.ID, actorID))
|
|
writeError(w, http.StatusForbidden, "forbidden")
|
|
return Request{}, users.User{}, users.User{}, errHandled
|
|
}
|
|
return req, requester, approver, nil
|
|
}
|
|
|
|
// isAncestor reports whether ancestorID sits on requesterID's manager line,
|
|
// upward (R-7). It walks the chain, following manager_user_id links, and fails
|
|
// closed on a broken lookup, an absent link, or a cycle.
|
|
func (a *Actions) isAncestor(ctx context.Context, ancestorID, requesterID int64) bool {
|
|
if ancestorID == 0 || requesterID == 0 {
|
|
return false
|
|
}
|
|
seen := map[int64]bool{}
|
|
cur := requesterID
|
|
for depth := 0; depth < maxChainDepth; depth++ {
|
|
if seen[cur] {
|
|
return false // a cycle in the chain — fail closed
|
|
}
|
|
seen[cur] = true
|
|
mgr, err := a.d.Users.Manager(ctx, cur)
|
|
if err != nil || !mgr.Set {
|
|
return false
|
|
}
|
|
if mgr.Value == ancestorID {
|
|
return true
|
|
}
|
|
cur = mgr.Value
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ── email bodies ────────────────────────────────────────────────────────────
|
|
|
|
func (a *Actions) approverBody(requester users.User, req Request) string {
|
|
return fmt.Sprintf(`A user has requested access recovery.
|
|
|
|
User: %s
|
|
Email: %s
|
|
|
|
You are their manager. Sign in to MyDay and approve or deny request #%d.
|
|
If this request is unexpected, deny it.
|
|
|
|
%s
|
|
`, requester.DisplayName, requester.Email, req.ID, a.baseURL())
|
|
}
|
|
|
|
func (a *Actions) employeeBody(requester users.User, req Request) string {
|
|
return fmt.Sprintf(`Hello %s,
|
|
|
|
Your access recovery request (#%d) has been submitted for approval.
|
|
Your manager must approve it before a new access link is issued.
|
|
|
|
%s
|
|
`, requester.DisplayName, req.ID, a.baseURL())
|
|
}
|
|
|
|
func (a *Actions) enrolmentBody(requester users.User, guid string) string {
|
|
return fmt.Sprintf(`Hello %s,
|
|
|
|
Your access recovery was approved. Your old access link is now disabled.
|
|
|
|
Enrol your new passkey and authenticator here:
|
|
%s/%s
|
|
|
|
%s
|
|
`, requester.DisplayName, strings.TrimRight(a.baseURL(), "/"), guid, a.baseURL())
|
|
}
|
|
|
|
func (a *Actions) baseURL() string { return strings.TrimRight(a.d.BaseURL, "/") }
|
|
|
|
func (a *Actions) send(ctx context.Context, m mail.Message) {
|
|
if err := a.d.Mailer.Send(ctx, m); err != nil {
|
|
a.d.Logger.Warn("recovery: mail delivery failed", "to", m.To, "error", err)
|
|
}
|
|
}
|
|
|
|
// record writes one audit row for the flow (A11). The door has already attached
|
|
// the actor to the context for the session-gated decisions.
|
|
func (a *Actions) record(ctx context.Context, action string, subjectUser int64, detail string) {
|
|
if a.d.Audit == nil {
|
|
return
|
|
}
|
|
a.d.Audit.Record(ctx, audit.Event{
|
|
Action: action,
|
|
Subject: fmt.Sprintf("user:%d", subjectUser),
|
|
Detail: detail,
|
|
})
|
|
}
|
|
|
|
// ── rendering helpers ───────────────────────────────────────────────────────
|
|
|
|
func accessPage(token string) web.Page {
|
|
c := html.EscapeString(token)
|
|
return web.Page{
|
|
Title: "Account access",
|
|
Guest: true,
|
|
CSRF: token,
|
|
Content: web.Markup(`<h1>Account access</h1>
|
|
<p>Enter the email address on record. If we find it, your manager will be asked to approve a new access link.</p>
|
|
<form method="post" action="/access?do=auth&action=recover">
|
|
<input type="hidden" name="csrf" value="` + c + `">
|
|
<label>Email <input name="email" type="email" autocomplete="email" required></label>
|
|
<button type="submit">Request access recovery</button></form>`),
|
|
}
|
|
}
|
|
|
|
// noticePage is the generic outcome page (AC-4).
|
|
func noticePage() web.Page {
|
|
return web.Page{
|
|
Title: "Account access",
|
|
Guest: true,
|
|
Content: web.Markup(`<h1>Account access</h1>
|
|
<p>` + genericNotice + `</p>`),
|
|
}
|
|
}
|
|
|
|
func renderHTML(w http.ResponseWriter, status int, page web.Page) error {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
return web.Render(w, status, page)
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, body string) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_, _ = io.WriteString(w, body)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.WriteHeader(status)
|
|
_, _ = io.WriteString(w, msg+"\n")
|
|
}
|
|
|
|
// clientIP returns the resolved client IP the door attached to the context
|
|
// (A6) — empty when unavailable.
|
|
func clientIP(ctx context.Context) string {
|
|
if m, ok := audit.MetaFrom(ctx); ok {
|
|
return m.ClientIP
|
|
}
|
|
return ""
|
|
}
|