190 lines
6.9 KiB
Go
190 lines
6.9 KiB
Go
// 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)
|
||
|
|
}
|