OAuth 2.0 + JWT Token 생성 / 인증

채상엽·2022년 10월 30일
1

Deepromeet

목록 보기
2/2

이번 프로젝트에서 Spring Security + OAuth 2.0 + JWT를 이용한 로그인/회원가입 기능을 구현하고 있다. OAuth 2.0이 무엇이며, 어떻게 프로젝트에 구현하였는지 알아보자.

OAuth 2.0이란?

인증을 위한 개방형 표준 프로토콜

Third-Party 프로그램에게 사용자를 대신해서 해당 리소스 서버에서 제공하는 자원에 대한 접근 권한을 위임하는 방식을 제공한다.

대표적인 예가 내가 만든 어플리케이션에서 구글, 카카오, 페이스북 등 소셜로그인을 OAuth를 이용해 구현할 수 있다.

권한 부여 방식

OAuth 2.0의 권한 부여 방식에는 총 4가지가 있다. 이 중에서 일반적으로 사용되는 권한 부여 승인 코드 방식에 대해서만 알아보자.

Authorization Code Grant(권한 부여 승인 코드 방식)

가장 일반적으로 많이 사용되고, 기본이 되는 방식이다. 권한 부여 승인을 위해 자체적으로 생성한 Authorization Code를 전달한다.

요청/응답 순서는 위의 다이어그램과 동일하다.

프로젝트

현재 진행중인 프로젝트는 클라이언트(안드로이드)와 서버(스프링부트)로 구성되어 있다.

위에서 설명했던 OAuth 2.0 flow를 현재 진행중인 프로젝트에서 어떻게 적용했는지 이해하기 쉽도록, 아래 그림을 그려보았다.

용어 정리

  • Client
    • 안드로이드 앱 및 사용자
  • Third-Party Authorization Server(인증 서버)
    • 위 그림에서 Authrozation Server와 Resource Server를 통칭
  • OAuth Access Token
    • OAuth에서 유효한 사용자인지 확인하는 Access Token
  • Server Access Token
    • 우리 서버에서 유효한 사용자인지 확인하는 Access Token

사용자 회원가입 flow

사용자 로그인 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에 저장되어진다.

profile
프로게이머 연습생 출신 주니어 서버 개발자 채상엽입니다.

0개의 댓글