// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package admin import ( "encoding/base32" "html/template" "net/http" "net/url" "strconv" "strings" "time" "sourcedock.dev/petrbalvin/volumen/internal/i18n" "sourcedock.dev/petrbalvin/volumen/internal/qrcode" "sourcedock.dev/petrbalvin/volumen/internal/session" "sourcedock.dev/petrbalvin/volumen/internal/totp" "sourcedock.dev/petrbalvin/volumen/internal/users" ) // The enrolment state rides the session: the candidate secret lives // there between the QR page and the verifying code, so the users file // only ever holds secrets that were proven by a working application. const ( totpEnrollKey = "totp_enroll" totpEnrollAt = "totp_enroll_at" ) // enrolWindow bounds how long a candidate secret stays answerable. const enrolWindow = 10 * time.Minute // totpURI builds the otpauth URI every application understands. func totpURI(secret, username string) string { u := url.URL{ Scheme: "otpauth", Host: "totp", Path: "/Volumen:" + username, RawQuery: url.Values{"secret": {secret}, "issuer": {"Volumen"}, "algorithm": {"SHA1"}, "digits": {"6"}, "period": {"30"}}.Encode(), } return u.String() } // fillTotpState carries the second-factor state of the signed-in // account and of an enrolment in flight onto the settings page. func (a *Admin) fillTotpState(data *PageData, r *http.Request) { record := a.deps.Users.Find(data.CurrentUser) if record != nil && record.TotpSecret != "" { data.TotpEnabled = true return } sess := session.FromContext(r.Context()) secret := sess.Get(totpEnrollKey) if secret == "" { return } started, err := strconv.ParseInt(sess.Get(totpEnrollAt), 10, 64) if err != nil || time.Since(time.Unix(started, 0)) > enrolWindow { sess.Delete(totpEnrollKey) sess.Delete(totpEnrollAt) return } data.TotpPending = true data.TotpSecret = secret data.TotpURI = totpURI(secret, data.CurrentUser) if svg, err := qrcode.SVG(data.TotpURI); err == nil { data.TotpSVG = template.HTML(svg) } } // decodeBase32Secret turns the stored candidate back into key bytes. func decodeBase32Secret(encoded string) ([]byte, error) { return base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(encoded)) } // totpOK checks a candidate secret against the code the application // shows; no replay floor applies, this is the first use. func totpOK(secret []byte, code string) bool { ok, _ := totp.Validate(secret, code, time.Now(), 0) return ok } // handleTotpStart begins enrolment: a fresh candidate secret travels to // the settings page inside the session, and nothing is stored yet. func (a *Admin) handleTotpStart(w http.ResponseWriter, r *http.Request) { if !a.requireCSRF(w, r) { return } sess := session.FromContext(r.Context()) if record := a.deps.Users.Find(sess.Get("user")); record != nil && record.TotpSecret != "" { a.renderSettings(w, r, i18n.Admin.T(a.lang(r, nil), "Two-factor authentication is already on."), "", http.StatusUnprocessableEntity) return } secret := users.GenerateTotpSecret() sess.Set(totpEnrollKey, secret) sess.Set(totpEnrollAt, strconv.FormatInt(time.Now().Unix(), 10)) http.Redirect(w, r, "/admin/settings#security", http.StatusSeeOther) } // handleTotpCancel drops an enrolment in flight. func (a *Admin) handleTotpCancel(w http.ResponseWriter, r *http.Request) { if !a.requireCSRF(w, r) { return } sess := session.FromContext(r.Context()) sess.Delete(totpEnrollKey) sess.Delete(totpEnrollAt) http.Redirect(w, r, "/admin/settings#security", http.StatusSeeOther) } // handleTotpVerify finishes enrolment: the code the application shows // proves the candidate secret, which is stored together with a fresh // set of recovery codes. The codes are shown exactly once, here. func (a *Admin) handleTotpVerify(w http.ResponseWriter, r *http.Request) { if !a.requireCSRF(w, r) { return } sess := session.FromContext(r.Context()) username := sess.Get("user") secret := sess.Get(totpEnrollKey) if secret == "" { http.Redirect(w, r, "/admin/settings#security", http.StatusSeeOther) return } code := r.PostFormValue("code") decoded, err := decodeBase32Secret(secret) if err != nil || !totpOK(decoded, code) { sess.Delete(totpEnrollKey) sess.Delete(totpEnrollAt) a.renderSettings(w, r, i18n.Admin.T(a.lang(r, nil), "That code did not match; start again."), "", http.StatusUnprocessableEntity) return } codes, hashes := users.GenerateRecoveryCodes(10) if _, err := a.deps.Users.EnableTotp(username, secret, hashes); err != nil { a.renderSettings(w, r, i18n.Admin.Tf(a.lang(r, nil), "Two-factor could not be enabled: %s", err.Error()), "", http.StatusInternalServerError) return } sess.Delete(totpEnrollKey) sess.Delete(totpEnrollAt) a.record(r, "user.totp_enabled", username, nil) data := a.settingsData(r) data.RecoveryCodes = codes data.RecoveryNotice = i18n.Admin.T(data.Lang, "Two-factor is on. Store these recovery codes now; they will not be shown again.") a.renderPage(w, r, "settings.html", data, http.StatusOK) } // handleTotpDisable turns the second factor off; possession of a // current code is the proof, so a stolen cookie alone cannot. func (a *Admin) handleTotpDisable(w http.ResponseWriter, r *http.Request) { if !a.requireCSRF(w, r) { return } sess := session.FromContext(r.Context()) username := sess.Get("user") if !a.deps.Users.VerifyTotp(username, r.PostFormValue("code"), time.Now()) { a.renderSettings(w, r, i18n.Admin.T(a.lang(r, nil), "Wrong or expired code."), "", http.StatusUnprocessableEntity) return } if _, err := a.deps.Users.ClearTotp(username); err != nil { a.renderSettings(w, r, i18n.Admin.Tf(a.lang(r, nil), "Two-factor could not be disabled: %s", err.Error()), "", http.StatusInternalServerError) return } a.record(r, "user.totp_disabled", username, nil) a.renderSettings(w, r, "", i18n.Admin.T(a.lang(r, nil), "Two-factor is off."), http.StatusOK) } // handleTotpCodes replaces the recovery codes; the old ones stop // working, and the new ones are shown exactly once. func (a *Admin) handleTotpCodes(w http.ResponseWriter, r *http.Request) { if !a.requireCSRF(w, r) { return } sess := session.FromContext(r.Context()) username := sess.Get("user") if !a.deps.Users.VerifyTotp(username, r.PostFormValue("code"), time.Now()) { a.renderSettings(w, r, i18n.Admin.T(a.lang(r, nil), "Wrong or expired code."), "", http.StatusUnprocessableEntity) return } codes, hashes := users.GenerateRecoveryCodes(10) if _, err := a.deps.Users.ReplaceRecovery(username, hashes); err != nil { a.renderSettings(w, r, i18n.Admin.Tf(a.lang(r, nil), "The codes could not be replaced: %s", err.Error()), "", http.StatusInternalServerError) return } a.record(r, "user.totp_codes", username, nil) data := a.settingsData(r) data.RecoveryCodes = codes data.RecoveryNotice = i18n.Admin.T(data.Lang, "New recovery codes. Store them now; they will not be shown again.") a.renderPage(w, r, "settings.html", data, http.StatusOK) }