From eb1426ed2684b5ddd185db247a8e082b28dfd014 Mon Sep 17 00:00:00 2001 From: Jonas <39232833+Pyxels@users.noreply.github.com> Date: Mon, 10 Mar 2025 12:45:45 +0100 Subject: [PATCH] feat(account): add ability to sign in with login code (#271) Co-authored-by: Elias Schneider --- backend/internal/common/errors.go | 7 ++ .../controller/app_config_controller.go | 2 +- .../internal/controller/user_controller.go | 16 +++- backend/internal/dto/user_dto.go | 2 +- .../service/email_service_templates.go | 6 +- backend/internal/service/user_service.go | 25 ++++-- .../email-templates/one-time-access_html.tmpl | 6 +- .../email-templates/one-time-access_text.tmpl | 8 +- frontend/package-lock.json | 4 +- frontend/src/hooks.server.ts | 2 +- .../lib/components/copy-to-clipboard.svelte | 2 +- .../src/lib/components/login-wrapper.svelte | 82 ++++++++----------- .../components}/one-time-link-modal.svelte | 17 ++-- .../components/web-authn-unsupported.svelte | 8 +- frontend/src/lib/services/user-service.ts | 2 +- frontend/src/routes/authorize/+page.svelte | 2 +- frontend/src/routes/lc/+server.ts | 10 +++ frontend/src/routes/lc/[code]/+server.ts | 15 ++++ frontend/src/routes/login/+page.svelte | 8 +- .../src/routes/login/[token]/+page.svelte | 69 ---------------- .../src/routes/login/alternative/+page.svelte | 65 +++++++++++++++ .../code}/+page.server.ts | 4 +- .../login/alternative/code/+page.svelte | 74 +++++++++++++++++ .../{ => alternative}/email/+page.server.ts | 0 .../{ => alternative}/email/+page.svelte | 27 ++++-- .../src/routes/settings/account/+page.svelte | 30 ++++++- .../settings/account/login-code-modal.svelte | 62 ++++++++++++++ .../forms/app-config-email-form.svelte | 6 +- .../settings/admin/users/user-list.svelte | 4 +- frontend/tests/account-settings.spec.ts | 17 ++++ .../tests/application-configuration.spec.ts | 4 +- frontend/tests/one-time-access-token.spec.ts | 39 +++++++-- frontend/tests/user-settings.spec.ts | 10 +-- scripts/create-one-time-access-token.sh | 2 +- 34 files changed, 446 insertions(+), 191 deletions(-) rename frontend/src/{routes/settings/admin/users => lib/components}/one-time-link-modal.svelte (80%) create mode 100644 frontend/src/routes/lc/+server.ts create mode 100644 frontend/src/routes/lc/[code]/+server.ts delete mode 100644 frontend/src/routes/login/[token]/+page.svelte create mode 100644 frontend/src/routes/login/alternative/+page.svelte rename frontend/src/routes/login/{[token] => alternative/code}/+page.server.ts (57%) create mode 100644 frontend/src/routes/login/alternative/code/+page.svelte rename frontend/src/routes/login/{ => alternative}/email/+page.server.ts (100%) rename frontend/src/routes/login/{ => alternative}/email/+page.svelte (62%) create mode 100644 frontend/src/routes/settings/account/login-code-modal.svelte 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 @@
-

One-Time Access

+

Login Code

- Click the button below to sign in to {{ .AppName }} with a one-time access link. This link expires in 15 minutes. + 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 15 minutes.

{{ 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 dbf14138..073edde8 100644 --- a/backend/resources/email-templates/one-time-access_text.tmpl +++ b/backend/resources/email-templates/one-time-access_text.tmpl @@ -1,8 +1,10 @@ {{ define "base" -}} -One-Time Access +Login Code ==================== -Click the link below to sign in to {{ .AppName }} with a one-time access link. This link expires in 15 minutes. +Click the link below to sign in to {{ .AppName }} with a login code. This code expires in 15 minutes. -{{ .Data.Link }} +{{ .Data.LoginLinkWithCode }} + +Or visit {{ .Data.LoginLink }} and enter the the code "{{ .Data.Code }}". {{ end -}} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bd87c076..71d7c6e0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "pocket-id-frontend", - "version": "0.35.2", + "version": "0.35.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pocket-id-frontend", - "version": "0.35.2", + "version": "0.35.3", "dependencies": { "@simplewebauthn/browser": "^13.1.0", "@tailwindcss/vite": "^4.0.0", diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index c927de22..2ff787dc 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -12,7 +12,7 @@ process.env.INTERNAL_BACKEND_URL = env.INTERNAL_BACKEND_URL ?? 'http://localhost export const handle: Handle = async ({ event, resolve }) => { const { isSignedIn, isAdmin } = verifyJwt(event.cookies.get(ACCESS_TOKEN_COOKIE_NAME)); - const isUnauthenticatedOnlyPath = event.url.pathname.startsWith('/login'); + const isUnauthenticatedOnlyPath = event.url.pathname.startsWith('/login') || event.url.pathname.startsWith('/lc') const isPublicPath = ['/authorize', '/health'].includes(event.url.pathname); const isAdminPath = event.url.pathname.startsWith('/settings/admin'); diff --git a/frontend/src/lib/components/copy-to-clipboard.svelte b/frontend/src/lib/components/copy-to-clipboard.svelte index 2f6f4189..53a1a6f3 100644 --- a/frontend/src/lib/components/copy-to-clipboard.svelte +++ b/frontend/src/lib/components/copy-to-clipboard.svelte @@ -28,7 +28,7 @@ - {@render children()} + {@render children()} {#if copied} Copied diff --git a/frontend/src/lib/components/login-wrapper.svelte b/frontend/src/lib/components/login-wrapper.svelte index bf09cd3e..dc3eaa03 100644 --- a/frontend/src/lib/components/login-wrapper.svelte +++ b/frontend/src/lib/components/login-wrapper.svelte @@ -1,46 +1,39 @@