2024-08-17 21:57:14 +02:00
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
import (
|
2025-04-03 08:06:56 -05:00
|
|
|
"bytes"
|
2025-04-06 06:04:08 -07:00
|
|
|
"context"
|
2024-08-17 21:57:14 +02:00
|
|
|
"errors"
|
2025-01-19 15:30:31 +01:00
|
|
|
"fmt"
|
2025-02-19 14:28:45 +01:00
|
|
|
"io"
|
2025-01-19 15:30:31 +01:00
|
|
|
"log"
|
|
|
|
|
"net/url"
|
2025-02-19 14:28:45 +01:00
|
|
|
"os"
|
2025-01-19 15:30:31 +01:00
|
|
|
"strings"
|
2024-08-17 21:57:14 +02:00
|
|
|
"time"
|
2025-02-05 18:08:01 +01:00
|
|
|
|
2025-03-06 15:25:03 -06:00
|
|
|
"github.com/google/uuid"
|
2025-04-06 06:04:08 -07:00
|
|
|
"gorm.io/gorm"
|
2025-03-06 15:25:03 -06:00
|
|
|
|
2025-02-05 18:08:01 +01:00
|
|
|
"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"
|
2025-02-14 04:01:43 +08:00
|
|
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
2025-02-05 18:08:01 +01:00
|
|
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
|
|
|
|
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
2025-04-06 06:04:08 -07:00
|
|
|
profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image"
|
2024-08-17 21:57:14 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type UserService struct {
|
2025-02-03 08:58:20 +01:00
|
|
|
db *gorm.DB
|
|
|
|
|
jwtService *JwtService
|
|
|
|
|
auditLogService *AuditLogService
|
|
|
|
|
emailService *EmailService
|
|
|
|
|
appConfigService *AppConfigService
|
2024-08-17 21:57:14 +02:00
|
|
|
}
|
|
|
|
|
|
2025-02-03 08:58:20 +01:00
|
|
|
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService) *UserService {
|
|
|
|
|
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService, emailService: emailService, appConfigService: appConfigService}
|
2024-08-17 21:57:14 +02:00
|
|
|
}
|
|
|
|
|
|
2025-04-06 06:04:08 -07:00
|
|
|
func (s *UserService) ListUsers(ctx context.Context, searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) {
|
2024-08-17 21:57:14 +02:00
|
|
|
var users []model.User
|
2025-04-18 10:38:50 -05:00
|
|
|
query := s.db.WithContext(ctx).
|
|
|
|
|
Model(&model.User{}).
|
|
|
|
|
Preload("UserGroups").
|
|
|
|
|
Preload("CustomClaims")
|
2024-08-17 21:57:14 +02:00
|
|
|
|
|
|
|
|
if searchTerm != "" {
|
|
|
|
|
searchPattern := "%" + searchTerm + "%"
|
2025-04-18 10:38:50 -05:00
|
|
|
query = query.Where("email LIKE ? OR first_name LIKE ? OR last_name LIKE ? OR username LIKE ?",
|
|
|
|
|
searchPattern, searchPattern, searchPattern, searchPattern)
|
2024-08-17 21:57:14 +02:00
|
|
|
}
|
|
|
|
|
|
2025-01-11 20:14:12 +01:00
|
|
|
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &users)
|
2025-04-18 10:38:50 -05:00
|
|
|
|
2024-08-17 21:57:14 +02:00
|
|
|
return users, pagination, err
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-06 06:04:08 -07:00
|
|
|
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) {
|
2024-08-17 21:57:14 +02:00
|
|
|
var user model.User
|
2025-04-06 06:04:08 -07:00
|
|
|
err := tx.
|
|
|
|
|
WithContext(ctx).
|
|
|
|
|
Preload("UserGroups").
|
|
|
|
|
Preload("CustomClaims").
|
|
|
|
|
Where("id = ?", userID).
|
|
|
|
|
First(&user).
|
|
|
|
|
Error
|
2024-08-17 21:57:14 +02:00
|
|
|
return user, err
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-06 06:04:08 -07:00
|
|
|
func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.ReadCloser, int64, error) {
|
2025-02-19 14:28:45 +01:00
|
|
|
// Validate the user ID to prevent directory traversal
|
|
|
|
|
if err := uuid.Validate(userID); err != nil {
|
|
|
|
|
return nil, 0, &common.InvalidUUIDError{}
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-03 08:06:56 -05:00
|
|
|
// First check for a custom uploaded profile picture (userID.png)
|
2025-03-23 13:21:44 -07:00
|
|
|
profilePicturePath := common.EnvConfig.UploadPath + "/profile-pictures/" + userID + ".png"
|
2025-02-19 14:28:45 +01:00
|
|
|
file, err := os.Open(profilePicturePath)
|
|
|
|
|
if err == nil {
|
|
|
|
|
// Get the file size
|
|
|
|
|
fileInfo, err := file.Stat()
|
|
|
|
|
if err != nil {
|
2025-04-03 08:06:56 -05:00
|
|
|
file.Close()
|
2025-02-19 14:28:45 +01:00
|
|
|
return nil, 0, err
|
|
|
|
|
}
|
|
|
|
|
return file, fileInfo.Size(), nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-03 08:06:56 -05:00
|
|
|
// If no custom picture exists, get the user's data for creating initials
|
2025-04-06 06:04:08 -07:00
|
|
|
user, err := s.GetUser(ctx, userID)
|
2025-02-19 14:28:45 +01:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, 0, err
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-03 08:06:56 -05:00
|
|
|
// Check if we have a cached default picture for these initials
|
|
|
|
|
defaultProfilePicturesDir := common.EnvConfig.UploadPath + "/profile-pictures/defaults/"
|
|
|
|
|
defaultPicturePath := defaultProfilePicturesDir + user.Initials() + ".png"
|
|
|
|
|
file, err = os.Open(defaultPicturePath)
|
|
|
|
|
if err == nil {
|
|
|
|
|
fileInfo, err := file.Stat()
|
|
|
|
|
if err != nil {
|
|
|
|
|
file.Close()
|
|
|
|
|
return nil, 0, err
|
|
|
|
|
}
|
|
|
|
|
return file, fileInfo.Size(), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If no cached default picture exists, create one and save it for future use
|
|
|
|
|
defaultPicture, err := profilepicture.CreateDefaultProfilePicture(user.Initials())
|
2025-02-19 14:28:45 +01:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, 0, err
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-03 08:06:56 -05:00
|
|
|
// Save the default picture for future use (in a goroutine to avoid blocking)
|
2025-04-04 01:04:36 -07:00
|
|
|
defaultPictureBytes := defaultPicture.Bytes()
|
2025-04-03 08:06:56 -05:00
|
|
|
go func() {
|
|
|
|
|
// Ensure the directory exists
|
|
|
|
|
err = os.MkdirAll(defaultProfilePicturesDir, os.ModePerm)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Printf("Failed to create directory for default profile picture: %v", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-04-04 01:04:36 -07:00
|
|
|
if err := utils.SaveFileStream(bytes.NewReader(defaultPictureBytes), defaultPicturePath); err != nil {
|
2025-04-03 08:06:56 -05:00
|
|
|
log.Printf("Failed to cache default profile picture for initials %s: %v", user.Initials(), err)
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
2025-04-04 01:04:36 -07:00
|
|
|
return io.NopCloser(bytes.NewReader(defaultPictureBytes)), int64(defaultPicture.Len()), nil
|
2025-02-19 14:28:45 +01:00
|
|
|
}
|
|
|
|
|
|
2025-04-06 06:04:08 -07:00
|
|
|
func (s *UserService) GetUserGroups(ctx context.Context, userID string) ([]model.UserGroup, error) {
|
2025-03-06 15:25:03 -06:00
|
|
|
var user model.User
|
2025-04-06 06:04:08 -07:00
|
|
|
err := s.db.
|
|
|
|
|
WithContext(ctx).
|
|
|
|
|
Preload("UserGroups").
|
|
|
|
|
Where("id = ?", userID).
|
|
|
|
|
First(&user).
|
|
|
|
|
Error
|
|
|
|
|
if err != nil {
|
2025-03-06 15:25:03 -06:00
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return user.UserGroups, nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-19 14:28:45 +01:00
|
|
|
func (s *UserService) UpdateProfilePicture(userID string, file io.Reader) error {
|
|
|
|
|
// Validate the user ID to prevent directory traversal
|
2025-03-23 13:21:44 -07:00
|
|
|
err := uuid.Validate(userID)
|
|
|
|
|
if err != nil {
|
2025-02-19 14:28:45 +01:00
|
|
|
return &common.InvalidUUIDError{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert the image to a smaller square image
|
|
|
|
|
profilePicture, err := profilepicture.CreateProfilePicture(file)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure the directory exists
|
2025-03-23 13:21:44 -07:00
|
|
|
profilePictureDir := common.EnvConfig.UploadPath + "/profile-pictures"
|
|
|
|
|
err = os.MkdirAll(profilePictureDir, os.ModePerm)
|
2025-02-19 14:28:45 +01:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-23 13:21:44 -07:00
|
|
|
// Create the profile picture file
|
|
|
|
|
err = utils.SaveFileStream(profilePicture, profilePictureDir+"/"+userID+".png")
|
2025-02-19 14:28:45 +01:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-06 06:04:08 -07:00
|
|
|
func (s *UserService) DeleteUser(ctx context.Context, userID string, allowLdapDelete bool) error {
|
2025-04-22 22:16:44 +09:00
|
|
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
|
|
|
|
return s.deleteUserInternal(ctx, userID, allowLdapDelete, tx)
|
|
|
|
|
})
|
2025-04-06 06:04:08 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *UserService) deleteUserInternal(ctx context.Context, userID string, allowLdapDelete bool, tx *gorm.DB) error {
|
2024-08-17 21:57:14 +02:00
|
|
|
var user model.User
|
2025-04-06 06:04:08 -07:00
|
|
|
|
|
|
|
|
err := tx.
|
|
|
|
|
WithContext(ctx).
|
|
|
|
|
Where("id = ?", userID).
|
|
|
|
|
First(&user).
|
|
|
|
|
Error
|
|
|
|
|
if err != nil {
|
2025-04-12 15:38:19 -07:00
|
|
|
return fmt.Errorf("failed to load user to delete: %w", err)
|
2024-08-17 21:57:14 +02:00
|
|
|
}
|
|
|
|
|
|
2025-04-18 10:38:50 -05:00
|
|
|
// 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 && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() {
|
2025-01-19 06:02:07 -06:00
|
|
|
return &common.LdapUserUpdateError{}
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-24 09:40:14 +01:00
|
|
|
// Delete the profile picture
|
2025-03-23 13:21:44 -07:00
|
|
|
profilePicturePath := common.EnvConfig.UploadPath + "/profile-pictures/" + userID + ".png"
|
2025-04-06 06:04:08 -07:00
|
|
|
err = os.Remove(profilePicturePath)
|
|
|
|
|
if err != nil && !os.IsNotExist(err) {
|
2025-02-24 09:40:14 +01:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-12 15:38:19 -07:00
|
|
|
err = tx.WithContext(ctx).Delete(&user).Error
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("failed to delete user: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
2025-04-06 06:04:08 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *UserService) CreateUser(ctx context.Context, input dto.UserCreateDto) (model.User, error) {
|
|
|
|
|
tx := s.db.Begin()
|
|
|
|
|
defer func() {
|
|
|
|
|
tx.Rollback()
|
|
|
|
|
}()
|
|
|
|
|
|
2025-04-12 15:38:19 -07:00
|
|
|
user, err := s.createUserInternal(ctx, input, false, tx)
|
2025-04-06 06:04:08 -07:00
|
|
|
if err != nil {
|
|
|
|
|
return model.User{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
err = tx.Commit().Error
|
|
|
|
|
if err != nil {
|
|
|
|
|
return model.User{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return user, nil
|
2024-08-17 21:57:14 +02:00
|
|
|
}
|
|
|
|
|
|
2025-04-12 15:38:19 -07:00
|
|
|
func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) {
|
2024-08-28 08:22:27 +02:00
|
|
|
user := model.User{
|
2024-08-23 17:04:19 +02:00
|
|
|
FirstName: input.FirstName,
|
|
|
|
|
LastName: input.LastName,
|
|
|
|
|
Email: input.Email,
|
|
|
|
|
Username: input.Username,
|
|
|
|
|
IsAdmin: input.IsAdmin,
|
2025-03-20 19:57:41 +01:00
|
|
|
Locale: input.Locale,
|
2024-08-23 17:04:19 +02:00
|
|
|
}
|
2025-01-20 10:27:36 +01:00
|
|
|
if input.LdapID != "" {
|
|
|
|
|
user.LdapID = &input.LdapID
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-06 06:04:08 -07:00
|
|
|
err := tx.WithContext(ctx).Create(&user).Error
|
|
|
|
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
2025-04-12 15:38:19 -07:00
|
|
|
// 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)
|
|
|
|
|
}
|
2025-04-06 06:04:08 -07:00
|
|
|
|
|
|
|
|
return model.User{}, err
|
|
|
|
|
} else if err != nil {
|
|
|
|
|
return model.User{}, err
|
|
|
|
|
}
|
|
|
|
|
return user, nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-03 23:32:56 +02:00
|
|
|
func (s *UserService) UpdateUser(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool) (model.User, error) {
|
2025-04-06 06:04:08 -07:00
|
|
|
tx := s.db.Begin()
|
|
|
|
|
defer func() {
|
|
|
|
|
tx.Rollback()
|
|
|
|
|
}()
|
|
|
|
|
|
2025-05-03 23:32:56 +02:00
|
|
|
user, err := s.updateUserInternal(ctx, userID, updatedUser, updateOwnUser, isLdapSync, tx)
|
2025-04-06 06:04:08 -07:00
|
|
|
if err != nil {
|
|
|
|
|
return model.User{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
err = tx.Commit().Error
|
|
|
|
|
if err != nil {
|
2024-08-23 17:04:19 +02:00
|
|
|
return model.User{}, err
|
2024-08-17 21:57:14 +02:00
|
|
|
}
|
2025-04-06 06:04:08 -07:00
|
|
|
|
2024-08-28 08:22:27 +02:00
|
|
|
return user, nil
|
2024-08-17 21:57:14 +02:00
|
|
|
}
|
|
|
|
|
|
2025-04-12 15:38:19 -07:00
|
|
|
func (s *UserService) updateUserInternal(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB) (model.User, error) {
|
2024-08-17 21:57:14 +02:00
|
|
|
var user model.User
|
2025-04-06 06:04:08 -07:00
|
|
|
err := tx.
|
|
|
|
|
WithContext(ctx).
|
|
|
|
|
Where("id = ?", userID).
|
|
|
|
|
First(&user).
|
|
|
|
|
Error
|
|
|
|
|
if err != nil {
|
2024-08-17 21:57:14 +02:00
|
|
|
return model.User{}, err
|
|
|
|
|
}
|
2025-01-19 06:02:07 -06:00
|
|
|
|
2025-05-03 23:32:56 +02:00
|
|
|
// Check if this is an LDAP user and LDAP is enabled
|
|
|
|
|
isLdapUser := user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue()
|
2025-06-02 11:35:13 +02:00
|
|
|
allowOwnAccountEdit := s.appConfigService.GetDbConfig().AllowOwnAccountEdit.IsTrue()
|
2025-05-03 23:32:56 +02:00
|
|
|
|
2025-06-02 11:35:13 +02:00
|
|
|
// For LDAP users or if own account editing is not allowed, only allow updating the locale unless it's an LDAP sync
|
|
|
|
|
if !isLdapSync && (isLdapUser || (!allowOwnAccountEdit && !updateOwnUser)) {
|
2025-05-03 23:32:56 +02:00
|
|
|
user.Locale = updatedUser.Locale
|
|
|
|
|
} else {
|
|
|
|
|
user.FirstName = updatedUser.FirstName
|
|
|
|
|
user.LastName = updatedUser.LastName
|
|
|
|
|
user.Email = updatedUser.Email
|
|
|
|
|
user.Username = updatedUser.Username
|
|
|
|
|
user.Locale = updatedUser.Locale
|
|
|
|
|
if !updateOwnUser {
|
|
|
|
|
user.IsAdmin = updatedUser.IsAdmin
|
|
|
|
|
user.Disabled = updatedUser.Disabled
|
|
|
|
|
}
|
2024-08-17 21:57:14 +02:00
|
|
|
}
|
|
|
|
|
|
2025-04-06 06:04:08 -07:00
|
|
|
err = tx.
|
|
|
|
|
WithContext(ctx).
|
|
|
|
|
Save(&user).
|
|
|
|
|
Error
|
|
|
|
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
2025-04-12 15:38:19 -07:00
|
|
|
// 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)
|
|
|
|
|
}
|
2025-04-06 06:04:08 -07:00
|
|
|
|
|
|
|
|
return user, err
|
|
|
|
|
} else if err != nil {
|
2024-08-17 21:57:14 +02:00
|
|
|
return user, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return user, nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-20 18:32:40 +02:00
|
|
|
func (s *UserService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, expiration time.Time) error {
|
|
|
|
|
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue()
|
|
|
|
|
if isDisabled {
|
|
|
|
|
return &common.OneTimeAccessDisabledError{}
|
|
|
|
|
}
|
2025-04-06 06:04:08 -07:00
|
|
|
|
2025-04-20 18:32:40 +02:00
|
|
|
return s.requestOneTimeAccessEmailInternal(ctx, userID, "", expiration)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) error {
|
|
|
|
|
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue()
|
2025-03-10 12:45:45 +01:00
|
|
|
if isDisabled {
|
|
|
|
|
return &common.OneTimeAccessDisabledError{}
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-20 18:32:40 +02:00
|
|
|
var userId string
|
|
|
|
|
err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
|
2025-04-06 06:04:08 -07:00
|
|
|
if err != nil {
|
2025-01-19 15:30:31 +01:00
|
|
|
// Do not return error if user not found to prevent email enumeration
|
|
|
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
|
|
|
return nil
|
|
|
|
|
} else {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-20 18:32:40 +02:00
|
|
|
expiration := time.Now().Add(15 * time.Minute)
|
|
|
|
|
return s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, expiration)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, expiration time.Time) error {
|
|
|
|
|
tx := s.db.Begin()
|
|
|
|
|
defer func() {
|
|
|
|
|
tx.Rollback()
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
user, err := s.GetUser(ctx, userID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
oneTimeAccessToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, expiration, tx)
|
2025-01-19 15:30:31 +01:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-06 06:04:08 -07:00
|
|
|
err = tx.Commit().Error
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
2025-01-19 15:30:31 +01:00
|
|
|
}
|
|
|
|
|
|
2025-04-06 06:04:08 -07:00
|
|
|
// We use a background context here as this is running in a goroutine
|
|
|
|
|
//nolint:contextcheck
|
2025-01-19 15:30:31 +01:00
|
|
|
go func() {
|
2025-04-06 06:04:08 -07:00
|
|
|
innerCtx := context.Background()
|
|
|
|
|
|
|
|
|
|
link := common.EnvConfig.AppURL + "/lc"
|
|
|
|
|
linkWithCode := link + "/" + oneTimeAccessToken
|
|
|
|
|
|
|
|
|
|
// Add redirect path to the link
|
|
|
|
|
if strings.HasPrefix(redirectPath, "/") {
|
|
|
|
|
encodedRedirectPath := url.QueryEscape(redirectPath)
|
|
|
|
|
linkWithCode = linkWithCode + "?redirect=" + encodedRedirectPath
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
errInternal := SendEmail(innerCtx, s.emailService, email.Address{
|
2025-04-20 09:40:20 -05:00
|
|
|
Name: user.FullName(),
|
2025-01-19 15:30:31 +01:00
|
|
|
Email: user.Email,
|
|
|
|
|
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
|
2025-03-10 12:45:45 +01:00
|
|
|
Code: oneTimeAccessToken,
|
|
|
|
|
LoginLink: link,
|
|
|
|
|
LoginLinkWithCode: linkWithCode,
|
2025-04-20 18:32:40 +02:00
|
|
|
ExpirationString: utils.DurationToString(time.Until(expiration).Round(time.Second)),
|
2025-01-19 15:30:31 +01:00
|
|
|
})
|
2025-04-06 06:04:08 -07:00
|
|
|
if errInternal != nil {
|
|
|
|
|
log.Printf("Failed to send email to '%s': %v\n", user.Email, errInternal)
|
2025-01-19 15:30:31 +01:00
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-06 06:04:08 -07:00
|
|
|
func (s *UserService) CreateOneTimeAccessToken(ctx context.Context, userID string, expiresAt time.Time) (string, error) {
|
|
|
|
|
return s.createOneTimeAccessTokenInternal(ctx, userID, expiresAt, s.db)
|
|
|
|
|
}
|
2025-03-10 12:45:45 +01:00
|
|
|
|
2025-04-06 06:04:08 -07:00
|
|
|
func (s *UserService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, expiresAt time.Time, tx *gorm.DB) (string, error) {
|
2025-05-18 04:22:40 -07:00
|
|
|
oneTimeAccessToken, err := NewOneTimeAccessToken(userID, expiresAt)
|
2024-08-17 21:57:14 +02:00
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-18 04:22:40 -07:00
|
|
|
if err := tx.WithContext(ctx).Create(oneTimeAccessToken).Error; err != nil {
|
2024-08-17 21:57:14 +02:00
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return oneTimeAccessToken.Token, nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-06 06:04:08 -07:00
|
|
|
func (s *UserService) ExchangeOneTimeAccessToken(ctx context.Context, token string, ipAddress, userAgent string) (model.User, string, error) {
|
|
|
|
|
tx := s.db.Begin()
|
|
|
|
|
defer func() {
|
|
|
|
|
tx.Rollback()
|
|
|
|
|
}()
|
|
|
|
|
|
2024-08-17 21:57:14 +02:00
|
|
|
var oneTimeAccessToken model.OneTimeAccessToken
|
2025-04-06 06:04:08 -07:00
|
|
|
err := tx.
|
|
|
|
|
WithContext(ctx).
|
|
|
|
|
Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).Preload("User").
|
|
|
|
|
First(&oneTimeAccessToken).
|
|
|
|
|
Error
|
|
|
|
|
if err != nil {
|
2024-08-17 21:57:14 +02:00
|
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
2024-10-28 18:11:54 +01:00
|
|
|
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
2024-08-17 21:57:14 +02:00
|
|
|
}
|
|
|
|
|
return model.User{}, "", err
|
|
|
|
|
}
|
|
|
|
|
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return model.User{}, "", err
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-06 06:04:08 -07:00
|
|
|
err = tx.
|
|
|
|
|
WithContext(ctx).
|
|
|
|
|
Delete(&oneTimeAccessToken).
|
|
|
|
|
Error
|
|
|
|
|
if err != nil {
|
2024-08-17 21:57:14 +02:00
|
|
|
return model.User{}, "", err
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-19 15:30:31 +01:00
|
|
|
if ipAddress != "" && userAgent != "" {
|
2025-04-06 06:04:08 -07:00
|
|
|
s.auditLogService.Create(ctx, model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}, tx)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
err = tx.Commit().Error
|
|
|
|
|
if err != nil {
|
|
|
|
|
return model.User{}, "", err
|
2025-01-19 15:30:31 +01:00
|
|
|
}
|
|
|
|
|
|
2024-08-17 21:57:14 +02:00
|
|
|
return oneTimeAccessToken.User, accessToken, nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-06 06:04:08 -07:00
|
|
|
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)
|
2025-03-06 15:25:03 -06:00
|
|
|
if err != nil {
|
|
|
|
|
return model.User{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fetch the groups based on userGroupIds
|
|
|
|
|
var groups []model.UserGroup
|
|
|
|
|
if len(userGroupIds) > 0 {
|
2025-04-06 06:04:08 -07:00
|
|
|
err = tx.
|
|
|
|
|
WithContext(ctx).
|
|
|
|
|
Where("id IN (?)", userGroupIds).
|
|
|
|
|
Find(&groups).
|
|
|
|
|
Error
|
|
|
|
|
if err != nil {
|
2025-03-06 15:25:03 -06:00
|
|
|
return model.User{}, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Replace the current groups with the new set of groups
|
2025-04-06 06:04:08 -07:00
|
|
|
err = tx.
|
|
|
|
|
WithContext(ctx).
|
|
|
|
|
Model(&user).
|
|
|
|
|
Association("UserGroups").
|
|
|
|
|
Replace(groups)
|
|
|
|
|
if err != nil {
|
2025-03-06 15:25:03 -06:00
|
|
|
return model.User{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Save the updated user
|
2025-04-06 06:04:08 -07:00
|
|
|
err = tx.WithContext(ctx).Save(&user).Error
|
|
|
|
|
if err != nil {
|
|
|
|
|
return model.User{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
err = tx.Commit().Error
|
|
|
|
|
if err != nil {
|
2025-03-06 15:25:03 -06:00
|
|
|
return model.User{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return user, nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-06 06:04:08 -07:00
|
|
|
func (s *UserService) SetupInitialAdmin(ctx context.Context) (model.User, string, error) {
|
|
|
|
|
tx := s.db.Begin()
|
|
|
|
|
defer func() {
|
|
|
|
|
tx.Rollback()
|
|
|
|
|
}()
|
|
|
|
|
|
2024-08-17 21:57:14 +02:00
|
|
|
var userCount int64
|
2025-04-06 06:04:08 -07:00
|
|
|
if err := tx.WithContext(ctx).Model(&model.User{}).Count(&userCount).Error; err != nil {
|
2024-08-17 21:57:14 +02:00
|
|
|
return model.User{}, "", err
|
|
|
|
|
}
|
|
|
|
|
if userCount > 1 {
|
2024-10-28 18:11:54 +01:00
|
|
|
return model.User{}, "", &common.SetupAlreadyCompletedError{}
|
2024-08-17 21:57:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
user := model.User{
|
|
|
|
|
FirstName: "Admin",
|
|
|
|
|
LastName: "Admin",
|
|
|
|
|
Username: "admin",
|
|
|
|
|
Email: "admin@admin.com",
|
|
|
|
|
IsAdmin: true,
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-06 06:04:08 -07:00
|
|
|
if err := tx.WithContext(ctx).Model(&model.User{}).Preload("Credentials").FirstOrCreate(&user).Error; err != nil {
|
2024-08-17 21:57:14 +02:00
|
|
|
return model.User{}, "", err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(user.Credentials) > 0 {
|
2024-10-28 18:11:54 +01:00
|
|
|
return model.User{}, "", &common.SetupAlreadyCompletedError{}
|
2024-08-17 21:57:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
token, err := s.jwtService.GenerateAccessToken(user)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return model.User{}, "", err
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-06 06:04:08 -07:00
|
|
|
err = tx.Commit().Error
|
|
|
|
|
if err != nil {
|
|
|
|
|
return model.User{}, "", err
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-17 21:57:14 +02:00
|
|
|
return user, token, nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-06 06:04:08 -07:00
|
|
|
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 {
|
2024-10-28 18:11:54 +01:00
|
|
|
return &common.AlreadyInUseError{Property: "email"}
|
2024-08-17 21:57:14 +02:00
|
|
|
}
|
|
|
|
|
|
2025-04-06 06:04:08 -07:00
|
|
|
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 {
|
2024-10-28 18:11:54 +01:00
|
|
|
return &common.AlreadyInUseError{Property: "username"}
|
2024-08-17 21:57:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2025-03-18 14:59:31 -05:00
|
|
|
|
|
|
|
|
// ResetProfilePicture deletes a user's custom profile picture
|
|
|
|
|
func (s *UserService) ResetProfilePicture(userID string) error {
|
|
|
|
|
// Validate the user ID to prevent directory traversal
|
|
|
|
|
if err := uuid.Validate(userID); err != nil {
|
|
|
|
|
return &common.InvalidUUIDError{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build path to profile picture
|
2025-03-23 13:21:44 -07:00
|
|
|
profilePicturePath := common.EnvConfig.UploadPath + "/profile-pictures/" + userID + ".png"
|
2025-03-18 14:59:31 -05:00
|
|
|
|
|
|
|
|
// Check if file exists and delete it
|
|
|
|
|
if _, err := os.Stat(profilePicturePath); err == nil {
|
|
|
|
|
if err := os.Remove(profilePicturePath); err != nil {
|
|
|
|
|
return fmt.Errorf("failed to delete profile picture: %w", err)
|
|
|
|
|
}
|
|
|
|
|
} else if !os.IsNotExist(err) {
|
|
|
|
|
// If any error other than "file not exists"
|
|
|
|
|
return fmt.Errorf("failed to check if profile picture exists: %w", err)
|
|
|
|
|
}
|
|
|
|
|
// It's okay if the file doesn't exist - just means there's no custom picture to delete
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2025-04-18 10:38:50 -05:00
|
|
|
|
2025-04-22 22:16:44 +09:00
|
|
|
func (s *UserService) disableUserInternal(ctx context.Context, userID string, tx *gorm.DB) error {
|
|
|
|
|
return tx.
|
|
|
|
|
WithContext(ctx).
|
2025-04-18 10:38:50 -05:00
|
|
|
Model(&model.User{}).
|
|
|
|
|
Where("id = ?", userID).
|
|
|
|
|
Update("disabled", true).
|
|
|
|
|
Error
|
|
|
|
|
}
|
2025-05-18 04:22:40 -07:00
|
|
|
|
|
|
|
|
func NewOneTimeAccessToken(userID string, expiresAt time.Time) (*model.OneTimeAccessToken, error) {
|
|
|
|
|
// If expires at is less than 15 minutes, use a 6-character token instead of 16
|
|
|
|
|
tokenLength := 16
|
|
|
|
|
if time.Until(expiresAt) <= 15*time.Minute {
|
|
|
|
|
tokenLength = 6
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
randomString, err := utils.GenerateRandomAlphanumericString(tokenLength)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
o := &model.OneTimeAccessToken{
|
|
|
|
|
UserID: userID,
|
|
|
|
|
ExpiresAt: datatype.DateTime(expiresAt),
|
|
|
|
|
Token: randomString,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return o, nil
|
|
|
|
|
}
|