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,237 @@
|
||||
// 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"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/totp"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/users"
|
||||
)
|
||||
|
||||
func currentCode(t *testing.T, secret string) string {
|
||||
t.Helper()
|
||||
return currentCodeIn(t, secret, 0)
|
||||
}
|
||||
|
||||
// currentCodeIn computes the code of a neighbouring time step, so a
|
||||
// test can answer twice without tripping the replay floor.
|
||||
func currentCodeIn(t *testing.T, secret string, steps int) string {
|
||||
t.Helper()
|
||||
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("decode secret: %v", err)
|
||||
}
|
||||
return totp.Code(key, time.Now().Add(time.Duration(steps)*totp.Step))
|
||||
}
|
||||
|
||||
// loginTo opens the first door and returns the session wherever it
|
||||
// stands: the dashboard, or the second-factor step when the account
|
||||
// has one.
|
||||
func loginTo(t *testing.T, f *fixture, username, secret string) *http.Cookie {
|
||||
t.Helper()
|
||||
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
|
||||
csrf := extractCSRF(t, get.Body.String())
|
||||
cookie := sessionCookie(t, get)
|
||||
form := url.Values{"_csrf": {csrf}, "username": {username}, "password": {secret}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("login failed: code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return sessionCookie(t, rec)
|
||||
}
|
||||
|
||||
// TestLoginWithSecondFactor walks the whole door: password, code, in,
|
||||
// and the recovery path when the application is lost.
|
||||
func TestLoginWithSecondFactor(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
secret := users.GenerateTotpSecret()
|
||||
codes, hashes := users.GenerateRecoveryCodes(10)
|
||||
if _, err := f.users.EnableTotp("admin", secret, hashes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cookie := loginTo(t, f, "admin", "correct-horse-9")
|
||||
// The password alone no longer opens anything: the admin bounces
|
||||
// to the login, which forwards a pending session to the step.
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
|
||||
req.AddCookie(cookie)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
|
||||
t.Fatalf("password step: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/login", nil)
|
||||
req.AddCookie(cookie)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/twofactor" {
|
||||
t.Fatalf("login form forwards pending session: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/twofactor", nil)
|
||||
req.AddCookie(cookie)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Verification code") {
|
||||
t.Fatalf("twofactor form: code=%d", rec.Code)
|
||||
}
|
||||
|
||||
// The direct route redirects anonymous traffic to the first step.
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/twofactor", nil)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
|
||||
t.Fatalf("anonymous twofactor: code=%d", rec.Code)
|
||||
}
|
||||
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
// A wrong code is refused and changes nothing.
|
||||
rec := postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {"000000"}}, cookie)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong code: code=%d", rec.Code)
|
||||
}
|
||||
|
||||
rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {currentCode(t, secret)}}, cookie)
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
|
||||
t.Fatalf("right code: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
cookie = sessionCookie(t, rec)
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/", nil)
|
||||
req.AddCookie(cookie)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusOK {
|
||||
t.Fatalf("dashboard after second factor: %d", rec.Code)
|
||||
}
|
||||
|
||||
// The recovery path: sign out, in again, spend one code; the same
|
||||
// code never works twice.
|
||||
postForm(t, f, "/admin/logout", url.Values{"_csrf": {csrf}}, cookie)
|
||||
cookie = loginTo(t, f, "admin", "correct-horse-9")
|
||||
csrf = csrfFromSession(t, f, cookie)
|
||||
rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {codes[0]}}, cookie)
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
|
||||
t.Fatalf("recovery code: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
cookie = sessionCookie(t, rec)
|
||||
postForm(t, f, "/admin/logout", url.Values{"_csrf": {csrf}}, cookie)
|
||||
cookie = loginTo(t, f, "admin", "correct-horse-9")
|
||||
csrf = csrfFromSession(t, f, cookie)
|
||||
rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {codes[0]}}, cookie)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("reused recovery code: code=%d", rec.Code)
|
||||
}
|
||||
// The typed shapes humans use still work.
|
||||
rec = postForm(t, f, "/admin/twofactor",
|
||||
url.Values{"_csrf": {csrf}, "code": {strings.ReplaceAll(codes[1], "-", " ")}}, cookie)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("spaced recovery code: code=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTotpEnrolment drives the settings flow: start, the QR page, the
|
||||
// verifying code, the one-time recovery codes, and turning it off.
|
||||
func TestTotpEnrolment(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
// Before anything, the settings page offers the setup.
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
req.AddCookie(cookie)
|
||||
body := f.do(t, req).Body.String()
|
||||
if !strings.Contains(body, "Set up two-factor") {
|
||||
t.Fatal("setup offer missing")
|
||||
}
|
||||
|
||||
// Start shows the QR and the secret, and stores nothing yet. The
|
||||
// candidate rides the cookie, so the jar moves on with it.
|
||||
rec := postForm(t, f, "/admin/settings/twofactor/start", url.Values{"_csrf": {csrf}}, cookie)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("start: %d", rec.Code)
|
||||
}
|
||||
cookie = sessionCookie(t, rec)
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
req.AddCookie(cookie)
|
||||
body = f.do(t, req).Body.String()
|
||||
if !strings.Contains(body, "totp__qr") || !strings.Contains(body, `<path fill="#000"`) {
|
||||
t.Fatal("QR panel missing after start")
|
||||
}
|
||||
if f.users.Find("admin").TotpSecret != "" {
|
||||
t.Fatal("start stored a secret before verification")
|
||||
}
|
||||
|
||||
// A wrong verifying code clears the candidate.
|
||||
rec = postForm(t, f, "/admin/settings/twofactor/verify", url.Values{"_csrf": {csrf}, "code": {"000000"}}, cookie)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("wrong verify: %d", rec.Code)
|
||||
}
|
||||
cookie = sessionCookie(t, rec)
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
req.AddCookie(cookie)
|
||||
if strings.Contains(f.do(t, req).Body.String(), "totp__qr") {
|
||||
t.Fatal("candidate survived a wrong code")
|
||||
}
|
||||
|
||||
// The honest path: start again, verify with the code the
|
||||
// application shows, receive the recovery codes once.
|
||||
rec = postForm(t, f, "/admin/settings/twofactor/start", url.Values{"_csrf": {csrf}}, cookie)
|
||||
cookie = sessionCookie(t, rec)
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
req.AddCookie(cookie)
|
||||
body = f.do(t, req).Body.String()
|
||||
i := strings.Index(body, `class="totp__secret"`)
|
||||
if i < 0 {
|
||||
t.Fatal("secret text missing")
|
||||
}
|
||||
rest := body[i:]
|
||||
j := strings.Index(rest, ">")
|
||||
k := strings.Index(rest[j:], "<")
|
||||
secret := rest[j+1 : j+k]
|
||||
if len(secret) < 26 {
|
||||
t.Fatalf("secret looks wrong: %q", secret)
|
||||
}
|
||||
rec = postForm(t, f, "/admin/settings/twofactor/verify", url.Values{"_csrf": {csrf}, "code": {currentCode(t, secret)}}, cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("verify: %d body=%s", rec.Code, rec.Body.String()[:200])
|
||||
}
|
||||
page := rec.Body.String()
|
||||
if !strings.Contains(page, "recovery__code") {
|
||||
t.Fatal("recovery codes not shown once")
|
||||
}
|
||||
if f.users.Find("admin").TotpSecret == "" {
|
||||
t.Fatal("enabled secret not stored")
|
||||
}
|
||||
|
||||
// The next sign-in needs the second factor.
|
||||
postForm(t, f, "/admin/logout", url.Values{"_csrf": {csrf}}, cookie)
|
||||
cookie = loginTo(t, f, "admin", "correct-horse-9")
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/login", nil)
|
||||
req.AddCookie(cookie)
|
||||
if rec := f.do(t, req); rec.Header().Get("Location") != "/admin/twofactor" {
|
||||
t.Fatalf("second factor not asked: %q", rec.Header().Get("Location"))
|
||||
}
|
||||
|
||||
// Turning it off asks for a current code.
|
||||
csrf = csrfFromSession(t, f, cookie)
|
||||
rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {currentCode(t, secret)}}, cookie)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("sign-in code: %d", rec.Code)
|
||||
}
|
||||
cookie = sessionCookie(t, rec)
|
||||
rec = postForm(t, f, "/admin/settings/twofactor/disable", url.Values{"_csrf": {csrf}, "code": {"000000"}}, cookie)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("disable with wrong code: %d", rec.Code)
|
||||
}
|
||||
// The sign-in already spent this window's code: the next window's
|
||||
// code answers, the spent one must not.
|
||||
rec = postForm(t, f, "/admin/settings/twofactor/disable",
|
||||
url.Values{"_csrf": {csrf}, "code": {currentCodeIn(t, secret, 1)}}, cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("disable: %d", rec.Code)
|
||||
}
|
||||
if f.users.Find("admin").TotpSecret != "" {
|
||||
t.Fatal("secret survived disable")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user