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>
395 lines
9.4 KiB
Go
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
|
|
}
|