Node.js 애플리케이션을 위한 신속하고 직관적이며 간소화된 인증 솔루션을 찾는 과정에서 기능 저하 없이 신속한 구현이 요구되는 시나리오에 직면했습니다.
사용자 가입 및 로그인부터 잊어버린 비밀번호 관리, 사용자 데이터 업데이트, 계정 삭제까지 이러한 필수 사용자 상호 작용을 원활하게 탐색할 수 있는 포괄적인 솔루션을 찾았습니다.
따라서 내 기사에서는 인증 및 캐싱을 구현하기 위한 명확한 방법론을 통합하여 강력하고 효율적인 사용자 흐름을 보장하는 응집력 있는 접근 방식을 정확하게 제시하는 것을 목표로 합니다.
여기서는 기본적인 설치 절차와 모델 생성을 우회하고 인증 및 사용자 흐름의 복잡성을 직접적으로 살펴보겠습니다. 기사 전체에 구성 파일을 얻는 데 필요한 모든 링크가 포함되어 설정에 필요한 리소스에 원활하게 액세스할 수 있습니다.
이 구현을 위해 Knex, Express 및 Redis와 함께 Node.js 버전 20.11.1을 활용합니다. 또한 원활한 관리를 위해 Docker를 사용하여 컨테이너화되고 조정되는 PostgreSQL을 데이터베이스로 활용할 예정입니다.
우리 애플리케이션의 이름은 user-flow-boilerplate
입니다. 해당 폴더를 만들고 내부에서 npm init -y
실행하여 기본 package.json
생성해 보겠습니다.
{ "name": "user-flow-boilerplate", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC" }
초기 패키지.json
다음 단계는 필요한 종속성을 추가하는 것입니다.
종속성 : npm i -S bcrypt body-parser cors dotenv express jsonwebtoken knex pg redis validator
개발자 종속성 :
npm i -D @babel/core @babel/eslint-parser @babel/plugin-transform-class-properties @babel/plugin-transform-runtime @babel/preset-env @babel/preset-typescript @faker-js/faker @types/bcrypt @types/body-parser @types/cors @types/express @types/jest @types/jsonwebtoken @types/node @types/node-cron @types/validator @typescript-eslint/eslint-plugin @typescript-eslint/parser babel-jest cross-env eslint eslint-config-prettier eslint-plugin-prettier jest nodemon npm-run-all prettier ts-jest ts-loader ts-node tsconfig-paths tslint typescript webpack webpack-cli webpack-node-externals
애플리케이션을 빌드하고 실행할 스크립트를 추가합니다.
"scripts": { "start": "NODE_ENV=production node dist/bundle.js", "build": "NODE_ENV=production webpack --config webpack.config.js", "dev": "cross-env NODE_ENV=development && npm-run-all -p dev:*", "dev:build": "webpack --config webpack.config.js --watch", "dev:start": "nodemon --watch dist --exec node dist/bundle.js", "test": "NODE_ENV=test jest --config ./jest.config.js", "lint": "eslint ./src -c .eslintrc.json" },
애플리케이션을 원활하게 실행하려면 src
폴더를 만들고 그 안에 초기 진입점 파일인 index.ts
배치하는 것이 중요합니다.
require('dotenv').config(); import process from 'process'; import express from 'express'; import bodyParser from 'body-parser'; import cors from 'cors'; const app = express(); const PORT = process.env.PORT || 9999; app.use(bodyParser.json()); app.use(cors()); app.get('/api/v1/health', (req, res) => res.status(200).json({ message: 'OK' })); (async () => { try { app.listen(PORT, async () => { console.log(`Server is running on port ${PORT}`); }); } catch (error) { console.error('Failed to start server:', error); process.exit(1); } })();
진입점 파일
개발을 위해서는 typscript
, lint
, jest
, bable
, prettier
, nodemon
에 대한 설정이 필요합니다. 다음 기사에서 설명한 모든 파일은 Express에서 Postgres 및 Knex를 사용하여 Node.js 서버 만들기 입니다 .
모든 설정을 구성하고 진입점을 생성한 후 npm run dev
실행하면 서버가 시작되고 다음과 유사한 출력이 표시됩니다.
./src/index.ts 1.7 KiB [built] [code generated] external "dotenv" 42 bytes [built] [code generated] external "process" 42 bytes [built] [code generated] external "express" 42 bytes [built] [code generated] external "body-parser" 42 bytes [built] [code generated] external "cors" 42 bytes [built] [code generated] webpack 5.90.3 compiled successfully in 751 ms [nodemon] restarting due to changes... [nodemon] starting `node dist/bundle.js` Server is running on port 9999
다음으로 이동하세요.GET
요청을 추가하고 cmd + E
(Mac에서는 키가 OS에 따라 다름)를 누른 다음 이름을 health
로 지정합니다.
URL에 Enter를 추가합니다: {{BASE_URI}}/health
. BASE_URI
의 경우 컬렉션 전체에서 사용할 새 변수를 추가합니다: http://localhost:9999/api/v1
그런 다음 '보내기' 버튼을 클릭하면 응답 본문을 관찰할 수 있습니다.
{ "message": "OK" }
앞으로 나아가기 전에 데이터베이스를 가동하고 실행하는 것이 중요합니다. docker-compose
로 실행하여 이를 달성하겠습니다. 데이터베이스에 액세스하고 관리하려면 다음과 같은 다양한 개발 플랫폼을 활용할 수 있습니다.
개인적으로 나는 다음을 사용하는 것을 선호합니다.
필요한 키, 비밀번호 및 테스트 이름이 포함된 .env
파일이 필요합니다.
PORT=9999 WEB_HOST="localhost" # DB DB_HOST="localhost" DB_PORT=5432 DB_NAME="user_flow_boilerplate" DB_USER="username_123" DB_PASSWORD="SomeParole999" # User DEFAULT_PASSWORD="SomeParole999" JWT_SECRET="6f1d7e9b9ba56476ae2f4bdebf667d88eeee6e6c98c68f392ed39f7cf6e51c5a" # Test User TEST_EMAIL="[email protected]" TEST_USERNAME="test_username" TEST_PASSWORD="SomeParole999" # Redis REDIS_HOST="localhost" REDIS_PORT=6379 REDIS_DB=0 REDIS_PASSWORD="SomeParole999"
데이터베이스, Redis 연결 및 시드 테스트 값용 .env
두려워하지 마십시오. 보다 확실한 방식으로 설명하기 위해 JWT_SECRET
무작위로 생성했습니다. 이제 프로젝트 루트에 docker-compose.yml
파일을 생성해 보겠습니다.
version: '3.6' volumes: data: services: database: build: context: . dockerfile: postgres.dockerfile image: postgres:latest container_name: postgres environment: TZ: Europe/Madrid POSTGRES_DB: ${DB_NAME} POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASSWORD} networks: - default volumes: - data:/var/lib/postgresql/data ports: - "5432:5432" restart: unless-stopped redis: image: redis:latest container_name: redis command: redis-server --requirepass ${REDIS_PASSWORD} networks: - default ports: - "6379:6379" restart: unless-stopped
서비스가 포함된 docker-compose 파일
빠른 연결을 위해 Docker에서 두 가지 서비스를 가동할 예정입니다. 데이터베이스나 Redis에 빠르게 액세스할 수 있도록 이 프로세스를 간소화하여 데이터를 효율적으로 검색할 수 있게 되었습니다. 따라서 해당 서비스 docker-compose up
실행하고 docker ps
다음 출력을 볼 수 있어야 합니다.
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES e4bef95de1dd postgres:latest "docker-entrypoint.s…" About a minute ago Up About a minute 0.0.0.0:5432->5432/tcp postgres 365e3a68351a redis:latest "docker-entrypoint.s…" About a minute ago Up About a minute 0.0.0.0:6379->6379/tcp redis
이제 애플리케이션용 유형을 저장하는 src/@types/index.ts
파일을 생성해야 합니다.
export enum Role { Admin = 'admin', Blogger = 'blogger', } export type UserSession = { id: number; }; export type DatabaseDate = { created_at: Date; updated_at: Date; }; export type DefaultUserData = { role: Role; }; export interface User extends DatabaseDate { id: number; email: string; username: string; password: string; role: Role; }
서비스 유형
현재 연결, 마이그레이션 및 시드를 위해 프로젝트 및 데이터베이스 폴더의 루트에 knexfile.ts
있어야 합니다.
Express에서 Postgres 및 Knex를 사용하여 Node.js 서버 만들기 기사에서 해당 env 변수를 사용하는 데이터베이스로 사용자를 마이그레이션하고 시드하는 방법에 대한 매우 자세한 설명을 남겼습니다.
마이그레이션을 구체적으로 확인하여 우리가 같은 입장에 있는지 확인하고 싶습니다. 우리는 이미 서비스를 시작했으며 데이터베이스에 대한 연결을 확인할 수 있어야 합니다.
docker exec -it postgres psql -U username_123 user_flow_boilerplate
연결이 양호하면 psql
콘솔에 있게 됩니다. 좋아, 연결에 문제가 없다면 테이블을 그곳으로 마이그레이션할 수 있을 것입니다. knex migrate:latest
실행하세요. 그런 다음 데이터베이스 내의 users
테이블에 새로 추가된 열을 관찰해야 합니다.
가짜 데이터 knex seed:run
으로 시드하고 테이블을 다시 확인해 보겠습니다.
따라서 이제 우리는 데이터베이스를 조작하여 필요에 따라 사용자를 추가, 삭제 또는 업데이트할 수 있습니다.
마지막으로 설정과 준비를 잊어버리고 사용자 흐름에만 집중할 수 있습니다. 이를 위해서는 라우터를 생성해야 합니다. 해당 라우터에서 login
, logout
, signup
, delete_user
, update_user
작업을 처리해야 합니다.
이를 위해 src/routes/index.ts
에 다음 코드를 추가하세요:
import { Router } from 'express'; import { authRouter } from 'src/routes/authRouter'; import { healthController } from 'src/controllers/healthController'; import { sessionController } from 'src/controllers/sessionController'; import { authMiddleware } from 'src/middlewares/authMiddleware'; import { userRouter } from 'src/routes/userRouter'; export const router = Router({ mergeParams: true }); router.get('/health', healthController); router.use('/auth', authRouter); router.get('/session', authMiddleware, sessionController); router.use('/user', authMiddleware, userRouter); router.use((_, res) => { return res.status(404).json({ message: 'Not Found' }); });
경로 파일
보시다시피 처음에는 이미 확인한 /health
경로를 추가했습니다. 그러면 해당 경로를 거기에 적용하도록 진입점을 업데이트하겠습니다. 먼저 이전 get
제거합니다.
-> REMOVE -> app.get('/api/v1/health', (req, res) => res.status(200).json({ message: 'OK' }));
파일 맨 위에 추가하십시오.
import { router } from 'src/routes'; // ... app.use(cors()); app.use('/api/v1', router);
다음 코드를 사용하여 health
확인을 위한 첫 번째 컨트롤러 src/controllers/healthController.ts
를 만듭니다.
import { Request, Response } from 'express'; export const healthController = (_: Request, res: Response) => res.status(200).send('ok');
건강 관리자
이제 라우터로 돌아가서 경로에 더 추가해야 할 것이 무엇인지 확인해 보겠습니다. authRouter.ts
및 userRouter.ts
라는 두 개의 파일을 더 추가해야 합니다.
import { Router } from 'express'; import { signUpController } from 'src/controllers/auth/signUpController'; import { loginController } from 'src/controllers/auth/loginController'; export const authRouter = Router(); authRouter.post('/signup', signUpController); authRouter.post('/login', loginController);
인증 라우터
import { Router } from 'express'; import { updateUserController } from 'src/controllers/user/updateUserController'; import { deleteUserController } from 'src/controllers/user/deleteUserController'; import { logoutController } from 'src/controllers/user/logoutController'; import { updatePasswordController } from 'src/controllers/user/updatePasswordController'; export const userRouter = Router(); userRouter.patch('/', updateUserController); userRouter.delete('/', deleteUserController); userRouter.post('/logout', logoutController); userRouter.post('/update-password', updatePasswordController);
사용자 라우터
격리된 기능을 유지하기 위한 가독성과 책임을 위해 이 논리를 나누었습니다. 이러한 모든 경로에는 논리를 처리할 컨트롤러가 필요합니다.
인증 및 상태 경로에는 인증 미들웨어가 필요하지 않으므로 해당 경로는 보호되지 않지만 일치하는 항목이 없으면 상태 404가 발생합니다.
router.get('/health', healthController); router.use('/auth', authRouter);
이제 모든 경로가 결정되었으므로 사용자 모델을 설정해야 합니다.
저는 CRUD 메소드를 재사용할 사용자 모델의 기본 모델을 활용할 것입니다. 이전에 다른 강의에서 모델 생성을 다루었지만src/models/Model.ts
에서 생성
import { database } from 'root/database'; export abstract class Model { protected static tableName?: string; protected static get table() { if (!this.tableName) { throw new Error('The table name must be defined for the model.'); } return database(this.tableName); } public static async insert<Payload>(data: Payload): Promise<{ id: number; }> { const [result] = await this.table.insert(data).returning('id'); return result; } public static async updateOneById<Payload>( id: number, data: Payload ): Promise<{ id: number; }> { const [result] = await this.table.where({ id }).update(data).returning('id'); return result; } public static async delete(id: number): Promise<number> { return this.table.where({ id }).del(); } public static async findOneById<Result>(id: number): Promise<Result> { return this.table.where('id', id).first(); } public static async findOneBy<Payload, Result>(data: Payload): Promise<Result> { return this.table.where(data as string).first(); } }
기본 모델
기본 모델을 사용하면 동일한 폴더에 UserModel.ts
생성할 수 있어야 합니다.
import { Model } from 'src/models/Model'; import { Role, User, DefaultUserData } from 'src/@types'; export class UserModel extends Model { static tableName = 'users'; public static async create<Payload>(data: Payload) { return super.insert<Payload & DefaultUserData>({ ...data, role: data.role || Role.Blogger, }); } public static findByEmail(email: string): Promise<User | null> { return this.findOneBy< { email: string; }, User >({ email }); } public static findByUsername(username: string): Promise<User | null> { return this.findOneBy< { username: string; }, User >({ username }); } }
사용자 모델
사용자 모델에서는 페이로드에서 제공되지 않으면 기본적으로 role
만 설정합니다. 이제 모델이 준비되었으므로 컨트롤러와 미들웨어 내에서 모델을 활용할 수 있습니다.
Node.js 애플리케이션의 인증 미들웨어는 들어오는 요청을 인증하여 해당 요청이 유효하고 승인된 사용자로부터 오는지 확인하는 역할을 합니다.
일반적으로 들어오는 요청을 가로채고, 인증 토큰 또는 자격 증명을 추출하고, 이 경우 JWT(JSON 웹 토큰)와 같은 사전 정의된 인증 메커니즘에 대해 유효성을 확인합니다.
인증 프로세스가 성공하면 미들웨어는 요청-응답 주기에서 요청이 다음 핸들러로 진행되도록 허용합니다. 그러나 인증이 실패하면 적절한 HTTP 상태 코드(예: 401 Unauthorized)로 응답하고 선택적으로 오류 메시지를 제공합니다.
src/middlewares
폴더를 만들고 여기에 다음 코드를 사용하여 authMiddleware.ts
파일을 추가합니다.
import { jwt } from 'src/utils/jwt'; import { Redis } from 'src/redis'; import type { Request, Response, NextFunction } from 'express'; import type { UserSession } from 'src/@types'; export async function authMiddleware(req: Request, res: Response, next: NextFunction) { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; const JWT_SECRET = process.env.JWT_SECRET; if (!token) return res.sendStatus(401); if (!JWT_SECRET) { console.error('JWT_SECRET Not Found'); return res.sendStatus(500); } if (!token) return res.status(401).json({ error: 'Token not provided' }); try { const userSession = await jwt.verify<UserSession>(token); if (!userSession) { return res.sendStatus(401); } const storedToken = await Redis.getSession(userSession.id); if (!storedToken || storedToken !== token) { return res.sendStatus(401); } req.user = userSession; next(); } catch (error) { console.error('JWT_ERROR', error); return res.sendStatus(401); } }
인증 미들웨어 파일
인증 미들웨어는 요청 헤더에서 JWT 토큰을 추출하고, JWT 라이브러리를 사용하여 유효성을 확인하고, 토큰이 Redis에 저장된 토큰과 일치하는지 확인합니다.
토큰이 유효하고 저장된 토큰과 일치하는 경우 미들웨어는 요청 객체( req.user
)에 인증된 사용자 세션을 설정하고 next()
함수를 호출하여 제어를 다음 미들웨어 또는 경로 핸들러로 전달합니다. 그렇지 않으면 인증 실패를 나타내는 401 상태 코드로 응답합니다.
jwt의 유틸리티를 검토해 보겠습니다. 다음 코드를 사용하여 src/utils/jwt.ts
파일에 생성합니다.
require('dotenv').config(); import jsonwebtoken from 'jsonwebtoken'; const JWT_SECRET = process.env.JWT_SECRET as string; export const jwt = { verify: <Result>(token: string): Promise<Result> => { if (!JWT_SECRET) { throw new Error('JWT_SECRET not found in environment variables!'); } return new Promise((resolve, reject) => { jsonwebtoken.verify(token, JWT_SECRET, (error, decoded) => { if (error) { reject(error); } else { resolve(decoded as Result); } }); }); }, sign: (payload: string | object | Buffer): Promise<string> => { if (!JWT_SECRET) { throw new Error('JWT_SECRET not found in environment variables!'); } return new Promise((resolve, reject) => { try { resolve(jsonwebtoken.sign(payload, JWT_SECRET)); } catch (error) { reject(error); } }); }, };
JWT 유틸리티 파일
이 유틸리티는 Node.js 애플리케이션 내에서 JSON 웹 토큰을 처리하는 데 중요한 역할을 합니다. jwt
객체는 jsonwebtoken
라이브러리를 활용하여 JWT 서명 및 확인 기능을 내보냅니다. 이러한 기능은 애플리케이션에서 인증 메커니즘을 구현하는 데 필수적인 JWT의 생성 및 유효성 검사를 용이하게 합니다.
유틸리티는 JWT 처리 기능을 캡슐화하여 Node.js 애플리케이션 내에서 보안 인증 메커니즘을 보장하는 동시에 환경 변수 관리에 대한 모범 사례를 준수합니다.
데이터베이스, 캐시 및 메시지 브로커로 사용됩니다. 캐싱, 세션 관리, 실시간 분석, 메시징 대기열, 순위표 등을 포함한 다양한 사용 사례에 일반적으로 사용됩니다.
Redis에서 토큰을 확인하면 JWT 토큰에 대한 추가 보안 및 유효성 검사 계층 역할을 합니다. 설정으로 들어가 보겠습니다. 이를 위해 다음 코드를 사용하여 src/redis/index.ts
파일을 만듭니다.
require('dotenv').config({ path: '../../.env', }); import process from 'process'; import * as redis from 'redis'; const client = redis.createClient({ url: `redis://:${process.env.REDIS_PASSWORD}@${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`, }); client.on('error', error => console.error('Redis Client Error', error)); const connect = async () => { try { await client.connect(); console.log('Connected to Redis'); } catch (err) { console.error(`Could not connect to Redis: ${err}`); process.exit(1); } }; class Redis { public static setSession(userId: number, token: string) { if (!userId) throw new Error('userId is required'); if (!token) throw new Error('token is required'); try { return client.set(`session:${userId}`, token); } catch (error) { console.error(error); } } public static getSession(userId: number) { if (!userId) throw new Error('userId is required'); return client.get(`session:${userId}`); } public static deleteSession(userId: string) { if (!userId) throw new Error('userId is required'); try { return client.del(`session:${userId}`); } catch (error) { console.error(error); } } } export { client, connect, Redis };
Redis 세션 저장소
Redis에서는 사용자 세션 토큰을 저장하고 관리해 보겠습니다. 인증 미들웨어에서는 JWT 토큰의 진위 여부를 확인한 후 토큰이 존재하는지 확인하고 해당 사용자 세션에 대해 Redis에 저장된 토큰과 일치하는지 확인합니다. 이는 유효하고 승인된 사용자만 보호된 경로에 액세스할 수 있도록 하는 데 도움이 됩니다.
Redis는 사용자 세션 토큰을 유지하기 위한 키-값 저장소로 사용됩니다. 사용자가 로그인하거나 인증하면 해당 세션 토큰이 Redis에 저장됩니다. 이를 통해 후속 인증 확인 중에 세션 토큰을 효율적이고 빠르게 검색할 수 있습니다.
효율적인 세션 관리를 위해 auth 미들웨어에 Redis가 활용되며, Redis 관련 파일은 Redis 서버에 대한 구성 및 연결을 처리하고 애플리케이션의 다른 부분에서 Redis와 상호 작용하기 위한 기능을 제공합니다.
이 설정은 Redis에 저장되고 관리되는 사용자 세션 토큰을 통해 안전하고 안정적인 인증 메커니즘을 보장합니다.
마지막 부분은 진입점에서 Redis에 연결해야 한다는 것입니다.
// all imports import * as Redis from 'src/redis'; const app = express(); const PORT = process.env.PORT || 9999; // middlewares (async () => { try { await Redis.connect(); app.listen(PORT, async () => { console.log(`Server is running on port ${PORT}`); }); } catch (error) { console.error('Failed to start server:', error); process.exit(1); } })();
Redis에 연결
인증 준비가 완료되면 이제 컨트롤러로 초점을 전환할 수 있습니다.
경로의 컨트롤러는 문제를 분리하고 코드 유지 관리 가능성을 높여 애플리케이션의 논리를 구성하는 데 도움이 됩니다. 상태 확인을 위한 컨트롤러를 이미 만들었습니다. 다음으로 사용자와 함께 작업을 처리하기 위한 컨트롤러를 만드는 과정을 진행하겠습니다.
우리가 사용할 첫 번째 컨트롤러는 다음 코드를 사용하여 src/controllers
에 있어야 하는 sessionController.ts
입니다.
import { Request, Response } from 'express'; import { UserModel } from 'src/models/UserModel'; import type { User } from 'src/@types'; export const sessionController = async (req: Request, res: Response) => { if (!req.user) return res.sendStatus(401); try { const user = await UserModel.findOneById<User>(req.user.id); if (user) { return res.status(200).json(user); } else { return res.sendStatus(401); } } catch (error) { return res.sendStatus(500); } };
세션 컨트롤러
이 컨트롤러는 현재 인증된 사용자에 대한 정보를 검색하는 역할을 담당하는 세션 관련 엔드포인트를 처리하는 역할을 합니다. 다음과 같은 이유로 이 컨트롤러가 필요합니다.
사용자 세션 정보: 이 컨트롤러를 사용하면 애플리케이션이 사용자 프로필이나 기타 관련 데이터와 같은 사용자 세션에 대한 정보를 검색할 수 있습니다. 이 정보는 사용자 경험을 맞춤화하거나 사용자 프로필을 기반으로 개인화된 콘텐츠를 제공하는 데 유용할 수 있습니다.
인증 및 권한 부여: req.user
존재하는지 확인하여 컨트롤러는 인증된 사용자만 엔드포인트에 액세스할 수 있도록 보장합니다. 이는 인증 및 권한 부여 규칙을 적용하여 중요한 사용자 데이터에 권한이 있는 사용자만 액세스할 수 있도록 보장합니다.
사용자 프로필 검색: 컨트롤러는 데이터베이스( UserModel
사용)를 쿼리하여 세션 ID를 기반으로 사용자 정보를 검색합니다. 이를 통해 애플리케이션은 사용자별 데이터를 동적으로 가져와 각 사용자에게 맞춤화된 경험을 제공할 수 있습니다. 이 부분은 Redis 캐시로 확실히 개선될 수 있습니다.
import { Request, Response } from 'express'; import { UserModel } from 'src/models/UserModel'; import { Redis } from 'src/redis'; import type { User } from 'src/@types'; export const sessionController = async (req: Request, res: Response) => { if (!req.user) return res.sendStatus(401); try { const cachedProfile = await Redis.getSession(req.user.id); if (cachedProfile) { return res.status(200).json(JSON.parse(cachedProfile)); } else { const user = await UserModel.findOneById<User>(req.user.id); if (user) { await Redis.setSession(req.user.id, JSON.stringify(user), CACHE_EXPIRATION); return res.status(200).json(user); } else { return res.sendStatus(401); } } } catch (error) { console.error('Error retrieving user profile:', error); return res.sendStatus(500); } };
Redis 세트 세션이 포함된 세션 컨트롤러 파일
캐시 만료 시간을 초 단위로 지정하기 위해 상수 CACHE_EXPIRATION
정의합니다. 이 예에서는 3600초(1시간)로 설정되어 있습니다. 캐시된 데이터는 주기적으로 새로 고쳐지므로 오래된 데이터가 사용자에게 제공되는 것을 방지하고 캐시 내에서 데이터 무결성을 유지합니다.
애플리케이션에서 신규 사용자의 등록 프로세스를 관리하는 signUpController
생성을 진행하기 전에 스키마를 검토해 보겠습니다.
우리의 경우 데이터베이스에 있는 기존 이메일로 가입을 시도할 때 사용자의 존재 여부를 명시적으로 밝히지 않음으로써 사용자 개인정보 보호를 우선시합니다. 대신 Invalid email or password
라는 일반 메시지를 고객에게 알립니다.
이 접근 방식은 클라이언트가 기존 사용자에 대한 불필요한 정보를 공개하지 않고 유효한 자격 증명을 제출하도록 권장합니다.
이제 src/controllers/auth/signUpController.ts
만들고 다음 코드를 추가해 보겠습니다.
import bcrypt from 'bcrypt'; import { jwt } from 'src/utils/jwt'; import { Request, Response } from 'express'; import { validate } from 'src/helpers/validation/validate'; import { userSchema } from 'src/helpers/validation/schemas/userSchema'; import { UserModel } from 'src/models/UserModel'; import { Redis } from 'src/redis'; import type { User } from 'src/@types'; import { getRandomString } from 'src/utils/getRandomString'; type Payload = Omit<User, 'id' | 'created_at' | 'updated_at' | 'role'>; export async function signUpController(req: Request, res: Response) { const { email, password }: Payload = req.body; const validation = validate<Payload>(req.body, userSchema); if (!validation.isValid) { return res.status(400).send(`Invalid ${validation.invalidKey}`); } try { const user = await UserModel.findOneBy({ email }); if (user) { return res.status(400).json({ message: 'Invalid email or password' }); } const hashedPassword = (await bcrypt.hash(password, 10)) as string; const username = `${email.split('@')[0]}${getRandomString(5)}`; const createdUser = await UserModel.create<Payload>({ email, password: hashedPassword, username, }); const token = await jwt.sign({ id: createdUser.id, }); await Redis.setSession(createdUser.id, token); res.status(200).json({ token, }); } catch (error) { return res.sendStatus(500); } }
컨트롤러 가입
컨트롤러는 일반적으로 가입 양식에서 사용자의 이메일과 비밀번호가 포함된 요청을 받습니다. 사전 정의된 userSchema
에 대해 들어오는 데이터의 유효성을 검사하여 필요한 형식을 충족하는지 확인합니다.
유효성 검사가 성공적으로 통과하여 기존 사용자와 유효한 필드가 없음을 나타내는 경우 컨트롤러는 bcrypt.hash
사용하여 비밀번호를 해시하고 username
생성한 다음 UserModel.create
사용하여 사용자를 생성합니다.
마지막으로 jwt
사용하여 token
생성하고 Redis
에 session
데이터를 설정한 다음 token
사용자에게 다시 보냅니다.
이제 로그인 컨트롤러 생성에 집중해 보겠습니다. src/controllers/auth/loginController.ts
파일을 만듭니다.
require('dotenv').config({ path: '../../.env', }); import bcrypt from 'bcrypt'; import { Request, Response } from 'express'; import { jwt } from 'src/utils/jwt'; import { UserModel } from 'src/models/UserModel'; import { Redis } from 'src/redis'; export async function loginController(req: Request, res: Response) { const { email, password } = req.body; if (!email || !password) { return res.status(400).json({ message: 'Invalid email or password' }); } try { const user = await UserModel.findByEmail(email); if (user) { const isValidPassword = await bcrypt.compare(password, user.password); if (!isValidPassword) { return res.status(400).json({ message: 'Invalid email or password' }); } const token: string = await jwt.sign({ id: user.id, }); await Redis.setSession(user.id, token); res.status(200).json({ token }); } else { return res.status(400).json({ message: 'Invalid email or password' }); } } catch (error) { console.error(error); return res.sendStatus(500); } }
로그인 컨트롤러
기본적으로 제공된 필드의 유효성을 검사한 다음 사용자가 있는지 확인하는 것부터 시작합니다. 사용자가 발견되지 않으면 signupController
의 동작과 유사하게 Invalid email or password
메시지와 함께 400 상태 코드로 응답합니다.
사용자가 존재하는 경우 bcrypt.compare
사용하여 제공된 비밀번호와 데이터베이스에 저장된 해시된 비밀번호를 비교합니다.
비밀번호가 일치하지 않으면 '잘못된 이메일 또는 비밀번호'라는 익숙한 메시지로 응답합니다. 마지막으로 인증이 성공하면 토큰을 생성하고 Redis에서 세션을 설정한 다음 토큰을 클라이언트에 다시 보냅니다.
미들웨어에서 얻은 user_id의 존재 여부에 따라 달라지는 보호된 컨트롤러를 검토해 보겠습니다. 우리는 이러한 컨트롤러 내의 작업을 위해 이 user_id를 지속적으로 사용합니다. 요청에 authorization
헤더가 없는 경우 401
상태 코드로 응답해야 합니다.
const authHeader = req.headers['authorization'];
다음 코드를 사용하여 src/controllers/user/logoutController.ts
파일을 만듭니다.
import type { Request, Response } from 'express'; import { Redis } from 'src/redis'; export async function logoutController(req: Request, res: Response) { try { await Redis.deleteSession(req.user.id); return res.sendStatus(200); } catch (error) { return res.sendStatus(500); } }
로그아웃 컨트롤러
이 logoutController
는 시스템에서 사용자 로그아웃을 담당합니다. 요청을 받으면 Redis 클라이언트와 상호 작용하여 user.id
와 연결된 세션을 삭제합니다. 작업이 성공하면 200
상태 코드로 응답하여 성공적인 로그아웃을 나타냅니다.
그러나 이 과정에서 오류가 발생하면 500
상태 코드로 응답하여 내부 서버 오류를 알립니다.
다음으로 사용자 데이터 삭제에 대해 살펴보겠습니다.
src/controllers/user/deleteUserController.ts
만들고 다음 코드를 추가하세요.
import { Request, Response } from 'express'; import { UserModel } from 'src/models/UserModel'; import { Redis } from 'src/redis'; export const deleteUserController = async (req: Request, res: Response) => { const user_id = req.user.id; try { await Redis.deleteSession(user_id); await UserModel.delete(user_id); return res.sendStatus(200); } catch (error) { return res.sendStatus(500); } };
사용자 컨트롤러 삭제
요청이 수신되면 일반적으로 인증 미들웨어에서 얻은 요청 개체에서 사용자 ID를 추출합니다.
그런 다음 Redis 클라이언트를 사용하여 Redis에서 이 user_id
와 연결된 세션을 삭제합니다. 그런 다음 UserModel
의 delete
메소드를 호출하여 데이터베이스에서 사용자 데이터를 제거합니다.
세션과 사용자 데이터가 모두 성공적으로 삭제되면 성공적인 삭제를 나타내는 200
상태 코드로 응답합니다. 삭제 과정에서 오류가 발생하면 500
상태 코드로 응답하여 내부 서버 오류를 나타냅니다.
시스템에서 사용자 데이터를 업데이트하려면 src/controllers/user/updateUserController.ts
만들고 다음 코드를 파일에 추가하세요.
import { Request, Response } from 'express'; import { UserModel } from 'src/models/UserModel'; import { filterObject } from 'src/utils/filterObject'; type Payload = { first_name?: string; last_name?: string; username?: string; }; export const updateUserController = async (req: Request, res: Response) => { const { first_name, last_name, username } = req.body; const payload: Payload = filterObject({ first_name, last_name, username, }); try { const existingUserName = await UserModel.findByUsername(username); if (existingUserName) { return res.status(400).json({ error: 'Invalid username', }); } const updatedUser = await UserModel.updateOneById<typeof payload>(req.user.id, payload); res.status(200).json(updatedUser); } catch (error) { res.sendStatus(500); } };
사용자 컨트롤러 업데이트
요청을 받으면 요청 본문에서 first_name
, last_name
및 username
필드를 추출합니다. 다음으로, 유효한 필드만 페이로드에 포함되도록 filterObject
유틸리티 함수를 사용하여 이러한 필드를 필터링합니다.
그런 다음 제공된 username
데이터베이스에 이미 존재하는지 확인합니다. 그렇다면 컨트롤러는 400
상태 코드와 잘못된 username
나타내는 오류 메시지로 응답합니다. username
이 고유한 경우 컨트롤러는 UserModel
의 updateOneById
메서드를 사용하여 데이터베이스의 사용자 데이터 업데이트를 진행합니다.
업데이트가 성공하면 200
상태 코드와 업데이트된 사용자 데이터로 응답합니다. 업데이트 프로세스 중에 오류가 발생하면 컨트롤러는 500
상태 코드로 응답하여 내부 서버 오류를 나타냅니다.
마지막 단계는 비밀번호를 업데이트하는 것입니다. 이는 사용자 데이터를 업데이트하는 것과 거의 동일한 아이디어이지만 새 비밀번호를 해싱하는 것입니다. src/controllers/user/updatePasswordController.ts
목록에서 마지막 컨트롤러를 만들고 코드를 추가합니다.
import { Request, Response } from 'express'; import { UserModel } from 'src/models/UserModel'; import bcrypt from 'bcrypt'; export const updatePasswordController = async (req: Request, res: Response) => { try { const { password } = req.body; if (!password) return res.sendStatus(400); const hashedPassword = (await bcrypt.hash(password, 10)) as string; const user = await UserModel.updateOneById(req.user.id, { password: hashedPassword }); return res.status(200).json({ id: user.id }); } catch (error) { return res.sendStatus(500); } };
비밀번호 컨트롤러 업데이트
요청을 받으면 요청 본문에서 새 비밀번호를 추출합니다. 그런 다음 요청 본문에 비밀번호가 제공되었는지 확인합니다. 그렇지 않은 경우 400
상태 코드로 응답하여 잘못된 요청을 나타냅니다. 다음으로 솔트 인자 10의 bcrypt
라이브러리를 사용하여 새 비밀번호를 해시합니다.
그런 다음 해시된 비밀번호는 UserModel
의 updateOneById
메소드를 사용하여 데이터베이스에 안전하게 저장되고 user.id
와 연결됩니다. 비밀번호 업데이트가 성공하면 컨트롤러는 200
상태 코드와 사용자 ID가 포함된 JSON 개체로 응답합니다.
비밀번호 업데이트 프로세스 중 오류가 발생하는 경우 컨트롤러는 500
상태 코드로 응답하여 다른 컨트롤러와 마찬가지로 내부 서버 오류를 나타냅니다.
다음에서 검증 도우미 및 유틸리티를 검토하고 설정해야 합니다.
가입 엔드포인트를 확인해 보겠습니다.
분명히 알 수 있듯이 우리는 세션을 검색하기 위해 헤더에서 활용될 토큰을 얻었습니다.
우리는 헤더에 있는 인증 토큰을 서버로 보냈고, 그에 대한 응답으로 서버는 데이터베이스에서 검색된 사용자 데이터를 우리에게 제공했습니다.
보안 기능과 Redis 캐싱을 자유롭게 탐색하고 실험해 보세요. 기본 모델을 마련하면 비밀번호를 잊어버린 사용자를 위한 계정 복구와 같은 추가 기능을 자세히 알아볼 수 있습니다. 그러나 이 주제는 향후 기사를 위해 남겨두겠습니다.
확장 가능한 방식으로 라우팅 및 사용자 인증 흐름을 관리하는 것은 어려울 수 있습니다. 경로를 보호하기 위해 미들웨어를 구현했지만 서비스의 성능과 안정성을 향상시키는 데 사용할 수 있는 추가 전략이 있습니다.
오류 처리는 더 포괄적인 적용 범위가 필요한 중요한 측면으로 남아 있기 때문에 더 명확한 오류 메시지를 제공함으로써 사용자 경험이 더욱 향상됩니다. 그러나 우리는 기본 인증 흐름을 성공적으로 구현하여 사용자가 가입하고, 계정에 액세스하고, 세션 데이터를 검색하고, 사용자 정보를 업데이트하고, 계정을 삭제할 수 있도록 했습니다.
이번 여정을 통해 통찰력을 얻고 사용자 인증에 대한 귀중한 지식을 얻으셨기를 바랍니다.
여기에도 게시됨