From a574b718f066066b591cf143623b5c5d1fad28ca Mon Sep 17 00:00:00 2001 From: Aleksi Lassila Date: Wed, 27 Mar 2024 01:02:28 +0200 Subject: [PATCH] feat: Authentication with backend --- backend/.gitignore | 4 +- backend/package-lock.json | 10 +++ backend/package.json | 4 +- backend/src/auth/auth.controller.ts | 27 +++++--- backend/src/auth/auth.guard.ts | 9 ++- backend/src/auth/auth.module.ts | 2 +- backend/src/generate-openapi.ts | 3 +- backend/src/main.ts | 2 +- backend/src/user/user.controller.ts | 7 +- backend/src/user/user.dto.ts | 2 + backend/src/user/user.module.ts | 2 +- backend/src/user/user.providers.ts | 2 +- backend/swagger-spec.json | 1 - package.json | 4 +- src/App.svelte | 77 ++++++++++++--------- src/lib/apis/api.interface.ts | 11 +++ src/lib/apis/reiverr/reiverr.generated.d.ts | 77 ++++++++++++++++----- src/lib/apis/reiverr/reiverrApi.ts | 29 ++++++++ src/lib/pages/LoginPage.svelte | 48 +++++++++++++ src/lib/stores/localstorage.store.ts | 4 ++ src/lib/stores/user.store.ts | 6 ++ 21 files changed, 257 insertions(+), 74 deletions(-) delete mode 100644 backend/swagger-spec.json create mode 100644 src/lib/apis/api.interface.ts create mode 100644 src/lib/apis/reiverr/reiverrApi.ts create mode 100644 src/lib/pages/LoginPage.svelte create mode 100644 src/lib/stores/user.store.ts diff --git a/backend/.gitignore b/backend/.gitignore index 4be7918..5919f68 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,3 +1,5 @@ +swagger-spec.json + # compiled output /dist /node_modules @@ -55,4 +57,4 @@ pids # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json -*.sqlite \ No newline at end of file +*.sqlite diff --git a/backend/package-lock.json b/backend/package-lock.json index 77abd70..e9810d7 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@nanogiants/nestjs-swagger-api-exception-decorator": "^1.6.11", "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", "@nestjs/jwt": "^10.2.0", @@ -1610,6 +1611,15 @@ "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.14.2.tgz", "integrity": "sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==" }, + "node_modules/@nanogiants/nestjs-swagger-api-exception-decorator": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@nanogiants/nestjs-swagger-api-exception-decorator/-/nestjs-swagger-api-exception-decorator-1.6.11.tgz", + "integrity": "sha512-F2Jvj52BDFvKo0I5LFj+kSjwLQecqrs+ibDWokq6Xkod/wrT6gxGia1H/z7ENGk9XwwXfQL9rZt4W/+Vwp0ZhQ==", + "peerDependencies": { + "@nestjs/common": "^7.6.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/swagger": "^4.8.1 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "10.3.2", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.3.2.tgz", diff --git a/backend/package.json b/backend/package.json index f729698..fa70c6e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,9 +17,11 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "openapi:schema": "ts-node src/generate-openapi.ts" }, "dependencies": { + "@nanogiants/nestjs-swagger-api-exception-decorator": "^1.6.11", "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", "@nestjs/jwt": "^10.2.0", diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index bd10a7b..fab1254 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,22 +1,33 @@ import { Body, Controller, - Get, HttpCode, HttpStatus, Post, - UseGuards, - Request, + UnauthorizedException, } from '@nestjs/common'; import { AuthService } from './auth.service'; -import { AuthGuard } from './auth.guard'; +import { SignInDto } from '../user/user.dto'; +import { + ApiOkResponse, + ApiProperty, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { ApiException } from '@nanogiants/nestjs-swagger-api-exception-decorator'; + +export class SignInResponse { + @ApiProperty() + accessToken: string; +} @Controller('auth') export class AuthController { constructor(private authService: AuthService) {} @HttpCode(HttpStatus.OK) @Post() - async signIn(@Body() signInDto: { name: string; password: string }) { + @ApiOkResponse({ description: 'User found', type: SignInResponse }) + @ApiException(() => UnauthorizedException) + async signIn(@Body() signInDto: SignInDto) { const { token } = await this.authService.signIn( signInDto.name, signInDto.password, @@ -25,10 +36,4 @@ export class AuthController { accessToken: token, }; } - - @UseGuards(AuthGuard) - @Get('profile') - getProfile(@Request() req) { - return req.user; - } } diff --git a/backend/src/auth/auth.guard.ts b/backend/src/auth/auth.guard.ts index b6f6ed3..23d10f6 100644 --- a/backend/src/auth/auth.guard.ts +++ b/backend/src/auth/auth.guard.ts @@ -11,9 +11,12 @@ import { AccessTokenPayload } from './auth.service'; import { User } from '../user/user.entity'; import { UserService } from '../user/user.service'; -export const GetUser = createParamDecorator((data, req): User => { - return req.user; -}); +export const GetUser = createParamDecorator( + (data: unknown, ctx: ExecutionContext): User => { + const request = ctx.switchToHttp().getRequest(); + return request.user; + }, +); @Injectable() export class AuthGuard implements CanActivate { diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index cd44874..1ba4a24 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -3,7 +3,7 @@ import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { UserModule } from '../user/user.module'; import { JwtModule } from '@nestjs/jwt'; -import { JWT_SECRET } from 'src/consts'; +import { JWT_SECRET } from '../consts'; @Module({ imports: [ diff --git a/backend/src/generate-openapi.ts b/backend/src/generate-openapi.ts index e41c7c2..bbc1838 100644 --- a/backend/src/generate-openapi.ts +++ b/backend/src/generate-openapi.ts @@ -6,14 +6,13 @@ import * as fs from 'fs'; async function bootstrap() { const app = await NestFactory.create(AppModule); - app.setGlobalPrefix('api'); const config = new DocumentBuilder().build(); const document = SwaggerModule.createDocument(app, config, { deepScanRoutes: true, }); - SwaggerModule.setup('openapi', app, document); + SwaggerModule.setup('openapi', app, document, {}); fs.writeFileSync('./swagger-spec.json', JSON.stringify(document)); } bootstrap(); diff --git a/backend/src/main.ts b/backend/src/main.ts index 3a0af81..678b1da 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -7,7 +7,7 @@ import * as fs from 'fs'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.setGlobalPrefix('api'); - + app.enableCors(); const config = new DocumentBuilder().build(); const document = SwaggerModule.createDocument(app, config, { diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index 26b9e12..a31f6b3 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -14,6 +14,7 @@ import { AuthGuard, GetUser } from '../auth/auth.guard'; import { ApiNotFoundResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { CreateUserDto, UserDto } from './user.dto'; import { User } from './user.entity'; +import { ApiException } from '@nanogiants/nestjs-swagger-api-exception-decorator'; @ApiTags('user') @Controller('user') @@ -22,9 +23,11 @@ export class UserController { @UseGuards(AuthGuard) @Get() - @ApiNotFoundResponse({ description: 'User not found' }) @ApiOkResponse({ description: 'User found', type: UserDto }) + @ApiException(() => NotFoundException, { description: 'User not found' }) async getProfile(@GetUser() user: User): Promise { + console.log(user); + if (!user) { throw new NotFoundException(); } @@ -35,7 +38,7 @@ export class UserController { @UseGuards(AuthGuard) @Get(':id') @ApiOkResponse({ description: 'User found', type: UserDto }) - @ApiNotFoundResponse({ description: 'User not found' }) + @ApiException(() => NotFoundException, { description: 'User not found' }) async findById( @Param('id') id: string, @GetUser() callerUser: User, diff --git a/backend/src/user/user.dto.ts b/backend/src/user/user.dto.ts index 48344f8..71343f7 100644 --- a/backend/src/user/user.dto.ts +++ b/backend/src/user/user.dto.ts @@ -19,3 +19,5 @@ export class CreateUserDto extends PickType(User, [ ] as const) {} export class UpdateUserDto extends OmitType(User, ['id'] as const) {} + +export class SignInDto extends PickType(User, ['name', 'password'] as const) {} diff --git a/backend/src/user/user.module.ts b/backend/src/user/user.module.ts index 80ad9d5..530dc6b 100644 --- a/backend/src/user/user.module.ts +++ b/backend/src/user/user.module.ts @@ -1,8 +1,8 @@ import { Module } from '@nestjs/common'; import { UserService } from './user.service'; import { userProviders } from './user.providers'; -import { DatabaseModule } from 'src/database/database.module'; import { UserController } from './user.controller'; +import { DatabaseModule } from '../database/database.module'; @Module({ imports: [DatabaseModule], diff --git a/backend/src/user/user.providers.ts b/backend/src/user/user.providers.ts index cd5afad..93b4d1e 100644 --- a/backend/src/user/user.providers.ts +++ b/backend/src/user/user.providers.ts @@ -1,6 +1,6 @@ -import { DATA_SOURCE } from 'src/database/database.providers'; import { DataSource } from 'typeorm'; import { User } from './user.entity'; +import { DATA_SOURCE } from '../database/database.providers'; export const USER_REPOSITORY = 'USER_REPOSITORY'; diff --git a/backend/swagger-spec.json b/backend/swagger-spec.json deleted file mode 100644 index 44b27fb..0000000 --- a/backend/swagger-spec.json +++ /dev/null @@ -1 +0,0 @@ -{"openapi":"3.0.0","paths":{"/api/user":{"get":{"operationId":"UserController_getProfile","parameters":[],"responses":{"200":{"description":"User found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserDto"}}}},"404":{"description":"User not found"}},"tags":["user"]},"post":{"operationId":"UserController_create","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserDto"}}}},"responses":{"200":{"description":""}},"tags":["user"]}},"/api/user/{id}":{"get":{"operationId":"UserController_findById","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"User found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserDto"}}}},"404":{"description":"User not found"}},"tags":["user"]}},"/api/auth":{"post":{"operationId":"AuthController_signIn","parameters":[],"responses":{"200":{"description":""}}}},"/api/auth/profile":{"get":{"operationId":"AuthController_getProfile","parameters":[],"responses":{"200":{"description":""}}}},"/api":{"get":{"operationId":"AppController_getHello","parameters":[],"responses":{"200":{"description":""}}}}},"info":{"title":"","description":"","version":"1.0.0","contact":{}},"tags":[],"servers":[],"components":{"schemas":{"SonarrSettings":{"type":"object","properties":{"apiKey":{"type":"string"},"baseUrl":{"type":"string"},"qualityProfileId":{"type":"number"},"rootFolderPath":{"type":"string"},"languageProfileId":{"type":"number"}},"required":["apiKey","baseUrl","qualityProfileId","rootFolderPath","languageProfileId"]},"RadarrSettings":{"type":"object","properties":{"apiKey":{"type":"string"},"baseUrl":{"type":"string"},"qualityProfileId":{"type":"number"},"rootFolderPath":{"type":"string"}},"required":["apiKey","baseUrl","qualityProfileId","rootFolderPath"]},"JellyfinSettings":{"type":"object","properties":{"apiKey":{"type":"string"},"baseUrl":{"type":"string"},"userId":{"type":"string"}},"required":["apiKey","baseUrl","userId"]},"Settings":{"type":"object","properties":{"autoplayTrailers":{"type":"boolean"},"language":{"type":"string"},"animationDuration":{"type":"number"},"sonarr":{"$ref":"#/components/schemas/SonarrSettings"},"radarr":{"$ref":"#/components/schemas/RadarrSettings"},"jellyfin":{"$ref":"#/components/schemas/JellyfinSettings"}},"required":["autoplayTrailers","language","animationDuration","sonarr","radarr","jellyfin"]},"UserDto":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"isAdmin":{"type":"boolean"},"settings":{"$ref":"#/components/schemas/Settings"}},"required":["id","name","isAdmin","settings"]},"CreateUserDto":{"type":"object","properties":{"name":{"type":"string"},"password":{"type":"string"},"isAdmin":{"type":"boolean"}},"required":["name","password","isAdmin"]}}}} \ No newline at end of file diff --git a/package.json b/package.json index 7770910..cf01aa9 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", "test:unit": "vitest", "lint": "prettier --plugin-search-dir . --check . && eslint .", - "format": "prettier --plugin-search-dir . --write ." + "format": "prettier --plugin-search-dir . --write .", + "openapi:update": "npm run --prefix backend openapi:schema && npm run openapi:codegen", + "openapi:codegen": "openapi-typescript \"backend/swagger-spec.json\" -o src/lib/apis/reiverr/reiverr.generated.d.ts" }, "devDependencies": { "@jellyfin/sdk": "^0.8.2", diff --git a/src/App.svelte b/src/App.svelte index f7f9386..f9ed69b 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,8 +1,7 @@ - - + {#if $userStore === undefined} +
+
+
+

Reiverr

+
+
Loading...
+
+ {:else if $userStore === null} + + {:else} + + - - - - - - - - - - - - - - - - - - -
404
-
-
-
+ + + + + + + + + + + + + + + + + + +
404
+
+
+ + {/if} diff --git a/src/lib/apis/api.interface.ts b/src/lib/apis/api.interface.ts new file mode 100644 index 0000000..d97e42b --- /dev/null +++ b/src/lib/apis/api.interface.ts @@ -0,0 +1,11 @@ +import type createClient from 'openapi-fetch'; + +export abstract class Api> { + protected abstract baseUrl: string; + protected abstract client: ReturnType>; + protected abstract isLoggedIn: boolean; + + getApi() { + return this.client; + } +} diff --git a/src/lib/apis/reiverr/reiverr.generated.d.ts b/src/lib/apis/reiverr/reiverr.generated.d.ts index 1bc2890..704545d 100644 --- a/src/lib/apis/reiverr/reiverr.generated.d.ts +++ b/src/lib/apis/reiverr/reiverr.generated.d.ts @@ -5,20 +5,17 @@ export interface paths { - "/api/user": { + "/user": { get: operations["UserController_getProfile"]; post: operations["UserController_create"]; }; - "/api/user/{id}": { + "/user/{id}": { get: operations["UserController_findById"]; }; - "/api/auth": { + "/auth": { post: operations["AuthController_signIn"]; }; - "/api/auth/profile": { - get: operations["AuthController_getProfile"]; - }; - "/api": { + "/": { get: operations["AppController_getHello"]; }; } @@ -59,6 +56,18 @@ export interface components { isAdmin: boolean; settings: components["schemas"]["Settings"]; }; + CreateUserDto: { + name: string; + password: string; + isAdmin: boolean; + }; + SignInDto: { + name: string; + password: string; + }; + SignInResponse: { + accessToken: string; + }; }; responses: never; parameters: never; @@ -81,13 +90,26 @@ export interface operations { "application/json": components["schemas"]["UserDto"]; }; }; - /** @description User not found */ 404: { - content: never; + content: { + "application/json": { + /** @example 404 */ + statusCode: number; + /** @example Not Found */ + message: string; + /** @example Not Found */ + error?: string; + }; + }; }; }; }; UserController_create: { + requestBody: { + content: { + "application/json": components["schemas"]["CreateUserDto"]; + }; + }; responses: { 200: { content: never; @@ -107,23 +129,44 @@ export interface operations { "application/json": components["schemas"]["UserDto"]; }; }; - /** @description User not found */ 404: { - content: never; + content: { + "application/json": { + /** @example 404 */ + statusCode: number; + /** @example Not Found */ + message: string; + /** @example Not Found */ + error?: string; + }; + }; }; }; }; AuthController_signIn: { - responses: { - 200: { - content: never; + requestBody: { + content: { + "application/json": components["schemas"]["SignInDto"]; }; }; - }; - AuthController_getProfile: { responses: { + /** @description User found */ 200: { - content: never; + content: { + "application/json": components["schemas"]["SignInResponse"]; + }; + }; + 401: { + content: { + "application/json": { + /** @example 401 */ + statusCode: number; + /** @example Unauthorized */ + message: string; + /** @example Unauthorized */ + error?: string; + }; + }; }; }; }; diff --git a/src/lib/apis/reiverr/reiverrApi.ts b/src/lib/apis/reiverr/reiverrApi.ts new file mode 100644 index 0000000..4ecfa47 --- /dev/null +++ b/src/lib/apis/reiverr/reiverrApi.ts @@ -0,0 +1,29 @@ +import createClient from 'openapi-fetch'; +import type { paths } from './reiverr.generated'; +import { Api } from '../api.interface'; +import { authenticationToken } from '../../stores/localstorage.store'; +import { get } from 'svelte/store'; + +class ReiverrApi> extends Api { + protected baseUrl: string; + protected client: ReturnType>; + protected isLoggedIn = false; + + constructor(baseUrl: string) { + super(); + this.baseUrl = baseUrl; + + const token = get(authenticationToken); + + this.client = createClient({ + baseUrl: this.baseUrl, + ...(token && { + headers: { + Authorization: 'Bearer ' + token + } + }) + }); + } +} + +export const reiverrApi = new ReiverrApi('http://localhost:3000/api'); diff --git a/src/lib/pages/LoginPage.svelte b/src/lib/pages/LoginPage.svelte new file mode 100644 index 0000000..142efca --- /dev/null +++ b/src/lib/pages/LoginPage.svelte @@ -0,0 +1,48 @@ + + +
+ {#if error} +
{error}
+ {/if} + +
+ Name: +
+ +
+ Password: +
+ +
diff --git a/src/lib/stores/localstorage.store.ts b/src/lib/stores/localstorage.store.ts index dbdb7f7..dc8a39c 100644 --- a/src/lib/stores/localstorage.store.ts +++ b/src/lib/stores/localstorage.store.ts @@ -13,3 +13,7 @@ export function createLocalStorageStore(key: string, defaultValue: T) { } export const skippedVersion = createLocalStorageStore('skipped-version', null); +export const authenticationToken = createLocalStorageStore( + 'authentication-token', + null +); diff --git a/src/lib/stores/user.store.ts b/src/lib/stores/user.store.ts new file mode 100644 index 0000000..ba7bb44 --- /dev/null +++ b/src/lib/stores/user.store.ts @@ -0,0 +1,6 @@ +import { writable } from 'svelte/store'; +import type { components } from '../apis/reiverr/reiverr.generated'; + +export type User = components['schemas']['UserDto']; + +export const userStore = writable(undefined);