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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user