mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-19 03:16:28 +00:00
feat: drop user initiated one time access token login method
This commit is contained in:
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user