summaryrefslogtreecommitdiff
path: root/internal/auth
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-29 07:50:31 +0300
committerPaul Buetow <paul@buetow.org>2026-04-29 07:50:31 +0300
commitaa95230758cd3487b5d4c55015c502c0f37e1760 (patch)
tree1cc18359b7753554cbf43dcf422641a4cee90414 /internal/auth
parent2de97cd74935b5215d2266a4ae0e06b34aa31a98 (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.go39
-rw-r--r--internal/auth/auth_test.go184
-rw-r--r--internal/auth/session.go82
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())
+}