NextWks/src/core/admin/users.go

306 lines
8.1 KiB
Go

package admin
import (
"crypto/rand"
"database/sql"
"encoding/base64"
"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$<salt>$<hash>
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 base64.RawURLEncoding.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]
}