이번 프로젝트에서 Spring Security + OAuth 2.0 + JWT를 이용한 로그인/회원가입 기능을 구현하고 있다. OAuth 2.0이 무엇이며, 어떻게 프로젝트에 구현하였는지 알아보자.
인증을 위한 개방형 표준 프로토콜
Third-Party 프로그램에게 사용자를 대신해서 해당 리소스 서버에서 제공하는 자원에 대한 접근 권한을 위임하는 방식을 제공한다.
대표적인 예가 내가 만든 어플리케이션에서 구글, 카카오, 페이스북 등 소셜로그인을 OAuth를 이용해 구현할 수 있다.
OAuth 2.0의 권한 부여 방식에는 총 4가지가 있다. 이 중에서 일반적으로 사용되는 권한 부여 승인 코드 방식에 대해서만 알아보자.
가장 일반적으로 많이 사용되고, 기본이 되는 방식이다. 권한 부여 승인을 위해 자체적으로 생성한 Authorization Code를 전달한다.
요청/응답 순서는 위의 다이어그램과 동일하다.
현재 진행중인 프로젝트는 클라이언트(안드로이드)와 서버(스프링부트)로 구성되어 있다.
위에서 설명했던 OAuth 2.0 flow를 현재 진행중인 프로젝트에서 어떻게 적용했는지 이해하기 쉽도록, 아래 그림을 그려보았다.
1. 클라이언트가 Third-Party 인증 서버로 로그인 요청을 보낸다.
2. Third-Party 인증 서버는 이에 대한 응답으로 OAuth Access Token을 반환한다.
3. 클라이언트는 OAuth Access Token을 이용해 서버에 로그인을 요청한다.
4. 서버는 OAuth Access Token을 이용해 Third-Party 인증 서버에 사용자 정보를 요청한다.
5. Third-Party 인증 서버가 사용자 정보를 응답한다.
6. 응답받은 사용자 정보가 서버 DB에 존재하는지 비교한다.
7. 존재한다면 로그인과 Server Access Token을 발급하고, 존재하지 않으면 회원가입 화면으로 유도한다.
이 과정 중 1~2번 까지는 클라이언트인 안드로이드에서 처리를 담당하였다.
내가 맡게 된 부분은 JWT 토큰 생성/인증 부분이다. 먼저 JWT 토큰을 생성하는 부분이다.
@Slf4j
@RequiredArgsConstructor
@Component
public class TokenGenerator {
@Value("${security.jwt.token.secretkey}")
private String SECRET_KEY;
@Value("${security.jwt.token.validtime}")
private Integer TOKEN_VALID_TIME;
private static final String MEMBER_ID_CLAIM_KEY = "memberId";
public String generateToken(Long memberId) {
Date now = new Date();
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.claim(MEMBER_ID_CLAIM_KEY, memberId)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + TOKEN_VALID_TIME))
.signWith(Keys.hmacShaKeyFor(SECRET_KEY.getBytes()))
.compact();
}
}
추후에 DB에 있는 정보와 비교하기 위해서는 해당 사용자만이 가질 수 있는 고유한 값이 필요하기 때문에, 토큰 생성은 memberId
를 포함하여 구성하였다. 또한 JWT는 암호화가 되어있기는 하지만, 데이터가 담기는 Payload 부분은 복호화가 가능하기 때문에 예민한 정보는 담지 않았다.
다음은 Server Access Token 인증부분이다. AuthenticationFilter중 AbstractPreAuthenticatedProcessingFilter
를 사용하였다.
@Slf4j
@RequiredArgsConstructor
public class TokenAuthenticationFilter extends AbstractPreAuthenticatedProcessingFilter {
@Value("${security.jwt.token.secretkey}")
private String SECRET_KEY;
private static final String BEARER_PREFIX = "Bearer ";
private static final int SUBSTRING_BEARER_INDEX = 7;
private static final String AUTHORIZATION_HEADER = "authorization";
@Override
protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
return resolveToken(request);
}
@Override
protected Object getPreAuthenticatedCredentials(HttpServletRequest request) {
return resolveToken(request);
}
public String resolveToken(HttpServletRequest request) {
String jwtToken = request.getHeader(AUTHORIZATION_HEADER);
if (validateToken(jwtToken)) {
return parseBearerToken(jwtToken);
}
return null;
}
private boolean validateToken(String jwtToken) {
if (jwtToken == null || !jwtToken.startsWith(BEARER_PREFIX)) {
return false;
}
Jws<Claims> claims =
Jwts.parserBuilder()
.setSigningKey(SECRET_KEY.getBytes())
.build()
.parseClaimsJws(parseBearerToken(jwtToken));
return !claims.getBody().getExpiration().before(new Date());
}
private String parseBearerToken(String jwtToken) {
return jwtToken.substring(SUBSTRING_BEARER_INDEX);
}
}
JWT 를 이용한 Bearer 인증 방식을 사용할 예정이기 때문에, 요청 헤더의 토큰 값의 시작이 Bearer
인지 검사하며, 만료 시간이 지나지는 않았는지를 검사한다.
유효성 검증에 성공하면, 해당 토큰에서 Bearer을 제외한 JWT 토큰의 {HEADER}.{PAYLOAD}.{SIGNATURE}
부분을 파싱하여 getPreAuthenticatedPrincipal()
에서 반환하게 된다.
이 때 반환값은 Authentication
객체로 변환되고, AuthenticationManager(ProviderManager)
에게 전달된다. 이후 AuthenticationProvider
가 실제로 인증을 진행하게 된다.
@Component
@RequiredArgsConstructor
public class TokenAuthenticationProvider implements AuthenticationProvider {
private static final String ROLE_USER = "ROLE_USER";
private static final int PAYLOAD_INDEX = 1;
private static final String PREAUTH_TOKEN_CREDENTIAL = "";
private static final String MEMBER_ID_CLAIM_KEY = "memberId";
private final GetMemberUseCase getMemberUseCase;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String jwtToken = authentication.getPrincipal().toString();
Base64.Decoder decoder = Base64.getUrlDecoder();
String[] split = jwtToken.split("\\.");
final String payload = new String(decoder.decode(split[PAYLOAD_INDEX].getBytes()));
JSONParser jsonParser = new JSONParser();
Long memberId = null;
try {
JSONObject jsonObject = (JSONObject) jsonParser.parse(payload);
Long authenticationMemberId = (Long) jsonObject.get(MEMBER_ID_CLAIM_KEY);
memberId = getMemberUseCase.execute(authenticationMemberId).getMemberId();
} catch (ResourceNotFoundException | ParseException e) {
e.printStackTrace();
}
if (authentication instanceof PreAuthenticatedAuthenticationToken) {
return new PreAuthenticatedAuthenticationToken(
memberId,
PREAUTH_TOKEN_CREDENTIAL,
Collections.singleton(new SimpleGrantedAuthority(ROLE_USER)));
}
return null;
}
@Override
public boolean supports(Class<?> authentication) {
return PreAuthenticatedAuthenticationToken.class.isAssignableFrom(authentication);
}
}
여기서 파라미터로 들어온 Authentication은 앞서 Filter에서 넘겨준 Authentication
객체이다. 해당 부분에서 실질적인 정보가 담긴 payload 부분을 파싱한 뒤, 실제 DB의 사용자와 비교한다. 해당 정보와 일치하는 사용자가 존재할 경우, PreAuthenticatedAuthenticationToken
을 생성하여 반환하게 되며 이때 setAuthenticated(true)
를 통해 인증을 성공시킨다.
이렇게 인증에 성공한 Authentication
객체는 Security Context에 저장되어진다.