프로젝트 하면서 OAuth를 로그인도 추가해할 일이 생겼습니다.
총 3가지 루트의 로그인을 지원합니다 (구글, 네이버, 카카오)
이 글은 Oauth 개념 정리 및 구현 과정에 대한 기록을 남기기 위해 작성하였습니다.
이미 많은 자세한 설명이 있기 때문에 간단한 개념을 설명한다면 나라는 것을 증명해주는 서비스가 대신 나임을 증명한다고 생각하시면 됩니다.
예를 들면 내가 A.com이라는 사이트에 접속하여 이 사이트에서 제공하는 회원만 사용할 수 있는 서비스를 사용하고 싶습니다. 하지만 가입이 되어있지 않아 회원임을 모른다면 나는 그 사이트에 회원가입을 해야만 하죠
그래서 A.com 에 회원 가입없이도 이미 내가 가입한 서비스가 나라는 것을 이미 내가 가입한 다른 사이트가 해줘서 그 사이트에서 정보를 갖고와 나라는 것을 증명하게끔 하는 과정이라고 생각하면 됩니다.
유저입장에서 보면 이게 끝입니다!
카카오 로그인만 하면 그 사이트에는 회원으로 등록되어있을 테니깐요!
하지만 서버는 이 과정에서 몇 차례 카카오랑 통신을 주고받습니다.
http://localhost:8080/oauth2/authorize/kakao
이런식으로 지정되어 있습니다. 이건 제가 커스텀하게 지정한 것입니다.
이 url로 응답 요청이 들어왔다면 카카오로 인증 요청을 보내도록설정해 놓은거죠
우리가 카카오에 등록한 콜백주소?code =dsf_asdlkfjsdal;kfjskfldjlsa
이런식으로 넘겨줍니다.
여기의 적는 콜백주소는 우리가 카카오 개발자 홈페이지에 입력한 그 주소에요
저같은 경우에는 이렇게 적었습니다
그러면 저는
http://localhost:8080/oauth2/callback/kakao?code=sdfkasjfdlakjsfd
이런식으로 응답을 받게 되는 겁니다 그럼 이렇게 응답을 왜주냐?
그래서 그냥 여기서 인증된 유저구나 하고 인증을 종료하게끔 구현할 수도 있습니다. 하지만 그렇게되면 카카오에 등록한 "그" 유저의 정보를 받아올 수는 없죠 그래서 그 유저의 정보를 다시 카카오에 요청하게끔 유저정보 접근을 위한 엑세스 토큰이 필요합니다.
카카오는 다음과 같이 요구합니다
카카오는 client_secret은 필수가 아니라고해서 저는 안했어요
그러면 우리는 저것들을 담아서 보내줘야 합니다. 지금 로그인을 요청한 사용자의 정보를 받아오기 위해서이죠(이메일, 전화번호, 이름 등등)
그러면 이런식으로 보내주면 되겠죠?
body 타입으로 보내야 하니
header
Content-type: application/x-www-form-urlencoded;charset=utf-8
body
grant_type : authorization_code
client_id : 우리가 카카오등록시 받았던 restapi key
redirect_uri : 우리가 작성한 callback 주소(http://localhost:8080/callback/kakao(저의 경우)
code : 이건 위에서 인증된 유저가 요청했을 때 받아온 코드(동적임)
이렇게 만들어진 요청을
https://kauth.kakao.com/oauth/token
여기로 보내면 됩니다(카카오에 있어요)
그러면 유저에 대한 응답을 받을 수 있습니다!
서버 입장에서 보면 카카오에서 2가지를 받죠?
code
accessToken
code의 경우 현재 카카오로 로그인한 유저가 카카오에 있는 유저다 라는 것을 카카오가 확인하고 그 증거로 보내줬다고 생각하시면 되고
accessToken은 카카오가 인증한 유저의 정보를 갖고있는 카카오에게 이 정보좀 보내줘! 라고 A.com사이트가 카카오에 다시 요청할 때 쓰는 값입니다.
그래서 서버는 이 값을 받고 이 값을 db에 저장시키거나 합니다.
그러면 이제 코드로 볼게요~
spring security는 구글, 페이스북등의 경우 이미 등록이 되어있다고 하네요 그래서 따로 provider설정 없이 registration만 작성하면 된다고 합니다.
하지만 kakao랑 naver는 등록해줘야 한다고하네요
oauth2:
client:
registration:
#kakao 로그인 요청 URL : http://localhost:8080/oauth2/authorize/kakao
kakao:
client-id: 이건 restapikey!!
client-name: Kakao
client-authentication-method: POST # 카카오 접근시 고정값
authorization-grant-type: authorization_code #카카오 접근시 고정값
redirect-uri: "{baseUrl}/oauth2/callback/{registrationId}" #http://localhost:8080/login/oauth/kakao
scope: # 내가 카카오에 요청하는 유저 정보 범위
- profile_nickname
- account_email
#provider 등록
provider:
kakao:
authorization_uri: https://kauth.kakao.com/oauth/authorize
token_uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user_name_attribute: id
직접 클래스파일에 해당 값을 등록시켜주어도 되겠지만 전 간편하게 yml파일에 등록해주었습니다!.
보시면 위해서 설명드린 내용에 해당하는 uri값들을 설정하는 부분들입니다
요렇게 작성해주시고
@EnableWebSecurity
@AllArgsConstructor
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {
....
....
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors()
.and()
.httpBasic().disable()
.exceptionHandling().authenticationEntryPoint(new AuthEntryPoint())
.and()
.csrf().disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin().disable()
.authorizeRequests()
.antMatchers(
"/h2-console/**",
"/oauth2/**",
...
).permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.oauth2Login()
.loginPage("/login")
.authorizationEndpoint()
.baseUri("/oauth2/authorize")
.authorizationRequestRepository(cookieAuthRepositories())
.and()
.redirectionEndpoint()
.baseUri("/oauth2/callback/**")
.and()
.userInfoEndpoint()
.userService(customOAuth2UserService)
.and()
.successHandler(oAuth2SuccessHandler)
...
}
webSecurityConfigurerApdapter를 상속받으면 Cors 처리와 페이지의 접근 권한 처리등을 자세히 구현할 수 있어요.
여기서는 Oauth관련된것만 보겠습니다.
configure(HttpSecurity http)를 상속해서 구현시 접근 가능한 페이지 설정, Oauth 로그인 성공시, 실패시 처리 조건, Oauth 사용시 유저의 정보처리 방법 등을 설정 할 수 있습니다.
위에서
.authorizationEndpoint()
.baseUri("/oauth2/authorize")
이 부분은 저 url로 접근시 oauth 로그인을 요청한다고 생각하시면 됩니다. 위에서 적은
http://localhost:8080/oauth2/authorize/kakao
에서 앞의 주소와 뒤에 kakao를 빼면 이해가 되시겠죠?
프론트에서 카카오 버튼 태그를 이렇게 설정해주시면 바로 로그인 요청이 발송됩니다.
.redirectionEndpoint()
.baseUri("/oauth2/callback/**")
이부분은 아까 위에서 적었던 callback 주소입니다. 이런식으로 설정해 주지 않으면 카카오에 적었던 그 주소로 반환받게 되어있는데, 저는 코드 통일을 위해서 따로 설정해주었습니다.
yml 파일의 이부분이죠
redirect-uri: "{baseUrl}/oauth2/callback/{registrationId}"
그다음
.userService(customOAuth2UserService)
.and()
.successHandler(oAuth2SuccessHandler)
이 부분인데, userService는 oauth로 유저정보를 받아오게되면 그 유저정보를 oauth2 인증 유저 객체로 등록하게끔 구현된 커스텀 클래스입니다.
그리고 successhandler는 정상적으로 유저가 잘 인증되어 등록되면 실행되는 클래스인데요 저 같은 경우에 이 클래스에는 제가 따로 만든 엑세스 토큰과 리프레시 토큰을 유저정보를 바탕으로 생성하여 프론트에 전달해주는 식으로 했습니다.
http://localhost:3000/oauth2/redirect?accessToken=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJydW5 ........................
여기서 localhsot:3000 은 리엑트 서버인데, 가동 안해서 저렇게 나온 것이고 저런식으로 보내도록 uri를 새로 만들수도 있는 것이죠
그럼 customOauth2UserService부분을 볼까요?
@Slf4j
@Service
@AllArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
//정상적인 유저 인증이 완료되면 -> 여기로 오게됨 그 다음에 successhandler로 감
private final UserRepository userRepository;
private final TokenProvider tokenProvider;
// private final AuthenticationManager authenticationManager;
// OAuth2User에는 개인정보(요청)이 들어있음
// 아래 메소드를 바탕으로 인증 처리함
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
System.out.println("인증유저" + oAuth2User);
System.out.println("***********useRequest********");
System.out.println(userRequest);
System.out.println("clientRegistration : " +userRequest.getClientRegistration().getClientName());
System.out.println("clientRegistration : " +userRequest.getClientRegistration().getClientId());
System.out.println("accestoken : " +userRequest.getAccessToken().getTokenValue());
System.out.println("additionaparameter : " +userRequest.getAdditionalParameters());
System.out.println("***********END********");
//userRequest.getAdditionalParameters().put("id_token",userRequest.getAccessToken());
try {
return processOAuth2User(userRequest, oAuth2User);
} catch (AuthenticationException ex) {
throw ex;
} catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex.getCause());
}
}
//로그인 요청한 유저의 등록 아이디와 속성값을 받아옴때 -> Oauth
private OAuth2User processOAuth2User(OAuth2UserRequest userRequest, OAuth2User oAuth2User) throws NoOAuthProviderException, JSONException {
String registrationId = userRequest.getClientRegistration().getRegistrationId();
Map<String, Object> attributes = oAuth2User.getAttributes();
System.out.println("here" + attributes);
// 인증받은 유저 정보가 저장되어있는 곳
OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, attributes);
Optional<User> userOptional = userRepository.findByEmail(new Email(oAuth2UserInfo.getEmail()));
if (userOptional.isPresent()){
User user = userOptional.get();
return UserPrincipal.createUser(user, attributes);
}
User user = registerUser(userRequest, oAuth2UserInfo);
System.out.println("process :" + user);
return UserPrincipal.createUser(user, attributes);
}
//새로운 유저 등록
private User registerUser(OAuth2UserRequest oAuth2UserRequest, OAuth2UserInfo oAuth2UserInfo) throws NoOAuthProviderException, JSONException {
AuthProvider authProvider = AuthProvider.of(oAuth2UserRequest.getClientRegistration().getRegistrationId());
System.out.println("authprovider: " + authProvider);
System.out.println(oAuth2UserInfo.getId());
System.out.println(oAuth2UserInfo.getEmail());
System.out.println(oAuth2UserInfo.getNickName());
User user = User.builder()
.email(oAuth2UserInfo.getEmail())
.passWord(oAuth2UserInfo.getId())
.phoneNumber("")
.nickName(oAuth2UserInfo.getNickName())
.profileImg(null)
.build();
user.setAuthProvider(authProvider);
return userRepository.save(user);
}
}
저기에 제가 system.out으로 찍은 값들을 아래에서 확인해보시면
인증유저Name:
[기밀이라 삭제!], Granted Authorities: [[ROLE_USER, SCOPE_account_email, SCOPE_profile_nickname]], User Attributes: [{id=기밀이라 삭제!, connected_at=2022-02-24T21:17:21Z, properties={nickname=이건 기밀이라 삭제}, kakao_account={profile_nickname_needs_agreement=false, profile_image_needs_agreement=true, profile={nickname=기밀이라 삭제!}, has_email=true, email_needs_agreement=false, is_email_valid=true, is_email_verified=true, email=기밀이라 삭제!}}]
***********useRequest********
org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest@60af3a94
clientRegistration : Kakao
clientRegistration : 기밀이라 삭제!
accestoken : xrghs4lWWyzNNBqg................
additionaparameter : {refresh_token_expires_in=5183999}
***********END********
here{id=기밀이라 삭제 connected_at=2022-02-24T21:17:21Z, properties={nickname=기밀이라 삭제}, kakao_account={profile_nickname_needs_agreement=false, profile_image_needs_agreement=true, profile={nickname=기밀이라 삭제}, has_email=true, email_needs_agreement=false, is_email_valid=true, is_email_verified=true, email=기밀이라 삭제!!
보시면 userRequest로 받은 값을 출력하도록 보면 카카오에서 보낸 정보를 받는 것을 알 수 가 있습니다.
원래대로라면 저 인증과정들을 컨트롤러, 서비스모두 각각 구현하여 작성해야 했지만
// 서큐리티 설정
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
이렇게 라이브러리를 이용하면 구현하는 것이 간편합니다.
정리하자면
제가 개념을 이해하는데 용어들이 어려워서 저같은 분들을 위해 최대한 간단하게 작성할려고 했습니다.
틀린점이나 개념이 있다면 많이 지적해주세요!
감사합니다.
상세한 설명 감사합니다 ~