🎉 berenickt 블로그에 온 걸 환영합니다. 🎉
Back
NestJs
35-authorization(인증)

1. 회원가입 & 로그인 프로세스

  1. 회원가입 이메일, 비밀번호 서버로 전달
  2. 비밀번호 암호화 후 이메일과 함께 데이터베이스 저장
  3. 로그인 이메일, 비밀번호 서버로 전달
  4. 이메일과 비밀번호 해시값 비교 검증
  5. 액세스 토큰 및 리프레시 토큰 생성
  6. 클라이언트에 토큰 전달
  7. 프라이빗 리소스 요청 및 토큰 재발급시 토큰 사용

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 예시

1
const crypto = require('crypto')
2
3
// 해시 함수 사용 예제
4
const hash = crypto.createHash('sha256')
5
hash.update('password123')
6
const hashedPassword = hash.digest('hex')
7
8
console.log(hashedPassword) // 해시된 비밀번호 출력

1.2.4 Bcrypt

  • SHA256 : 해싱이 빠르다, Salt를 필요로 하지 않는다
  • Bcrypt : 해싱이 느리다, Salt를 요구한다

1.3 Dictionary Attack

  • 사전 공격(Dictionary Attack)은 암호화된 비밀번호를 해독하기 위해 미리 준비된 단어 목록(사전)을 사용하는 공격 방법이다.
  • 이 공격은 주로 사용자가 쉽게 기억할 수 있는 일반적인 단어, 구문, 또는 흔히 사용되는 비밀번호를 대상으로 한다.

1.3.1 사전 공격의 과정

  1. 사전 준비: 공격자는 일반적으로 사용되는 비밀번호 목록을 준비한다.
    • 이 목록은 실제 사용자 비밀번호 데이터베이스 유출, 일반적인 단어 목록,
    • 또는 비밀번호 생성 규칙을 기반으로 생성될 수 있다.
  2. 해시 생성: 준비된 목록의 각 단어에 대해 해시 값을 생성한다.
  3. 비교: 생성된 해시 값을 목표 시스템의 해시 값과 비교하여 일치하는 항목을 찾는다.

1.3.2 방어 방법

  • 강력한 비밀번호 정책: 사용자에게 길고 복잡한 비밀번호를 사용하도록 요구한다.
  • 비밀번호 해싱: 비밀번호를 저장할 때 안전한 해싱 알고리즘(SHA-256, bcrypt 등)을 사용한다.
  • 솔트(Salt) 추가: 해시를 생성할 때 각 비밀번호에 고유한 솔트를 추가하여 동일한 비밀번호라도 다른 해시 값을 생성하도록 한다.
  • 계정 잠금: 일정 횟수 이상 로그인 실패 시 계정을 잠그는 등의 보안 조치를 취한다.

1.3.3 예시

1
const crypto = require('crypto')
2
3
// 비밀번호 해싱 예제
4
const salt = crypto.randomBytes(16).toString('hex')
5
const hash = crypto.pbkdf2Sync('password123', salt, 1000, 64, 'sha512').toString('hex')
6
7
// 비밀번호 'password123'에 솔트를 추가하고 해시 값을 생성
8
// 이를 통해 사전 공격에 대한 방어력을 높일 수 있다
9
console.log(`Salt: ${salt}`)
10
console.log(`Hash: ${hash}`)

1.4 Salt

솔트(Salt)는 비밀번호 해싱 과정에서 추가되는 임의의 데이터다.

  • 솔트는 동일한 비밀번호라도 서로 다른 해시 값을 생성하도록 하여,
  • 사전 공격(Dictionary Attack)이나 무차별 대입 공격(Brute Force Attack)을 방어하는 데 중요한 역할을 한다.

1.4.1 솔트의 주요 특징

  • 고유성: 각 사용자마다 고유한 솔트를 생성하여 사용한다.
  • 임의성: 솔트는 예측할 수 없는 임의의 값이어야 한다.
  • 비밀번호 강화: 솔트를 추가함으로써 동일한 비밀번호라도 서로 다른 해시 값을 생성하게 되어, 해시 충돌을 방지한다.

1.4.2 예시

1
const crypto = require('crypto')
2
3
// 비밀번호와 솔트를 사용한 해싱 예제
4
const password = 'password123'
5
const salt = crypto.randomBytes(16).toString('hex') // 임의의 솔트 생성
6
const hash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex')
7
8
console.log(`Salt: ${salt}`)
9
console.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 구조

  1. 헤더(Header): 헤더는 토큰의 유형(JWT)과 해싱 알고리즘(예: HMAC SHA256)을 지정
  2. 페이로드(Payload) : 페이로드는 클레임(Claims)을 포함하며,
    • 클레임은 엔터티(일반적으로 사용자)와 추가 메타데이터에 대한 정보를 포함합니다
  3. 서명(Signature) : 서명은 헤더와 페이로드를 인코딩한 후, 지정된 비밀 키를 사용하여 서명합니다

1.5.2 JWT 예시

JWT는 세 부분을 점(.)으로 구분하여 결합한 문자열입니다.

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
2
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.
3
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
  • 헤더: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
  • 페이로드: eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
  • 서명: SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

2. Passport

  • 모듈화된 인증 시스템 다양한 전략 (Strategy)를 쉽게 연결해서 사용 가능하다. 인증관련 작성할 코드가 많이 줄어든다
  • 미들웨어 기반 디자인 요청, 응답 라이프사이클에 비파괴적 방식으로 통합된다
  • 일반화된 가벼운 코어 패스포트 코어는 넓은 전략을 수용 할 수 있도록 가볍고 일반적으로 (Unopinonated) 설계됐다
  • 세션 및 토큰 방식 사용 세션 기반과 토큰 기반의 인증 시스템 모두 사용 가능하다
  • 방대한 생태계 다양한 오픈소스 전략들이 무료로 공개돼있다. 어려운 부분은 직접 코딩할 필요 없을 가능성이 높다

2.1 Passport 사용 방식

1
import { Injectable } from '@nestjs/common'
2
import { PassportStrategy } from '@nestjs/passport'
3
import { Strategy } from 'passport-local'
4
import { AuthService } from './auth.service'
5
6
@Injectable()
7
export class LocalStrategy extends PassportStrategy(Strategy) {
8
constructor(private authService: AuthService) {
9
super()
10
}
11
12
async validate(username: string, password: string) {
13
const user = await this.authService.validateUser(username, password)
14
if (!user) {
15
throw new UnauthorizedException()
16
}
17
return user
18
}
19
}

2.2 Passport 적용 방법

1
@Controller('auth')
2
export class AuthController {
3
@UseGuards(AuthGuard('local'))
4
@Post('login')
5
async login(@Request() req) {
6
return req.user
7
}
8
}

3. IsPostMineOrAdmin 가드 생성

posts/guard/is-post-mine-or-admin.guard.ts 파일을 만든다.

posts/guard/is-post-mine-or-admin.guard.ts
1
import {
2
BadRequestException,
3
CanActivate,
4
ExecutionContext,
5
ForbiddenException,
6
Injectable,
7
UnauthorizedException,
8
} from '@nestjs/common'
9
10
import { RolesEnum } from 'src/users/const/roles.const'
11
import { PostsService } from '../posts.service'
12
import { Request } from 'express'
13
import { UsersModel } from 'src/users/entity/users.entity'
14
15
@Injectable()
16
export class IsPostMineOrAdminGuard implements CanActivate {
17
constructor(private readonly postService: PostsService) {}
18
19
async canActivate(context: ExecutionContext): Promise<boolean> {
20
const req = context.switchToHttp().getRequest() as Request & {
21
user: UsersModel
22
}
23
24
const { user } = req
25
26
if (!user) {
27
throw new UnauthorizedException('사용자 정보를 가져올 수 없습니다.')
28
}
29
30
// Admin일 경우 그냥 패스
31
if (user.role === RolesEnum.ADMIN) return true
32
33
const postId = req.params.postId
34
35
if (!postId) {
36
throw new BadRequestException('Post ID가 파라미터로 제공 돼야합니다.')
37
}
38
39
const isOk = await this.postService.isPostMine(user.id, parseInt(postId))
40
41
if (!isOk) throw new ForbiddenException('권한이 없습니다.')
42
43
return true
44
}
45
}

posts 서비스에 관리자나 내가 작성한 글인지 확인하는 기능을 추가한다.

posts/posts.service.ts
1
// posts.service.ts 생략
2
// **** 내 포스트인지 아닌지 확인
3
async isPostMine(userId: number, postId: number) {
4
return this.postsRepository.exist({
5
where: {
6
id: postId,
7
author: { id: userId },
8
},
9
relations: { author: true },
10
})
11
}

4. IsPostMineOrAdmin 적용하고 테스트

posts 컨트롤러에 포스트 수정에서 guard를 추가한다.

posts.controller.ts
1
// **** (5) PATCH /posts/:id : id에 해당하는 post를 부분 변경
2
@Patch(':postId')
3
@UseGuards(IsPostMineOrAdminGuard)
4
patchPost(
5
@Param('postId', ParseIntPipe) id: number, //
6
@Body() body: UpdatePostDto,
7
) {
8
return 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
1
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'
2
import { Request } from 'express'
3
import { UsersModel } from 'src/users/entity/users.entity'
4
import { RolesEnum } from 'src/users/const/roles.const'
5
import { CommentsService } from '../comments.service'
6
7
@Injectable()
8
export class IsCommentMineOrAdminGuard implements CanActivate {
9
constructor(private readonly commentService: CommentsService) {}
10
11
async canActivate(context: ExecutionContext): Promise<boolean> {
12
const req = context.switchToHttp().getRequest() as Request & {
13
user: UsersModel
14
}
15
16
const { user } = req
17
18
if (!user) {
19
throw new UnauthorizedException('사용자 정보를 가져올 수 없습니다.')
20
}
21
22
if (user.role === RolesEnum.ADMIN) {
23
return true
24
}
25
26
const commentId = req.params.commentId
27
28
const isOk = await this.commentService.isCommentMine(user.id, parseInt(commentId))
29
30
if (!isOk) {
31
throw new ForbiddenException('권한이 없습니다.')
32
}
33
34
return true
35
}
36
}

comments 서비스에 내가 작성한 댓글인지 확인하는 코드를 추가한다.

comments.service.ts
1
// **** 내가 작성한 댓글인지 확인
2
async isCommentMine(userId: number, commentId: number) {
3
return this.commentsRepository.exist({
4
where: {
5
id: commentId,
6
author: { id: userId },
7
},
8
relations: { author: true },
9
})
10
}

comments 컨트롤러에 댓글 삭제, 수정에서 guard를 추가한다.

comments.controller.ts
1
// **** (4) 댓글 수정
2
@Patch(':commentId')
3
@UseGuards(IsCommentMineOrAdminGuard)
4
async patchComment() {} // 생략
5
6
// **** (5) 댓글 삭제
7
@Delete(':commentId')
8
@UseGuards(IsCommentMineOrAdminGuard)
9
async deleteComment() {} // 생략