hako/internal/auth/domain/user_service.go
Felipe M. 484b225af7
feat: add user administration API and UI
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
2026-04-02 10:33:36 +02:00

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,
}
}