
📌 개요
해당 기간 과제가 주어졌는데 리팩토링에 관한 주제를 다룬 과제였다. 하지만 리팩토링할만한 부분이 잘 보이지 않고 요구사항대로 과제를 진행했더니 1시간 안에 다 끝났다…. ;; (뭐 더 할 수 있다면 리팩토링을 진행해볼 수 있겠지만 그러기엔 내가 주어진 시간을 활용해야하는 공부시간 대비 해당과제를 고민하는 시간이 아깝게 느껴졌기 때문에 적당히만 진행하고 트러블 슈팅이나 과제 관련한 TIL 작성도 하지 않았다… )
그러다 챌린지 세션에서 재미있는 과제가 주어졌다.
Spring Security 와 OAuth Client 를 적용하지 않고 OAuth 로그인 구현하기라는 과제가 주어졌다. 요구사항은 간단했다.
요구사항
Spring Security, OAuth Client 라이브러리를 사용하지 않을 것Access Token 정보를 보이게 할 것요구사항만 보게 되면 간단하지만 OAuth 로그인은 내부적으로 어떤 방식으로 통신이 이루어지는 알아야하고 단순히 하나의 소셜로그인 서비스만 제공하면 간단하겠지만 여러 서비스를 제공해야한다면 각각에 Authorization Server 에서 요구하는 필수 데이터를 전달해야 하기 때문에 구현조건이 까다롭다.
또한 Authorization Server 에서 요구하는 API 가 각자 다르기때문에 Spring Security 에 존재하는 Registration을 직접 구성해야했다.
프로젝트에 시작하기 앞서 동작원리나 짚고 넘어가야할 것을 정리해봐야겠다.
⚙️ OAuth 동작 원리

GET : /oauth/authorize - 인가 코드 받기POST : /oauth/token - 토큰 정보 받기Code를 통해 Authorization Server 에 Code 정보와 추가 데이터를 보내게 된다.Authorization Server 에서 유효성을 검증하였다면 토큰을 발급하게 된다.OAuth 로그인에 전체적인 흐름은 이렇게 진행된다. 그렇다면 어떤 데이터를 주고받아야 하는지도 정리해보겠다.📍 Authorization Server 에서 요구하는 데이터 정리
인가 코드 요청 API - GET
- Client-Id
- Redirect-Uri
- Response-Type
토큰 정보 요청 API - POST
- Clinet-Id
- Clinet-Secret
- Redirect-Uri
- Grant-Type
- Code
인가 코드 요청 API - GET
- Client-Id
- Redirect-Uri
- Response-Type
- Code
토큰 정보 요청 API - POST
Client-idRedirect-UriGrant-TypeCode인가 코드 요청 API - GET
Client-IdRedirect-UriResponse-TypeScope토큰 정보 요청 API - POST
Client-IdClient-SecretRedirect-UriGrant_typeCodeClient-IdClient-SecretRedirect-UriGrant-TypeResponse-TypeAuthorization Server 로 요청을 보내고 어떤 타입으로 응답받을지에 대한 정보code, token, id_tokenScopeAuthorization Server 에서 가져올 데이터 정보email, profileStateCSRF 공격 방지를 위해 사용하는 정보Authorization Server 로 부터 동일한 값인지 확인💡 짚고 넘어가야할 것
API 통신을 위해 Feign Client 를 사용했었다. MSA 프로젝트에서 사용해보고 너무 편하다는 것을 알았기 때문이다. 하지만 스프링 공식문서에는 이제 Open Feign 에 대해 지원을 중단한다고 밝혔다. 이미 완벽한 기능이기 때문이라는 이유이다. Feign Client 도 좋고 편해서 해당 과제해서 적용해도되지만 챌린지 세션에서 RestClient 라이브러리가 새롭게 생겨났다고 소개받아서 해당과제에서 새로 배우고 써보기로 했다.Enum 클래스로 사용하거나 의존성 주입을 이용하지 않을 경우 @Value 어노테이션을 해당 구현체가 쓰고 있을 경우 빈으로 관리되지 않기 때문에 @Value 어노테이션을 적용한 파라미터나, 필드 값이 바인딩되지 않는다. (해결방법으로 Environment 를 사용하는 방법이 있지만 코드량 증가/가독성 하락…)🧑💻 코드로 구현하기
@GetMapping("/login/{provider}")
public void loginPage(@PathVariable String provider, HttpServletResponse response) throws IOException {
String redirectUrl = oAuthLoginService.retrieveUrlFromProvider(provider);
response.sendRedirect(redirectUrl);
}
@GetMapping("/login/oauth2/code/{provider}")
public String accessTokenInfo(@RequestParam("code") String code, @PathVariable String provider) {
log.info("code = {}", code);
return oAuthLoginService.exchangeCodeForToken(code, provider);
}
loginPageClient 가 어떤 서비스로 로그인을 수행할지 모르기때문에 provider를 소셜로그인 정보를 받는다.API URL 주소를 받아온후 해당 경로로 데이터를 요청한다.accessTokenInfoCode 를 받아오고 소셜로그인 타입 정보를 URI 를 통해 받아오게된다.Code 정보와 소셜로그인 타입 정보를 서비스 로직에게 넘겨준후 Access Token 정보를 받아온후 화면에 보여지게 된다.@Override
public String loginPage() {
HashMap<String, String> params = new HashMap<>();
params.put("client_id", clientId);
params.put("redirect_uri", redirectUri);
params.put("response_type", responseType);
return queryParamBuilder.createUrl(authorizationUri, params);
}
@Override
public String getToken(String code) {
LinkedMultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("client_id", clientId);
body.add("client_secret", clientSecret);
body.add("redirect_uri", redirectUri);
body.add("grant_type", grantType);
body.add("code", code);
return restClientService.exchangeCodeForToken(tokenUri, body);
}
loginPageUrl를 만든 후 Controller로 반환하게 되는 로직이다.getTokenbody 에 담아주고 RestClientService 를 호출하여 Authorization Server 와 통신하게 된다.public LoginService getLoginService(String loginType) {
return switch (loginType.toLowerCase()) {
case "naver" -> naverLoginService;
case "google" -> googleLoginService;
case "kakao" -> kakaoLoginService;
default -> throw new IllegalArgumentException("정확한 로그인 서비스를 입력해주세요.");
};
}
Factory 클래스는 오직 객체 생성만을 관심을 가지고 있기 때문에 객체를 생성하는데에만 집중합니다.provider 가 대소문자가 섞여있을 수 있기 때문에 소문자로 전부 전환합니다.public String retrieveUrlFromProvider(String provider) {
return oAuthLoginFactory.getLoginService(provider).loginPage();
}
public String exchangeCodeForToken(String code, String provider) {
return oAuthLoginFactory.getLoginService(provider).getToken(code);
}
public String exchangeCodeForToken(String tokenUri, LinkedMultiValueMap<String, String> body) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
URI uri = URI.create(tokenUri);
Map response = RestClient.create()
.post()
.uri(uri)
.headers(httpHeaders -> httpHeaders.addAll(headers))
.body(body)
.retrieve()
.body(Map.class);
log.info("Response = {}", response);
return (String) response.get("access_token");
}
body 에 담겨진 데이터를 통해서 외부 Authorization Server 와 통신하게 되는 코드입니다.ContentType 은 모든 소셜로그인이 동일하게 Application Form Url Enoceded 를 사용하기에 적용해줍니다.tokenUri 를 통해 POST 방식으로 body에 데이터가 담겨서 통신하게됩니다.Map.class 데이터를 바인딩해준후 Access Token 정보를 꺼낸후 반환하게 됩니다.📖 톺아보기
Github 링크 : https://github.com/dbrjsdn2051/OAuth-Provider/tree/main?tab=readme-ov-file