package admin import ( "crypto/rand" "database/sql" "encoding/hex" "fmt" "math/big" "golang.org/x/crypto/argon2" ) // User represents a managed user in the NextWks admin system. type User struct { ID int64 `json:"id"` Username string `json:"username"` DisplayName string `json:"display_name"` Email string `json:"email"` Role string `json:"role"` // "admin" or "user" Groups string `json:"groups"` PasswordHash string `json:"-"` Disabled bool `json:"disabled"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } // UserStore handles user CRUD operations against SQLite. type UserStore struct { db *sql.DB } // NewUserStore creates a new UserStore with the given database. func NewUserStore(db *sql.DB) *UserStore { return &UserStore{db: db} } // List returns all non-deleted users. func (s *UserStore) List() ([]User, error) { rows, err := s.db.Query(` SELECT id, username, display_name, email, role, groups, password_hash, disabled, created_at, updated_at FROM users ORDER BY username ASC `) if err != nil { return nil, fmt.Errorf("list users: %w", err) } defer rows.Close() var users []User for rows.Next() { var u User if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.Groups, &u.PasswordHash, &u.Disabled, &u.CreatedAt, &u.UpdatedAt); err != nil { return nil, fmt.Errorf("scan user: %w", err) } users = append(users, u) } return users, rows.Err() } // GetByUsername retrieves a single user by username. func (s *UserStore) GetByUsername(username string) (*User, error) { var u User err := s.db.QueryRow(` SELECT id, username, display_name, email, role, groups, password_hash, disabled, created_at, updated_at FROM users WHERE username = ? `, username).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.Groups, &u.PasswordHash, &u.Disabled, &u.CreatedAt, &u.UpdatedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("get user %s: %w", username, err) } return &u, nil } // CreateUserRequest represents a request to create one or more users. type CreateUserRequest struct { Users []CreateUserInput `json:"users"` } // CreateUserInput represents a single user creation input. type CreateUserInput struct { Username string `json:"username"` DisplayName string `json:"display_name"` Email string `json:"email"` Role string `json:"role"` // "admin" or "user" (default: "user") Groups string `json:"groups"` } // CreateUserResult holds the result of a user creation. type CreateUserResult struct { Username string `json:"username"` GeneratedPassword string `json:"generated_password,omitempty"` Error string `json:"error,omitempty"` } // Create creates users and returns results with generated passwords. func (s *UserStore) Create(req CreateUserRequest) []CreateUserResult { results := make([]CreateUserResult, 0, len(req.Users)) for _, input := range req.Users { result := CreateUserResult{Username: input.Username} // Validate username if input.Username == "" { result.Error = "username is required" results = append(results, result) continue } // Check for existing user existing, _ := s.GetByUsername(input.Username) if existing != nil { result.Error = "user already exists" results = append(results, result) continue } // Generate random password password, err := generatePassword(20) if err != nil { result.Error = fmt.Sprintf("password generation failed: %v", err) results = append(results, result) continue } // Hash password with argon2id hash := hashPassword(password) // Default role to "user" if not set if input.Role == "" { input.Role = "user" } // Build effective groups: role-based + explicit effectiveGroups := input.Groups if input.Role == "admin" { if effectiveGroups == "" { effectiveGroups = "admins" } else if !containsGroup(effectiveGroups, "admins") { effectiveGroups = effectiveGroups + ",admins" } } _, err = s.db.Exec(` INSERT INTO users (username, display_name, email, role, groups, password_hash, disabled, updated_at) VALUES (?, ?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP) `, input.Username, input.DisplayName, input.Email, input.Role, effectiveGroups, hash) if err != nil { result.Error = fmt.Sprintf("insert failed: %v", err) results = append(results, result) continue } result.GeneratedPassword = password results = append(results, result) } return results } // Delete removes a user by username. func (s *UserStore) Delete(username string) error { result, err := s.db.Exec("DELETE FROM users WHERE username = ?", username) if err != nil { return fmt.Errorf("delete user %s: %w", username, err) } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("user %s not found", username) } return nil } // Count returns the total number of users. func (s *UserStore) Count() (int, error) { var count int err := s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) return count, err } // containsGroup checks if a comma-separated groups string contains a specific group. func containsGroup(groups, target string) bool { for _, g := range splitAndTrim(groups, ",") { if g == target { return true } } return false } // generatePassword creates a cryptographically secure random password. func generatePassword(length int) (string, error) { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_" result := make([]byte, length) for i := range result { n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) if err != nil { return "", err } result[i] = charset[n.Int64()] } return string(result), nil } // hashPassword hashes a password using argon2id (matching Authelia's format). func hashPassword(password string) string { salt := make([]byte, 16) rand.Read(salt) hash := argon2.IDKey([]byte(password), salt, 3, 65536, 4, 32) // Format: $argon2id$v=19$m=65536,t=3,p=4$$ saltB64 := encodeBase64Raw(salt) hashB64 := encodeBase64Raw(hash) return fmt.Sprintf("$argon2id$v=19$m=65536,t=3,p=4$%s$%s", saltB64, hashB64) } // encodeBase64Raw encodes to raw URL-safe base64 (no padding). func encodeBase64Raw(data []byte) string { return hex.EncodeToString(data) } // GetDB returns the underlying database connection for sync operations. func (s *UserStore) GetDB() *sql.DB { return s.db } // SyncUser is a snapshot of user data used for YAML export. type SyncUser struct { Username string DisplayName string Email string Role string Groups []string Password string Disabled bool } // SyncSnapshot returns all users for YAML export. func (s *UserStore) SyncSnapshot() ([]SyncUser, error) { users, err := s.List() if err != nil { return nil, err } syncUsers := make([]SyncUser, 0, len(users)) for _, u := range users { var groups []string if u.Groups != "" { // Split by comma, trim spaces groups = splitAndTrim(u.Groups, ",") } syncUsers = append(syncUsers, SyncUser{ Username: u.Username, DisplayName: u.DisplayName, Email: u.Email, Role: u.Role, Groups: groups, Password: u.PasswordHash, Disabled: u.Disabled, }) } return syncUsers, nil } // splitAndTrim splits a string by delimiter and trims spaces. func splitAndTrim(s, delim string) []string { if s == "" { return nil } // Simple split without importing slices result := make([]string, 0) current := "" for i := 0; i < len(s); i++ { if i+len(delim) <= len(s) && s[i:i+len(delim)] == delim { if current != "" { result = append(result, trimSpace(current)) current = "" } i += len(delim) - 1 } else { current += string(s[i]) } } if current != "" { result = append(result, trimSpace(current)) } return result } // trimSpace removes leading and trailing whitespace. func trimSpace(s string) string { start, end := 0, len(s) for start < end && (s[start] == ' ' || s[start] == '\t') { start++ } for end > start && (s[end-1] == ' ' || s[end-1] == '\t') { end-- } return s[start:end] }