JWT 그리고 Authentication

💡 로그인 전략
크게 두 개로 양분되는 로그인 전략은 세션과 JWT다.
-
Session
- 특수한 ID값으로 구성된다
- 서버에서 생성되어 클라이언트의 쿠키에 저장한다
- 클라이언트에서 요청을 보낼 때 쿠키의 세션 ID를 같이 보낸다
- 세션 ID는 데이터베이스에 저장되어 있다. 그래서 매 요청마다 데이터베이스를 조회해야 한다
- 클라이언트에서 사용자 정보가 탈취될 위험이 없다
-
JWT
- 유저 정보를
Base64
로 인코딩하여 일련의 JSON String 값으로 저장하는 도구다 - Header / Payload / Signauture 로 구성되어 있다
- 서버에서 생성되어 클라이언트에 저장된다
- 클라이언트에서 요청을 보낼 때 JWT 토큰을 같이 보낸다
- 데이터베이스에 저장되지 않기에 서버에서 바로 검증할 수 있다. 즉 DB를 조회할 필요가 없다
- 검증에는 Signauture 값을 이용한다
- 사용자 정보가 모두 토큰에 담겨 있어 정보 유출의 위험이 있다
- 유저 정보를
💡 JWT 구조
"[ "HEADER": { "alg": "HS256", "typ": "JWT", }, "PAYLOAD": { "sub": 1, "iat": 1517304031, "exp": 1522310283, "type": "refresh", "email": "hyezoprk@kakao.com" }, "SIGNATURE": { BASE64ENCODE(header) + BASE64ENCODE(payload) + BASE64ENCODE('id:password') } ]"
- 헤더
- 토큰의 종류와 알고리즘을 담는다
- 페이로드
- 토큰에 담고 싶은 정보를 싣는다
- 주로 주요키로 사용되는 ID를 넣는다
- 발급시간, 만료시간 등은 라이브러리(
@nestjs/jwt
)에 의해 자동으로 생성된다 - 이메일 같이 사용자 신상에 관한 정보는 삼가도록 한다
- 시그니처
- 헤더와 페이로드, 그리고 아이디와 암호를 모두 Base64로 인코딩하여 알고리즘을 돌린 값이다
- 셋중 하나만 바뀌어도 인코딩 값이 달라진다
💡 액세스 토큰 발급 과정
- 클라이언트
- 폼에서 입력받은
username:password
을 Base64로 인코딩한 후 Basic $token
형태로 Authorization 헤더에 담아 서버에 요청을 보낸다- 토큰 전송은 Authorization 헤더를 이용하는 것이 관습이다
- 서버
- 클라이언트가 보낸
Basic $token
을 디코딩하여 DB에서 id를 조회한다 - 사용자 정보가 있으면 입력받은 password(RAW)와 DB에 저장된 password(HASHED)를 비교한다
- 이것도 일치하면 액세스 토큰과 리프레쉬 토큰을 생성하여 발급한다. 없다면 에러를 던진다.
💡 액세스 토큰 사용 과정
- 로그인이 필요한 곳에서 신분증으로 사용한다
Bearer $accessToken
의 형태로 요청을 보낸다- 액세스 토큰을 검증(verify)하여
payload
를 추출한다 payload
에 포함된 email, id 등으로 유저 정보를 조회한다- 있다면 유저 정보를 그대로 가져오고, 없다면 에러를 던진다
- 가져온 유저 정보를 데이터베이스 요청에 이용한다
💡 리프레쉬 토큰 사용 과정
- 액세스 토큰이 만료되었을 때 재발급받기 위해 사용한다
Bearer $refreshToken
으로 요청- 토큰값을 재검증하고(verify), 성공하면 담겨 있던 payload 추출
- 페이로드의 타입이
refresh
가 아니라면 에러 - 페이로드의 타입을
access
로 바꾸고 기존 sub, email과 함께 재사인 - 새로운 액세스 토큰을 발급
💡 액세스 토큰이 만료된 경우
- 액세스 토큰으로 접근을 했는데 기간이 만료된 경우(verify 함수가 토큰의 유효기간을 읽고 만료 여부를 판단. 401 에러)
- 바로 에러를 띄우기보다
AccessToken
재발급 url로 요청을 보내게 유도한다 - 위의 리프레쉬 토큰 사용 과정으로 넘어감
- 새로운 액세스 토큰을 발급받고 기존 접근으로 요청을 다시 보낸다
- 제대로 되었다면 데이터 요청, 응답 성공
💡 암호화
bycrypt / sha1 / md5 ...
- 사용자가 입력한 비밀번호를 (알고리즘)해쉬로 꼬아서 데이터베이스에 저장한다
- 데이터베이스가 털리더라도 사용자가 타이핑하는 암호는 유출되지 않는다
- 해쉬 사전을 통한 해킹
- 해쉬에서 문자열로 되돌리는 작업은 소요시간이 오래 걸린다
- 일정 해쉬열에 대한 해독을 사전 형태로 만들어놓은 경우를 대비해
salt
를 뿌린다 - 그 경우 해쉬를 해독하더라도 원래의 암호가 아닌 원래의 암호 + 솔트가 더해진 값을 해킹하는 것이 되기 때문에 무용지물이 된다
- 그럼 솔트까지 해킹하는 경우: 그게 바로 비크립트가 의도적으로 느린 이유다. 이를 해킹하는 데는 무수한 시도가 필요한데, 속도까지 느리게 돼있으니 해킹이 힘들다
💡 로그인 로직
- registerWithEmail
- 신규가입시 호출하는 메소드
- 이메일, 닉네임, 패스워드를 입력받고 사용자를 생성한다
- 생성이 완료되면 액세스 토큰과 리프레쉬 토큰을 발급한다
→ 회원가입 후 다시 로그인을 요하는 쓸데없는 과정을 방지하기 위함이다
export class AuthService { constructor( readonly private userService: UserService, readonly private jwtService: JwtService, ) {} async registerWithEmail(user: Pick<UserModel, "email"|"password"|"nickname">){ const hash = await bcrypt.hash( user.password, ENV_KEYS.HASH_ROUND ) const newUser = await this.userService.createUser( ...user, password: hash, ); return this.loginUser(newUser); } }
- 비크립트 해쉬 메소드를 이용해 사용자에게 받은 암호를 해쉬화한다
- 해싱 라운드(속도)는 공홈을 참고하여 기획에 맞게 선택한다
- 솔트는 해쉬 메소드 내부에서 자동으로 뿌려진다
userService
에 신규가입 메소드를 작성하고, 그를 불러와 사용자를 생성한다- 생성이 완료되면 다음 로직인
loginUser
메소드를 호출한다
- loginWithEmail
- 신규가입이 아닌, 일반적인 로그인에 사용되는 메소드다
- 이메일, 패스워드를 받아 사용자를 검증한다
- 검증이 성공하면 액세스 토큰과 리프레쉬 토큰을 발급한다
export class AuthService { constructor( readonly private userService: UserService, readonly private jwtService: JwtService, ) {} async loginWithEmail(user: Pick<UserModel, "email"|"password">){ const existingUser = await authenticateWithEmailAndPassword(user); return this.loginUser(existingUser); }}
- loginUser
- 1과 2에서 호출된, 액세스 토큰과 리프레쉬 토큰을 반환하는 로직이다
export class AuthService { constructor( readonly private userService: UserService, readonly private jwtService: JwtService, ) {} async loginUser(user: Pick<UserModel, "id">){ return { accessToken: this.signToken(user, 'access'), refreshToken: this.signToken(user, 'refresh') } }}
- signToken
- 3에서 필요한, 액세스 토큰과 리프레쉬 토큰을 서명하는 로직이다
- 넘겨받은 유저 정보를 활용해
payload
를 구성한다 - 주로 id, 토큰의 타입(access, refresh)을 담는다
jwtService.sign
에서 토큰의 유효기간을 설정한다- 서명하면 토큰의 유효기간, 타입, id 등이 페이로드인 토큰이 반환된다
export class AuthService { constructor( readonly private userService: UserService, readonly private jwtService: JwtService, ) {} signToken( user: Pick<UserModel, "id">, requestTokenType: 'access' | 'refresh', ){ const payload = { sub: user.id, type: requestTokenType, }; return this.jwtService.sign(payload, { secret: this.configService.get(ENV_KEYS.jwt_secret), expiresIn: requestTokenType === 'refresh' ? '1h' : '5m', }); }}
- 페이로드는 아무나 까서 볼 수 있는 형태이기 때문에 개인정보를 넣지 않는 개발자도 많다
- 기본적으로 포함하는 값은 sub(id), type(access, refresh) 정도다
- iap, exp 등은 내가 정한 만기시간을 기반으로
sign
메소드가 자동으로 생성한다
- authenicateWithEmailAndPassword
- 2에서 로그인을 진행할 때 필요한 기본적인 검증 과정이다
export class AuthService { constructor( readonly private userService: UserService, readonly private jwtService: JwtService, ) {} async authenticateWithEmailAndPassword( user: Pick<UserModel, "password"|"email"> ){ const existingUser = await this.userService.getUserById(user.id); if(!existingUser) throw new UnauthorizedException('존재하지 않는 사용자입니다'); const passOk = bycrypt.compare(user.password, existingUser.password); if(!passOk) throw new UnauthorizedExeption('비밀번호가 틀렸습니다'); return existingUser; }}
-
사용자가 데이터베이스에 존재하는지 확인(by email)
-
비밀번호가 맞는지 확인(bycrypt.compare).
-
모두 통과되면 찾은 1에서 찾은 사용자 정보 반환.
-
loginWithEmail에서 반환된 데이터를 기반으로 토큰 생성(payload)
bycrypt.compare(x, y)
- 첫번째 파라미터 x는 사용자에게 입력받은 raw한 비밀번호가 온다
- 두번째 파라미터 y는 데이터베이스에 저장된 해쉬된 비밀번호가 온다
- compare 메소드는 첫번째 파라미터를 해쉬하하여 둘을 비교한다
- 비교 결과가 일치하면 true, 아니면 false를 반환한다
- 일치할 경우 유저 데이터를 반환하고, 불일치의 경우 에러를 던지도록 한다
- extractTokenFromHeader
- 클라이언트에서 오타를 냈을 경우를 대비해
Basic | Bearer
의 타입인지 확인한다 - 잘 보냈다면 토큰만 추출하여 반환한다
export class AuthService { constructor( readonly private userService: UserService, readonly private jwtService: JwtService, ) {} extractTokenFromHeader(header: string) { const splitToken = header.split(' '); const [type, token] = splitToken as [Token, string]; if (splitToken.length !== 2 || (type !== 'Basic' && type !== 'Bearer')) throw new UnauthorizedException('잘못된 토큰입니다.'); return token; }}
- decodeBasicToken
export class AuthService { constructor( readonly private userService: UserService, readonly private jwtService: JwtService, ) {} decodeBasicToken(base64String: string) { // - 프론트엔드(또는 postman)에서 인코드되어 담겨 온다. // const encode = btoa(base64String); const decoded = atob(base64String); const split = decoded.split(':'); if (split.length !== 2) throw new UnauthorizedException('잘못된 토큰입니다.'); const [email, password] = split; return { email, password, }; }}
- verifyToken
jwtService.verify
메소드를 이용하여 토큰을 검증한다- 토큰과 시크릿 코드를 넘겨주고, 일치하면 토큰에 실었던 payload를 반환한다.
export class AuthService { constructor( readonly private userService: UserService, readonly private jwtService: JwtService, ) {} verifyToken(token: string) { try { return this.jwtService.verify<Payload>(token, { secret: this.configService.get(ENV_KEYS.jwt_secret), }); } catch (error) { throw new UnauthorizedException('토큰이 유효하지 않습니다.'); } }}
- rotateToken
- 토큰을 갱신하는 로직이다
- 리프레쉬 토큰을 받아 페이로드를 추출한다
- 추출한 페이로드의 타입이
refresh
가 아니라면 에러를 던진다 - 통과했다면 페이로드를 이용해 새로운 토큰을 발급받는다
- 어떤 타입의 토큰을 갱신할 건진 파라미터로 받도록 한다
export class AuthService { constructor( readonly private userService: UserService, readonly private jwtService: JwtService, ) {} rotateToken(token: string, requestTokenType: AuthTokenType) { const decoded = this.verifyToken(token); if (decoded.type !== 'refresh') { throw new UnauthorizedException( '토큰 재발급은 refresh_token으로만 가능합니다.', ); } return this.signToken( { id: decoded.sub, }, requestTokenType, ); }}
Comment ?
▾ Comment
🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧