diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index fe81f21d..ce186899 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -224,3 +224,10 @@ func (e *InvalidUUIDError) Error() string { } type InvalidEmailError struct{} + +type OneTimeAccessDisabledError struct{} + +func (e *OneTimeAccessDisabledError) Error() string { + return "One-time access is disabled" +} +func (e *OneTimeAccessDisabledError) HttpStatusCode() int { return http.StatusBadRequest } diff --git a/backend/internal/controller/app_config_controller.go b/backend/internal/controller/app_config_controller.go index f1c35371..a9580f04 100644 --- a/backend/internal/controller/app_config_controller.go +++ b/backend/internal/controller/app_config_controller.go @@ -27,7 +27,7 @@ func NewAppConfigController( } group.GET("/application-configuration", acc.listAppConfigHandler) group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler) - group.PUT("/application-configuration", acc.updateAppConfigHandler) + group.PUT("/application-configuration", jwtAuthMiddleware.Add(true), acc.updateAppConfigHandler) group.GET("/application-configuration/logo", acc.getLogoHandler) group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler) diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index 66a57595..e892a3cc 100644 --- a/backend/internal/controller/user_controller.go +++ b/backend/internal/controller/user_controller.go @@ -38,7 +38,8 @@ func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt group.PUT("/users/:id/profile-picture", jwtAuthMiddleware.Add(true), uc.updateUserProfilePictureHandler) group.PUT("/users/me/profile-picture", jwtAuthMiddleware.Add(false), uc.updateCurrentUserProfilePictureHandler) - group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createOneTimeAccessTokenHandler) + group.POST("/users/me/one-time-access-token", jwtAuthMiddleware.Add(false), uc.createOwnOneTimeAccessTokenHandler) + group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createAdminOneTimeAccessTokenHandler) group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler) group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler) group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.requestOneTimeAccessEmailHandler) @@ -235,13 +236,16 @@ func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context) c.Status(http.StatusNoContent) } -func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) { +func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bool) { var input dto.OneTimeAccessTokenCreateDto if err := c.ShouldBindJSON(&input); err != nil { c.Error(err) return } + if own { + input.UserID = c.GetString("userID") + } token, err := uc.userService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt) if err != nil { c.Error(err) @@ -251,6 +255,14 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) { c.JSON(http.StatusCreated, gin.H{"token": token}) } +func (uc *UserController) createOwnOneTimeAccessTokenHandler(c *gin.Context) { + uc.createOneTimeAccessTokenHandler(c, true) +} + +func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) { + uc.createOneTimeAccessTokenHandler(c, false) +} + func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) { var input dto.OneTimeAccessEmailDto if err := c.ShouldBindJSON(&input); err != nil { diff --git a/backend/internal/dto/user_dto.go b/backend/internal/dto/user_dto.go index e60cde1a..edce7790 100644 --- a/backend/internal/dto/user_dto.go +++ b/backend/internal/dto/user_dto.go @@ -24,7 +24,7 @@ type UserCreateDto struct { } type OneTimeAccessTokenCreateDto struct { - UserID string `json:"userId" binding:"required"` + UserID string `json:"userId"` ExpiresAt time.Time `json:"expiresAt" binding:"required"` } diff --git a/backend/internal/service/email_service_templates.go b/backend/internal/service/email_service_templates.go index d9920280..8b7366c5 100644 --- a/backend/internal/service/email_service_templates.go +++ b/backend/internal/service/email_service_templates.go @@ -31,7 +31,7 @@ var NewLoginTemplate = email.Template[NewLoginTemplateData]{ var OneTimeAccessTemplate = email.Template[OneTimeAccessTemplateData]{ Path: "one-time-access", Title: func(data *email.TemplateData[OneTimeAccessTemplateData]) string { - return "One time access" + return "Login Code" }, } @@ -51,7 +51,9 @@ type NewLoginTemplateData struct { } type OneTimeAccessTemplateData = struct { - Link string + Code string + LoginLink string + LoginLinkWithCode string } // this is list of all template paths used for preloading templates diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index ab044af6..663b27b1 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -197,6 +197,11 @@ func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, u } func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath string) error { + isDisabled := s.appConfigService.DbConfig.EmailOneTimeAccessEnabled.Value != "true" + if isDisabled { + return &common.OneTimeAccessDisabledError{} + } + var user model.User if err := s.db.Where("email = ?", emailAddress).First(&user).Error; err != nil { // Do not return error if user not found to prevent email enumeration @@ -207,17 +212,18 @@ func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath strin } } - oneTimeAccessToken, err := s.CreateOneTimeAccessToken(user.ID, time.Now().Add(time.Hour)) + oneTimeAccessToken, err := s.CreateOneTimeAccessToken(user.ID, time.Now().Add(15*time.Minute)) if err != nil { return err } - link := fmt.Sprintf("%s/login/%s", common.EnvConfig.AppURL, oneTimeAccessToken) + link := fmt.Sprintf("%s/lc", common.EnvConfig.AppURL) + linkWithCode := fmt.Sprintf("%s/%s", link, oneTimeAccessToken) // Add redirect path to the link if strings.HasPrefix(redirectPath, "/") { encodedRedirectPath := url.QueryEscape(redirectPath) - link = fmt.Sprintf("%s?redirect=%s", link, encodedRedirectPath) + linkWithCode = fmt.Sprintf("%s?redirect=%s", linkWithCode, encodedRedirectPath) } go func() { @@ -225,7 +231,9 @@ func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath strin Name: user.Username, Email: user.Email, }, OneTimeAccessTemplate, &OneTimeAccessTemplateData{ - Link: link, + Code: oneTimeAccessToken, + LoginLink: link, + LoginLinkWithCode: linkWithCode, }) if err != nil { log.Printf("Failed to send email to '%s': %v\n", user.Email, err) @@ -236,7 +244,14 @@ func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath strin } func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time) (string, error) { - randomString, err := utils.GenerateRandomAlphanumericString(16) + tokenLength := 16 + + // If expires at is less than 15 minutes, use an 6 character token instead of 16 + if expiresAt.Sub(time.Now()) <= 15*time.Minute { + tokenLength = 6 + } + + randomString, err := utils.GenerateRandomAlphanumericString(tokenLength) if err != nil { return "", err } diff --git a/backend/resources/email-templates/one-time-access_html.tmpl b/backend/resources/email-templates/one-time-access_html.tmpl index f2847694..3494cc74 100644 --- a/backend/resources/email-templates/one-time-access_html.tmpl +++ b/backend/resources/email-templates/one-time-access_html.tmpl @@ -6,12 +6,12 @@
Browser unsupported
-- This browser doesn't support passkeys. Please use a browser that supports WebAuthn to sign in. +
Browser unsupported
++ This browser doesn't support passkeys. Please or use a alternative sign in method.
Client not found
{:else} -+
{error}. Please try to sign in again.
{:else} -+
Authenticate yourself with your passkey to access the admin panel.
{/if} diff --git a/frontend/src/routes/login/[token]/+page.svelte b/frontend/src/routes/login/[token]/+page.svelte deleted file mode 100644 index c51807d7..00000000 --- a/frontend/src/routes/login/[token]/+page.svelte +++ /dev/null @@ -1,69 +0,0 @@ - - -- {error}. Please try again. -
- {:else if !skipPage} -- {#if data.token === 'setup'} - You're about to sign in to the initial admin account. Anyone with this link can access the - account until a passkey is added. Please set up a passkey as soon as possible to prevent - unauthorized access. - {:else} - You've been granted one-time access to your {$appConfigStore.appName} account. Please note that - if you continue, this link will become invalid. To avoid this, make sure to add a passkey. Otherwise, - you'll need to request a new link. - {/if} -
- - {/if} -+ If you dont't have access to your passkey, you can sign in using one of the following methods. +
+{method.description}
++ {error}. Please try again. +
+ {:else} +Enter the code you received to sign in.
+ {/if} + ++
{error}. Please try again.
+
An email has been sent to the provided email, if it exists in the system.
+{code}
+or visit
+{page.url.origin + '/lc/' + code!}
+