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