Spring Security OAuth 설정 및 이해하기

이봐요이상해씨·2022년 2월 25일
3

SpringBoot

목록 보기
9/10
post-thumbnail

프로젝트 하면서 OAuth를 로그인도 추가해할 일이 생겼습니다.
총 3가지 루트의 로그인을 지원합니다 (구글, 네이버, 카카오)
이 글은 Oauth 개념 정리 및 구현 과정에 대한 기록을 남기기 위해 작성하였습니다.

OAuth란?

이미 많은 자세한 설명이 있기 때문에 간단한 개념을 설명한다면 나라는 것을 증명해주는 서비스가 대신 나임을 증명한다고 생각하시면 됩니다.

예를 들면 내가 A.com이라는 사이트에 접속하여 이 사이트에서 제공하는 회원만 사용할 수 있는 서비스를 사용하고 싶습니다. 하지만 가입이 되어있지 않아 회원임을 모른다면 나는 그 사이트에 회원가입을 해야만 하죠
그래서 A.com 에 회원 가입없이도 이미 내가 가입한 서비스가 나라는 것을 이미 내가 가입한 다른 사이트가 해줘서 그 사이트에서 정보를 갖고와 나라는 것을 증명하게끔 하는 과정이라고 생각하면 됩니다.

한마디로 나를 다른 친구에게 알려줄 때 이미 날 알고 있는 친구가 대신 알려주는 셈이죠

과정은?

유저입장

  1. 먼저 내가 A.com 사이트에 접속합니다.
  2. 사이트에서 로그인을 요청하는데, 이때

    요런 버튼을 대신 누르면 이제 이 회사가 나라는 것을 A.com 사이트에 대신 증명해주는 겁니다!
  3. 처음 이 버튼을 누르게되면 카카오 로그인 화면이 나타나게 되고
  4. 로그인을 하게되면 내가 카카오에 가입이 되어있으니 증명이되었다! 라고 A.com사이트에 다시 알려주게됩니다.
  5. 이 과정에서 A.com사이트는 카카오에게 현재 이 유저의 정보(닉네임, 핸드폰번호, 이메일 등등)을 카카오에게 같이 넘겨달라고 미리 설정해 둘 수 도 있습니다. 그래서 가끔 보면 난 핸드폰이나 이메일을 알려주지도 않았는데, 이미 그사이트가 아는 경우도 있죠

유저입장에서 보면 이게 끝입니다!
카카오 로그인만 하면 그 사이트에는 회원으로 등록되어있을 테니깐요!

서버입장

하지만 서버는 이 과정에서 몇 차례 카카오랑 통신을 주고받습니다.

  1. 유저가 카카오 버튼을 눌러 로그인을 카카오 로그인을 진행합니다.
    이떄 이 버튼의 href는
 http://localhost:8080/oauth2/authorize/kakao

이런식으로 지정되어 있습니다. 이건 제가 커스텀하게 지정한 것입니다.
이 url로 응답 요청이 들어왔다면 카카오로 인증 요청을 보내도록설정해 놓은거죠

  1. 유저가 카카오 로그인을 하면 -> 유저의 인증이 완료된 것입니다!
    즉 유저가 카카오에 가입이 되어있다는 것을 카카오는 확인을 하고
우리가 카카오에 등록한 콜백주소?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가지를 받죠?

  1. code

  2. accessToken

    code의 경우 현재 카카오로 로그인한 유저가 카카오에 있는 유저다 라는 것을 카카오가 확인하고 그 증거로 보내줬다고 생각하시면 되고

    accessToken은 카카오가 인증한 유저의 정보를 갖고있는 카카오에게 이 정보좀 보내줘! 라고 A.com사이트가 카카오에 다시 요청할 때 쓰는 값입니다.

그래서 서버는 이 값을 받고 이 값을 db에 저장시키거나 합니다.

그러면 이제 코드로 볼게요~

application.yml 설정

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값들을 설정하는 부분들입니다

요렇게 작성해주시고

SecurityConfig.class


@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부분을 볼까요?

customOauth2UserService.class

@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'

이렇게 라이브러리를 이용하면 구현하는 것이 간편합니다.

정리하자면

  1. 유저가 A.com 사이트에 접속함
  2. 유저는 A.com의 카카오 로그인 버튼을 눌름
  3. 유저가 카카오 로그인을 진행함
  4. 유저정보가 있으면 카카오에서 code를 A.com에 반환
  5. A.com은 이 code를 갖고 카카오에 유저정보를 갖고오기 위해 accessToken을 요청함(code값을 바탕으로 요청)
  6. code값이 유효하면 accessToken을 반환해줌
  7. A.com은 이 accessToken을 바탕으로 카카오에게 유저정보 요청
  8. 유저정보를 A.com이 카카오에서 받음

제가 개념을 이해하는데 용어들이 어려워서 저같은 분들을 위해 최대한 간단하게 작성할려고 했습니다.
틀린점이나 개념이 있다면 많이 지적해주세요!
감사합니다.

4개의 댓글

comment-user-thumbnail
2022년 9월 27일

상세한 설명 감사합니다 ~

답글 달기
comment-user-thumbnail
2023년 6월 27일

혹시 postman 으로 테스트가 가능한가요???

답글 달기
comment-user-thumbnail
2023년 10월 25일

설명 감사합니다.

답글 달기
comment-user-thumbnail
2023년 12월 31일

정리 엄청 잘되어있네요.. 대단합니다

답글 달기