diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index dadb8671..c7d547a3 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -179,7 +179,6 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices ) svc.oneTimeAccessModule.RegisterRoutes(apiGroup, authMiddleware.Add(), - authMiddleware.WithAdminNotRequired().Add(), rateLimitMiddleware.Add(middleware.RateLimitOneTimeAccessToken), rateLimitMiddleware.Add(middleware.RateLimitOneTimeAccessEmail), ) diff --git a/backend/internal/onetimeaccess/handler.go b/backend/internal/onetimeaccess/handler.go index 55cf5f62..79e53bb4 100644 --- a/backend/internal/onetimeaccess/handler.go +++ b/backend/internal/onetimeaccess/handler.go @@ -23,7 +23,15 @@ func newHandler(service *Service, appConfig AppConfigResolver) *handler { return &handler{service: service, appConfig: appConfig} } -func (h *handler) createToken(c *gin.Context, own bool) { +// createTokenForUser godoc +// @Summary Create one-time access token for user (admin) +// @Description Generate a one-time access token for a specific user (admin only) +// @Tags Users +// @Param id path string true "User ID" +// @Param body body tokenCreateDto true "Token options" +// @Success 201 {object} object "{ \"token\": \"string\" }" +// @Router /api/users/{id}/one-time-access-token [post] +func (h *handler) createTokenForUser(c *gin.Context) { var input tokenCreateDto err := c.ShouldBindJSON(&input) if err != nil { @@ -31,21 +39,11 @@ func (h *handler) createToken(c *gin.Context, own bool) { return } - var ( - userID string - ttl time.Duration - ) - if own { - // Get user ID from context and force the default TTL - userID = c.GetString("userID") + // Get the target user ID from the URL and apply the default expiration when no TTL is provided + userID := c.Param("id") + ttl := input.TTL.Duration + if ttl <= 0 { ttl = defaultTokenDuration - } else { - // Get user ID from URL parameter, and optional TTL from body - userID = c.Param("id") - ttl = input.TTL.Duration - if ttl <= 0 { - ttl = defaultTokenDuration - } } if userID == "" { _ = c.Error(&common.UserIdNotProvidedError{}) @@ -61,29 +59,6 @@ func (h *handler) createToken(c *gin.Context, own bool) { c.JSON(http.StatusCreated, gin.H{"token": token}) } -// createOwnToken godoc -// @Summary Create one-time access token for current user -// @Description Generate a one-time access token for the currently authenticated user -// @Tags Users -// @Param body body tokenCreateDto true "Token options" -// @Success 201 {object} object "{ \"token\": \"string\" }" -// @Router /api/users/me/one-time-access-token [post] -func (h *handler) createOwnToken(c *gin.Context) { - h.createToken(c, true) -} - -// createTokenForUser godoc -// @Summary Create one-time access token for user (admin) -// @Description Generate a one-time access token for a specific user (admin only) -// @Tags Users -// @Param id path string true "User ID" -// @Param body body tokenCreateDto true "Token options" -// @Success 201 {object} object "{ \"token\": \"string\" }" -// @Router /api/users/{id}/one-time-access-token [post] -func (h *handler) createTokenForUser(c *gin.Context) { - h.createToken(c, false) -} - // requestEmailAsUnauthenticatedUser godoc // @Summary Request one-time access email // @Description Request a one-time access email for unauthenticated users diff --git a/backend/internal/onetimeaccess/module.go b/backend/internal/onetimeaccess/module.go index 9f9344b7..2c352659 100644 --- a/backend/internal/onetimeaccess/module.go +++ b/backend/internal/onetimeaccess/module.go @@ -67,9 +67,8 @@ func New(deps Dependencies) (*Module, error) { } // RegisterRoutes mounts the one-time access token endpoints -// auth guards the admin routes and ownAuth the current user's own token, while the rate limiters throttle the public exchange and email endpoints -func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, auth, ownAuth, exchangeRateLimit, emailRateLimit gin.HandlerFunc) { - apiGroup.POST("/users/me/one-time-access-token", ownAuth, m.handler.createOwnToken) +// auth guards the admin routes, while the rate limiters throttle the public exchange and email endpoints +func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, auth, exchangeRateLimit, emailRateLimit gin.HandlerFunc) { apiGroup.POST("/users/:id/one-time-access-token", auth, m.handler.createTokenForUser) apiGroup.POST("/users/:id/one-time-access-email", auth, m.handler.requestEmailAsAdmin) apiGroup.POST("/one-time-access-token/:token", exchangeRateLimit, m.handler.exchangeToken) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index faeff0d8..d9157e19 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -116,7 +116,6 @@ "manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Manage your passkeys that you can use to authenticate yourself.", "manage_this_users_passkeys": "Manage this user's passkeys.", "add_passkey": "Add Passkey", - "create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Create a one-time login code to sign in from a different device without a passkey.", "create": "Create", "first_name": "First name", "last_name": "Last name", diff --git a/frontend/src/lib/services/user-service.ts b/frontend/src/lib/services/user-service.ts index 230ecdd3..5c424205 100644 --- a/frontend/src/lib/services/user-service.ts +++ b/frontend/src/lib/services/user-service.ts @@ -81,7 +81,7 @@ export default class UserService extends APIService { cachedProfilePicture.bustCache(userId); }; - createOneTimeAccessToken = async (userId: string = 'me', ttl?: string | number) => { + createOneTimeAccessToken = async (userId: string, ttl?: string | number) => { const res = await this.api.post(`/users/${userId}/one-time-access-token`, { ttl }); return res.data.token; }; diff --git a/frontend/src/routes/device/+page.svelte b/frontend/src/routes/device/+page.svelte index aa168cc8..0ee4e52e 100644 --- a/frontend/src/routes/device/+page.svelte +++ b/frontend/src/routes/device/+page.svelte @@ -229,6 +229,7 @@ class="mt-7 flex w-full max-w-112.5 justify-center" > value.replace(/[^a-zA-Z0-9]/g, '').toUpperCase()} > {#snippet children({ cells })} - + {#each cells.slice(0, 4) as cell (cell)} - + {/each} - + {#each cells.slice(4) as cell (cell)} - + {/each} {/snippet} @@ -274,10 +275,10 @@ {/if} diff --git a/frontend/src/routes/login/alternative/+page.svelte b/frontend/src/routes/login/alternative/+page.svelte index 8b627b1a..3b315cd6 100644 --- a/frontend/src/routes/login/alternative/+page.svelte +++ b/frontend/src/routes/login/alternative/+page.svelte @@ -13,18 +13,18 @@ } from '@lucide/svelte'; const methods = [ + { + icon: LucideQrCode, + title: m.sign_in_with_another_device(), + description: m.sign_in_with_another_device_description(), + href: '/login/alternative/device' + }, { icon: LucideRectangleEllipsis, title: m.login_code(), description: m.enter_a_login_code_to_sign_in(), href: '/login/alternative/code' }, - { - icon: LucideQrCode, - title: m.sign_in_with_another_device(), - description: m.sign_in_with_another_device_description(), - href: '/login/alternative/device' - } ]; if ($appConfigStore.emailOneTimeAccessAsUnauthenticatedEnabled) { diff --git a/frontend/src/routes/login/alternative/code/+page.svelte b/frontend/src/routes/login/alternative/code/+page.svelte index edcb8a0c..f099b514 100644 --- a/frontend/src/routes/login/alternative/code/+page.svelte +++ b/frontend/src/routes/login/alternative/code/+page.svelte @@ -6,6 +6,7 @@ import Input from '$lib/components/ui/input/input.svelte'; import { m } from '$lib/paraglide/messages'; import UserService from '$lib/services/user-service'; + import appConfigStore from '$lib/stores/application-configuration-store'; import userStore from '$lib/stores/user-store.js'; import { getAxiosErrorMessage } from '$lib/utils/error-util'; import { preventDefault } from '$lib/utils/event-util'; @@ -18,8 +19,10 @@ let error: string | undefined = $state(); let backHref = $state('/login/alternative'); - let longCodeRequested = $state(code.length > 6); - let showLongCodeOption = $state(true); + let longCodeRequested = $state( + code.length > 6 || !$appConfigStore.emailOneTimeAccessAsUnauthenticatedEnabled + ); + let showLongCodeOption = $state($appConfigStore.emailOneTimeAccessAsUnauthenticatedEnabled); let codeComplete = $derived(longCodeRequested ? code.length === 16 : code.length === 6); const userService = new UserService(); diff --git a/frontend/src/routes/settings/account/+page.svelte b/frontend/src/routes/settings/account/+page.svelte index df030094..9b1dcb66 100644 --- a/frontend/src/routes/settings/account/+page.svelte +++ b/frontend/src/routes/settings/account/+page.svelte @@ -12,18 +12,11 @@ import type { Passkey } from '$lib/types/passkey.type'; import type { AccountUpdate } from '$lib/types/user.type'; import { axiosErrorToast, getWebauthnErrorMessage } from '$lib/utils/error-util'; - import { - KeyRound, - Languages, - LucideAlertTriangle, - RectangleEllipsis, - UserCog - } from '@lucide/svelte'; + import { KeyRound, Languages, LucideAlertTriangle, UserCog } from '@lucide/svelte'; import { startRegistration } from '@simplewebauthn/browser'; import { toast } from 'svelte-sonner'; import AccountForm from './account-form.svelte'; import LocalePicker from './locale-picker.svelte'; - import LoginCodeModal from './login-code-modal.svelte'; import PasskeyList from './passkey-list.svelte'; import RenamePasskeyModal from './rename-passkey-modal.svelte'; @@ -31,8 +24,6 @@ let account = $state(data.account); let passkeys = $state(data.passkeys); let passkeyToRename: Passkey | null = $state(null); - let showLoginCodeModal: boolean = $state(false); - const userService = new UserService(); const webauthnService = new WebAuthnService(); @@ -103,26 +94,6 @@ {/if} - -
- - - - - - {m.login_code()} - - {m.create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey()} - - - - - - -
- @@ -163,25 +134,6 @@ {/if} - - @@ -203,4 +155,3 @@ bind:passkey={passkeyToRename} callback={async () => (passkeys = await webauthnService.listCredentials())} /> - diff --git a/frontend/src/routes/settings/account/login-code-modal.svelte b/frontend/src/routes/settings/account/login-code-modal.svelte deleted file mode 100644 index 20437492..00000000 --- a/frontend/src/routes/settings/account/login-code-modal.svelte +++ /dev/null @@ -1,73 +0,0 @@ - - - - e.preventDefault()}> - - {m.login_code()} - {m.sign_in_using_the_following_code_the_code_will_expire_in_minutes()} - - - -
- -

{code}

-
-
- -

{m.or_visit()}

- -
- - - -

{loginCodeLink!}

-
-
-
-
diff --git a/tests/specs/account-settings.spec.ts b/tests/specs/account-settings.spec.ts index 28ecb071..608366f3 100644 --- a/tests/specs/account-settings.spec.ts +++ b/tests/specs/account-settings.spec.ts @@ -112,23 +112,6 @@ test('Delete passkey from account', async ({ page }) => { await expect(page.locator('[data-type="success"]')).toHaveText('Passkey deleted successfully'); }); -test('Generate own one time access token as non admin', async ({ page, context }) => { - await context.clearCookies(); - await page.goto('/login'); - await (await passkeyUtil.init(page)).addPasskey('craig'); - - await page.getByRole('button', { name: 'Authenticate' }).click(); - await page.waitForURL('/settings/account'); - - await page.getByRole('button', { name: 'Create' }).click(); - const link = await page.getByTestId('login-code-link').textContent(); - - await context.clearCookies(); - - await page.goto(link!); - await page.waitForURL('/settings/account'); -}); - test('Email verification succeeds', async ({ page, context }) => { await context.clearCookies();