| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462 |
- 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()
- }
- }
|