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:
Julian Tölle 2023-02-19 16:16:34 +01:00
parent eda89716ef
commit 8f7eebb806
15 changed files with 614 additions and 154 deletions

View 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;
}

View 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"));
}
}

View file

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

View file

@ -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],

View file

@ -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", () => {

View file

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

View file

@ -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" })
);
}

View 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;
}

View 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;
}

View file

@ -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

View 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;
}
}

View file

@ -2,6 +2,7 @@ export enum AuthStrategy {
// Internal
AccessToken = "access_token",
RefreshToken = "refresh_token",
ApiToken = "api_token",
// Auth Provider
Spotify = "spotify",