75 lines
3.1 KiB
Go
75 lines
3.1 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
)
|
|
|
|
// ErrEmptyToken is returned when a token is required and none was supplied.
|
|
// It is deliberately distinct from "no such session": an absent cookie is a
|
|
// different situation from a cookie that no longer matches a row.
|
|
var ErrEmptyToken = errors.New("auth: session token is empty")
|
|
|
|
// TokenBytes is the entropy behind a session token.
|
|
//
|
|
// 32 bytes — 256 bits — is the size of the SHA-256 output it is hashed to, so
|
|
// nothing is wasted at either end, and it puts guessing a live session far
|
|
// beyond reach: an attacker who could test a billion candidates a second would
|
|
// still need on the order of 10^60 years.
|
|
//
|
|
// This is a raw byte count, not a character count. The encoded token is 43
|
|
// characters of base64url.
|
|
const TokenBytes = 32
|
|
|
|
// tokenHashPattern is the exact shape stored in sessions.token_hash, and the
|
|
// same pattern the sessions_token_hash_sha256 CHECK constraint enforces in
|
|
// migration 000004. Validating here turns a database constraint violation into
|
|
// a clear Go error at the point the mistake was made.
|
|
var tokenHashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
|
|
|
// GenerateToken returns a new, cryptographically random session token.
|
|
//
|
|
// The returned string is the secret itself. It is what goes into the HttpOnly
|
|
// cookie and it must never be written to the database, to a log, or to an
|
|
// error message. Only its hash is persisted — see HashToken.
|
|
//
|
|
// base64url without padding, so the value is safe in a cookie, a header and a
|
|
// URL without escaping, and contains no '=' to be mangled by a cookie parser.
|
|
func GenerateToken() (string, error) {
|
|
buf := make([]byte, TokenBytes)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
// There is no fallback. math/rand here would produce tokens an
|
|
// attacker can predict from a handful of observed sessions.
|
|
return "", fmt.Errorf("auth: read random bytes: %w", err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
|
}
|
|
|
|
// HashToken returns the lowercase hex SHA-256 of a session token.
|
|
//
|
|
// This is what the database stores. A plain hash — not argon2 — is the right
|
|
// choice here and the wrong one for a password, and the difference is entropy:
|
|
// a session token is 256 uniformly random bits, so there is no dictionary to
|
|
// run against it and no work factor worth paying on every single request. A
|
|
// password is chosen by a human and needs argon2id precisely because it is not.
|
|
//
|
|
// The function is pure and deterministic: the same token always hashes to the
|
|
// same string, which is what makes lookup by hash possible at all.
|
|
func HashToken(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// IsTokenHash reports whether s has the shape HashToken produces.
|
|
//
|
|
// Used to catch the one mistake that would be catastrophic and silent: passing
|
|
// a raw token where a hash is expected, and storing the secret in plaintext.
|
|
// A raw token is base64url and contains characters outside [0-9a-f], or is the
|
|
// wrong length, so it always fails this test.
|
|
func IsTokenHash(s string) bool { return tokenHashPattern.MatchString(s) }
|