Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user