4645107ea1
The auth middleware trusted any unexpired, well-signed access token, so logout, session termination and admin blocks had no effect until the 15-minute token expired. The middleware now validates that the token's session is still active on every request (SessionRepo.GetByID), and blocking a user deactivates all of their sessions, immediately revoking their outstanding access tokens. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
192 lines
5.4 KiB
Go
192 lines
5.4 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"tanabata/backend/internal/domain"
|
|
"tanabata/backend/internal/port"
|
|
)
|
|
|
|
// UserService handles user CRUD and profile management.
|
|
type UserService struct {
|
|
users port.UserRepo
|
|
sessions port.SessionRepo
|
|
audit *AuditService
|
|
}
|
|
|
|
// NewUserService creates a UserService.
|
|
func NewUserService(users port.UserRepo, sessions port.SessionRepo, audit *AuditService) *UserService {
|
|
return &UserService{users: users, sessions: sessions, audit: audit}
|
|
}
|
|
|
|
// EnsureAdmin creates the initial administrator account if it does not already
|
|
// exist. It is idempotent and never overwrites an existing user's password, so
|
|
// an operator who has changed the admin password keeps it across restarts.
|
|
func (s *UserService) EnsureAdmin(ctx context.Context, username, password string) error {
|
|
if username == "" || password == "" {
|
|
return fmt.Errorf("EnsureAdmin: username and password must be set")
|
|
}
|
|
|
|
if _, err := s.users.GetByName(ctx, username); err == nil {
|
|
return nil // already exists
|
|
} else if !errors.Is(err, domain.ErrNotFound) {
|
|
return fmt.Errorf("EnsureAdmin: lookup: %w", err)
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return fmt.Errorf("EnsureAdmin: hash: %w", err)
|
|
}
|
|
_, err = s.users.Create(ctx, &domain.User{
|
|
Name: username,
|
|
Password: string(hash),
|
|
IsAdmin: true,
|
|
CanCreate: true,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("EnsureAdmin: create: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Self-service
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// GetMe returns the profile of the currently authenticated user.
|
|
func (s *UserService) GetMe(ctx context.Context) (*domain.User, error) {
|
|
userID, _, _ := domain.UserFromContext(ctx)
|
|
return s.users.GetByID(ctx, userID)
|
|
}
|
|
|
|
// UpdateMeParams holds fields a user may change on their own profile.
|
|
type UpdateMeParams struct {
|
|
Name string // empty = no change
|
|
Password *string // nil = no change
|
|
}
|
|
|
|
// UpdateMe allows a user to change their own name and/or password.
|
|
func (s *UserService) UpdateMe(ctx context.Context, p UpdateMeParams) (*domain.User, error) {
|
|
userID, _, _ := domain.UserFromContext(ctx)
|
|
|
|
current, err := s.users.GetByID(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
patch := *current
|
|
if p.Name != "" {
|
|
patch.Name = p.Name
|
|
}
|
|
if p.Password != nil {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(*p.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("UserService.UpdateMe hash: %w", err)
|
|
}
|
|
patch.Password = string(hash)
|
|
}
|
|
|
|
return s.users.Update(ctx, userID, &patch)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Admin CRUD
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// List returns a paginated list of users (admin only — caller must enforce).
|
|
func (s *UserService) List(ctx context.Context, params port.OffsetParams) (*domain.UserPage, error) {
|
|
return s.users.List(ctx, params)
|
|
}
|
|
|
|
// Get returns a user by ID (admin only).
|
|
func (s *UserService) Get(ctx context.Context, id int16) (*domain.User, error) {
|
|
return s.users.GetByID(ctx, id)
|
|
}
|
|
|
|
// CreateParams holds fields for creating a new user.
|
|
type CreateUserParams struct {
|
|
Name string
|
|
Password string
|
|
IsAdmin bool
|
|
CanCreate bool
|
|
}
|
|
|
|
// Create inserts a new user with a bcrypt-hashed password (admin only).
|
|
func (s *UserService) Create(ctx context.Context, p CreateUserParams) (*domain.User, error) {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(p.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("UserService.Create hash: %w", err)
|
|
}
|
|
|
|
u := &domain.User{
|
|
Name: p.Name,
|
|
Password: string(hash),
|
|
IsAdmin: p.IsAdmin,
|
|
CanCreate: p.CanCreate,
|
|
}
|
|
created, err := s.users.Create(ctx, u)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_ = s.audit.Log(ctx, "user_create", nil, nil, map[string]any{"target_user_id": created.ID})
|
|
return created, nil
|
|
}
|
|
|
|
// UpdateAdminParams holds fields an admin may change on any user.
|
|
type UpdateAdminParams struct {
|
|
IsAdmin *bool
|
|
CanCreate *bool
|
|
IsBlocked *bool
|
|
}
|
|
|
|
// UpdateAdmin applies an admin-level patch to a user.
|
|
func (s *UserService) UpdateAdmin(ctx context.Context, id int16, p UpdateAdminParams) (*domain.User, error) {
|
|
current, err := s.users.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
patch := *current
|
|
if p.IsAdmin != nil {
|
|
patch.IsAdmin = *p.IsAdmin
|
|
}
|
|
if p.CanCreate != nil {
|
|
patch.CanCreate = *p.CanCreate
|
|
}
|
|
if p.IsBlocked != nil {
|
|
patch.IsBlocked = *p.IsBlocked
|
|
}
|
|
|
|
updated, err := s.users.Update(ctx, id, &patch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Log block/unblock specifically, and revoke all sessions on block so the
|
|
// user's outstanding access tokens stop working immediately.
|
|
if p.IsBlocked != nil {
|
|
action := "user_unblock"
|
|
if *p.IsBlocked {
|
|
action = "user_block"
|
|
if err := s.sessions.DeleteByUserID(ctx, id); err != nil {
|
|
return nil, fmt.Errorf("UserService.UpdateAdmin revoke sessions: %w", err)
|
|
}
|
|
}
|
|
_ = s.audit.Log(ctx, action, nil, nil, map[string]any{"target_user_id": id})
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
// Delete removes a user by ID (admin only).
|
|
func (s *UserService) Delete(ctx context.Context, id int16) error {
|
|
if err := s.users.Delete(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
_ = s.audit.Log(ctx, "user_delete", nil, nil, map[string]any{"target_user_id": id})
|
|
return nil
|
|
} |