Files
llmgw/internal/account/account_test.go
T
Ilker ManapandClaude Opus 5.5 44cd58005a Count sign-in attempts per address and client, guard the sign-in form with a token, ration both forms per address
A wrong password from anywhere counted against the address, so ten locked
its owner out. Attempts are now counted per address and client, with a
looser bound per address alone. The sign-in form carries a token matched
by a cookie (login CSRF). POST /login and /signup are rationed per client
address, a hash waits at most ten seconds for a turn, and server init
writes signup_open: false.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:26:29 +02:00

451 lines
15 KiB
Go

package account
import (
"context"
"errors"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
func newStore(t *testing.T) *store.DB {
t.Helper()
s, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "a.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Close() })
ctx := context.Background()
for _, err := range []error{
s.PutOrg(ctx, store.Org{ID: "org", Name: "Org"}),
s.PutTeam(ctx, store.Team{ID: "team", OrgID: "org", Name: "Team"}),
s.PutProject(ctx, store.Project{ID: "proj", TeamID: "team", Name: "Proj"}),
} {
if err != nil {
t.Fatal(err)
}
}
return s
}
func newService(t *testing.T) (*Service, *store.DB) {
t.Helper()
s := newStore(t)
return New(s, DefaultConfig(), slog.New(slog.DiscardHandler)), s
}
const goodPassword = "correct horse battery staple"
func TestSignupLeavesAnAccountWaiting(t *testing.T) {
// A public signup form that produced a working account would be a way in.
svc, st := newService(t)
ctx := context.Background()
if err := svc.SignUp(ctx, "Ayse@Example.COM", "Ayşe", goodPassword); err != nil {
t.Fatal(err)
}
u, err := st.UserByEmail(ctx, "ayse@example.com")
if err != nil {
t.Fatal(err)
}
if u.State != store.UserPending {
t.Errorf("a new account is %q", u.State)
}
if u.Email != "ayse@example.com" {
t.Errorf("the address was not normalized: %q", u.Email)
}
if _, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword); !errors.Is(err, ErrNotApproved) {
t.Errorf("a pending account signed in: %v", err)
}
}
func TestSignupDoesNotRevealExistingAccounts(t *testing.T) {
// A form that says "this address already has an account" is an account
// enumerator anyone can run, and the person signing up learns nothing they
// need: either way they wait for approval.
svc, _ := newService(t)
ctx := context.Background()
if err := svc.SignUp(ctx, "ayse@example.com", "A", goodPassword); err != nil {
t.Fatal(err)
}
if err := svc.SignUp(ctx, "ayse@example.com", "Someone Else", goodPassword); err != nil {
t.Errorf("the second signup reported %v, which confirms the address is taken", err)
}
}
func TestApprovalIsWhatMakesAnAccountWork(t *testing.T) {
svc, st := newService(t)
ctx := context.Background()
must(t, svc.SignUp(ctx, "ayse@example.com", "A", goodPassword))
u, _ := st.UserByEmail(ctx, "ayse@example.com")
must(t, st.ApproveUser(ctx, u.ID, "proj", "ilker", time.Now()))
token, csrf, err := svc.SignIn(ctx, "ayse@example.com", goodPassword)
if err != nil {
t.Fatal(err)
}
if token == "" || csrf == "" {
t.Fatal("sign-in produced no session")
}
_, got, err := svc.Resolve(ctx, token)
if err != nil {
t.Fatal(err)
}
if ok, err := st.CanUseProject(ctx, got.ID, "proj"); err != nil || !ok {
t.Errorf("the approved account is not a member of the project it was approved into: %v", err)
}
}
func TestApprovalNeedNotNameAProject(t *testing.T) {
// A person need not belong to anything to use the gateway: approved into
// nothing, they work on their own within their personal limits.
svc, st := newStoreAndUser(t)
ctx := context.Background()
if err := st.ApproveUser(ctx, "usr-1", "", "ilker", time.Now()); err != nil {
t.Fatalf("approving with no project: %v", err)
}
if _, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword); err != nil {
t.Errorf("an account approved into nothing cannot sign in: %v", err)
}
}
func newStoreAndUser(t *testing.T) (*Service, *store.DB) {
t.Helper()
svc, st := newService(t)
hash, _ := HashPassword(goodPassword)
must(t, st.CreateUser(context.Background(), store.User{
ID: "usr-1", Email: "ayse@example.com", Name: "A",
PasswordHash: hash, State: store.UserPending, CreatedAt: time.Now(),
}))
return svc, st
}
func TestSuspensionTakesEffectOnOpenSessions(t *testing.T) {
// Leaving existing sessions open would make suspension advisory.
svc, st := newStoreAndUser(t)
ctx := context.Background()
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
token, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword)
if err != nil {
t.Fatal(err)
}
if _, _, err := svc.Resolve(ctx, token); err != nil {
t.Fatal(err)
}
must(t, st.SetUserState(ctx, "usr-1", store.UserSuspended))
if _, _, err := svc.Resolve(ctx, token); !errors.Is(err, store.ErrNoSession) {
t.Errorf("a suspended account kept its session: %v", err)
}
}
func TestSignOutEndsTheSession(t *testing.T) {
svc, st := newStoreAndUser(t)
ctx := context.Background()
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
token, _, _ := svc.SignIn(ctx, "ayse@example.com", goodPassword)
must(t, svc.SignOut(ctx, token))
if _, _, err := svc.Resolve(ctx, token); !errors.Is(err, store.ErrNoSession) {
t.Errorf("the session survived sign-out: %v", err)
}
}
func TestAWrongPasswordAndAMissingAccountAreIndistinguishable(t *testing.T) {
svc, st := newStoreAndUser(t)
ctx := context.Background()
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
_, _, wrong := svc.SignIn(ctx, "ayse@example.com", "wrong password here")
_, _, missing := svc.SignIn(ctx, "nobody@example.com", "wrong password here")
if !errors.Is(wrong, ErrBadCredentials) || !errors.Is(missing, ErrBadCredentials) {
t.Fatalf("wrong=%v missing=%v", wrong, missing)
}
}
func TestGuessingIsBounded(t *testing.T) {
// Without this a public login form is an open brute-force target.
svc, st := newStoreAndUser(t)
ctx := context.Background()
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
cfg := DefaultConfig()
cfg.MaxAttempts = 3
svc = New(st, cfg, slog.New(slog.DiscardHandler))
for i := range 3 {
if _, _, err := svc.SignIn(ctx, "ayse@example.com", "nope"); !errors.Is(err, ErrBadCredentials) {
t.Fatalf("attempt %d: %v", i, err)
}
}
if _, _, err := svc.SignIn(ctx, "ayse@example.com", "nope"); !errors.Is(err, ErrTooManyTries) {
t.Errorf("guessing was not bounded: %v", err)
}
// And a correct password is refused too while the window is open, or the
// limit would be trivially bypassed by interleaving.
if _, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword); !errors.Is(err, ErrTooManyTries) {
t.Errorf("the window did not apply to a correct password: %v", err)
}
}
func TestASuccessfulSignInClearsTheWindow(t *testing.T) {
// Otherwise a person who mistypes their password a few times is locked out
// for the rest of the window even after getting it right.
st := newStore(t)
hash, _ := HashPassword(goodPassword)
ctx := context.Background()
must(t, st.CreateUser(ctx, store.User{
ID: "usr-1", Email: "a@example.com", Name: "A",
PasswordHash: hash, State: store.UserPending, CreatedAt: time.Now(),
}))
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
cfg := DefaultConfig()
cfg.MaxAttempts = 3
svc := New(st, cfg, slog.New(slog.DiscardHandler))
for range 2 {
_, _, _ = svc.SignIn(ctx, "a@example.com", "nope")
}
if _, _, err := svc.SignIn(ctx, "a@example.com", goodPassword); err != nil {
t.Fatal(err)
}
for range 3 {
if _, _, err := svc.SignIn(ctx, "a@example.com", "nope"); errors.Is(err, ErrTooManyTries) {
t.Fatal("the window was not cleared by a successful sign-in")
}
}
}
func TestCSRFTokensAreCheckedAgainstTheSession(t *testing.T) {
svc, st := newStoreAndUser(t)
ctx := context.Background()
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
token, csrf, _ := svc.SignIn(ctx, "ayse@example.com", goodPassword)
sess, _, _ := svc.Resolve(ctx, token)
if !svc.CheckCSRF(sess, csrf) {
t.Error("the session's own token was rejected")
}
for _, bad := range []string{"", "wrong", csrf + "x", csrf[:len(csrf)-1]} {
if svc.CheckCSRF(sess, bad) {
t.Errorf("%q was accepted as a csrf token", bad)
}
}
// A token from another session must not work in this one.
other, otherCSRF, _ := svc.SignIn(ctx, "ayse@example.com", goodPassword)
_ = other
if svc.CheckCSRF(sess, otherCSRF) {
t.Error("another session's token was accepted")
}
}
func TestPasswordsAreHashedNotStored(t *testing.T) {
svc, st := newService(t)
ctx := context.Background()
must(t, svc.SignUp(ctx, "ayse@example.com", "A", goodPassword))
u, _ := st.UserByEmail(ctx, "ayse@example.com")
if strings.Contains(u.PasswordHash, goodPassword) {
t.Fatal("the password is in the database")
}
if !strings.HasPrefix(u.PasswordHash, "$argon2id$") {
t.Errorf("hash format: %q", u.PasswordHash)
}
ok, err := VerifyPassword(goodPassword, u.PasswordHash)
if err != nil || !ok {
t.Errorf("the hash does not verify: %v %v", ok, err)
}
if ok, _ := VerifyPassword("something else entirely", u.PasswordHash); ok {
t.Error("a wrong password verified")
}
}
func TestTwoHashesOfOnePasswordDiffer(t *testing.T) {
// A salt that did not vary would make the table a rainbow-table lookup.
a, _ := HashPassword(goodPassword)
b, _ := HashPassword(goodPassword)
if a == b {
t.Fatal("hashing is not salted")
}
}
func TestShortPasswordsAreRefused(t *testing.T) {
if _, err := HashPassword("short"); !errors.Is(err, ErrWeakPassword) {
t.Errorf("a five-character password was accepted: %v", err)
}
svc, _ := newService(t)
if err := svc.SignUp(context.Background(), "a@example.com", "A", "short"); !errors.Is(err, ErrWeakPassword) {
t.Errorf("signup accepted it: %v", err)
}
}
func TestMalformedHashesAreRejectedRatherThanMatched(t *testing.T) {
for _, bad := range []string{"", "plaintext", "$argon2id$", "$bcrypt$v=19$m=1,t=1,p=1$aa$bb"} {
if ok, err := VerifyPassword(goodPassword, bad); ok || err == nil {
t.Errorf("VerifyPassword(%q) = %v, %v", bad, ok, err)
}
}
}
func TestObviousNonAddressesAreRefused(t *testing.T) {
svc, _ := newService(t)
ctx := context.Background()
for _, bad := range []string{"", "ayse", "ayse@", "@example.com", "a b@example.com", "a@b", "a@@b.com"} {
if err := svc.SignUp(ctx, bad, "A", goodPassword); !errors.Is(err, ErrInvalidEmail) {
t.Errorf("SignUp(%q) = %v", bad, err)
}
}
}
func must(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
func TestChangingAPasswordNeedsTheOldOne(t *testing.T) {
// A session is what an attacker gets from a borrowed laptop or a stolen
// cookie. The password is what they do not have, and without this check the
// first thing they would do is take the account.
svc, st := newStoreAndUser(t)
ctx := context.Background()
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
u, _ := st.UserByID(ctx, "usr-1")
if err := svc.ChangePassword(ctx, st, u, "not the password", "a brand new passphrase"); !errors.Is(err, ErrBadCredentials) {
t.Errorf("a wrong current password was accepted: %v", err)
}
// And the old one still works.
if _, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword); err != nil {
t.Errorf("the password changed anyway: %v", err)
}
}
func TestChangingAPasswordSignsOutEverywhere(t *testing.T) {
// Leaving other sessions open would mean a password change does nothing
// about the reason someone is changing it.
svc, st := newStoreAndUser(t)
ctx := context.Background()
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
laptop, _, _ := svc.SignIn(ctx, "ayse@example.com", goodPassword)
phone, _, _ := svc.SignIn(ctx, "ayse@example.com", goodPassword)
u, _ := st.UserByID(ctx, "usr-1")
must(t, svc.ChangePassword(ctx, st, u, goodPassword, "a brand new passphrase"))
for name, token := range map[string]string{"laptop": laptop, "phone": phone} {
if _, _, err := svc.Resolve(ctx, token); !errors.Is(err, store.ErrNoSession) {
t.Errorf("the %s session survived the change: %v", name, err)
}
}
// The new password works and the old one does not.
if _, _, err := svc.SignIn(ctx, "ayse@example.com", "a brand new passphrase"); err != nil {
t.Errorf("the new password does not work: %v", err)
}
if _, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword); !errors.Is(err, ErrBadCredentials) {
t.Errorf("the old password still works: %v", err)
}
}
func TestANewPasswordMustActuallyBeNew(t *testing.T) {
svc, st := newStoreAndUser(t)
ctx := context.Background()
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
u, _ := st.UserByID(ctx, "usr-1")
if err := svc.ChangePassword(ctx, st, u, goodPassword, goodPassword); !errors.Is(err, ErrSamePassword) {
t.Errorf("changing a password to itself: %v", err)
}
if err := svc.ChangePassword(ctx, st, u, goodPassword, "short"); !errors.Is(err, ErrWeakPassword) {
t.Errorf("a short new password was accepted: %v", err)
}
}
func TestGuessingTheCurrentPasswordIsBounded(t *testing.T) {
// Someone with a stolen session should not get unlimited attempts at the
// password that would let them keep it.
st := newStore(t)
ctx := context.Background()
hash, _ := HashPassword(goodPassword)
must(t, st.CreateUser(ctx, store.User{
ID: "usr-1", Email: "a@example.com", Name: "A",
PasswordHash: hash, State: store.UserPending, CreatedAt: time.Now(),
}))
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
cfg := DefaultConfig()
cfg.MaxAttempts = 3
svc := New(st, cfg, slog.New(slog.DiscardHandler))
u, _ := st.UserByID(ctx, "usr-1")
for range 3 {
_ = svc.ChangePassword(ctx, st, u, "wrong", "a brand new passphrase")
}
if err := svc.ChangePassword(ctx, st, u, "wrong", "a brand new passphrase"); !errors.Is(err, ErrTooManyTries) {
t.Errorf("guessing was unbounded: %v", err)
}
}
func TestSomebodyGuessingElsewhereCannotLockTheOwnerOut(t *testing.T) {
svc, st := newStoreAndUser(t)
ctx := context.Background()
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
cfg := DefaultConfig()
cfg.MaxAttempts = 3
svc = New(st, cfg, slog.New(slog.DiscardHandler))
attacker, owner := WithClient(ctx, "203.0.113.9"), WithClient(ctx, "198.51.100.7")
for range 5 {
_, _, _ = svc.SignIn(attacker, "ayse@example.com", "nope")
}
if _, _, err := svc.SignIn(attacker, "ayse@example.com", "nope"); !errors.Is(err, ErrTooManyTries) {
t.Errorf("the guessing client was not held back: %v", err)
}
if _, _, err := svc.SignIn(owner, "ayse@example.com", goodPassword); err != nil {
t.Errorf("the owner, elsewhere, was locked out: %v", err)
}
// Spread over many clients, guessing is still bounded per address.
held := false
for i := range 40 {
c := WithClient(ctx, "192.0.2."+strings.Repeat("1", 1+i%3)+string(rune('0'+i%10)))
if _, _, err := svc.SignIn(c, "ayse@example.com", "nope"); errors.Is(err, ErrTooManyTries) {
held = true
}
}
if !held {
t.Error("guesses from many clients were not bounded")
}
}
func TestAHashThatCannotGetATurnSaysTheServerIsBusy(t *testing.T) {
was := argonWait
argonWait = 10 * time.Millisecond
t.Cleanup(func() { argonWait = was })
// Every turn taken.
n := cap(argonSlots)
for range n {
argonSlots <- struct{}{}
}
t.Cleanup(func() {
for range n {
<-argonSlots
}
})
if _, err := HashPassword(goodPassword); !errors.Is(err, ErrBusy) {
t.Errorf("a hash with no turn: %v", err)
}
}