diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 0fea2ace..346bc090 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -76,7 +76,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) { controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService) controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService) controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService) - controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.appConfigService) + controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.oneTimeAccessService, svc.appConfigService) controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService) controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService) controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware) @@ -84,6 +84,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) { controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService) controller.NewVersionController(apiGroup, svc.versionService) controller.NewScimController(apiGroup, authMiddleware, svc.scimService) + controller.NewUserSignupController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userSignUpService, svc.appConfigService) // Add test controller in non-production environments if !common.EnvConfig.AppEnv.IsProduction() { diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index 86a33a93..4cb6c86d 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -13,23 +13,25 @@ import ( ) type services struct { - appConfigService *service.AppConfigService - appImagesService *service.AppImagesService - emailService *service.EmailService - geoLiteService *service.GeoLiteService - auditLogService *service.AuditLogService - jwtService *service.JwtService - webauthnService *service.WebAuthnService - scimService *service.ScimService - userService *service.UserService - customClaimService *service.CustomClaimService - oidcService *service.OidcService - userGroupService *service.UserGroupService - ldapService *service.LdapService - apiKeyService *service.ApiKeyService - versionService *service.VersionService - fileStorage storage.FileStorage - appLockService *service.AppLockService + appConfigService *service.AppConfigService + appImagesService *service.AppImagesService + emailService *service.EmailService + geoLiteService *service.GeoLiteService + auditLogService *service.AuditLogService + jwtService *service.JwtService + webauthnService *service.WebAuthnService + scimService *service.ScimService + userService *service.UserService + customClaimService *service.CustomClaimService + oidcService *service.OidcService + userGroupService *service.UserGroupService + ldapService *service.LdapService + apiKeyService *service.ApiKeyService + versionService *service.VersionService + fileStorage storage.FileStorage + appLockService *service.AppLockService + userSignUpService *service.UserSignUpService + oneTimeAccessService *service.OneTimeAccessService } // Initializes all services @@ -74,6 +76,8 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage) svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage) svc.apiKeyService = service.NewApiKeyService(db, svc.emailService) + svc.userSignUpService = service.NewUserSignupService(db, svc.jwtService, svc.auditLogService, svc.appConfigService, 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/common/errors.go b/backend/internal/common/errors.go index c2bd43d6..13fb9a13 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -412,3 +412,13 @@ func (e *ImageNotFoundError) Error() string { func (e *ImageNotFoundError) HttpStatusCode() int { return http.StatusNotFound } + +type InvalidEmailVerificationTokenError struct{} + +func (e *InvalidEmailVerificationTokenError) Error() string { + return "Invalid email verification token" +} + +func (e *InvalidEmailVerificationTokenError) HttpStatusCode() int { + return http.StatusBadRequest +} diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index d348e893..78a5563c 100644 --- a/backend/internal/controller/user_controller.go +++ b/backend/internal/controller/user_controller.go @@ -14,19 +14,17 @@ import ( "golang.org/x/time/rate" ) -const ( - defaultOneTimeAccessTokenDuration = 15 * time.Minute - defaultSignupTokenDuration = time.Hour -) +const defaultOneTimeAccessTokenDuration = 15 * time.Minute // NewUserController creates a new controller for user management endpoints // @Summary User management controller // @Description Initializes all user-related API endpoints // @Tags Users -func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) { +func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, appConfigService *service.AppConfigService) { uc := UserController{ - userService: userService, - appConfigService: appConfigService, + userService: userService, + oneTimeAccessService: oneTimeAccessService, + appConfigService: appConfigService, } group.GET("/users", authMiddleware.Add(), uc.listUsersHandler) @@ -54,17 +52,14 @@ 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) - group.POST("/signup/setup", uc.signUpInitialAdmin) - + group.POST("/users/me/send-email-verification", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), authMiddleware.WithAdminNotRequired().Add(), uc.sendEmailVerificationHandler) + group.POST("/users/me/verify-email", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), authMiddleware.WithAdminNotRequired().Add(), uc.verifyEmailHandler) } type UserController struct { - userService *service.UserService - appConfigService *service.AppConfigService + userService *service.UserService + oneTimeAccessService *service.OneTimeAccessService + appConfigService *service.AppConfigService } // getUserGroupsHandler godoc @@ -342,7 +337,7 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bo ttl = defaultOneTimeAccessTokenDuration } } - token, err := uc.userService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, ttl) + token, err := uc.oneTimeAccessService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, ttl) if err != nil { _ = c.Error(err) return @@ -391,7 +386,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler( return } - deviceToken, err := uc.userService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath) + deviceToken, err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath) if err != nil { _ = c.Error(err) return @@ -424,7 +419,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context if ttl <= 0 { ttl = defaultOneTimeAccessTokenDuration } - err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl) + err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl) if err != nil { _ = c.Error(err) return @@ -442,41 +437,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context // @Router /api/one-time-access-token/{token} [post] func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) { deviceToken, _ := c.Cookie(cookie.DeviceTokenCookieName) - user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Request.Context(), c.Param("token"), deviceToken, c.ClientIP(), c.Request.UserAgent()) - if err != nil { - _ = c.Error(err) - return - } - - var userDto dto.UserDto - if err := dto.MapStruct(user, &userDto); err != nil { - _ = c.Error(err) - return - } - - maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds()) - cookie.AddAccessTokenCookie(c, maxAge, token) - - c.JSON(http.StatusOK, userDto) -} - -// signUpInitialAdmin godoc -// @Summary Sign up initial admin user -// @Description Sign up and generate setup access token for initial admin user -// @Tags Users -// @Accept json -// @Produce json -// @Param body body dto.SignUpDto true "User information" -// @Success 200 {object} dto.UserDto -// @Router /api/signup/setup [post] -func (uc *UserController) signUpInitialAdmin(c *gin.Context) { - var input dto.SignUpDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return - } - - user, token, err := uc.userService.SignUpInitialAdmin(c.Request.Context(), input) + user, token, err := uc.oneTimeAccessService.ExchangeOneTimeAccessToken(c.Request.Context(), c.Param("token"), deviceToken, c.ClientIP(), c.Request.UserAgent()) if err != nil { _ = c.Error(err) return @@ -524,130 +485,6 @@ 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 - } - - ttl := input.TTL.Duration - if ttl <= 0 { - ttl = defaultSignupTokenDuration - } - - signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs) - if err != nil { - _ = c.Error(err) - return - } - - var tokenDto dto.SignupTokenDto - err = dto.MapStruct(signupToken, &tokenDto) - if 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) { - listRequestOptions := utils.ParseListRequestOptions(c) - - tokens, pagination, err := uc.userService.ListSignupTokens(c.Request.Context(), listRequestOptions) - 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 := dto.ShouldBindWithNormalizedJSON(c, &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 @@ -714,3 +551,44 @@ func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context) c.Status(http.StatusNoContent) } + +// sendEmailVerificationHandler godoc +// @Summary Send email verification +// @Description Send an email verification to the currently authenticated user +// @Tags Users +// @Produce json +// @Success 204 "No Content" +// @Router /api/users/me/send-email-verification [post] +func (uc *UserController) sendEmailVerificationHandler(c *gin.Context) { + userID := c.GetString("userID") + + if err := uc.userService.SendEmailVerification(c.Request.Context(), userID); err != nil { + _ = c.Error(err) + return + } + + c.Status(http.StatusNoContent) +} + +// verifyEmailHandler godoc +// @Summary Verify email +// @Description Verify the currently authenticated user's email using a verification token +// @Tags Users +// @Param body body dto.EmailVerificationDto true "Email verification token" +// @Success 204 "No Content" +// @Router /api/users/me/verify-email [post] +func (uc *UserController) verifyEmailHandler(c *gin.Context) { + var input dto.EmailVerificationDto + if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { + _ = c.Error(err) + return + } + + userID := c.GetString("userID") + if err := uc.userService.VerifyEmail(c.Request.Context(), userID, input.Token); err != nil { + _ = c.Error(err) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/controller/user_signup_controller.go b/backend/internal/controller/user_signup_controller.go new file mode 100644 index 00000000..4b53c44f --- /dev/null +++ b/backend/internal/controller/user_signup_controller.go @@ -0,0 +1,198 @@ +package controller + +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/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" +) + +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.POST("/signup/setup", usc.signUpInitialAdmin) + +} + +type UserSignupController struct { + userSignUpService *service.UserSignUpService + appConfigService *service.AppConfigService +} + +// signUpInitialAdmin godoc +// @Summary Sign up initial admin user +// @Description Sign up and generate setup access token for initial admin user +// @Tags Users +// @Accept json +// @Produce json +// @Param body body dto.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 + if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { + _ = c.Error(err) + return + } + + user, token, err := usc.userSignUpService.SignUpInitialAdmin(c.Request.Context(), input) + if err != nil { + _ = c.Error(err) + return + } + + var userDto dto.UserDto + if err := dto.MapStruct(user, &userDto); err != nil { + _ = c.Error(err) + return + } + + maxAge := int(usc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds()) + cookie.AddAccessTokenCookie(c, maxAge, token) + + 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 (usc *UserSignupController) createSignupTokenHandler(c *gin.Context) { + var input dto.SignupTokenCreateDto + if err := c.ShouldBindJSON(&input); err != nil { + _ = c.Error(err) + return + } + + ttl := input.TTL.Duration + if ttl <= 0 { + ttl = defaultSignupTokenDuration + } + + signupToken, err := usc.userSignUpService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs) + if err != nil { + _ = c.Error(err) + return + } + + var tokenDto dto.SignupTokenDto + err = dto.MapStruct(signupToken, &tokenDto) + if 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 (usc *UserSignupController) listSignupTokensHandler(c *gin.Context) { + listRequestOptions := utils.ParseListRequestOptions(c) + + tokens, pagination, err := usc.userSignUpService.ListSignupTokens(c.Request.Context(), listRequestOptions) + 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 (usc *UserSignupController) deleteSignupTokenHandler(c *gin.Context) { + tokenID := c.Param("id") + + err := usc.userSignUpService.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 (usc *UserSignupController) signupHandler(c *gin.Context) { + var input dto.SignUpDto + if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { + _ = c.Error(err) + return + } + + ipAddress := c.ClientIP() + userAgent := c.GetHeader("User-Agent") + + user, accessToken, err := usc.userSignUpService.SignUp(c.Request.Context(), input, ipAddress, userAgent) + if err != nil { + _ = c.Error(err) + return + } + + maxAge := int(usc.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) +} diff --git a/backend/internal/dto/app_config_dto.go b/backend/internal/dto/app_config_dto.go index 646550be..7fbabb9a 100644 --- a/backend/internal/dto/app_config_dto.go +++ b/backend/internal/dto/app_config_dto.go @@ -54,4 +54,5 @@ type AppConfigUpdateDto struct { EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"` EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"` EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required"` + EmailVerificationEnabled string `json:"emailVerificationEnabled" binding:"required"` } diff --git a/backend/internal/dto/one_time_access_dto.go b/backend/internal/dto/one_time_access_dto.go new file mode 100644 index 00000000..336def70 --- /dev/null +++ b/backend/internal/dto/one_time_access_dto.go @@ -0,0 +1,17 @@ +package dto + +import "github.com/pocket-id/pocket-id/backend/internal/utils" + +type OneTimeAccessTokenCreateDto struct { + UserID string `json:"userId"` + TTL utils.JSONDuration `json:"ttl" binding:"ttl"` +} + +type OneTimeAccessEmailAsUnauthenticatedUserDto struct { + Email string `json:"email" binding:"required,email" unorm:"nfc"` + RedirectPath string `json:"redirectPath"` +} + +type OneTimeAccessEmailAsAdminDto struct { + TTL utils.JSONDuration `json:"ttl" binding:"ttl"` +} diff --git a/backend/internal/dto/signup_dto.go b/backend/internal/dto/signup_dto.go new file mode 100644 index 00000000..b135a49f --- /dev/null +++ b/backend/internal/dto/signup_dto.go @@ -0,0 +1,9 @@ +package dto + +type SignUpDto struct { + Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"` + Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"` + FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"` + LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` + Token string `json:"token"` +} diff --git a/backend/internal/dto/user_dto.go b/backend/internal/dto/user_dto.go index 671142f5..90e457ff 100644 --- a/backend/internal/dto/user_dto.go +++ b/backend/internal/dto/user_dto.go @@ -4,35 +4,36 @@ import ( "errors" "github.com/gin-gonic/gin/binding" - "github.com/pocket-id/pocket-id/backend/internal/utils" ) type UserDto struct { - ID string `json:"id"` - Username string `json:"username"` - Email *string `json:"email" ` - FirstName string `json:"firstName"` - LastName *string `json:"lastName"` - DisplayName string `json:"displayName"` - IsAdmin bool `json:"isAdmin"` - Locale *string `json:"locale"` - CustomClaims []CustomClaimDto `json:"customClaims"` - UserGroups []UserGroupMinimalDto `json:"userGroups"` - LdapID *string `json:"ldapId"` - Disabled bool `json:"disabled"` + ID string `json:"id"` + Username string `json:"username"` + Email *string `json:"email"` + EmailVerified bool `json:"emailVerified"` + FirstName string `json:"firstName"` + LastName *string `json:"lastName"` + DisplayName string `json:"displayName"` + IsAdmin bool `json:"isAdmin"` + Locale *string `json:"locale"` + CustomClaims []CustomClaimDto `json:"customClaims"` + UserGroups []UserGroupMinimalDto `json:"userGroups"` + LdapID *string `json:"ldapId"` + Disabled bool `json:"disabled"` } type UserCreateDto struct { - Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"` - Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"` - FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"` - LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` - DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"` - IsAdmin bool `json:"isAdmin"` - Locale *string `json:"locale"` - Disabled bool `json:"disabled"` - UserGroupIds []string `json:"userGroupIds"` - LdapID string `json:"-"` + Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"` + Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"` + EmailVerified bool `json:"emailVerified"` + FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"` + LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` + DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"` + IsAdmin bool `json:"isAdmin"` + Locale *string `json:"locale"` + Disabled bool `json:"disabled"` + UserGroupIds []string `json:"userGroupIds"` + LdapID string `json:"-"` } func (u UserCreateDto) Validate() error { @@ -46,28 +47,10 @@ func (u UserCreateDto) Validate() error { return e.Struct(u) } -type OneTimeAccessTokenCreateDto struct { - UserID string `json:"userId"` - TTL utils.JSONDuration `json:"ttl" binding:"ttl"` -} - -type OneTimeAccessEmailAsUnauthenticatedUserDto struct { - Email string `json:"email" binding:"required,email" unorm:"nfc"` - RedirectPath string `json:"redirectPath"` -} - -type OneTimeAccessEmailAsAdminDto struct { - TTL utils.JSONDuration `json:"ttl" binding:"ttl"` +type EmailVerificationDto struct { + Token string `json:"token" binding:"required"` } type UserUpdateUserGroupDto struct { UserGroupIds []string `json:"userGroupIds" binding:"required"` } - -type SignUpDto struct { - Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"` - Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"` - FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"` - LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` - Token string `json:"token"` -} diff --git a/backend/internal/job/db_cleanup_job.go b/backend/internal/job/db_cleanup_job.go index 338f989c..fca96516 100644 --- a/backend/internal/job/db_cleanup_job.go +++ b/backend/internal/job/db_cleanup_job.go @@ -24,6 +24,7 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro 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, "ClearEmailVerificationTokens", def, jobs.clearEmailVerificationTokens, true), s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true), s.RegisterJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true), s.RegisterJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true), @@ -135,3 +136,16 @@ func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error { return nil } + +// ClearEmailVerificationTokens deletes email verification tokens that have expired +func (j *DbCleanupJobs) clearEmailVerificationTokens(ctx context.Context) error { + st := j.db. + WithContext(ctx). + Delete(&model.EmailVerificationToken{}, "expires_at < ?", datatype.DateTime(time.Now())) + if st.Error != nil { + return fmt.Errorf("failed to clean expired email verification tokens: %w", st.Error) + } + + slog.InfoContext(ctx, "Cleaned expired email verification tokens", slog.Int64("count", st.RowsAffected)) + return nil +} diff --git a/backend/internal/model/app_config.go b/backend/internal/model/app_config.go index 424a2c42..3e008478 100644 --- a/backend/internal/model/app_config.go +++ b/backend/internal/model/app_config.go @@ -59,6 +59,7 @@ type AppConfig struct { EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigVariable `key:"emailOneTimeAccessAsUnauthenticatedEnabled,public"` // Public EmailOneTimeAccessAsAdminEnabled AppConfigVariable `key:"emailOneTimeAccessAsAdminEnabled,public"` // Public EmailApiKeyExpirationEnabled AppConfigVariable `key:"emailApiKeyExpirationEnabled"` + EmailVerificationEnabled AppConfigVariable `key:"emailVerificationEnabled,public"` // Public // LDAP LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public LdapUrl AppConfigVariable `key:"ldapUrl"` diff --git a/backend/internal/model/email_verification_token.go b/backend/internal/model/email_verification_token.go new file mode 100644 index 00000000..d93d6c6d --- /dev/null +++ b/backend/internal/model/email_verification_token.go @@ -0,0 +1,13 @@ +package model + +import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + +type EmailVerificationToken struct { + Base + + Token string + ExpiresAt datatype.DateTime + + UserID string + User User +} diff --git a/backend/internal/model/one_time_access_token.go b/backend/internal/model/one_time_access_token.go new file mode 100644 index 00000000..3a3c095d --- /dev/null +++ b/backend/internal/model/one_time_access_token.go @@ -0,0 +1,13 @@ +package model + +import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + +type OneTimeAccessToken struct { + Base + Token string + DeviceToken *string + ExpiresAt datatype.DateTime + + UserID string + User User +} diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index a2fb44d3..4426512a 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -14,16 +14,17 @@ import ( type User struct { Base - Username string `sortable:"true"` - Email *string `sortable:"true"` - FirstName string `sortable:"true"` - LastName string `sortable:"true"` - DisplayName string `sortable:"true"` - IsAdmin bool `sortable:"true" filterable:"true"` - Locale *string - LdapID *string - Disabled bool `sortable:"true" filterable:"true"` - UpdatedAt *datatype.DateTime + Username string `sortable:"true"` + Email *string `sortable:"true"` + EmailVerified bool `sortable:"true" filterable:"true"` + FirstName string `sortable:"true"` + LastName string `sortable:"true"` + DisplayName string `sortable:"true"` + IsAdmin bool `sortable:"true" filterable:"true"` + Locale *string + LdapID *string + Disabled bool `sortable:"true" filterable:"true"` + UpdatedAt *datatype.DateTime CustomClaims []CustomClaim UserGroups []UserGroup `gorm:"many2many:user_groups_users;"` @@ -93,13 +94,3 @@ func (u User) LastModified() time.Time { } return u.CreatedAt.ToTime() } - -type OneTimeAccessToken struct { - Base - Token string - DeviceToken *string - ExpiresAt datatype.DateTime - - UserID string - User User -} diff --git a/backend/internal/service/app_config_service.go b/backend/internal/service/app_config_service.go index 1f8ea731..e05427f6 100644 --- a/backend/internal/service/app_config_service.go +++ b/backend/internal/service/app_config_service.go @@ -84,6 +84,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig { EmailOneTimeAccessAsUnauthenticatedEnabled: model.AppConfigVariable{Value: "false"}, EmailOneTimeAccessAsAdminEnabled: model.AppConfigVariable{Value: "false"}, EmailApiKeyExpirationEnabled: model.AppConfigVariable{Value: "false"}, + EmailVerificationEnabled: model.AppConfigVariable{Value: "false"}, // LDAP LdapEnabled: model.AppConfigVariable{Value: "false"}, LdapUrl: model.AppConfigVariable{}, diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index 7cb98530..4e65064d 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -80,23 +80,25 @@ func (s *TestService) SeedDatabase(baseURL string) error { Base: model.Base{ ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e", }, - Username: "tim", - Email: utils.Ptr("tim.cook@test.com"), - FirstName: "Tim", - LastName: "Cook", - DisplayName: "Tim Cook", - IsAdmin: true, + Username: "tim", + Email: utils.Ptr("tim.cook@test.com"), + EmailVerified: true, + FirstName: "Tim", + LastName: "Cook", + DisplayName: "Tim Cook", + IsAdmin: true, }, { Base: model.Base{ ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036", }, - Username: "craig", - Email: utils.Ptr("craig.federighi@test.com"), - FirstName: "Craig", - LastName: "Federighi", - DisplayName: "Craig Federighi", - IsAdmin: false, + Username: "craig", + Email: utils.Ptr("craig.federighi@test.com"), + EmailVerified: false, + FirstName: "Craig", + LastName: "Federighi", + DisplayName: "Craig Federighi", + IsAdmin: false, }, { Base: model.Base{ @@ -427,6 +429,31 @@ func (s *TestService) SeedDatabase(baseURL string) error { } } + emailVerificationTokens := []model.EmailVerificationToken{ + { + Base: model.Base{ + ID: "ef9ca469-b178-4857-bd39-26639dca45de", + }, + Token: "2FZFSoupBdHyqIL65bWTsgCgHIhxlXup", + ExpiresAt: datatype.DateTime(time.Now().Add(2 * time.Hour)), + UserID: users[1].ID, + }, + { + Base: model.Base{ + ID: "a3dcb4d2-7f3c-4e8a-9f4d-5b6c7d8e9f00", + }, + Token: "EXPIRED1234567890ABCDE", + ExpiresAt: datatype.DateTime(time.Now().Add(-1 * time.Hour)), + UserID: users[1].ID, + }, + } + + for _, token := range emailVerificationTokens { + if err := tx.Create(&token).Error; err != nil { + return err + } + } + keyValues := []model.KV{ { Key: jwkutils.PrivateKeyDBKey, diff --git a/backend/internal/service/email_service_templates.go b/backend/internal/service/email_service_templates.go index a7ca10a0..694e6613 100644 --- a/backend/internal/service/email_service_templates.go +++ b/backend/internal/service/email_service_templates.go @@ -49,6 +49,13 @@ var ApiKeyExpiringSoonTemplate = email.Template[ApiKeyExpiringSoonTemplateData]{ }, } +var EmailVerificationTemplate = email.Template[EmailVerificationTemplateData]{ + Path: "email-verification", + Title: func(data *email.TemplateData[EmailVerificationTemplateData]) string { + return "Verify your " + data.AppName + " email address" + }, +} + type NewLoginTemplateData struct { IPAddress string Country string @@ -70,5 +77,10 @@ type ApiKeyExpiringSoonTemplateData struct { ExpiresAt time.Time } +type EmailVerificationTemplateData struct { + UserFullName string + VerificationLink string +} + // this is list of all template paths used for preloading templates -var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path, ApiKeyExpiringSoonTemplate.Path} +var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path, ApiKeyExpiringSoonTemplate.Path, EmailVerificationTemplate.Path} diff --git a/backend/internal/service/ldap_service.go b/backend/internal/service/ldap_service.go index 93778e96..01a8ff3d 100644 --- a/backend/internal/service/ldap_service.go +++ b/backend/internal/service/ldap_service.go @@ -378,13 +378,14 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C } newUser := dto.UserCreateDto{ - Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value), - Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)), - FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value), - LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value), - DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value), - IsAdmin: isAdmin, - LdapID: ldapId, + Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value), + Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)), + EmailVerified: true, + FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value), + LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value), + DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value), + IsAdmin: isAdmin, + LdapID: ldapId, } if newUser.DisplayName == "" { diff --git a/backend/internal/service/one_time_access_service.go b/backend/internal/service/one_time_access_service.go new file mode 100644 index 00000000..a9d80d80 --- /dev/null +++ b/backend/internal/service/one_time_access_service.go @@ -0,0 +1,229 @@ +package service + +import ( + "context" + "errors" + "log/slog" + "net/url" + "strings" + "time" + + "github.com/pocket-id/pocket-id/backend/internal/common" + "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" + "github.com/pocket-id/pocket-id/backend/internal/utils/email" + "go.opentelemetry.io/otel/trace" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type OneTimeAccessService struct { + db *gorm.DB + userService *UserService + appConfigService *AppConfigService + jwtService *JwtService + auditLogService *AuditLogService + emailService *EmailService +} + +func NewOneTimeAccessService(db *gorm.DB, userService *UserService, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService) *OneTimeAccessService { + return &OneTimeAccessService{ + db: db, + userService: userService, + appConfigService: appConfigService, + jwtService: jwtService, + auditLogService: auditLogService, + emailService: emailService, + } +} + +func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, ttl time.Duration) error { + isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue() + if isDisabled { + return &common.OneTimeAccessDisabledError{} + } + + _, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false) + return err +} + +func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) (string, error) { + isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue() + if isDisabled { + return "", &common.OneTimeAccessDisabledError{} + } + + var userId string + err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + // Do not return error if user not found to prevent email enumeration + return "", nil + } else if err != nil { + return "", err + } + + deviceToken, err := s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute, true) + if err != nil { + return "", err + } else if deviceToken == nil { + return "", errors.New("device token expected but not returned") + } + + return *deviceToken, nil +} + +func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool) (*string, error) { + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + user, err := s.userService.GetUser(ctx, userID) + if err != nil { + return nil, err + } + + if user.Email == nil { + return nil, &common.UserEmailNotSetError{} + } + + oneTimeAccessToken, deviceToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, withDeviceToken, tx) + if err != nil { + return nil, err + } + err = tx.Commit().Error + if err != nil { + return nil, err + } + + // We use a background context here as this is running in a goroutine + //nolint:contextcheck + go func() { + span := trace.SpanFromContext(ctx) + innerCtx := trace.ContextWithSpan(context.Background(), span) + + 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{ + Name: user.FullName(), + Email: *user.Email, + }, OneTimeAccessTemplate, &OneTimeAccessTemplateData{ + Code: oneTimeAccessToken, + LoginLink: link, + LoginLinkWithCode: linkWithCode, + ExpirationString: utils.DurationToString(ttl), + }) + if errInternal != nil { + slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", errInternal), slog.String("address", *user.Email)) + return + } + }() + + return deviceToken, nil +} + +func (s *OneTimeAccessService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) { + token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, s.db) + return token, err +} + +func (s *OneTimeAccessService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, withDeviceToken bool, tx *gorm.DB) (token string, deviceToken *string, err error) { + oneTimeAccessToken, err := NewOneTimeAccessToken(userID, ttl, withDeviceToken) + if err != nil { + return "", nil, err + } + + err = tx.WithContext(ctx).Create(oneTimeAccessToken).Error + if err != nil { + return "", nil, err + } + + return oneTimeAccessToken.Token, oneTimeAccessToken.DeviceToken, nil +} + +func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) { + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + var oneTimeAccessToken model.OneTimeAccessToken + err := tx. + WithContext(ctx). + Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())). + Preload("User"). + Clauses(clause.Locking{Strength: "UPDATE"}). + First(&oneTimeAccessToken). + Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.User{}, "", &common.TokenInvalidOrExpiredError{} + } + return model.User{}, "", err + } + if oneTimeAccessToken.DeviceToken != nil && deviceToken != *oneTimeAccessToken.DeviceToken { + return model.User{}, "", &common.DeviceCodeInvalid{} + } + + accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User) + if err != nil { + return model.User{}, "", err + } + + err = tx. + WithContext(ctx). + Delete(&oneTimeAccessToken). + Error + if err != nil { + return model.User{}, "", err + } + + 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 + } + + return oneTimeAccessToken.User, accessToken, nil +} + +func NewOneTimeAccessToken(userID string, ttl time.Duration, withDeviceToken bool) (*model.OneTimeAccessToken, error) { + // If expires at is less than 15 minutes, use a 6-character token instead of 16 + tokenLength := 16 + if ttl <= 15*time.Minute { + tokenLength = 6 + } + + token, err := utils.GenerateRandomUnambiguousString(tokenLength) + if err != nil { + return nil, err + } + + var deviceToken *string + if withDeviceToken { + dt, err := utils.GenerateRandomAlphanumericString(16) + if err != nil { + return nil, err + } + deviceToken = &dt + } + + now := time.Now().Round(time.Second) + o := &model.OneTimeAccessToken{ + UserID: userID, + ExpiresAt: datatype.DateTime(now.Add(ttl)), + Token: token, + DeviceToken: deviceToken, + } + + return o, nil +} diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index b263aeed..b02dcab3 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -9,13 +9,11 @@ import ( "io" "io/fs" "log/slog" - "net/url" "path" - "strings" "time" "github.com/google/uuid" - "go.opentelemetry.io/otel/trace" + "github.com/pocket-id/pocket-id/backend/internal/utils/email" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -25,7 +23,6 @@ import ( 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" ) @@ -269,15 +266,16 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea } user := model.User{ - FirstName: input.FirstName, - LastName: input.LastName, - DisplayName: input.DisplayName, - Email: input.Email, - Username: input.Username, - IsAdmin: input.IsAdmin, - Locale: input.Locale, - Disabled: input.Disabled, - UserGroups: userGroups, + 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 @@ -419,13 +417,20 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd user.FirstName = updatedUser.FirstName user.LastName = updatedUser.LastName user.DisplayName = updatedUser.DisplayName - user.Email = updatedUser.Email 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 = s.appConfigService.GetDbConfig().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 } } @@ -455,164 +460,6 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd return user, nil } -func (s *UserService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, ttl time.Duration) error { - isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue() - if isDisabled { - return &common.OneTimeAccessDisabledError{} - } - - _, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false) - return err -} - -func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) (string, error) { - isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue() - if isDisabled { - return "", &common.OneTimeAccessDisabledError{} - } - - var userId string - err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - // Do not return error if user not found to prevent email enumeration - return "", nil - } else if err != nil { - return "", err - } - - deviceToken, err := s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute, true) - if err != nil { - return "", err - } else if deviceToken == nil { - return "", errors.New("device token expected but not returned") - } - - return *deviceToken, nil -} - -func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool) (*string, error) { - tx := s.db.Begin() - defer func() { - tx.Rollback() - }() - - user, err := s.GetUser(ctx, userID) - if err != nil { - return nil, err - } - - if user.Email == nil { - return nil, &common.UserEmailNotSetError{} - } - - oneTimeAccessToken, deviceToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, withDeviceToken, tx) - if err != nil { - return nil, err - } - err = tx.Commit().Error - if err != nil { - return nil, err - } - - // We use a background context here as this is running in a goroutine - //nolint:contextcheck - go func() { - span := trace.SpanFromContext(ctx) - innerCtx := trace.ContextWithSpan(context.Background(), span) - - 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{ - Name: user.FullName(), - Email: *user.Email, - }, OneTimeAccessTemplate, &OneTimeAccessTemplateData{ - Code: oneTimeAccessToken, - LoginLink: link, - LoginLinkWithCode: linkWithCode, - ExpirationString: utils.DurationToString(ttl), - }) - if errInternal != nil { - slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", errInternal), slog.String("address", *user.Email)) - return - } - }() - - return deviceToken, nil -} - -func (s *UserService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) { - token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, s.db) - return token, err -} - -func (s *UserService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, withDeviceToken bool, tx *gorm.DB) (token string, deviceToken *string, err error) { - oneTimeAccessToken, err := NewOneTimeAccessToken(userID, ttl, withDeviceToken) - if err != nil { - return "", nil, err - } - - err = tx.WithContext(ctx).Create(oneTimeAccessToken).Error - if err != nil { - return "", nil, err - } - - return oneTimeAccessToken.Token, oneTimeAccessToken.DeviceToken, nil -} - -func (s *UserService) ExchangeOneTimeAccessToken(ctx context.Context, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) { - tx := s.db.Begin() - defer func() { - tx.Rollback() - }() - - var oneTimeAccessToken model.OneTimeAccessToken - err := tx. - WithContext(ctx). - Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())). - Preload("User"). - Clauses(clause.Locking{Strength: "UPDATE"}). - First(&oneTimeAccessToken). - Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return model.User{}, "", &common.TokenInvalidOrExpiredError{} - } - return model.User{}, "", err - } - if oneTimeAccessToken.DeviceToken != nil && deviceToken != *oneTimeAccessToken.DeviceToken { - return model.User{}, "", &common.DeviceCodeInvalid{} - } - - accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User) - if err != nil { - return model.User{}, "", err - } - - err = tx. - WithContext(ctx). - Delete(&oneTimeAccessToken). - Error - if err != nil { - return model.User{}, "", err - } - - 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 - } - - return oneTimeAccessToken.User, accessToken, nil -} - func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroupIds []string) (user model.User, err error) { tx := s.db.Begin() defer func() { @@ -672,47 +519,6 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup return user, nil } -func (s *UserService) SignUpInitialAdmin(ctx context.Context, signUpData dto.SignUpDto) (model.User, string, error) { - tx := s.db.Begin() - defer func() { - tx.Rollback() - }() - - var userCount int64 - if err := tx.WithContext(ctx).Model(&model.User{}).Count(&userCount).Error; err != nil { - return model.User{}, "", err - } - if userCount != 0 { - return model.User{}, "", &common.SetupAlreadyCompletedError{} - } - - 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.createUserInternal(ctx, userToCreate, false, tx) - if err != nil { - return model.User{}, "", err - } - - token, err := s.jwtService.GenerateAccessToken(user) - if err != nil { - return model.User{}, "", err - } - - err = tx.Commit().Error - if err != nil { - return model.User{}, "", err - } - - return user, token, nil -} - func (s *UserService) checkDuplicatedFields(ctx context.Context, user model.User, tx *gorm.DB) error { var result struct { Found bool @@ -774,172 +580,72 @@ func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, user return nil } -func (s *UserService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (model.SignupToken, error) { - signupToken, err := NewSignupToken(ttl, usageLimit) +func (s *UserService) SendEmailVerification(ctx context.Context, userID string) error { + user, err := s.GetUser(ctx, userID) if err != nil { - return model.SignupToken{}, err + return err } - var userGroups []model.UserGroup - err = s.db.WithContext(ctx). - Where("id IN ?", userGroupIDs). - Find(&userGroups). - Error - if err != nil { - return model.SignupToken{}, err - } - signupToken.UserGroups = userGroups - - err = s.db.WithContext(ctx).Create(signupToken).Error - if err != nil { - return model.SignupToken{}, err + if user.Email == nil { + return &common.UserEmailNotSetError{} } - return *signupToken, nil + 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, 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) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAddress, userAgent string) (model.User, string, error) { +func (s *UserService) VerifyEmail(ctx context.Context, userID string, token string) error { tx := s.db.Begin() - defer func() { - tx.Rollback() - }() + defer tx.Rollback() - tokenProvided := signupData.Token != "" + 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 - config := s.appConfigService.GetDbConfig() - if config.AllowUserSignups.Value != "open" && !tokenProvided { - return model.User{}, "", &common.OpenSignupDisabledError{} + if errors.Is(err, gorm.ErrRecordNotFound) { + return &common.InvalidEmailVerificationTokenError{} + } else if err != nil { + return err } - var signupToken model.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, - } - - user, err := s.createUserInternal(ctx, userToCreate, false, tx) + user, err := s.getUserInternal(ctx, emailVerificationToken.UserID, tx) if err != nil { - return model.User{}, "", err + return err } - accessToken, err := s.jwtService.GenerateAccessToken(user) + user.EmailVerified = true + user.UpdatedAt = utils.Ptr(datatype.DateTime(time.Now())) + err = tx.WithContext(ctx).Save(&user).Error if err != nil { - return model.User{}, "", err + return 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 + err = tx.WithContext(ctx).Delete(&emailVerificationToken).Error if err != nil { - return model.User{}, "", err + return err } - return user, accessToken, nil -} - -func (s *UserService) 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{}) - - pagination, err := utils.PaginateFilterAndSort(listRequestOptions, 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, ttl time.Duration, withDeviceToken bool) (*model.OneTimeAccessToken, error) { - // If expires at is less than 15 minutes, use a 6-character token instead of 16 - tokenLength := 16 - if ttl <= 15*time.Minute { - tokenLength = 6 - } - - token, err := utils.GenerateRandomUnambiguousString(tokenLength) - if err != nil { - return nil, err - } - - var deviceToken *string - if withDeviceToken { - dt, err := utils.GenerateRandomAlphanumericString(16) - if err != nil { - return nil, err - } - deviceToken = &dt - } - - now := time.Now().Round(time.Second) - o := &model.OneTimeAccessToken{ - UserID: userID, - ExpiresAt: datatype.DateTime(now.Add(ttl)), - Token: token, - DeviceToken: deviceToken, - } - - return o, nil -} - -func NewSignupToken(ttl time.Duration, usageLimit int) (*model.SignupToken, error) { - // Generate a random token - randomString, err := utils.GenerateRandomAlphanumericString(16) - if err != nil { - return nil, err - } - - now := time.Now().Round(time.Second) - token := &model.SignupToken{ - Token: randomString, - ExpiresAt: datatype.DateTime(now.Add(ttl)), - UsageLimit: usageLimit, - UsageCount: 0, - } - - return token, nil + return tx.Commit().Error } diff --git a/backend/internal/service/user_signup_service.go b/backend/internal/service/user_signup_service.go new file mode 100644 index 00000000..604a81b2 --- /dev/null +++ b/backend/internal/service/user_signup_service.go @@ -0,0 +1,214 @@ +package service + +import ( + "context" + "errors" + "strings" + "time" + + "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 +} + +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 (s *UserSignUpService) 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 + 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: s.appConfigService.GetDbConfig().EmailsVerified.IsTrue(), + } + + user, err := s.userService.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 *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData dto.SignUpDto) (model.User, string, error) { + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + var userCount int64 + if err := tx.WithContext(ctx).Model(&model.User{}).Count(&userCount).Error; err != nil { + return model.User{}, "", err + } + if userCount != 0 { + return model.User{}, "", &common.SetupAlreadyCompletedError{} + } + + 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.userService.createUserInternal(ctx, userToCreate, false, tx) + if err != nil { + return model.User{}, "", err + } + + token, err := s.jwtService.GenerateAccessToken(user) + if err != nil { + return model.User{}, "", err + } + + err = tx.Commit().Error + if err != nil { + return model.User{}, "", err + } + + return user, token, 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{}) + + 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 *UserSignUpService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (model.SignupToken, error) { + signupToken, err := NewSignupToken(ttl, usageLimit) + if err != nil { + return model.SignupToken{}, err + } + + var userGroups []model.UserGroup + err = s.db.WithContext(ctx). + Where("id IN ?", userGroupIDs). + Find(&userGroups). + Error + if err != nil { + return model.SignupToken{}, err + } + signupToken.UserGroups = userGroups + + err = s.db.WithContext(ctx).Create(signupToken).Error + if err != nil { + return model.SignupToken{}, err + } + + return *signupToken, nil +} + +func NewSignupToken(ttl time.Duration, usageLimit int) (*model.SignupToken, error) { + // Generate a random token + randomString, err := utils.GenerateRandomAlphanumericString(16) + if err != nil { + return nil, err + } + + now := time.Now().Round(time.Second) + token := &model.SignupToken{ + Token: randomString, + ExpiresAt: datatype.DateTime(now.Add(ttl)), + UsageLimit: usageLimit, + UsageCount: 0, + } + + return token, nil +} diff --git a/backend/resources/email-templates/api-key-expiring-soon_html.tmpl b/backend/resources/email-templates/api-key-expiring-soon_html.tmpl index a7ee76b1..8b52a5a0 100644 --- a/backend/resources/email-templates/api-key-expiring-soon_html.tmpl +++ b/backend/resources/email-templates/api-key-expiring-soon_html.tmpl @@ -1 +1 @@ -{{define "root"}}
{{.AppName}}

{{.AppName}}

API Key Expiring Soon

Warning

Hello {{.Data.Name}},
This is a reminder that your API key {{.Data.APIKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.

Please generate a new API key if you need continued access.

{{end}} \ No newline at end of file +{{define "root"}}
{{.AppName}}

{{.AppName}}

API Key Expiring Soon

Warning

Hello {{.Data.Name}},
This is a reminder that your API key {{.Data.APIKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.

Please generate a new API key if you need continued access.

{{end}} \ No newline at end of file diff --git a/backend/resources/email-templates/api-key-expiring-soon_text.tmpl b/backend/resources/email-templates/api-key-expiring-soon_text.tmpl index e9f285d8..ae7ba74b 100644 --- a/backend/resources/email-templates/api-key-expiring-soon_text.tmpl +++ b/backend/resources/email-templates/api-key-expiring-soon_text.tmpl @@ -6,7 +6,6 @@ API KEY EXPIRING SOON Warning Hello {{.Data.Name}}, -This is a reminder that your API key {{.Data.APIKeyName}} will expire on -{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}. +This is a reminder that your API key {{.Data.APIKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}. Please generate a new API key if you need continued access.{{end}} \ No newline at end of file diff --git a/backend/resources/email-templates/email-verification_html.tmpl b/backend/resources/email-templates/email-verification_html.tmpl new file mode 100644 index 00000000..3d32f78f --- /dev/null +++ b/backend/resources/email-templates/email-verification_html.tmpl @@ -0,0 +1 @@ +{{define "root"}}
{{.AppName}}

{{.AppName}}

Email Verification

Hello {{.Data.UserFullName}},
Click the button below to verify your email address for {{.AppName}}. This link will expire in 24 hours.

{{end}} \ No newline at end of file diff --git a/backend/resources/email-templates/email-verification_text.tmpl b/backend/resources/email-templates/email-verification_text.tmpl new file mode 100644 index 00000000..660394ed --- /dev/null +++ b/backend/resources/email-templates/email-verification_text.tmpl @@ -0,0 +1,10 @@ +{{define "root"}}{{.AppName}} + + +EMAIL VERIFICATION + +Hello {{.Data.UserFullName}}, +Click the button below to verify your email address for {{.AppName}}. This link will expire in 24 hours. + + +Verify {{.Data.VerificationLink}}{{end}} \ No newline at end of file diff --git a/backend/resources/email-templates/login-with-new-device_html.tmpl b/backend/resources/email-templates/login-with-new-device_html.tmpl index c91a7ea1..a3c5aa5c 100644 --- a/backend/resources/email-templates/login-with-new-device_html.tmpl +++ b/backend/resources/email-templates/login-with-new-device_html.tmpl @@ -1 +1 @@ -{{define "root"}}
{{.AppName}}

{{.AppName}}

New Sign-In Detected

Warning

Your {{.AppName}} account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.

Details

Approximate Location

{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}

IP Address

{{.Data.IPAddress}}

Device

{{.Data.Device}}

Sign-In Time

{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}

{{end}} \ No newline at end of file +{{define "root"}}
{{.AppName}}

{{.AppName}}

New Sign-In Detected

Warning

Your {{.AppName}} account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.

Details

Approximate Location

{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}

IP Address

{{.Data.IPAddress}}

Device

{{.Data.Device}}

Sign-In Time

{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}

{{end}} \ No newline at end of file diff --git a/backend/resources/email-templates/login-with-new-device_text.tmpl b/backend/resources/email-templates/login-with-new-device_text.tmpl index b4baba0b..9d8c183c 100644 --- a/backend/resources/email-templates/login-with-new-device_text.tmpl +++ b/backend/resources/email-templates/login-with-new-device_text.tmpl @@ -5,15 +5,13 @@ NEW SIGN-IN DETECTED Warning -Your {{.AppName}} account was recently accessed from a new IP address or -browser. If you recognize this activity, no further action is required. +Your {{.AppName}} account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required. DETAILS Approximate Location -{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if -.Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}} +{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}} IP Address diff --git a/backend/resources/email-templates/one-time-access_html.tmpl b/backend/resources/email-templates/one-time-access_html.tmpl index 6b6fb0b6..86007b1c 100644 --- a/backend/resources/email-templates/one-time-access_html.tmpl +++ b/backend/resources/email-templates/one-time-access_html.tmpl @@ -1 +1 @@ -{{define "root"}}
{{.AppName}}

{{.AppName}}

Your Login Code

Click the button below to sign in to {{.AppName}} with a login code.
Or visit {{.Data.LoginLink}} and enter the code {{.Data.Code}}.

This code expires in {{.Data.ExpirationString}}.

{{end}} \ No newline at end of file +{{define "root"}}
{{.AppName}}

{{.AppName}}

Your Login Code

Click the button below to sign in to {{.AppName}} with a login code.
Or visit {{.Data.LoginLink}} and enter the code {{.Data.Code}}.

This code expires in {{.Data.ExpirationString}}.

{{end}} \ No newline at end of file diff --git a/backend/resources/email-templates/one-time-access_text.tmpl b/backend/resources/email-templates/one-time-access_text.tmpl index 4a166150..8a311977 100644 --- a/backend/resources/email-templates/one-time-access_text.tmpl +++ b/backend/resources/email-templates/one-time-access_text.tmpl @@ -4,8 +4,7 @@ YOUR LOGIN CODE Click the button below to sign in to {{.AppName}} with a login code. -Or visit {{.Data.LoginLink}} {{.Data.LoginLink}} and enter the code -{{.Data.Code}}. +Or visit {{.Data.LoginLink}} and enter the code {{.Data.Code}}. This code expires in {{.Data.ExpirationString}}. diff --git a/backend/resources/email-templates/test_html.tmpl b/backend/resources/email-templates/test_html.tmpl index 21a4bdde..ac1b65be 100644 --- a/backend/resources/email-templates/test_html.tmpl +++ b/backend/resources/email-templates/test_html.tmpl @@ -1 +1 @@ -{{define "root"}}
{{.AppName}}

{{.AppName}}

Test Email

Your email setup is working correctly!

{{end}} \ No newline at end of file +{{define "root"}}
{{.AppName}}

{{.AppName}}

Test Email

Your email setup is working correctly!

{{end}} \ No newline at end of file diff --git a/backend/resources/migrations/postgres/20260109090200_email_verification.down.sql b/backend/resources/migrations/postgres/20260109090200_email_verification.down.sql new file mode 100644 index 00000000..85cc57a0 --- /dev/null +++ b/backend/resources/migrations/postgres/20260109090200_email_verification.down.sql @@ -0,0 +1,2 @@ +DROP TABLE email_verification_tokens; +ALTER TABLE users DROP COLUMN email_verified; \ No newline at end of file diff --git a/backend/resources/migrations/postgres/20260109090200_email_verification.up.sql b/backend/resources/migrations/postgres/20260109090200_email_verification.up.sql new file mode 100644 index 00000000..c9342103 --- /dev/null +++ b/backend/resources/migrations/postgres/20260109090200_email_verification.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE email_verification_tokens +( + id UUID PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + token TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE +); + +ALTER TABLE users + ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE users +SET email_verified = EXISTS (SELECT 1 + FROM app_config_variables + WHERE key = 'emailsVerified' + AND value = 'true'); \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20260109090200_email_verification.down.sql b/backend/resources/migrations/sqlite/20260109090200_email_verification.down.sql new file mode 100644 index 00000000..3e7e669f --- /dev/null +++ b/backend/resources/migrations/sqlite/20260109090200_email_verification.down.sql @@ -0,0 +1,8 @@ +PRAGMA foreign_keys= OFF; +BEGIN; + +DROP TABLE email_verification_tokens; +ALTER TABLE users DROP COLUMN email_verified; + +COMMIT; +PRAGMA foreign_keys= ON; diff --git a/backend/resources/migrations/sqlite/20260109090200_email_verification.up.sql b/backend/resources/migrations/sqlite/20260109090200_email_verification.up.sql new file mode 100644 index 00000000..c680d516 --- /dev/null +++ b/backend/resources/migrations/sqlite/20260109090200_email_verification.up.sql @@ -0,0 +1,24 @@ +PRAGMA foreign_keys= OFF; +BEGIN; + +CREATE TABLE email_verification_tokens +( + id TEXT PRIMARY KEY, + created_at DATETIME NOT NULL, + token TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + user_id TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +ALTER TABLE users + ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE users +SET email_verified =EXISTS (SELECT 1 + FROM app_config_variables + WHERE key = 'emailsVerified' + AND value = 'true'); + +COMMIT; +PRAGMA foreign_keys= ON; diff --git a/email-templates/emails/email-verification.tsx b/email-templates/emails/email-verification.tsx new file mode 100644 index 00000000..11e4d4c2 --- /dev/null +++ b/email-templates/emails/email-verification.tsx @@ -0,0 +1,54 @@ +import { Text } from "@react-email/components"; +import { BaseTemplate } from "../components/base-template"; +import { Button } from "../components/button"; +import CardHeader from "../components/card-header"; +import { sharedPreviewProps, sharedTemplateProps } from "../props"; + +interface EmailVerificationData { + userFullName: string; + verificationLink: string; +} + +interface EmailVerificationProps { + logoURL: string; + appName: string; + data: EmailVerificationData; +} + +export const EmailVerification = ({ + logoURL, + appName, + data, +}: EmailVerificationProps) => ( + + + + + Hello {data.userFullName},
+ Click the button below to verify your email address for {appName}. This + link will expire in 24 hours. +
+
+ + +
+); + +export default EmailVerification; + +EmailVerification.TemplateProps = { + ...sharedTemplateProps, + data: { + userFullName: "{{.Data.UserFullName}}", + verificationLink: "{{.Data.VerificationLink}}", + }, +}; + +EmailVerification.PreviewProps = { + ...sharedPreviewProps, + data: { + userFullName: "Tim Cook", + verificationLink: + "https://localhost:1411/user/verify-email?code=abcdefg12345", + }, +}; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 18a9d9bf..e328330e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -196,8 +196,6 @@ "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "The duration of a session in minutes before the user has to sign in again.", "enable_self_account_editing": "Enable Self-Account Editing", "whether_the_users_should_be_able_to_edit_their_own_account_details": "Whether the users should be able to edit their own account details.", - "emails_verified": "Emails Verified", - "whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Whether the user's email should be marked as verified for the OIDC clients.", "ldap_configuration_updated_successfully": "LDAP configuration updated successfully", "ldap_disabled_successfully": "LDAP disabled successfully", "ldap_sync_finished": "LDAP sync finished", @@ -511,5 +509,17 @@ "renew_api_key_description": "Renewing the API key will generate a new key. Make sure to update any integrations using this key.", "api_key_renewed": "API key renewed", "app_config_home_page": "Home Page", - "app_config_home_page_description": "The page users are redirected to after signing in." + "app_config_home_page_description": "The page users are redirected to after signing in.", + "email_verification_warning": "Verify your email address", + "email_verification_warning_description": "Your email address is not verified yet. Please verify it as soon as possible.", + "email_verification": "Email Verification", + "email_verification_description": "Send a verification email to users when they sign up or change their email address.", + "email_verification_success_title": "Email Verified Successfully", + "email_verification_success_description": "Your email address has been verified successfully.", + "email_verification_error_title": "Email Verification Failed", + "mark_as_unverified": "Mark as unverified", + "mark_as_verified": "Mark as verified", + "email_verification_sent": "Verification email sent successfully.", + "emails_verified_by_default": "Emails verified by default", + "emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed." } diff --git a/frontend/src/lib/components/email-verification-state-box.svelte b/frontend/src/lib/components/email-verification-state-box.svelte new file mode 100644 index 00000000..e684c180 --- /dev/null +++ b/frontend/src/lib/components/email-verification-state-box.svelte @@ -0,0 +1,79 @@ + + +{#if emailVerificationState} + {#if emailVerificationState === 'success'} + + + {m.email_verification_success_title()} + + {m.email_verification_success_description()} + + + {:else} + + + {m.email_verification_error_title()} + + {emailVerificationState} + + + {/if} +{:else if $userStore && $appConfigStore.emailVerificationEnabled && !$userStore.emailVerified} + + +
+
+ {m.email_verification_warning()} + + {m.email_verification_warning_description()} + +
+
+ +
+
+
+{/if} diff --git a/frontend/src/lib/components/form/form-input.svelte b/frontend/src/lib/components/form/form-input.svelte index b2ef00e1..09273c36 100644 --- a/frontend/src/lib/components/form/form-input.svelte +++ b/frontend/src/lib/components/form/form-input.svelte @@ -31,6 +31,7 @@ children, onInput, labelFor, + inputClass, ...restProps }: HTMLAttributes & (WithChildren | WithoutChildren) & { @@ -39,6 +40,7 @@ docsLink?: string; placeholder?: string; disabled?: boolean; + inputClass?: string; type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date'; onInput?: (e: FormInputEvent) => void; } = $props(); @@ -73,6 +75,7 @@ {:else} svg]:text-green-900 dark:[&>svg]:text-green-100', info: 'bg-blue-100 text-blue-900 dark:bg-blue-900 dark:text-blue-100 [&>svg]:text-blue-900 dark:[&>svg]:text-blue-100', destructive: - 'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current', + 'bg-red-100 text-red-900 dark:bg-red-900 dark:text-red-100 [&>svg]:text-red-900 dark:[&>svg]:text-red-100', warning: 'bg-warning text-warning-foreground border-warning/40 [&>svg]:text-warning-foreground' } @@ -32,10 +34,12 @@ class: className, variant = 'default', children, + onDismiss, dismissibleId = undefined, ...restProps }: WithElementRef> & { variant?: AlertVariant; + onDismiss?: () => void; dismissibleId?: string; } = $props(); @@ -49,6 +53,7 @@ }); function dismiss() { + onDismiss?.(); if (dismissibleId) { const dismissedAlerts = JSON.parse(localStorage.getItem('dismissed-alerts') || '[]'); localStorage.setItem('dismissed-alerts', JSON.stringify([...dismissedAlerts, dismissibleId])); @@ -66,7 +71,7 @@ role="alert" > {@render children?.()} - {#if dismissibleId} + {#if dismissibleId || onDismiss} diff --git a/frontend/src/lib/components/ui/toggle/index.ts b/frontend/src/lib/components/ui/toggle/index.ts new file mode 100644 index 00000000..8cb2936f --- /dev/null +++ b/frontend/src/lib/components/ui/toggle/index.ts @@ -0,0 +1,13 @@ +import Root from "./toggle.svelte"; +export { + toggleVariants, + type ToggleSize, + type ToggleVariant, + type ToggleVariants, +} from "./toggle.svelte"; + +export { + Root, + // + Root as Toggle, +}; diff --git a/frontend/src/lib/components/ui/toggle/toggle.svelte b/frontend/src/lib/components/ui/toggle/toggle.svelte new file mode 100644 index 00000000..f48b95f7 --- /dev/null +++ b/frontend/src/lib/components/ui/toggle/toggle.svelte @@ -0,0 +1,52 @@ + + + + + diff --git a/frontend/src/lib/services/user-service.ts b/frontend/src/lib/services/user-service.ts index 9f3163f0..e577d513 100644 --- a/frontend/src/lib/services/user-service.ts +++ b/frontend/src/lib/services/user-service.ts @@ -2,7 +2,7 @@ import userStore from '$lib/stores/user-store'; import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type'; import type { SignupToken } from '$lib/types/signup-token.type'; import type { UserGroup } from '$lib/types/user-group.type'; -import type { User, UserCreate, UserSignUp } from '$lib/types/user.type'; +import type { AccountUpdate, User, UserCreate, UserSignUp } from '$lib/types/user.type'; import { cachedProfilePicture } from '$lib/utils/cached-image-util'; import { get } from 'svelte/store'; import APIService from './api-service'; @@ -38,7 +38,7 @@ export default class UserService extends APIService { return res.data as User; }; - updateCurrent = async (user: UserCreate) => { + updateCurrent = async (user: AccountUpdate) => { const res = await this.api.put('/users/me', user); return res.data as User; }; @@ -121,4 +121,14 @@ export default class UserService extends APIService { deleteSignupToken = async (tokenId: string) => { await this.api.delete(`/signup-tokens/${tokenId}`); }; + + sendEmailVerification = async () => { + const res = await this.api.post('/users/me/send-email-verification'); + return res.data as User; + }; + + verifyEmail = async (token: string) => { + const res = await this.api.post('/users/me/verify-email', { token }); + return res.data as User; + }; } diff --git a/frontend/src/lib/types/application-configuration.type.ts b/frontend/src/lib/types/application-configuration.type.ts index a45fd131..011fe5b7 100644 --- a/frontend/src/lib/types/application-configuration.type.ts +++ b/frontend/src/lib/types/application-configuration.type.ts @@ -7,6 +7,7 @@ export type AppConfig = { allowUserSignups: 'disabled' | 'withToken' | 'open'; emailOneTimeAccessAsUnauthenticatedEnabled: boolean; emailOneTimeAccessAsAdminEnabled: boolean; + emailVerificationEnabled: boolean; ldapEnabled: boolean; disableAnimations: boolean; uiConfigDisabled: boolean; diff --git a/frontend/src/lib/types/user.type.ts b/frontend/src/lib/types/user.type.ts index 710a7d99..13c6f721 100644 --- a/frontend/src/lib/types/user.type.ts +++ b/frontend/src/lib/types/user.type.ts @@ -6,6 +6,7 @@ export type User = { id: string; username: string; email: string | undefined; + emailVerified: boolean; firstName: string; lastName?: string; displayName: string; @@ -19,6 +20,11 @@ export type User = { export type UserCreate = Omit; -export type UserSignUp = Omit & { +export type AccountUpdate = Omit + +export type UserSignUp = Omit< + UserCreate, + 'isAdmin' | 'disabled' | 'displayName' | 'emailVerified' +> & { token?: string; }; diff --git a/frontend/src/lib/utils/redirection-util.ts b/frontend/src/lib/utils/redirection-util.ts index 4240d83e..b6c878b0 100644 --- a/frontend/src/lib/utils/redirection-util.ts +++ b/frontend/src/lib/utils/redirection-util.ts @@ -2,7 +2,8 @@ import type { User } from '$lib/types/user.type'; // Returns the path to redirect to based on the current path and user authentication status // If no redirect is needed, it returns null -export function getAuthRedirectPath(path: string, user: User | null) { +export function getAuthRedirectPath(url: URL, user: User | null) { + const path = url.pathname; const isSignedIn = !!user; const isAdmin = user?.isAdmin; @@ -19,7 +20,8 @@ export function getAuthRedirectPath(path: string, user: User | null) { const isAdminPath = path == '/settings/admin' || path.startsWith('/settings/admin/'); if (!isUnauthenticatedOnlyPath && !isPublicPath && !isSignedIn) { - return '/login'; + const redirect = url.pathname + url.search; + return `/login?redirect=${encodeURIComponent(redirect)}`; } if (isUnauthenticatedOnlyPath && isSignedIn) { @@ -29,4 +31,6 @@ export function getAuthRedirectPath(path: string, user: User | null) { if (isAdminPath && !isAdmin) { return '/settings'; } + + return null; } diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts index 7f5a9015..d33bb849 100644 --- a/frontend/src/routes/+layout.ts +++ b/frontend/src/routes/+layout.ts @@ -24,7 +24,7 @@ export const load: LayoutLoad = async ({ url }) => { const [user, appConfig] = await Promise.all([userPromise, appConfigPromise]); - const redirectPath = getAuthRedirectPath(url.pathname, user); + const redirectPath = getAuthRedirectPath(url, user); if (redirectPath) { redirect(302, redirectPath); } diff --git a/frontend/src/routes/settings/+layout.svelte b/frontend/src/routes/settings/+layout.svelte index 3f318d8b..b5cd4232 100644 --- a/frontend/src/routes/settings/+layout.svelte +++ b/frontend/src/routes/settings/+layout.svelte @@ -1,8 +1,9 @@