feat(auth): session token + password verification helpers
Shared primitives for /api/* auth: mint a url-safe opaque token, hash it for storage, verify a bcrypt password hash. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// sessionTokenBytes is the raw entropy per session token. 32 bytes of
|
||||
// crypto/rand gives ~256 bits; after base64 url-safe encoding the cookie
|
||||
// value is 43 chars with no padding.
|
||||
const sessionTokenBytes = 32
|
||||
|
||||
// MintSessionToken returns a freshly-generated, url-safe opaque token.
|
||||
// The token is what the client carries; the DB only ever sees its sha256.
|
||||
func MintSessionToken() (string, error) {
|
||||
b := make([]byte, sessionTokenBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// HashSessionToken is the single source of truth for mapping a raw token to
|
||||
// the `sessions.token_hash` column. sha256 is fine here — we're not guarding
|
||||
// against offline brute force (the token has 256 bits of entropy); we only
|
||||
// want "leaked DB row can't be replayed without also having the raw token."
|
||||
func HashSessionToken(token string) []byte {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// VerifyPassword is the canonical bcrypt comparison. Returns false on a
|
||||
// malformed hash so callers don't need to distinguish "hash invalid" from
|
||||
// "password wrong" — both are auth failures from the client's perspective.
|
||||
func VerifyPassword(hash, plaintext string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintext)) == nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestMintSessionToken_ReturnsUrlSafeBase64(t *testing.T) {
|
||||
token, err := MintSessionToken()
|
||||
if err != nil {
|
||||
t.Fatalf("MintSessionToken: %v", err)
|
||||
}
|
||||
if len(token) < 40 {
|
||||
t.Errorf("token length = %d, want >= 40 (32B base64 url-safe)", len(token))
|
||||
}
|
||||
if strings.ContainsAny(token, "+/=") {
|
||||
t.Errorf("token %q contains non-url-safe chars", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashSessionToken_IsDeterministicSHA256(t *testing.T) {
|
||||
token := "test-token-xyz"
|
||||
want := sha256.Sum256([]byte(token))
|
||||
|
||||
got := HashSessionToken(token)
|
||||
if len(got) != sha256.Size {
|
||||
t.Fatalf("hash length = %d, want %d", len(got), sha256.Size)
|
||||
}
|
||||
if base64.StdEncoding.EncodeToString(got) != base64.StdEncoding.EncodeToString(want[:]) {
|
||||
t.Errorf("hash = %x, want %x", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPassword(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("hunter2"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateFromPassword: %v", err)
|
||||
}
|
||||
if !VerifyPassword(string(hash), "hunter2") {
|
||||
t.Error("correct password rejected")
|
||||
}
|
||||
if VerifyPassword(string(hash), "wrong") {
|
||||
t.Error("wrong password accepted")
|
||||
}
|
||||
if VerifyPassword("not-a-hash", "hunter2") {
|
||||
t.Error("malformed hash accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user