개요
Spring Security를 사용하지 않기에, 일일히 OAuth 2.0의 인증 방식에 맞추어서 구현해야 했다.
우리 서비스의 경우, Google, 42(교육기관 내부 API)를 이용해 OAuth2.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");
}
}
Java
복사
유저의 사용 요청(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;
//... 생략 ...
Java
복사
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 인증은 생략 ...
Java
복사
직접 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();
}
}
Java
복사
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)));
}
}
Java
복사
Jwts 라이브러리의 ParserBuilder에서 parseClaimsJws()를 이용하여 토큰의 유효성(유효기간 만료 여부, 변조 여부)를 확인할 수 있다.
정리
어노테이션을 이용한 AOP, 그리고 한땀한땀 요청해서 OAuth 2.0 인증을 구현하는 데에 애를 좀 먹었다.
사실 이렇게 할 거면 Spring Security를 깊게 공부해보는게 나았을까 싶기는 했지만, OAuth에 대해서도, 스프링의 AOP에 대해서도 조금 알아볼 수 있는 계기가 되었다.
아마도, 이후에 Spring Security를 적용할 때까지는 우선 이런 방식으로 사용할 것 같다.
자세한 코드는 아래 임베드(2023-05-25 기준 be/spring/#1023 브랜치)를 참조하세요!