feat: implement Phase 2 auth & security with TDD

- auth/session: 256-bit crypto-random tokens, SHA-256 hashing for storage
- auth/password: bcrypt cost 12, strength validation (8-72 chars)
- auth/ratelimit: sliding-window RateLimiter with lockout, thread-safe
- db/models: User, Session, Invite, Role types
- db/auth_queries: full user/session/invite CRUD with in-memory test coverage
- api/middleware: AuthMiddleware (Bearer token), RequirePermission (bitfield),
  RateLimitMiddleware (X-Real-IP, Retry-After header)
- api/auth_handler: POST register/login, POST logout, GET me
  - Generic errors — username existence never revealed
  - Rate limits: 3/min register, 5/min login, lockout after 10 failures
- api/invite_handler: create/list/revoke behind MANAGE_INVITES permission
- bluemonday sanitization on all user-supplied string fields

Test coverage: auth 90.9%, db 84.4%, api 80.9%
This commit is contained in:
jevb
2026-03-14 20:52:11 +01:00
parent a1434ad07f
commit b7dd6eabe9
21 changed files with 3329 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
package auth
import (
"errors"
"golang.org/x/crypto/bcrypt"
)
const (
bcryptCost = 12
minPassLen = 8
maxPassLen = 72 // bcrypt silently truncates beyond 72 bytes
)
// ErrPasswordTooShort is returned when the password is below the minimum length.
var ErrPasswordTooShort = errors.New("password must be at least 8 characters")
// ErrPasswordTooLong is returned when the password exceeds bcrypt's 72-byte limit.
var ErrPasswordTooLong = errors.New("password must not exceed 72 characters")
// HashPassword returns a bcrypt hash of password using cost 12.
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// CheckPassword reports whether password matches hash. Returns false on any
// error, including an empty or malformed hash.
func CheckPassword(hash, password string) bool {
if hash == "" {
return false
}
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
// ValidatePasswordStrength returns an error if password fails strength
// requirements: minimum 8 characters, maximum 72 characters.
func ValidatePasswordStrength(password string) error {
if len(password) < minPassLen {
return ErrPasswordTooShort
}
if len(password) > maxPassLen {
return ErrPasswordTooLong
}
return nil
}
+106
View File
@@ -0,0 +1,106 @@
package auth_test
import (
"strings"
"testing"
"github.com/owncord/server/auth"
)
func TestHashPassword_DiffersFromPlaintext(t *testing.T) {
hash, err := auth.HashPassword("mypassword")
if err != nil {
t.Fatalf("HashPassword() error = %v", err)
}
if hash == "mypassword" {
t.Error("HashPassword() hash equals plaintext")
}
}
func TestHashPassword_BcryptPrefix(t *testing.T) {
hash, err := auth.HashPassword("mypassword")
if err != nil {
t.Fatalf("HashPassword() error = %v", err)
}
if !strings.HasPrefix(hash, "$2") {
t.Errorf("HashPassword() = %q, want bcrypt prefix $2*", hash)
}
}
func TestCheckPassword_CorrectPassword(t *testing.T) {
hash, err := auth.HashPassword("correctpassword")
if err != nil {
t.Fatalf("HashPassword() error = %v", err)
}
if !auth.CheckPassword(hash, "correctpassword") {
t.Error("CheckPassword() returned false for correct password")
}
}
func TestCheckPassword_WrongPassword(t *testing.T) {
hash, err := auth.HashPassword("correctpassword")
if err != nil {
t.Fatalf("HashPassword() error = %v", err)
}
if auth.CheckPassword(hash, "wrongpassword") {
t.Error("CheckPassword() returned true for wrong password")
}
}
func TestCheckPassword_EmptyPassword(t *testing.T) {
hash, err := auth.HashPassword("somepassword")
if err != nil {
t.Fatalf("HashPassword() error = %v", err)
}
if auth.CheckPassword(hash, "") {
t.Error("CheckPassword() returned true for empty password")
}
}
func TestCheckPassword_EmptyHash(t *testing.T) {
if auth.CheckPassword("", "somepassword") {
t.Error("CheckPassword() returned true with empty hash")
}
}
func TestValidatePasswordStrength_Valid(t *testing.T) {
cases := []string{
"12345678", // exactly 8 chars
"abcdefghij", // 10 chars
strings.Repeat("a", 72), // exactly 72 chars (bcrypt max)
}
for _, pw := range cases {
if err := auth.ValidatePasswordStrength(pw); err != nil {
t.Errorf("ValidatePasswordStrength(%q) error = %v, want nil", pw, err)
}
}
}
func TestValidatePasswordStrength_TooShort(t *testing.T) {
cases := []string{
"", // empty
"1234567", // 7 chars
"abc", // 3 chars
}
for _, pw := range cases {
if err := auth.ValidatePasswordStrength(pw); err == nil {
t.Errorf("ValidatePasswordStrength(%q) error = nil, want error", pw)
}
}
}
func TestValidatePasswordStrength_TooLong(t *testing.T) {
pw := strings.Repeat("a", 73) // 73 chars — over bcrypt 72 byte limit
if err := auth.ValidatePasswordStrength(pw); err == nil {
t.Errorf("ValidatePasswordStrength(%q) error = nil, want error for >72 chars", pw)
}
}
func TestHashPassword_TwoCallsDifferentHashes(t *testing.T) {
// bcrypt includes a random salt
h1, _ := auth.HashPassword("password")
h2, _ := auth.HashPassword("password")
if h1 == h2 {
t.Error("HashPassword() produced identical hashes for the same password (salt missing?)")
}
}
+104
View File
@@ -0,0 +1,104 @@
package auth
import (
"sync"
"time"
)
// entry records individual request timestamps for sliding-window limiting.
type entry struct {
timestamps []time.Time
}
// lockoutEntry records when a lockout expires.
type lockoutEntry struct {
expiresAt time.Time
}
// RateLimiter is an in-memory, thread-safe sliding-window rate limiter with
// optional IP lockout support.
type RateLimiter struct {
mu sync.Mutex
windows map[string]*entry
lockouts map[string]*lockoutEntry
}
// NewRateLimiter returns an initialised RateLimiter.
func NewRateLimiter() *RateLimiter {
return &RateLimiter{
windows: make(map[string]*entry),
lockouts: make(map[string]*lockoutEntry),
}
}
// Allow reports whether a request from key is permitted given the limit and
// window. It records the current request timestamp regardless of the outcome.
// Returns false when key is locked out or has exceeded limit within window.
func (r *RateLimiter) Allow(key string, limit int, window time.Duration) bool {
r.mu.Lock()
defer r.mu.Unlock()
// Lockout takes priority.
if lo, ok := r.lockouts[key]; ok {
if time.Now().Before(lo.expiresAt) {
return false
}
delete(r.lockouts, key)
}
now := time.Now()
cutoff := now.Add(-window)
e, ok := r.windows[key]
if !ok {
e = &entry{}
r.windows[key] = e
}
// Prune timestamps outside the current window.
valid := e.timestamps[:0]
for _, ts := range e.timestamps {
if ts.After(cutoff) {
valid = append(valid, ts)
}
}
e.timestamps = valid
if len(e.timestamps) >= limit {
return false
}
e.timestamps = append(e.timestamps, now)
return true
}
// Lockout prevents any requests from key for duration regardless of the
// sliding-window counter.
func (r *RateLimiter) Lockout(key string, duration time.Duration) {
r.mu.Lock()
defer r.mu.Unlock()
r.lockouts[key] = &lockoutEntry{expiresAt: time.Now().Add(duration)}
}
// IsLockedOut reports whether key is currently under a lockout.
func (r *RateLimiter) IsLockedOut(key string) bool {
r.mu.Lock()
defer r.mu.Unlock()
lo, ok := r.lockouts[key]
if !ok {
return false
}
if time.Now().Before(lo.expiresAt) {
return true
}
delete(r.lockouts, key)
return false
}
// Reset clears all rate-limit state (timestamps and lockout) for key.
func (r *RateLimiter) Reset(key string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.windows, key)
delete(r.lockouts, key)
}
+126
View File
@@ -0,0 +1,126 @@
package auth_test
import (
"testing"
"time"
"github.com/owncord/server/auth"
)
func TestRateLimiter_UnderLimitAllowed(t *testing.T) {
rl := auth.NewRateLimiter()
for i := 0; i < 5; i++ {
if !rl.Allow("key1", 5, time.Second) {
t.Errorf("Allow() = false at iteration %d, want true", i)
}
}
}
func TestRateLimiter_AtLimitAllowed(t *testing.T) {
rl := auth.NewRateLimiter()
// Allow up to exactly the limit
for i := 0; i < 3; i++ {
rl.Allow("keyA", 3, time.Second)
}
// The 4th call should be blocked
if rl.Allow("keyA", 3, time.Second) {
t.Error("Allow() = true after limit exceeded, want false")
}
}
func TestRateLimiter_OverLimitBlocked(t *testing.T) {
rl := auth.NewRateLimiter()
limit := 3
for i := 0; i < limit; i++ {
rl.Allow("key2", limit, time.Second)
}
if rl.Allow("key2", limit, time.Second) {
t.Error("Allow() = true when over limit, want false")
}
}
func TestRateLimiter_WindowExpiryResets(t *testing.T) {
rl := auth.NewRateLimiter()
window := 50 * time.Millisecond
limit := 2
// Exhaust limit
rl.Allow("key3", limit, window)
rl.Allow("key3", limit, window)
if rl.Allow("key3", limit, window) {
t.Error("Allow() should be blocked after exhausting limit")
}
// Wait for window to expire
time.Sleep(window + 10*time.Millisecond)
if !rl.Allow("key3", limit, window) {
t.Error("Allow() should be permitted after window expires")
}
}
func TestRateLimiter_DifferentKeysIndependent(t *testing.T) {
rl := auth.NewRateLimiter()
for i := 0; i < 5; i++ {
rl.Allow("keyX", 3, time.Second)
}
// keyY should still be allowed
if !rl.Allow("keyY", 3, time.Second) {
t.Error("Allow() blocked keyY even though only keyX exceeded limit")
}
}
func TestRateLimiter_LockoutEnforced(t *testing.T) {
rl := auth.NewRateLimiter()
rl.Lockout("keyLock", time.Hour)
if !rl.IsLockedOut("keyLock") {
t.Error("IsLockedOut() = false after Lockout(), want true")
}
}
func TestRateLimiter_LockoutExpires(t *testing.T) {
rl := auth.NewRateLimiter()
rl.Lockout("keyExp", 30*time.Millisecond)
time.Sleep(50 * time.Millisecond)
if rl.IsLockedOut("keyExp") {
t.Error("IsLockedOut() = true after lockout expired, want false")
}
}
func TestRateLimiter_IsLockedOut_UnknownKey(t *testing.T) {
rl := auth.NewRateLimiter()
if rl.IsLockedOut("unknown") {
t.Error("IsLockedOut() = true for unknown key, want false")
}
}
func TestRateLimiter_Reset(t *testing.T) {
rl := auth.NewRateLimiter()
rl.Allow("keyR", 1, time.Second)
rl.Allow("keyR", 1, time.Second) // now blocked
rl.Reset("keyR")
if !rl.Allow("keyR", 1, time.Second) {
t.Error("Allow() = false after Reset(), want true")
}
}
func TestRateLimiter_LockoutBlocksAllow(t *testing.T) {
rl := auth.NewRateLimiter()
rl.Lockout("keyLB", time.Hour)
// Even under normal limit, lockout should block
if rl.Allow("keyLB", 100, time.Second) {
t.Error("Allow() = true for locked-out key, want false")
}
}
func TestRateLimiter_ThreadSafe(t *testing.T) {
rl := auth.NewRateLimiter()
done := make(chan struct{}, 100)
for i := 0; i < 100; i++ {
go func() {
rl.Allow("concurrent", 50, time.Second)
done <- struct{}{}
}()
}
for i := 0; i < 100; i++ {
<-done
}
// If we get here without a race condition data race, we pass
}
+24
View File
@@ -0,0 +1,24 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
)
// GenerateToken returns a cryptographically random 256-bit token encoded as a
// 64-character lowercase hex string.
func GenerateToken() (string, error) {
raw := make([]byte, 32) // 256 bits
if _, err := rand.Read(raw); err != nil {
return "", err
}
return hex.EncodeToString(raw), nil
}
// HashToken returns the SHA-256 hex digest of token. Store this hash in the
// database; never store the plaintext token.
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
+77
View File
@@ -0,0 +1,77 @@
package auth_test
import (
"testing"
"github.com/owncord/server/auth"
)
func TestGenerateToken_Length(t *testing.T) {
token, err := auth.GenerateToken()
if err != nil {
t.Fatalf("GenerateToken() error = %v", err)
}
if len(token) != 64 {
t.Errorf("GenerateToken() len = %d, want 64", len(token))
}
}
func TestGenerateToken_HexCharacters(t *testing.T) {
token, err := auth.GenerateToken()
if err != nil {
t.Fatalf("GenerateToken() error = %v", err)
}
for i, c := range token {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
t.Errorf("GenerateToken() char[%d] = %q, not lowercase hex", i, c)
}
}
}
func TestGenerateToken_Uniqueness(t *testing.T) {
const n = 1000
seen := make(map[string]struct{}, n)
for i := 0; i < n; i++ {
tok, err := auth.GenerateToken()
if err != nil {
t.Fatalf("GenerateToken() iteration %d error = %v", i, err)
}
if _, dup := seen[tok]; dup {
t.Fatalf("GenerateToken() produced duplicate token at iteration %d", i)
}
seen[tok] = struct{}{}
}
}
func TestHashToken_Deterministic(t *testing.T) {
token := "abc123"
h1 := auth.HashToken(token)
h2 := auth.HashToken(token)
if h1 != h2 {
t.Errorf("HashToken() not deterministic: %q != %q", h1, h2)
}
}
func TestHashToken_DiffersFromPlaintext(t *testing.T) {
token := "abc123"
hash := auth.HashToken(token)
if hash == token {
t.Errorf("HashToken() hash equals plaintext token")
}
}
func TestHashToken_Length(t *testing.T) {
// SHA-256 hex = 64 chars
hash := auth.HashToken("any-token")
if len(hash) != 64 {
t.Errorf("HashToken() len = %d, want 64", len(hash))
}
}
func TestHashToken_DifferentInputsDifferentHashes(t *testing.T) {
h1 := auth.HashToken("token-one")
h2 := auth.HashToken("token-two")
if h1 == h2 {
t.Errorf("HashToken() same hash for different inputs")
}
}