From 110a240cd95e2511c41502f4b92da47cab584775 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Fri, 16 Oct 2026 09:21:08 -0300 Subject: [PATCH] fix(auth): app passwords now sign in SMTP, IMAP and POP3 The webmail creates app passwords (shown once, stored hashed, with scopes) but the protocol servers only compared a login against mailboxes.password_hash, so an app password was always refused with 535 / authentication failed. They exist because mail clients cannot do a second factor. - port.MailboxAuth carries the mailbox's app passwords (newest 20) with their scopes; the Postgres repository loads them and stamps last_used_at on use - verifyCredential accepts the mailbox password or an app password that holds the scope of the protocol; SMTP, IMAP and POP3 use it. ManageSieve keeps the mailbox password only, as no default scope covers it - a malformed app password hash is skipped instead of failing the login; a malformed mailbox hash still surfaces as the server-side error it was Tests: unit cases for scopes and malformed hashes, the three entry points, repository tests against Postgres, and an end-to-end test on the real submission server that fails with 535 without the fix and passes with it. Full `go test ./...` with TEST_DATABASE_URL, go vet and lang-lint are clean. --- docs/OPERATIONS.md | 17 ++ .../application/port/auth_repository.go | 21 ++ .../application/usecase/credentials.go | 68 ++++++ .../application/usecase/credentials_test.go | 218 ++++++++++++++++++ .../application/usecase/imap_session.go | 3 +- .../application/usecase/pop3_session.go | 3 +- .../usecase/process_outbound_email.go | 2 +- .../internal/e2e/app_password_e2e_test.go | 119 ++++++++++ .../postgres/auth_repository.go | 32 +++ .../postgres/auth_repository_test.go | 121 ++++++++++ 10 files changed, 599 insertions(+), 5 deletions(-) create mode 100644 services/protocols/internal/application/usecase/credentials.go create mode 100644 services/protocols/internal/application/usecase/credentials_test.go create mode 100644 services/protocols/internal/e2e/app_password_e2e_test.go diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 04a3dda..ee10a4d 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -93,6 +93,23 @@ make reset-password EMAIL=… PASSWORD='…' # recovers a lockout, ends its se make reset-mfa EMAIL=… # clears every second factor ``` +### App passwords for mail clients + +A mail client cannot answer a second factor, so an account signs a client in +with an **app password**: create one in the webmail (Settings, app passwords), +copy it once, and give it to the client as the account password. It is shown a +single time and stored only as an Argon2id hash. + +An app password works for SMTP submission, IMAP and POP3, as its `scopes` +column allows (the default is all three). A scope limits it: an app password +with only `imap` can read mail and cannot send. ManageSieve takes the mailbox +password only. The mailbox password keeps working everywhere, and a login with +an app password stamps its `last_used_at`, which the settings screen shows. + +Until this was wired in, the protocols compared every password only against the +mailbox password, so an app password was created, shown and then refused by +SMTP, IMAP and POP3 with `535 Authentication credentials invalid`. + An account locks itself for 15 minutes after 5 failed passwords. That is the lockout working, not a fault; `reset-password` clears it. diff --git a/services/protocols/internal/application/port/auth_repository.go b/services/protocols/internal/application/port/auth_repository.go index 47677dd..688754e 100644 --- a/services/protocols/internal/application/port/auth_repository.go +++ b/services/protocols/internal/application/port/auth_repository.go @@ -15,8 +15,29 @@ type MailboxAuth struct { DomainName string // MaxRecipientsPerHour caps a compromised account's blast radius. MaxRecipientsPerHour int + // AppPasswords are the per-client credentials the user generated in the + // webmail. Protocol clients cannot answer a second factor, so these are how + // a mail client signs in on an account that has one. + AppPasswords []AppPasswordAuth } +// AppPasswordAuth is one app password as stored: a hash, never the secret. +type AppPasswordAuth struct { + ID uuid.UUID + Hash string + Scopes []string +} + +// AppPasswordUseRecorder is implemented by repositories that can note when an +// app password was last used. It is optional: a fake in a test need not have it. +type AppPasswordUseRecorder interface { + RecordAppPasswordUse(ctx context.Context, id uuid.UUID) error +} + +// MaxAppPasswordsChecked bounds how many app passwords one login attempt is +// compared against. +const MaxAppPasswordsChecked = 20 + // AuthRepository resolves a login address to its stored credential. type AuthRepository interface { // FindByAddress returns nil, nil (not an error) when no active mailbox diff --git a/services/protocols/internal/application/usecase/credentials.go b/services/protocols/internal/application/usecase/credentials.go new file mode 100644 index 0000000..40e7395 --- /dev/null +++ b/services/protocols/internal/application/usecase/credentials.go @@ -0,0 +1,68 @@ +package usecase + +import ( + "context" + "log/slog" + + "github.com/alexedwards/argon2id" + + "lambdamail/protocols/internal/application/port" +) + +// Scopes an app password can be limited to. They match the values of +// app_passwords.scopes, whose default is {imap,pop3,smtp}. +const ( + ScopeSMTP = "smtp" + ScopeIMAP = "imap" + ScopePOP3 = "pop3" +) + +// verifyCredential reports whether password unlocks the mailbox for a client +// of the given protocol: either the mailbox's own password, or one of its app +// passwords that holds the scope. +// +// Before this, every protocol compared only against the mailbox password, so +// an app password - created in the webmail precisely because a mail client +// cannot answer a second factor - never worked anywhere. +// +// An error is returned only when the mailbox's own stored hash is unreadable +// (a server-side fault, reported as before) and no app password matched. A +// broken app password hash is skipped: it must not take the good ones, or a +// plain wrong-password answer, down with it. +func verifyCredential(ctx context.Context, repo port.AuthRepository, rec *port.MailboxAuth, password, scope string) (bool, error) { + match, mainErr := argon2id.ComparePasswordAndHash(password, rec.PasswordHash) + if mainErr == nil && match { + return true, nil + } + + for _, app := range rec.AppPasswords { + if !hasScope(app.Scopes, scope) { + continue + } + ok, err := argon2id.ComparePasswordAndHash(password, app.Hash) + if err != nil || !ok { + continue + } + if recorder, can := repo.(port.AppPasswordUseRecorder); can { + // Best effort: a failed timestamp must not fail a login that succeeded + if err := recorder.RecordAppPasswordUse(ctx, app.ID); err != nil { + slog.Warn("record app password use", "error", err) + } + } + return true, nil + } + + if mainErr != nil { + return false, mainErr + } + return false, nil +} + +func hasScope(scopes []string, scope string) bool { + for _, s := range scopes { + if s == scope { + return true + } + } + return false +} diff --git a/services/protocols/internal/application/usecase/credentials_test.go b/services/protocols/internal/application/usecase/credentials_test.go new file mode 100644 index 0000000..1414f90 --- /dev/null +++ b/services/protocols/internal/application/usecase/credentials_test.go @@ -0,0 +1,218 @@ +package usecase + +import ( + "context" + "errors" + "testing" + + "github.com/alexedwards/argon2id" + "github.com/google/uuid" + + "lambdamail/protocols/internal/application/port" +) + +// Light parameters: the tests check logic, not the cost of a real hash. +var testArgon = &argon2id.Params{Memory: 8 * 1024, Iterations: 1, Parallelism: 1, SaltLength: 16, KeyLength: 32} + +func mustHash(t *testing.T, password string) string { + t.Helper() + hash, err := argon2id.CreateHash(password, testArgon) + if err != nil { + t.Fatalf("create hash: %v", err) + } + return hash +} + +// recordingAuthRepo is an AuthRepository that also records which app password +// was used, like the Postgres one does. +type recordingAuthRepo struct { + used []uuid.UUID +} + +func (r *recordingAuthRepo) FindByAddress(context.Context, string) (*port.MailboxAuth, error) { + return nil, nil +} + +func (r *recordingAuthRepo) RecordAppPasswordUse(_ context.Context, id uuid.UUID) error { + r.used = append(r.used, id) + return nil +} + +func TestVerifyCredential(t *testing.T) { + appID := uuid.New() + rec := &port.MailboxAuth{ + ID: uuid.New(), + PasswordHash: mustHash(t, "main-password-123"), + AppPasswords: []port.AppPasswordAuth{ + {ID: appID, Hash: mustHash(t, "app-password-abc"), Scopes: []string{ScopeSMTP, ScopeIMAP}}, + {ID: uuid.New(), Hash: mustHash(t, "imap-only-xyz"), Scopes: []string{ScopeIMAP}}, + }, + } + repo := &recordingAuthRepo{} + + cases := []struct { + name string + password string + scope string + want bool + }{ + {"main password works for smtp", "main-password-123", ScopeSMTP, true}, + {"main password works for imap", "main-password-123", ScopeIMAP, true}, + {"app password works in a scope it holds", "app-password-abc", ScopeSMTP, true}, + {"app password works for imap too", "app-password-abc", ScopeIMAP, true}, + {"app password is refused outside its scopes", "app-password-abc", ScopePOP3, false}, + {"an imap-only app password cannot send", "imap-only-xyz", ScopeSMTP, false}, + {"an imap-only app password can read", "imap-only-xyz", ScopeIMAP, true}, + {"wrong password fails", "nope", ScopeSMTP, false}, + {"empty password fails", "", ScopeSMTP, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := verifyCredential(context.Background(), repo, rec, tc.password, tc.scope) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("verifyCredential = %v, want %v", got, tc.want) + } + }) + } + + // Only the successful app password logins were recorded, and only that one + for _, id := range repo.used { + if id == rec.ID { + t.Fatalf("recorded the mailbox id as an app password use") + } + } + if len(repo.used) == 0 { + t.Fatalf("expected app password use to be recorded") + } +} + +func TestVerifyCredentialMainPasswordDoesNotRecordAppUse(t *testing.T) { + rec := &port.MailboxAuth{ + ID: uuid.New(), + PasswordHash: mustHash(t, "main-password-123"), + AppPasswords: []port.AppPasswordAuth{{ID: uuid.New(), Hash: mustHash(t, "app-password-abc"), Scopes: []string{ScopeSMTP}}}, + } + repo := &recordingAuthRepo{} + + ok, err := verifyCredential(context.Background(), repo, rec, "main-password-123", ScopeSMTP) + if err != nil || !ok { + t.Fatalf("main password: ok=%v err=%v", ok, err) + } + if len(repo.used) != 0 { + t.Fatalf("a main password login must not touch app passwords, recorded %v", repo.used) + } +} + +func TestVerifyCredentialMalformedAppHashIsSkipped(t *testing.T) { + rec := &port.MailboxAuth{ + ID: uuid.New(), + PasswordHash: mustHash(t, "main-password-123"), + AppPasswords: []port.AppPasswordAuth{ + {ID: uuid.New(), Hash: "not-a-phc-string", Scopes: []string{ScopeSMTP}}, + {ID: uuid.New(), Hash: mustHash(t, "app-password-abc"), Scopes: []string{ScopeSMTP}}, + }, + } + + ok, err := verifyCredential(context.Background(), &recordingAuthRepo{}, rec, "app-password-abc", ScopeSMTP) + if err != nil || !ok { + t.Fatalf("a good app password must still work next to a broken one: ok=%v err=%v", ok, err) + } + + ok, err = verifyCredential(context.Background(), &recordingAuthRepo{}, rec, "wrong", ScopeSMTP) + if err != nil || ok { + t.Fatalf("a broken app hash must not turn a wrong password into an error: ok=%v err=%v", ok, err) + } +} + +func TestVerifyCredentialMalformedMainHashKeepsReportingTheError(t *testing.T) { + rec := &port.MailboxAuth{ID: uuid.New(), PasswordHash: "garbage"} + + ok, err := verifyCredential(context.Background(), &recordingAuthRepo{}, rec, "anything", ScopeSMTP) + if ok { + t.Fatalf("must not authenticate against a malformed hash") + } + if err == nil { + t.Fatalf("a malformed main hash is a server-side fault and must surface as an error, as before") + } +} + +func TestVerifyCredentialMalformedMainHashStillAllowsAnAppPassword(t *testing.T) { + rec := &port.MailboxAuth{ + ID: uuid.New(), + PasswordHash: "garbage", + AppPasswords: []port.AppPasswordAuth{{ID: uuid.New(), Hash: mustHash(t, "app-password-abc"), Scopes: []string{ScopeSMTP}}}, + } + + ok, err := verifyCredential(context.Background(), &recordingAuthRepo{}, rec, "app-password-abc", ScopeSMTP) + if err != nil || !ok { + t.Fatalf("ok=%v err=%v", ok, err) + } +} + +// The three protocol entry points, wired the way the servers wire them: an app +// password signs in where its scopes allow, and only there. + +func appPasswordMailbox(t *testing.T, scopes ...string) (port.MailboxAuth, string) { + t.Helper() + const secret = "app-password-from-the-webmail" + return port.MailboxAuth{ + ID: uuid.New(), + PasswordHash: mustHash(t, "the-real-mailbox-password"), + AppPasswords: []port.AppPasswordAuth{{ID: uuid.New(), Hash: mustHash(t, secret), Scopes: scopes}}, + }, secret +} + +func TestSubmissionAuthenticate_AcceptsAnAppPasswordWithTheSmtpScope(t *testing.T) { + rec, secret := appPasswordMailbox(t, ScopeSMTP) + uc := NewProcessOutboundEmailUseCase(&fakeAuthRepo{record: &rec}, nil, nil, nil) + + got, err := uc.Authenticate(context.Background(), "user@example.test", secret) + if err != nil || got == nil || got.ID != rec.ID { + t.Fatalf("Authenticate = %+v, %v; want the mailbox", got, err) + } +} + +func TestSubmissionAuthenticate_RefusesAnAppPasswordWithoutTheSmtpScope(t *testing.T) { + rec, secret := appPasswordMailbox(t, ScopeIMAP) + uc := NewProcessOutboundEmailUseCase(&fakeAuthRepo{record: &rec}, nil, nil, nil) + + if _, err := uc.Authenticate(context.Background(), "user@example.test", secret); !errors.Is(err, ErrSubmissionAuthFailed) { + t.Fatalf("error = %v, want ErrSubmissionAuthFailed", err) + } +} + +func TestImapLogin_AcceptsAnAppPasswordWithTheImapScope(t *testing.T) { + rec, secret := appPasswordMailbox(t, ScopeIMAP) + auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{"user@example.test": rec}} + uc := NewImapSessionUseCase(auth, nil, nil, nil, nil, nil, nil) + + id, err := uc.Login(context.Background(), "user@example.test", secret) + if err != nil || id != rec.ID.String() { + t.Fatalf("Login = %q, %v; want %q", id, err, rec.ID) + } +} + +func TestImapLogin_RefusesAnAppPasswordWithoutTheImapScope(t *testing.T) { + rec, secret := appPasswordMailbox(t, ScopeSMTP) + auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{"user@example.test": rec}} + uc := NewImapSessionUseCase(auth, nil, nil, nil, nil, nil, nil) + + if _, err := uc.Login(context.Background(), "user@example.test", secret); !errors.Is(err, ErrAuthFailed) { + t.Fatalf("error = %v, want ErrAuthFailed", err) + } +} + +func TestPop3Login_AcceptsAnAppPasswordWithThePop3Scope(t *testing.T) { + rec, secret := appPasswordMailbox(t, ScopePOP3) + auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{"user@example.test": rec}} + uc := NewPop3SessionUseCase(auth, nil, nil, nil, nil) + + id, err := uc.Login(context.Background(), "user@example.test", secret) + if err != nil || id != rec.ID.String() { + t.Fatalf("Login = %q, %v; want %q", id, err, rec.ID) + } +} diff --git a/services/protocols/internal/application/usecase/imap_session.go b/services/protocols/internal/application/usecase/imap_session.go index 1441e19..a3a28e8 100644 --- a/services/protocols/internal/application/usecase/imap_session.go +++ b/services/protocols/internal/application/usecase/imap_session.go @@ -4,7 +4,6 @@ import ( "context" "errors" - "github.com/alexedwards/argon2id" "github.com/google/uuid" "lambdamail/protocols/internal/application/port" @@ -64,7 +63,7 @@ func (uc *ImapSessionUseCase) Login(ctx context.Context, address, password strin if rec == nil { return "", ErrAuthFailed } - match, err := argon2id.ComparePasswordAndHash(password, rec.PasswordHash) + match, err := verifyCredential(ctx, uc.auth, rec, password, ScopeIMAP) if err != nil { return "", err } diff --git a/services/protocols/internal/application/usecase/pop3_session.go b/services/protocols/internal/application/usecase/pop3_session.go index 83b8d36..14b0915 100644 --- a/services/protocols/internal/application/usecase/pop3_session.go +++ b/services/protocols/internal/application/usecase/pop3_session.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "github.com/alexedwards/argon2id" "github.com/google/uuid" "lambdamail/protocols/internal/application/port" @@ -42,7 +41,7 @@ func (uc *Pop3SessionUseCase) Login(ctx context.Context, username, password stri if rec == nil { return "", ErrAuthFailed } - match, err := argon2id.ComparePasswordAndHash(password, rec.PasswordHash) + match, err := verifyCredential(ctx, uc.authRepo, rec, password, ScopePOP3) if err != nil { return "", fmt.Errorf("verify password hash: %w", err) } diff --git a/services/protocols/internal/application/usecase/process_outbound_email.go b/services/protocols/internal/application/usecase/process_outbound_email.go index 58a0bff..1d61ab5 100644 --- a/services/protocols/internal/application/usecase/process_outbound_email.go +++ b/services/protocols/internal/application/usecase/process_outbound_email.go @@ -82,7 +82,7 @@ func (uc *ProcessOutboundEmailUseCase) Authenticate(ctx context.Context, address return nil, ErrSubmissionAuthFailed } - match, err := argon2id.ComparePasswordAndHash(password, rec.PasswordHash) + match, err := verifyCredential(ctx, uc.auth, rec, password, ScopeSMTP) if err != nil || !match { return nil, ErrSubmissionAuthFailed } diff --git a/services/protocols/internal/e2e/app_password_e2e_test.go b/services/protocols/internal/e2e/app_password_e2e_test.go new file mode 100644 index 0000000..7bfc26b --- /dev/null +++ b/services/protocols/internal/e2e/app_password_e2e_test.go @@ -0,0 +1,119 @@ +package e2e + +import ( + "context" + "crypto/tls" + "net" + "testing" + "time" + + "github.com/alexedwards/argon2id" + embeddedpostgres "github.com/fergusstrange/embedded-postgres" + "github.com/google/uuid" + + appusecase "lambdamail/protocols/internal/application/usecase" + "lambdamail/protocols/internal/infrastructure/diskstorage" + "lambdamail/protocols/internal/infrastructure/dkim" + "lambdamail/protocols/internal/infrastructure/postgres" + smtppresentation "lambdamail/protocols/internal/presentation/smtp" + + gosmtp "github.com/emersion/go-smtp" +) + +// TestSubmissionAcceptsAppPasswords drives the real submission server over +// STARTTLS. An app password is what a mail client uses on an account that has a +// second factor, and it used to be refused everywhere because the protocols +// only compared against the mailbox password. +func TestSubmissionAcceptsAppPasswords(t *testing.T) { + if testing.Short() { + t.Skip("e2e test skipped in -short mode") + } + ctx := context.Background() + + pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig(). + Port(15445). + RuntimePath(t.TempDir()). + StartTimeout(120 * time.Second)) + if err := pg.Start(); err != nil { + t.Fatalf("start embedded postgres: %v", err) + } + defer pg.Stop() + + pool, err := postgres.NewPool(ctx, "postgres://postgres:postgres@localhost:15445/postgres?sslmode=disable") + if err != nil { + t.Fatalf("connect pool: %v", err) + } + defer pool.Close() + applyMigrations(t, ctx, pool) + + const ( + address = "plataforma@apppass.test" + mainPassword = "the main mailbox password" + sendSecret = "app password that may send" + readSecret = "app password for reading only" + ) + + hashOf := func(secret string) string { + hash, err := argon2id.CreateHash(secret, argon2id.DefaultParams) + if err != nil { + t.Fatalf("hash: %v", err) + } + return hash + } + + domainID, mailboxID := uuid.New(), uuid.New() + pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'apppass.test', 'apppass.test')`, domainID) + pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'plataforma', $3, $4)`, + mailboxID, domainID, address, hashOf(mainPassword)) + // Default scopes: imap, pop3, smtp + pool.Exec(ctx, `INSERT INTO app_passwords (mailbox_id, label, password_hash) VALUES ($1, 'ciberlab', $2)`, mailboxID, hashOf(sendSecret)) + pool.Exec(ctx, `INSERT INTO app_passwords (mailbox_id, label, password_hash, scopes) VALUES ($1, 'reader', $2, '{imap}')`, mailboxID, hashOf(readSecret)) + + blobs := diskstorage.NewLocalDiskBlobStorage(pool, t.TempDir()) + submissionUC := appusecase.NewProcessOutboundEmailUseCase(postgres.NewAuthRepository(pool), postgres.NewOutboundRepository(pool), blobs, dkim.NewSigner(nil)) + + server := gosmtp.NewServer(smtppresentation.NewSubmissionBackend(submissionUC)) + server.Domain = "mail.apppass.test" + server.AllowInsecureAuth = false + server.TLSConfig = &tls.Config{Certificates: []tls.Certificate{selfSignedTLSCert(t)}} + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + go server.Serve(ln) + + login := func(password string) error { + client, err := gosmtp.DialStartTLS(ln.Addr().String(), &tls.Config{InsecureSkipVerify: true}) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer client.Close() + return client.Auth(plainAuth(address, password)) + } + + if err := login(mainPassword); err != nil { + t.Errorf("the mailbox password must keep working: %v", err) + } + if err := login(sendSecret); err != nil { + t.Errorf("an app password with the smtp scope must sign in: %v", err) + } + if err := login(readSecret); err == nil { + t.Error("an imap-only app password must not be able to submit mail") + } + if err := login("not a password"); err == nil { + t.Error("a wrong password must still be refused") + } + + var stamped bool + pool.QueryRow(ctx, `SELECT last_used_at IS NOT NULL FROM app_passwords WHERE label = 'ciberlab'`).Scan(&stamped) + if !stamped { + t.Error("using an app password must stamp last_used_at") + } + var untouched bool + pool.QueryRow(ctx, `SELECT last_used_at IS NULL FROM app_passwords WHERE label = 'reader'`).Scan(&untouched) + if !untouched { + t.Error("a refused app password must not be stamped as used") + } +} diff --git a/services/protocols/internal/infrastructure/postgres/auth_repository.go b/services/protocols/internal/infrastructure/postgres/auth_repository.go index e32d15b..ce82c0d 100644 --- a/services/protocols/internal/infrastructure/postgres/auth_repository.go +++ b/services/protocols/internal/infrastructure/postgres/auth_repository.go @@ -38,5 +38,37 @@ func (r *AuthRepository) FindByAddress(ctx context.Context, address string) (*po return nil, err } rec.ID = id + + // The newest few only: each one costs an Argon2 comparison on a failed login, + // so the number considered is bounded. + rows, err := r.pool.Query(ctx, ` + SELECT id, password_hash, scopes + FROM app_passwords + WHERE mailbox_id = $1 + ORDER BY created_at DESC + LIMIT $2 + `, id, port.MaxAppPasswordsChecked) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var app port.AppPasswordAuth + if err := rows.Scan(&app.ID, &app.Hash, &app.Scopes); err != nil { + return nil, err + } + rec.AppPasswords = append(rec.AppPasswords, app) + } + if err := rows.Err(); err != nil { + return nil, err + } + return &rec, nil } + +// RecordAppPasswordUse stamps the moment an app password last signed a client in. +func (r *AuthRepository) RecordAppPasswordUse(ctx context.Context, id uuid.UUID) error { + _, err := r.pool.Exec(ctx, `UPDATE app_passwords SET last_used_at = NOW() WHERE id = $1`, id) + return err +} diff --git a/services/protocols/internal/infrastructure/postgres/auth_repository_test.go b/services/protocols/internal/infrastructure/postgres/auth_repository_test.go index 4c3034a..1bfa495 100644 --- a/services/protocols/internal/infrastructure/postgres/auth_repository_test.go +++ b/services/protocols/internal/infrastructure/postgres/auth_repository_test.go @@ -5,6 +5,9 @@ import ( "testing" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + + "lambdamail/protocols/internal/application/port" ) func TestAuthRepository_FindByAddress_ReturnsHashForActiveMailbox(t *testing.T) { @@ -56,3 +59,121 @@ func TestAuthRepository_FindByAddress_ReturnsNilForUnknownAddress(t *testing.T) t.Errorf("rec = %+v, want nil", rec) } } + +// seedAuthMailbox creates a throwaway domain and mailbox and returns the mailbox id +// and address. Cleanup removes the domain, which cascades to the rest. +func seedAuthMailbox(t *testing.T, pool interface { + Exec(context.Context, string, ...any) (pgconn.CommandTag, error) +}) (uuid.UUID, string) { + t.Helper() + ctx := context.Background() + + domainID := uuid.New() + domainName := "authrepo-app-" + domainID.String() + ".invalid" + if _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`, domainID, domainName, domainName); err != nil { + t.Fatalf("seed domain: %v", err) + } + t.Cleanup(func() { pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, domainID) }) + + mailboxID := uuid.New() + address := "user@" + domainName + if _, err := pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'user', $3, 'main-hash')`, + mailboxID, domainID, address); err != nil { + t.Fatalf("seed mailbox: %v", err) + } + return mailboxID, address +} + +func TestAuthRepository_FindByAddress_LoadsAppPasswordsWithTheirScopes(t *testing.T) { + pool := testPool(t) + defer pool.Close() + ctx := context.Background() + + mailboxID, address := seedAuthMailbox(t, pool) + + if _, err := pool.Exec(ctx, `INSERT INTO app_passwords (mailbox_id, label, password_hash) VALUES ($1, 'phone', 'hash-default-scopes')`, mailboxID); err != nil { + t.Fatalf("seed app password: %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO app_passwords (mailbox_id, label, password_hash, scopes) VALUES ($1, 'reader', 'hash-imap-only', '{imap}')`, mailboxID); err != nil { + t.Fatalf("seed app password: %v", err) + } + + rec, err := NewAuthRepository(pool).FindByAddress(ctx, address) + if err != nil || rec == nil { + t.Fatalf("FindByAddress: rec=%v err=%v", rec, err) + } + if len(rec.AppPasswords) != 2 { + t.Fatalf("AppPasswords = %d, want 2", len(rec.AppPasswords)) + } + + byHash := map[string][]string{} + for _, app := range rec.AppPasswords { + byHash[app.Hash] = app.Scopes + } + if got := byHash["hash-default-scopes"]; len(got) != 3 { + t.Errorf("default scopes = %v, want imap, pop3 and smtp", got) + } + if got := byHash["hash-imap-only"]; len(got) != 1 || got[0] != "imap" { + t.Errorf("imap-only scopes = %v, want [imap]", got) + } +} + +func TestAuthRepository_FindByAddress_AMailboxWithoutAppPasswordsStillWorks(t *testing.T) { + pool := testPool(t) + defer pool.Close() + + _, address := seedAuthMailbox(t, pool) + + rec, err := NewAuthRepository(pool).FindByAddress(context.Background(), address) + if err != nil || rec == nil { + t.Fatalf("FindByAddress: rec=%v err=%v", rec, err) + } + if len(rec.AppPasswords) != 0 { + t.Errorf("AppPasswords = %v, want none", rec.AppPasswords) + } +} + +func TestAuthRepository_FindByAddress_ReadsOnlyTheNewestAppPasswords(t *testing.T) { + pool := testPool(t) + defer pool.Close() + ctx := context.Background() + + mailboxID, address := seedAuthMailbox(t, pool) + + for i := 0; i < port.MaxAppPasswordsChecked+5; i++ { + if _, err := pool.Exec(ctx, `INSERT INTO app_passwords (mailbox_id, label, password_hash, created_at) VALUES ($1, $2, 'h', NOW() - make_interval(mins => $3))`, + mailboxID, "label", i); err != nil { + t.Fatalf("seed app password: %v", err) + } + } + + rec, err := NewAuthRepository(pool).FindByAddress(ctx, address) + if err != nil || rec == nil { + t.Fatalf("FindByAddress: rec=%v err=%v", rec, err) + } + if len(rec.AppPasswords) != port.MaxAppPasswordsChecked { + t.Errorf("AppPasswords = %d, want the cap of %d", len(rec.AppPasswords), port.MaxAppPasswordsChecked) + } +} + +func TestAuthRepository_RecordAppPasswordUse_StampsLastUsedAt(t *testing.T) { + pool := testPool(t) + defer pool.Close() + ctx := context.Background() + + mailboxID, _ := seedAuthMailbox(t, pool) + + var appID uuid.UUID + if err := pool.QueryRow(ctx, `INSERT INTO app_passwords (mailbox_id, label, password_hash) VALUES ($1, 'phone', 'h') RETURNING id`, mailboxID).Scan(&appID); err != nil { + t.Fatalf("seed app password: %v", err) + } + + if err := NewAuthRepository(pool).RecordAppPasswordUse(ctx, appID); err != nil { + t.Fatalf("RecordAppPasswordUse: %v", err) + } + + var stamped bool + if err := pool.QueryRow(ctx, `SELECT last_used_at IS NOT NULL FROM app_passwords WHERE id = $1`, appID).Scan(&stamped); err != nil || !stamped { + t.Fatalf("last_used_at not set: stamped=%v err=%v", stamped, err) + } +}