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.
This commit is contained in:
1 parent
8e7c0abd0a
commit
110a240cd9
10 files changed
+599
-5
No files matched your search
@@ -93,6 +93,23 @@ make reset-password EMAIL=… PASSWORD='…' # recovers a lockout, ends its se
|
|||||||
make reset-mfa EMAIL=… # clears every second factor
|
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
|
An account locks itself for 15 minutes after 5 failed passwords. That is the
|
||||||
lockout working, not a fault; `reset-password` clears it.
|
lockout working, not a fault; `reset-password` clears it.
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,29 @@ type MailboxAuth struct {
|
|||||||
DomainName string
|
DomainName string
|
||||||
// MaxRecipientsPerHour caps a compromised account's blast radius.
|
// MaxRecipientsPerHour caps a compromised account's blast radius.
|
||||||
MaxRecipientsPerHour int
|
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.
|
// AuthRepository resolves a login address to its stored credential.
|
||||||
type AuthRepository interface {
|
type AuthRepository interface {
|
||||||
// FindByAddress returns nil, nil (not an error) when no active mailbox
|
// FindByAddress returns nil, nil (not an error) when no active mailbox
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/alexedwards/argon2id"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"lambdamail/protocols/internal/application/port"
|
"lambdamail/protocols/internal/application/port"
|
||||||
@@ -64,7 +63,7 @@ func (uc *ImapSessionUseCase) Login(ctx context.Context, address, password strin
|
|||||||
if rec == nil {
|
if rec == nil {
|
||||||
return "", ErrAuthFailed
|
return "", ErrAuthFailed
|
||||||
}
|
}
|
||||||
match, err := argon2id.ComparePasswordAndHash(password, rec.PasswordHash)
|
match, err := verifyCredential(ctx, uc.auth, rec, password, ScopeIMAP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/alexedwards/argon2id"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"lambdamail/protocols/internal/application/port"
|
"lambdamail/protocols/internal/application/port"
|
||||||
@@ -42,7 +41,7 @@ func (uc *Pop3SessionUseCase) Login(ctx context.Context, username, password stri
|
|||||||
if rec == nil {
|
if rec == nil {
|
||||||
return "", ErrAuthFailed
|
return "", ErrAuthFailed
|
||||||
}
|
}
|
||||||
match, err := argon2id.ComparePasswordAndHash(password, rec.PasswordHash)
|
match, err := verifyCredential(ctx, uc.authRepo, rec, password, ScopePOP3)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("verify password hash: %w", err)
|
return "", fmt.Errorf("verify password hash: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func (uc *ProcessOutboundEmailUseCase) Authenticate(ctx context.Context, address
|
|||||||
return nil, ErrSubmissionAuthFailed
|
return nil, ErrSubmissionAuthFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
match, err := argon2id.ComparePasswordAndHash(password, rec.PasswordHash)
|
match, err := verifyCredential(ctx, uc.auth, rec, password, ScopeSMTP)
|
||||||
if err != nil || !match {
|
if err != nil || !match {
|
||||||
return nil, ErrSubmissionAuthFailed
|
return nil, ErrSubmissionAuthFailed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,5 +38,37 @@ func (r *AuthRepository) FindByAddress(ctx context.Context, address string) (*po
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rec.ID = id
|
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
|
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
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAuthRepository_FindByAddress_ReturnsHashForActiveMailbox(t *testing.T) {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user