package auth import ( "context" "errors" "sync" ) // ErrInvalidCredentials is the single answer to every failed sign-in. // // It is returned when the email is unknown, when the password is wrong, when // the account has no password set, and when the account is suspended. The // caller cannot tell those apart from the error, which is the point: an error // that distinguishes them is an account-enumeration oracle, and one that says // "this account is suspended" confirms the address is real. // // The distinction is still made — it goes to the server log, via the reason // returned alongside this error. var ErrInvalidCredentials = errors.New("auth: invalid credentials") // Reason is why a sign-in failed. For the log, never for the client. type Reason string const ( ReasonOK Reason = "ok" ReasonNoSuchUser Reason = "no_such_user" ReasonNoPassword Reason = "no_password_set" ReasonNotActive Reason = "user_not_active" ReasonBadPassword Reason = "wrong_password" ) // Credentials verifies a password against a stored user. type Credentials struct { users UserStore } // NewCredentials builds the verifier. func NewCredentials(users UserStore) *Credentials { return &Credentials{users: users} } // decoyHash is a real argon2id hash of a value nobody knows. // // It exists to close a timing side channel. Without it, an unknown email // returns as fast as the database can say "no rows" — a millisecond or two — // while a known email spends the ~100ms that argon2id costs by design. That // difference is trivially measurable over a network and turns the login // endpoint into an account-enumeration oracle no matter how carefully the // error messages are worded. // // So every failure that skips the real password check pays for a decoy one // instead. The comparison always fails; the cost is the entire purpose. // // Built once, lazily: it costs a full argon2id derivation, which is worth // paying on the first failed login rather than on every process start. var decoyHash = sync.OnceValue(func() string { token, err := GenerateToken() if err != nil { // A hash of a fixed string is still a fine decoy — its only job is to // take the right amount of time, and it is never compared against // anything a caller supplies. token = "decoy-password-that-is-never-correct" } hash, err := HashPassword(token) if err != nil { return "" } return hash }) // burnTime performs a password verification that is guaranteed to fail, so a // rejected sign-in costs the same as an accepted one. func burnTime(password string) { if h := decoyHash(); h != "" { _, _ = VerifyPassword(h, password) } } // Verify resolves an email and password to a user. // // On success it returns the user and ReasonOK. On any failure it returns // ErrInvalidCredentials, a zero user, and the reason — which the caller should // log and must not send to the client. // // A non-nil error that is NOT ErrInvalidCredentials is an operational failure // (the database is down, a stored hash is corrupt) and should become a 500 // rather than a 401: the caller's credentials were never actually judged. func (c *Credentials) Verify(ctx context.Context, email, password string) (User, Reason, error) { user, err := c.users.FindByEmail(ctx, email) if errors.Is(err, ErrUserNotFound) { burnTime(password) return User{}, ReasonNoSuchUser, ErrInvalidCredentials } if err != nil { return User{}, ReasonNoSuchUser, err } if user.PasswordHash == "" { // The seeded user is in this state until `setpassword` is run against // it. Refused exactly like a wrong password, at the same cost. burnTime(password) return User{}, ReasonNoPassword, ErrInvalidCredentials } ok, err := VerifyPassword(user.PasswordHash, password) if err != nil { // The stored hash could not be read. That is this server's problem, // not the caller's, and must not be reported as a failed login. return User{}, ReasonBadPassword, err } if !ok { return User{}, ReasonBadPassword, ErrInvalidCredentials } // Status is checked AFTER the password, and reported the same way. // // Order matters: checking it first would let anyone learn that an address // belongs to a suspended account without knowing its password, because the // refusal would arrive without paying the argon2 cost. Checking it after // means a suspended account is indistinguishable from a wrong password — // same answer, same timing. if !user.IsActive() { return User{}, ReasonNotActive, ErrInvalidCredentials } return user, ReasonOK, nil }