package auth import ( "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "log" "os" "path/filepath" "strings" "sync" "time" "golang.org/x/crypto/bcrypt" ) var ( ErrInvalidCredentials = errors.New("invalid username or password") ErrUserDisabled = errors.New("user account is disabled") ErrUserNotFound = errors.New("user not found") ErrUserExists = errors.New("username already exists") ErrCannotDeleteSelf = errors.New("cannot delete currently authenticated user") ErrCannotDeleteAdmin = errors.New("cannot delete or disable the last active administrator") ErrInvalidUsername = errors.New("username must be at least 3 characters and contain only alphanumeric characters, dashes or underscores") ErrPasswordTooShort = errors.New("password must be at least 4 characters") ErrSessionExpired = errors.New("session expired") ) const ( DefaultUsersPath = "/etc/debtik/users.json" SessionDuration = 7 * 24 * time.Hour ) type Manager struct { mu sync.RWMutex filePath string users map[string]User sessions map[string]Session userSession map[string][]string // username -> tokens } func NewManager(customPath string) *Manager { p := customPath if p == "" { p = DefaultUsersPath } m := &Manager{ filePath: p, users: make(map[string]User), sessions: make(map[string]Session), userSession: make(map[string][]string), } if err := m.load(); err != nil { log.Printf("[Auth] Failed to load users, initializing default: %v", err) _ = m.initDefaultAdmin() } // Start background session cleanup go m.cleanupWorker() return m } func (m *Manager) initDefaultAdmin() error { m.mu.Lock() defer m.mu.Unlock() if len(m.users) == 0 { hash, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost) if err != nil { return err } admin := User{ Username: "admin", PasswordHash: string(hash), Role: RoleFull, Comment: "Default System Administrator", Disabled: false, CreatedAt: time.Now(), } m.users["admin"] = admin return m.saveLocked() } return nil } func (m *Manager) load() error { m.mu.Lock() defer m.mu.Unlock() data, err := os.ReadFile(m.filePath) if err != nil { if os.IsNotExist(err) { return m.initDefaultAdminLocked() } return err } var list []User if err := json.Unmarshal(data, &list); err != nil { return err } m.users = make(map[string]User) for _, u := range list { m.users[u.Username] = u } if len(m.users) == 0 { return m.initDefaultAdminLocked() } return nil } func (m *Manager) initDefaultAdminLocked() error { hash, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost) if err != nil { return err } admin := User{ Username: "admin", PasswordHash: string(hash), Role: RoleFull, Comment: "Default System Administrator", Disabled: false, CreatedAt: time.Now(), } m.users["admin"] = admin return m.saveLocked() } func (m *Manager) saveLocked() error { dir := filepath.Dir(m.filePath) if err := os.MkdirAll(dir, 0755); err != nil { return err } list := make([]User, 0, len(m.users)) for _, u := range m.users { list = append(list, u) } data, err := json.MarshalIndent(list, "", " ") if err != nil { return err } tmpFile := filepath.Join(dir, fmt.Sprintf(".users.%d.tmp", time.Now().UnixNano())) if err := os.WriteFile(tmpFile, data, 0600); err != nil { return err } return os.Rename(tmpFile, m.filePath) } func (m *Manager) Authenticate(username, password string) (*Session, error) { m.mu.Lock() defer m.mu.Unlock() u, exists := m.users[username] if !exists { return nil, ErrInvalidCredentials } if u.Disabled { return nil, ErrUserDisabled } if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil { return nil, ErrInvalidCredentials } // Update last login timestamp u.LastLoginAt = time.Now() m.users[username] = u _ = m.saveLocked() // Generate session token tokenBytes := make([]byte, 32) if _, err := rand.Read(tokenBytes); err != nil { return nil, fmt.Errorf("failed to generate session token: %w", err) } token := hex.EncodeToString(tokenBytes) sess := Session{ Token: token, Username: u.Username, Role: u.Role, ExpiresAt: time.Now().Add(SessionDuration), } m.sessions[token] = sess m.userSession[username] = append(m.userSession[username], token) return &sess, nil } func (m *Manager) ValidateSession(token string) (*Session, error) { if token == "" { return nil, ErrSessionExpired } m.mu.RLock() defer m.mu.RUnlock() sess, exists := m.sessions[token] if !exists { return nil, ErrSessionExpired } if time.Now().After(sess.ExpiresAt) { return nil, ErrSessionExpired } // Ensure user is still valid and not disabled u, exists := m.users[sess.Username] if !exists || u.Disabled { return nil, ErrUserDisabled } return &sess, nil } func (m *Manager) RevokeSession(token string) { m.mu.Lock() defer m.mu.Unlock() if sess, ok := m.sessions[token]; ok { delete(m.sessions, token) // Clean from userSession tokens := m.userSession[sess.Username] filtered := make([]string, 0, len(tokens)) for _, t := range tokens { if t != token { filtered = append(filtered, t) } } m.userSession[sess.Username] = filtered } } func (m *Manager) ListUsers() []PublicUser { m.mu.RLock() defer m.mu.RUnlock() res := make([]PublicUser, 0, len(m.users)) for _, u := range m.users { res = append(res, PublicUser{ Username: u.Username, Role: u.Role, Comment: u.Comment, Disabled: u.Disabled, CreatedAt: u.CreatedAt, LastLoginAt: u.LastLoginAt, }) } return res } func (m *Manager) GetPublicUser(username string) (*PublicUser, error) { m.mu.RLock() defer m.mu.RUnlock() u, exists := m.users[username] if !exists { return nil, ErrUserNotFound } return &PublicUser{ Username: u.Username, Role: u.Role, Comment: u.Comment, Disabled: u.Disabled, CreatedAt: u.CreatedAt, LastLoginAt: u.LastLoginAt, }, nil } func validateUsername(name string) error { name = strings.TrimSpace(name) if len(name) < 3 { return ErrInvalidUsername } for _, c := range name { if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.') { return ErrInvalidUsername } } return nil } func (m *Manager) CreateUser(req CreateUserRequest) error { if err := validateUsername(req.Username); err != nil { return err } if len(req.Password) < 4 { return ErrPasswordTooShort } role := req.Role if role == "" { role = RoleRead } m.mu.Lock() defer m.mu.Unlock() if _, exists := m.users[req.Username]; exists { return ErrUserExists } hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { return fmt.Errorf("failed to hash password: %w", err) } u := User{ Username: req.Username, PasswordHash: string(hash), Role: role, Comment: req.Comment, Disabled: false, CreatedAt: time.Now(), } m.users[req.Username] = u return m.saveLocked() } func (m *Manager) UpdateUser(username string, req UpdateUserRequest) error { m.mu.Lock() defer m.mu.Unlock() u, exists := m.users[username] if !exists { return ErrUserNotFound } // Safeguard: Check if disabling or demoting the last active administrator if (req.Disabled || req.Role != RoleFull) && u.Role == RoleFull && !u.Disabled { activeAdmins := 0 for _, other := range m.users { if other.Role == RoleFull && !other.Disabled { activeAdmins++ } } if activeAdmins <= 1 { return ErrCannotDeleteAdmin } } if req.Role != "" { u.Role = req.Role } u.Comment = req.Comment u.Disabled = req.Disabled m.users[username] = u return m.saveLocked() } func (m *Manager) DeleteUser(username, actorUsername string) error { if username == actorUsername { return ErrCannotDeleteSelf } m.mu.Lock() defer m.mu.Unlock() u, exists := m.users[username] if !exists { return ErrUserNotFound } // Safeguard: do not delete the last active admin if u.Role == RoleFull && !u.Disabled { activeAdmins := 0 for _, other := range m.users { if other.Role == RoleFull && !other.Disabled { activeAdmins++ } } if activeAdmins <= 1 { return ErrCannotDeleteAdmin } } delete(m.users, username) // Invalidate any active sessions of this user for _, token := range m.userSession[username] { delete(m.sessions, token) } delete(m.userSession, username) return m.saveLocked() } func (m *Manager) ChangePassword(username, oldPassword, newPassword string, isAdmin bool) error { if len(newPassword) < 4 { return ErrPasswordTooShort } m.mu.Lock() defer m.mu.Unlock() u, exists := m.users[username] if !exists { return ErrUserNotFound } if !isAdmin { if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(oldPassword)); err != nil { return errors.New("current password incorrect") } } hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) if err != nil { return fmt.Errorf("failed to hash password: %w", err) } u.PasswordHash = string(hash) m.users[username] = u return m.saveLocked() } func (m *Manager) cleanupWorker() { ticker := time.NewTicker(30 * time.Minute) defer ticker.Stop() for range ticker.C { m.mu.Lock() now := time.Now() for token, sess := range m.sessions { if now.After(sess.ExpiresAt) { delete(m.sessions, token) } } // Clean up expired tokens from userSession tracking map to prevent memory leak for username, tokens := range m.userSession { var activeTokens []string for _, t := range tokens { if _, ok := m.sessions[t]; ok { activeTokens = append(activeTokens, t) } } if len(activeTokens) == 0 { delete(m.userSession, username) } else { m.userSession[username] = activeTokens } } m.mu.Unlock() } }