Artifact e7c037a3a603f5afa0037c9a9dc1374d3c8a160688db6bcb7d21d498610e4a2a:
- File
internal/oltp/account_test.go
— part of check-in
[7bdf7a189b]
at
2026-08-17 00:10:56
on branch trunk
— web: cookie sessions and a login page
Basic auth works and stays for the API, but a browser has no way to log out of it and the credential lives in the browser's keeping. A gated page now redirects to /login instead of raising a Basic prompt, the header names who is signed in and offers sign-out, and Basic remains for scripts.
Session tokens are stored as SHA-256 digests, so the table cannot be read back into working cookies. Cookies are HttpOnly, Path=/, SameSite=Lax — Lax being what stops another origin POSTing quarry's write forms on a reader's behalf — and Secure under TLS.
A password change or an account deletion ends that account's sessions; otherwise 'change your password' would leave the browser already holding one still signed in. Authorization for a cookie runs the same RoleOf lookup the password path uses, so a cookie is never worth more than a password.
safeNext keeps the post-login redirect on this site: a reader is at their most trusting on the hop straight after authenticating, which is exactly when an open redirect pays. (user: dmestas, size: 7769) [annotate] [blame] [check-ins using]
package oltp import ( "path/filepath" "testing" "time" ) func testDB(t *testing.T) *DB { t.Helper() db, err := Open(filepath.Join(t.TempDir(), "oltp.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) return db } func TestPasswordRoundTrip(t *testing.T) { h, err := HashPassword("correct horse battery staple") if err != nil { t.Fatal(err) } if !VerifyPassword(h, "correct horse battery staple") { t.Fatal("correct password rejected") } if VerifyPassword(h, "wrong") { t.Fatal("wrong password accepted") } // Salted: the same password twice must not produce the same verifier, // or the store leaks which accounts share a password. h2, _ := HashPassword("correct horse battery staple") if h == h2 { t.Fatal("verifier is not salted") } } func TestVerifyRejectsMalformed(t *testing.T) { for _, bad := range []string{ "", "not-a-hash", "$argon2id$", "$argon2i$v=19$m=1,t=1,p=1$AAAA$AAAA", "$argon2id$v=99$m=65536,t=1,p=4$AAAA$AAAA", "$argon2id$v=19$m=65536,t=1,p=4$!!!$AAAA", } { if VerifyPassword(bad, "anything") { t.Errorf("accepted malformed verifier %q", bad) } } } func TestAccountLifecycle(t *testing.T) { db := testDB(t) // The gate is open until the first account exists — local single-user // serving keeps working, exactly as sync users do. if db.HasAccounts() { t.Fatal("fresh database reports accounts") } if err := db.AddAccount("dan", "hunter2", false); err != nil { t.Fatal(err) } if !db.HasAccounts() { t.Fatal("account added but gate still open") } if err := db.AddAccount("dan", "other", false); err == nil { t.Fatal("duplicate account name accepted — that is an account takeover") } if _, ok := db.Authenticate("dan", "hunter2"); !ok { t.Fatal("correct credentials rejected") } if _, ok := db.Authenticate("dan", "wrong"); ok { t.Fatal("wrong password accepted") } if _, ok := db.Authenticate("nobody", "hunter2"); ok { t.Fatal("nonexistent account authenticated") } if err := db.SetPassword("dan", "hunter3"); err != nil { t.Fatal(err) } if _, ok := db.Authenticate("dan", "hunter2"); ok { t.Fatal("old password still works after change") } if _, ok := db.Authenticate("dan", "hunter3"); !ok { t.Fatal("new password rejected") } if err := db.SetPassword("ghost", "x"); err == nil { t.Fatal("set password on a nonexistent account") } } func TestRolesAreOrdered(t *testing.T) { db := testDB(t) if err := db.AddAccount("dan", "pw", false); err != nil { t.Fatal(err) } acct, _ := db.Authenticate("dan", "pw") if got := db.RoleOf(acct, "quarry"); got != NoRole { t.Fatalf("ungranted repo gives %s", got) } if err := db.Grant("dan", "quarry", Write); err != nil { t.Fatal(err) } if got := db.RoleOf(acct, "quarry"); got != Write { t.Fatalf("role = %s, want write", got) } // A grant on one repo says nothing about another. if got := db.RoleOf(acct, "other"); got != NoRole { t.Fatalf("grant leaked to another repo: %s", got) } // Ordering is what authorization compares against. if !(NoRole < Read && Read < Write && Write < RepoAdmin) { t.Fatal("roles are not ordered") } if err := db.Revoke("dan", "quarry"); err != nil { t.Fatal(err) } if got := db.RoleOf(acct, "quarry"); got != NoRole { t.Fatalf("revoked role still %s", got) } if err := db.Grant("ghost", "quarry", Write); err == nil { t.Fatal("granted a role to a nonexistent account") } } func TestSiteAdminHoldsEveryRepo(t *testing.T) { db := testDB(t) if err := db.AddAccount("root", "pw", true); err != nil { t.Fatal(err) } acct, ok := db.Authenticate("root", "pw") if !ok || !acct.Admin { t.Fatalf("admin flag lost: %+v", acct) } // No explicit grant anywhere, yet admin everywhere — that is what // "site admin" means, and it must not need per-repo rows. if got := db.RoleOf(acct, "never-granted"); got != RepoAdmin { t.Fatalf("site admin has %s on an ungranted repo", got) } } func TestDeleteAccountDropsItsRoles(t *testing.T) { db := testDB(t) if err := db.AddAccount("temp", "pw", false); err != nil { t.Fatal(err) } if err := db.Grant("temp", "quarry", Write); err != nil { t.Fatal(err) } if err := db.DeleteAccount("temp"); err != nil { t.Fatal(err) } if _, ok := db.Authenticate("temp", "pw"); ok { t.Fatal("deleted account still authenticates") } // A later account reusing the name must not inherit the old grants. if err := db.AddAccount("temp", "pw", false); err != nil { t.Fatal(err) } acct, _ := db.Authenticate("temp", "pw") if got := db.RoleOf(acct, "quarry"); got != NoRole { t.Fatalf("recreated account inherited %s", got) } } func TestGrantsForListsRepos(t *testing.T) { db := testDB(t) if err := db.AddAccount("dan", "pw", false); err != nil { t.Fatal(err) } for repo, role := range map[string]Role{"a": Read, "b": Write, "c": RepoAdmin} { if err := db.Grant("dan", repo, role); err != nil { t.Fatal(err) } } got, err := db.GrantsFor("dan") if err != nil { t.Fatal(err) } if len(got) != 3 || got["a"] != Read || got["b"] != Write || got["c"] != RepoAdmin { t.Fatalf("grants = %v", got) } if repos := SortedRepos(got); repos[0] != "a" || repos[2] != "c" { t.Fatalf("SortedRepos = %v", repos) } } func TestSessionLifecycle(t *testing.T) { db := testDB(t) if err := db.AddAccount("dan", "hunter2", false); err != nil { t.Fatal(err) } token, expires, ok := db.NewSession("dan", "hunter2") if !ok { t.Fatal("login with correct credentials failed") } if expires.Before(time.Now()) { t.Fatalf("session already expired: %v", expires) } acct, ok := db.SessionAccount(token) if !ok || acct.Name != "dan" { t.Fatalf("resolve = %+v %v", acct, ok) } if _, _, ok := db.NewSession("dan", "wrong"); ok { t.Fatal("login succeeded with the wrong password") } if _, ok := db.SessionAccount("not-a-token"); ok { t.Fatal("an invented token resolved") } db.EndSession(token) if _, ok := db.SessionAccount(token); ok { t.Fatal("session survived logout") } } // The cookie value must not be recoverable from the database. A session // table that reads back into working cookies turns a backup or a stray // SELECT into a set of live logins. func TestSessionTokenIsStoredHashed(t *testing.T) { db := testDB(t) if err := db.AddAccount("dan", "pw", false); err != nil { t.Fatal(err) } token, _, ok := db.NewSession("dan", "pw") if !ok { t.Fatal("login failed") } var stored string if err := db.db.QueryRow(`SELECT token FROM session`).Scan(&stored); err != nil { t.Fatal(err) } if stored == token { t.Fatal("the raw cookie value is in the database") } if _, ok := db.SessionAccount(stored); ok { t.Fatal("the stored digest works as a cookie") } } // Changing a password is how somebody responds to a credential they // believe is compromised; it has to invalidate the browser already // holding a session, or the response does nothing. func TestPasswordChangeEndsSessions(t *testing.T) { db := testDB(t) if err := db.AddAccount("dan", "old", false); err != nil { t.Fatal(err) } token, _, _ := db.NewSession("dan", "old") if _, ok := db.SessionAccount(token); !ok { t.Fatal("session not established") } if err := db.SetPassword("dan", "new"); err != nil { t.Fatal(err) } if _, ok := db.SessionAccount(token); ok { t.Fatal("session survived a password change") } } // A deleted account cannot keep browsing on a live cookie. func TestDeletedAccountEndsSessions(t *testing.T) { db := testDB(t) if err := db.AddAccount("temp", "pw", false); err != nil { t.Fatal(err) } token, _, _ := db.NewSession("temp", "pw") if err := db.DeleteAccount("temp"); err != nil { t.Fatal(err) } if _, ok := db.SessionAccount(token); ok { t.Fatal("deleted account still resolves its session") } }