feat: drop user initiated one time access token login method

This commit is contained in:
Elias Schneider
2026-07-29 23:49:08 +02:00
parent 2ef7c93fee
commit 0c27f733b9
11 changed files with 35 additions and 198 deletions
@@ -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),
)
+13 -38
View File
@@ -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
+2 -3
View File
@@ -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)
-1
View File
@@ -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",
+1 -1
View File
@@ -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;
};
+7 -6
View File
@@ -229,6 +229,7 @@
class="mt-7 flex w-full max-w-112.5 justify-center"
>
<InputOTP.Root
class="gap-1 sm:gap-2"
maxlength={8}
aria-label={m.code()}
bind:value={userCode}
@@ -236,15 +237,15 @@
pasteTransformer={(value) => value.replace(/[^a-zA-Z0-9]/g, '').toUpperCase()}
>
{#snippet children({ cells })}
<InputOTP.Group>
<InputOTP.Group class="gap-0.5 sm:gap-1.5">
{#each cells.slice(0, 4) as cell (cell)}
<InputOTP.Slot {cell} />
<InputOTP.Slot class="h-12 w-8 sm:h-13 sm:w-10" {cell} />
{/each}
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
<InputOTP.Group class="gap-0.5 sm:gap-1.5">
{#each cells.slice(4) as cell (cell)}
<InputOTP.Slot {cell} />
<InputOTP.Slot class="h-12 w-8 sm:h-13 sm:w-10" {cell} />
{/each}
</InputOTP.Group>
{/snippet}
@@ -274,10 +275,10 @@
<Button
form="device-code-form"
class="flex-1"
disabled={isLoading || !codeComplete}
disabled={!codeComplete}
{isLoading}
onclick={authorize}
>
{#if isLoading}<Spinner data-icon="inline-start" />{/if}
{m.authorize()}
</Button>
{/if}
@@ -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) {
@@ -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();
@@ -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 @@
</Alert.Root>
{/if}
<!-- Login code card mobile -->
<div class="block sm:hidden">
<Item.Root variant="outline">
<Item.Media class="text-primary/80">
<RectangleEllipsis class="size-5" />
</Item.Media>
<Item.Content>
<Item.Title>{m.login_code()}</Item.Title>
<Item.Description>
{m.create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey()}
</Item.Description>
</Item.Content>
<Item.Actions class="w-full sm:w-auto">
<Button variant="outline" class="w-full" onclick={() => (showLoginCodeModal = true)}>
{m.create()}
</Button>
</Item.Actions>
</Item.Root>
</div>
<Card.Root>
<Card.Header>
<Card.Title>
@@ -163,25 +134,6 @@
{/if}
</Item.Group>
<div class="hidden sm:block">
<Item.Root variant="card" class="border-border">
<Item.Media class="text-primary/80">
<RectangleEllipsis class="size-5" />
</Item.Media>
<Item.Content>
<Item.Title>{m.login_code()}</Item.Title>
<Item.Description>
{m.create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey()}
</Item.Description>
</Item.Content>
<Item.Actions>
<Button variant="outline" onclick={() => (showLoginCodeModal = true)}>
{m.create()}
</Button>
</Item.Actions>
</Item.Root>
</div>
<Item.Root variant="card" class="border-border mb-2">
<Item.Media class="text-primary/80">
<Languages class="size-5" />
@@ -203,4 +155,3 @@
bind:passkey={passkeyToRename}
callback={async () => (passkeys = await webauthnService.listCredentials())}
/>
<LoginCodeModal bind:show={showLoginCodeModal} />
@@ -1,73 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import Qrcode from '$lib/components/qrcode/qrcode.svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { Separator } from '$lib/components/ui/separator';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import { axiosErrorToast } from '$lib/utils/error-util';
import { mode } from 'mode-watcher';
let {
show = $bindable()
}: {
show: boolean;
} = $props();
const userService = new UserService();
let code: string | null = $state(null);
let loginCodeLink: string | null = $state(null);
$effect(() => {
if (show) {
userService
.createOneTimeAccessToken('me')
.then((c) => {
code = c;
loginCodeLink = page.url.origin + '/lc/' + code;
})
.catch((e) => axiosErrorToast(e));
}
});
function onOpenChange(open: boolean) {
if (!open) {
code = null;
show = false;
}
}
</script>
<Dialog.Root open={!!code} {onOpenChange}>
<Dialog.Content class="max-w-md" onOpenAutoFocus={(e) => e.preventDefault()}>
<Dialog.Header>
<Dialog.Title>{m.login_code()}</Dialog.Title>
<Dialog.Description
>{m.sign_in_using_the_following_code_the_code_will_expire_in_minutes()}
</Dialog.Description>
</Dialog.Header>
<div class="flex flex-col items-center gap-2">
<CopyToClipboard value={code!}>
<p class="text-3xl font-bold">{code}</p>
</CopyToClipboard>
<div class="flex items-center justify-center gap-3 my-2 text-muted-foreground">
<Separator />
<p class="text-xs text-nowrap">{m.or_visit()}</p>
<Separator />
</div>
<Qrcode
class="mb-2"
value={loginCodeLink}
size={150}
color={mode.current === 'dark' ? '#FFFFFF' : '#000000'}
/>
<CopyToClipboard value={loginCodeLink!}>
<p class="text-sm" data-testId="login-code-link">{loginCodeLink!}</p>
</CopyToClipboard>
</div>
</Dialog.Content>
</Dialog.Root>
-17
View File
@@ -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();