mirror of
https://github.com/apricote/Listory.git
synced 2026-01-13 21:21:02 +00:00
feat(api): API tokens for authentication
Create and managed simple API tokens for access to the API from external tools.
This commit is contained in:
parent
eda89716ef
commit
8f7eebb806
15 changed files with 614 additions and 154 deletions
32
src/auth/api-token.entity.ts
Normal file
32
src/auth/api-token.entity.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from "typeorm";
|
||||
import { User } from "../users/user.entity";
|
||||
|
||||
@Entity()
|
||||
export class ApiToken {
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id: string;
|
||||
|
||||
@ManyToOne(() => User, { eager: true })
|
||||
user: User;
|
||||
|
||||
@Column()
|
||||
description: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
token: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" })
|
||||
lastUsedAt: Date;
|
||||
|
||||
@Column({ type: "timestamp", nullable: true })
|
||||
revokedAt: Date | null;
|
||||
}
|
||||
24
src/auth/api-token.repository.ts
Normal file
24
src/auth/api-token.repository.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/* eslint-disable max-classes-per-file */
|
||||
import { Repository, SelectQueryBuilder } from "typeorm";
|
||||
import { EntityRepository } from "../database/entity-repository";
|
||||
import { User } from "../users/user.entity";
|
||||
import { ApiToken } from "./api-token.entity";
|
||||
|
||||
export class ApiTokenScopes extends SelectQueryBuilder<ApiToken> {
|
||||
/**
|
||||
* `byUser` scopes the query to ApiTokens created by the user.
|
||||
* @param currentUser
|
||||
*/
|
||||
byUser(currentUser: User): this {
|
||||
return this.andWhere(`token."userId" = :userID`, {
|
||||
userID: currentUser.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@EntityRepository(ApiToken)
|
||||
export class ApiTokenRepository extends Repository<ApiToken> {
|
||||
get scoped(): ApiTokenScopes {
|
||||
return new ApiTokenScopes(this.createQueryBuilder("token"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +1,25 @@
|
|||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Post,
|
||||
Res,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags } from "@nestjs/swagger";
|
||||
import { ApiBody, ApiTags } from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
import { User } from "../users/user.entity";
|
||||
import { ApiToken } from "./api-token.entity";
|
||||
import { AuthSession } from "./auth-session.entity";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { COOKIE_REFRESH_TOKEN } from "./constants";
|
||||
import { AuthAccessToken } from "./decorators/auth-access-token.decorator";
|
||||
import { ReqUser } from "./decorators/req-user.decorator";
|
||||
import { CreateApiTokenRequestDto } from "./dto/create-api-token-request.dto";
|
||||
import { RefreshAccessTokenResponseDto } from "./dto/refresh-access-token-response.dto";
|
||||
import { RevokeApiTokenRequestDto } from "./dto/revoke-api-token-request.dto";
|
||||
import {
|
||||
RefreshTokenAuthGuard,
|
||||
SpotifyAuthGuard,
|
||||
|
|
@ -55,4 +61,30 @@ export class AuthController {
|
|||
|
||||
return { accessToken };
|
||||
}
|
||||
|
||||
@Post("api-tokens")
|
||||
@ApiBody({ type: CreateApiTokenRequestDto })
|
||||
@AuthAccessToken()
|
||||
async createApiToken(
|
||||
@ReqUser() user: User,
|
||||
@Body("description") description: string
|
||||
): Promise<ApiToken> {
|
||||
return this.authService.createApiToken(user, description);
|
||||
}
|
||||
|
||||
@Get("api-tokens")
|
||||
@AuthAccessToken()
|
||||
async listApiTokens(@ReqUser() user: User): Promise<ApiToken[]> {
|
||||
return this.authService.listApiTokens(user);
|
||||
}
|
||||
|
||||
// This endpoint does not validate that the token belongs to the logged in user.
|
||||
// Once the token is known, it does not matter which account makes the actual
|
||||
// request to revoke it.
|
||||
@Delete("api-tokens")
|
||||
@ApiBody({ type: RevokeApiTokenRequestDto })
|
||||
@AuthAccessToken()
|
||||
async revokeApiToken(@Body("token") token: string): Promise<void> {
|
||||
return this.authService.revokeApiToken(token);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,16 +5,18 @@ import { PassportModule } from "@nestjs/passport";
|
|||
import { CookieParserMiddleware } from "../cookie-parser";
|
||||
import { TypeOrmRepositoryModule } from "../database/entity-repository/typeorm-repository.module";
|
||||
import { UsersModule } from "../users/users.module";
|
||||
import { ApiTokenRepository } from "./api-token.repository";
|
||||
import { AuthSessionRepository } from "./auth-session.repository";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AccessTokenStrategy } from "./strategies/access-token.strategy";
|
||||
import { ApiTokenStrategy } from "./strategies/api-token.strategy";
|
||||
import { RefreshTokenStrategy } from "./strategies/refresh-token.strategy";
|
||||
import { SpotifyStrategy } from "./strategies/spotify.strategy";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmRepositoryModule.for([AuthSessionRepository]),
|
||||
TypeOrmRepositoryModule.for([AuthSessionRepository, ApiTokenRepository]),
|
||||
PassportModule.register({ defaultStrategy: "jwt" }),
|
||||
JwtModule.registerAsync({
|
||||
useFactory: (config: ConfigService) => ({
|
||||
|
|
@ -33,6 +35,7 @@ import { SpotifyStrategy } from "./strategies/spotify.strategy";
|
|||
SpotifyStrategy,
|
||||
AccessTokenStrategy,
|
||||
RefreshTokenStrategy,
|
||||
ApiTokenStrategy,
|
||||
],
|
||||
exports: [PassportModule],
|
||||
controllers: [AuthController],
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { JwtService } from "@nestjs/jwt";
|
|||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { User } from "../users/user.entity";
|
||||
import { UsersService } from "../users/users.service";
|
||||
import { ApiTokenRepository } from "./api-token.repository";
|
||||
import { AuthSession } from "./auth-session.entity";
|
||||
import { AuthSessionRepository } from "./auth-session.repository";
|
||||
import { AuthService } from "./auth.service";
|
||||
|
|
@ -15,6 +16,7 @@ describe("AuthService", () => {
|
|||
let usersService: UsersService;
|
||||
let jwtService: JwtService;
|
||||
let authSessionRepository: AuthSessionRepository;
|
||||
let apiTokenRepository: ApiTokenRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
|
|
@ -27,6 +29,7 @@ describe("AuthService", () => {
|
|||
{ provide: UsersService, useFactory: () => ({}) },
|
||||
{ provide: JwtService, useFactory: () => ({}) },
|
||||
{ provide: AuthSessionRepository, useFactory: () => ({}) },
|
||||
{ provide: ApiTokenRepository, useFactory: () => ({}) },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
|
@ -37,6 +40,7 @@ describe("AuthService", () => {
|
|||
authSessionRepository = module.get<AuthSessionRepository>(
|
||||
AuthSessionRepository
|
||||
);
|
||||
apiTokenRepository = module.get<ApiTokenRepository>(ApiTokenRepository);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
|
|
@ -45,6 +49,7 @@ describe("AuthService", () => {
|
|||
expect(usersService).toBeDefined();
|
||||
expect(jwtService).toBeDefined();
|
||||
expect(authSessionRepository).toBeDefined();
|
||||
expect(apiTokenRepository).toBeDefined();
|
||||
});
|
||||
|
||||
describe("spotifyLogin", () => {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@ import { ConfigService } from "@nestjs/config";
|
|||
import { JwtService } from "@nestjs/jwt";
|
||||
import { User } from "../users/user.entity";
|
||||
import { UsersService } from "../users/users.service";
|
||||
import { ApiToken } from "./api-token.entity";
|
||||
import { ApiTokenRepository } from "./api-token.repository";
|
||||
import { AuthSession } from "./auth-session.entity";
|
||||
import { AuthSessionRepository } from "./auth-session.repository";
|
||||
import { LoginDto } from "./dto/login.dto";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
|
|
@ -16,7 +19,8 @@ export class AuthService {
|
|||
private readonly config: ConfigService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly authSessionRepository: AuthSessionRepository
|
||||
private readonly authSessionRepository: AuthSessionRepository,
|
||||
private readonly apiTokenRepository: ApiTokenRepository
|
||||
) {
|
||||
this.userFilter = this.config.get<string>("SPOTIFY_USER_FILTER");
|
||||
this.sessionExpirationTime = this.config.get<string>(
|
||||
|
|
@ -66,7 +70,7 @@ export class AuthService {
|
|||
*/
|
||||
private async createRefreshToken(
|
||||
session: AuthSession
|
||||
): Promise<{ refreshToken }> {
|
||||
): Promise<{ refreshToken: string }> {
|
||||
const payload = {
|
||||
sub: session.user.id,
|
||||
name: session.user.displayName,
|
||||
|
|
@ -81,7 +85,9 @@ export class AuthService {
|
|||
return { refreshToken: token };
|
||||
}
|
||||
|
||||
async createAccessToken(session: AuthSession): Promise<{ accessToken }> {
|
||||
async createAccessToken(
|
||||
session: AuthSession
|
||||
): Promise<{ accessToken: string }> {
|
||||
if (session.revokedAt) {
|
||||
throw new ForbiddenException("SessionIsRevoked");
|
||||
}
|
||||
|
|
@ -101,6 +107,41 @@ export class AuthService {
|
|||
return this.authSessionRepository.findOneBy({ id });
|
||||
}
|
||||
|
||||
async createApiToken(user: User, description: string): Promise<ApiToken> {
|
||||
console.log("createApiToken");
|
||||
const apiToken = this.apiTokenRepository.create();
|
||||
|
||||
apiToken.user = user;
|
||||
apiToken.description = description;
|
||||
|
||||
// TODO demagic 20
|
||||
const tokenBuffer = await new Promise<Buffer>((resolve, reject) =>
|
||||
randomBytes(20, (err, buf) => (err ? reject(err) : resolve(buf)))
|
||||
);
|
||||
apiToken.token = `lis${tokenBuffer.toString("hex")}`;
|
||||
|
||||
await this.apiTokenRepository.save(apiToken);
|
||||
|
||||
return apiToken;
|
||||
}
|
||||
|
||||
async listApiTokens(user: User): Promise<ApiToken[]> {
|
||||
return this.apiTokenRepository.scoped.byUser(user).getMany();
|
||||
}
|
||||
|
||||
async revokeApiToken(token: string): Promise<void> {
|
||||
const apiToken = await this.findApiToken(token);
|
||||
|
||||
apiToken.revokedAt = new Date();
|
||||
await this.apiTokenRepository.save(apiToken);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async findApiToken(token: string): Promise<ApiToken> {
|
||||
return this.apiTokenRepository.findOneBy({ token });
|
||||
}
|
||||
|
||||
async findUser(id: string): Promise<User> {
|
||||
return this.usersService.findById(id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { applyDecorators, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiUnauthorizedResponse } from "@nestjs/swagger";
|
||||
import { AccessTokenAuthGuard } from "../guards/auth-strategies.guard";
|
||||
import { ApiAuthGuard } from "../guards/auth-strategies.guard";
|
||||
|
||||
export function AuthAccessToken() {
|
||||
return applyDecorators(
|
||||
UseGuards(AccessTokenAuthGuard),
|
||||
UseGuards(ApiAuthGuard),
|
||||
ApiBearerAuth(),
|
||||
ApiUnauthorizedResponse({ description: 'Unauthorized"' })
|
||||
ApiUnauthorizedResponse({ description: "Unauthorized" })
|
||||
);
|
||||
}
|
||||
|
|
|
|||
9
src/auth/dto/create-api-token-request.dto.ts
Normal file
9
src/auth/dto/create-api-token-request.dto.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class CreateApiTokenRequestDto {
|
||||
@ApiProperty({
|
||||
description: "Opaque text field to identify the API token",
|
||||
example: "My super duper token",
|
||||
})
|
||||
description: string;
|
||||
}
|
||||
9
src/auth/dto/revoke-api-token-request.dto.ts
Normal file
9
src/auth/dto/revoke-api-token-request.dto.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class RevokeApiTokenRequestDto {
|
||||
@ApiProperty({
|
||||
description: "The API Token that should be revoked",
|
||||
example: "lisasdasdjaksr2381asd",
|
||||
})
|
||||
token: string;
|
||||
}
|
||||
|
|
@ -2,7 +2,10 @@ import { AuthGuard } from "@nestjs/passport";
|
|||
import { AuthStrategy } from "../strategies/strategies.enum";
|
||||
|
||||
// Internal
|
||||
export const AccessTokenAuthGuard = AuthGuard(AuthStrategy.AccessToken);
|
||||
export const ApiAuthGuard = AuthGuard([
|
||||
AuthStrategy.AccessToken,
|
||||
AuthStrategy.ApiToken,
|
||||
]);
|
||||
export const RefreshTokenAuthGuard = AuthGuard(AuthStrategy.RefreshToken);
|
||||
|
||||
// Auth Provider
|
||||
|
|
|
|||
34
src/auth/strategies/api-token.strategy.ts
Normal file
34
src/auth/strategies/api-token.strategy.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
} from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { Strategy } from "passport-http-bearer";
|
||||
import { User } from "../../users/user.entity";
|
||||
import { AuthService } from "../auth.service";
|
||||
import { AuthStrategy } from "./strategies.enum";
|
||||
|
||||
@Injectable()
|
||||
export class ApiTokenStrategy extends PassportStrategy(
|
||||
Strategy,
|
||||
AuthStrategy.ApiToken
|
||||
) {
|
||||
constructor(private readonly authService: AuthService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async validate(token: string): Promise<User> {
|
||||
const apiToken = await this.authService.findApiToken(token);
|
||||
|
||||
if (!apiToken) {
|
||||
throw new UnauthorizedException("TokenNotFound");
|
||||
}
|
||||
|
||||
if (apiToken.revokedAt) {
|
||||
throw new ForbiddenException("TokenIsRevoked");
|
||||
}
|
||||
|
||||
return apiToken.user;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ export enum AuthStrategy {
|
|||
// Internal
|
||||
AccessToken = "access_token",
|
||||
RefreshToken = "refresh_token",
|
||||
ApiToken = "api_token",
|
||||
|
||||
// Auth Provider
|
||||
Spotify = "spotify",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue