개요
이전 글(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를 제공하는 기관이 인증 서버와 리소스 서버를 구분하기 때문에, 위 네 개의 주체가 있는 것이다. 인증하는 방식은 다음 순서와 같다.
- 사용 요청 - 서비스 사용자가 로그인을 시도한다.
- 권한 부여 승인 코드 요청 - 필요한 정보 양식(client_id…)을 담은 HTTP 요청으로 서비스 서버가 API 서버에게 요청한다. (이 때 우리가 익히 알고 있는 구글 로그인과 같은 로그인 창으로 리디렉션 된다)
- 로그인 - 서비스 사용자가 서비스 서버가 리디렉션 해놓은 API 서버에 로그인한다.
- 권한 부여 승인 코드 전달 - 서비스 사용자가 로그인에 성공하면, API 서버가 접근 가능한 범위(scope)에 맞는 코드를 서비스 서버에게 전달해준다.
- Access Token 요청 - 서비스 서버가 API 서버에게 해당 유저에 대한 권한 코드를 가지고 Access Token을 요청한다. (이 때 Access Token은 서비스 서버가 API 서버에게 서비스 사용자의 정보를 얻기 위한 토큰이지, 서비스 서버가 사용자에게 발급해주는 인가를 위한 토큰이 아니다)
- Access Token 전달 - API 서버가 서비스 서버에게 받은 파라미터와 코드를 검증하여, 유효하다고 판단하면 해당 서비스 사용자가 로그인한 API 서버의 계정에 관련한 정보에 접근할 수 있는 Access Token을 발급해준다.
- 보호된 자원 요청 - 서비스 서버가 API 서버에게 발급받은 Access Token을 가지고, 서비스 사용자가 갖는 API 서버 계정의 정보를 요청한다.
- 요청 자원 전달 - 서비스 서버가 요청하면서 전달한 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: 사물함 관리 서비스
참고 자료
'JAVA' 카테고리의 다른 글
동시성 문제와 데드락, 테스트와 해결, 그리고 삽질 (3) | 2023.06.06 |
---|---|
스프링에서 HTTP 요청 보내기 - RestTemplate vs WebClient (0) | 2023.06.01 |
Spring Security 안쓰고 AOP로 OAuth 구현하기 - 1 (0) | 2023.05.25 |
스프링의 싱글톤과 멀티 스레딩 (0) | 2023.04.08 |
public static void main(String[] args)?? (5) | 2022.06.30 |