package auth import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "fmt" "log/slog" "net/http" "strings" "time" "update_server/internal/config" "update_server/internal/db" ) const sessionTokenBytes = 32 const sessionCookiePath = "/admin" var ( ErrInvalidCredentials = errors.New("invalid credentials") ErrUnauthenticated = errors.New("unauthenticated") ErrUnauthorized = errors.New("unauthorized") ) type Service struct { config config.Config logger *slog.Logger store *db.Store } type SessionState struct { User db.User Session db.Session } func NewService(cfg config.Config, logger *slog.Logger, store *db.Store) *Service { return &Service{ config: cfg, logger: logger, store: store, } } func (s *Service) EnsureBootstrapAdmin(ctx context.Context) error { hasActiveAdmin, err := s.store.Users.HasActiveAdmin(ctx) if err != nil { return fmt.Errorf("check active admin users: %w", err) } if hasActiveAdmin { return nil } email := strings.TrimSpace(s.config.AdminEmail) password := s.config.AdminPassword if email == "" || strings.TrimSpace(password) == "" { if s.logger != nil { s.logger.Warn("no active admin user found; set ADMIN_EMAIL and ADMIN_PASSWORD to bootstrap the first admin") } return nil } if existingUser, err := s.store.Users.GetByEmail(ctx, email); err == nil { if s.logger != nil { s.logger.Warn("bootstrap admin skipped because the configured email already exists", "email", existingUser.Email) } return nil } else if !errors.Is(err, db.ErrNotFound) { return fmt.Errorf("check bootstrap admin email: %w", err) } passwordHash, err := HashPassword(password) if err != nil { return fmt.Errorf("hash bootstrap admin password: %w", err) } if _, err := s.store.Users.Create(ctx, db.CreateUserParams{ Email: email, PasswordHash: passwordHash, Role: db.UserRoleAdmin, IsActive: true, }); err != nil { return fmt.Errorf("create bootstrap admin user: %w", err) } if s.logger != nil { s.logger.Info("bootstrapped admin user", "email", email) } return nil } func (s *Service) Authenticate(ctx context.Context, email, password, ipAddress, userAgent string) (string, *SessionState, error) { email = strings.TrimSpace(email) if email == "" || password == "" { return "", nil, ErrInvalidCredentials } user, err := s.store.Users.GetByEmail(ctx, email) if err != nil { if errors.Is(err, db.ErrNotFound) { return "", nil, ErrInvalidCredentials } return "", nil, fmt.Errorf("load user by email: %w", err) } if !user.IsActive { return "", nil, ErrInvalidCredentials } if err := ComparePassword(user.PasswordHash, password); err != nil { return "", nil, ErrInvalidCredentials } now := time.Now().UTC() expiresAt := now.Add(s.config.SessionTTL) token, tokenHash, err := generateSessionToken() if err != nil { return "", nil, err } var session *db.Session if err := s.store.WithTx(ctx, func(tx *db.TxStore) error { createdSession, err := tx.Sessions.Create(ctx, db.CreateSessionParams{ UserID: user.ID, TokenHash: tokenHash, ExpiresAt: expiresAt, IPAddress: ipAddress, UserAgent: userAgent, }) if err != nil { return err } if err := tx.Users.UpdateLastLoginAt(ctx, user.ID, now); err != nil { return err } session = createdSession return nil }); err != nil { return "", nil, fmt.Errorf("create authenticated session: %w", err) } user.LastLoginAt = &now return token, &SessionState{ User: *user, Session: *session, }, nil } func (s *Service) LoadSession(ctx context.Context, token string) (*SessionState, error) { token = strings.TrimSpace(token) if token == "" { return nil, ErrUnauthenticated } now := time.Now().UTC() record, err := s.store.Sessions.GetActiveWithUserByTokenHash(ctx, hashSessionToken(token), now) if err != nil { if errors.Is(err, db.ErrNotFound) { return nil, ErrUnauthenticated } return nil, fmt.Errorf("lookup active session: %w", err) } if err := s.store.Sessions.Touch(ctx, record.Session.ID, now); err != nil { return nil, fmt.Errorf("touch active session: %w", err) } record.Session.LastSeenAt = &now return &SessionState{ User: record.User, Session: record.Session, }, nil } func (s *Service) InvalidateSession(ctx context.Context, token string) error { token = strings.TrimSpace(token) if token == "" { return nil } if err := s.store.Sessions.InvalidateByTokenHash(ctx, hashSessionToken(token), time.Now().UTC()); err != nil && !errors.Is(err, db.ErrNotFound) { return fmt.Errorf("invalidate session: %w", err) } return nil } func (s *Service) SessionCookie(token string, expiresAt time.Time) *http.Cookie { maxAge := int(time.Until(expiresAt).Seconds()) if maxAge < 0 { maxAge = 0 } return &http.Cookie{ Name: s.config.SessionCookieName, Value: token, Path: sessionCookiePath, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: s.config.SecureCookies, Expires: expiresAt.UTC(), MaxAge: maxAge, } } func (s *Service) ClearSessionCookie() *http.Cookie { return &http.Cookie{ Name: s.config.SessionCookieName, Value: "", Path: sessionCookiePath, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: s.config.SecureCookies, Expires: time.Unix(0, 0).UTC(), MaxAge: -1, } } func (s *Service) SessionCookieName() string { return s.config.SessionCookieName } func RoleAllowed(actualRole, requiredRole db.UserRole) bool { ranks := map[db.UserRole]int{ db.UserRoleViewer: 1, db.UserRoleEditor: 2, db.UserRoleAdmin: 3, } return ranks[actualRole] >= ranks[requiredRole] && ranks[requiredRole] > 0 } func generateSessionToken() (string, string, error) { bytes := make([]byte, sessionTokenBytes) if _, err := rand.Read(bytes); err != nil { return "", "", fmt.Errorf("generate session token: %w", err) } token := base64.RawURLEncoding.EncodeToString(bytes) return token, hashSessionToken(token), nil } func hashSessionToken(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) }