Add admin-only CRUD endpoints for user management under /api/v1/system/users with full frontend implementation including create, edit, change password, and delete operations with self-protection guards. - Extend AuthDomain interface with ListUsers, GetUser, UpdateUser, UpdateUserPassword, DeleteUser methods - Add List, Update, Delete methods to UserStore with shared scanUser helper - Create UserHandler with 5 endpoints protected by admin middleware - Complete Users.vue admin page with modals and error handling - Make CreateUser accept role parameter for atomic user creation - Fix auth middleware to use database role as source of truth instead of stale JWT claims, preventing privilege persistence after demotion - Fix pre-existing gofmt issues in yt_dlp.go and config.go
271 lines
6.6 KiB
Go
271 lines
6.6 KiB
Go
package domain
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"time"
|
|
|
|
"git.nakama.town/fmartingr/hako/internal/auth/store"
|
|
"git.nakama.town/fmartingr/hako/internal/dependencies"
|
|
"git.nakama.town/fmartingr/hako/internal/model"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
|
|
|
// AuthDomain handles business logic for authentication
|
|
type AuthDomain struct {
|
|
deps model.Dependencies
|
|
}
|
|
|
|
// NewAuthDomain creates a new AuthDomain
|
|
func NewAuthDomain(deps model.Dependencies) model.AuthDomain {
|
|
return &AuthDomain{deps: deps}
|
|
}
|
|
|
|
// getDeps returns the concrete dependencies implementation
|
|
func (d *AuthDomain) getDeps() *dependencies.Dependencies {
|
|
return d.deps.(*dependencies.Dependencies)
|
|
}
|
|
|
|
// Login authenticates a user and returns a JWT token
|
|
func (d *AuthDomain) Login(email, password string) (*model.User, *model.Token, error) {
|
|
deps := d.getDeps()
|
|
// Find user by email
|
|
storeUser, err := deps.UserStore.FindByEmail(email)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("invalid credentials")
|
|
}
|
|
|
|
// Convert to domain user
|
|
user := convertUser(storeUser)
|
|
|
|
// Verify password
|
|
if !d.VerifyPassword(user, password) {
|
|
return nil, nil, fmt.Errorf("invalid credentials")
|
|
}
|
|
|
|
// Get JWT service from dependencies
|
|
jwtService := deps.GetJWTService()
|
|
if jwtService == nil {
|
|
return nil, nil, fmt.Errorf("JWT service not available")
|
|
}
|
|
|
|
// Generate JWT token with user's role
|
|
tokenString, expiresAt, err := jwtService.GenerateTokenWithRole(user.ID, user.Email, user.Role)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to generate token: %w", err)
|
|
}
|
|
|
|
token := &model.Token{
|
|
Token: tokenString,
|
|
ExpiresAt: expiresAt,
|
|
}
|
|
|
|
return user, token, nil
|
|
}
|
|
|
|
// VerifyPassword verifies a password against a user's password hash
|
|
func (d *AuthDomain) VerifyPassword(user *model.User, password string) bool {
|
|
err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
|
|
return err == nil
|
|
}
|
|
|
|
// CreateUser creates a new user with hashed password and specified role
|
|
func (d *AuthDomain) CreateUser(email, password, role string) (*model.User, error) {
|
|
deps := d.getDeps()
|
|
|
|
if !d.isValidEmail(email) {
|
|
return nil, fmt.Errorf("invalid email format")
|
|
}
|
|
|
|
if len(password) < 8 {
|
|
return nil, fmt.Errorf("password must be at least 8 characters")
|
|
}
|
|
|
|
if role == "" {
|
|
role = "user"
|
|
}
|
|
if role != "user" && role != "admin" {
|
|
return nil, fmt.Errorf("role must be 'user' or 'admin'")
|
|
}
|
|
|
|
// Check if user already exists
|
|
_, err := deps.UserStore.FindByEmail(email)
|
|
if err == nil {
|
|
return nil, fmt.Errorf("user with this email already exists")
|
|
}
|
|
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to hash password: %w", err)
|
|
}
|
|
|
|
user := model.NewUserWithRole(email, string(hashedPassword), role)
|
|
|
|
storeUser := convertUserToStore(user)
|
|
if err := deps.UserStore.Create(storeUser); err != nil {
|
|
return nil, fmt.Errorf("failed to create user: %w", err)
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|
|
// ListUsers returns all users
|
|
func (d *AuthDomain) ListUsers() ([]*model.User, error) {
|
|
deps := d.getDeps()
|
|
storeUsers, err := deps.UserStore.List()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list users: %w", err)
|
|
}
|
|
|
|
users := make([]*model.User, len(storeUsers))
|
|
for i, su := range storeUsers {
|
|
users[i] = convertUser(su)
|
|
}
|
|
return users, nil
|
|
}
|
|
|
|
// GetUser returns a user by ID
|
|
func (d *AuthDomain) GetUser(id string) (*model.User, error) {
|
|
deps := d.getDeps()
|
|
uid, err := uuid.Parse(id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid user ID")
|
|
}
|
|
|
|
storeUser, err := deps.UserStore.FindByID(uid)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("user not found")
|
|
}
|
|
|
|
return convertUser(storeUser), nil
|
|
}
|
|
|
|
// UpdateUser updates a user's email and role
|
|
func (d *AuthDomain) UpdateUser(id, email, role string) (*model.User, error) {
|
|
deps := d.getDeps()
|
|
uid, err := uuid.Parse(id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid user ID")
|
|
}
|
|
|
|
if !d.isValidEmail(email) {
|
|
return nil, fmt.Errorf("invalid email format")
|
|
}
|
|
|
|
if role != "user" && role != "admin" {
|
|
return nil, fmt.Errorf("role must be 'user' or 'admin'")
|
|
}
|
|
|
|
// Fetch existing user
|
|
storeUser, err := deps.UserStore.FindByID(uid)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("user not found")
|
|
}
|
|
|
|
// Check email uniqueness (allow keeping the same email)
|
|
if email != storeUser.Email {
|
|
existing, err := deps.UserStore.FindByEmail(email)
|
|
if err == nil && existing.ID != uid {
|
|
return nil, fmt.Errorf("email already in use")
|
|
}
|
|
}
|
|
|
|
storeUser.Email = email
|
|
storeUser.Role = role
|
|
storeUser.UpdatedAt = time.Now()
|
|
|
|
if err := deps.UserStore.Update(storeUser); err != nil {
|
|
return nil, fmt.Errorf("failed to update user: %w", err)
|
|
}
|
|
|
|
return convertUser(storeUser), nil
|
|
}
|
|
|
|
// UpdateUserPassword changes a user's password
|
|
func (d *AuthDomain) UpdateUserPassword(id, password string) error {
|
|
deps := d.getDeps()
|
|
uid, err := uuid.Parse(id)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid user ID")
|
|
}
|
|
|
|
if len(password) < 8 {
|
|
return fmt.Errorf("password must be at least 8 characters")
|
|
}
|
|
|
|
storeUser, err := deps.UserStore.FindByID(uid)
|
|
if err != nil {
|
|
return fmt.Errorf("user not found")
|
|
}
|
|
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to hash password: %w", err)
|
|
}
|
|
|
|
storeUser.PasswordHash = string(hashedPassword)
|
|
storeUser.UpdatedAt = time.Now()
|
|
|
|
if err := deps.UserStore.Update(storeUser); err != nil {
|
|
return fmt.Errorf("failed to update password: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteUser deletes a user by ID
|
|
func (d *AuthDomain) DeleteUser(id string) error {
|
|
deps := d.getDeps()
|
|
uid, err := uuid.Parse(id)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid user ID")
|
|
}
|
|
|
|
if err := deps.UserStore.Delete(uid); err != nil {
|
|
return fmt.Errorf("failed to delete user: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// isValidEmail validates email format
|
|
func (d *AuthDomain) isValidEmail(email string) bool {
|
|
return emailRegex.MatchString(email)
|
|
}
|
|
|
|
// Helper conversion functions
|
|
func convertUser(su *store.User) *model.User {
|
|
if su == nil {
|
|
return nil
|
|
}
|
|
// Default role to "user" if not set
|
|
role := su.Role
|
|
if role == "" {
|
|
role = "user"
|
|
}
|
|
return &model.User{
|
|
ID: su.ID,
|
|
Email: su.Email,
|
|
PasswordHash: su.PasswordHash,
|
|
Role: role,
|
|
CreatedAt: su.CreatedAt,
|
|
UpdatedAt: su.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func convertUserToStore(du *model.User) *store.User {
|
|
if du == nil {
|
|
return nil
|
|
}
|
|
return &store.User{
|
|
ID: du.ID,
|
|
Email: du.Email,
|
|
PasswordHash: du.PasswordHash,
|
|
Role: du.Role,
|
|
CreatedAt: du.CreatedAt,
|
|
UpdatedAt: du.UpdatedAt,
|
|
}
|
|
}
|