Hyesung Oh

JWT, Cookie&SessinId를 활용한 Auth, 인증(Authentication) 본문

Web

JWT, Cookie&SessinId를 활용한 Auth, 인증(Authentication)

혜성 Hyesung 2022. 6. 9. 23:59
반응형

웹에서 사용하는 대표적인 인증 방식 두 가지인 Cookie&SessionId 그리고 JWT에 대해 정리해보았다.


Bcrypt

두 가지 방식에서 공통적으로 User의 최초 회원가입 정보를 암호화하고 추후 verification에 사용하는 password hasing function이다. 

출처: https://stackoverflow.com/questions/40993645/understanding-bcrypt-salt-as-used-by-php-password-hash

User의 password는 위와 같이 암호화 되어 database에 저장된다. Bcrypt는 이 때 사용되는 hasing function이며 가변길이의 문자열을 고정길이의 문자열로 hashing 하는데 사용되는 1. Algorithm, 2. 그 결과인 Hashed password, 그리고 가운데 Salt라는 값으로 결과를 리턴한다.

Salt가 없다면 Hashed password가 유출되었을 때 이를 복호화 하기 수월하다. 하지만, 특정 길이의 랜덤 문자열과 함께 저장한다면 문자 길이에 비례하여 해독하는데 시간이 더 걸리게 된다. 시간이 더 걸린다는 것이지 해킹을 완벽히 막는 방법은 없기 때문에 결국 일정 시간이 지나면 대부분의 웹사이트에선 User에게 Password 변경을 요구하며 강제하기도 하는 이유가 이 때문이다. 
마찬가지로 Salt의 길이를 길게 하면 할 수록 Server의 CPU overhead가 증가한다. 서버와 가용한 리소스에 따라 상이하겠지만, 보통 10~12 정도를 권장한다고 한다.

Python과 Node.js에서 Bcrypt라는 동일한 이름의 모듈이 지원되며 사용법을 간단히 정리해보았다.
Node.js

const bcrypt = require('bcrypt');

const password = 'password123';
const hashed = bcrypt.hashSync(password, 10); // 길이 10의 Salt 생성
console.log(`password: ${password}, hashed: ${hashed}`);

const result = bcrypt.compareSync('password1234', hashed); // password verification
console.log(result); // return false

마지막 compareSync 부분은 DB에 저장된 hashed password와 Client가 요청한 password 문자열과의 비교 과정에 대응할 수 있다.

Python

import bcrypt

password = 'password123'
hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())

print(bcrypt.checkpw('password1234'.encode('utf-8'), b))  # return false

매우 간단하다. 차이 점이 있다면 Python의 bcrypt는 base64 encoded된 byte열을 input으로 받는 다는 것이다. 문자열 그대로 입력하면 에러가 난다. checkpw는 compareSync에 대응된다.

이제 본론인 두 가지 인증 방식에 대해 알아보자.

 

Cookie와 SessionId

Client가 로그인을 요청하고, password verification이 완료되면 server는 session을 생성한다. session id는 랜덤 문자열로 session을 관리하는 database에 저장된다. 즉, state가 생기게 된다.

state를 관리함으로서 클라이언트에서 별도 처리 없이 server로 부터 헤더에 심어진 Cookie의 sessionId를 이용해 간편하게 유저의 상태 정보를 주고 받을 수 있다. 하지만, state를 유지하는 하나의 데이터베이스에서 해당 트랜잭션을 수행해야하므로 분산현 서비스를 잘구축하더라도 병목이 생길 수 있다.

 

JWT(JSON Web Token)

출처: https://velog.io/@dyparkkk/spring-sequrity-jwt-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0access-token-refresh-token

verification 이후 sessionId 대신 Server가 JWT를 생성하여 Client에게 Cookie로 전달하는 방식이다. JWT는 Server에서 별도로 저장하는 값은 아니고 Client로 부터다시 요청을 받았을 때 JWT validation을 수행 할 뿐이다. 즉, Server에 state가 생기지 않아 분산형 MSA에 적합한 인증방식이다.

하지만 JWT 자체가 보안 취약점이 될 수 있다. 영원히 만료되지 않는 JWT가 Client와 Server사이에 오간다면 해커가 이를 탈취할 수 있다. 따라서 보통 payload에 expiration 값을 설정하여 만료시점을 설정한다. 

해커가 Payload 내용 자체를 조작하면 expiration 설정도 의미없지 않을까? 이는 그림의 제일아래 Signature 부분을 이해할 필요가 있다.
jwt.io 사이트와 node.js를 통해 간단한 실습을 진행해보았다.

 

JWT.IO

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

jwt.io

const jwt = require('jsonwebtoken');

const secret = 'fABWWh2471^%Vw9dmUyYK32BXL*VJhq&N&';
const token = jwt.sign(
  {
    id: 'hyesung',
    isAdmin: false,
  },
  secret,
  { expiresIn: 2 }
);

console.log(token);

secret은 LastPass라는 사이트에서 만들었다. secret은 서명에 사용되는 key로서 서버에서만 가지고 있어야 하며 JWT를 통한 인증 방식에서 매우 중요한 역할을 한다. 위 실행된 token 값을 입력하게 되면 우측에 decoding된 결과 값을 확인 할 수 있다. 하지만 secret 값은 알 수 없다.

우측 payload의 isAdmin을 true로 바꿔 보았더니 좌측 payload, signature 부분의 Encoded 문자열 결과가 다른 것을 확인 할 수 있다.

변경된 Encoded 값을 changed 변수에 저장하고 아래 코드를 실행해보면

const changed = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Imh5ZXN1bmciLCJpc0FkbWluIjp0cnVlLCJpYXQiOjE2NTQ3ODYxNzgsImV4cCI6MTY1NDc4NjE4MH0.w3UOFJQ-if4gxUUT0LSbRpw2trjqxTlF-zRop3kIA0Q";
jwt.verify(changed, secret, (error, decoded) => {
  console.log(error, decoded);
});

실제 아래와 같이 Invalid Signature Error가 발생하는 것을 확인할 수 있다.

JsonWebTokenError: invalid signature

따라서 위에서 언급한 expiration 값을 임의로 조정하면 이미 secret key로 서명된 결과와 일치하는지 verification 과정을 통해 무효될 것이기에 JWT의 만료기간을 설정하여 보안 수준을 높이는 효과를 얻을 수 있는 것이다.

마지막으로 현재 속한 팀의 JWT 사용 사례를 간단히 소개하고 글을 마치도록 하겠다.

def _introspectJwt(token: str, config: Config) -> dict:
    if not token:
        raise NoTokenException()

    jwt = CFJwtValidator()
    key = jwt.getPublicKeys(config.CF_ACCESS_DOMAIN or config.RPC_URL)
    payload = jwt.decode(token, key, config.CF_AUDIENCE_TAG)
    if not payload:
        raise UnauthorizedException()

    return payload

def authenticate(token: str, config: Config) -> str:
    if config.TEST_ID:
        return config.TEST_ID

    payload = _introspectJwt(token, config)
    id = payload['email'].split('@')[0]
    return id

위의 코드를 한 문장으로 요약하면 RPC를 통해 받은 public key로 JWT를 한번 복호화 한 뒤, payload 부분에 admin_id가 있는지 인증(authentication)하는 단계라고 할 수 있다.

이상으로 글을 마치겠습니다. 감사합니다.

 

반응형
Comments