morz-infoboard/server/backend/internal/store/auth.go
Jesko Anschütz 0b21be6469 Tenant-Feature Phase 3: Auth-Middleware verdrahtet
- TenantSlug in User-Struct + GetSessionUser per JOIN befüllt
- middleware.go: RequireAuth, RequireAdmin, RequireTenantAccess
- router.go: alle Routen mit passendem Middleware-Stack gesichert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 17:52:55 +01:00

191 lines
5.9 KiB
Go

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"`
TenantSlug string `json:"tenant_slug"`
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.
// TenantSlug is populated via JOIN on tenants.
func (s *AuthStore) GetSessionUser(ctx context.Context, sessionID string) (*User, error) {
row := s.pool.QueryRow(ctx,
`select u.id, u.tenant_id, coalesce(t.slug, ''), u.username, u.password_hash, u.role, u.created_at
from sessions se
join users u on u.id = se.user_id
left join tenants t on t.id = u.tenant_id
where se.id = $1
and se.expires_at > now()`, sessionID)
return scanUserWithSlug(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
}
// scanUserWithSlug scans a row that includes tenant_slug as the third column.
func scanUserWithSlug(row interface {
Scan(dest ...any) error
}) (*User, error) {
var u User
err := row.Scan(&u.ID, &u.TenantID, &u.TenantSlug, &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
}