diff --git a/.gitignore b/.gitignore index cbd84b1..d44e513 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,6 @@ test.sqlite3 !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json -!.vscode/extensions.json \ No newline at end of file +!.vscode/extensions.json + +.env.production diff --git a/package-lock.json b/package-lock.json index e282a37..9181738 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@nestjs/cqrs": "^10.2.5", "@nestjs/platform-express": "^10.0.0", "nestjs-cls": "^3.5.1", + "php-serialize": "^5.0.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1" }, @@ -8800,6 +8801,15 @@ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", "integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==" }, + "node_modules/php-serialize": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/php-serialize/-/php-serialize-5.0.1.tgz", + "integrity": "sha512-+uxULDruX7uwGZmC1HjcvQMg6APyK1wzWXnaRR0Vxb7Vk4YMn5/Chp9tm+ccNqmXOtfZ/oZVbR8GZnPnojltDw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", diff --git a/package.json b/package.json index 2ee196d..b164dfc 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@nestjs/cqrs": "^10.2.5", "@nestjs/platform-express": "^10.0.0", "nestjs-cls": "^3.5.1", + "php-serialize": "^5.0.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1" }, diff --git a/src/core/auth/AuthGuard.ts b/src/core/auth/AuthGuard.ts index 96a0072..74bf035 100644 --- a/src/core/auth/AuthGuard.ts +++ b/src/core/auth/AuthGuard.ts @@ -3,39 +3,53 @@ import { ClsService } from 'nestjs-cls'; import { CanActivate, ExecutionContext, - Inject, Injectable, UnauthorizedException, } from '@nestjs/common'; -import { ITokenVerifier, TokenVerifier } from '@sight/core/auth/ITokenVerifier'; - import { Message } from '@sight/constant/message'; +import { LaravelAuthnAdapter } from './LaravelAuthnAdapter'; +import { IRequester } from './IRequester'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { User } from '@sight/app/domain/user/model/User'; +import { EntityRepository } from '@mikro-orm/core'; +import { UserRole } from './UserRole'; @Injectable() export class AuthGuard implements CanActivate { constructor( - @Inject(TokenVerifier) - private readonly tokenVerifier: ITokenVerifier, + private readonly laravelAuthnAdapter: LaravelAuthnAdapter, private readonly clsService: ClsService, + + @InjectRepository(User) + private readonly userRepository: EntityRepository, ) {} - canActivate(context: ExecutionContext): boolean { + async canActivate(context: ExecutionContext): Promise { const req: Request = context.switchToHttp().getRequest(); - const authorizationHeader = req.headers['authorization']; + const rawSession = req.cookies['khlug_session']; - if (!authorizationHeader) { + if (!rawSession) { throw new UnauthorizedException(Message.TOKEN_REQUIRED); } - const token = authorizationHeader.split(' ')[1]; - if (!token) { - throw new UnauthorizedException(Message.TOKEN_REQUIRED); + const requesterUserId = + await this.laravelAuthnAdapter.authenticate(rawSession); + if (!requesterUserId) { + throw new UnauthorizedException(); } - const requester = this.tokenVerifier.verify(token); - req['requester'] = requester; + const user = await this.userRepository.findOne({ id: requesterUserId }); + if (!user) { + throw new UnauthorizedException(); + } + + const requester: IRequester = { + userId: requesterUserId, + role: user.manager ? UserRole.MANAGER : UserRole.USER, + }; + req['requester'] = requester; this.clsService.set('requester', requester); return true; diff --git a/src/core/auth/ITokenVerifier.ts b/src/core/auth/ITokenVerifier.ts deleted file mode 100644 index 766e0de..0000000 --- a/src/core/auth/ITokenVerifier.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IRequester } from '@sight/core/auth/IRequester'; - -export const TokenVerifier = Symbol('TokenVerifier'); - -export interface ITokenVerifier { - verify: (token: string) => IRequester; -} diff --git a/src/core/auth/LaravelAuthnAdapter.ts b/src/core/auth/LaravelAuthnAdapter.ts new file mode 100644 index 0000000..84b62b5 --- /dev/null +++ b/src/core/auth/LaravelAuthnAdapter.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import fs from 'fs/promises'; +import crypto from 'crypto'; +import { unserialize } from 'php-serialize'; + +import { LaravelSessionConfig } from '../config/LaravelSessionConfig'; + +type EncryptedSession = { + iv: string; + value: string; + mac: string; +}; + +type SessionData = { + _token: string; + _previous: { + url: string; + }; + _flash: { + old: any; + new: any; + }; + [key: `login_${string}`]: number | undefined; +}; + +@Injectable() +export class LaravelAuthnAdapter { + private readonly config: LaravelSessionConfig; + + constructor(configService: ConfigService) { + this.config = configService.getOrThrow('session'); + } + + async authenticate(rawSession: string): Promise { + const session = this.normalize(rawSession); + const decrypted = this.decrypt(session); + + const sessionId = unserialize(decrypted); + const serializedSessionData = await this.getSessionData(sessionId); + + const sessionData: SessionData = unserialize(serializedSessionData); + const userId = this.getUserId(sessionData); + + return userId; + } + + private normalize(session: string): EncryptedSession { + const urlDecoded = decodeURIComponent(session); + const base64Decoded = Buffer.from(urlDecoded, 'base64').toString('utf-8'); + return JSON.parse(base64Decoded); + } + + private decrypt(session: EncryptedSession): string { + const key = this.config.secret; + + const iv = Buffer.from(session.iv, 'base64'); + const value = Buffer.from(session.value, 'base64'); + + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); + const decrypted = Buffer.concat([decipher.update(value), decipher.final()]); + + const hash = crypto + .createHmac('sha256', key) + .update(session.iv + session.value) + .digest() + .toString('hex'); + + if (session.mac !== hash) { + throw new Error('Invalid MAC'); + } + + return decrypted.toString(); + } + + private async getSessionData(sessionId: string): Promise { + const path = `${this.config.storagePath}/${sessionId}`; + return await fs.readFile(path, { encoding: 'utf8' }); + } + + private getUserId(sessionData: SessionData): string | null { + const loginKey = Object.keys(sessionData).find((key) => + key.startsWith('login_'), + ); + return loginKey ? String(sessionData[loginKey]) : null; + } +} diff --git a/src/core/auth/UserRole.ts b/src/core/auth/UserRole.ts index f1043a1..a1c84c4 100644 --- a/src/core/auth/UserRole.ts +++ b/src/core/auth/UserRole.ts @@ -1,5 +1,5 @@ export const UserRole = { USER: 'USER', - ADMIN: 'ADMIN', + MANAGER: 'MANAGER', } as const; export type UserRole = (typeof UserRole)[keyof typeof UserRole]; diff --git a/src/core/config/LaravelSessionConfig.ts b/src/core/config/LaravelSessionConfig.ts new file mode 100644 index 0000000..a787163 --- /dev/null +++ b/src/core/config/LaravelSessionConfig.ts @@ -0,0 +1,10 @@ +export type LaravelSessionConfig = { + secret: string; + storagePath: string; +}; + +// 레거시 시스템과의 호환성을 위해 Laravel 세션을 사용합니다. +export const config = (): LaravelSessionConfig => ({ + secret: process.env.APP_KEY || '', + storagePath: process.env.SESSION_STORAGE_PATH || '', +}); diff --git a/src/core/config/index.ts b/src/core/config/index.ts index b4b0658..870d848 100644 --- a/src/core/config/index.ts +++ b/src/core/config/index.ts @@ -1,7 +1,10 @@ import * as db from '@sight/core/config/DatabaseConfig'; +import * as session from '@sight/core/config/LaravelSessionConfig'; export const configuration = (): { database: db.DatabaseConfig; + session: session.LaravelSessionConfig; } => ({ database: db.config(), + session: session.config(), });