first commit
This commit is contained in:
74
go-api/internal/auth/token.go
Normal file
74
go-api/internal/auth/token.go
Normal file
@@ -0,0 +1,74 @@
|
||||
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) }
|
||||
Reference in New Issue
Block a user