이전글
작성한 코드는 아래 저장소에 있습니다!
=> https://github.com/KJunseo/oauth-without-spring-security
스프링 시큐리티가 OAuth 로그인을 처리하는 구조를 참고하여 스프링 시큐리티 없이 OAuth 로그인을 구현해보려고 한다.
이전 글에 적었듯 OAuth 로그인을 처리하기 위해서는 Set up과정과 실제 로그인을 처리하는 과정 2가지를 구현해줘야한다.(각 OAuth 서버 등록은 [OAuth + Spring Boot + JWT] 2. 스프링 시큐리티로 OAuth 구현해보기를 참고하기. redirect url은 임의로 바꿔줘야한다!)
application.yml 파일에 각 OAuth 서버의 정보를 등록해준다. application.yml 파일에 적어준 형식대로 값을 바인딩할 객체를 생성해 줄 것이기 때문에 시큐리티를 사용할 때 처럼 굳이 spring.security.oauth2.client.registration
, spring.security.oauth2.client.provider
와 같은 형식을 지켜줄 필요는 없다. 원하는 대로 구조를 잡아주자. 나는 간단하게 oauth2.user
(OAuth 서버 정보 등록시 발급받은 client-id, client-secret와 직접 설정해준 redirect-uri), oauth2.provider
(OAuth 서버의 access token을 얻을 수 있는 uri, access token으로 유저 정보를 가져올 수 있는 uri)로 구조를 잡아서 진행하겠다.
이때 redirect-uri은 임의로 적어줘도 되지만, OAuth 서버 등록할 때 적어준 redirect-uri 값과 일치는 시켜줘야한다.
예시(github oauth 정보 등록 시 redirect-uri 적어주는 부분)
# application-oauth.yml
oauth2:
user:
github:
client-id: 6c34d9a6903231c5a301
client-secret: ${비밀키}
redirect-uri: http://localhost:8080/redirect/oauth
google:
client-id: 54767115914-gcla0mork6h3156h4qcutjerm0mdf4fu.apps.googleusercontent.com
client-secret: ${비밀키}
redirect-uri: http://localhost:8080/redirect/oauth
naver:
client-id: sCfhQHgPVQFFf8RTGjVe
client-secret: ${비밀키}
redirect-uri: http://localhost:8080/redirect/oauth
provider:
github:
token-uri: https://github.com/login/oauth/access_token
user-info-uri: https://api.github.com/user
google:
token-uri: https://www.googleapis.com/oauth2/v4/token
user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo
naver:
token-uri: https://nid.naver.com/oauth2.0/token
user-info-uri: https://openapi.naver.com/v1/nid/me
프로퍼티 파일에 적어준 정보들을 객체로 바인딩 해보자. 이전 글에서 스프링 시큐리티는 @ConfigurationProperties
을 사용하여 프로퍼티 값을 객체로 바인딩 해주었다.
먼저 현재 구조를 바인딩 받을 수 있는 객체를 생성해준다. oauth2
하위에 크게 user
와 provider
가 존재하고 각각 하위에 존재하는 값들을 아래와 같이 static class의 필드로 두면 값을 바인딩 받을 수 있는 상태가 된다.
@Getter
@ConfigurationProperties(prefix = "oauth2")
public class OauthProperties {
private final Map<String, User> user = new HashMap<>();
private final Map<String, Provider> provider = new HashMap<>();
@Getter
@Setter
public static class User {
private String clientId;
private String clientSecret;
private String redirectUri;
}
@Getter
@Setter
public static class Provider {
private String tokenUri;
private String userInfoUri;
private String userNameAttribute;
}
}
여기까지는 값을 바인딩 할 수 있는 상태로 만든 것이고 실제로 사용하기 위해서는 설정 파일을 만들어주고 @EnableConfigurationProperties
를 붙여줘야한다.
@Configuration
@EnableConfigurationProperties(OauthProperties.class)
public class OauthConfig {
private final OauthProperties properties;
public OauthConfig(OauthProperties properties) {
this.properties = properties;
}
}
이렇게 까지 하면 프로퍼티 파일에 적어준 정보가 하나의 OauthProperties
객체로 만들어진다. 이를 각 OAuth 서버 정보로 나눠서 InMemory 저장소에 저장해서 사용해야 한다.
저장소에 저장하기에 앞서 OauthProperties
를 분해해야 한다. 스프링 시큐리티로 보면 ClientRegistration
객체를 만들어 주는 것이다.
// spring security의 ClientRegistration 역할
@Getter
public class OauthProvider {
private final String clientId;
private final String clientSecret;
private final String redirectUrl;
private final String tokenUrl;
private final String userInfoUrl;
public OauthProvider(OauthProperties.User user, OauthProperties.Provider provider) {
this(user.getClientId(), user.getClientSecret(), user.getRedirectUri(), provider.getTokenUri(), provider.getUserInfoUri());
}
@Builder
public OauthProvider(String clientId, String clientSecret, String redirectUrl, String tokenUrl, String userInfoUrl) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.redirectUrl = redirectUrl;
this.tokenUrl = tokenUrl;
this.userInfoUrl = userInfoUrl;
}
}
스프링 시큐리티는 OAuth2ClientPropertiesRegistrationAdapter
를 통해 OAuth2ClientProperties
(우리는 OauthProperties)를 ClientRegistration
(우리는 OauthProvider)로 바꿔준다. adapter를 만들어주자
public class OauthAdapter {
private OauthAdapter() {}
// OauthProperties를 OauthProvider로 변환해준다.
public static Map<String, OauthProvider> getOauthProviders(OauthProperties properties) {
Map<String, OauthProvider> oauthProvider = new HashMap<>();
properties.getUser().forEach((key, value) -> oauthProvider.put(key,
new OauthProvider(value, properties.getProvider().get(key))));
return oauthProvider;
}
}
이제 이 OauthProvider
를 저장해 줄 InMemory 저장소를 만들자
public class InMemoryProviderRepository {
private final Map<String, OauthProvider> providers;
public InMemoryProviderRepository(Map<String, OauthProvider> providers) {
this.providers = new HashMap<>(providers);
}
public OauthProvider findByProviderName(String name) {
return providers.get(name);
}
}
마지막으로 OauthConfig
에서 빈으로 등록된 OauthProperties
를 주입받아 OauthAdapter
를 사용해 각 OAuth 서버 정보를 가진 OauthProvider
로 분해하여 InMemoryProviderRepository
에 저장해 주면 된다.
@Configuration
@EnableConfigurationProperties(OauthProperties.class)
public class OauthConfig {
private final OauthProperties properties;
public OauthConfig(OauthProperties properties) {
this.properties = properties;
}
// 추가된 부분
@Bean
public InMemoryProviderRepository inMemoryProviderRepository() {
Map<String, OauthProvider> providers = OauthAdapter.getOauthProviders(properties);
return new InMemoryProviderRepository(providers);
}
}
여기까지 진행한다면 애플리케이션이 실행될 때, OAuth 서버 정보들을 객체로 만들어 메모리에 저장된다.
이제 OAuth 로그인을 처리하는 컨트롤러를 만들 것이다.
그전에 제일 처음 포스트의 최종 설계 부분을 보자.
여기서 백엔드는 프론트에서 authorization code
를 넘겨 받은 이후 과정을 진행하기로 하였다.
약식으로 프론트 역할은 간단한 html 페이지와 postman으로 대체하려고 한다. resources - static 하위에 index.html을 만들어주자.
<!--authoriazation code를 얻어오기 위한 간단한 코드-->
<a href="https://github.com/login/oauth/authorize?client_id=6c34d9a6903231c5a301&scope=id,name,email,avatar_url">Github
Login</a><br>
<a href="https://accounts.google.com/o/oauth2/v2/auth?scope=https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email&client_id=54767115914-gcla0mork6h3156h4qcutjerm0mdf4fu.apps.googleusercontent.com&response_type=code&redirect_uri=http://localhost:8080/redirect/oauth&access_type=offline">Google
Login</a><br>
<a href="https://nid.naver.com/oauth2.0/authorize?response_type=code&client_id=sCfhQHgPVQFFf8RTGjVe&redirect_uri=http://localhost:8080/redirect/oauth">Naver
Login</a>
먼저 컨트롤러 형태부터 만들자. provider
이름과, authorization code
를 받아서 실제로 로그인을 실행할 것이다. 실제 로직은 service 단에서 처리하도록 할 것이다.
@RestController
public class OauthRestController {
private final OauthService oauthService;
public OauthRestController(OauthService oauthService) {
this.oauthService = oauthService;
}
@GetMapping("/login/oauth/{provider}")
public ResponseEntity<LoginResponse> login(@PathVariable String provider, @RequestParam String code) {
LoginResponse loginResponse = oauthService.login(provider, code);
return ResponseEntity.ok().body(loginResponse);
}
}
OAuth 로그인 시 할 일은 크게 2가지이다. 프론트에서 받은 authorizatoin code
를 통해 OAuth 서버의 access token을 얻어오는 것과, access token을 통해 실제 유저 정보를 얻어오는 것이다.
access token을 얻어오기 위해서 우리는 만들어 둔 OauthProvider
가 필요하다. 프론트에서 넘어온 provider
값을 통해 InMemoryProviderRepository
에서 OauthProvider
를 가져오자.
@Service
public class OauthService {
private final InMemoryProviderRepository inMemoryProviderRepository;
public OauthService(InMemoryProviderRepository inMemoryProviderRepository) {
this.inMemoryProviderRepository = inMemoryProviderRepository;
}
public LoginResponse login(String providerName, String code) {
// 프론트에서 넘어온 provider 이름을 통해 InMemoryProviderRepository에서 OauthProvider 가져오기
OauthProvider provider = inMemoryProviderRepository.findByProviderName(providerName);
// TODO access token 가져오기
// TODO 유저 정보 가져오기
// TODO 유저 DB에 저장
return null;
}
}
access token을 가져오거나, 유저 정보를 가져올 때는 실제로 OAuth 서버와 통신을 해야한다. WebClient
를 사용하여 통신을 하려고한다. 아래의 의존성을 추가해주자.
implementation 'org.springframework.boot:spring-boot-starter-webflux'
WebClient에 대한 내용은 [Spring] WebClient 참고하기
OAuth 서버와의 통신을 통해 access token을 받아올 dto를 만들어주자
@Getter
@NoArgsConstructor
public class OauthTokenResponse {
@JsonProperty("access_token")
private String accessToken;
private String scope;
@JsonProperty("token_type")
private String tokenType;
@Builder
public OauthTokenResponse(String accessToken, String scope, String tokenType) {
this.accessToken = accessToken;
this.scope = scope;
this.tokenType = tokenType;
}
}
이제 WebClient
를 사용해 OAuth 서버에 access token 요청을 하면 된다. 프로퍼티 파일에 적어줬던 access token을 요청할 수 있는 uri에 요청을 보내면 된다. 이 때 헤더에 client-id와 client-secret값으로 Basic Auth를 추가해주고, 컨텐츠 타입을 APPLICATION_FORM_URLENCODED
로 설정해준다. 요청 바디에는 authorization code
, redirect_uri
등을 넘겨주면 된다.
@Service
public class OauthService {
private final InMemoryProviderRepository inMemoryProviderRepository;
public OauthService(InMemoryProviderRepository inMemoryProviderRepository) {
this.inMemoryProviderRepository = inMemoryProviderRepository;
}
public LoginResponse login(String providerName, String code) {
// 프론트에서 넘어온 provider 이름을 통해 InMemoryProviderRepository에서 OauthProvider 가져오기
OauthProvider provider = inMemoryProviderRepository.findByProviderName(providerName);
// access token 가져오기
OauthTokenResponse tokenResponse = getToken(code, provider);
// TODO 유저 정보 가져오기
// TODO 유저 DB에 저장
return null;
}
private OauthTokenResponse getToken(String code, OauthProvider provider) {
return WebClient.create()
.post()
.uri(provider.getTokenUrl())
.headers(header -> {
header.setBasicAuth(provider.getClientId(), provider.getClientSecret());
header.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
header.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
header.setAcceptCharset(Collections.singletonList(StandardCharsets.UTF_8));
})
.bodyValue(tokenRequest(code, provider))
.retrieve()
.bodyToMono(OauthTokenResponse.class)
.block();
}
private MultiValueMap<String, String> tokenRequest(String code, OauthProvider provider) {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("code", code);
formData.add("grant_type", "authorization_code");
formData.add("redirect_uri", provider.getRedirectUrl());
return formData;
}
}
OAuth 서버 별로 가져올 수 있는 유저 정보가 다르다. 이번에는 oauthId, email, name, imageUrl 정도만 가져와 보겠다. token과 마찬가지로 이를 받을 수 있는 dto를 만들어준다.
@Getter
public class UserProfile {
private final String oauthId;
private final String email;
private final String name;
private final String imageUrl;
@Builder
public UserProfile(String oauthId, String email, String name, String imageUrl) {
this.oauthId = oauthId;
this.email = email;
this.name = name;
this.imageUrl = imageUrl;
}
public Member toMember() {
return Member.builder()
.oauthId(oauthId)
.email(email)
.name(name)
.imageUrl(imageUrl)
.role(Role.GUEST)
.build();
}
}
OAuth 서버에 WebClient
를 통해 유저 정보를 요청하고 map으로 받아온다. Bearer 타입으로 Auth 헤더에 access token 값을 담아주면 된다.
public LoginResponse login(String providerName, String code) {
// 프론트에서 넘어온 provider 이름을 통해 InMemoryProviderRepository에서 OauthProvider 가져오기
OauthProvider provider = inMemoryProviderRepository.findByProviderName(providerName);
// access token 가져오기
OauthTokenResponse tokenResponse = getToken(code, provider);
// 유저 정보 가져오기
UserProfile userProfile = getUserProfile(providerName, tokenResponse, provider);
// TODO 유저 DB에 저장
return null;
}
private UserProfile getUserProfile(String providerName, OauthTokenResponse tokenResponse, OauthProvider provider) {
Map<String, Object> userAttributes = getUserAttributes(provider, tokenResponse);
// TODO 유저 정보(map)를 통해 UserProfile 만들기
return OauthAttributes.extract(providerName, userAttributes);
}
// OAuth 서버에서 유저 정보 map으로 가져오기
private Map<String, Object> getUserAttributes(OauthProvider provider, OauthTokenResponse tokenResponse) {
return WebClient.create()
.get()
.uri(provider.getUserInfoUrl())
.headers(header -> header.setBearerAuth(tokenResponse.getAccessToken()))
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.block();
}
얻어온 유저 정보(map)를 UserProfile
로 만들어 줘야하는데 enum을 사용해보겠다. 각 OAuth 서버 별로 데이터의 key값이 다르다. 예를 들어 github의 프로필 이미지는 avatar_url
이지만 google의 경우는 picture
이다. 따라서 OAuth 서버가 어떤 형식으로 데이터를 리턴하는지 확인해보고 추가해줘야한다.
public enum OauthAttributes {
GITHUB("github") {
@Override
public UserProfile of(Map<String, Object> attributes) {
return UserProfile.builder()
.oauthId(String.valueOf(attributes.get("id")))
.email((String) attributes.get("email"))
.name((String) attributes.get("name"))
.imageUrl((String) attributes.get("avatar_url"))
.build();
}
},
NAVER("naver") {
@Override
public UserProfile of(Map<String, Object> attributes) {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return UserProfile.builder()
.oauthId((String) response.get("id"))
.email((String) response.get("email"))
.name((String) response.get("name"))
.imageUrl((String) response.get("profile_image"))
.build();
}
},
GOOGLE("google") {
@Override
public UserProfile of(Map<String, Object> attributes) {
return UserProfile.builder()
.oauthId(String.valueOf(attributes.get("sub")))
.email((String) attributes.get("email"))
.name((String) attributes.get("name"))
.imageUrl((String) attributes.get("picture"))
.build();
}
};
private final String providerName;
OauthAttributes(String name) {
this.providerName = name;
}
public static UserProfile extract(String providerName, Map<String, Object> attributes) {
return Arrays.stream(values())
.filter(provider -> providerName.equals(provider.providerName))
.findFirst()
.orElseThrow(IllegalArgumentException::new)
.of(attributes);
}
public abstract UserProfile of(Map<String, Object> attributes);
}
이렇게 만들어진 UserProfile을 DB에 저장해주면 된다.(Member는 이전 포스팅 참고)
그 후 프론트로 보낼 LoginResponse
Dto를 만들어준다.
@Getter
@NoArgsConstructor
public class LoginResponse {
private Long id;
private String name;
private String email;
private String imageUrl;
private Role role;
private String tokenType;
private String accessToken;
private String refreshToken;
@Builder
public LoginResponse(Long id, String name, String email, String imageUrl, Role role, String tokenType, String accessToken, String refreshToken) {
this.id = id;
this.name = name;
this.email = email;
this.imageUrl = imageUrl;
this.role = role;
this.tokenType = tokenType;
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
}
우리 애플리케이션에서 쓸 access token과 refresh token도 생성해서 같이 LoginResponse
에 담아주면 된다.(이 부분은 github 코드를 참고해보기)
public LoginResponse login(String providerName, String code) {
// 프론트에서 넘어온 provider 이름을 통해 InMemoryProviderRepository에서 OauthProvider 가져오기
OauthProvider provider = inMemoryProviderRepository.findByProviderName(providerName);
// access token 가져오기
OauthTokenResponse tokenResponse = getToken(code, provider);
// 유저 정보 가져오기
UserProfile userProfile = getUserProfile(providerName, tokenResponse, provider);
// 유저 DB에 저장
Member member = saveOrUpdate(userProfile);
// 우리 애플리케이션의 JWT 토큰 만들기
String accessToken = jwtTokenProvider.createAccessToken(String.valueOf(member.getId()));
String refreshToken = jwtTokenProvider.createRefreshToken();
// TODO 레디스에 refresh token 저장
// redisUtil.setData(String.valueOf(member.getId()), refreshToken);
return LoginResponse.builder()
.id(member.getId())
.name(member.getName())
.email(member.getEmail())
.imageUrl(member.getImageUrl())
.role(member.getRole())
.tokenType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
private Member saveOrUpdate(UserProfile userProfile) {
Member member = memberRepository.findByOauthId(userProfile.getOauthId())
.map(entity -> entity.update(
userProfile.getEmail(), userProfile.getName(), userProfile.getImageUrl()))
.orElseGet(userProfile::toMember);
return memberRepository.save(member);
}
애플리케이션을 실행시킨 후 http://localhost:8080/에 접속한다.
원하는 로그인을 클릭한다. 그럼 authorization code가 나온다.
postman을 이용하여 이 코드를 백엔드에 보내준다.
참고로 구글 같은 경우는 authorization code를 한 번 decoding 해줘야한다
작성한 코드는 아래 저장소에 있습니다!
=> https://github.com/KJunseo/oauth-without-spring-security
안녕하세요, 보고 많이 배우네요. 정말 감사합니다.
하나 여쭤보고싶은게 프론트에서 리소스서버에서 authorization_code 를 가져오는부분은 따로 구현을 하신걸까요?
안녕하세요 잘보았습니다! 혹시 Google 의 Authorization code의 경우 디코딩을 해주어야 한다는데 어떤 걸로 디코딩 해주어야할까요???
혹시 application-oauth.yml에 있는 jwt token secret-key 부분엔 어떤걸 적어줘야 하나요?? 전 열심히 따라해봤는데 401에러가 나와서요 ㅠㅠ
정말 감사합니다... 보면서 많이 배우고 느끼고 갑니다..
죄송한데 질문드려도 될까요!
그전 게시물에서 시큐리티 동작과정을 직접 디버깅 하시면서 뜯어 보시는 것을 보며 저도 그런 공부 습관을 가져야겠다고 생각했습니다 혹시 추천해 주실만한 디버깅 습관이 있으신가요?!
두번째로는, 저도 지금 사이드 프로젝트로 Junseo Kim 님과 비슷한 구조로 프론트와 백을 나누어 진행하고 있습니다. 프론트는 ios앱이고, 백엔드로는 스프링부트를 사용하고 있습니다. 서버는 aws를 통해 웹페이지로 배포해 둔 상태입니다! 이와 같은 환경에서도 Junseo Kim 님께서 작성 해주신 방식으로 code를 앱에서 부터 get api로 받아와 서버에서 outh2 서버에 접근하는 방식이 유효한가요?!
긴글 읽어주셔서 감사합니다~ ㅎㅎ