프로젝트/Ticketing

[Ticketing] AWS Lambda Authorizer를 활용한 인가 서버 개발

이덩우 2024. 5. 31. 00:36

1. 인가 서버를 분리

우선 본 프로젝트의 전체 흐름을 살펴보자.

서비스 아키텍처

  1. 모든 요청은 단일 진입점, API Gateway에서 처리하도록 설계
  2. 로그인 성공 시, WAS에서 JWT 발급
  3. 클라이언트는 권한이 필요한 요청 시 Request Header에 JWT를 함께 전송

이후 *토큰을 어디서 검증할까?*에 대해 고민을 많이 했는데, 간단하게 토큰 발급을 구현한 WAS에서 처리할 수 도 있지만, 관심사를 분리하는게 맞다고 생각했다. 

 

모든 요청의 단일 진입점 역할을 맡는 API Gateway 레벨에서 인증 및 인가되지 않은 요청은 미리 차단하는게 뒷 단의 부하도 줄여주고 자연스럽다. 따라서 API Gateway 레벨에서 인증/인가를 도와주는 Lambda 권한 부여자를 활용해보기로 했다.

 


2. Secret Key 공유 문제

최초 스프링에서는 HS256 알고리즘 서명을 통해 JWT를 생성했다.

@Component
@Slf4j
public class JwtTokenProvider {
    private final Key key;

    public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
        byte[] ketBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(ketBytes);
    }
    
	public JwtTokenDto generateToken(Authentication authentication) {
        .
        .
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim("auth", authorities)
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
		// 생략
    }
}

 토큰도 잘 생성된걸 확인했고, 이제 인증 서버에서 SecretKey를 받아와서 로직을 작성하면 되는데 문제가 있었다.

HS256 알고리즘 서명 방식은 서버측에 존재하는 하나의 비밀키로 복호화, 암호화를 수행하기 때문에 키 자체가 하나의 서버에 존재할 때 유용한 방식이었다.

 

현재는 분산 환경 기반으로 WAS에서 JWT 생성, Lambda 권한 부여자에서 인증의 역할을 맡기 때문에 다른 방식이 더 적절하다고 판단했다.

대안으로, RS256 알고리즘을 사용하기로 했다.

RS256 알고리즘은 공개키와 개인키 두 가지를 활용하며, 서버 측에서는 개인키로 서명하고 인증이 필요한 곳에서는 공개키를 활용하면 된다.

  • WAS(Spring) : 내부에 private_key.pem을 가짐
  • Lambda Authorizer : 내부에 public_key.pem을 가짐

- 변경된 Spring 소스 코드

@Component
@Slf4j
public class JwtTokenProvider {

    private final PrivateKey privateKey;

    public JwtTokenProvider(@Value("${jwt.private-key}") String privateKeyStr) throws Exception {
        // private key 생성
        byte[] decodedKey = Base64.getDecoder().decode(privateKeyStr);
        PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decodedKey);
        KeyFactory kf = KeyFactory.getInstance("RSA");
        this.privateKey = kf.generatePrivate(spec);
    }

    public JwtTokenDto generateToken(Authentication authentication) {
        .
        .
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim("auth", authorities)
                .setExpiration(accessTokenExpiresIn)
                .signWith(privateKey, SignatureAlgorithm.RS256)
                .compact();
        // 생략
    }

}

 

- Lambda Authorizer

const jwt = require('jsonwebtoken');
const publicKeyPath = '/opt/public_key/public_key.pem';

module.exports.handler = async (event, context, callback) => {
  console.log(event);
  const { awsRequestId } = context;
  
  let publicKey;
  try {
    publicKey = await loadPublicKey(publicKeyPath);
  } catch (err) {
    console.log(err);
    return generateDeny(awsRequestId, event.routeArn);
  }
  
  let authorizationToken;
  if (event.headers && event.headers.authorization) {
    const authHeader = event.headers.authorization;
    authorizationToken = authHeader.split(' ')[1];
  } else {
    console.log("Authorization token is missing");
    return generateDeny(awsRequestId, event.routeArn);
  }
  
  try {
    const token = jwt.verify(authorizationToken, publicKey, {algorithm : 'RS256'});
    console.log("token: ", token);
    return generateAllow(awsRequestId, event.routeArn);
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      console.log("TokenExpiredError");
      console.log(err);
      callback("Unauthorized");
    } else if (err.name === 'JsonWebTokenError') {
      console.log("JsonWebTokenError");
      console.log(err);
      return generateDeny(awsRequestId, event.routeArn);
    }
  }
};

async function loadPublicKey(path) {
  const fs = require('fs');
  return fs.readFileSync(path, 'utf8');
}

const generateAllow = (principalId, methodArn) => {
  console.log("Allow");
  return generatePolicy(principalId, 'Allow', methodArn);
};

const generateDeny = (principalId, methodArn) => {
  console.log(`[auth.js] deny. principalId: ${principalId}, resource: ${methodArn}`);
  return generatePolicy(principalId, 'Deny', methodArn);
};

const generatePolicy = (principalId, effect, methodArn) => {
  const authResponse = { principalId };
  if (effect && methodArn) {
    const policyDocument = {
      Version: '2012-10-17',
      Statement: [{
        Action: 'execute-api:Invoke',
        Effect: effect,
        Resource: methodArn
      }]
    };
    authResponse.policyDocument = policyDocument;
  }
  console.log(authResponse);
  return authResponse;
};

 

Public Key를 가져오기 위해 레이어를 추가했다.

node_module 및 public key 추가

 

이후 nodeJS 환경에서는 /opt 경로에서 파일을 가져올 수 있다.

const publicKeyPath = '/opt/public_key/public_key.pem';

try {
    publicKey = await loadPublicKey(publicKeyPath);
  } catch (err) {
    console.log(err);
    return generateDeny(awsRequestId, event.routeArn);
}

async function loadPublicKey(path) {
  const fs = require('fs');
  return fs.readFileSync(path, 'utf8');
}

 

인증 성공 시 실제 백엔드 단을 호출할 수 있는 IAM 정책을 반환한다.

module.exports.handler = async (event, context, callback) => {
  try {
    const token = jwt.verify(authorizationToken, publicKey, {algorithm : 'RS256'});
    console.log("token: ", token);
    return generateAllow(awsRequestId, event.routeArn);
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      console.log("TokenExpiredError");
      console.log(err);
      callback("Unauthorized");
    } else if (err.name === 'JsonWebTokenError') {
      console.log("JsonWebTokenError");
      console.log(err);
      return generateDeny(awsRequestId, event.routeArn);
    }
  }
};

const generateAllow = (principalId, methodArn) => {
  console.log("Allow");
  return generatePolicy(principalId, 'Allow', methodArn);
};

const generatePolicy = (principalId, effect, methodArn) => {
  const authResponse = { principalId };
  if (effect && methodArn) {
    const policyDocument = {
      Version: '2012-10-17',
      Statement: [{
        Action: 'execute-api:Invoke',
        Effect: effect,
        Resource: methodArn
      }]
    };
    authResponse.policyDocument = policyDocument;
  }
  console.log(authResponse);
  return authResponse;
};

 


3. 인가 성공, 그러나 500 에러..

위 과정을 거치고 정상적으로 Allow 로그와 함께 인가 성공을 확인했다.

인증 성공

하지만 클라이언트 측에서는 500 에러를 반환받고 있었고, 심지어 실제 백엔드 측으로는 요청조차 오지 않았다.

여러 삽질 결과.. Lambda Authorizer를 생성할 때 무심코 지나간 옵션을 확인할 수 있었다.

옵션

응답 모드가 문제였다.

무심코 지나쳤었는데, 기본 값이 *단순*으로 설정되어있어 생긴 문제였다. 

나의 경우 Allow 및 Deny를 포함하는 IAM 정책을 반환하는 코드를 작성했는데.. Lambda Authrizer를 생성할 때 이 부분을 확인하지 못해 생긴 오류였다.