Files

238 lines
9.4 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"
"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")
}
}