306 lines
8.1 KiB
Go
306 lines
8.1 KiB
Go
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$<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 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]
|
|
}
|