diff --git a/server/backend/internal/db/migrations/002_auth.sql b/server/backend/internal/db/migrations/002_auth.sql new file mode 100644 index 0000000..76b8cef --- /dev/null +++ b/server/backend/internal/db/migrations/002_auth.sql @@ -0,0 +1,22 @@ +-- 002_auth.sql +-- Auth-Schema: Users und Sessions fuer Tenant-Login + +create table if not exists users ( + id text primary key default gen_random_uuid()::text, + tenant_id text not null references tenants(id) on delete cascade, + username text not null, + password_hash text not null, + role text not null default 'tenant', + created_at timestamptz not null default now(), + unique(tenant_id, username) +); + +create table if not exists sessions ( + id text primary key default gen_random_uuid()::text, + user_id text not null references users(id) on delete cascade, + created_at timestamptz not null default now(), + expires_at timestamptz not null default (now() + interval '8 hours') +); + +create index if not exists idx_sessions_user_id on sessions(user_id); +create index if not exists idx_sessions_expires_at on sessions(expires_at); diff --git a/server/backend/internal/store/auth.go b/server/backend/internal/store/auth.go new file mode 100644 index 0000000..82b1ed1 --- /dev/null +++ b/server/backend/internal/store/auth.go @@ -0,0 +1,173 @@ +package store + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/crypto/bcrypt" +) + +// ------------------------------------------------------------------ +// Domain types +// ------------------------------------------------------------------ + +// User represents an authenticated user belonging to a tenant. +type User struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + Role string `json:"role"` + CreatedAt time.Time `json:"created_at"` +} + +// Session represents an authenticated session token. +type Session struct { + ID string `json:"id"` + UserID string `json:"user_id"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +// ------------------------------------------------------------------ +// AuthStore +// ------------------------------------------------------------------ + +// AuthStore handles user authentication and session management. +type AuthStore struct{ pool *pgxpool.Pool } + +// NewAuthStore creates a new AuthStore backed by pool. +func NewAuthStore(pool *pgxpool.Pool) *AuthStore { return &AuthStore{pool} } + +// GetUserByUsername returns the user with the given username or pgx.ErrNoRows. +func (s *AuthStore) GetUserByUsername(ctx context.Context, username string) (*User, error) { + row := s.pool.QueryRow(ctx, + `select id, tenant_id, username, password_hash, role, created_at + from users + where username = $1`, username) + return scanUser(row) +} + +// CreateSession inserts a new session for userID with the given TTL and returns the session. +func (s *AuthStore) CreateSession(ctx context.Context, userID string, ttl time.Duration) (*Session, error) { + expiresAt := time.Now().Add(ttl) + row := s.pool.QueryRow(ctx, + `insert into sessions(user_id, expires_at) + values($1, $2) + returning id, user_id, created_at, expires_at`, + userID, expiresAt) + return scanSession(row) +} + +// GetSessionUser returns the user associated with sessionID if the session is still valid. +// Returns pgx.ErrNoRows when the session does not exist or has expired. +func (s *AuthStore) GetSessionUser(ctx context.Context, sessionID string) (*User, error) { + row := s.pool.QueryRow(ctx, + `select u.id, u.tenant_id, u.username, u.password_hash, u.role, u.created_at + from sessions se + join users u on u.id = se.user_id + where se.id = $1 + and se.expires_at > now()`, sessionID) + return scanUser(row) +} + +// DeleteSession removes the session with the given ID. +func (s *AuthStore) DeleteSession(ctx context.Context, sessionID string) error { + _, err := s.pool.Exec(ctx, `delete from sessions where id = $1`, sessionID) + return err +} + +// CleanExpiredSessions removes all sessions whose expires_at is in the past. +func (s *AuthStore) CleanExpiredSessions(ctx context.Context) error { + _, err := s.pool.Exec(ctx, `delete from sessions where expires_at <= now()`) + return err +} + +// VerifyPassword checks if the provided password matches the hashed password for a user. +func (s *AuthStore) VerifyPassword(ctx context.Context, userID, password string) (bool, error) { + var passwordHash string + err := s.pool.QueryRow(ctx, + `select password_hash from users where id = $1`, userID). + Scan(&passwordHash) + if err != nil { + if err == pgx.ErrNoRows { + return false, nil + } + return false, fmt.Errorf("auth: get password hash: %w", err) + } + + err = bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)) + return err == nil, nil +} + +// EnsureAdminUser creates an 'admin' user for the tenant identified by tenantSlug +// if no user with username 'admin' already exists. The password is hashed with bcrypt. +// bcrypt cost factor 12 is used (minimum recommended for production). +func (s *AuthStore) EnsureAdminUser(ctx context.Context, tenantSlug, password string) error { + // Check whether 'admin' user already exists for this tenant. + var exists bool + err := s.pool.QueryRow(ctx, + `select exists(select 1 from users where username = $1)`, + "admin", + ).Scan(&exists) + if err != nil { + return fmt.Errorf("auth: check admin user: %w", err) + } + if exists { + return nil + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) + if err != nil { + return fmt.Errorf("auth: hash password: %w", err) + } + + _, err = s.pool.Exec(ctx, + `insert into users(tenant_id, username, password_hash, role) + values( + (select id from tenants where slug = $1), + 'admin', + $2, + 'admin' + )`, + tenantSlug, string(hash)) + if err != nil { + return fmt.Errorf("auth: create admin user: %w", err) + } + return nil +} + +// ------------------------------------------------------------------ +// scan helpers +// ------------------------------------------------------------------ + +func scanUser(row interface { + Scan(dest ...any) error +}) (*User, error) { + var u User + err := row.Scan(&u.ID, &u.TenantID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt) + if err != nil { + if err == pgx.ErrNoRows { + return nil, pgx.ErrNoRows + } + return nil, fmt.Errorf("scan user: %w", err) + } + return &u, nil +} + +func scanSession(row interface { + Scan(dest ...any) error +}) (*Session, error) { + var se Session + err := row.Scan(&se.ID, &se.UserID, &se.CreatedAt, &se.ExpiresAt) + if err != nil { + if err == pgx.ErrNoRows { + return nil, pgx.ErrNoRows + } + return nil, fmt.Errorf("scan session: %w", err) + } + return &se, nil +}