본문 바로가기
개발

OAuth2.0 + SpringSecurity

by hwan-da 2024. 8. 4.

이번주는 OAuth2.0과 SpringSecuirty를 이용한 사용자 로그인을 가지고 왔다.

프로젝트에 이미 써먹고 있지만, 원리부터 과정까지 이해하니 이전에 코드만 이해하고 적용했던 것보다 훨씬 이해가 잘 된다.

다음주는 마지막으로 OAuth 2.0과 Jwt를 이용한 로그인을 공부해보겠다.

실습 목표 및 간단한 동작 원리

실습 목표

Oauth20 클라이언트와 스프링 시큐리티 6 프레임워크를 활용하여 신뢰할 수 있는 외부 사이트로부터 인증을 받고, 전달 받은 유저 데이터를 활용하여 세션을 만들고 인가를 진행

구현

  • 인증 : 네이버 소셜 로그인, 구글 소셜 로그인(코드 방식)
  • 인가 : 세션 방식을 통한 경로별 접근 권한
  • 인증 정보 DB 저장 후 추가 정보 기입

OAuth2.0 인증 방식 및 시큐리티 동작 원리

  • 인증 서버와 자원 서버 및 우리의 서버
    • 로그인 요청
    • → SpringBoot(로그인 페이지 리디렉션 필터)
    • → OAuth 인증 서버 로그인 페이지
    • → SpringBoot(시큐리티 인증 필터로 코드 발급)
    • → OAuth 인증 서버(access token을 얻기 위해 다시 요청)
    • → SpringBoot(access token 발급 받음)
    • → OAuth 인증 서버(사용자 정보 발급 받기 위해 토큰과 user 정보를 보내줌)

기타

  • OAuth2.0 코드 방식 인증 활용
  • 인증 후 발급된 정보로 세션을 만들고 SSR 방식으로 모든 페이지를 응답
  • 소셜 로그인을 통해 인증 받은 데이터는 우리의 서비스 데이터베이스에 저장한 후 관리 진행
    • 관리를 하지 않고 인증만 받은 후 사용할 수 있지만, 추가적인 사용자 정보나 어떠한 사용자가 우리의 서비스를 활용하는지 확인하기 위해 관리하는 것을 권장

프로젝트 생성 및 의존성 추가

기본 컨트롤러 및 VIEW 생성

  • MainController

      package com.practice.oauthsession.controller;
    
      import org.springframework.stereotype.Controller;
      import org.springframework.web.bind.annotation.GetMapping;
    
      @Controller
      public class MainController {
    
          @GetMapping("/")
          public String mainPage() {
              return "main";
          }
    
      }
  • MyController

      package com.practice.oauthsession.controller;
    
      import org.springframework.stereotype.Controller;
      import org.springframework.web.bind.annotation.GetMapping;
    
      @Controller
      public class MyController {
    
          @GetMapping("/my")
          public String myPage() {
              return "my";
          }
    
      }
  • main.mustache

  • my.mustache

동작 원리

코드 방식

  • 클라이언트 → 로그인 시도(/oauth2/authorization/naver or google)
  • → OAuth2AuthorizationrRequestRedirectFilter
  • → 소셜 로그인 인증 서버(해당 서비스 로그인 페이지 응답 → 사용자 로그인)
  • → 성공하면 미리 등록한 우리 서비스 특정 경로로 리다이렉트
  • → 로그인 성공 리다이렉트 url(/login/oauth2/code/naver or google) + 인증 서버에서 code를 줌
  • → OAuth2LoginAuthenticationFilter에서 해당 요청을 받음
  • → OAuth2LoginAuthenticationProvider로 code와 정보를 넘겨줌
  • → code와 정보를 가지고 인증 서버에 특정 정보와 code를 넘겨줌 → accessToken 받음(코드 소멸)
  • → OAuth2LoginAuthenticationProvider에서 소셜 리소스 서버로 accessToken을 보내 유저 정보를 요청함
  • → OAuth2UserDetailsService에서 세션 저장과 같은 나머지 시큐리티 로직이 동작됨

각각의 필터가 동작하는 주소(관습)

  • OAuth2AuthorizationRequestRedirectFilter

      /oauth2/authorization/서비스명(naver, google)
  • OAuth2LoginAuthenticationFilter : 외부 인증 서버에 설정할 redirect url

      /login/oauth2/code/서비스명(naver, google)

OAuth2 인증 및 동작을 위한 변수들

변수 설정만 진행 시

  • OAuth2AuthorizationRequestRedirectFilter → OAuth2LoginAuthenticationFilter → OAuth2LoginAuthenticationProvider
  • 과정을 추가 설정하지 않아도 자동으로 진행

사용자는 UserDetailsService만 구현하면 됨

  • application.properties
    • spring.security

OAuth2 변수 역할

  • application.properties

      # registartion
      spring.security.oauth2.client.registration.naver.client-name=서비스명
      spring.security.oauth2.client.registration.naver.client-id=서비스에서 발급 받은 아이디
      spring.security.oauth2.client.registration.naver.client-secret=서비스에서 발급 받은 비밀번호
      spring.security.oauth2.client.registration.naver.redirect-uri=서비스에 등록한 우리쪽 로그인 성공 URI
      spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
      spring.security.oauth2.client.registration.naver.scope=리소스 서버에서 가져올 데이터 범위
    
      # provider
      spring.security.oauth2.client.provider.naver.authorization-uri=서비스 로그인 창 주소
      spring.security.oauth2.client.provider.naver.token-uri=토큰 발급 서버 주소
      spring.security.oauth2.client.provider.naver.user-info-uri=사용자 정보 획득 주소
      spring.security.oauth2.client.provider.naver.user-name-attribute=응답 데이터 변수
  • 예시

      # registartion
      spring.security.oauth2.client.registration.naver.client-name=naver
      spring.security.oauth2.client.registration.naver.client-id=서비스에서 발급 받은 아이디
      spring.security.oauth2.client.registration.naver.client-secret=서비스에서 발급 받은 비밀번호
      spring.security.oauth2.client.registration.naver.redirect-uri=http://localhost:8080/login/oauth2/code/naver
      spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
      spring.security.oauth2.client.registration.naver.scope=name, email
    
      # provider
      spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
      spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
      spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
      spring.security.oauth2.client.provider.naver.user-name-attribute=response
  • registration은 외부 서비스에서 우리 서비스를 특정하기 위해 등록하는 정보여서 등록이 필수적

  • 하지만, provider의 경우 OAuth2 클라이언트 의존성이 유명한 서비스의 경우 내부적으로 데이터를 가지고 있음

    • 즉, 구글은 provider 설정 따로 안해줘도 됨

SecurityConfig 설정

package com.practice.oauthsession.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        http
                .csrf((csrf) -> csrf.disable());

        http
                .formLogin((login) -> login.disable());

        http
                .httpBasic((basic) -> basic.disable());

        http
                // oauth2Client : 내부를 직접 구현해야 함
                // ouath2Login : 필터와 세팅을 자동으로 지정해줌
                .oauth2Login(Customizer.withDefaults());

        http
                .authorizeHttpRequests((auth) -> auth
                        .requestMatchers("/", "/oauth/**", "/login/**").permitAll()
                        .anyRequest().authenticated()
                );

        return http.build();
    }

}

네이버 소셜 로그인 신청

  • callback URL
    • 인증 서버에서 로그인 한 후 code를 받을 주소
    • 관습적으로 /login/oauth2/code/서비스명으로 지정함

카카오 소셜 로그인 신청

  • REST API 키 : client_id
  • Client Secret :
    • "보안" 탭으로 이동
    • "코드 생성" 버튼 클릭 (아직 생성하지 않은 경우)
    • 생성된 코드가 client_secret

OAuth2UserService 응답 받기

OAuth2UserService

  • DefaultOAuth2UserService 상속

    • OAuth2UserService의 구현체이기 때문에 상관없음

        package com.practice.oauthsession.service;
      
        import com.practice.oauthsession.dto.KakaoResponse;
        import com.practice.oauthsession.dto.NaverResponse;
        import com.practice.oauthsession.dto.OAuth2Response;
        import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
        import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
        import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
        import org.springframework.security.oauth2.core.user.OAuth2User;
        import org.springframework.stereotype.Service;
      
        @Service
        public class CustomOauth2UserService extends DefaultOAuth2UserService {
      
            @Override
            public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
                // 유저 정보 가져오기
                OAuth2User oAuth2User = super.loadUser(userRequest);
                // 확인용
                System.out.println(oAuth2User.getAttributes());
      
                // 네이버나 카카오 혹은 다른 소셜에서 응답이 넘어올 것
                // 어디서 온 건지 알려줘야 함
                String registrationId = userRequest.getClientRegistration().getRegistrationId();
      
                OAuth2Response oAuth2Response = null;
                // 응답 규격이 달라서 다른 DTO 바구니에 담아야 함
                if(registrationId.equals("naver")) {
                    oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
                }
                // 이것도 카카오에 맞춰서 바꾸기
                else if(registrationId.equals("kakao")) {
                    oAuth2Response = new KakaoResponse(oAuth2User.getAttributes());
                }
                else {
                }
      
                return null;
            }
      
        }
      

OAuth2Response

  • 소셜 서비스에서 주는 데이터는 서비스마다 다르기 때문에 DTO를 만들어줘야 함

    • 네이버 데이터
    • 구글 데이터
  • DTO → 인터페이스 OAuth2Response

    • NaverResponse

        package com.practice.oauthsession.dto;
      
        import lombok.RequiredArgsConstructor;
      
        import java.util.Map;
      
        public class NaverResponse implements OAuth2Response {
      
            private final Map<String, Object> attribute;
      
            public NaverResponse(Map<String, Object> attribute) {
                this.attribute = (Map<String, Object>) attribute.get("response");
            }
      
            @Override
            public String getProvider() {
                return "naver";
            }
      
            @Override
            public String getProviderId() {
                return attribute.get("id").toString();
            }
      
            @Override
            public String getEmail() {
                return attribute.get("email").toString();
            }
      
            @Override
            public String getName() {
                return attribute.get("name").toString();
            }
        }
      

응답 데이터로 로그인 완료

CustomOauth2UserService 나머지 구현

package com.practice.oauthsession.service;

import com.practice.oauthsession.dto.CustomOAuth2User;
import com.practice.oauthsession.dto.KakaoResponse;
import com.practice.oauthsession.dto.NaverResponse;
import com.practice.oauthsession.dto.OAuth2Response;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

@Service
public class CustomOauth2UserService extends DefaultOAuth2UserService {

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        // 유저 정보 가져오기
        OAuth2User oAuth2User = super.loadUser(userRequest);
        // 확인용
        System.out.println(oAuth2User.getAttributes());

        // 네이버나 카카오 혹은 다른 소셜에서 응답이 넘어올 것
        // 어디서 온 건지 알려줘야 함
        String registrationId = userRequest.getClientRegistration().getRegistrationId();

        OAuth2Response oAuth2Response = null;
        // 응답 규격이 달라서 다른 DTO 바구니에 담아야 함
        if(registrationId.equals("naver")) {
            oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
        }
        // 이것도 카카오에 맞춰서 바꾸기
        else if(registrationId.equals("kakao")) {
            oAuth2Response = new KakaoResponse(oAuth2User.getAttributes());
        }
        else {
            return null;
        }

        String role = "ROLE_USER";

        // oAuth2Response에는 role이 없기 때문에 넘겨주어야 함
        // CustomOAuth2User를 만들어주어야 함
        return new CustomOAuth2User(oAuth2Response, role);
    }

}

CustomOauth2User 구현

package com.practice.oauthsession.dto;

import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.user.OAuth2User;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;

@RequiredArgsConstructor
public class CustomOAuth2User implements OAuth2User {

    private final OAuth2Response oAuth2Response;
    private final String role;

    @Override
    public Map<String, Object> getAttributes() {
        // Attributes는 서비스에서 넘어오는 모든 데이터

        return null;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {

        Collection<GrantedAuthority> collection = new ArrayList<>();

        collection.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return role;
            }
        });

        return collection;
    }

    @Override
    public String getName() {
        // 전달 받은 oAuth2Response에 이름 존재
        return oAuth2Response.getName();
    }

    // Override 하지 않은 메서드 작성
    public String userName() {
        // ID를 특별하게 만들어줄 것임
        return oAuth2Response.getProvider() + " " + oAuth2Response.getProviderId();
    }
}

유저 정보 DB 저장

OAuth2UserService에서 유저 정보를 DB에 저장

  • 이미 있는 유저인지 조회
    • 없으면 신규 저장
    • 있으면 데이터 업데이트 시 업데이트
// 해당 로직 추가

// 여기서 유저 정보 DB 저장 로직 작성
        // naver에서는 provider와 providreId를 가지고 ID로 지정
        String username = oAuth2Response.getProvider() + " " + oAuth2Response.getProviderId();

        UserEntity existData = userRepository.findByUsername(username);

        String role = null;
        if(existData == null) {
            UserEntity user = new UserEntity();
            user.setUsername(username);
            user.setEmail(oAuth2Response.getEmail());
            user.setRole("ROLE_USER");

            role = user.getRole();
            userRepository.save(user);
        }
        else {
            role = existData.getRole();
            existData.setEmail(oAuth2Response.getEmail());

            userRepository.save(existData);
        }

커스텀 로그인 페이지

기본 OAuth2 로그인 페이지 존재

커스텀 로그인 페이지 설정

  • login.mustache

      <h1>login page</h1>
      <hr>
          <a href="/oauth2/authorization/naver">naver login</a><br>
          <a href="/oauth2/authorization/google">google login</a>
      </body>
  • LoginController

      package com.practice.oauthsession.controller;
    
      import org.springframework.stereotype.Controller;
      import org.springframework.web.bind.annotation.GetMapping;
    
      @Controller
      public class LoginController {
    
          @GetMapping("/login")
          public String loginPage() {
              return "login";
          }
    
      }
    

SecurityConfig OAuth2 커스텀 로그인 페이지 등록

  • SecurityConfig 수정

              http
                      // oauth2Client : 내부를 직접 구현해야 함
                      // ouath2Login : 필터와 세팅을 자동으로 지정해줌
                      // 유저 엔드포인트 지정 -> 데이터를 받을 수 있는 userDetailsService를 등록해주는 엔드포인트라는 뜻
                      .oauth2Login((oauth2) -> oauth2
                              // 커스텀 로그인 페이지 등록
                              .loginPage("/login")
                              // 요청 엔드포인트 등록
                              .userInfoEndpoint(userInfoEndpointConfig ->
                                      userInfoEndpointConfig.userService(customOauth2UserService))
                      );

Client Registration

  • 기존 application.properties 변수 설정 파일에서 설정했던 소셜 로그인 제공 서비스에 대한 정보 가입을 관련 클래스를 통해 직접 진행하는 방법

커스텀을 진행하기 위해 클래스를 직접 구현하는 것이 필수

  • ClientRegistration
    • 서비스 별 OAuth2 클라이언트의 등록 정보를 가지는 클래스
  • ClientRegistrationRepository
    • ClientRegistration의 저장소, 서비스별 ClientRegistration들을 가짐

application.properties의 oauth2 관련 변수를 모두 주석 처리 후 작성

  • SocialClientRegistration 작성

      package com.practice.oauthsession.oauth2;
    
      import org.springframework.security.oauth2.client.registration.ClientRegistration;
      import org.springframework.security.oauth2.core.AuthorizationGrantType;
      import org.springframework.stereotype.Component;
    
      @Component
      public class SocialClientRegistration {
    
          public ClientRegistration naverClientRegistration() {
              return ClientRegistration.withRegistrationId("naver")
                      .clientId("OpD7Da5aVaxvSDQa65PT")
                      .clientSecret("7SGpG0Xu47")
                      .redirectUri("http://localhost:8080/login/oauth2/code/naver")
                      .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                      .scope("name", "email")
                      .authorizationUri("https://nid.naver.com/oauth2.0/authorize")
                      .tokenUri("https://nid.naver.com/oauth2.0/token")
                      .userInfoUri("https://openapi.naver.com/v1/nid/me")
                      .userNameAttributeName("response")
                      .build();
          }
    
          public ClientRegistration kakaoClientRegistration() {
              return ClientRegistration.withRegistrationId("kakao")
                      .clientId("bebf13b3e768b4306e7930e69e7aa30e")
                      .redirectUri("http://localhost:8080/login/oauth2/code/kakao")
                      .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                      .scope("profile_nickname")
                      .authorizationUri("https://kauth.kakao.com/oauth/authorize")
                      .tokenUri("https://kauth.kakao.com/oauth/token")
                      .userInfoUri("https://kapi.kakao.com/v2/user/me")
                      .userNameAttributeName("id")
                      .build();
          }
    
      }
    
  • CustomClientRegistartionRepo 작성

      package com.practice.oauthsession.oauth2;
    
      import lombok.RequiredArgsConstructor;
      import org.springframework.context.annotation.Configuration;
      import org.springframework.security.oauth2.client.registration.ClientRegistration;
      import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
      import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
    
      @Configuration
      @RequiredArgsConstructor
      public class CustomClientRegistrationRepo {
    
          // SocialClientRegistration 의존성 주잊
          private final SocialClientRegistration socialClientRegistration;
    
          public ClientRegistrationRepository clientRegistrationRepository() {
              // 두 가지 방식 존재
              // 1. 인메모리 저장
              // 2. JDBC로 DB에 연결하여 저장
              // 여기서는 인메모리로 저장
              return new InMemoryClientRegistrationRepository(socialClientRegistration.naverClientRegistration(), socialClientRegistration.kakaoClientRegistration());
          }
      }
    
  • securityConfig 수정

      http
                      // oauth2Client : 내부를 직접 구현해야 함
                      // ouath2Login : 필터와 세팅을 자동으로 지정해줌
                      // 유저 엔드포인트 지정 -> 데이터를 받을 수 있는 userDetailsService를 등록해주는 엔드포인트라는 뜻
                      .oauth2Login((oauth2) -> oauth2
                              // 커스텀 로그인 페이지 등록
                              .loginPage("/login")
                              // 변수 설정이 아닌 커스텀 할 수 있게 레포지토리 등록
                              .clientRegistrationRepository(customClientRegistrationRepo.clientRegistrationRepository())
                              // 요청 엔드포인트 등록
                              .userInfoEndpoint(userInfoEndpointConfig ->
                                      userInfoEndpointConfig.userService(customOauth2UserService))
                      );

OAuth2AuthorizationRequestRedirectFilter

로그인 페이지에서 GET : /oauth2/authorization 서비스 경로로 요청

  • OAuth2 의존성에 의존해 OAuth2AuthorizationRequestRedirectFilter에서 해당 요청을 받음
  • 이후 내부 프로세스를 진행
  • OAuth2AuthorizationRequestRedirectFilter 요청을 받은 후 해당하는 서비스의 로그인 URI로 요청을 리디렉션
  • 이때 서비스의 정보는 ClientRegistrationRepository에서 가져옴

OAuth2AuthorizationRequestRedirectFilter

  • 한 번만 실행되는 OncePerRequestFilter를 상속 받아서 한 번만 실행됨
  • doFilterInternal을 통해 리디렉션 시킴

OAuth2LoginAuthenticationFilter

OAuth2LoginAuthenticationFilter

  • 인증 서버에서 로그인을 성공한 뒤 우리 서버측으로 발급되는 CODE를 획득하고, CODE를 통해 Access 토큰과 User 정보를 획득하는 OAuth2LoginAuthenticationProvider를 호출하는 과정을 시작하는 필터
  • attemptAuthentication에서 OAuth2LoginAuthenticationProvider를 호출

'개발' 카테고리의 다른 글

[OAuth2] X(구 트위터) 로그인 API 사용기  (2) 2024.11.17
Docker 공부하기 (기초)  (1) 2024.09.01
JWT 심화  (1) 2024.08.11
스프링 시큐리티(Spring Security) + JWT  (0) 2024.07.21
스프링 시큐리티(Spring Security) 기본  (6) 2024.07.14