기존에는 plain text로 password를 DB에 저장하였다. 이런 경우에 해킹을 당하게 되면 그러한 정보들이 온전히 노출된다. 이러한 경우를 대비하여 비밀번호를 암호화하여 DB에 저장해보자.
비밀번호를 암호화하는 방식은 크게 두 가지가 있다.
양방향 암호화(Encrypt)와 단방향 암호화(Hash)
양방향 암호화를 지향하는 사이트는 비밀번호를 찾을 때 온전한 비밀번호를 볼 수 있다. 그러나 이런 경우엔 보안이 조금 떨어진다.
172936을 10으로 나눈 나머지로 암호화한다고 가정하자. 그런데 이렇게 생긴 암호는 복호화가 불가능하다. 왜냐하면 10으로 나눈 나머지가 7 9 6이 되는 수는 수없이 많기 때문이다. 그렇기에 이런 식으로 단방향 암호화를 하게 되면 보안이 더 철저해진다.
수 없이 많은 경우의 수를 rainbowTable을 이용해서 비밀번호를 뚫어내는 경우도 존재한다.
rainbowTable(모든 경우의 수를 담은 테이블)
암호화는 bcrypt라는 라이브러리를 설치하면 간단하게 진행할 수 있다.
npm install bcrypt//npm 사용시
yarn add bcrypt//yarn 사용시
우리는 공식 문서에 나와있는 2번 방법을 사용할 것이다.
bcrypt.hash(myPlaintextPassword, saltRounds, function(err, hash) {
// Store hash in your password DB.
})
함수의 첫번째 인자는 암호화 되지 않은 비밀번호를 넣고, 두 번째 인자는 알고리즘을 얼마나 더 복잡하게 할 지 횟수를 정해준다. Hash에 salt를 적용하여 알고리즘을 더욱 복잡하게 해보자.
그럼 적용하기 위해 기존에 만들어둔 User테이블을 활용해보자.
//user.resolver.ts
import { User } from 'src/apis/users/entities/user.entity';
import { Resolver, Mutation, Args } from '@nestjs/graphql';
import {UserService} from './user.service'
import * as bcrypt from 'bcrypt'
@Resolver()
export class UserResolver{
constructor(
private readonly userService: UserService,
){}
@Mutation(()=> User)
async createUser(
@Args('email') email:string,
@Args('password') password:string,
@Args('name') name:string,
@Args('age') age:number,
){
const hashedPassword = await bcrypt.hash(password, 10)
console.log(hashedPassword)
return this.userService.create({email, hashedPassword, name, age})
}
}
//yarn add --dev @types/bcrypt bcrypt타입스크립트 지원!
//user.service.ts
import { InjectRepository } from '@nestjs/typeorm';
import{ConflictException, Injectable}from '@nestjs/common'
import {User} from './entities/user.entity'
import { Repository } from 'typeorm';
@Injectable()
export class UserService{
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
){}
async create({email, hashedPassword: password, name, age}) {
const user = await this.userRepository.findOne({email})
if(user) throw new ConflictException("이미 등록된 이메일 입니다.")
return await this.userRepository.save({email, password, name, age})
}
}
playground에서 실행해보자.
결과가 잘 나온다.
bcrypt.hash(password, 10)를 사용할 때 salt 횟수의 따른 암호화 시간이다. 컴퓨터 성능에 맞춰 적용하자.
로그인을 하는 api를 만들어 볼 것이다. 이때 JWT를 accessToken용도로 활용할 예정이다.
근데 왜 토큰 기반 인증 시스템을 사용하는가? 참고
- 기존 서버 기반 인증 시스템은 서버측에서 유저들의 정보를 세션에 담아 기록하였는데 로그인 하는 유저가 많아지면 서버가 과부하된다.
- 세션 사용시 시스템 분산과 서버 확장이 어렵다.
- 이때 사용하는 쿠키는 여러 도메인에서 관리하는 것이 번거롭다.
- 쉽게 말하면 서버 기반 인증시스템의 모든 단점들을 보완했다.
- 클라이언트가 서버에 요청을 보낼 때 쿠키를 사용하지 않아도 되므로 보안이 좋다.
- 서버를 확장시키기도 하며 로그인 정보가 사용되는 분야를 확장할 수 있다. google, facebook, kakaotalk 계정을 통한 소셜 로그인도 stateless를 활용한 것이다.
- 상태정보를 매 요청시마다 전달해야하므로 네트워크 리소스 요구량이 많다. 그렇기에 다수의 클라이언트 관리가 어렵고, 대량 리소스로 인한 문제점이 발생할 수 있다.
우리는 게임처럼 실시간 연동이 많이 필요한 서버를 구현하는 것이 아니므로 stateless방식(토큰 기반 인증 시스템)을 사용해볼 것이다.
우리가 구현할 시스템의 작동원리는 아래와 같다.
- 유저가 아이디와 비밀번호로 로그인
- 서버측에서 해당 계정 정보를 검증
- 계정 정보가 정확하다면, 서버측에서 유저에게 signed 토큰을 발급
- 클라이언트 측에서 전달받은 토큰을 저장해두고, 서버에 요청을 할 때 마다 해당 토큰을 함께 서버에 전달
- 서버는 토큰을 검증하고, 요청에 응답
이때 우리가 사용하는 토큰은 json으로 이루어진 토큰인 JWT이다. JWT는 웹표준으로서 대부분의 주류 프로그래밍 언어에서 지원된다. 또한 모든 정보를 자체적으로 가지고 있어 자가 수용적이고, 두 개체 사이에서 쉽게 전달이 가능하다. 공식문서
JWT는 점(.)으로 구분된 세 부분으로 구성되어있다.
{
"alg":"HS256",
"typ":"JWT"
}
payload는 엔티티(일반적으로 사용자) 및 추가 데이터에 대한 설명이 담긴다. 클레임에는 registered, public, private의 세분류로 나뉘어져 있다.
- registered claim에는 iss(발급자), exp(만료시간), sub(제목), sup(대상) 및 기타 등등의 정보가 담긴다.
- public claim에는 JWT를 마음대로 정의할 수 있는데 충돌방지를 위해 네임스페이스를 포함하는 URL이나 IANA JSON Web Token Registry에서 정의하는 것이 좋다.
- private claim에는 사용에 동의하고 등록된 클레임, 공개클레임이 아닌 비공개로 사용자들간의 정보를 공유하기 위해 생성된 맞춤 클레임이다.
//페이로드 예시
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
payload된 토큰의 경우 변조는 불가능 하지만 누구나 읽을 수 있는 정보가 된다. 암호화 되지 않은 비밀번호나 중요한 정보를 담지 않는 것이 좋다.
npm install jsonwebtoken
yarn add jsonwebtoken
src/apis 경로에 로그인을 위한 auth폴더와 파일들을 만들어보자.
//폴더 구조
auth
├── auth.module.ts
├── auth.resolver.ts
└── auth.service.ts
//auth.model.ts
import { JwtModule } from '@nestjs/jwt';
import { AuthService } from './auth.service';
import { AuthResolver } from './auth.resolver';
import { Module } from '@nestjs/common';
import { UserService } from '../users/user.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../users/entities/user.entity';
@Module({
imports:[
JwtModule.register({}),
TypeOrmModule.forFeature([User]),
],
providers:[
AuthResolver,
AuthService,
UserService,
]
})
export class AuthModule {}
//auth.resolver.ts
import { AuthService } from './auth.service';
import { UnprocessableEntityException } from '@nestjs/common';
import {Args, Mutation, Resolver } from '@nestjs/graphql';
import { UserService } from '../users/user.service';
import * as bcrypt from 'bcrypt'
@Resolver()
export class AuthResolver {
constructor(
private readonly userService: UserService,
private readonly authService: AuthService
){}
@Mutation(()=> String)
async login(
@Args('email') email: string,
@Args('password') password: string,
){
// 1. 로그인(이메일과 비밀번호가 일치하는 유저 찾기)
const user = await this.userService.findOne({ email })
// 2. 일치하는 유저가 없으면? 에러 던지기!
if(!user) throw new UnprocessableEntityException('이메일이 존재하지 않습니다.')
// 3. 일차하는 유저가 있지만, 암호가 틀렸다면 에러던지기!!
const isAuth = await bcrypt.compare(password, user.password)
if(!isAuth) throw new UnprocessableEntityException('암호가 틀렸습니다.')
// 4. 일치하는 유저가 있으면? accessToken(JWT)을 만들어서 프론트엔드에 주기
return this.authService.getAccessToken({user})
}
}
//auth.service.ts
import { Injectable } from "@nestjs/common";
import { JwtService } from '@nestjs/jwt'
@Injectable()
export class AuthService {
constructor(
private readonly jwtService:JwtService
){}
getAccessToken({user}){
return this.jwtService.sign(
{ email: user.email, sub: user.id},
{ secret: "myAccessKye", expiresIn: '1h'}
)
}
//토큰의 유효기간을 만들어야 보안이 좋음
}
위와같이 코드를 작성하고, 기존에 만들어 둔 createUser를 이용해 유저를 생성하자. 그 후 loginAPI를 사용해보자.
이때 auth.service.ts의 jwtService.sign을 유심히 보자.
jwtService에서 sign 메서드를 사용하면 토큰을 발급받을 수 있다.
jsondata: 입력받은 데이터를 담은 payload를 의미
secretKey: 해싱 알고리즘(default: HS256알고리즘)
option: 토큰의 유효기간 및 발행자 지정 가능
createUser로 유저를 먼저 만들고
login을 시도 하면
이런식으로 결과를 return받을 수 있다.
로그인한 유저의 accessToken을 복호화하여 실제로 사용하는 유저가 맞는지 확인하는 과정으로 JWT Strategy를 통해 구현할 수 있다.
이때 사용자가 맞는 지 확인(login)하는 과정에서 accessToken(JWT)를 HTTP-Header에 실어 보낸다. Bearer는 아무 의미 없는 관례의 불과하다.
//HTTP-Header
{
"authorization":"Bearer accessToken"
}
이렇게 암호화된 accessToken을 passport라이브러리를 통해 복호화 할 수 있다.
nodejs의 인증 라이브러리로서 자격증명(JWT,사용자이름/암호)을 확인하여 사용자를 인증하고, 인증상태를 관리하고, 인증된 사용자에 대한 정보를 Route Handler에서 사용할 수 있도록 Request 객체에 첨부해준다.
npm install @nestjs/passport
yarn add @nestjs/passport
그럼 로그인(토큰이 존재하지 않으면)을 하지 않았으면 에러를 출력하는 코드를 작성할 예정인데 유저를 조회하는 API를 만들어 로그인한 경우에만 사용할 수 있도록 할 것이다. 이를 위해선 GUARD가 필요하다. 구현해보자
JWT를 우선 Header로 부터 받아오고, 암호화된 토큰을 복호화하여 전송할 jwt-access.strategy.ts를 만들어보자.
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
@Injectable()
export class JwtAccessStrategy extends PassportStrategy(Strategy, 'access'){
//1. 검증하는 부분(secretOrKey가 복호회되어 비교!)
constructor(){
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), //http-header부분에 담긴 값을 가져오면 됨! jwt
secretOrKey: 'myAccessKey' // password
})
}
//중간에 검증이 실패하면 프론트로 에러 출력
// 2. 검증 완료되면 실행
validate(payload){
console.log(payload)
return {
...payload
}
//return하는 값들은 PassportStrategy로 인해 context안에 request안에 user객체로 들어간다.
}
}
JwtAccessStrategy
에PassportStrtegy
을 상속하고 super를 사용하여 부모클래스의 생성자함수를 호출하여 JWT를 전송jwtFromRequest
를 통해 Header의 Token으로부터 JWT를 추출- secretOrKey는 이전에 토큰을 발행했던 secretKey와 동일하게 적어주어야 토큰의
payload
의 정보 추출
4.validate
는payload
를 열어서 사용자의 정보를 반환
우리가 만약 restapi를 사용했다면 여기서 코드 작성을 마무리해도 좋다. 그러나 graphql을 이용하한 단계를 더 거쳐주어야한다. 왜냐하면 graphql이 restapi보다 처리 과정에서 위에 있는데 context를 graphql용으로 변경을 해주어야 guard를 통과할 수 있으므로 처리해주자.
//common/auth/gql-auth-guard
import { ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AuthGuard } from '@nestjs/passport';
//AuthGuard(restapi용)을 graphql용으로 변환!
export class GqlAuthAccessGuard extends AuthGuard('access'){
getRequest(context: ExecutionContext){ //contextheader를 따라 들어오는 데이터들
const ctx = GqlExecutionContext.create(context)
return ctx.getContext().req//graphql용 context
}
}
}
//user.resolver.ts
import { User } from 'src/apis/users/entities/user.entity';
import { Resolver, Mutation, Args } from '@nestjs/graphql';
import {UserService} from './user.service'
import * as bcrypt from 'bcrypt'
@Resolver()
export class UserResolver{
constructor(
private readonly userService: UserService,
){}
@Mutation(()=> User)
async createUser(
@Args('email') email:string,
@Args('password') password:string,
@Args('name') name:string,
@Args('age') age:number,
){
const hashedPassword = await bcrypt.hash(password, 10)
console.log(hashedPassword)
return this.userService.create({email, hashedPassword, name, age})
}
@UseGuards(GqlAuthAccessGuard )//passport라이브러리를 통해 guard형성!
@Query(()=> User)
fetchUser(){
console.log(currentUser)
console.log('fetchUser실행 완료')
return await this.userService.findOneEmail({email:currentUser.email})
}
}
로그인을 위한 api를 만들면서 jwt를 사용해보았는데 다른 부분에도 응용할 수 있을 것 같아 공부할 예정이다.