mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-22 12:41:27 +02:00
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
232 lines
6.3 KiB
Go
232 lines
6.3 KiB
Go
package usersignup
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"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/utils"
|
|
)
|
|
|
|
// authenticationMethodOneTimePassword identifies one-time-password authentication, used for the initial admin setup token
|
|
// It must match the value emitted by the JWT service in the access token's "amr" claim
|
|
const authenticationMethodOneTimePassword = "otp"
|
|
|
|
type Service struct {
|
|
db *gorm.DB
|
|
userCreator UserCreator
|
|
signer TokenService
|
|
auditLog AuditLogger
|
|
}
|
|
|
|
func newService(deps Dependencies) *Service {
|
|
return &Service{
|
|
db: deps.DB,
|
|
userCreator: deps.UserCreator,
|
|
signer: deps.Signer,
|
|
auditLog: deps.AuditLog,
|
|
}
|
|
}
|
|
|
|
func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel, signupData signUpDto, ipAddress, userAgent string) (model.User, string, error) {
|
|
tx := s.db.Begin()
|
|
defer func() {
|
|
tx.Rollback()
|
|
}()
|
|
|
|
tokenProvided := signupData.Token != ""
|
|
|
|
if config.AllowUserSignups.String() != "open" && !tokenProvided {
|
|
return model.User{}, "", &common.OpenSignupDisabledError{}
|
|
}
|
|
|
|
var signupToken SignupToken
|
|
var userGroupIDs []string
|
|
if tokenProvided {
|
|
err := tx.
|
|
WithContext(ctx).
|
|
Preload("UserGroups").
|
|
Where("token = ?", signupData.Token).
|
|
Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
First(&signupToken).
|
|
Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
|
}
|
|
return model.User{}, "", err
|
|
}
|
|
|
|
if !signupToken.IsValid() {
|
|
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
|
}
|
|
|
|
for _, group := range signupToken.UserGroups {
|
|
userGroupIDs = append(userGroupIDs, group.ID)
|
|
}
|
|
}
|
|
|
|
userToCreate := dto.UserCreateDto{
|
|
Username: signupData.Username,
|
|
Email: signupData.Email,
|
|
FirstName: signupData.FirstName,
|
|
LastName: signupData.LastName,
|
|
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
|
|
UserGroupIds: userGroupIDs,
|
|
EmailVerified: config.EmailsVerified.IsTrue(),
|
|
}
|
|
|
|
user, err := s.userCreator.CreateUserInternal(ctx, config, userToCreate, false, tx)
|
|
if err != nil {
|
|
return model.User{}, "", err
|
|
}
|
|
|
|
accessToken, err := s.signer.GenerateAccessToken(user, "", config.SessionDuration.AsDurationMinutes())
|
|
if err != nil {
|
|
return model.User{}, "", err
|
|
}
|
|
|
|
if tokenProvided {
|
|
s.auditLog.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
|
|
"signupToken": signupToken.Token,
|
|
}, tx)
|
|
|
|
signupToken.UsageCount++
|
|
|
|
err = tx.WithContext(ctx).Save(&signupToken).Error
|
|
if err != nil {
|
|
return model.User{}, "", err
|
|
}
|
|
} else {
|
|
s.auditLog.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
|
|
"method": "open_signup",
|
|
}, tx)
|
|
}
|
|
|
|
err = tx.Commit().Error
|
|
if err != nil {
|
|
return model.User{}, "", err
|
|
}
|
|
|
|
return user, accessToken, nil
|
|
}
|
|
|
|
func (s *Service) SignUpInitialAdmin(ctx context.Context, config *appconfig.AppConfigModel, signUpData signUpDto) (model.User, string, error) {
|
|
tx := s.db.Begin()
|
|
defer func() {
|
|
tx.Rollback()
|
|
}()
|
|
|
|
setupCompleted, err := s.isInitialAdminSetupCompleted(ctx, tx)
|
|
if err != nil {
|
|
return model.User{}, "", err
|
|
}
|
|
if setupCompleted {
|
|
return model.User{}, "", &common.SetupNotAvailableError{}
|
|
}
|
|
|
|
userToCreate := dto.UserCreateDto{
|
|
FirstName: signUpData.FirstName,
|
|
LastName: signUpData.LastName,
|
|
DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
|
|
Username: signUpData.Username,
|
|
Email: signUpData.Email,
|
|
IsAdmin: true,
|
|
}
|
|
|
|
user, err := s.userCreator.CreateUserInternal(ctx, config, userToCreate, false, tx)
|
|
if err != nil {
|
|
return model.User{}, "", err
|
|
}
|
|
|
|
token, err := s.signer.GenerateAccessToken(user, authenticationMethodOneTimePassword, config.SessionDuration.AsDurationMinutes())
|
|
if err != nil {
|
|
return model.User{}, "", err
|
|
}
|
|
|
|
err = tx.Commit().Error
|
|
if err != nil {
|
|
return model.User{}, "", err
|
|
}
|
|
|
|
return user, token, nil
|
|
}
|
|
|
|
func (s *Service) IsInitialAdminSetupCompleted(ctx context.Context) (bool, error) {
|
|
return s.isInitialAdminSetupCompleted(ctx, s.db)
|
|
}
|
|
|
|
func (s *Service) isInitialAdminSetupCompleted(ctx context.Context, db *gorm.DB) (bool, error) {
|
|
var userCount int64
|
|
if err := db.WithContext(ctx).Model(&model.User{}).
|
|
Where("id != ?", common.StaticApiKeyUserID).
|
|
Count(&userCount).Error; err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return userCount != 0, nil
|
|
}
|
|
|
|
func (s *Service) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]SignupToken, utils.PaginationResponse, error) {
|
|
var tokens []SignupToken
|
|
query := s.db.WithContext(ctx).Preload("UserGroups").Model(&SignupToken{})
|
|
|
|
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &tokens)
|
|
return tokens, pagination, err
|
|
}
|
|
|
|
func (s *Service) DeleteSignupToken(ctx context.Context, tokenID string) error {
|
|
return s.db.WithContext(ctx).Delete(&SignupToken{}, "id = ?", tokenID).Error
|
|
}
|
|
|
|
func (s *Service) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (SignupToken, error) {
|
|
signupToken, err := newSignupToken(ttl, usageLimit)
|
|
if err != nil {
|
|
return SignupToken{}, err
|
|
}
|
|
|
|
var userGroups []model.UserGroup
|
|
err = s.db.WithContext(ctx).
|
|
Where("id IN ?", userGroupIDs).
|
|
Find(&userGroups).
|
|
Error
|
|
if err != nil {
|
|
return SignupToken{}, err
|
|
}
|
|
signupToken.UserGroups = userGroups
|
|
|
|
err = s.db.WithContext(ctx).Create(signupToken).Error
|
|
if err != nil {
|
|
return SignupToken{}, err
|
|
}
|
|
|
|
return *signupToken, nil
|
|
}
|
|
|
|
func newSignupToken(ttl time.Duration, usageLimit int) (*SignupToken, error) {
|
|
// Generate a random token
|
|
randomString, err := utils.GenerateRandomAlphanumericString(16)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := time.Now().Round(time.Second)
|
|
token := &SignupToken{
|
|
Token: randomString,
|
|
ExpiresAt: datatype.DateTime(now.Add(ttl)),
|
|
UsageLimit: usageLimit,
|
|
UsageCount: 0,
|
|
}
|
|
|
|
return token, nil
|
|
}
|