/forgot mails a link to set a new password when the cluster's mail settings say how - SMTP relay, sender, and the public base_url the link points at, never the request's Host - and says the same whether or not the address has an account. /reset sets it once, closing every session. Links are kept by their hash in password_resets, good for an hour, a newer one replacing an older. Without mail, /forgot says to ask an operator, and llmgw users reset-link makes the link to hand over. Single sign-on accounts get none. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
488 lines
16 KiB
Go
488 lines
16 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)
|
|
}
|
|
}
|
|
|
|
func TestAForgottenPasswordIsResetOnceThroughALink(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")
|
|
|
|
// Waiting for approval: no link, said the same as for nobody.
|
|
if _, _, err := svc.RequestReset(ctx, st, "ayse@example.com"); !errors.Is(err, ErrNoReset) {
|
|
t.Fatalf("a pending account got a link: %v", err)
|
|
}
|
|
if _, _, err := svc.RequestReset(ctx, st, "nobody@example.com"); !errors.Is(err, ErrNoReset) {
|
|
t.Fatalf("an unknown address: %v", err)
|
|
}
|
|
must(t, st.ApproveUser(ctx, u.ID, "proj", "ilker", time.Now()))
|
|
session, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword)
|
|
must(t, err)
|
|
|
|
token, got, err := svc.RequestReset(ctx, st, "AYSE@example.com")
|
|
if err != nil || token == "" || got.ID != u.ID {
|
|
t.Fatalf("an active account: %q %+v %v", token, got, err)
|
|
}
|
|
if err := svc.ResetPassword(ctx, st, st, token, "short"); !errors.Is(err, ErrWeakPassword) {
|
|
t.Fatalf("a weak password: %v", err)
|
|
}
|
|
next := goodPassword + "-new"
|
|
must(t, svc.ResetPassword(ctx, st, st, token, next))
|
|
if _, _, err := svc.Resolve(ctx, session); err == nil {
|
|
t.Error("a session from before the reset still works")
|
|
}
|
|
if _, _, err := svc.SignIn(ctx, "ayse@example.com", next); err != nil {
|
|
t.Errorf("the new password does not sign in: %v", err)
|
|
}
|
|
if err := svc.ResetPassword(ctx, st, st, token, next+"2"); !errors.Is(err, ErrNoReset) {
|
|
t.Errorf("a link used twice: %v", err)
|
|
}
|
|
}
|