Files
pocket-id/backend/internal/service/user_service.go
T
Claude 0e0187dbc6 refactor: pass app config as explicit argument instead of request context
Remove the AppConfigMiddleware that stored an app-config resolver in the
request context and the FromCtx helper that read it back downstream.
Handlers now load the app config from AppConfigService and pass it as an
explicit argument to the service methods that need it, which then forward
it further down the call chain.

The webauthn and usersignup modules gain an AppConfigResolver dependency so
their handlers can load the config the same way. The email SendEmail and
LDAP SyncAll helpers drop their context-based variants in favor of the
explicit-config versions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ6SoJpmnLggZqactXxrak
2026-07-19 21:33:10 +00:00

711 lines
20 KiB
Go

package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"path"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image"
)
type UserService struct {
db *gorm.DB
jwtService *JwtService
auditLogService *AuditLogService
emailService *EmailService
customClaimService *CustomClaimService
appImagesService *AppImagesService
scimService *ScimService
fileStorage storage.FileStorage
}
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService {
return &UserService{
db: db,
jwtService: jwtService,
auditLogService: auditLogService,
emailService: emailService,
customClaimService: customClaimService,
appImagesService: appImagesService,
scimService: scimService,
fileStorage: fileStorage,
}
}
func (s *UserService) ListUsers(ctx context.Context, searchTerm string, listRequestOptions utils.ListRequestOptions) ([]model.User, utils.PaginationResponse, error) {
var users []model.User
query := s.db.WithContext(ctx).
Model(&model.User{}).
Preload("UserGroups").
Preload("CustomClaims")
if searchTerm != "" {
searchPattern := "%" + searchTerm + "%"
query = query.Where(
"email LIKE ? OR first_name LIKE ? OR last_name LIKE ? OR username LIKE ?",
searchPattern, searchPattern, searchPattern, searchPattern)
}
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &users)
return users, pagination, err
}
func (s *UserService) GetUser(ctx context.Context, userID string) (model.User, error) {
return s.getUserInternal(ctx, userID, s.db)
}
func (s *UserService) getUserInternal(ctx context.Context, userID string, tx *gorm.DB) (model.User, error) {
var user model.User
err := tx.
WithContext(ctx).
Preload("UserGroups").
Preload("CustomClaims").
Where("id = ?", userID).
First(&user).
Error
return user, err
}
func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.ReadCloser, int64, error) {
// Validate the user ID to prevent directory traversal
if err := uuid.Validate(userID); err != nil {
return nil, 0, &common.InvalidUUIDError{}
}
user, err := s.GetUser(ctx, userID)
if err != nil {
return nil, 0, err
}
profilePicturePath := path.Join("profile-pictures", userID+".png")
// Try custom profile picture
file, size, err := s.fileStorage.Open(ctx, profilePicturePath)
if err == nil {
return file, size, nil
} else if !errors.Is(err, fs.ErrNotExist) {
return nil, 0, err
}
// Try default global profile picture
if s.appImagesService.IsDefaultProfilePictureSet() {
reader, size, _, err := s.appImagesService.GetImage(ctx, "default-profile-picture")
if err == nil {
return reader, size, nil
}
if !errors.Is(err, &common.ImageNotFoundError{}) {
return nil, 0, err
}
}
// Try cached default for initials
defaultPicturePath := path.Join("profile-pictures", "defaults", user.Initials()+".png")
file, size, err = s.fileStorage.Open(ctx, defaultPicturePath)
if err == nil {
return file, size, nil
} else if !errors.Is(err, fs.ErrNotExist) {
return nil, 0, err
}
// Create and return generated default with initials
defaultPicture, err := profilepicture.CreateDefaultProfilePicture(user.Initials())
if err != nil {
return nil, 0, err
}
// Save the default picture for future use (in a goroutine to avoid blocking)
defaultPictureBytes := defaultPicture.Bytes()
//#nosec G118 - We use a background context as this is running in background
// nolint:contextcheck
go func() {
// Use bytes.NewReader because we need an io.ReadSeeker
rErr := s.fileStorage.Save(context.Background(), defaultPicturePath, bytes.NewReader(defaultPictureBytes))
if rErr != nil {
slog.Error("Failed to cache default profile picture", slog.String("initials", user.Initials()), slog.Any("error", rErr))
}
}()
return io.NopCloser(bytes.NewReader(defaultPictureBytes)), int64(len(defaultPictureBytes)), nil
}
func (s *UserService) GetUserGroups(ctx context.Context, userID string) ([]model.UserGroup, error) {
var user model.User
err := s.db.
WithContext(ctx).
Preload("UserGroups").
Where("id = ?", userID).
First(&user).
Error
if err != nil {
return nil, err
}
return user.UserGroups, nil
}
func (s *UserService) UpdateProfilePicture(ctx context.Context, userID string, file io.ReadSeeker) error {
// Validate the user ID to prevent directory traversal
err := uuid.Validate(userID)
if err != nil {
return &common.InvalidUUIDError{}
}
// Convert the image to a smaller square image
profilePicture, err := profilepicture.CreateProfilePicture(file)
if err != nil {
return err
}
profilePicturePath := path.Join("profile-pictures", userID+".png")
err = s.fileStorage.Save(ctx, profilePicturePath, profilePicture)
if err != nil {
return err
}
return nil
}
func (s *UserService) DeleteUser(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string, allowLdapDelete bool) error {
err := s.db.Transaction(func(tx *gorm.DB) error {
return s.deleteUserInternal(ctx, tx, userID, allowLdapDelete, dbConfig)
})
if err != nil {
return fmt.Errorf("failed to delete user '%s': %w", userID, err)
}
// Storage operations must be executed outside of a transaction
profilePicturePath := path.Join("profile-pictures", userID+".png")
err = s.fileStorage.Delete(ctx, profilePicturePath)
if err != nil && !storage.IsNotExist(err) {
return fmt.Errorf("failed to delete profile picture for user '%s': %w", userID, err)
}
return nil
}
func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userID string, allowLdapDelete bool, cfg *appconfig.AppConfigModel) error {
var user model.User
err := tx.
WithContext(ctx).
Where("id = ?", userID).
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&user).
Error
if err != nil {
return fmt.Errorf("failed to load user to delete: %w", err)
}
// Disallow deleting the user if it is an LDAP user, LDAP is enabled, and the user is not disabled
if !allowLdapDelete && !user.Disabled && user.LdapID != nil {
if cfg.LdapEnabled.IsTrue() {
return &common.LdapUserUpdateError{}
}
}
err = tx.WithContext(ctx).Delete(&user).Error
if err != nil {
return fmt.Errorf("failed to delete user: %w", err)
}
if s.scimService != nil {
s.scimService.ScheduleSync()
}
return nil
}
func (s *UserService) CreateUser(ctx context.Context, dbConfig *appconfig.AppConfigModel, input dto.UserCreateDto) (model.User, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
user, err := s.CreateUserInternal(ctx, dbConfig, input, false, tx)
if err != nil {
return model.User{}, err
}
err = tx.Commit().Error
if err != nil {
return model.User{}, err
}
return user, nil
}
func (s *UserService) CreateUserInternal(ctx context.Context, dbConfig *appconfig.AppConfigModel, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) {
return s.createUserInternal(ctx, input, isLdapSync, tx, dbConfig)
}
func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB, cfg *appconfig.AppConfigModel) (model.User, error) {
if cfg.RequireUserEmail.IsTrue() && input.Email == nil {
return model.User{}, &common.UserEmailNotSetError{}
}
var userGroups []model.UserGroup
if len(input.UserGroupIds) > 0 {
err := tx.
WithContext(ctx).
Where("id IN ?", input.UserGroupIds).
Find(&userGroups).
Error
if err != nil {
return model.User{}, err
}
}
user := model.User{
FirstName: input.FirstName,
LastName: input.LastName,
DisplayName: input.DisplayName,
Email: input.Email,
EmailVerified: input.EmailVerified,
Username: input.Username,
IsAdmin: input.IsAdmin,
Locale: input.Locale,
Disabled: input.Disabled,
UserGroups: userGroups,
}
if input.LdapID != "" {
user.LdapID = &input.LdapID
}
err := tx.WithContext(ctx).Create(&user).Error
if errors.Is(err, gorm.ErrDuplicatedKey) {
// Do not follow this path if we're using LDAP, as we don't want to roll-back the transaction here
if !isLdapSync {
tx.Rollback()
// If we are here, the transaction is already aborted due to an error, so we pass s.db
err = s.checkDuplicatedFields(ctx, user, s.db)
} else {
err = s.checkDuplicatedFields(ctx, user, tx)
}
return model.User{}, err
} else if err != nil {
return model.User{}, err
}
// Bump the UpdatedAt timestamp of the groups the new user was added to
// This is necessary for SCIM to work with the newly-created user, or groups may not be synced via SCIM
if len(userGroups) > 0 {
err = s.touchUserGroups(ctx, tx, groupIDs(userGroups))
if err != nil {
return model.User{}, err
}
}
// Apply default groups and claims for new non-LDAP users
if !isLdapSync {
if len(input.UserGroupIds) == 0 {
err = s.applyDefaultGroups(ctx, &user, tx, cfg)
if err != nil {
return model.User{}, err
}
}
err = s.applyDefaultCustomClaims(ctx, &user, tx, cfg)
if err != nil {
return model.User{}, err
}
}
if s.scimService != nil {
s.scimService.ScheduleSync()
}
return user, nil
}
func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User, tx *gorm.DB, cfg *appconfig.AppConfigModel) error {
var groupIDs []string
v := cfg.SignupDefaultUserGroupIDs
if v != "" && v != "[]" {
err := json.Unmarshal([]byte(v), &groupIDs)
if err != nil {
return fmt.Errorf("invalid SignupDefaultUserGroupIDs JSON: %w", err)
}
if len(groupIDs) > 0 {
var groups []model.UserGroup
err = tx.WithContext(ctx).
Where("id IN ?", groupIDs).
Find(&groups).
Error
if err != nil {
return fmt.Errorf("failed to find default user groups: %w", err)
}
err = tx.WithContext(ctx).
Model(user).
Association("UserGroups").
Replace(groups)
if err != nil {
return fmt.Errorf("failed to associate default user groups: %w", err)
}
// Bump the groups' UpdatedAt so the SCIM sync picks up the new
// membership (see touchUserGroups for details).
touchIDs := make([]string, len(groups))
for i := range groups {
touchIDs[i] = groups[i].ID
}
if err := s.touchUserGroups(ctx, tx, touchIDs); err != nil {
return fmt.Errorf("failed to update default user groups timestamp: %w", err)
}
}
}
return nil
}
// touchUserGroups updates the UpdatedAt timestamp of the given user groups.
//
// Group membership is stored in the user_groups_users join table, so adding or
// removing a member does not modify the group row itself. The SCIM sync only
// re-pushes a group to the provider when its UpdatedAt is not older than the
// remote resource's last-modified time, so any code path that changes a group's
// membership must bump this timestamp explicitly. Otherwise the membership
// change is never synced to the SCIM provider.
func (s *UserService) touchUserGroups(ctx context.Context, tx *gorm.DB, ids []string) error {
if len(ids) == 0 {
return nil
}
now := datatype.DateTime(time.Now())
return tx.
WithContext(ctx).
Model(&model.UserGroup{}).
Where("id IN ?", ids).
Update("updated_at", now).
Error
}
func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.User, tx *gorm.DB, cfg *appconfig.AppConfigModel) error {
var claims []dto.CustomClaimCreateDto
v := cfg.SignupDefaultCustomClaims
if v != "" && v != "[]" {
err := json.Unmarshal([]byte(v), &claims)
if err != nil {
return fmt.Errorf("invalid SignupDefaultCustomClaims JSON: %w", err)
}
if len(claims) > 0 {
_, err = s.customClaimService.updateCustomClaimsInternal(ctx, UserID, user.ID, claims, tx)
if err != nil {
return fmt.Errorf("failed to apply default custom claims: %w", err)
}
}
}
return nil
}
func (s *UserService) UpdateUser(ctx context.Context, cfg *appconfig.AppConfigModel, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool) (model.User, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
user, err := s.updateUserInternal(ctx, userID, updatedUser, updateOwnUser, isLdapSync, tx, cfg)
if err != nil {
return model.User{}, err
}
err = tx.Commit().Error
if err != nil {
return model.User{}, err
}
return user, nil
}
func (s *UserService) updateUserInternal(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB, cfg *appconfig.AppConfigModel) (model.User, error) {
if cfg.RequireUserEmail.IsTrue() && updatedUser.Email == nil {
return model.User{}, &common.UserEmailNotSetError{}
}
var user model.User
err := tx.
WithContext(ctx).
Where("id = ?", userID).
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&user).
Error
if err != nil {
return model.User{}, err
}
// Check if this is an LDAP user and LDAP is enabled
isLdapUser := user.LdapID != nil && cfg.LdapEnabled.IsTrue()
allowOwnAccountEdit := cfg.AllowOwnAccountEdit.IsTrue()
if !isLdapSync && (isLdapUser || (!allowOwnAccountEdit && updateOwnUser)) {
// Restricted update: Only locale can be changed when:
// - User is from LDAP, OR
// - User is editing their own account but global setting disallows self-editing
// (Exception: LDAP sync operations can update everything)
user.Locale = updatedUser.Locale
} else {
// Full update: Allow updating all personal fields
user.FirstName = updatedUser.FirstName
user.LastName = updatedUser.LastName
user.DisplayName = updatedUser.DisplayName
user.Username = updatedUser.Username
user.Locale = updatedUser.Locale
if (user.Email == nil && updatedUser.Email != nil) || (user.Email != nil && updatedUser.Email != nil && *user.Email != *updatedUser.Email) {
// Email has changed, reset email verification status
user.EmailVerified = cfg.EmailsVerified.IsTrue()
}
user.Email = updatedUser.Email
// Admin-only fields: Only allow updates when not updating own account
if !updateOwnUser {
user.IsAdmin = updatedUser.IsAdmin
user.EmailVerified = updatedUser.EmailVerified
user.Disabled = updatedUser.Disabled
}
}
user.UpdatedAt = new(datatype.DateTime(time.Now()))
err = tx.
WithContext(ctx).
Save(&user).
Error
if errors.Is(err, gorm.ErrDuplicatedKey) {
// Do not follow this path if we're using LDAP, as we don't want to roll-back the transaction here
if !isLdapSync {
tx.Rollback()
// If we are here, the transaction is already aborted due to an error, so we pass s.db
err = s.checkDuplicatedFields(ctx, user, s.db)
} else {
err = s.checkDuplicatedFields(ctx, user, tx)
}
return user, err
} else if err != nil {
return user, err
}
if s.scimService != nil {
s.scimService.ScheduleSync()
}
return user, nil
}
func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroupIds []string) (user model.User, err error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
user, err = s.getUserInternal(ctx, id, tx)
if err != nil {
return model.User{}, err
}
// Fetch the groups based on userGroupIds
var groups []model.UserGroup
if len(userGroupIds) > 0 {
err := tx.
WithContext(ctx).
Where("id IN (?)", userGroupIds).
Find(&groups).
Error
if err != nil {
return model.User{}, err
}
}
// Replace the current groups with the new set of groups
err = tx.
WithContext(ctx).
Model(&user).
Association("UserGroups").
Replace(groups)
if err != nil {
return model.User{}, err
}
// Save the updated user
err = tx.WithContext(ctx).Save(&user).Error
if err != nil {
return model.User{}, err
}
// Update the UpdatedAt field for all affected groups
now := datatype.DateTime(time.Now())
for _, group := range groups {
group.UpdatedAt = &now
err = tx.WithContext(ctx).Save(&group).Error
if err != nil {
return model.User{}, err
}
}
err = tx.Commit().Error
if err != nil {
return model.User{}, err
}
if s.scimService != nil {
s.scimService.ScheduleSync()
}
return user, nil
}
func (s *UserService) checkDuplicatedFields(ctx context.Context, user model.User, tx *gorm.DB) error {
var result struct {
Found bool
}
err := tx.
WithContext(ctx).
Raw(`SELECT EXISTS(SELECT 1 FROM users WHERE id != ? AND email = ?) AS found`, user.ID, user.Email).
First(&result).
Error
if err != nil {
return err
}
if result.Found {
return &common.AlreadyInUseError{Property: "email"}
}
err = tx.
WithContext(ctx).
Raw(`SELECT EXISTS(SELECT 1 FROM users WHERE id != ? AND username = ?) AS found`, user.ID, user.Username).
First(&result).
Error
if err != nil {
return err
}
if result.Found {
return &common.AlreadyInUseError{Property: "username"}
}
return nil
}
// ResetProfilePicture deletes a user's custom profile picture
func (s *UserService) ResetProfilePicture(ctx context.Context, userID string) error {
// Validate the user ID to prevent directory traversal
if err := uuid.Validate(userID); err != nil {
return &common.InvalidUUIDError{}
}
profilePicturePath := path.Join("profile-pictures", userID+".png")
if err := s.fileStorage.Delete(ctx, profilePicturePath); err != nil {
return fmt.Errorf("failed to delete profile picture: %w", err)
}
return nil
}
func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, userID string) error {
err := tx.
WithContext(ctx).
Model(&model.User{}).
Where("id = ?", userID).
Update("disabled", true).
Error
if err != nil {
return err
}
if s.scimService != nil {
s.scimService.ScheduleSync()
}
return nil
}
func (s *UserService) SendEmailVerification(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string) error {
user, err := s.GetUser(ctx, userID)
if err != nil {
return err
}
if user.Email == nil {
return &common.UserEmailNotSetError{}
}
randomToken, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return err
}
expiration := time.Now().Add(24 * time.Hour)
emailVerificationToken := &model.EmailVerificationToken{
UserID: user.ID,
Token: randomToken,
ExpiresAt: datatype.DateTime(expiration),
}
err = s.db.WithContext(ctx).Create(emailVerificationToken).Error
if err != nil {
return err
}
return SendEmail(ctx, s.emailService, dbConfig, email.Address{
Name: user.FullName(),
Email: *user.Email,
}, EmailVerificationTemplate, &EmailVerificationTemplateData{
UserFullName: user.FullName(),
VerificationLink: common.EnvConfig.AppURL + "/verify-email?token=" + emailVerificationToken.Token,
})
}
func (s *UserService) VerifyEmail(ctx context.Context, userID string, token string) error {
tx := s.db.Begin()
defer tx.Rollback()
var emailVerificationToken model.EmailVerificationToken
err := tx.WithContext(ctx).Where("token = ? AND user_id = ? AND expires_at > ?",
token, userID, datatype.DateTime(time.Now())).First(&emailVerificationToken).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return &common.InvalidEmailVerificationTokenError{}
} else if err != nil {
return err
}
user, err := s.getUserInternal(ctx, emailVerificationToken.UserID, tx)
if err != nil {
return err
}
user.EmailVerified = true
user.UpdatedAt = new(datatype.DateTime(time.Now()))
err = tx.WithContext(ctx).Save(&user).Error
if err != nil {
return err
}
err = tx.WithContext(ctx).Delete(&emailVerificationToken).Error
if err != nil {
return err
}
return tx.Commit().Error
}