Files
krow_backend/go-api/internal/auth/credentials.go
2026-08-24 13:06:29 +05:30

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
}