diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-29 07:50:31 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-29 07:50:31 +0300 |
| commit | aa95230758cd3487b5d4c55015c502c0f37e1760 (patch) | |
| tree | 1cc18359b7753554cbf43dcf422641a4cee90414 /internal/auth | |
| parent | 2de97cd74935b5215d2266a4ae0e06b34aa31a98 (diff) | |
feat: implement bcrypt password hashing, session management, login/logout handlers, and bootstrap flow (m9)
Diffstat (limited to 'internal/auth')
| -rw-r--r-- | internal/auth/auth.go | 39 | ||||
| -rw-r--r-- | internal/auth/auth_test.go | 184 | ||||
| -rw-r--r-- | internal/auth/session.go | 82 |
3 files changed, 305 insertions, 0 deletions
diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 6f65cd0..55dea94 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1,2 +1,41 @@ // Package auth handles authentication and authorization. package auth + +import ( + "fmt" + + "golang.org/x/crypto/bcrypt" +) + +// Hasher hashes passwords and compares plaintext against hashes. +type Hasher interface { + Hash(password string) (string, error) + Compare(hash, password string) error +} + +// BCryptHasher implements Hasher using bcrypt. +type BCryptHasher struct { + cost int +} + +// NewBCryptHasher creates a BCryptHasher with the given cost. +func NewBCryptHasher(cost int) *BCryptHasher { + return &BCryptHasher{cost: cost} +} + +// Hash returns a bcrypt hash of the password. +func (b *BCryptHasher) Hash(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), b.cost) + if err != nil { + return "", fmt.Errorf("hash password: %w", err) + } + return string(bytes), nil +} + +// Compare checks a password against a bcrypt hash. +func (b *BCryptHasher) Compare(hash, password string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) +} + +// Compile-time interface check. +var _ Hasher = (*BCryptHasher)(nil) diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..dc2b478 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,184 @@ +package auth + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/paul/kiss-media-player/internal/clock" + "github.com/paul/kiss-media-player/internal/model" + "github.com/paul/kiss-media-player/internal/repository" +) + +func TestBCryptHasher_HashAndCompare(t *testing.T) { + h := NewBCryptHasher(4) // low cost for speed + tests := []struct { + name string + password string + }{ + {"simple password", "hello"}, + {"long password", "averylongpasswordthatexceeds32charactersormore"}, + {"unicode password", "пароль密码🔐"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hash, err := h.Hash(tt.password) + if err != nil { + t.Fatalf("hash: %v", err) + } + if hash == "" { + t.Fatal("expected non-empty hash") + } + if err := h.Compare(hash, tt.password); err != nil { + t.Fatalf("compare correct password: %v", err) + } + if err := h.Compare(hash, tt.password+"x"); err == nil { + t.Fatal("expected error for wrong password") + } + // ensure same password yields different hash (salted) + hash2, _ := h.Hash(tt.password) + if hash == hash2 { + t.Fatal("expected different hashes for same password") + } + }) + } +} + +func TestSessionManager_CreateSession(t *testing.T) { + now := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + clk := &clock.MockClock{T: now} + + var created *model.Session + mockRepo := repository.MockSessionRepo{ + CreateSessionFunc: func(ctx context.Context, session *model.Session) error { + created = session + return nil + }, + } + + sm := NewSessionManager(&mockRepo, clk, time.Hour) + + id, err := sm.CreateSession(context.Background(), 42) + if err != nil { + t.Fatalf("create session: %v", err) + } + if id == "" { + t.Fatal("expected non-empty session id") + } + if created == nil { + t.Fatal("expected session to be created") + } + if created.UserID != 42 { + t.Fatalf("expected user id 42, got %d", created.UserID) + } + if created.ExpiresAt != now.Add(time.Hour) { + t.Fatalf("unexpected expires at: %v", created.ExpiresAt) + } +} + +func TestSessionManager_ValidateSession(t *testing.T) { + now := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + clk := &clock.MockClock{T: now} + + tests := []struct { + name string + returns *model.Session + returnsErr error + expectNil bool + expectDel bool + }{ + { + name: "valid session", + returns: &model.Session{ + ID: "abc", + UserID: 1, + ExpiresAt: now.Add(time.Hour), + CreatedAt: now.Add(-time.Hour), + }, + expectNil: false, + }, + { + name: "session not found", + returns: nil, + expectNil: true, + }, + { + name: "expired session", + returns: &model.Session{ + ID: "old", + UserID: 1, + ExpiresAt: now.Add(-time.Minute), + CreatedAt: now.Add(-time.Hour), + }, + expectNil: true, + expectDel: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var deleted string + repo := repository.MockSessionRepo{ + GetSessionByIDFunc: func(ctx context.Context, id string) (*model.Session, error) { + return tt.returns, tt.returnsErr + }, + DeleteSessionFunc: func(ctx context.Context, id string) error { + deleted = id + return nil + }, + } + sm := NewSessionManager(&repo, clk, time.Hour) + sess, err := sm.ValidateSession(context.Background(), "testID") + if err != nil { + t.Fatalf("validate: %v", err) + } + if (sess == nil) != tt.expectNil { + t.Fatalf("expected nil=%v, got %v", tt.expectNil, sess) + } + if tt.expectDel && deleted == "" { + t.Fatal("expected expired session to be deleted") + } + if !tt.expectDel && deleted != "" { + t.Fatal("unexpected delete") + } + }) + } +} + +func TestSessionManager_DeleteSession(t *testing.T) { + repo := repository.MockSessionRepo{ + DeleteSessionFunc: func(ctx context.Context, id string) error { + if id != "abc" { + return errors.New("unexpected id") + } + return nil + }, + } + sm := NewSessionManager(&repo, &clock.MockClock{T: time.Now()}, time.Hour) + if err := sm.DeleteSession(context.Background(), "abc"); err != nil { + t.Fatalf("delete: %v", err) + } +} + +func TestSessionManager_Cleanup(t *testing.T) { + now := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC) + clk := &clock.MockClock{T: now} + + var called time.Time + repo := repository.MockSessionRepo{ + DeleteExpiredSessionsFunc: func(ctx context.Context, t time.Time) error { + called = t + return nil + }, + } + + sm := NewSessionManager(&repo, clk, time.Hour) + if err := sm.Cleanup(context.Background()); err != nil { + t.Fatalf("cleanup: %v", err) + } + if called != now { + t.Fatalf("expected cleanup called with %v, got %v", now, called) + } +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..2b5be2a --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,82 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "github.com/paul/kiss-media-player/internal/clock" + "github.com/paul/kiss-media-player/internal/model" + "github.com/paul/kiss-media-player/internal/repository" +) + +// SessionManager handles session lifecycle. +type SessionManager struct { + repo repository.SessionRepo + clock clock.Clock + timeout time.Duration +} + +// NewSessionManager creates a SessionManager. +func NewSessionManager(repo repository.SessionRepo, clock clock.Clock, timeout time.Duration) *SessionManager { + return &SessionManager{ + repo: repo, + clock: clock, + timeout: timeout, + } +} + +func (m *SessionManager) generateID() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate session id: %w", err) + } + return hex.EncodeToString(b), nil +} + +// CreateSession creates a new session for a user and returns the session ID. +func (m *SessionManager) CreateSession(ctx context.Context, userID int64) (string, error) { + id, err := m.generateID() + if err != nil { + return "", err + } + now := m.clock.Now() + sess := &model.Session{ + ID: id, + UserID: userID, + ExpiresAt: now.Add(m.timeout), + CreatedAt: now, + } + if err := m.repo.CreateSession(ctx, sess); err != nil { + return "", fmt.Errorf("create session: %w", err) + } + return id, nil +} + +// ValidateSession checks if a session ID is valid and not expired. +func (m *SessionManager) ValidateSession(ctx context.Context, id string) (*model.Session, error) { + sess, err := m.repo.GetSessionByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("get session: %w", err) + } + if sess == nil { + return nil, nil + } + if m.clock.Now().After(sess.ExpiresAt) { + _ = m.repo.DeleteSession(ctx, id) + return nil, nil + } + return sess, nil +} + +// DeleteSession removes a session. +func (m *SessionManager) DeleteSession(ctx context.Context, id string) error { + return m.repo.DeleteSession(ctx, id) +} + +// Cleanup removes expired sessions. +func (m *SessionManager) Cleanup(ctx context.Context) error { + return m.repo.DeleteExpiredSessions(ctx, m.clock.Now()) +} |
