// 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 }