Home
프로필

JWT 그리고 Authentication

image

💡 로그인 전략

크게 두 개로 양분되는 로그인 전략은 세션과 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로 인코딩하여 알고리즘을 돌린 값이다
    • 셋중 하나만 바뀌어도 인코딩 값이 달라진다

💡 액세스 토큰 발급 과정

  1. 클라이언트
  • 폼에서 입력받은 username:password을 Base64로 인코딩한 후
  • Basic $token 형태로 Authorization 헤더에 담아 서버에 요청을 보낸다
  • 토큰 전송은 Authorization 헤더를 이용하는 것이 관습이다

  1. 서버
  • 클라이언트가 보낸 Basic $token을 디코딩하여 DB에서 id를 조회한다
  • 사용자 정보가 있으면 입력받은 password(RAW)와 DB에 저장된 password(HASHED)를 비교한다
  • 이것도 일치하면 액세스 토큰과 리프레쉬 토큰을 생성하여 발급한다. 없다면 에러를 던진다.

💡 액세스 토큰 사용 과정

  1. 로그인이 필요한 곳에서 신분증으로 사용한다
  2. Bearer $accessToken의 형태로 요청을 보낸다
  3. 액세스 토큰을 검증(verify)하여 payload를 추출한다
  4. payload에 포함된 email, id 등으로 유저 정보를 조회한다
  5. 있다면 유저 정보를 그대로 가져오고, 없다면 에러를 던진다
  6. 가져온 유저 정보를 데이터베이스 요청에 이용한다

💡 리프레쉬 토큰 사용 과정

  1. 액세스 토큰이 만료되었을 때 재발급받기 위해 사용한다
  2. Bearer $refreshToken 으로 요청
  3. 토큰값을 재검증하고(verify), 성공하면 담겨 있던 payload 추출
  4. 페이로드의 타입이 refresh 가 아니라면 에러
  5. 페이로드의 타입을 access 로 바꾸고 기존 sub, email과 함께 재사인
  6. 새로운 액세스 토큰을 발급

💡 액세스 토큰이 만료된 경우

  1. 액세스 토큰으로 접근을 했는데 기간이 만료된 경우(verify 함수가 토큰의 유효기간을 읽고 만료 여부를 판단. 401 에러)
  2. 바로 에러를 띄우기보다 AccessToken 재발급 url로 요청을 보내게 유도한다
  3. 위의 리프레쉬 토큰 사용 과정으로 넘어감
  4. 새로운 액세스 토큰을 발급받고 기존 접근으로 요청을 다시 보낸다
  5. 제대로 되었다면 데이터 요청, 응답 성공

💡 암호화

bycrypt / sha1 / md5 ...

  • 사용자가 입력한 비밀번호를 (알고리즘)해쉬로 꼬아서 데이터베이스에 저장한다
  • 데이터베이스가 털리더라도 사용자가 타이핑하는 암호는 유출되지 않는다
  • 해쉬 사전을 통한 해킹
    • 해쉬에서 문자열로 되돌리는 작업은 소요시간이 오래 걸린다
    • 일정 해쉬열에 대한 해독을 사전 형태로 만들어놓은 경우를 대비해 salt를 뿌린다
    • 그 경우 해쉬를 해독하더라도 원래의 암호가 아닌 원래의 암호 + 솔트가 더해진 값을 해킹하는 것이 되기 때문에 무용지물이 된다
    • 그럼 솔트까지 해킹하는 경우: 그게 바로 비크립트가 의도적으로 느린 이유다. 이를 해킹하는 데는 무수한 시도가 필요한데, 속도까지 느리게 돼있으니 해킹이 힘들다

💡 로그인 로직

  1. 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 메소드를 호출한다

  1. 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);
}
}


  1. 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')
}
}
}


  1. 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 메소드가 자동으로 생성한다

  1. 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를 반환한다
    • 일치할 경우 유저 데이터를 반환하고, 불일치의 경우 에러를 던지도록 한다

  1. 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;
}
}


  1. 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,
};
}
}


  1. 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('토큰이 유효하지 않습니다.');
}
}
}


  1. 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

🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧