126 lines
4.5 KiB
Go
126 lines
4.5 KiB
Go
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
|
|
}
|