package server import ( "net/http" "net/url" "regexp" "strings" "testing" "time" "cc.tiredsysadmin.keeporg/internal/secret" "cc.tiredsysadmin.keeporg/internal/store" "cc.tiredsysadmin.keeporg/internal/totp" ) // codeAt is the one-time code d steps from now. Each code is good once, so // a test that needs several uses inside one 30-second step takes them at // -1, 0 and +1, all within the accepted skew. func codeAt(t *testing.T, sec string, d int) string { t.Helper() c, err := totp.Code(sec, time.Now().Add(time.Duration(d)*30*time.Second)) if err != nil { t.Fatal(err) } return c } // enrollTOTP turns the second factor on for the token's account, spending // the code one step back, and returns the secret. func (h *harness) enrollTOTP(t *testing.T, tok, password string) string { t.Helper() status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, map[string]string{"password": password}) if status != http.StatusOK { t.Fatalf("enroll: %d %v", status, body) } sec := body["secret"].(string) if status, body := h.call("POST", "/api/v1/auth/totp/confirm", tok, map[string]string{"code": codeAt(t, sec, -1)}); status != http.StatusOK { t.Fatalf("confirm: %d %v", status, body) } return sec } func sessionList(t *testing.T, h *harness, token string) []map[string]any { t.Helper() status, body := h.call("GET", "/api/v1/auth/sessions", token, nil) if status != http.StatusOK { t.Fatalf("list sessions: %d %v", status, body) } var out []map[string]any for _, s := range body["sessions"].([]any) { out = append(out, s.(map[string]any)) } return out } func TestSessionsAreListedAndRevokedByTheirOwner(t *testing.T) { h := newHarness(t) laptop, _, _ := h.login("admin", adminPassword, "") phone, _, _ := h.login("admin", adminPassword, "") list := sessionList(t, h, laptop) if len(list) != 2 || list[0]["current"] != true { t.Fatalf("sessions as the laptop sees them: %v", list) } for _, s := range list { if id := s["id"].(string); id == laptop || id == phone || strings.Contains(laptop+phone, id) && len(id) > 16 { t.Fatalf("a session id gives its token away: %v", s) } } // The laptop signs the phone out; the phone is refused from then on. phoneID := sessionID(phone) if status, body := h.call("DELETE", "/api/v1/auth/sessions/"+phoneID, laptop, nil); status != http.StatusOK { t.Fatalf("revoke: %d %v", status, body) } if status, _ := h.call("GET", "/api/v1/auth/whoami", phone, nil); status != http.StatusUnauthorized { t.Fatalf("the revoked phone still works: %d", status) } // Somebody else's session is not found, and stays signed in. other := h.makeUser("bob", "bob-password-1") if status, _ := h.call("DELETE", "/api/v1/auth/sessions/"+sessionID(other), laptop, nil); status != http.StatusNotFound { t.Fatalf("revoking another account's session: %d", status) } if status, _ := h.call("GET", "/api/v1/auth/whoami", other, nil); status != http.StatusOK { t.Fatal("another account's session was revoked") } // Sign out everything else. h.login("admin", adminPassword, "") h.login("admin", adminPassword, "") status, body := h.call("DELETE", "/api/v1/auth/sessions", laptop, nil) if status != http.StatusOK || body["revoked"] != float64(2) { t.Fatalf("revoke others: %d %v", status, body) } if list := sessionList(t, h, laptop); len(list) != 1 { t.Fatalf("after revoking the others: %v", list) } } func TestReplacingOrRemovingTheSecondFactorNeedsThePassword(t *testing.T) { h := newHarness(t) tok, _, _ := h.login("admin", adminPassword, "") // Even turning it on needs the password: on a stolen session it would // lock the owner out. if status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil); status != http.StatusForbidden { t.Fatalf("enroll without the password: %d %v", status, body) } sec := h.enrollTOTP(t, tok, adminPassword) code := codeAt(t, sec, 0) // A session alone can no longer swap the secret or turn it off. if status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil); status != http.StatusForbidden || body["code"] != "reauth_required" { t.Fatalf("re-enroll without a password: %d %v", status, body) } if status, body := h.call("DELETE", "/api/v1/auth/totp", tok, map[string]string{"password": adminPassword}); status != http.StatusForbidden || body["code"] != "reauth_required" { t.Fatalf("disable without a code: %d %v", status, body) } if status, body := h.call("DELETE", "/api/v1/auth/totp", tok, map[string]string{"password": "wrong-password", "totp": code}); status != http.StatusForbidden || body["code"] != "bad_credentials" { t.Fatalf("disable with a wrong password: %d %v", status, body) } // A wrong code reads the same as a wrong password. if status, body := h.call("DELETE", "/api/v1/auth/totp", tok, map[string]string{"password": adminPassword, "totp": "000000"}); status != http.StatusForbidden || body["code"] != "bad_credentials" { t.Fatalf("disable with a wrong code: %d %v", status, body) } var on bool h.srv.Store().Read(func(st *store.State) { on = st.Users["admin"].TOTPConfirmed }) if !on { t.Fatal("a refused request turned the second factor off") } if status, body := h.call("DELETE", "/api/v1/auth/totp", tok, map[string]string{"password": adminPassword, "totp": code}); status != http.StatusOK { t.Fatalf("disable: %d %v", status, body) } if _, status, _ := h.login("admin", adminPassword, ""); status != http.StatusOK { t.Fatal("still asked for a code after turning it off") } } func TestSecondFactorIsRefusedForSSOOnlyAccounts(t *testing.T) { h := newHarness(t) if err := h.srv.Store().Update(func(st *store.State) error { st.Users["sso"] = &store.User{ID: "sso", Username: "ssouser", OIDCIssuer: "https://id.example", OIDCSubject: "s"} st.Sessions["sso-token"] = &store.Session{Token: "sso-token", UserID: "sso", ExpiresAt: time.Now().Add(time.Hour)} return nil }); err != nil { t.Fatal(err) } if status, body := h.call("POST", "/api/v1/auth/totp/enroll", "sso-token", nil); status != http.StatusConflict { t.Fatalf("enroll on an SSO-only account: %d %v", status, body) } } var sessionButton = regexp.MustCompile(`name="session" value="([0-9a-f]+)"`) func TestAccountPageSignsOutOtherDevices(t *testing.T) { h := newHarness(t) device, _, _ := h.login("admin", adminPassword, "") b := newBrowser(t, h) csrf := b.signIn() _, page := b.get("/web/account") for _, want := range []string{"Signed-in devices", "this browser", "test", "Two-factor authentication", "Change password"} { if !strings.Contains(page, want) { t.Fatalf("account page lacks %q:\n%s", want, page) } } if strings.Contains(page, device) || strings.Contains(page, b.cookie()) { t.Fatal("the page carries a session token") } ids := sessionButton.FindAllStringSubmatch(page, -1) if len(ids) != 1 || ids[0][1] != sessionID(device) { t.Fatalf("revoke buttons %v, want only the device's", ids) } _, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"revoke"}, "session": {ids[0][1]}}) if !strings.Contains(page, "That device is signed out") { t.Fatalf("no confirmation:\n%s", page) } if status, _ := h.call("GET", "/api/v1/databases", device, nil); status != http.StatusUnauthorized { t.Fatalf("the device still syncs after being signed out: %d", status) } // Revoking this browser's own session from the list signs it out. _, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"revoke"}, "session": {sessionID(b.cookie())}}) if !strings.Contains(page, "Sign in to create") || b.cookie() != "" { t.Fatalf("revoking this browser left it signed in:\n%s", page) } } func TestAccountPageSetsUpTheSecondFactor(t *testing.T) { h := newHarness(t) b := newBrowser(t, h) csrf := b.signIn() _, page := b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-begin"}}) if !strings.Contains(page, "Confirm with your password") { t.Fatalf("set up without the password:\n%s", page) } _, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-begin"}, "password": {adminPassword}}) if !strings.Contains(page, `href="otpauth://totp/`) { t.Fatalf("no authenticator link:\n%s", page) } var sec string h.srv.Store().Read(func(st *store.State) { sec = st.Users["admin"].TOTPSecret }) if !strings.Contains(page, groupKey(sec)) { t.Fatalf("the key is not shown:\n%s", page) } _, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-confirm"}, "code": {"000000"}}) if !strings.Contains(page, "rejected") { t.Fatalf("a wrong code was not refused:\n%s", page) } _, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-confirm"}, "code": {codeAt(t, sec, -1)}}) if !strings.Contains(page, "Two-factor authentication is on") { t.Fatalf("not turned on:\n%s", page) } if _, status, _ := h.login("admin", adminPassword, ""); status != http.StatusUnauthorized { t.Fatal("a password alone still signs in") } // Turning it off from the page needs the password and a code. _, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-disable"}, "password": {adminPassword}}) if !strings.Contains(page, "Confirm with your password") { t.Fatalf("turned off without a code:\n%s", page) } _, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-disable"}, "password": {adminPassword}, "totp": {codeAt(t, sec, 0)}}) if !strings.Contains(page, "Two-factor authentication is off") { t.Fatalf("not turned off:\n%s", page) } } func TestAdministratorSignsSomebodyOutEverywhere(t *testing.T) { h := newHarness(t) bob := h.makeUser("bob", "bob-password-1") b := newBrowser(t, h) csrf := b.signIn() var bobID string h.srv.Store().Read(func(st *store.State) { bobID = st.UserByName("bob").ID }) _, page := b.post("/web/users", url.Values{"csrf": {csrf}, "user": {bobID}, "action": {"sign-out"}}) if !strings.Contains(page, "signed out on every device") { t.Fatalf("no confirmation:\n%s", page) } if status, _ := h.call("GET", "/api/v1/auth/whoami", bob, nil); status != http.StatusUnauthorized { t.Fatal("bob's session survived") } if _, status, _ := h.login("bob", "bob-password-1", ""); status != http.StatusOK { t.Fatal("signing out everywhere locked the account") } } func TestLastSeenIsRecordedCoarsely(t *testing.T) { h := newHarness(t) tok, _, _ := h.login("admin", adminPassword, "") old := time.Now().Add(-time.Hour) _ = h.srv.Store().Update(func(st *store.State) error { st.Sessions[tok].LastSeenAt = old return nil }) h.call("GET", "/api/v1/auth/whoami", tok, nil) var seen time.Time h.srv.Store().Read(func(st *store.State) { seen = st.Sessions[tok].LastSeenAt }) if time.Since(seen) > time.Minute { t.Fatalf("a stale last-seen was not refreshed: %v", seen) } } func TestBrowserLabel(t *testing.T) { for ua, want := range map[string]string{ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5; rv:128.0) Gecko/20100101 Firefox/128.0": "Firefox on macOS", "Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Mobile Safari/537.36": "Chrome on Android", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36 Edg/130.0": "Edge on Windows", "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile Safari/604.1": "Safari on iOS", "": "Browser", } { if got := browserLabel(ua); got != want { t.Errorf("browserLabel(%q) = %q, want %q", ua, got, want) } } } // makeUser adds a password account and returns a session token for it. func (h *harness) makeUser(name, password string) string { h.t.Helper() h.makeUserNoLogin(name, password) tok, status, body := h.login(name, password, "") if status != http.StatusOK { h.t.Fatalf("login %s: %d %v", name, status, body) } return tok } // makeUserNoLogin adds a password account. func (h *harness) makeUserNoLogin(name, password string) { h.t.Helper() hash, err := secret.Hash(password) if err != nil { h.t.Fatal(err) } if err := h.srv.Store().Update(func(st *store.State) error { st.Users[name] = &store.User{ID: name, Username: name, PassHash: hash, CreatedAt: time.Now()} return nil }); err != nil { h.t.Fatal(err) } }