feat: Authentication with backend

This commit is contained in:
Aleksi Lassila
2024-03-27 01:02:28 +02:00
parent 7318a0fa99
commit a574b718f0
21 changed files with 257 additions and 74 deletions

4
backend/.gitignore vendored
View File

@@ -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
*.sqlite

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<UserDto> {
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,

View File

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

View File

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

View File

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

View File

@@ -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"]}}}}

View File

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

View File

@@ -1,8 +1,7 @@
<script lang="ts">
import I18n from './lib/components/Lang/I18n.svelte';
import { Route, Router } from 'svelte-navigator';
import { handleKeyboardNavigation, Selectable } from './lib/selectable';
import { onMount } from 'svelte';
import { handleKeyboardNavigation } from './lib/selectable';
import Container from './Container.svelte';
import BrowseSeriesPage from './lib/pages/BrowseSeriesPage.svelte';
import MoviesPage from './lib/pages/MoviesPage.svelte';
@@ -11,41 +10,57 @@
import SearchPage from './lib/pages/SearchPage.svelte';
import SeriesPage from './lib/pages/SeriesPage.svelte';
import Sidebar from './lib/components/Sidebar/Sidebar.svelte';
import { userStore } from './lib/stores/user.store';
import LoginPage from './lib/pages/LoginPage.svelte';
import { reiverrApi } from './lib/apis/reiverr/reiverrApi';
let mainContent: Selectable;
onMount(() => {
mainContent.focus();
});
reiverrApi
.getApi()
.GET('/user', {})
.then((res) => res.data)
.then((user) => userStore.set(user || null))
.catch(() => userStore.set(null));
</script>
<I18n />
<Container horizontal class="bg-stone-950 text-white flex flex-1 w-screen">
<Router>
<Sidebar />
{#if $userStore === undefined}
<div class="h-screen w-screen flex flex-col items-center justify-center">
<div class="flex items-center justify-center hover:text-inherit selectable rounded-sm mb-2">
<div class="rounded-full bg-amber-300 h-4 w-4 mr-2" />
<h1 class="font-display uppercase font-semibold tracking-wider text-xl">Reiverr</h1>
</div>
<div>Loading...</div>
</div>
{:else if $userStore === null}
<LoginPage />
{:else}
<Router>
<Sidebar />
<Container bind:container={mainContent} class="flex-1 flex flex-col min-w-0">
<Route path="/">
<BrowseSeriesPage />
</Route>
<Route path="movies">
<MoviesPage />
</Route>
<Route path="library">
<LibraryPage />
</Route>
<Route path="manage">
<ManagePage />
</Route>
<Route path="search">
<SearchPage />
</Route>
<Route path="series/:id" component={SeriesPage} />
<Route path="*">
<div>404</div>
</Route>
</Container>
</Router>
<Container class="flex-1 flex flex-col min-w-0">
<Route path="/">
<BrowseSeriesPage />
</Route>
<Route path="movies">
<MoviesPage />
</Route>
<Route path="library">
<LibraryPage />
</Route>
<Route path="manage">
<ManagePage />
</Route>
<Route path="search">
<SearchPage />
</Route>
<Route path="series/:id" component={SeriesPage} />
<Route path="*">
<div>404</div>
</Route>
</Container>
</Router>
{/if}
</Container>
<svelte:window on:keydown={handleKeyboardNavigation} />

View File

@@ -0,0 +1,11 @@
import type createClient from 'openapi-fetch';
export abstract class Api<Paths extends NonNullable<unknown>> {
protected abstract baseUrl: string;
protected abstract client: ReturnType<typeof createClient<Paths>>;
protected abstract isLoggedIn: boolean;
getApi() {
return this.client;
}
}

View File

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

View File

@@ -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<asd extends NonNullable<unknown>> extends Api<asd> {
protected baseUrl: string;
protected client: ReturnType<typeof createClient<paths>>;
protected isLoggedIn = false;
constructor(baseUrl: string) {
super();
this.baseUrl = baseUrl;
const token = get(authenticationToken);
this.client = createClient<paths>({
baseUrl: this.baseUrl,
...(token && {
headers: {
Authorization: 'Bearer ' + token
}
})
});
}
}
export const reiverrApi = new ReiverrApi<paths>('http://localhost:3000/api');

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import { reiverrApi } from '../apis/reiverr/reiverrApi';
import { authenticationToken } from '../stores/localstorage.store';
let name: string;
let password: string;
let error: string | undefined = undefined;
function handleLogin() {
reiverrApi
.getApi()
.POST('/auth', {
body: {
name,
password
}
})
.then((res) => {
if (res.error?.statusCode === 401) {
error = 'Invalid credentials. Please try again.';
} else if (res.error) {
error = res.error.message;
} else {
const token = res.data.accessToken;
authenticationToken.set(token);
window.location.reload();
}
})
.catch((err: Error) => {
error = err.name + ': ' + err.message;
});
}
</script>
<div class="flex flex-col">
{#if error}
<div class="text-red-300">{error}</div>
{/if}
<div>
Name: <input class="bg-stone-900" type="text" bind:value={name} />
</div>
<div>
Password: <input class="bg-stone-900" type="password" bind:value={password} />
</div>
<button on:click={handleLogin}>Submit</button>
</div>

View File

@@ -13,3 +13,7 @@ export function createLocalStorageStore<T>(key: string, defaultValue: T) {
}
export const skippedVersion = createLocalStorageStore<string | null>('skipped-version', null);
export const authenticationToken = createLocalStorageStore<string | null>(
'authentication-token',
null
);

View File

@@ -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<User | null>(undefined);