Files
tyto/backend/internal/auth/auth.go
vikingowl 50c5811e22 feat: add authentication system with local and LDAP support
Auth Package (internal/auth/):
- Service: main auth orchestrator with multi-provider support
- LocalProvider: username/password auth with bcrypt hashing
- LDAPProvider: LDAP/Active Directory authentication with:
  - Service account bind for user search
  - User bind for password verification
  - Automatic user provisioning on first login
  - Group membership to role synchronization
- SessionManager: token-based session lifecycle
- Middleware: Gin middleware for route protection
- API: REST endpoints for login/logout/register

Security Features:
- bcrypt with cost factor 12 for password hashing
- Secure random 32-byte session tokens
- HTTP-only session cookies with SameSite=Lax
- Bearer token support for API clients
- Session expiration and cleanup
- Account disable with session invalidation

API Endpoints:
- POST /auth/login - Authenticate and get session
- POST /auth/logout - Invalidate current session
- POST /auth/logout/all - Invalidate all user sessions
- POST /auth/register - Create account (if enabled)
- GET /auth/me - Get current user info
- PUT /auth/me - Update profile
- PUT /auth/me/password - Change password

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 08:24:39 +01:00

395 lines
9.4 KiB
Go

// Package auth provides authentication and authorization for Tyto.
package auth
import (
"context"
"errors"
"time"
"tyto/internal/database"
)
// Common errors
var (
ErrInvalidCredentials = errors.New("invalid username or password")
ErrUserNotFound = errors.New("user not found")
ErrUserDisabled = errors.New("user account is disabled")
ErrSessionExpired = errors.New("session has expired")
ErrSessionNotFound = errors.New("session not found")
ErrUsernameExists = errors.New("username already exists")
ErrInvalidToken = errors.New("invalid or expired token")
)
// Provider defines the interface for authentication providers.
type Provider interface {
// Authenticate validates credentials and returns user info on success.
Authenticate(ctx context.Context, username, password string) (*database.User, error)
// Name returns the provider name (e.g., "local", "ldap").
Name() string
// Available returns true if this provider is configured and available.
Available() bool
}
// Service provides authentication operations.
type Service struct {
db database.Database
sessions *SessionManager
providers []Provider
config *Config
}
// Config contains authentication configuration.
type Config struct {
// Session settings
SessionDuration time.Duration
SessionCookieName string
SessionCookiePath string
SessionCookieHTTPS bool
// Registration settings
AllowRegistration bool
DefaultRole string
// Password policy
MinPasswordLength int
// LDAP settings (if enabled)
LDAP *LDAPConfig
}
// DefaultConfig returns default authentication configuration.
func DefaultConfig() *Config {
return &Config{
SessionDuration: 24 * time.Hour,
SessionCookieName: "tyto_session",
SessionCookiePath: "/",
SessionCookieHTTPS: false, // Set true in production
AllowRegistration: false, // Disabled by default for security
DefaultRole: "viewer",
MinPasswordLength: 8,
}
}
// NewService creates a new authentication service.
func NewService(db database.Database, cfg *Config) *Service {
if cfg == nil {
cfg = DefaultConfig()
}
s := &Service{
db: db,
sessions: NewSessionManager(db, cfg.SessionDuration),
config: cfg,
}
// Register local provider by default
s.providers = append(s.providers, NewLocalProvider(db))
// Register LDAP provider if configured
if cfg.LDAP != nil && cfg.LDAP.Enabled {
s.providers = append(s.providers, NewLDAPProvider(db, cfg.LDAP))
}
return s
}
// Login authenticates a user and creates a session.
func (s *Service) Login(ctx context.Context, username, password, ipAddress, userAgent string) (*database.Session, *database.User, error) {
var user *database.User
var err error
// Try each provider until one succeeds
for _, provider := range s.providers {
if !provider.Available() {
continue
}
user, err = provider.Authenticate(ctx, username, password)
if err == nil {
break
}
// If it's not an invalid credentials error, log it but continue
if !errors.Is(err, ErrInvalidCredentials) && !errors.Is(err, ErrUserNotFound) {
// Log provider error but try next provider
continue
}
}
if user == nil {
return nil, nil, ErrInvalidCredentials
}
if user.Disabled {
return nil, nil, ErrUserDisabled
}
// Update last login time
user.LastLogin = time.Now().UTC()
if err := s.db.UpdateUser(ctx, user); err != nil {
// Non-fatal, continue with session creation
}
// Create session
session, err := s.sessions.Create(ctx, user.ID, ipAddress, userAgent)
if err != nil {
return nil, nil, err
}
return session, user, nil
}
// Logout invalidates a session.
func (s *Service) Logout(ctx context.Context, token string) error {
return s.sessions.Delete(ctx, token)
}
// LogoutAll invalidates all sessions for a user.
func (s *Service) LogoutAll(ctx context.Context, userID string) error {
return s.db.DeleteUserSessions(ctx, userID)
}
// ValidateSession checks if a session token is valid and returns the user.
func (s *Service) ValidateSession(ctx context.Context, token string) (*database.Session, *database.User, error) {
session, err := s.sessions.Get(ctx, token)
if err != nil {
return nil, nil, err
}
user, err := s.db.GetUser(ctx, session.UserID)
if err != nil {
return nil, nil, err
}
if user == nil {
return nil, nil, ErrUserNotFound
}
if user.Disabled {
// Invalidate session for disabled users
s.sessions.Delete(ctx, token)
return nil, nil, ErrUserDisabled
}
return session, user, nil
}
// Register creates a new local user account.
func (s *Service) Register(ctx context.Context, username, password, email string) (*database.User, error) {
if !s.config.AllowRegistration {
return nil, errors.New("registration is disabled")
}
// Check if username exists
existing, err := s.db.GetUserByUsername(ctx, username)
if err != nil {
return nil, err
}
if existing != nil {
return nil, ErrUsernameExists
}
// Validate password
if len(password) < s.config.MinPasswordLength {
return nil, errors.New("password too short")
}
// Hash password
hash, err := HashPassword(password)
if err != nil {
return nil, err
}
// Create user
user := &database.User{
ID: generateID(),
Username: username,
Email: email,
PasswordHash: hash,
AuthProvider: database.AuthProviderLocal,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
if err := s.db.CreateUser(ctx, user); err != nil {
return nil, err
}
// Assign default role
if s.config.DefaultRole != "" {
s.db.AssignRole(ctx, user.ID, s.config.DefaultRole)
}
return user, nil
}
// ChangePassword updates a user's password.
func (s *Service) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) error {
user, err := s.db.GetUser(ctx, userID)
if err != nil {
return err
}
if user == nil {
return ErrUserNotFound
}
// Verify current password
if !CheckPassword(currentPassword, user.PasswordHash) {
return ErrInvalidCredentials
}
// Validate new password
if len(newPassword) < s.config.MinPasswordLength {
return errors.New("password too short")
}
// Hash new password
hash, err := HashPassword(newPassword)
if err != nil {
return err
}
user.PasswordHash = hash
user.UpdatedAt = time.Now().UTC()
return s.db.UpdateUser(ctx, user)
}
// GetUser returns a user by ID.
func (s *Service) GetUser(ctx context.Context, userID string) (*database.User, error) {
return s.db.GetUser(ctx, userID)
}
// GetUserByUsername returns a user by username.
func (s *Service) GetUserByUsername(ctx context.Context, username string) (*database.User, error) {
return s.db.GetUserByUsername(ctx, username)
}
// ListUsers returns all users (admin only).
func (s *Service) ListUsers(ctx context.Context) ([]*database.User, error) {
return s.db.ListUsers(ctx)
}
// CreateUser creates a new user (admin only).
func (s *Service) CreateUser(ctx context.Context, username, password, email string, disabled bool) (*database.User, error) {
// Check if username exists
existing, err := s.db.GetUserByUsername(ctx, username)
if err != nil {
return nil, err
}
if existing != nil {
return nil, ErrUsernameExists
}
var hash []byte
if password != "" {
hash, err = HashPassword(password)
if err != nil {
return nil, err
}
}
user := &database.User{
ID: generateID(),
Username: username,
Email: email,
PasswordHash: hash,
AuthProvider: database.AuthProviderLocal,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
Disabled: disabled,
}
if err := s.db.CreateUser(ctx, user); err != nil {
return nil, err
}
return user, nil
}
// UpdateUser updates a user's profile.
func (s *Service) UpdateUser(ctx context.Context, user *database.User) error {
user.UpdatedAt = time.Now().UTC()
return s.db.UpdateUser(ctx, user)
}
// DisableUser disables a user account and invalidates all sessions.
func (s *Service) DisableUser(ctx context.Context, userID string) error {
user, err := s.db.GetUser(ctx, userID)
if err != nil {
return err
}
if user == nil {
return ErrUserNotFound
}
user.Disabled = true
user.UpdatedAt = time.Now().UTC()
if err := s.db.UpdateUser(ctx, user); err != nil {
return err
}
// Invalidate all sessions
return s.db.DeleteUserSessions(ctx, userID)
}
// EnableUser enables a disabled user account.
func (s *Service) EnableUser(ctx context.Context, userID string) error {
user, err := s.db.GetUser(ctx, userID)
if err != nil {
return err
}
if user == nil {
return ErrUserNotFound
}
user.Disabled = false
user.UpdatedAt = time.Now().UTC()
return s.db.UpdateUser(ctx, user)
}
// ResetPassword sets a new password for a user (admin only).
func (s *Service) ResetPassword(ctx context.Context, userID, newPassword string) error {
user, err := s.db.GetUser(ctx, userID)
if err != nil {
return err
}
if user == nil {
return ErrUserNotFound
}
hash, err := HashPassword(newPassword)
if err != nil {
return err
}
user.PasswordHash = hash
user.UpdatedAt = time.Now().UTC()
if err := s.db.UpdateUser(ctx, user); err != nil {
return err
}
// Invalidate all existing sessions
return s.db.DeleteUserSessions(ctx, userID)
}
// CleanupSessions removes expired sessions.
func (s *Service) CleanupSessions(ctx context.Context) error {
return s.db.CleanupExpiredSessions(ctx)
}
// SessionManager returns the session manager for middleware use.
func (s *Service) SessionManager() *SessionManager {
return s.sessions
}
// Config returns the auth configuration.
func (s *Service) Config() *Config {
return s.config
}