개요

이전 글(Spring Security 안쓰고 AOP로 OAuth 구현하기 - 1)

Spring Security를 사용하지 않기에, 일일히 OAuth 2.0의 인증 방식에 맞추어서 구현해야 했다.

우리 서비스의 경우, Google, 42(교육기관 내부 API)를 이용해 OAuth2.0 인증을 구현했다.

OAuth 1.0과 2.0의 차이가 궁금하다면 이곳을 참고하면 될 것 같다.


OAuth(Open Authorization) 2.0 동작 방식

여러 가지 동작 방식이 있는데, 우리는 일반적으로 사용하는 Authorization Code Grant(코드 권한 부여 방식)을 사용하였다.

여기서 User는 서비스 사용자(브라우저, 클라이언트)이고, Client는 서비스 서버(백엔드)라고 생각하면 될 것 같다. OAuth 2.0의 구조 자체가 해당 API를 제공하는 기관이 인증 서버와 리소스 서버를 구분하기 때문에, 위 네 개의 주체가 있는 것이다. 인증하는 방식은 다음 순서와 같다.

  1. 사용 요청 - 서비스 사용자가 로그인을 시도한다.
  2. 권한 부여 승인 코드 요청 - 필요한 정보 양식(client_id…)을 담은 HTTP 요청으로 서비스 서버가 API 서버에게 요청한다. (이 때 우리가 익히 알고 있는 구글 로그인과 같은 로그인 창으로 리디렉션 된다)
  3. 로그인 - 서비스 사용자가 서비스 서버가 리디렉션 해놓은 API 서버에 로그인한다.
  4. 권한 부여 승인 코드 전달 - 서비스 사용자가 로그인에 성공하면, API 서버가 접근 가능한 범위(scope)에 맞는 코드를 서비스 서버에게 전달해준다.
  5. Access Token 요청 - 서비스 서버가 API 서버에게 해당 유저에 대한 권한 코드를 가지고 Access Token을 요청한다. (이 때 Access Token은 서비스 서버가 API 서버에게 서비스 사용자의 정보를 얻기 위한 토큰이지, 서비스 서버가 사용자에게 발급해주는 인가를 위한 토큰이 아니다)
  6. Access Token 전달 - API 서버가 서비스 서버에게 받은 파라미터와 코드를 검증하여, 유효하다고 판단하면 해당 서비스 사용자가 로그인한 API 서버의 계정에 관련한 정보에 접근할 수 있는 Access Token을 발급해준다.
  7. 보호된 자원 요청 - 서비스 서버가 API 서버에게 발급받은 Access Token을 가지고, 서비스 사용자가 갖는 API 서버 계정의 정보를 요청한다.
  8. 요청 자원 전달 - 서비스 서버가 요청하면서 전달한 Access Token을 검증하고, 유효하다면 해당 정보 범위(scope)에 맞는 정보를 서비스 서버에게 전달한다.

까지가 OAuth 2.0을 이용하여 외부 API(구글 등)에게서 해당 유저의 정보를 가져오는 과정이다.

우리는 이 과정을 통해서 외부 API 유저의 profile을 가져오고, 이를 이용하여 다시금 우리 서비스의 인증 - 인가를 위하여 토큰을 발급해주어야 한다. 우리는 JWT 토큰을 사용하였다.

인증-인가를 위한 토큰에 대해 궁금하다면 이전에 정리해본 글을 참고하면 좋을 것 같다.


코드 구현 - Controller

위에 써놓은 방식 그대로 코드로 옮겨서 구현하면 된다. 우선 컨트롤러를 보자.

/**
 * 관리자 인증을 수행하는 컨트롤러 클래스입니다.
 */
@RestController
@RequestMapping("/api/admin/auth")
@RequiredArgsConstructor
public class AdminAuthController {

	private final TokenProvider tokenProvider;
	private final OauthService oauthService;
	private final CookieManager cookieManager;
	private final SiteUrlProperties siteUrlProperties;
	private final GoogleApiProperties googleApiProperties;
	private final JwtProperties jwtProperties;

	/**
	 * 구글 로그인 페이지로 리다이렉트합니다.
	 *
	 * @param response 요청 시의 서블렛 {@link HttpServletResponse}
	 * @throws IOException 입출력 예외
	 */
	@GetMapping("/login")
	public void login(HttpServletResponse response) throws IOException {
		oauthService.sendToGoogleApi(response);
	}

	/**
	 * 구글 로그인 성공 시에 콜백을 처리합니다.
	 * <br>
	 * 구글 API로부터 받은 인증 코드를 이용하여 구글 API에게 인증 토큰을 요청하고,
	 * <br>
	 * 인증 토큰을 이용하여 구글 API에게 프로필 정보를 요청합니다.
	 * <br>
	 * 프로필 정보를 이용하여 JWT 토큰을 생성하고, JWT 토큰을 쿠키에 저장합니다.
	 * <br>
	 * 완료되면, 프론트엔드의 메인 화면으로 리다이렉트합니다.
	 *
	 * @param code 구글 API로부터 쿼리로 받은 인증 코드
	 * @param req  요청 시의 서블렛 {@link HttpServletRequest}
	 * @param res  요청 시의 서블렛 {@link HttpServletResponse}
	 * @throws IOException 입출력 예외
	 */
	@GetMapping("/login/callback")
	public void loginCallback(@RequestParam String code, HttpServletRequest req,
			HttpServletResponse res) throws IOException {
		String apiToken = oauthService.getGoogleToken(code);
		JsonNode profile = oauthService.getGoogleProfile(apiToken);
		String accessToken = tokenProvider.createToken(googleApiProperties.getProviderName(),
				profile,
				DateUtil.getNow());
		String serverName = req.getServerName();
		cookieManager.setCookie(res, jwtProperties.getAdminTokenName(), accessToken, "/",
				serverName);
		res.sendRedirect(siteUrlProperties.getFeHost() + "/main");
	}

}

유저의 사용 요청(1번)이 컨트롤러에 요청하는 “/login” 부분이다.

로그인 요청을 하면, 서블릿의 response를 이용하여 권한 부여 승인 코드 요청(2번, sendToGoogleApi)을 수행(리디렉션)한다.

유저가 로그인에 성공(3번)하면, 정해놓은 콜백인 “/login/callback”으로 API 서버가 code를 파라미터로 붙여서 리디렉션(4번, “/login/callback”) 해준다.

이후에 API 서버에, code를 이용해서 Access Token을 요청(5번, getGoogleToken)한다.

그리고 Access Token을 전달받아(6번, apiToken) 유저의 Profile을 요청(7번, getGoogleProfile)한다.

전달받은 유저의 Profile(8번, profile)을 가지고, 우리 서비스가 원하는 토큰을 발급(createToken)해준다.

  • 우리 서비스는 따로 아이디, 비밀번호를 관리하지 않는다.

Properties

변경될 수 있는 부분들, 그리고 노출되어서는 안되는 정보들에 대해서 별도의 yml 파일 + Properties라는 접미사를 갖는 클래스로 관리하였다.

@Component
@Getter
public class GoogleApiProperties {

	@Value("${oauth2.client.registration.google.name}")
	private String providerName;

	@Value("${oauth2.client.registration.google.client-id}")
	private String clientId;

	@Value("${oauth2.client.registration.google.client-secret}")
	private String clientSecret;

	//... 생략 ...


Service

/**
 * OAuth를 수행하는 서비스 클래스입니다.
 */
@Service
@RequiredArgsConstructor
public class OauthService {

	private final GoogleApiProperties googleApiProperties;

	private final FtApiProperties ftApiProperties;

	/**
	 * 구글 OAuth 인증을 위한 URL을 생성하고, HttpServletResponse에 리다이렉트합니다.
	 *
	 * @param response {@link HttpServletResponse}
	 * @throws IOException 입출력 예외
	 */
	public void sendToGoogleApi(HttpServletResponse response) throws IOException {
		response.sendRedirect(
				ApiUriBuilder
						.builder()
						.authUri(googleApiProperties.getAuthUri())
						.clientId(googleApiProperties.getClientId())
						.redirectUri(googleApiProperties.getRedirectUri())
						.scope(googleApiProperties.getScope())
						.grantType(googleApiProperties.getGrantType())
						.build()
						.getCodeRequestUri());
	}

	/**
	 * 구글 OAuth 인증을 위한 토큰을 요청합니다.
	 *
	 * @param code 인증 코드
	 * @return API 액세스 토큰
	 * @throws ServiceException API 요청에 에러가 반환됐을 때 발생하는 예외
	 */
	public String getGoogleToken(String code) {
		ObjectMapper objectMapper = new ObjectMapper();
		RestTemplate restTemplate = new RestTemplate();
		HttpHeaders headers = new HttpHeaders();

		headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
		MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
		map.add("grant_type", "authorization_code");
		map.add("client_id", googleApiProperties.getClientId());
		map.add("client_secret", googleApiProperties.getClientSecret());
		map.add("redirect_uri", googleApiProperties.getRedirectUri());
		map.add("code", code);

		HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(map, headers);
		try {
			ResponseEntity<String> response = restTemplate.postForEntity(
					googleApiProperties.getTokenUri(), request, String.class);
			return objectMapper.readTree(response.getBody())
					.get(googleApiProperties.getAccessTokenName()).asText();
		} catch (Exception e) {
			throw new ServiceException(ExceptionStatus.OAUTH_BAD_GATEWAY);
		}
	}

	/**
	 * 구글 OAuth 인증을 통해 받은 토큰을 이용해 사용자 정보를 요청합니다.
	 *
	 * @param token 토큰
	 * @return 사용자 정보
	 * @throws ServiceException API 요청에 에러가 반환됐을 때 발생하는 예외
	 */
	public JsonNode getGoogleProfile(String token) {
		ObjectMapper objectMapper = new ObjectMapper();
		RestTemplate restTemplate = new RestTemplate();
		HttpHeaders headers = new HttpHeaders();
		headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
		headers.setBearerAuth(token);
		HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(null, headers);

		try {
			ResponseEntity<String> response = restTemplate.exchange(
					googleApiProperties.getUserInfoUri(), HttpMethod.GET, request, String.class);
			return objectMapper.readTree(response.getBody());
		} catch (Exception e) {
			throw new ServiceException(ExceptionStatus.OAUTH_BAD_GATEWAY);
		}
	
// ... 42 인증은 생략 ...

직접 Request를 만들어서 API 서버에게 요청하는 서비스다.

RestTemplate이 Depricated 되고, WebClient를 공식 권장한다고 하여서 변경 예정에 있다. (스프링에서 HTTP 요청 보내기 - RestTemplate vs WebClient)

직접 만들어서 보낸다는 점에서 좀 켕기기도 하고, 굳이 드러나지 않을 부분들이 있는 것 같다.

JSONObject에 취약성이 있다고 하여서, JsonNode를 사용하고 있다.


토큰 도메인

/**
 * API 제공자에 따라 JWT 토큰을 생성하는 클래스입니다.
 */
@Component
@RequiredArgsConstructor
public class TokenProvider {

	private final JwtProperties jwtProperties;

	private final GoogleApiProperties googleApiProperties;

	private final FtApiProperties ftApiProperties;

	/**
	 * JWT 토큰에 담을 클레임(Payload)을 생성합니다.
	 *
	 * @param provider API 제공자 이름
	 * @param profile  API 제공자로부터 받은 프로필
	 * @return JWT 클레임(Payload)
	 */
	public Map<String, Object> makeClaimsByProviderProfile(String provider, JsonNode profile) {
		Map<String, Object> claims = new HashMap<>();
		if (provider.equals(googleApiProperties.getProviderName())) {
			claims.put("email", profile.get("email").asText());
		}
		if (provider.equals(ftApiProperties.getProviderName())) {
			claims.put("name", profile.get("login").asText());
			claims.put("email", profile.get("email").asText());
			claims.put("blackholedAt",
					profile.get("cursus_users").get(1).get("blackholed_at") != null ?
							profile.get("cursus_users").get(1).get("blackholed_at").asText()
							: null);
			claims.put("role", UserRole.USER);
		}
		return claims;
	}

	/**
	 * JWT 토큰을 생성합니다.
	 *
	 * @param provider API 제공자 이름
	 * @param profile  API 제공자로부터 받은 프로필
	 * @param now      현재 시각
	 * @return JWT 토큰
	 */
	public String createToken(String provider, JsonNode profile, Date now) {
		return Jwts.builder()
				.setClaims(makeClaimsByProviderProfile(provider, profile))
				.signWith(jwtProperties.getSigningKey(), SignatureAlgorithm.HS256)
				.setExpiration(DateUtil.addDaysToDate(now, jwtProperties.getExpiry()))
				.compact();
	}
}

TokenProvider에서 유저 profile을 이용하여 필요한 정보들을 토큰 payload(claims)에 담고, 토큰을 발급한다.

아래는 토큰의 유효성을 검사하는 TokenValidator다.

/**
 * 토큰의 유효성을 검사하는 클래스입니다.
 */
@Component
@RequiredArgsConstructor
@Slf4j
public class TokenValidator {

	private final JwtProperties jwtProperties;

	/**
	 * 토큰의 유효성을 검사합니다.
	 * <br>
	 * 매 요청시 헤더에 Bearer 토큰으로 인증을 시도하기 때문에,
	 * <br>
	 * 헤더에 bearer 방식으로 유효하게 토큰이 전달되었는지 검사합니다.
	 *
	 * @param req {@link HttpServletRequest}
	 * @return 정상적인 방식의 토큰 요청인지, 유효한 토큰인지 여부
	 */
	public Boolean isTokenValid(HttpServletRequest req) {
		String authHeader = req.getHeader("Authorization");
		if (authHeader == null || authHeader.startsWith("Bearer ") == false) {
			return false;
		}
		String token = authHeader.substring(7);
		if (token == null || checkTokenValidity(token) == false) {
			return false;
		}
		return true;
	}

	/**
	 * 토큰의 유효성을 검사합니다.
	 * <br>
	 * JWT ParseBuilder의 parseClaimJws를 통해 토큰을 검사합니다.
	 * <br>
	 * 만료되었거나, 잘못된(위, 변조된) 토큰이거스나, 지원되지 않는 토큰이면 false를 반환합니다.
	 *
	 * @param token 검사할 토큰
	 * @return 토큰이 만료되거나 유효한지 아닌지 여부
	 */
	public Boolean checkTokenValidity(String token) {
		try {
			Jwts.parserBuilder().setSigningKey(jwtProperties.getSigningKey()).build()
					.parseClaimsJws(token);
			return true;
		} catch (MalformedJwtException e) {
			log.info("잘못된 JWT 서명입니다.");
		} catch (ExpiredJwtException e) {
			log.info("만료된 JWT 토큰입니다.");
		} catch (UnsupportedJwtException e) {
			log.info("지원되지 않는 JWT 토큰입니다.");
		} catch (IllegalArgumentException e) {
			log.info("JWT 토큰이 잘못되었습니다.");
		}
		return false;
	}

	public JsonNode getPayloadJson(final String token) throws JsonProcessingException {
		ObjectMapper objectMapper = new ObjectMapper();
		final String payloadJWT = token.split("\\\\.")[1];
		Base64.Decoder decoder = Base64.getUrlDecoder();

		return objectMapper.readTree(new String(decoder.decode(payloadJWT)));
	}
}

Jwts 라이브러리의 ParserBuilder에서 parseClaimsJws()를 이용하여 토큰의 유효성(유효기간 만료 여부, 변조 여부)를 확인할 수 있다.


정리

어노테이션을 이용한 AOP, 그리고 한땀한땀 요청해서 OAuth 2.0 인증을 구현하는 데에 애를 좀 먹었다.

안전한지, 잘 짠 것인지 내가 판단하기에는 애매한 것 같다.. (그냥 스프링 시큐리티를 썼다면..?)

사실 이렇게 할 거면 Spring Security를 깊게 공부해보는게 나았을까 싶기는 했지만, OAuth에 대해서도, 스프링의 AOP에 대해서도 조금 알아볼 수 있는 계기가 되었다.

아마도, 이후에 Spring Security를 적용할 때까지는 우선 이런 방식으로 사용할 것 같다.

 

자세한 코드는 아래 임베드(2023-05-25 기준 be/spring/#1023 브랜치)를 참조하세요!

GitHub - innovationacademy-kr/42cabi: 사물함 관리 서비스

참고 자료

OAuth 2.0 동작 방식의 이해

복사했습니다!