feat(server): support access token (#13372)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced user access tokens, enabling users to generate, list, and
revoke personal access tokens via the GraphQL API.
* Added GraphQL mutations and queries for managing access tokens,
including token creation (with optional expiration), listing, and
revocation.
* Implemented authentication support for private API endpoints using
access tokens in addition to session cookies.

* **Bug Fixes**
  * None.

* **Tests**
* Added comprehensive tests for access token creation, listing,
revocation, expiration handling, and authentication using tokens.

* **Chores**
* Updated backend models, schema, and database migrations to support
access token functionality.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Yii
2025-07-31 13:55:10 +08:00
committed by GitHub
parent feb42e34be
commit 49e8f339d4
21 changed files with 564 additions and 9 deletions

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { AccessTokenResolver } from './resolver';
@Module({
providers: [AccessTokenResolver],
})
export class AccessTokenModule {}

View File

@@ -0,0 +1,73 @@
import {
Args,
Field,
InputType,
Mutation,
ObjectType,
Query,
Resolver,
} from '@nestjs/graphql';
import { Models } from '../../models';
import { CurrentUser } from '../auth/session';
@ObjectType()
class AccessToken {
@Field()
id!: string;
@Field()
name!: string;
@Field()
createdAt!: Date;
@Field(() => Date, { nullable: true })
expiresAt!: Date | null;
}
@ObjectType()
class RevealedAccessToken extends AccessToken {
@Field()
token!: string;
}
@InputType()
class GenerateAccessTokenInput {
@Field()
name!: string;
@Field(() => Date, { nullable: true })
expiresAt!: Date | null;
}
@Resolver(() => AccessToken)
export class AccessTokenResolver {
constructor(private readonly models: Models) {}
@Query(() => [AccessToken])
async accessTokens(@CurrentUser() user: CurrentUser): Promise<AccessToken[]> {
return await this.models.accessToken.list(user.id);
}
@Mutation(() => RevealedAccessToken)
async generateUserAccessToken(
@CurrentUser() user: CurrentUser,
@Args('input') input: GenerateAccessTokenInput
): Promise<RevealedAccessToken> {
return await this.models.accessToken.create({
userId: user.id,
name: input.name,
expiresAt: input.expiresAt,
});
}
@Mutation(() => Boolean)
async revokeUserAccessToken(
@CurrentUser() user: CurrentUser,
@Args('id') id: string
): Promise<boolean> {
await this.models.accessToken.revoke(id, user.id);
return true;
}
}

View File

@@ -19,7 +19,7 @@ import {
} from '../../base';
import { WEBSOCKET_OPTIONS } from '../../base/websocket';
import { AuthService } from './service';
import { Session } from './session';
import { Session, TokenSession } from './session';
const PUBLIC_ENTRYPOINT_SYMBOL = Symbol('public');
const INTERNAL_ENTRYPOINT_SYMBOL = Symbol('internal');
@@ -56,10 +56,7 @@ export class AuthGuard implements CanActivate, OnModuleInit {
throw new AccessDenied('Invalid internal request');
}
const userSession = await this.signIn(req, res);
if (res && userSession && userSession.expiresAt) {
await this.auth.refreshUserSessionIfNeeded(res, userSession);
}
const authedUser = await this.signIn(req, res);
// api is public
const isPublic = this.reflector.getAllAndOverride<boolean>(
@@ -71,14 +68,29 @@ export class AuthGuard implements CanActivate, OnModuleInit {
return true;
}
if (!userSession) {
if (!authedUser) {
throw new AuthenticationRequired();
}
return true;
}
async signIn(req: Request, res?: Response): Promise<Session | null> {
async signIn(
req: Request,
res?: Response
): Promise<Session | TokenSession | null> {
const userSession = await this.signInWithCookie(req, res);
if (userSession) {
return userSession;
}
return await this.signInWithAccessToken(req);
}
async signInWithCookie(
req: Request,
res?: Response
): Promise<Session | null> {
if (req.session) {
return req.session;
}
@@ -87,6 +99,10 @@ export class AuthGuard implements CanActivate, OnModuleInit {
const userSession = await this.auth.getUserSessionFromRequest(req, res);
if (userSession) {
if (res) {
await this.auth.refreshUserSessionIfNeeded(res, userSession.session);
}
req.session = {
...userSession.session,
user: userSession.user,
@@ -97,6 +113,25 @@ export class AuthGuard implements CanActivate, OnModuleInit {
return null;
}
async signInWithAccessToken(req: Request): Promise<TokenSession | null> {
if (req.token) {
return req.token;
}
const tokenSession = await this.auth.getTokenSessionFromRequest(req);
if (tokenSession) {
req.token = {
...tokenSession.token,
user: tokenSession.user,
};
return req.token;
}
return null;
}
}
/**

View File

@@ -264,6 +264,36 @@ export class AuthService implements OnApplicationBootstrap {
return session;
}
async getTokenSessionFromRequest(req: Request) {
const tokenHeader = req.headers.authorization;
if (!tokenHeader) {
return null;
}
const tokenValue = extractTokenFromHeader(tokenHeader);
if (!tokenValue) {
return null;
}
const token = await this.models.accessToken.getByToken(tokenValue);
if (token) {
const user = await this.models.user.get(token.userId);
if (!user) {
return null;
}
return {
token,
user: sessionUser(user),
};
}
return null;
}
async changePassword(
id: string,
newPassword: string

View File

@@ -1,5 +1,6 @@
import type { ExecutionContext } from '@nestjs/common';
import { createParamDecorator } from '@nestjs/common';
import { AccessToken } from '@prisma/client';
import { getRequestResponseFromContext } from '../../base';
import type { User, UserSession } from '../../models';
@@ -40,7 +41,8 @@ import type { User, UserSession } from '../../models';
// oxlint-disable-next-line no-redeclare
export const CurrentUser = createParamDecorator(
(_: unknown, context: ExecutionContext) => {
return getRequestResponseFromContext(context).req.session?.user;
const req = getRequestResponseFromContext(context).req;
return req.session?.user ?? req.token?.user;
}
);
@@ -61,3 +63,7 @@ export const Session = createParamDecorator(
export type Session = UserSession & {
user: CurrentUser;
};
export type TokenSession = AccessToken & {
user: CurrentUser;
};