mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-04 17:24:48 +00:00
feat: self-service user signup (#672)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -349,3 +349,13 @@ func (e *OidcAuthorizationPendingError) Error() string {
|
||||
func (e *OidcAuthorizationPendingError) HttpStatusCode() int {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
type OpenSignupDisabledError struct{}
|
||||
|
||||
func (e *OpenSignupDisabledError) Error() string {
|
||||
return "Open user signup is not enabled"
|
||||
}
|
||||
|
||||
func (e *OpenSignupDisabledError) HttpStatusCode() int {
|
||||
return http.StatusForbidden
|
||||
}
|
||||
|
||||
@@ -49,6 +49,12 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
||||
|
||||
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
|
||||
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
|
||||
|
||||
group.POST("/signup-tokens", authMiddleware.Add(), uc.createSignupTokenHandler)
|
||||
group.GET("/signup-tokens", authMiddleware.Add(), uc.listSignupTokensHandler)
|
||||
group.DELETE("/signup-tokens/:id", authMiddleware.Add(), uc.deleteSignupTokenHandler)
|
||||
group.POST("/signup", rateLimitMiddleware.Add(rate.Every(1*time.Minute), 10), uc.signupHandler)
|
||||
|
||||
}
|
||||
|
||||
type UserController struct {
|
||||
@@ -495,6 +501,128 @@ func (uc *UserController) updateUserGroups(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
}
|
||||
|
||||
// createSignupTokenHandler godoc
|
||||
// @Summary Create signup token
|
||||
// @Description Create a new signup token that allows user registration
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param token body dto.SignupTokenCreateDto true "Signup token information"
|
||||
// @Success 201 {object} dto.SignupTokenDto
|
||||
// @Router /api/signup-tokens [post]
|
||||
func (uc *UserController) createSignupTokenHandler(c *gin.Context) {
|
||||
var input dto.SignupTokenCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), input.ExpiresAt, input.UsageLimit)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var tokenDto dto.SignupTokenDto
|
||||
if err := dto.MapStruct(signupToken, &tokenDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, tokenDto)
|
||||
}
|
||||
|
||||
// listSignupTokensHandler godoc
|
||||
// @Summary List signup tokens
|
||||
// @Description Get a paginated list of signup tokens
|
||||
// @Tags Users
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @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]
|
||||
// @Router /api/signup-tokens [get]
|
||||
func (uc *UserController) listSignupTokensHandler(c *gin.Context) {
|
||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
tokens, pagination, err := uc.userService.ListSignupTokens(c.Request.Context(), sortedPaginationRequest)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var tokensDto []dto.SignupTokenDto
|
||||
if err := dto.MapStructList(tokens, &tokensDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.SignupTokenDto]{
|
||||
Data: tokensDto,
|
||||
Pagination: pagination,
|
||||
})
|
||||
}
|
||||
|
||||
// deleteSignupTokenHandler godoc
|
||||
// @Summary Delete signup token
|
||||
// @Description Delete a signup token by ID
|
||||
// @Tags Users
|
||||
// @Param id path string true "Token ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/signup-tokens/{id} [delete]
|
||||
func (uc *UserController) deleteSignupTokenHandler(c *gin.Context) {
|
||||
tokenID := c.Param("id")
|
||||
|
||||
err := uc.userService.DeleteSignupToken(c.Request.Context(), tokenID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// signupWithTokenHandler 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
|
||||
// @Router /api/signup [post]
|
||||
func (uc *UserController) signupHandler(c *gin.Context) {
|
||||
var input dto.SignUpDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ipAddress := c.ClientIP()
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
|
||||
user, accessToken, err := uc.userService.SignUp(c.Request.Context(), input, ipAddress, userAgent)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
|
||||
cookie.AddAccessTokenCookie(c, maxAge, accessToken)
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, userDto)
|
||||
}
|
||||
|
||||
// updateUser is an internal helper method, not exposed as an API endpoint
|
||||
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||
var input dto.UserCreateDto
|
||||
|
||||
@@ -17,6 +17,7 @@ type AppConfigUpdateDto struct {
|
||||
EmailsVerified string `json:"emailsVerified" binding:"required"`
|
||||
DisableAnimations string `json:"disableAnimations" binding:"required"`
|
||||
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
||||
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
|
||||
AccentColor string `json:"accentColor"`
|
||||
SmtpHost string `json:"smtpHost"`
|
||||
SmtpPort string `json:"smtpPort"`
|
||||
|
||||
21
backend/internal/dto/signup_token_dto.go
Normal file
21
backend/internal/dto/signup_token_dto.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type SignupTokenCreateDto struct {
|
||||
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
||||
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
|
||||
}
|
||||
|
||||
type SignupTokenDto struct {
|
||||
ID string `json:"id"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||
UsageLimit int `json:"usageLimit"`
|
||||
UsageCount int `json:"usageCount"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
}
|
||||
@@ -44,3 +44,11 @@ type OneTimeAccessEmailAsAdminDto struct {
|
||||
type UserUpdateUserGroupDto struct {
|
||||
UserGroupIds []string `json:"userGroupIds" binding:"required"`
|
||||
}
|
||||
|
||||
type SignUpDto struct {
|
||||
Username string `json:"username" binding:"required,username,min=2,max=50"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
FirstName string `json:"firstName" binding:"required,min=1,max=50"`
|
||||
LastName string `json:"lastName" binding:"max=50"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
|
||||
return errors.Join(
|
||||
s.registerJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
|
||||
s.registerJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
|
||||
s.registerJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
|
||||
s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
|
||||
s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
|
||||
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
|
||||
@@ -60,6 +61,21 @@ func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Cleaned expired tokens", slog.Int64("count", st.RowsAffected))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
||||
func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error {
|
||||
st := j.db.
|
||||
|
||||
@@ -38,6 +38,7 @@ type AppConfig struct {
|
||||
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
|
||||
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
|
||||
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
|
||||
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
|
||||
// Internal
|
||||
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
|
||||
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
|
||||
|
||||
@@ -28,6 +28,7 @@ type AuditLogEvent string //nolint:recvcheck
|
||||
const (
|
||||
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
|
||||
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
|
||||
AuditLogEventAccountCreated AuditLogEvent = "ACCOUNT_CREATED"
|
||||
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
|
||||
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
|
||||
AuditLogEventDeviceCodeAuthorization AuditLogEvent = "DEVICE_CODE_AUTHORIZATION"
|
||||
|
||||
28
backend/internal/model/signup_token.go
Normal file
28
backend/internal/model/signup_token.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type SignupToken struct {
|
||||
Base
|
||||
|
||||
Token string `json:"token"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"`
|
||||
UsageLimit int `json:"usageLimit" sortable:"true"`
|
||||
UsageCount int `json:"usageCount" sortable:"true"`
|
||||
}
|
||||
|
||||
func (st *SignupToken) IsExpired() bool {
|
||||
return time.Time(st.ExpiresAt).Before(time.Now())
|
||||
}
|
||||
|
||||
func (st *SignupToken) IsUsageLimitReached() bool {
|
||||
return st.UsageCount >= st.UsageLimit
|
||||
}
|
||||
|
||||
func (st *SignupToken) IsValid() bool {
|
||||
return !st.IsExpired() && !st.IsUsageLimitReached()
|
||||
}
|
||||
@@ -68,6 +68,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
||||
EmailsVerified: model.AppConfigVariable{Value: "false"},
|
||||
DisableAnimations: model.AppConfigVariable{Value: "false"},
|
||||
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
|
||||
AllowUserSignups: model.AppConfigVariable{Value: "disabled"},
|
||||
AccentColor: model.AppConfigVariable{Value: "default"},
|
||||
// Internal
|
||||
BackgroundImageType: model.AppConfigVariable{Value: "jpg"},
|
||||
|
||||
@@ -310,6 +310,50 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
signupTokens := []model.SignupToken{
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
},
|
||||
Token: "VALID1234567890A",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
|
||||
UsageLimit: 1,
|
||||
UsageCount: 0,
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "b2c3d4e5-f6g7-8901-bcde-f12345678901",
|
||||
},
|
||||
Token: "PARTIAL567890ABC",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(7 * 24 * time.Hour)),
|
||||
UsageLimit: 5,
|
||||
UsageCount: 2,
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "c3d4e5f6-g7h8-9012-cdef-123456789012",
|
||||
},
|
||||
Token: "EXPIRED34567890B",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(-24 * time.Hour)), // Expired
|
||||
UsageLimit: 3,
|
||||
UsageCount: 1,
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "d4e5f6g7-h8i9-0123-def0-234567890123",
|
||||
},
|
||||
Token: "FULLYUSED567890C",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
|
||||
UsageLimit: 1,
|
||||
UsageCount: 1, // Usage limit reached
|
||||
},
|
||||
}
|
||||
for _, token := range signupTokens {
|
||||
if err := tx.Create(&token).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
@@ -636,6 +636,110 @@ func (s *UserService) disableUserInternal(ctx context.Context, userID string, tx
|
||||
Error
|
||||
}
|
||||
|
||||
func (s *UserService) CreateSignupToken(ctx context.Context, expiresAt time.Time, usageLimit int) (model.SignupToken, error) {
|
||||
return s.createSignupTokenInternal(ctx, expiresAt, usageLimit, s.db)
|
||||
}
|
||||
|
||||
func (s *UserService) createSignupTokenInternal(ctx context.Context, expiresAt time.Time, usageLimit int, tx *gorm.DB) (model.SignupToken, error) {
|
||||
signupToken, err := NewSignupToken(expiresAt, usageLimit)
|
||||
if err != nil {
|
||||
return model.SignupToken{}, err
|
||||
}
|
||||
|
||||
if err := tx.WithContext(ctx).Create(signupToken).Error; err != nil {
|
||||
return model.SignupToken{}, err
|
||||
}
|
||||
|
||||
return *signupToken, nil
|
||||
}
|
||||
|
||||
func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAddress, userAgent string) (model.User, string, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
tokenProvided := signupData.Token != ""
|
||||
|
||||
config := s.appConfigService.GetDbConfig()
|
||||
if config.AllowUserSignups.Value != "open" && !tokenProvided {
|
||||
return model.User{}, "", &common.OpenSignupDisabledError{}
|
||||
}
|
||||
|
||||
var signupToken model.SignupToken
|
||||
if tokenProvided {
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Where("token = ?", signupData.Token).
|
||||
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{}
|
||||
}
|
||||
}
|
||||
|
||||
userToCreate := dto.UserCreateDto{
|
||||
Username: signupData.Username,
|
||||
Email: signupData.Email,
|
||||
FirstName: signupData.FirstName,
|
||||
LastName: signupData.LastName,
|
||||
}
|
||||
|
||||
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
accessToken, err := s.jwtService.GenerateAccessToken(user)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
if tokenProvided {
|
||||
s.auditLogService.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.auditLogService.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 *UserService) ListSignupTokens(ctx context.Context, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.SignupToken, utils.PaginationResponse, error) {
|
||||
var tokens []model.SignupToken
|
||||
query := s.db.WithContext(ctx).Model(&model.SignupToken{})
|
||||
|
||||
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &tokens)
|
||||
return tokens, pagination, err
|
||||
}
|
||||
|
||||
func (s *UserService) DeleteSignupToken(ctx context.Context, tokenID string) error {
|
||||
return s.db.WithContext(ctx).Delete(&model.SignupToken{}, "id = ?", tokenID).Error
|
||||
}
|
||||
|
||||
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
|
||||
@@ -656,3 +760,20 @@ func NewOneTimeAccessToken(userID string, expiresAt time.Time) (*model.OneTimeAc
|
||||
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func NewSignupToken(expiresAt time.Time, usageLimit int) (*model.SignupToken, error) {
|
||||
// Generate a random token
|
||||
randomString, err := utils.GenerateRandomAlphanumericString(16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token := &model.SignupToken{
|
||||
Token: randomString,
|
||||
ExpiresAt: datatype.DateTime(expiresAt),
|
||||
UsageLimit: usageLimit,
|
||||
UsageCount: 0,
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user