diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 26d9947a..2490e854 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -143,7 +143,10 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) error { controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService) controller.NewVersionController(apiGroup, authMiddleware, svc.versionService) controller.NewScimController(apiGroup, authMiddleware, svc.scimService) - controller.NewUserSignupController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userSignUpService, svc.appConfigService) + svc.userSignUpModule.RegisterRoutes(apiGroup, + authMiddleware.Add(), + middleware.NewRateLimitMiddleware().Add(rate.Every(1*time.Minute), 10), + ) optionalBrowserAuth := authMiddleware.WithAdminNotRequired().WithSuccessOptional().WithApiKeyAuthDisabled().Add() browserAuth := authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add() diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index 5d0a33b4..4a850f84 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -13,6 +13,7 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/oidc" "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/storage" + "github.com/pocket-id/pocket-id/backend/internal/usersignup" "github.com/pocket-id/pocket-id/backend/internal/webauthn" ) @@ -32,12 +33,12 @@ type services struct { versionService *service.VersionService fileStorage storage.FileStorage appLockService *service.AppLockService - userSignUpService *service.UserSignUpService oneTimeAccessService *service.OneTimeAccessService - apiKeyModule *apikey.Module - oidcModule *oidc.Module - webauthnModule *webauthn.Module + apiKeyModule *apikey.Module + oidcModule *oidc.Module + webauthnModule *webauthn.Module + userSignUpModule *usersignup.Module } // Initializes all services @@ -113,7 +114,13 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima return nil, fmt.Errorf("failed to create API key module: %w", err) } - svc.userSignUpService = service.NewUserSignupService(db, svc.jwtService, svc.auditLogService, svc.appConfigService, svc.userService) + svc.userSignUpModule = usersignup.New(usersignup.Dependencies{ + DB: db, + Signer: svc.jwtService, + AuditLog: svc.auditLogService, + AppConfig: svc.appConfigService, + UserCreator: svc.userService, + }) svc.oneTimeAccessService = service.NewOneTimeAccessService(db, svc.userService, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService) svc.versionService = service.NewVersionService(httpClient) diff --git a/backend/internal/dto/signup_dto.go b/backend/internal/dto/signup_dto.go deleted file mode 100644 index a31d3e93..00000000 --- a/backend/internal/dto/signup_dto.go +++ /dev/null @@ -1,9 +0,0 @@ -package dto - -type SignUpDto struct { - Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"` - Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"` - FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"` - LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` - Token string `json:"token"` -} diff --git a/backend/internal/dto/signup_token_dto.go b/backend/internal/dto/signup_token_dto.go deleted file mode 100644 index bffd430e..00000000 --- a/backend/internal/dto/signup_token_dto.go +++ /dev/null @@ -1,22 +0,0 @@ -package dto - -import ( - datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" - "github.com/pocket-id/pocket-id/backend/internal/utils" -) - -type SignupTokenCreateDto struct { - TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"` - UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"` - UserGroupIDs []string `json:"userGroupIds"` -} - -type SignupTokenDto struct { - ID string `json:"id"` - Token string `json:"token"` - ExpiresAt datatype.DateTime `json:"expiresAt"` - UsageLimit int `json:"usageLimit"` - UsageCount int `json:"usageCount"` - UserGroups []UserGroupMinimalDto `json:"userGroups"` - CreatedAt datatype.DateTime `json:"createdAt"` -} diff --git a/backend/internal/job/db_cleanup_job.go b/backend/internal/job/db_cleanup_job.go index 7e3f434a..cc092d92 100644 --- a/backend/internal/job/db_cleanup_job.go +++ b/backend/internal/job/db_cleanup_job.go @@ -15,6 +15,7 @@ import ( datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/oidc" "github.com/pocket-id/pocket-id/backend/internal/service" + "github.com/pocket-id/pocket-id/backend/internal/usersignup" "github.com/pocket-id/pocket-id/backend/internal/webauthn" ) @@ -74,17 +75,14 @@ func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error { return nil } -// ClearSignupTokens deletes signup tokens that have expired +// clearSignupTokens deletes signup tokens that have expired func (j *DbCleanupJobs) clearSignupTokens(ctx context.Context) error { - // Delete tokens that are expired OR have reached their usage limit - st := j.db. - WithContext(ctx). - Delete(&model.SignupToken{}, "expires_at < ?", datatype.DateTime(time.Now())) - if st.Error != nil { - return fmt.Errorf("failed to clean expired tokens: %w", st.Error) + count, err := usersignup.CleanupExpiredSignupTokens(ctx, j.db) + if err != nil { + return fmt.Errorf("failed to clean expired signup tokens: %w", err) } - slog.InfoContext(ctx, "Cleaned expired tokens", slog.Int64("count", st.RowsAffected)) + slog.InfoContext(ctx, "Cleaned expired signup tokens", slog.Int64("count", count)) return nil } diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index db28bfa7..ed3f2e75 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -29,6 +29,7 @@ import ( datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/oidc" "github.com/pocket-id/pocket-id/backend/internal/storage" + "github.com/pocket-id/pocket-id/backend/internal/usersignup" "github.com/pocket-id/pocket-id/backend/internal/utils" jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk" "github.com/pocket-id/pocket-id/backend/internal/webauthn" @@ -387,7 +388,7 @@ func (s *TestService) SeedDatabase(baseURL string) error { } } - signupTokens := []model.SignupToken{ + signupTokens := []usersignup.SignupToken{ { Base: model.Base{ ID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", diff --git a/backend/internal/service/ldap_service.go b/backend/internal/service/ldap_service.go index 9de25b30..73392c61 100644 --- a/backend/internal/service/ldap_service.go +++ b/backend/internal/service/ldap_service.go @@ -531,7 +531,7 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs userID := databaseUser.ID if databaseUser.ID == "" { - createdUser, err := s.userService.createUserInternal(ctx, desiredUser.input, true, tx) + createdUser, err := s.userService.CreateUserInternal(ctx, desiredUser.input, true, tx) if errors.Is(err, &common.AlreadyInUseError{}) { slog.Warn("Skipping creating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err)) continue diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 8899d68e..07be9c67 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -239,7 +239,7 @@ func (s *UserService) CreateUser(ctx context.Context, input dto.UserCreateDto) ( tx.Rollback() }() - user, err := s.createUserInternal(ctx, input, false, tx) + user, err := s.CreateUserInternal(ctx, input, false, tx) if err != nil { return model.User{}, err } @@ -252,7 +252,7 @@ func (s *UserService) CreateUser(ctx context.Context, input dto.UserCreateDto) ( return user, nil } -func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) { +func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) { if s.appConfigService.GetDbConfig().RequireUserEmail.IsTrue() && input.Email == nil { return model.User{}, &common.UserEmailNotSetError{} } diff --git a/backend/internal/usersignup/cleanup.go b/backend/internal/usersignup/cleanup.go new file mode 100644 index 00000000..bcbc0971 --- /dev/null +++ b/backend/internal/usersignup/cleanup.go @@ -0,0 +1,19 @@ +package usersignup + +import ( + "context" + "time" + + "gorm.io/gorm" + + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" +) + +// CleanupExpiredSignupTokens deletes signup tokens that have expired +// It returns the number of rows removed +func CleanupExpiredSignupTokens(ctx context.Context, db *gorm.DB) (int64, error) { + st := db. + WithContext(ctx). + Delete(&SignupToken{}, "expires_at < ?", datatype.DateTime(time.Now())) + return st.RowsAffected, st.Error +} diff --git a/backend/internal/usersignup/dto.go b/backend/internal/usersignup/dto.go new file mode 100644 index 00000000..b7a4aad4 --- /dev/null +++ b/backend/internal/usersignup/dto.go @@ -0,0 +1,31 @@ +package usersignup + +import ( + "github.com/pocket-id/pocket-id/backend/internal/dto" + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + "github.com/pocket-id/pocket-id/backend/internal/utils" +) + +type signUpDto struct { + Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"` + Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"` + FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"` + LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` + Token string `json:"token"` +} + +type signupTokenCreateDto struct { + TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"` + UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"` + UserGroupIDs []string `json:"userGroupIds"` +} + +type signupTokenDto struct { + ID string `json:"id"` + Token string `json:"token"` + ExpiresAt datatype.DateTime `json:"expiresAt"` + UsageLimit int `json:"usageLimit"` + UsageCount int `json:"usageCount"` + UserGroups []dto.UserGroupMinimalDto `json:"userGroups"` + CreatedAt datatype.DateTime `json:"createdAt"` +} diff --git a/backend/internal/controller/user_signup_controller.go b/backend/internal/usersignup/handler.go similarity index 51% rename from backend/internal/controller/user_signup_controller.go rename to backend/internal/usersignup/handler.go index b8c4edf5..5abdbcd7 100644 --- a/backend/internal/controller/user_signup_controller.go +++ b/backend/internal/usersignup/handler.go @@ -1,48 +1,30 @@ -package controller +package usersignup import ( "net/http" "time" - "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" - "github.com/gin-gonic/gin" + "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/middleware" - "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/utils" - "golang.org/x/time/rate" + "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" ) const defaultSignupTokenDuration = time.Hour -// NewUserSignupController creates a new controller for user signup and signup token management -// @Summary User signup and signup token management controller -// @Description Initializes all user signup-related API endpoints -// @Tags Users -func NewUserSignupController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userSignUpService *service.UserSignUpService, appConfigService *service.AppConfigService) { - usc := UserSignupController{ - userSignUpService: userSignUpService, - appConfigService: appConfigService, - } - - group.POST("/signup-tokens", authMiddleware.Add(), usc.createSignupTokenHandler) - group.GET("/signup-tokens", authMiddleware.Add(), usc.listSignupTokensHandler) - group.DELETE("/signup-tokens/:id", authMiddleware.Add(), usc.deleteSignupTokenHandler) - group.POST("/signup", rateLimitMiddleware.Add(rate.Every(1*time.Minute), 10), usc.signupHandler) - group.GET("/signup/setup", usc.checkInitialAdminSetupAvailable) - group.POST("/signup/setup", usc.signUpInitialAdmin) - +type handler struct { + service *Service + appConfig AppConfigProvider } -type UserSignupController struct { - userSignUpService *service.UserSignUpService - appConfigService *service.AppConfigService +func newHandler(service *Service, appConfig AppConfigProvider) *handler { + return &handler{service: service, appConfig: appConfig} } -func (usc *UserSignupController) checkInitialAdminSetupAvailable(c *gin.Context) { - setupCompleted, err := usc.userSignUpService.IsInitialAdminSetupCompleted(c.Request.Context()) +func (h *handler) checkInitialAdminSetupAvailable(c *gin.Context) { + setupCompleted, err := h.service.IsInitialAdminSetupCompleted(c.Request.Context()) if err != nil { _ = c.Error(err) return @@ -62,17 +44,17 @@ func (usc *UserSignupController) checkInitialAdminSetupAvailable(c *gin.Context) // @Tags Users // @Accept json // @Produce json -// @Param body body dto.SignUpDto true "User information" +// @Param body body signUpDto true "User information" // @Success 200 {object} dto.UserDto // @Router /api/signup/setup [post] -func (usc *UserSignupController) signUpInitialAdmin(c *gin.Context) { - var input dto.SignUpDto +func (h *handler) signUpInitialAdmin(c *gin.Context) { + var input signUpDto if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { _ = c.Error(err) return } - user, token, err := usc.userSignUpService.SignUpInitialAdmin(c.Request.Context(), input) + user, token, err := h.service.SignUpInitialAdmin(c.Request.Context(), input) if err != nil { _ = c.Error(err) return @@ -84,7 +66,7 @@ func (usc *UserSignupController) signUpInitialAdmin(c *gin.Context) { return } - maxAge := int(usc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds()) + maxAge := int(h.appConfig.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds()) cookie.AddAccessTokenCookie(c, maxAge, token) c.JSON(http.StatusOK, userDto) @@ -96,11 +78,11 @@ func (usc *UserSignupController) signUpInitialAdmin(c *gin.Context) { // @Tags Users // @Accept json // @Produce json -// @Param token body dto.SignupTokenCreateDto true "Signup token information" -// @Success 201 {object} dto.SignupTokenDto +// @Param token body signupTokenCreateDto true "Signup token information" +// @Success 201 {object} signupTokenDto // @Router /api/signup-tokens [post] -func (usc *UserSignupController) createSignupTokenHandler(c *gin.Context) { - var input dto.SignupTokenCreateDto +func (h *handler) createSignupToken(c *gin.Context) { + var input signupTokenCreateDto if err := c.ShouldBindJSON(&input); err != nil { _ = c.Error(err) return @@ -111,13 +93,13 @@ func (usc *UserSignupController) createSignupTokenHandler(c *gin.Context) { ttl = defaultSignupTokenDuration } - signupToken, err := usc.userSignUpService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs) + signupToken, err := h.service.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs) if err != nil { _ = c.Error(err) return } - var tokenDto dto.SignupTokenDto + var tokenDto signupTokenDto err = dto.MapStruct(signupToken, &tokenDto) if err != nil { _ = c.Error(err) @@ -135,24 +117,24 @@ func (usc *UserSignupController) createSignupTokenHandler(c *gin.Context) { // @Param pagination[limit] query int false "Number of items per page" default(20) // @Param sort[column] query string false "Column to sort by" // @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc") -// @Success 200 {object} dto.Paginated[dto.SignupTokenDto] +// @Success 200 {object} dto.Paginated[signupTokenDto] // @Router /api/signup-tokens [get] -func (usc *UserSignupController) listSignupTokensHandler(c *gin.Context) { +func (h *handler) listSignupTokens(c *gin.Context) { listRequestOptions := utils.ParseListRequestOptions(c) - tokens, pagination, err := usc.userSignUpService.ListSignupTokens(c.Request.Context(), listRequestOptions) + tokens, pagination, err := h.service.ListSignupTokens(c.Request.Context(), listRequestOptions) if err != nil { _ = c.Error(err) return } - var tokensDto []dto.SignupTokenDto + var tokensDto []signupTokenDto if err := dto.MapStructList(tokens, &tokensDto); err != nil { _ = c.Error(err) return } - c.JSON(http.StatusOK, dto.Paginated[dto.SignupTokenDto]{ + c.JSON(http.StatusOK, dto.Paginated[signupTokenDto]{ Data: tokensDto, Pagination: pagination, }) @@ -165,10 +147,10 @@ func (usc *UserSignupController) listSignupTokensHandler(c *gin.Context) { // @Param id path string true "Token ID" // @Success 204 "No Content" // @Router /api/signup-tokens/{id} [delete] -func (usc *UserSignupController) deleteSignupTokenHandler(c *gin.Context) { +func (h *handler) deleteSignupToken(c *gin.Context) { tokenID := c.Param("id") - err := usc.userSignUpService.DeleteSignupToken(c.Request.Context(), tokenID) + err := h.service.DeleteSignupToken(c.Request.Context(), tokenID) if err != nil { _ = c.Error(err) return @@ -177,17 +159,17 @@ func (usc *UserSignupController) deleteSignupTokenHandler(c *gin.Context) { c.Status(http.StatusNoContent) } -// signupWithTokenHandler godoc +// signupHandler godoc // @Summary Sign up // @Description Create a new user account // @Tags Users // @Accept json // @Produce json -// @Param user body dto.SignUpDto true "User information" -// @Success 201 {object} dto.SignUpDto +// @Param user body signUpDto true "User information" +// @Success 201 {object} dto.UserDto // @Router /api/signup [post] -func (usc *UserSignupController) signupHandler(c *gin.Context) { - var input dto.SignUpDto +func (h *handler) signup(c *gin.Context) { + var input signUpDto if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { _ = c.Error(err) return @@ -196,13 +178,13 @@ func (usc *UserSignupController) signupHandler(c *gin.Context) { ipAddress := c.ClientIP() userAgent := c.GetHeader("User-Agent") - user, accessToken, err := usc.userSignUpService.SignUp(c.Request.Context(), input, ipAddress, userAgent) + user, accessToken, err := h.service.SignUp(c.Request.Context(), input, ipAddress, userAgent) if err != nil { _ = c.Error(err) return } - maxAge := int(usc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds()) + maxAge := int(h.appConfig.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds()) cookie.AddAccessTokenCookie(c, maxAge, accessToken) var userDto dto.UserDto diff --git a/backend/internal/model/signup_token.go b/backend/internal/usersignup/models.go similarity index 73% rename from backend/internal/model/signup_token.go rename to backend/internal/usersignup/models.go index 6ebbe9c1..ddbf5b3e 100644 --- a/backend/internal/model/signup_token.go +++ b/backend/internal/usersignup/models.go @@ -1,19 +1,21 @@ -package model +package usersignup import ( "time" + "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" ) +// SignupToken is a single- or limited-use token that grants the ability to self-register type SignupToken struct { - Base + model.Base Token string `json:"token"` ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"` UsageLimit int `json:"usageLimit" sortable:"true"` UsageCount int `json:"usageCount" sortable:"true"` - UserGroups []UserGroup `gorm:"many2many:signup_tokens_user_groups;"` + UserGroups []model.UserGroup `gorm:"many2many:signup_tokens_user_groups;"` } func (st *SignupToken) IsExpired() bool { diff --git a/backend/internal/usersignup/module.go b/backend/internal/usersignup/module.go new file mode 100644 index 00000000..54f4b0b8 --- /dev/null +++ b/backend/internal/usersignup/module.go @@ -0,0 +1,60 @@ +package usersignup + +import ( + "context" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/model" +) + +type TokenService interface { + GenerateAccessToken(user model.User, authenticationMethod string) (string, error) +} + +type AuditLogger interface { + Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool) +} + +type AppConfigProvider interface { + GetDbConfig() *model.AppConfig +} + +type UserCreator interface { + CreateUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) +} + +type Dependencies struct { + DB *gorm.DB + + Signer TokenService + AuditLog AuditLogger + AppConfig AppConfigProvider + UserCreator UserCreator +} + +type Module struct { + service *Service + handler *handler +} + +func New(deps Dependencies) *Module { + service := newService(deps) + return &Module{ + service: service, + handler: newHandler(service, deps.AppConfig), + } +} + +// RegisterRoutes mounts the signup and signup-token management endpoints +// adminAuth guards the admin token-management routes; signupRateLimit throttles public self-signup +func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth, signupRateLimit gin.HandlerFunc) { + apiGroup.POST("/signup-tokens", adminAuth, m.handler.createSignupToken) + apiGroup.GET("/signup-tokens", adminAuth, m.handler.listSignupTokens) + apiGroup.DELETE("/signup-tokens/:id", adminAuth, m.handler.deleteSignupToken) + apiGroup.POST("/signup", signupRateLimit, m.handler.signup) + apiGroup.GET("/signup/setup", m.handler.checkInitialAdminSetupAvailable) + apiGroup.POST("/signup/setup", m.handler.signUpInitialAdmin) +} diff --git a/backend/internal/service/user_signup_service.go b/backend/internal/usersignup/service.go similarity index 59% rename from backend/internal/service/user_signup_service.go rename to backend/internal/usersignup/service.go index 2790b305..0ec3a76d 100644 --- a/backend/internal/service/user_signup_service.go +++ b/backend/internal/usersignup/service.go @@ -1,4 +1,4 @@ -package service +package usersignup import ( "context" @@ -6,34 +6,39 @@ import ( "strings" "time" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "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" - "gorm.io/gorm" - "gorm.io/gorm/clause" ) -type UserSignUpService struct { - db *gorm.DB - userService *UserService - jwtService *JwtService - auditLogService *AuditLogService - appConfigService *AppConfigService +// 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 + appConfig AppConfigProvider } -func NewUserSignupService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService, userService *UserService) *UserSignUpService { - return &UserSignUpService{ - db: db, - jwtService: jwtService, - auditLogService: auditLogService, - appConfigService: appConfigService, - userService: userService, +func newService(deps Dependencies) *Service { + return &Service{ + db: deps.DB, + userCreator: deps.UserCreator, + signer: deps.Signer, + auditLog: deps.AuditLog, + appConfig: deps.AppConfig, } } -func (s *UserSignUpService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAddress, userAgent string) (model.User, string, error) { +func (s *Service) SignUp(ctx context.Context, signupData signUpDto, ipAddress, userAgent string) (model.User, string, error) { tx := s.db.Begin() defer func() { tx.Rollback() @@ -41,12 +46,12 @@ func (s *UserSignUpService) SignUp(ctx context.Context, signupData dto.SignUpDto tokenProvided := signupData.Token != "" - config := s.appConfigService.GetDbConfig() + config := s.appConfig.GetDbConfig() if config.AllowUserSignups.Value != "open" && !tokenProvided { return model.User{}, "", &common.OpenSignupDisabledError{} } - var signupToken model.SignupToken + var signupToken SignupToken var userGroupIDs []string if tokenProvided { err := tx. @@ -79,21 +84,21 @@ func (s *UserSignUpService) SignUp(ctx context.Context, signupData dto.SignUpDto LastName: signupData.LastName, DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName), UserGroupIds: userGroupIDs, - EmailVerified: s.appConfigService.GetDbConfig().EmailsVerified.IsTrue(), + EmailVerified: s.appConfig.GetDbConfig().EmailsVerified.IsTrue(), } - user, err := s.userService.createUserInternal(ctx, userToCreate, false, tx) + user, err := s.userCreator.CreateUserInternal(ctx, userToCreate, false, tx) if err != nil { return model.User{}, "", err } - accessToken, err := s.jwtService.GenerateAccessToken(user, "") + accessToken, err := s.signer.GenerateAccessToken(user, "") if err != nil { return model.User{}, "", err } if tokenProvided { - s.auditLogService.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{ + s.auditLog.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{ "signupToken": signupToken.Token, }, tx) @@ -102,10 +107,9 @@ func (s *UserSignUpService) SignUp(ctx context.Context, signupData dto.SignUpDto err = tx.WithContext(ctx).Save(&signupToken).Error if err != nil { return model.User{}, "", err - } } else { - s.auditLogService.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{ + s.auditLog.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{ "method": "open_signup", }, tx) } @@ -118,7 +122,7 @@ func (s *UserSignUpService) SignUp(ctx context.Context, signupData dto.SignUpDto return user, accessToken, nil } -func (s *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData dto.SignUpDto) (model.User, string, error) { +func (s *Service) SignUpInitialAdmin(ctx context.Context, signUpData signUpDto) (model.User, string, error) { tx := s.db.Begin() defer func() { tx.Rollback() @@ -141,12 +145,12 @@ func (s *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData d IsAdmin: true, } - user, err := s.userService.createUserInternal(ctx, userToCreate, false, tx) + user, err := s.userCreator.CreateUserInternal(ctx, userToCreate, false, tx) if err != nil { return model.User{}, "", err } - token, err := s.jwtService.GenerateAccessToken(user, AuthenticationMethodOneTimePassword) + token, err := s.signer.GenerateAccessToken(user, authenticationMethodOneTimePassword) if err != nil { return model.User{}, "", err } @@ -159,11 +163,11 @@ func (s *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData d return user, token, nil } -func (s *UserSignUpService) IsInitialAdminSetupCompleted(ctx context.Context) (bool, error) { +func (s *Service) IsInitialAdminSetupCompleted(ctx context.Context) (bool, error) { return s.isInitialAdminSetupCompleted(ctx, s.db) } -func (s *UserSignUpService) isInitialAdminSetupCompleted(ctx context.Context, db *gorm.DB) (bool, error) { +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). @@ -174,22 +178,22 @@ func (s *UserSignUpService) isInitialAdminSetupCompleted(ctx context.Context, db return userCount != 0, nil } -func (s *UserSignUpService) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.SignupToken, utils.PaginationResponse, error) { - var tokens []model.SignupToken - query := s.db.WithContext(ctx).Preload("UserGroups").Model(&model.SignupToken{}) +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 *UserSignUpService) DeleteSignupToken(ctx context.Context, tokenID string) error { - return s.db.WithContext(ctx).Delete(&model.SignupToken{}, "id = ?", tokenID).Error +func (s *Service) DeleteSignupToken(ctx context.Context, tokenID string) error { + return s.db.WithContext(ctx).Delete(&SignupToken{}, "id = ?", tokenID).Error } -func (s *UserSignUpService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (model.SignupToken, error) { - signupToken, err := NewSignupToken(ttl, usageLimit) +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 model.SignupToken{}, err + return SignupToken{}, err } var userGroups []model.UserGroup @@ -198,19 +202,19 @@ func (s *UserSignUpService) CreateSignupToken(ctx context.Context, ttl time.Dura Find(&userGroups). Error if err != nil { - return model.SignupToken{}, err + return SignupToken{}, err } signupToken.UserGroups = userGroups err = s.db.WithContext(ctx).Create(signupToken).Error if err != nil { - return model.SignupToken{}, err + return SignupToken{}, err } return *signupToken, nil } -func NewSignupToken(ttl time.Duration, usageLimit int) (*model.SignupToken, error) { +func newSignupToken(ttl time.Duration, usageLimit int) (*SignupToken, error) { // Generate a random token randomString, err := utils.GenerateRandomAlphanumericString(16) if err != nil { @@ -218,7 +222,7 @@ func NewSignupToken(ttl time.Duration, usageLimit int) (*model.SignupToken, erro } now := time.Now().Round(time.Second) - token := &model.SignupToken{ + token := &SignupToken{ Token: randomString, ExpiresAt: datatype.DateTime(now.Add(ttl)), UsageLimit: usageLimit,