refactor: use adonis's access tokens instead of creating custom (and unsecured) logic

This commit is contained in:
Sonny
2025-08-22 18:35:50 +02:00
parent d00b6b9edd
commit 9aa71dad30
19 changed files with 241 additions and 402 deletions

View File

@@ -3,21 +3,23 @@ import { createApiTokenValidator } from '#user/validators/token/create_api_token
import { deleteApiTokenValidator } from '#user/validators/token/delete_api_token';
import { inject } from '@adonisjs/core';
import { HttpContext } from '@adonisjs/core/http';
import { DateTime } from 'luxon';
@inject()
export default class ApiTokenController {
constructor(private apiTokenService: ApiTokenService) {}
async store({ request, response, auth }: HttpContext) {
async store({ request, response, auth, session }: HttpContext) {
const { name, expiresAt } = await request.validateUsing(
createApiTokenValidator
);
await this.apiTokenService.createToken({
user: auth.user!,
const token = await this.apiTokenService.createToken(auth.user!, {
name,
expiresAt: expiresAt ? DateTime.fromJSDate(expiresAt) : undefined,
expiresAt,
});
session.flash('token', {
...token.toJSON(),
token: token.value?.release(),
identifier: token.identifier,
});
return response.redirect().withQs().back();
}
@@ -26,7 +28,18 @@ export default class ApiTokenController {
const { params } = await request.validateUsing(deleteApiTokenValidator);
const tokenId = params.tokenId;
await this.apiTokenService.revokeToken(tokenId, auth.user!.id);
const token = await this.apiTokenService.getTokenByValue(
auth.user!,
tokenId
);
if (!token) {
return response.notFound();
}
await this.apiTokenService.revokeToken(
auth.user!,
Number(token.identifier)
);
return response.redirect().withQs().back();
}
}

View File

@@ -1,52 +0,0 @@
import AppBaseModel from '#core/models/app_base_model';
import User from '#user/models/user';
import { beforeSave, belongsTo, column } from '@adonisjs/lucid/orm';
import type { BelongsTo } from '@adonisjs/lucid/types/relations';
import { DateTime } from 'luxon';
import { randomBytes } from 'node:crypto';
export default class ApiToken extends AppBaseModel {
@column()
declare userId: number;
@column()
declare name: string;
@column()
declare token: string;
@column.dateTime()
declare lastUsedAt: DateTime | null;
@column.dateTime()
declare expiresAt: DateTime | null;
@column()
declare isActive: boolean;
@belongsTo(() => User, {
foreignKey: 'userId',
})
declare user: BelongsTo<typeof User>;
isExpired(): boolean {
if (!this.expiresAt) return false;
return DateTime.now() > this.expiresAt;
}
isValid(): boolean {
return this.isActive && !this.isExpired();
}
async markAsUsed(): Promise<void> {
this.lastUsedAt = DateTime.now();
await this.save();
}
@beforeSave()
static async generateToken(token: ApiToken) {
if (!token.token) {
token.token = randomBytes(32).toString('hex');
}
}
}

View File

@@ -3,8 +3,8 @@ import AppBaseModel from '#core/models/app_base_model';
import Link from '#links/models/link';
import { type DisplayPreferences } from '#shared/types/index';
import { ensureDisplayPreferences } from '#user/lib/index';
import ApiToken from '#user/models/api_token';
import type { GoogleToken } from '@adonisjs/ally/types';
import { DbAccessTokensProvider } from '@adonisjs/auth/access_tokens';
import { column, computed, hasMany } from '@adonisjs/lucid/orm';
import type { HasMany } from '@adonisjs/lucid/types/relations';
import { DateTime } from 'luxon';
@@ -44,11 +44,6 @@ export default class User extends AppBaseModel {
})
declare links: HasMany<typeof Link>;
@hasMany(() => ApiToken, {
foreignKey: 'userId',
})
declare apiTokens: HasMany<typeof ApiToken>;
@computed()
get fullname() {
return this.nickName || this.name;
@@ -70,4 +65,6 @@ export default class User extends AppBaseModel {
prepare: (value) => JSON.stringify(value),
})
declare displayPreferences: DisplayPreferences;
static accessTokens = DbAccessTokensProvider.forModel(User);
}

View File

@@ -1,54 +1,33 @@
import ApiToken from '#user/models/api_token';
import User from '#user/models/user';
import { DateTime } from 'luxon';
import { AccessToken } from '@adonisjs/auth/access_tokens';
type CreateApiTokenPayload = {
user: User;
type CreateTokenParams = {
name: string;
expiresAt?: DateTime;
expiresAt?: Date;
};
export class ApiTokenService {
async createToken({
user,
name,
expiresAt,
}: CreateApiTokenPayload): Promise<ApiToken> {
return await ApiToken.create({
userId: user.id,
createToken(user: User, { name, expiresAt }: CreateTokenParams) {
const expiresIn = expiresAt ? expiresAt.getTime() - Date.now() : undefined;
return User.accessTokens.create(user, undefined, {
name,
expiresAt,
isActive: true,
expiresIn,
});
}
async getUserTokens(userId: number): Promise<ApiToken[]> {
return await ApiToken.query()
.where('userId', userId)
.orderBy('created_at', 'desc');
getTokens(user: User) {
return User.accessTokens.all(user);
}
async revokeToken(tokenId: number, userId: number): Promise<void> {
const token = await ApiToken.query()
.where('id', tokenId)
.where('userId', userId)
.firstOrFail();
token.isActive = false;
await token.save();
revokeToken(user: User, identifier: number) {
return User.accessTokens.delete(user, identifier);
}
async validateToken(tokenString: string): Promise<ApiToken | null> {
const token = await ApiToken.query()
.where('token', tokenString)
.where('isActive', true)
.first();
validateToken(token: AccessToken) {
return User.accessTokens.verify(token.value!);
}
if (!token || !token.isValid()) {
return null;
}
await token.markAsUsed();
return token;
getTokenByValue(user: User, value: string) {
return User.accessTokens.find(user, value);
}
}

View File

@@ -3,7 +3,7 @@ import vine from '@vinejs/vine';
export const deleteApiTokenValidator = vine.compile(
vine.object({
params: vine.object({
tokenId: vine.number().positive(),
tokenId: vine.string().trim().minLength(1).maxLength(255),
}),
})
);

View File

@@ -8,10 +8,15 @@ export default class ShowUserSettingsController {
public async render({ auth, inertia }: HttpContext) {
const user = await auth.authenticate();
const tokens = await this.apiTokenService.getUserTokens(user.id);
const tokens = await this.apiTokenService.getTokens(user);
return inertia.render('user_settings/show', {
user,
tokens,
tokens: tokens.map((token) => {
return {
...token.toJSON(),
identifier: token.identifier,
};
}),
});
}
}

View File

@@ -15,6 +15,7 @@ export default defineConfig({
*/
sharedData: {
errors: (ctx) => ctx.session?.flashMessages.get('errors'),
token: (ctx) => ctx.session?.flashMessages.get('token'),
user: (ctx) => ({
theme: ctx.session?.get(KEY_USER_THEME, DEFAULT_USER_THEME),
}),

View File

@@ -1,35 +0,0 @@
import { defaultTableFields } from '#database/default_table_fields';
import { BaseSchema } from '@adonisjs/lucid/schema';
export default class CreateApiTokensTable extends BaseSchema {
static tableName = 'api_tokens';
async up() {
const exists = await this.schema.hasTable(CreateApiTokensTable.tableName);
if (exists) {
return console.warn(
`Table ${CreateApiTokensTable.tableName} already exists.`
);
}
this.schema.createTable(CreateApiTokensTable.tableName, (table) => {
table
.integer('user_id')
.unsigned()
.references('id')
.inTable('users')
.onDelete('CASCADE');
table.string('name', 255).notNullable();
table.string('token', 255).notNullable().unique();
table.timestamp('last_used_at').nullable();
table.timestamp('expires_at').nullable();
table.boolean('is_active').defaultTo(true).notNullable();
defaultTableFields(table);
});
}
async down() {
this.schema.dropTable(CreateApiTokensTable.tableName);
}
}

View File

@@ -0,0 +1,31 @@
import { BaseSchema } from '@adonisjs/lucid/schema';
export default class CreateAuthAccessTokensTable extends BaseSchema {
protected tableName = 'auth_access_tokens';
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id');
table
.integer('tokenable_id')
.notNullable()
.unsigned()
.references('id')
.inTable('users')
.onDelete('CASCADE');
table.string('type').notNullable();
table.string('name').nullable();
table.string('hash').notNullable();
table.text('abilities').notNullable();
table.timestamp('created_at');
table.timestamp('updated_at');
table.timestamp('last_used_at').nullable();
table.timestamp('expires_at').nullable();
});
}
async down() {
this.schema.dropTable(this.tableName);
}
}

View File

@@ -1,25 +1,33 @@
import { usePage } from '@inertiajs/react';
import {
ActionIcon,
Badge,
Button,
Card,
CopyButton,
Group,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import { modals } from '@mantine/modals';
import { DateTime } from 'luxon';
import { useTranslation } from 'react-i18next';
import { TbCheck, TbCopy, TbPlus, TbTrash } from 'react-icons/tb';
import { TbPlus, TbTrash } from 'react-icons/tb';
import { SimpleTable } from '~/components/common/simple_table/simple_table';
import { useApiTokens } from '~/hooks/use_api_tokens';
import { ApiToken } from '~/types/app';
import { CreateTokenModal } from './create_token_modal';
const useGetCreatedToken = () => {
const newlyCreatedToken = usePage<{
token?: ApiToken;
}>().props.token;
return newlyCreatedToken;
};
export function ApiTokens() {
const { t } = useTranslation();
const { tokens, createToken, revokeToken } = useApiTokens();
const newlyCreatedToken = useGetCreatedToken();
const handleCreateTokenModal = () => {
modals.open({
title: t('api-tokens.create-new'),
@@ -32,32 +40,67 @@ export function ApiTokens() {
});
};
const handleRevokeToken = async (tokenId: number, tokenName: string) => {
const handleRevokeToken = async (tokenId: number) => {
const token = tokens.find((t) => t.identifier === tokenId);
if (!token) return;
modals.openConfirmModal({
title: t('api-tokens.revoke'),
children: (
<Text size="sm">
{t('api-tokens.confirm-revoke')} <strong>{tokenName}</strong>?
</Text>
title: (
<>
{t('api-tokens.revoke')} "<strong>{token.name}</strong>"
</>
),
children: <Text size="sm">{t('api-tokens.confirm-revoke')}</Text>,
labels: {
confirm: t('api-tokens.revoke'),
cancel: t('common.cancel'),
cancel: t('cancel'),
},
confirmProps: { color: 'red' },
onConfirm: () => revokeToken(tokenId),
});
};
const formatDate = (dateString: string | null) => {
if (!dateString) return t('api-tokens.never');
return DateTime.fromISO(dateString).toRelative();
};
const generateTokenRow = (token: ApiToken) =>
newlyCreatedToken?.identifier === token.identifier && (
<>
<Text c="green" size="sm">
{t('api-tokens.new-token')}{' '}
{newlyCreatedToken.token && (
<CopyButton value={newlyCreatedToken.token}>
{({ copied, copy }) => (
<Button
color={copied ? 'teal' : 'blue'}
onClick={copy}
size="xs"
variant="light"
>
{copied ? t('copied') : t('copy')}
</Button>
)}
</CopyButton>
)}
</Text>
</>
);
const isExpired = (expiresAt: string | null) => {
if (!expiresAt) return false;
return DateTime.fromISO(expiresAt) < DateTime.now();
};
const generateRow = (token: ApiToken) => ({
key: token.identifier.toString(),
name: token.name,
token: generateTokenRow(token) || undefined,
expiresAt: token.expiresAt,
lastUsedAt: token.lastUsedAt,
actions: [
<ActionIcon
color="red"
variant="subtle"
onClick={() => handleRevokeToken(token.identifier)}
>
<TbTrash size={16} />
</ActionIcon>,
],
});
const rows = tokens.map(generateRow);
return (
<Card withBorder>
@@ -73,87 +116,13 @@ export function ApiTokens() {
</Button>
</Group>
<Stack gap="sm">
{tokens.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{t('api-tokens.no-tokens')}
</Text>
) : (
tokens.map((token) => (
<Card
key={token.id}
withBorder
p="sm"
opacity={token.isActive ? 1 : 0.5}
>
<Group justify="space-between" align="flex-start">
<Stack gap="xs" style={{ flex: 1 }}>
<Group gap="xs">
<Text fw={500}>{token.name}</Text>
{isExpired(token.expiresAt) && (
<Badge color="red" variant="light" size="xs">
{t('api-tokens.expired')}
</Badge>
)}
{!token.isActive && (
<Badge color="gray" variant="light" size="xs">
{t('api-tokens.revoked')}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed">
{t('api-tokens.created')}: {formatDate(token.createdAt)}
</Text>
<Text size="xs" c="dimmed">
{t('api-tokens.last-used')}: {formatDate(token.lastUsedAt)}
</Text>
{token.expiresAt && (
<Text size="xs" c="dimmed">
{t('api-tokens.expires')}: {formatDate(token.expiresAt)}
</Text>
)}
</Stack>
{token.isActive && (
<Group gap="xs">
<CopyButton value={token.token} timeout={2000}>
{({ copied, copy }) => (
<Tooltip
label={
copied
? t('api-tokens.copied')
: t('api-tokens.copy')
}
>
<ActionIcon
color={copied ? 'teal' : 'blue'}
onClick={copy}
variant="subtle"
>
{copied ? (
<TbCheck size={16} />
) : (
<TbCopy size={16} />
)}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
<Tooltip label={t('api-tokens.revoke')}>
<ActionIcon
color="red"
variant="subtle"
onClick={() => handleRevokeToken(token.id, token.name)}
>
<TbTrash size={16} />
</ActionIcon>
</Tooltip>
</Group>
)}
</Group>
</Card>
))
)}
</Stack>
{tokens.length === 0 && (
<Text c="dimmed" ta="center" py="xl">
{t('api-tokens.no-tokens')}
</Text>
)}
{tokens.length > 0 && <SimpleTable data={rows} />}
</Card>
);
}

View File

@@ -38,7 +38,7 @@ export function CreateTokenModal({ onCreate, onClose }: CreateTokenModalProps) {
/>
<Group justify="flex-end">
<Button variant="subtle" onClick={onClose}>
{t('common.cancel')}
{t('cancel')}
</Button>
<Button
onClick={handleCreate}

View File

@@ -0,0 +1,20 @@
.header {
position: sticky;
top: 0;
background-color: var(--mantine-color-body);
transition: box-shadow 150ms ease;
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
border-bottom: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-3));
}
}
.scrolled {
box-shadow: var(--mantine-shadow-sm);
}

View File

@@ -0,0 +1,56 @@
import { ScrollArea, Table, Text } from '@mantine/core';
import cx from 'clsx';
import { useState } from 'react';
import classes from './simple_table.module.css';
export type SimpleTableData = {
key: string;
[key: string]: string | React.ReactNode | undefined;
actions?: React.ReactNode[];
};
interface SimpleTableProps {
data: SimpleTableData[];
}
export function SimpleTable({ data }: SimpleTableProps) {
const [scrolled, setScrolled] = useState(false);
const columns = data.length > 0 ? Object.keys(data[0]) : [];
const rows = data.map((row) => {
return (
<Table.Tr key={row.key}>
{columns.map((column) => (
<Table.Td key={column}>
{row[column] ?? (
<Text c="dimmed" size="sm">
N/A
</Text>
)}
</Table.Td>
))}
</Table.Tr>
);
});
return (
<ScrollArea
h={300}
onScrollPositionChange={({ y }) => setScrolled(y !== 0)}
>
<Table miw={700}>
<Table.Thead
className={cx(classes.header, { [classes.scrolled]: scrolled })}
>
<Table.Tr>
{columns.map((column) => (
<Table.Th key={column}>{column}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</ScrollArea>
);
}

View File

@@ -1,29 +0,0 @@
.header {
height: rem(60px);
border-bottom: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.link {
display: flex;
align-items: center;
height: 100%;
padding-left: var(--mantine-spacing-md);
padding-right: var(--mantine-spacing-md);
text-decoration: none;
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
font-weight: 500;
font-size: var(--mantine-font-size-sm);
@media (max-width: $mantine-breakpoint-sm) {
height: rem(42px);
width: 100%;
}
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
}
}

View File

@@ -1,119 +0,0 @@
import PATHS from '#core/constants/paths';
import { Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import {
Box,
Burger,
Button,
Divider,
Drawer,
Group,
Image,
ScrollArea,
rem,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import ExternalLink from '~/components/common/external_link';
import { LocaleSwitcher } from '~/components/common/locale_switcher';
import { ThemeSwitcher } from '~/components/common/theme_switcher';
import useUser from '~/hooks/use_auth';
import classes from './mobile.module.css';
export default function Navbar() {
const { t } = useTranslation('common');
const { isAuthenticated } = useUser();
const [drawerOpened, { toggle: toggleDrawer, close: closeDrawer }] =
useDisclosure(false);
return (
<Box pb={40}>
<header className={classes.header}>
<Group justify="space-between" h="100%">
<Link href="/">
<Image src="/logo.png" h={35} alt="MyLinks's logo" />
</Link>
<Group h="100%" gap={0} visibleFrom="sm">
<Link href="/" className={classes.link}>
{t('home')}
</Link>
<ExternalLink href={PATHS.REPO_GITHUB} className={classes.link}>
Github
</ExternalLink>
<ExternalLink href={PATHS.EXTENSION} className={classes.link}>
Extension
</ExternalLink>
</Group>
<Group gap="xs">
<ThemeSwitcher />
<LocaleSwitcher />
{!isAuthenticated ? (
<Button
component="a"
href={route('auth').path}
visibleFrom="sm"
w={110}
>
{t('login')}
</Button>
) : (
<Button
component={Link}
href={route('dashboard').path}
visibleFrom="sm"
w={110}
>
Dashboard
</Button>
)}
<Burger
opened={drawerOpened}
onClick={toggleDrawer}
hiddenFrom="sm"
/>
</Group>
</Group>
</header>
<Drawer
opened={drawerOpened}
onClose={closeDrawer}
size="100%"
padding="md"
title="Navigation"
hiddenFrom="sm"
zIndex={1000000}
>
<ScrollArea h={`calc(100vh - ${rem(80)})`} mx="-md">
<Divider my="sm" />
<Link href="#" className={classes.link}>
{t('home')}
</Link>
<ExternalLink href={PATHS.REPO_GITHUB} className={classes.link}>
Github
</ExternalLink>
<ExternalLink href={PATHS.EXTENSION} className={classes.link}>
Extension
</ExternalLink>
<Divider my="sm" />
<Group justify="center" grow pb="xl" px="md">
{!isAuthenticated ? (
<Button component="a" href={route('auth').path} w={110}>
{t('login')}
</Button>
) : (
<Button component={Link} href={route('dashboard').path} w={110}>
Dashboard
</Button>
)}
</Group>
</ScrollArea>
</Drawer>
</Box>
);
}

View File

@@ -1,14 +1,5 @@
import { router, usePage } from '@inertiajs/react';
interface ApiToken {
id: number;
name: string;
token: string;
lastUsedAt: string | null;
expiresAt: string | null;
isActive: boolean;
createdAt: string;
}
import { ApiToken } from '~/types/app';
export function useApiTokens() {
const {

View File

@@ -114,6 +114,7 @@
"created": "Created",
"last-used": "Last used",
"expires": "Expires",
"never": "Never"
"never": "Never",
"new-token": "New token created"
}
}

View File

@@ -114,6 +114,7 @@
"created": "Créé",
"last-used": "Dernière utilisation",
"expires": "Expire",
"never": "Jamais"
"never": "Jamais",
"new-token": "Nouveau token créé"
}
}

View File

@@ -63,3 +63,13 @@ export enum Visibility {
PUBLIC = 'PUBLIC',
PRIVATE = 'PRIVATE',
}
export type ApiToken = {
identifier: number;
token: string | undefined;
name: string | null;
type: 'bearer';
lastUsedAt: string | null;
expiresAt: string | null;
abilities: string[];
};