1. 인가 서버를 분리
우선 본 프로젝트의 전체 흐름을 살펴보자.
- 모든 요청은 단일 진입점, API Gateway에서 처리하도록 설계
- 로그인 성공 시, WAS에서 JWT 발급
- 클라이언트는 권한이 필요한 요청 시 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를 가져오기 위해 레이어를 추가했다.
이후 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를 생성할 때 이 부분을 확인하지 못해 생긴 오류였다.
'프로젝트 > Ticketing' 카테고리의 다른 글
[Ticketing] 인프라 아키텍처 설계 및 구축 (0) | 2024.06.04 |
---|---|
[Ticketing] Lock을 활용한 동시성 제어 (0) | 2024.06.04 |
[Ticketing] Slack으로 에러 메세지 전송하기 (Feat: Spring AOP) (0) | 2024.06.02 |
[Ticketing] Git Actions와 AWS CodePipeline을 활용한 자동화된 배포 파이프라인 구축 (0) | 2024.06.01 |
[Ticketing] JPQL에서 QueryDSL으로 전환 (0) | 2024.05.18 |