인증(Authentication)은 서비스를 이용하려는 사용자가 인증된 신분을 가진 사람이 맞는지 검증하는 작업을 뜻한다. 일반적으로 로그인 기능에 해당한다.
반대로 인가(Authorization)는 이미 인증된 사용자가 특정 리소스에 접근하거나, 특정 작업을 수행할 수 있는 권한이 있는지 검증을 한다. 즉, 로그인 된 사용자가 게시글을 작성할 수 있는지 검증한다면, 이 과정을 인가라고 한다.
놀이동산을 예를 들면, 놀이동산에 들어가기 위해선 입장 티켓이 필요하다. 이 과정을 인증(Authentication)이라고 한다.
해당 티켓만 있다면 관람객은 기구를 마음껏 탈 수 있다. 하지만 놀이동산 관계자만 들어갈 수 있는 "관계자 외 출입금지" 장소에는 들어갈 수 없다. 이 과정을 인가(Authorization)라고 한다.
우리는 이런 인증/인가를 사용해 프로젝트를 진행할 것이다.
특히 인가의 경우, 사용자 인증 미들웨어를 통해 구현할 예정이다.
API 명세서
기능 | API URL | Method | Request Header | Request | Response | Response Header |
회원가입 | localhost:3018/api/sign-up | POST | { "email": "archepro84@gmail.com", "password": "4321aaaa", "name": "이용우", "age": 30, "gender": "MALE", "profileImage": "https://prismalens.vercel.app/header/logo-dark.svg" } |
{ "message": "회원가입이 완료되었습니다." } |
||
로그인 | localhost:3018/api/sign-in | POST | { "email":"archepro84@gmail.com", "password":"4321aaaa" } |
{ "message": "로그인 성공" } |
{ “authorization”: “Bearer eyJhbGciOiJIUzI1NiIsIn…” } |
|
사용자 조회 | localhost:3018/api/users | GET | { authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTY5MTA1NTc5OH0.2bM9s-Vv312rgkRzQiWLy4ASa0lWk5TOEGlEvNOa67k' } | { } | { "data": { "userId": 1, "email": "archepro84@gmail.com", "createdAt": "2024-01-03T13:57:48.058Z", "updatedAt": "2024-01-03T13:57:48.058Z", "userInfos": { "name": "이용우", "age": 30, "gender": "MALE", "profileImage": "https://prismalens.vercel.app/header/logo-dark.svg" } } } |
|
사용자 정보 변경 | localhost:3018/api/users | PATCH | { authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTY5MTA1NTc5OH0.2bM9s-Vv312rgkRzQiWLy4ASa0lWk5TOEGlEvNOa67k' } | { "name": "김범수", "age": 32, "gender": "MALE", "profileImage": "https://labs.mysql.com/common/logos/mysql-logo.svg?v2" } |
{ "message": "사용자 정보 변경에 성공하였습니다." } |
|
게시글 생성 | localhost:3018/api/posts/ | POST | { authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTY5MTA1NTc5OH0.2bM9s-Vv312rgkRzQiWLy4ASa0lWk5TOEGlEvNOa67k' } | { "title": "타이틀입니다.", "content": "내용입니다." } |
{ "data": { "postId": 1, "userId": 1, "title": "타이틀입니다.", "content": "내용입니다.", "createdAt": "2024-01-03T13:58:59.693Z", "updatedAt": "2024-01-03T13:58:59.693Z" } } |
|
게시글 목록 조회 | localhost:3018/api/posts/ | GET | { } | { "data": [ { "postId": 2, "title": "2번째 타이틀입니다.", "createdAt": "2024-01-03T14:00:33.365Z", "updatedAt": "2024-01-03T14:00:33.365Z" }, { "postId": 1, "title": "타이틀입니다.", "createdAt": "2024-01-03T13:58:59.693Z", "updatedAt": "2024-01-03T13:58:59.693Z" } ] } |
||
게시글 상세 조회 | localhost:3018/api/posts/:postId | GET | { } | { "data": { "postId": 1, "title": "타이틀입니다.", "content": "내용입니다.", "createdAt": "2024-01-03T13:58:59.693Z", "updatedAt": "2024-01-03T13:58:59.693Z" } } |
||
댓글 생성 | localhost:3018/api/posts/:postId/comments | POST | { authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTY5MTA1NTc5OH0.2bM9s-Vv312rgkRzQiWLy4ASa0lWk5TOEGlEvNOa67k' } | { "content": "댓글입니다." } |
{ "data": { "commentId": 1, "postId": 2, "userId": 1, "content": "댓글입니다.", "createdAt": "2024-01-03T14:01:06.294Z", "updatedAt": "2024-01-03T14:01:06.294Z" } } |
|
댓글 조회 | localhost:3018/api/posts/:postId/comments | GET | { } | { "data": [ { "commentId": 2, "postId": 2, "userId": 1, "content": "댓글입니다.", "createdAt": "2024-01-03T14:01:22.869Z", "updatedAt": "2024-01-03T14:01:22.869Z" }, { "commentId": 1, "postId": 2, "userId": 1, "content": "댓글입니다.", "createdAt": "2024-01-03T14:01:06.294Z", "updatedAt": "2024-01-03T14:01:06.294Z" } ] } |
Request Header은 JWT를 할당한 authorization를 전달한 것이다.
사용자 조회 부분을 보면 Request에서 아무런 데이터를 전달하지 않아도, 사용자의 자기 정보를 인증할 수 있는 방법을 통해서 구현할 예정이다. 최종적으로 반환되는 결과값 또한 사용자의 정보&해당 사용자가 연간 관계를 맺고 있는 사용자 정보 테이블에 있는 값 또한 함께 조회될 것이다.
asyncHandler
API를 구현하기 앞서, Express.js에서 비동기 함수(async function)를 라우트 핸들러로 사용할 때, 예기치 못한 에러 처리가 제대로 되지 않을 수 있는 문제가 있다.
이게 무슨 소리인가 하면, Express.js 4.x 버전에서는 async/await을 사용하는 라우트 핸들러에서 비동기 오류가 발생할 경우, 이 오류를 자동으로 처리해주지 않는다. 즉, 에러가 발생해도 Express의 전역 에러 처리 미들웨어까지 도달하지 않아서 서버가 뻗어버리거나 예기치 못한 동작을 할 수 있다.
아래 코드를 한번 살펴보자.
app.get('/example', async (req, res) => {
const data = await someAsyncOperation();
res.send(data);
});
만약 someAsyncOperation()에서 오류가 발생하면, 그 오류는 Express의 기본 오류 처리기로 전달되지 않고, 잡히지 않은 Promise의 실패(rejection)로 남게된다. 그 결과 서버가 뻗거나 오류 응답이 제대로 클라이언트로 전달되지 않을 수 있다.
그렇다면 어떻게 해야할까?
이 문제를 해결하는 방법으로는, 두 가지 방법이 있다.
- try - catch로 오류를 명시적으로 처리
비동기 함수 안에서 오류가 발생할 가능성이 있는 코드를 항상 try - catch로 감싸 직접 처리하는 것이다. 즉, 아래 코드처럼 next(error)를 호출하여 Express의 에러 처리 미들웨어로 오류를 넘겨야 한다.
app.get('/example', async (req, res, next) => {
try {
const data = await someAsyncOperation();
res.send(data);
} catch (error) {
next(error); // Express의 전역 에러 처리 미들웨어로 오류 전달
}
});
그러나 위 방법처럼 모든 모든 route 핸들러에 try - catch를 넣는 건 번거로운 일이다.
따라서 아래와 같은 방법을 사용하기도 한다.
- 비동기 핸들러를 자동으로 처리하는 래퍼 함수 사용
asyncHandler는 비동기 함수 (fn)을 감싸서 에러가 발생할 경우, catch 블록에서 Express의 에러 처리 미들웨어로 자동으로 전달하는 방법이다.
const asyncHandler = fn => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
app.get('/example', asyncHandler(async (req, res) => {
const data = await someAsyncOperation();
res.send(data);
}));
물론 Express 5.x부터는 이 문제가 공식적으로 해결될 예정이라고 한다.
그러나 Express 5.x는 아직 베타 버전이기 때문에, 현재 일반적으로 사용하는 Express 4.x에서는 수동으로 해결해야 한다.
- 결론!!
따라서 아래 코드를 추가해서, API를 작성할 곳에 연결을 한다.
// /handler/asyncHandler.js
export const asyncHandler = fn => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
회원가입 API
1. email, password, name, age, gender, profileImage를 body로 전달받는다.
2. 동일한 email을 가진 사용자가 있는지 확인한다.(동일한 email이 있으면 회원가입이 불가하다.)
3. Users 테이블에 email, password를 이용해 사용자를 생성한다.
4. UserInfos 테이블에 name, age, gender, profileImage를 이용해 사용자 정보를 생성한다.
회원가입 API는 사용자와 사용자 정보가 1:1 관계를 가진 걸 바탕으로 비즈니스 로직이 구현된다.
사용자 정보는 사용자가 존재하지 않을 경우, 생성될 수 없다. 따라서 전달받은 인자값을 바탕으로 사용자 → 사용자 정보 순서대로 회원가입을 진행해야 한다.
- 전체 코드
import express from 'express'
import { asyncHandler } from '../handler/asyncHandler.js'
import { prisma } from '../utils/prisma/index.js'
const router = express.Router();
// 회원가입 API
router.post('/sing-up', asyncHandler(async (req, res, next) => {
const {email, password, name, age, gender, profileImage} = req.body;
const isExistUser = await prisma.users.findFirst({
where: {
email
}
});
if(isExistUser)
return res.status(409).json({message: '이미 존재하는 이메일이다.'});
const user = await prisma.users.create({
data: {
email,
password
}
});
const userInfo = await prisma.usersInfos.create({
data: {
usersId: user.usersId, // 사용자의 usersId를 바탕으로 사용자 정보 usersId 생성
name,
age,
gender: gender.toUpperCase(),
profileImage
}
});
return res.status(201).json({message: '회원가입 완료!'});
}))
export default router;
그렇다면 만약, 사용자만 생성되고 사용자 정보 생성에는 실패하면 어떻게 될까?
현재는 Users 테이블에 사용자를 생성하고, UserInfos 테이블에 userId를 이용해 사용자의 정보를 저장한다.
하지만 사용자 정보 저장에 실패하게 될 경우, Users 테이블에는 사용자가 존재하지만, UserInfos 테이블의 사용자 정보는 저장되지 않은 상태로 남아있게 된다.
이런 문제를 해결하기 위해서 MySQL과 같은 RDBMS에서는 트랜잭션(Transaction)이라는 개념이 도입됐는데, Prisma 또한 동일하게 사용할 수 있다.(이 부분은 추후 다루게 될 예정이다.)
암호화
사용자의 비밀번호를 DB에 저장할 때, 보안을 위해 비밀번호를 평문으로 저장하지 않고 암호화하여 저장한다.
이때 사용하는 것이 bcrypt 모듈로, 해당 모듈은 입력 받은 데이터를 특정 암호화 알고리즘을 이용해 암호화 및 검증을 도와준다.
이렇게 변환된 암호는 단방향 암호화가 되어 원래의 비밀번호로는 복구할 수 없게 된다. 하지만 입력된 비밀번호가 암호화 된 비밀번호와 일치하는지 아닌지 비교는 가능하여, 입력된 비밀번호가 올바른지 아닌지 검증할 수 있게 된다.
설치는 다음과 같이 한다.
# yarn을 이용해 bcrypt를 설치합니다.
yarn add bcrypt
아래는 간단한 예시이다.
- 암호화
import bcrypt from 'bcrypt';
const password = 'Sparta'; // 사용자의 비밀번호
const saltRounds = 10; // salt를 얼마나 복잡하게 만들지 결정합니다.
// 'hashedPassword'는 암호화된 비밀번호 입니다.
const hashedPassword = await bcrypt.hash(password, saltRounds);
console.log(hashedPassword); //$2b$10$OOziCKNP/dH1jd.Wvc3JluZVm7H8WXR8oUmxUQ/cfdizQOLjCXoXa
- 복호화
import bcrypt from 'bcrypt';
const password = 'Sparta'; // 사용자가 입력한 비밀번호
const hashed = '$2b$10$OOziCKNP/dH1jd.Wvc3JluZVm7H8WXR8oUmxUQ/cfdizQOLjCXoXa'; // DB에서 가져온 암호화된 비밀번호
// 'result'는 비밀번호가 일치하면 'true' 아니면 'false'
const result = await bcrypt.compare(password, hashed);
console.log(result); // true
// 비밀번호가 일치하지 않다면, 'false'
const failedResult = await bcrypt.compare('FailedPassword', hashed);
console.log(failedResult); // false
해당 암호화를 이용해 회원가입 API를 수정하면 다음과 같다.
import bcrypt from 'bcrypt';
// 중략
// 회원가입 API
router.post('/sing-up', asyncHandler(async (req, res, next) => {
// 중략
// 사용자 비밀번호를 암호화합니다.
const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.users.create({
data: {
email,
password: hashedPassword
}
});
//중략
}))
로그인 API
1. email, password를 body로 전달받는다.
2. 전달 받은 email에 해당하는 사용자가 있는지 확인한다.
3. 전달 받은 password와 DB에 저장된 password를 bcrypt를 이용해 검증한다.
4. 로그인에 성공하면, 사용자에게 JWT를 발급한다.
해당 API에서는 bcrypt로 암호화된 사용자 비밀번호를 인증해야한다. 클라이언트가 제공한 비밀번호와 DB에 저장된 암호화 된 비밀번호가 일치하는지 검증할 것이다.
또한 클라이언트는 로그인 이후 요청부터 쿠키를 함께 보내면, 이를 통해 서버에서는 해당 사용자를 식별하고, 인증, 인가 과정을 거쳐, 다음부터는 로그인 된 사용자라는 걸 확인한 뒤에 모든 API를 사용할 수 있도록 특정 권한 같은 걸 검증하는 작업 또한 구현할 것이다.
- 전체 코드
import bcrypt from 'bcrypt'
import jwt from 'jsonwebtoken'
// 중략
// 로그인 API
router.post('/sing-in', asyncHandler(async (req, res, next) => {
const {email, password} = req.body;
const user = await prisma.users.findFirst({
where: {
email
}
})
if(!user)
return res.status(401).json({message: '사용자가 존재하지 않는다'});
// 비밀번호를 검증: 복호화를 할 때, compare라는 메서드를 사용한다.
if(!(await bcrypt.compare(password, user.password)))
return res.status(401).json({message: '비밀번호가 일치하지 않다.'});
// JWT 할당
// sing를 통해 삽입할 사용자의 정보를 저장한다.
const token = jwt.sign(
{ // 사용자가 가지고 있는 usersId를 저장
usersId: user.usersId
},
// 비밀키. jwt를 만들 때 사용되며,
// 다음부터는 jwt가 해당하는 키랑 정상적으로 확인하기 위해서는
// 아래와 동일한 문자열을 사용해야 한다.
'custom-secret-key'
);
// 클라이언트한테 쿠키 할당
// authorization라는 쿠키의 키를 갖을 것이고,
// 실제로 전달할 문자열은 token 정보를 전달해서,
// 실제 쿠키에다 authorization라는 쿠키 값을 전달할 것이다.
res.cookie('authorization', `Bearer ${token}`);
return res.status(200).json({message: '로그인 성공!'});
}));
위 코드의 해당 부분은 사용자 인증 과정에서 JWT토큰을 생성하고, 그 토큰을 쿠키에 저장해 클라이언트에게 전달하는 부분이다.
const token = jwt.sign(
{
usersId: user.usersId
},
'custom-secret-key'
);
res.cookie('authorization', `Bearer ${token}`);
좀 더 자세히 설명하자면, const token = jwt.sign(...) 은 JWT를 생성하는 코드이다.
JWT는 서버가 사용자에게 인증 정보를 제공하는 데 사용하는 JSON 기반의 토큰으로, 해당 토큰은 사용자 인증 후 클라이언트에게 제공되어, 클라이언트는 이후 요청 시 해당 토큰을 사용해 서버에 인증된 요청을 보낼 수 있다.
- 첫번째 인수
토큰에 포함할 정보를 객체로 전달한다. 해당 정보는 서버가 이후 토큰을 확인할 때 사용자를 식별하는 데 사용된다. - 두번째 인수
서명을 위한 비밀키. 해당 비밀키는 JWT를 생성할 때 서명에 사용되며, 나중에 토큰을 검증할 때도 이 키를 사용해 토큰의 무결성을 확인할 수 있다. 여기서는 'custom-secret-key'라는 문자열을 사용하고 있다.(실제로는 더 복잡하고 안전한 문자열이나 환경 변수로 관리되는 비밀키를 사용하는 게 좋다.)
JWT는 다음과 같은 세 부분으로 나누진다.(좀 더 자세한 건 이쪽으로...)
- Header: 토큰의 타입(JWT)과 서명 알고리즘(HS256 등)이 포함됩니다.
- Payload: 사용자 정보(여기서는 usersId와 같은 데이터)가 들어갑니다.
- Signature: 비밀키를 사용하여 서명된 값으로, 토큰이 변조되지 않았는지 검증하는 데 사용됩니다.
이렇게 생성된 token은 서명이 포함된 형태로 사용되며, 서버는 이 토큰을 나중에 요청받을 때 검증해 유효한 사용자인지 확인할 수 있다.
res.cookie('authorization', `Bearer ${token}`);은 클라이언트에게 JWT 토큰을 쿠키에 저장해서 전달하는 코드이다.
res.cookie()는 HTTP 응답에 쿠키를 설정해주는 함수로, 여기서 사용되는 쿠키 이름은 'authorization'이다. 따라서 해당 이름으로 클라이언트 측에 쿠키가 저장이 된다.
또한 쿠키의 실제 값은 앞서 생성한 token을 기반으로 하며, `Bearer ${token}` 형식으로 설정된다. 여기서 Bearer은 토큰 타입을 나타내며, 보통 Authorization 헤더에서 사용하는 인증 방식이다.
이렇게 설정된 쿠키는 클라이언트가 이후 요청을 보낼 때 자동으로 서버에 전달된다. 서버는 해당 쿠키의 JWT 토큰을 확인하여, 사용자가 유효한 인증을 받은 사용자인지 확인할 수 있다.
'프로젝트' 카테고리의 다른 글
24/09/17 - [실습] 나만의 게시판(4): 미들웨어(로그, 에러 처리) (0) | 2024.09.17 |
---|---|
24/09/13 - [실습] 나만의 게시판(3): 사용자 정보 조회 API(사용자 인증 미들웨어) (0) | 2024.09.13 |
24/09/12 - [실습] 나만의 게시판(1): DB 설계 (0) | 2024.09.12 |
24/09/10 - [실습] Prisma 게시글 (0) | 2024.09.10 |
24/09/06 - [실습] 할 일 메모 사이트(5): 유효성 검증 & 폴더 구조 확인 (0) | 2024.09.08 |