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,
};
}),
});
}
}