Files

190 lines
6.9 KiB
Go
Raw Permalink Normal View History

2026-09-18 12:03:35 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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)
}