mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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
|
||||
}
|
||||
@@ -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?)")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user