Add two-factor login (TOTP) with recovery codes
Users can set up an authenticator app from their account page; login then asks for a code after the password, with one-time recovery codes as a fallback. Sensitive actions (tokens, user changes, 2FA resets) need a code from the last 5 minutes. totp.require (none/admins/all) forces setup, and admins can reset a user's 2FA from the UI, the jarl CLI or jarld on the server. The dashboard and jarl status flag users missing 2FA and a shared "admin" account still in use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b22ffee1bb
commit
8f9cdd0a17
35 files changed
+1965
-43
No files matched your search
@@ -0,0 +1,273 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/totp"
|
||||
)
|
||||
|
||||
// browser is a web UI session: cookies plus the CSRF token.
|
||||
type browser struct {
|
||||
t *testing.T
|
||||
e *apiEnv
|
||||
c *http.Client
|
||||
csrf string
|
||||
}
|
||||
|
||||
func newBrowser(e *apiEnv) *browser {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return &browser{t: e.t, e: e, c: &http.Client{Jar: jar}}
|
||||
}
|
||||
|
||||
// call sends a request; header is an optional X-Jarl-TOTP code.
|
||||
func (b *browser) call(method, path string, body any, code string) (map[string]any, int) {
|
||||
b.t.Helper()
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
j, _ := json.Marshal(body)
|
||||
rd = bytes.NewReader(j)
|
||||
}
|
||||
r, _ := http.NewRequest(method, b.e.ts.URL+api.Prefix+path, rd)
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if b.csrf != "" {
|
||||
r.Header.Set(csrfHeader, b.csrf)
|
||||
}
|
||||
if code != "" {
|
||||
r.Header.Set(TOTPHeader, code)
|
||||
}
|
||||
resp, err := b.c.Do(r)
|
||||
if err != nil {
|
||||
b.t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var m map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&m)
|
||||
if c, ok := m["csrf_token"].(string); ok && c != "" {
|
||||
b.csrf = c
|
||||
}
|
||||
return m, resp.StatusCode
|
||||
}
|
||||
|
||||
func (b *browser) must(method, path string, body any, code string, want int) map[string]any {
|
||||
b.t.Helper()
|
||||
m, status := b.call(method, path, body, code)
|
||||
if status != want {
|
||||
b.t.Fatalf("%s %s = %d, want %d: %v", method, path, status, want, m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// sessionHash is the stored hash of the browser's session cookie.
|
||||
func (b *browser) sessionHash() string {
|
||||
u, _ := url.Parse(b.e.ts.URL)
|
||||
for _, c := range b.c.Jar.Cookies(u) {
|
||||
if c.Name == sessionCookie {
|
||||
return auth.HashSecret(c.Value)
|
||||
}
|
||||
}
|
||||
b.t.Fatal("no session cookie")
|
||||
return ""
|
||||
}
|
||||
|
||||
// codeAt is secret's code for a step. Tests count steps from a base
|
||||
// taken at their start, so crossing a step boundary mid-test changes
|
||||
// nothing (the server accepts one step either side).
|
||||
func codeAt(secret string, step int64) string {
|
||||
c, _ := totp.Code(secret, step)
|
||||
return c
|
||||
}
|
||||
|
||||
func bodyErrCode(m map[string]any) string {
|
||||
if e, ok := m["error"].(map[string]any); ok {
|
||||
s, _ := e["code"].(string)
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestTOTPLogin(t *testing.T) {
|
||||
base := totp.Step(time.Now())
|
||||
e := newAPIEnv(t)
|
||||
const pw = "correct horse battery"
|
||||
b := newBrowser(e)
|
||||
b.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200)
|
||||
if st := b.must("GET", "/me/totp", nil, "", 200); st["enabled"] != false {
|
||||
t.Fatalf("status = %v", st)
|
||||
}
|
||||
// Setting up needs the password; the secret only counts once a code
|
||||
// confirms it.
|
||||
if m, status := b.call("POST", "/me/totp/setup", map[string]string{"password": "wrong"}, ""); status != 403 {
|
||||
t.Errorf("setup with a wrong password = %d %v", status, m)
|
||||
}
|
||||
setup := b.must("POST", "/me/totp/setup", map[string]string{"password": pw}, "", 200)
|
||||
secret := setup["secret"].(string)
|
||||
if setup["qr"] == "" || setup["account"] != "admin" {
|
||||
t.Errorf("setup = %v", setup)
|
||||
}
|
||||
if m, status := b.call("POST", "/me/totp/enable", map[string]string{"code": "000000"}, ""); status != 400 || bodyErrCode(m) != "totp_invalid" {
|
||||
t.Errorf("enable with a wrong code = %d %v", status, m)
|
||||
}
|
||||
// The codes of three steps are accepted (the clock may be off by one);
|
||||
// each works once. Enable with the previous step's, log in with the
|
||||
// current one's, and step up with the next one's.
|
||||
en := b.must("POST", "/me/totp/enable", map[string]string{"code": codeAt(secret, base-1)}, "", 200)
|
||||
recovery := en["recovery_codes"].([]any)
|
||||
if len(recovery) != totp.RecoveryCodes {
|
||||
t.Fatalf("recovery codes = %v", en)
|
||||
}
|
||||
// Turning it on kept this session and recorded the proved code: a
|
||||
// token can be made right away.
|
||||
b.must("POST", "/me/tokens", map[string]any{"name": "laptop"}, "", 201)
|
||||
|
||||
// The password alone is not a session any more.
|
||||
b2 := newBrowser(e)
|
||||
ch := b2.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200)
|
||||
if ch["totp_required"] != true || ch["challenge"] == "" {
|
||||
t.Fatalf("login = %v", ch)
|
||||
}
|
||||
if _, status := b2.call("GET", "/auth/whoami", nil, ""); status != 401 {
|
||||
t.Errorf("whoami after the password only = %d", status)
|
||||
}
|
||||
// The code used to turn it on can't be used again.
|
||||
if m, status := b2.call("POST", "/auth/login/totp", map[string]string{"challenge": ch["challenge"].(string), "code": codeAt(secret, base-1)}, ""); status != 401 || bodyErrCode(m) != "totp_invalid" {
|
||||
t.Errorf("reused code = %d %v", status, m)
|
||||
}
|
||||
who := b2.must("POST", "/auth/login/totp", map[string]string{"challenge": ch["challenge"].(string), "code": codeAt(secret, base)}, "", 200)
|
||||
if who["totp"].(map[string]any)["enabled"] != true {
|
||||
t.Errorf("whoami = %v", who)
|
||||
}
|
||||
if _, status := b2.call("POST", "/auth/login/totp", map[string]string{"challenge": ch["challenge"].(string), "code": codeAt(secret, base)}, ""); status != 401 {
|
||||
t.Errorf("challenge used twice = %d", status)
|
||||
}
|
||||
|
||||
// Step-up: once the proved code is older than the window, making a
|
||||
// token needs a fresh one.
|
||||
old := time.Now().Add(-stepUpWindow - time.Minute)
|
||||
e.st.SetSessionTOTP(context.Background(), b2.sessionHash(), old)
|
||||
if m, status := b2.call("POST", "/me/tokens", map[string]any{"name": "ci"}, ""); status != 403 || bodyErrCode(m) != "totp_step_up" {
|
||||
t.Errorf("token without a recent code = %d %v", status, m)
|
||||
}
|
||||
if m, status := b2.call("POST", "/me/tokens", map[string]any{"name": "ci"}, "123456"); status != 403 || bodyErrCode(m) != "totp_invalid" {
|
||||
t.Errorf("token with a wrong code = %d %v", status, m)
|
||||
}
|
||||
b2.must("POST", "/me/tokens", map[string]any{"name": "ci"}, codeAt(secret, base+1), 201)
|
||||
// That proved a code again: the next action needs none.
|
||||
b2.must("POST", "/me/tokens", map[string]any{"name": "ci2"}, "", 201)
|
||||
// API tokens never need a code.
|
||||
e.do("POST", "/me/tokens", e.token, map[string]any{"name": "script"}, 201)
|
||||
|
||||
// A recovery code logs in once.
|
||||
rc := recovery[0].(string)
|
||||
for i, want := range []int{200, 401} {
|
||||
b3 := newBrowser(e)
|
||||
ch := b3.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200)
|
||||
if _, status := b3.call("POST", "/auth/login/totp", map[string]string{"challenge": ch["challenge"].(string), "code": " " + rc + " "}, ""); status != want {
|
||||
t.Errorf("recovery code use %d = %d, want %d", i+1, status, want)
|
||||
}
|
||||
}
|
||||
if st := b2.must("GET", "/me/totp", nil, "", 200); st["recovery_codes_left"] != float64(totp.RecoveryCodes-1) {
|
||||
t.Errorf("recovery codes left = %v", st["recovery_codes_left"])
|
||||
}
|
||||
evs := e.list("/audit?action=user.totp", e.token)
|
||||
if len(evs) == 0 {
|
||||
t.Error("no user.totp audit events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOTPRequiredAndReset(t *testing.T) {
|
||||
base := totp.Step(time.Now())
|
||||
e := newAPIEnvConfig(t, Config{Version: "test", TOTPRequire: "admins", TOTPIssuer: "Jarl test"})
|
||||
const pw = "correct horse battery"
|
||||
// An administrator without TOTP gets a session that can only set it up.
|
||||
b := newBrowser(e)
|
||||
who := b.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200)
|
||||
if who["totp_setup_required"] != true {
|
||||
t.Fatalf("whoami = %v", who)
|
||||
}
|
||||
if m, status := b.call("GET", "/zones", nil, ""); status != 403 || bodyErrCode(m) != "totp_setup_required" {
|
||||
t.Errorf("zones before setup = %d %v", status, m)
|
||||
}
|
||||
// Tokens are not affected.
|
||||
e.do("GET", "/zones", e.token, nil, 200)
|
||||
setup := b.must("POST", "/me/totp/setup", map[string]string{"password": pw}, "", 200)
|
||||
if setup["issuer"] != "Jarl test" {
|
||||
t.Errorf("issuer = %v", setup["issuer"])
|
||||
}
|
||||
secret := setup["secret"].(string)
|
||||
b.must("POST", "/me/totp/enable", map[string]string{"code": codeAt(secret, base)}, "", 200)
|
||||
b.must("GET", "/zones", nil, "", 200)
|
||||
// It can't be turned off while required.
|
||||
if _, status := b.call("POST", "/me/totp/disable", map[string]string{"password": pw, "code": codeAt(secret, base+1)}, ""); status != 409 {
|
||||
t.Errorf("disable while required = %d", status)
|
||||
}
|
||||
// Users that aren't administrators may go without.
|
||||
e.do("POST", "/users", e.token, map[string]any{"username": "bob", "password": "bob-password-123", "role": "readonly"}, 201)
|
||||
bob := newBrowser(e)
|
||||
if who := bob.must("POST", "/auth/login", map[string]string{"username": "bob", "password": "bob-password-123"}, "", 200); who["totp_setup_required"] == true {
|
||||
t.Error("bob must set up TOTP")
|
||||
}
|
||||
bob.must("GET", "/zones", nil, "", 200)
|
||||
|
||||
// The dashboard: another administrator without two-factor login is
|
||||
// listed, and the first "admin" account looks shared.
|
||||
e.do("POST", "/users", e.token, map[string]any{"username": "carol", "password": "carol-password-123", "admin": true}, 201)
|
||||
st := e.do("GET", "/status", e.token, nil, 200)["totp"].(map[string]any)
|
||||
if missing := st["missing"].([]any); len(missing) != 1 || missing[0] != "carol" || st["shared_admin"] != "admin" || st["policy"] != "admins" {
|
||||
t.Errorf("status totp = %v", st)
|
||||
}
|
||||
|
||||
// An administrator resets someone's TOTP (with a token: no code).
|
||||
users := e.list("/users", e.token)
|
||||
var adminID string
|
||||
for _, u := range users {
|
||||
if u["username"] == "admin" {
|
||||
adminID = itoa(int64(u["id"].(float64)))
|
||||
if u["totp_required"] != true || u["totp_enabled_at"] == nil {
|
||||
t.Errorf("admin = %v", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
e.do("DELETE", "/users/"+adminID+"/totp", e.token, nil, 200)
|
||||
if _, status := b.call("GET", "/zones", nil, ""); status != 401 {
|
||||
t.Errorf("session after reset = %d", status)
|
||||
}
|
||||
e.do("DELETE", "/users/"+adminID+"/totp", e.token, nil, 409)
|
||||
// Next login: password only, and setup again.
|
||||
b = newBrowser(e)
|
||||
if who := b.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200); who["totp_setup_required"] != true {
|
||||
t.Errorf("after reset = %v", who)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOTPDisable(t *testing.T) {
|
||||
base := totp.Step(time.Now())
|
||||
e := newAPIEnv(t)
|
||||
const pw = "correct horse battery"
|
||||
b := newBrowser(e)
|
||||
b.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200)
|
||||
secret := b.must("POST", "/me/totp/setup", map[string]string{"password": pw}, "", 200)["secret"].(string)
|
||||
b.must("POST", "/me/totp/enable", map[string]string{"code": codeAt(secret, base)}, "", 200)
|
||||
if _, status := b.call("POST", "/me/totp/disable", map[string]string{"password": pw, "code": codeAt(secret, base)}, ""); status != 403 {
|
||||
t.Errorf("disable with a used code = %d", status)
|
||||
}
|
||||
b.must("POST", "/me/totp/disable", map[string]string{"password": pw, "code": codeAt(secret, base+1)}, "", 204)
|
||||
// The password alone logs in again.
|
||||
b2 := newBrowser(e)
|
||||
if who := b2.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200); who["csrf_token"] == "" {
|
||||
t.Errorf("login = %v", who)
|
||||
}
|
||||
// A token can't set up TOTP.
|
||||
if _, status := e.req("POST", "/me/totp/setup", e.token, map[string]string{"password": pw}); status != 403 {
|
||||
t.Errorf("setup with a token = %d", status)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user