스프링 oauth2 로그인 구현

이진우·2024년 2월 2일
0

스프링 학습

목록 보기
22/41

위는 정수원님의 OAuth2 강의를 참조하여
기존 OAuth2 로그인 코드에 배운 내용을 적용한 것입니다.

OAuth 란?

기본

OAuth 란 Open + Authorization 의 합성어로 애플리케이션이 사용자 대신하여 애플리케이션(우리가 만든 것)이 사용자 대신 하여 사용자의 대한 제한된 엑세스를 얻기 위해 승인 상호 작용함으로써 애플리케이션이 자체적으로 액세스 권한을 얻도록 한 것이다.

용어

  • Resource Owner(자원 소유자): 보호된 자원에 대한 접근 권한을 부여할 수 있는 주체이다. 그냥 이용자라고 생각하면 편하다.

  • Resource Server(보호자원서버): 타사 어플리케이션에서 접근하는 사용자의 자원이 포함된 서버(ex: 사용자의 자원(이메일,프로필 사진)을 가지고 있는 네이버 서버)

  • Authorization Server(인가 서버): 클라이언트(백앤드+프론트엔드라고 생각하자) 사용자계정에 대한 동의 및 접근을 요청할 때 상호 작용하는 서버로서 클라이언트의 권한 부여 요청을 승인하거나 거부하는 서버이다(네이버 , 구글을 생각하자)

  • 클라이언트: 사용자를 대신하여 권한을 부여받아 사용자의 리소스에 접근하는 애플리케이션(그냥 우리가 만든 애플리케이션이다. 백앤드+ 프론트엔드 를 걍 클라이언트라 생각하자)

  • 보호자원서버에서 사용자의 자원(email,프로필 등) 을 가지고 오려면 accessToken 이 필요하다 . 여기서 accessToken 은 인가 서버 즉 네이버가 걍 발급해주는 것이므로 우리가 만들거나 이런 것은 아니다.

    흐름

    대략 이런 식의 흐름을 가진다.

    우리가 사용할 OAuth2 Client type은 client_secret 의 기밀성을 유지할 수 있는 기밀 클라이언트이다.(반대는 공개 클라이언트)

    기밀 클라이언트의 대표적인 방식인 authorization code 의 방식은 아래 그림과 같다.

    먼저 인가 서버에 code 를 요청하고
    client 는 code 를 받고
    다시 그 코드로 Auth-Server 에 전달하면
    accessToken을 받을 수 있다.(다시 말하지만 이건 그냥 네이버가 발급해주는 것이다)

    인가 서버에 code 를 요청할 때

    이 화면을 생각하면 편하다.
    위에 Url 을 보자.

    처음에 code 를 요청할 떄는 client-secret 값이 필요하지 않다.

  • response type: code,token,id_token 이 있으나 우리는 무조건 code 를 사용할 것이다.위의 흐름인 authorization code 방식을 의미한다.

  • client_id: 이따가 보겠지만 client_id를 application properties 에 등록한다. 그것으로 이 값을 채워 code 를 요청할 수 있다.

  • scope:scope도 마찬가지로 application.properties or yml 에 등록 한다. 사용자의 자원 중 어느 범위를 가져올지 알려준다

  • state: 응용프로그램은 임의의 문자열을 생성 및 요청에 포함하고 사용자가 앱을 승인한 이후에 서버로부터 동일한 값이 반환되는지 확인한다. CSRF 공격을 방지하는데 좋다고 한다.

  • redirect url: 사용자가 로그인을 통해 프로그램을 성공적으로 승인하면 권한 부여 서버는 사용자를 다시 응용 프로그램으로 리디렉션한다.

  • 대부분 이런 형식일 것이다.

    redirect_uri=http://localhost:8080/login/oauth2/code/naver
    redirect_uri=http://localhost:8080/login/oauth2/code/google

    이런 식으로 다들 써주었기 때문이다.

    그런데 중요한 것은 이따가 application. yml 혹은 application.properties 에서 redirection_url 을 써줄 것인데(네이버의 경우) 이 값과 정확히 똑같이 맞춰야 한다.!!!

    토시하나 안틀리고 /login/oauth2/code/google or naver 를 고정하자.

    스프링 내부의 filter로 인해서 이 url 로 매칭시켜 내부적으로 code를 받아온 redirection url을 바탕으로 다시 인가서버로 accessToken 을 요청하기 때문이다!!!

    이 필터이다.

    물론 기존 filter를 상속받아 새로운 필터를 만들어서 해결할 수도 있지만 그건 쫌 귀찮은 작업일 것 같다.

    물론 배포할 때는 IP주소:8080/login/oauth2/code/google 이런 식으로 승인된 리디렉션 url를 추가하면 된다.

    그냥 끝만 고정해주자.

    Authorization Code 방식의 흐름을 다시한번 보면

    이런 식이다.

    Authorization code 방식 이외에도 Implicit Grant 방식, Resouce Owner Password Credentials Grant 방식, Client Credentials Grant 방식, 등등이 많지만 우리가 소셜 로그인을 구현하려면 Authorization code 방식을 사용할 것이고, 다른 방식은 필요하면 공부하도록 하자!

    흐름 + 코드 작성

    https://velog.io/@nefertiri/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B6%80%ED%8A%B8-OAuth2-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-01

    에 좋은 그림이 있다.

    프론트가 백에게 요청

      const NAVER_AUTH_URL =
        API_BASE_URL +
        "/oauth2/authorization/naver";

    API_BASE_URL 은 서버 주소:8080 이런 식으로 설정하면 된다.

    이 주소를 백에게 보내면

    위 필터가 /oauth2/authorization/provider 를 감지하여

    이 화면을 띄우도록 한다.

    그런데 이 화면에는 client_id 등등이 들어가 있는데 이거는 어떻게 띄우는 것일까??

    build.gradle 및 application.yml 설정

    실은 이미 우리 백앤드 서버가 돌고 있을 떄 application.properties 또는 yml 을 바탕으로 다 만들어 둔다.
    일단 build.gradle 부터 추가한다.

    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    
    
    	//oauth gradle 추가
    	implementation "org.springframework.boot:spring-boot-starter-oauth2-client"
    
    	//JWT 추가
    	implementation 'com.auth0:java-jwt:4.2.1'
    	implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
    	runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
    	runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
        
  • OAuth2 를 사용하기 위해 oauth gradle 을 추가한다.
  • OAuth2 인증에 성공하면 accessToken 을 프론트에 직접 반환하기 위해 JWT 관련 을 추가한다(이 accessToken 은 우리가 직접 만드는 것이다)
  • 우리가 accessToken 이 만료되었을 때 refreshToken 을 통해 재발급 받을 수 있다.그 refreshToken 을 저장하기 위하여 redis 관련 을 추가한다

  • 이제 application.yml 을 보자
      security:
        oauth2:
          client:
            registration:
              google:
                client-id: 발급받은 client id
                client-secret: 발급 받은 cleint-secret
                scope: 
                  - profile
                  - email
    
              naver:
                client-id: 발급 받은 client-id
                client-secret: 발급 받은 client-secret
                client-authentication-method: client_secret_post
                authorization-grant-type: authorization_code
                redirect-uri: "http://localhost:8080/login/oauth2/code/naver"
                scope:
                  - name
                  - email
                  - profile_image
                client-name: Naver
    
            provider:
              naver:
                authorization-uri: https://nid.naver.com/oauth2.0/authorize
                token-uri: https://nid.naver.com/oauth2.0/token
                user-info-uri: https://openapi.naver.com/v1/nid/me
                user-info-authentication-method: header
                user-name-attribute: response # Naver 응답 값 resultCode, message, response 중 response 지정
    

    역시 가장 중요한 건 redirect-url 이다. 이것은 배포하면 배포한 http:// IP 주소:8080/login/oauth2/code/naver 로 꼭 바꿔주자!

    전체적인 과정은 위 그림과 같다. 요약하면 application.yml 의 정보를 바탕으로 ClientRegistration 을 채워두고 OAuth2Client 는 이를 참조하여 자원서버 인증서버 등과 통신한다.

    ClientRegistration: 클라이언트의 실질적인 정보가 들어있는 클래스, 인가 서버에 등록되어 있는 클라이언트의 정보. 실제로 클라이언트와 인가서버와 통신 위해 필요한 엔드포인트등의 정보를 담은 클래스.

    이런 식으로 정보가 들어가 있다.

    구글은요? provider 왜 안써주나요??

    글로벌 기업들은 이미 세팅되어 있다.
    CommonOAuth2Provider 에 찾아서 들어가면 깃허브 등등이 있다.

    private static final String DEFAULT_REDIRECT_URL = "{baseUrl}/{action}/oauth2/code/{registrationId}";

    또한 이렇게 기본적인 baseRedirect url 이 있다. 우리는 이미 동일하게 설정하였다.

    구글에 open ID를 써주면 안되나요?

    안된다!
    open id를 써주면 상속해서 사용하는 OAuth2UserService 가 아니라 OidcUserService를 세팅하게 되서 안된다고 한다.


    이제 받아온 코드를 인가서버에게 가서 AccessToken 으로 교환한다.

    이런 과정을 거친다고 한다. 일단 인가 서버로부터 accessToken 을 가져오는 부분은 우리가 할게 없다. 걍 내비두면 된다.

    CustomOAuth2UserService 작성

    이제 accessToken 을 가져왔으니 이걸 바탕으로 사용자의 자원(이메일, 프로필 사진) 을 갖고 있는 서버로 accessToken 을 가지고 요청하고 사용자의 자원을 가져옵시다!

    @Slf4j
    @Service
    @RequiredArgsConstructor
    public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
        private final MemberRepository memberRepository;
    
        private static final String NAVER="naver";
        private static final String GOOGLE="google";
    
        @Override
        public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
    
            //이제 resource 서버로 부터 accessToken 을 가져온 상태입니다. 그 accessToken 을 바탕으로
            //아래 코드를 통해 세팅된 userInfo URL 을 바탕으로 USERINFO 정보를 가지고 올 수 있습니다.
            OAuth2UserService<OAuth2UserRequest,OAuth2User> delegate=new DefaultOAuth2UserService();
            OAuth2User oAuth2User=delegate.loadUser(userRequest);
    
    
            String registrationId=userRequest.getClientRegistration().getRegistrationId();//현재 코드 상 naver 또는 google 이 나올 것입니다.
            SocialType socialType=getSocialType(registrationId);//naver 또는 google 을 바탕으로 SocialType 을 Enum 형식으로 가지고 옵니다.
    
    
            String userNameAttributeName=userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();//Naver 의 경우는 response 가 나올것이고 , google 의 경우는 sub 이 나올 것입니다.
            //이는 밑에 DefaultOAuth2User 를 상속한 CustomOAuth2User에 활용됩니다. 기본적으로 DefalutOAuth2User 에 필요한 정보 입니다 .getName 을 위한 키로 활용되고 있습니다
    
    
            //실제로 User 에 대한 정보가 담겨있습니다. 이메일 프로필 사진 등의 정보를 Map에 담아둡니다.
            Map<String,Object> attributes=oAuth2User.getAttributes();
    
            //NAVER 와 Google 등 여러 소셜 로그인 들의 사용자의 정보(이메일,프로필 사진) 을 주는 key 값이 다른데
            //그것을 일반화하여 사용하면 편리하기 때문에 사용합니다.
            OAuthAttributes extractAttributes=OAuthAttributes.of(socialType,userNameAttributeName,attributes);
    
            //밑에서 보는 것처럼 사용자의 정보에서 email,socialType을 추출하여 DB에 존재하는 경우 그것을 가져오고
            //처음 로그인한 경우 새로운 member 를 생성합니다.
            Member createdMember=getMember(extractAttributes,socialType);
    
    
    
            //CustomOAuth2User는 DefaultOAuth2User를 상속받습니다.
            //1)DefaultOAuth2User에 필요한 권한 정보입니다.
            //2)DefaultOAuth2User에 필요한 사용자의 특성 정보입니다.
            //3)DefaultOAuth2User에 필요한 key 정보입니다(네이버:response, google: sub)
            //email은 제가 임의로 추가해보았습니다. 사실상 이메일 정보는 사용자의 특성 정보에 있지만
            //그래도 한번 넣어보았습니다. 이는 나중에 OAUth2SuccessHandler 즉 OAuth2인증이 성공한 이후에
            //로직상 필요할 때 넣어주시면 됩니다.
            return new CustomOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority(createdMember.getRole().name())),
                attributes,
                extractAttributes.getNameAttributeKey(),
                createdMember.getEmail()
            );
    
        }
        //"naver","google" String 타입을 EnumType으로 변화시켜서 사용합니다.
        private SocialType getSocialType(String registrationId){
            if(NAVER.equals(registrationId)){
                return SocialType.NAVER;
            }
            else if(GOOGLE.equals(registrationId)){
                return SocialType.GOOGLE;
            }
            return null;
        }
    
        //email과 socialtype으로 member를 가져오는 메서드
        private Member getMember(OAuthAttributes attributes,SocialType socialType){
            Member findMember=memberRepository.findBySocialTypeAndEmail(socialType,attributes.getOauth2UserInfo().getEmail()).orElseGet(()->saveMember(attributes,socialType));
            return findMember;
        }
    
        //처음 로그인한 경우 member 를 저장하는데 password는 임의로 저장하였습니다.
        // 그 이유는 소셜로그인의 경우는 비밀번호가 넘어오지 않습니다(당연히)
        // 하지만 만약 소셜 로그인 뿐만 아니라 우리의 일반 로그인도 사용한다면 비밀번호에 대한 password 필드가 존재합니다.(BCrypt 화 시켜서 저장시켜야 합니다)
        //하지만 소셜로그인은 비밀번호를 넣을게 없으므로 UUID로 random 하게 생성하였습니다.
        private Member saveMember(OAuthAttributes attributes,SocialType socialType){
            Member createdMember=attributes.toEntity(socialType,attributes.getOauth2UserInfo(),UUID.randomUUID()+"password");
            return memberRepository.save(createdMember);
    
        }
    }

    위에서 loadUser는 OAuth2UserRequest 를 받아 OAuth2User 를 가져온 후 CustomOAuth2User 를 리턴하는 역할을 합니다.

    이 과정이며

    OAuth2UserRequest 는

    으로 구성되어 있습니다. 즉 사용자의 자원을 가져오기 위한 accessToken 과 어디로 사용자의 자원을 요청해야 하는지 (ClientRegistration 내 정보) 등등 필요한 정보가 있습니다.

    이후

    CustomOAuth2User를 반환하면 그 후 과정은 OAuth2AuthenticationToken 을 거쳐서 SecurityContext 에 담겨 Authentication 으로 조회할 수 있습니다.

    OAuth2User oAuth2User=delegate.loadUser(userRequest); 를 통해 사용자의 자원을 가지고 오며 이를 통해 CustomUser를 반환합니다.

    @Getter
    public class CustomOAuth2User extends DefaultOAuth2User {
       private String email;
    
    
       public CustomOAuth2User(
           Collection<? extends GrantedAuthority> authorities, Map<String, Object> attributes, String nameAttributeKey,String email) {
           super(authorities, attributes, nameAttributeKey);
           this.email = email;
       }
    }
    

    email 은 나중에 attributes 에서 꺼낼수 있어서 안써줘도 되나 그냥 한번 써봤습니다.!

    이후에 return 된 CustomUser 를 SecurityContext 에 담는 작업을 스프링이 알아서 해줍니다 .

    SuccessHandler

    이후 SecurityContext 에 담긴 것을 메서드 파라미터에 Authentication 을 써줌으로써 가져올 수 있습니다.

    @Slf4j
    @Component
    @RequiredArgsConstructor
    public class OAuth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler {
        private final TokenProvider tokenProvider;
        private final ObjectMapper objectMapper;
        private final RedisTemplate<String,String> redisTemplate;
    
        //소셜 로그인이 성공하고 마무리 단계입니다.
        //Authentication.getPrincipal 안에는 우리가 만들었던 CustomOAuth2User가 들어있습니다.
        @Override
        public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
            log.info("OAuth2 로그인 성공!");
            //accessToken 과 refreshToken 을 각각 만들어서 반환합니다.
            try{
    
                CustomOAuth2User oAuth2User=((CustomOAuth2User) authentication.getPrincipal());
    
                //getName 은 아까 nameAttribute key 를 바탕으로 그 key 에 대한 값을 가지고 옵니다.
                //아까 naver 의 경우 저희는 키가 response 임을 확인하였습니다. 이 경우 반환값은 데이터 전체일 것입니다 ex:{id=xATIv6lgOcf-zKpGuCMjpPMGcG1YSWJZg2dMBMq8B-M, nickname=이진우, profile_image=https://phinf.pstatic.net/contact/20230615_207/1686815329264fVtbk_PNG/%BD%BA%C5%A9%B8%B0%BC%A6_2023-06-15_164754.png, email=dionisos198@naver.com, name=이진우}
                //google 의 경우 sub 이였으므로 sub에 해당하는 값이 넘어오겠습니다.
                //솔직히 왜 필요한지는 모르겠습니다. ㅎ
                System.out.println(oAuth2User.getName());
    
    
                TokenDto tokenDto=tokenProvider.createTokenByOAuth(oAuth2User);//OAuth2로 새로운 access,refreshToken 생성
                // TokenResponseDto 객체 생성:
                TokenResponseDto tokenResponseDto = new TokenResponseDto(tokenDto.getType(),tokenDto.getAccessToken(),tokenDto.getRefreshToken(),tokenDto.getAccessTokenValidationTime());
    
                //redis 에 email 을 키 값: refreshToken 을 값으로 저장합니다.
                redisTemplate.opsForValue().set(oAuth2User.getEmail(),tokenDto.getRefreshToken(),tokenDto.getRefreshTokenValidationTime(), TimeUnit.MILLISECONDS);
    
                // Dto 객체를 JSON으로 변환하여 응답으로 전송
                response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                objectMapper.writeValue(response.getWriter(), tokenResponseDto);
    
            }catch (Exception e){
                throw e;
            }
        }
    
    
    }
  • authentication.getPrincipal 을 통해 아까 우리가 만들어서 반환했던 CustomOAuth2User 를 사용할 수 있습니다
  • 이제 이 정보를 바탕으로 우리가 직접 토큰을 생성해서 토큰을 만들고
    반환하면 됩니다.

    반환할 때는 쿠키에 값을 넣거나, url 파라미터를 통해 값을 넣거나 이런 식으로 선택할 수 있습니다. 헤더에 accessToken ,refreshToken 을 넣고 response.sendRedirect 를 하면 로컬에서 돌릴 떄는 되는데 프론트랑 연동하면 헤더가 아예 사라질 수 있으니 주의합니다.

    이 부분에 대해 공부가 필요해보입니다.(response.sendRedirect )

    저는 편리하게 실험하려고 Body에 넣고 걍 전송해버렸습니다.

    테스트 및 깃허브 주소

    테스트 하는 상황은 다음과 같습니다.

    GUEST 는 USER 가 접근 가능한 자원에 접근 할 수 없다.
    처음 로그인시 GUEST로 역할을 고정한다.
    GUEST 는 특정행위를 수행하면 Role,Authority 를 USER로 바꿀수 있다.
    그러면 USER 의 권한으로 USER가 접근 가능한 자원에 접근 한다.

    소셜로그인 진행

    OAuth2 SuccessHandler 를 통해 토큰을 생성 및 반환

    GUEST 로 접근 가능함 확인

    USER 만 접근 가능한 자원은 GUEST 로 접근 불가

    403에러

    그로 인해 역할 변경 수행

    역할 변경으로 인한 토큰 재발급

    USER가 접근 가능한 자원에 접근할 수 있음을 확인

    깃허브 주소?

    필요한 사람이 있으면 업로드할게요 ㅠ 누가 봐줘 제발 ㅎ

    profile
    기록을 통해 실력을 쌓아가자

    1개의 댓글

    comment-user-thumbnail
    2024년 3월 5일

    글 너무 잘봤습니다! 저도 현재 진행하는 프로젝트에서 프론트 연동 시 sendredirect에서 쿠키가 사라지는 문제가 발생하네요ㅠㅠ 혹시 실례가 안된다면 깃허브 주소를 알 수 있을까요??ㅠㅠ

    답글 달기