1. 회원가입 & 로그인 프로세스
- 회원가입 이메일, 비밀번호 서버로 전달
- 비밀번호 암호화 후 이메일과 함께 데이터베이스 저장
- 로그인 이메일, 비밀번호 서버로 전달
- 이메일과 비밀번호 해시값 비교 검증
- 액세스 토큰 및 리프레시 토큰 생성
- 클라이언트에 토큰 전달
- 프라이빗 리소스 요청 및 토큰 재발급시 토큰 사용
1.1 회원가입 및 비밀번호 암호화
- 원본 비밀번호는 그 어디에도 저장하지 않는다. 서버가 해킹되더라도 비밀번호를 알 수 없게하기 위함이다
- 원본 비밀번호대신 암호화된 값을 데이터베이스에 저장한다
- 비밀번호는 절대 복호화가 안되고 같은 값에대해 항상 같은 결과를 반환하는 알고리즘을 사용해서 암호화한다
- 비밀번호를 비교할때는 입력된 비밀번호를 다시 암호화하고 암호화된 값이 같은지 비교한다
- bcrypt가 가장 많이 사용되는 비밀번호 알고리즘이다
1.2 Hashing
해싱(Hashing): 데이터를 고정된 크기의 고유한 값으로 변환하는 과정- 해싱 함수는 임의의 길이의 입력 데이터를 받아 고정된 길이의 해시 값을 생성한다.
- 이 해시 값은 원래 데이터를 대표하는 고유한 식별자로 사용될 수 있다.
- 해싱은 주로 데이터 무결성 검증, 비밀번호 저장, 데이터 검색 등에 사용된다.
1.2.1 주요 해싱 알고리즘
MD5: 128비트 해시 값을 생성하는 알고리즘. 빠르지만 충돌 가능성이 높아 보안에 취약.SHA-1: 160비트 해시 값을 생성하는 알고리즘. MD5보다 안전하지만 여전히 충돌 가능성이 존재.SHA-256: 256비트 해시 값을 생성하는 알고리즘. 현재 많이 사용되는 안전한 해싱 알고리즘 중 하나.
1.2.2 해싱의 주요 특징
- 고정된 길이 : 입력 데이터의 길이에 상관없이 항상 고정된 길이의 해시 값을 생성.
- 결정론적 : 동일한 입력 데이터는 항상 동일한 해시 값을 생성.
- 충돌 회피 : 서로 다른 입력 데이터가 동일한 해시 값을 생성할 확률이 매우 낮음.
- 단방향성 : 해시 값을 통해 원래 입력 데이터를 복원하는 것이 불가능.
1.2.3 예시
1const crypto = require('crypto')23// 해시 함수 사용 예제4const hash = crypto.createHash('sha256')5hash.update('password123')6const hashedPassword = hash.digest('hex')78console.log(hashedPassword) // 해시된 비밀번호 출력
1.2.4 Bcrypt
SHA256: 해싱이 빠르다, Salt를 필요로 하지 않는다Bcrypt: 해싱이 느리다, Salt를 요구한다
1.3 Dictionary Attack
사전 공격(Dictionary Attack)은 암호화된 비밀번호를 해독하기 위해 미리 준비된 단어 목록(사전)을 사용하는 공격 방법이다.- 이 공격은 주로 사용자가 쉽게 기억할 수 있는 일반적인 단어, 구문, 또는 흔히 사용되는 비밀번호를 대상으로 한다.
1.3.1 사전 공격의 과정
사전 준비: 공격자는 일반적으로 사용되는 비밀번호 목록을 준비한다.- 이 목록은 실제 사용자 비밀번호 데이터베이스 유출, 일반적인 단어 목록,
- 또는 비밀번호 생성 규칙을 기반으로 생성될 수 있다.
해시 생성: 준비된 목록의 각 단어에 대해 해시 값을 생성한다.비교: 생성된 해시 값을 목표 시스템의 해시 값과 비교하여 일치하는 항목을 찾는다.
1.3.2 방어 방법
강력한 비밀번호 정책: 사용자에게 길고 복잡한 비밀번호를 사용하도록 요구한다.비밀번호 해싱: 비밀번호를 저장할 때 안전한 해싱 알고리즘(SHA-256, bcrypt 등)을 사용한다.솔트(Salt) 추가: 해시를 생성할 때 각 비밀번호에 고유한 솔트를 추가하여 동일한 비밀번호라도 다른 해시 값을 생성하도록 한다.계정 잠금: 일정 횟수 이상 로그인 실패 시 계정을 잠그는 등의 보안 조치를 취한다.
1.3.3 예시
1const crypto = require('crypto')23// 비밀번호 해싱 예제4const salt = crypto.randomBytes(16).toString('hex')5const hash = crypto.pbkdf2Sync('password123', salt, 1000, 64, 'sha512').toString('hex')67// 비밀번호 'password123'에 솔트를 추가하고 해시 값을 생성8// 이를 통해 사전 공격에 대한 방어력을 높일 수 있다9console.log(`Salt: ${salt}`)10console.log(`Hash: ${hash}`)
1.4 Salt
솔트(Salt)는 비밀번호 해싱 과정에서 추가되는 임의의 데이터다.
- 솔트는 동일한 비밀번호라도 서로 다른 해시 값을 생성하도록 하여,
- 사전 공격(Dictionary Attack)이나 무차별 대입 공격(Brute Force Attack)을 방어하는 데 중요한 역할을 한다.
1.4.1 솔트의 주요 특징
고유성: 각 사용자마다 고유한 솔트를 생성하여 사용한다.임의성: 솔트는 예측할 수 없는 임의의 값이어야 한다.비밀번호 강화: 솔트를 추가함으로써 동일한 비밀번호라도 서로 다른 해시 값을 생성하게 되어, 해시 충돌을 방지한다.
1.4.2 예시
1const crypto = require('crypto')23// 비밀번호와 솔트를 사용한 해싱 예제4const password = 'password123'5const salt = crypto.randomBytes(16).toString('hex') // 임의의 솔트 생성6const hash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex')78console.log(`Salt: ${salt}`)9console.log(`Hash: ${hash}`)
- 이 예제에서는
crypto모듈을 사용하여 임의의 솔트를 생성하고, 비밀번호와 솔트를 결합하여 해시 값을 생성한다. - 이렇게 생성된 해시 값은 데이터베이스에 저장되며,
- 로그인 시 입력된 비밀번호를 동일한 방식으로 해싱하여 저장된 해시 값과 비교한다.
1.4.3 솔트의 장점
- 비밀번호 유추 방지: 동일한 비밀번호라도 각기 다른 해시 값을 생성하여, 해커가 비밀번호를 유추하기 어렵게 만든다.
- 사전 공격 방어: 미리 계산된 해시 값을 사용한 사전 공격을 방어할 수 있다.
- 무차별 대입 공격 방어: 해시 값을 예측할 수 없게 만들어 무차별 대입 공격을 어렵게 만든다.
솔트를 사용함으로써 비밀번호 보안이 크게 강화되며, 안전한 사용자 인증 시스템을 구축할 수 있다.
1.5 보안에 사용되는 토큰 종류
Basic Token: 사용자 정보를 보내는 데 사용된다Access Token: 프라이빗 리소스를 접 근하는데 사용된다Refresh Token: Access Token을 재 발급 받는데 사용된다
1.6 JWT
JWT(JSON Web Token): JSON 객체를 사용하여 두 개체 간에 정보를 안전하게 전송하기 위한 컴팩트하고 자가 포함된 방식- JWT는 주로 인증 및 정보 교환에 사용된다.
- JWT는 3 부분으로 구성된다: 헤더(Header), 페이로드(Payload), 서명(Signature).
1.5.1 JWT 구조
헤더(Header): 헤더는 토큰의 유형(JWT)과 해싱 알고리즘(예: HMAC SHA256)을 지정페이로드(Payload): 페이로드는 클레임(Claims)을 포함하며,- 클레임은 엔터티(일반적으로 사용자)와 추가 메타데이터에 대한 정보를 포함합니다
서명(Signature): 서명은 헤더와 페이로드를 인코딩한 후, 지정된 비밀 키를 사용하여 서명합니다
1.5.2 JWT 예시
JWT는 세 부분을 점(.)으로 구분하여 결합한 문자열입니다.
1eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.2eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.3SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
- 헤더:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 - 페이로드:
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9 - 서명:
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
2. Passport
- 모듈화된 인증 시스템 다양한 전략 (Strategy)를 쉽게 연결해서 사용 가능하다. 인증관련 작성할 코드가 많이 줄어든다
- 미들웨어 기반 디자인 요청, 응답 라이프사이클에 비파괴적 방식으로 통합된다
- 일반화된 가벼운 코어 패스포트 코어는 넓은 전략을 수용 할 수 있도록 가볍고 일반적으로 (Unopinonated) 설계됐다
- 세션 및 토큰 방식 사용 세션 기반과 토큰 기반의 인증 시스템 모두 사용 가능하다
- 방대한 생태계 다양한 오픈소스 전략들이 무료로 공개돼있다. 어려운 부분은 직접 코딩할 필요 없을 가능성이 높다
2.1 Passport 사용 방식
1import { Injectable } from '@nestjs/common'2import { PassportStrategy } from '@nestjs/passport'3import { Strategy } from 'passport-local'4import { AuthService } from './auth.service'56@Injectable()7export class LocalStrategy extends PassportStrategy(Strategy) {8constructor(private authService: AuthService) {9super()10}1112async validate(username: string, password: string) {13const user = await this.authService.validateUser(username, password)14if (!user) {15throw new UnauthorizedException()16}17return user18}19}
2.2 Passport 적용 방법
1@Controller('auth')2export class AuthController {3@UseGuards(AuthGuard('local'))4@Post('login')5async login(@Request() req) {6return req.user7}8}
3. IsPostMineOrAdmin 가드 생성
posts/guard/is-post-mine-or-admin.guard.ts 파일을 만든다.
posts/guard/is-post-mine-or-admin.guard.ts
1import {2BadRequestException,3CanActivate,4ExecutionContext,5ForbiddenException,6Injectable,7UnauthorizedException,8} from '@nestjs/common'910import { RolesEnum } from 'src/users/const/roles.const'11import { PostsService } from '../posts.service'12import { Request } from 'express'13import { UsersModel } from 'src/users/entity/users.entity'1415@Injectable()16export class IsPostMineOrAdminGuard implements CanActivate {17constructor(private readonly postService: PostsService) {}1819async canActivate(context: ExecutionContext): Promise<boolean> {20const req = context.switchToHttp().getRequest() as Request & {21user: UsersModel22}2324const { user } = req2526if (!user) {27throw new UnauthorizedException('사용자 정보를 가져올 수 없습니다.')28}2930// Admin일 경우 그냥 패스31if (user.role === RolesEnum.ADMIN) return true3233const postId = req.params.postId3435if (!postId) {36throw new BadRequestException('Post ID가 파라미터로 제공 돼야합니다.')37}3839const isOk = await this.postService.isPostMine(user.id, parseInt(postId))4041if (!isOk) throw new ForbiddenException('권한이 없습니다.')4243return true44}45}
posts 서비스에 관리자나 내가 작성한 글인지 확인하는 기능을 추가한다.
posts/posts.service.ts
1// posts.service.ts 생략2// **** 내 포스트인지 아닌지 확인3async isPostMine(userId: number, postId: number) {4return this.postsRepository.exist({5where: {6id: postId,7author: { id: userId },8},9relations: { author: true },10})11}
4. IsPostMineOrAdmin 적용하고 테스트
posts 컨트롤러에 포스트 수정에서 guard를 추가한다.
posts.controller.ts
1// **** (5) PATCH /posts/:id : id에 해당하는 post를 부분 변경2@Patch(':postId')3@UseGuards(IsPostMineOrAdminGuard)4patchPost(5@Param('postId', ParseIntPipe) id: number, //6@Body() body: UpdatePostDto,7) {8return this.postsService.updatePost(id, body)9}
5. IsCommentMineOrAdminGuard 생성 및 적용
posts/comments/guard/is-comment-mine-or-admin.guard.ts 파일을 만든다.
posts/comments/guard/is-comment-mine-or-admin.guard.ts
1import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'2import { Request } from 'express'3import { UsersModel } from 'src/users/entity/users.entity'4import { RolesEnum } from 'src/users/const/roles.const'5import { CommentsService } from '../comments.service'67@Injectable()8export class IsCommentMineOrAdminGuard implements CanActivate {9constructor(private readonly commentService: CommentsService) {}1011async canActivate(context: ExecutionContext): Promise<boolean> {12const req = context.switchToHttp().getRequest() as Request & {13user: UsersModel14}1516const { user } = req1718if (!user) {19throw new UnauthorizedException('사용자 정보를 가져올 수 없습니다.')20}2122if (user.role === RolesEnum.ADMIN) {23return true24}2526const commentId = req.params.commentId2728const isOk = await this.commentService.isCommentMine(user.id, parseInt(commentId))2930if (!isOk) {31throw new ForbiddenException('권한이 없습니다.')32}3334return true35}36}
comments 서비스에 내가 작성한 댓글인지 확인하는 코드를 추가한다.
comments.service.ts
1// **** 내가 작성한 댓글인지 확인2async isCommentMine(userId: number, commentId: number) {3return this.commentsRepository.exist({4where: {5id: commentId,6author: { id: userId },7},8relations: { author: true },9})10}
comments 컨트롤러에 댓글 삭제, 수정에서 guard를 추가한다.
comments.controller.ts
1// **** (4) 댓글 수정2@Patch(':commentId')3@UseGuards(IsCommentMineOrAdminGuard)4async patchComment() {} // 생략56// **** (5) 댓글 삭제7@Delete(':commentId')8@UseGuards(IsCommentMineOrAdminGuard)9async deleteComment() {} // 생략