[Spring Security/OAuth2] 스프링 OAuth2 클라이언트 세션

황인찬·2024년 8월 9일
post-thumbnail

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

필수 의존성

  • Lombok
  • Spring Web
  • Mustache
  • Spring Security
  • OAuth2 Client
  • Spring Data JPA
  • MySQL Driver

기본 컨트롤러 및 View 생성

  • MainController
@Controller
public class MainController {

    @GetMapping("/")
    public String mainP() {
        return "main";
    }
}
  • main.mustache
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Main Page</title>
</head>
<body>
main page
</body>
</html>
  • MyController
@Controller
public class MyController {

    @GetMapping("/my")
    public String myP() {
        return "my";
    }
}
  • my.mustache
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>My Page</title>
</head>
<body>
my page
</body>
</html>

OAuth2UserService 구현

OAuth2UserService

  • CustomOAuth2UserService
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
    //DefaultOAuth2UserService OAuth2UserService의 구현체

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        //유저 정보 가져오기
        OAuth2User oAuth2User = super.loadUser(userRequest);
        System.out.println(oAuth2User.getAttributes());

        //registrationId를 통해 구글, 네이버 등 판별
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        OAuth2Response oAuth2Response = null;
        if (registrationId.equals("naver")) {
            oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
        } else if (registrationId.equals("google")) {
            oAuth2Response = new GoogleResponse(oAuth2User.getAttributes());
        } else {
            return null;
        }

        //나머지 구현
    }
}

OAuth2Response

  • 네이버 데이터 : json
    • response로 한 번 더 감싸져있음
{
		resultcode=00, message=success, response={id=123123123, name=홍길동}
}
  • 구글 데이터 : json
{
		resultcode=00, message=success, id=123123123, name=홍길동
}

OAuth2Response

  • 공통 기능을 명시하는 interface 작성
public interface OAuth2Response {

    //제공자 ex)naver, google, ...
    String getProvider();
    //제공자에서 발급해주는 아이디
    String getProviderId();
    //이메일
    String getEmail();
    //사용자 이름
    String getName();
}
  • OAuth2Response 구현
    • json데이터가 response로 감싸져 있어서 생성자에서 attribute.get("response")로 초기화
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();
    }
}

GoogleResponse

public class GoogleResponse implements OAuth2Response{

    private final Map<String, Object> attribute;

    public GoogleResponse(Map<String, Object> attribute) {
        this.attribute = attribute;
    }

    @Override
    public String getProvider() {
        return "google";
    }

    @Override
    public String getProviderId() {
        return attribute.get("sub").toString();
    }

    @Override
    public String getEmail() {
        return attribute.get("email").toString();
    }

    @Override
    public String getName() {
        return attribute.get("name").toString();
    }

}

OAuth2Response를 SecurityConfig에 등록

  • 생성자 방식으로 DI 주입
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;

    public SecurityConfig(CustomOAuth2UserService customOAuth2UserService) {
        this.customOAuth2UserService = customOAuth2UserService;
    }

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

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

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

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

        //우리가 만든 customOAuth2UserService 등록
        http
                .oauth2Login((oauth2) -> oauth2
                        .userInfoEndpoint((userInfoEndpointConfig ->
                                userInfoEndpointConfig.userService(customOAuth2UserService))));

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

        return http.build();
    }
}

로그인 완료

CustomOAuth2UserService 최종 구현

  • 유저에게 role 부여 및 CustomOAuth2User를 만들어서 리턴
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
    //DefaultOAuth2UserService OAuth2UserService의 구현체

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        //유저 정보 가져오기
        OAuth2User oAuth2User = super.loadUser(userRequest);
        System.out.println(oAuth2User.getAttributes());

        //registrationId를 통해 구글, 네이버 등 판별
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        OAuth2Response oAuth2Response = null;
        if (registrationId.equals("naver")) {
            oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
        } else if (registrationId.equals("google")) {
            oAuth2Response = new GoogleResponse(oAuth2User.getAttributes());
        } else {
            return null;
        }

        //나머지 구현

        String role = "ROLE_USER";
        return new CustomOAuth2User(oAuth2Response, role);
    }
}

CustomOAuth2user 생성

  • OAuth2User를 구현
    -Collection<GrantedAuthority> collection = new ArrayList<>(); 컬렉션 생성
    • collection.add()를 통해 role값 리턴
    • getUsername()은 임의로 사용자 식별 값으로 사용하기 위한 예시
public class CustomOAuth2User implements OAuth2User {

    private final OAuth2Response oAuth2Response;
    private final String role;

    public CustomOAuth2User(OAuth2Response oAuth2Response, String role) {
        this.oAuth2Response = oAuth2Response;
        this.role = role;
    }

    @Override
    public Map<String, Object> getAttributes() {
        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() {
        return oAuth2Response.getName();
    }

    public String getUsername() {
        return oAuth2Response.getProvider() + " " + oAuth2Response.getProviderId();
    }
}

유저 정보 DB 저장

User 엔티티 작성

@Entity
@Getter
@Setter
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;

    private String email;

    private String role;
}

UserRepository 작성

public interface UserRepository extends JpaRepository<User, Long> {
    User findByUsername(String username);
}

CustomOAuth2UserService 수정

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
    //DefaultOAuth2UserService OAuth2UserService의 구현체

    private final UserRepository userRepository;

    public CustomOAuth2UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        //유저 정보 가져오기
        OAuth2User oAuth2User = super.loadUser(userRequest);
        System.out.println(oAuth2User.getAttributes());

        //registrationId를 통해 구글, 네이버 등 판별
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        OAuth2Response oAuth2Response = null;
        if (registrationId.equals("naver")) {
            oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
        } else if (registrationId.equals("google")) {
            oAuth2Response = new GoogleResponse(oAuth2User.getAttributes());
        } else {
            return null;
        }

        String username = oAuth2Response.getProvider() + " " + oAuth2Response.getProviderId();
        User findUser = userRepository.findByUsername(username);
        String role = null;
        if (findUser == null) {
            User user = new User();
            user.setUsername(username);
            user.setEmail(oAuth2Response.getEmail());
            user.setRole("ROLE_USER");
            userRepository.save(user);
        } else {
            role = findUser.getRole();
            findUser.setEmail(oAuth2Response.getEmail());
        }

        return new CustomOAuth2User(oAuth2Response, role);
    }
}

커스텀 로그인 페이지

커스텀 로그인 페이지 설정

  • login.mustache
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Login Page</title>
</head>
<body>
login page
<hr>
<a href="/oauth2/authorization/naver">naver login</a><br>
<a href="/oauth2/authorization/google">google login</a><br>
</body>
</html>
  • LoginController
@Controller
public class LoginController {

    @GetMapping("/login")
    public String loginP() {
        return "login";
    }
}
  • SecurityConfig
http
	.oauth2Login((oauth2) -> oauth2
    	.loginPage("/login")
        .userInfoEndpoint((userInfoEndpointConfig ->
        	userInfoEndpointConfig.userService(customOAuth2UserService))));

ClientRegistration

OAuth2 서비스 변수 등록 방법

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

ClientRegistration 생성

@Component
public class SocialClientRegistration {

    public ClientRegistration naverClientRegistration() {

        return ClientRegistration.withRegistrationId("naver")
                .clientId("아이디")
                .clientSecret("비밀번호")
                .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 googleClientRegistration() {

        return ClientRegistration.withRegistrationId("google")
                .clientId("아이디")
                .clientSecret("비밀번호")
                .redirectUri("http://localhost:8080/login/oauth2/code/google")
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .scope("profile", "email")
                .authorizationUri("https://accounts.google.com/o/oauth2/v2/auth")
                .tokenUri("https://www.googleapis.com/oauth2/v4/token")
                .jwkSetUri("https://www.googleapis.com/oauth2/v3/certs")
                .issuerUri("https://accounts.google.com")
                .userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo")
                .userNameAttributeName(IdTokenClaimNames.SUB)
                .build();
    }

ClientRegistrationRepository

  • ClientRegistration 종류가 몇가지 되지 않아 InMemory 방식 사용
@Configuration
public class CustomClientRegistrationRepo {

    private final SocialClientRegistration socialClientRegistration;

    public CustomClientRegistrationRepo(SocialClientRegistration socialClientRegistration) {
        this.socialClientRegistration = socialClientRegistration;
    }

    public ClientRegistrationRepository clientRegistrationRepository() {
        //인메모리 방식, JDBC 방식이 있는데 구글,네이버,깃허브 등 최대 10개 정도이므로 인메모리를 써도 무관
        return new InMemoryClientRegistrationRepository(
                socialClientRegistration.naverClientRegistration(),
                socialClientRegistration.googleClientRegistration());
    }

}

SecurityConfig에 등록

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;
    private final CustomClientRegistrationRepo customClientRegistrationRepo;

    public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, CustomClientRegistrationRepo customClientRegistrationRepo) {
        this.customOAuth2UserService = customOAuth2UserService;
        this.customClientRegistrationRepo = customClientRegistrationRepo;
    }

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

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

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

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

        //우리가 만든 customOAuth2UserService 등록
        http
                .oauth2Login((oauth2) -> oauth2
                        .loginPage("/login")
                        .clientRegistrationRepository(customClientRegistrationRepo.clientRegistrationRepository())
                        .userInfoEndpoint((userInfoEndpointConfig ->
                                userInfoEndpointConfig.userService(customOAuth2UserService))));

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

        return http.build();
    }
}

OAuth2AuthorizationRequestRedirectFilter

로그인 페이지 : /oauth2/authorization/서비스명

  • 로그인 페이지에서 GET : /oauth2/authorization/서비스명 경로로 요청을 할 경우 OAuth2 의존성에 의해 OAuth2AuthorizationRequestRedirectFilter에서 해당 요청을 받고 내부 프로세서를 진행한다.

OAuth2LoginAuthenticationFilter

로그인 redirect_uri 필터 : /login/oauth2/code/서비스명

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

OAuth2AuthorizedClientService

OAuth2AuthorizedClientService 설명

  • OAuth2 소셜 로그인을 진행한 사용자에 대해 서버는 사용자의 access 토큰과 같은 정보를 담을 공간이 필요함
  • 기본적으로 InMemory 형식이 사용되나 사용자가 증가하면 스케일 아웃 문제로 인해 InMemory 방식은 실무에서 사용되지 않음
  • 따라서 DB에 정보를 저장하기 위해서는 OAuth2AuthorizedClientService를 직접 작성해야함

JDBC 의존성 추가

  • JDBC 방식을 지원하며 JPA 사용을 원할 경우 직접 커스텀 해야함
  • build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-jdbc'
}

CustomOAuth2AuthorizedClientService 생성

  • return new를 통해 InMemory가 아닌 Jdbc방식으로 설정
@Configuration
public class CustomOAuth2AuthorizedClientService {

    @Bean
    public OAuth2AuthorizedClientService oAuth2AuthorizedClientService(JdbcTemplate jdbcTemplate, 
                                                                       ClientRegistrationRepository clientRegistrationRepository) {
        return new JdbcOAuth2AuthorizedClientService(jdbcTemplate, clientRegistrationRepository);
    }
}

SecurityConfig 등록

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;
    private final CustomClientRegistrationRepo customClientRegistrationRepo;
    private final CustomOAuth2AuthorizedClientService customOAuth2AuthorizedClientService;
    private final JdbcTemplate jdbcTemplate;

    public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, CustomClientRegistrationRepo customClientRegistrationRepo, CustomOAuth2AuthorizedClientService customOAuth2AuthorizedClientService, JdbcTemplate jdbcTemplate) {
        this.customOAuth2UserService = customOAuth2UserService;
        this.customClientRegistrationRepo = customClientRegistrationRepo;
        this.customOAuth2AuthorizedClientService = customOAuth2AuthorizedClientService;
        this.jdbcTemplate = jdbcTemplate;
    }

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

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

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

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

        //우리가 만든 customOAuth2UserService 등록
        http
                .oauth2Login((oauth2) -> oauth2
                        .loginPage("/login")
                        .clientRegistrationRepository(customClientRegistrationRepo.clientRegistrationRepository())
                        .authorizedClientService(customOAuth2AuthorizedClientService.oAuth2AuthorizedClientService(
                                jdbcTemplate, customClientRegistrationRepo.clientRegistrationRepository()))
                        .userInfoEndpoint((userInfoEndpointConfig ->
                                userInfoEndpointConfig.userService(customOAuth2UserService))));

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

        return http.build();
    }
}

DB에 해당 테이블 생성

CREATE TABLE oauth2_authorized_client (
  client_registration_id varchar(100) NOT NULL,
  principal_name varchar(200) NOT NULL,
  access_token_type varchar(100) NOT NULL,
  access_token_value blob NOT NULL,
  access_token_issued_at timestamp NOT NULL,
  access_token_expires_at timestamp NOT NULL,
  access_token_scopes varchar(1000) DEFAULT NULL,
  refresh_token_value blob DEFAULT NULL,
  refresh_token_issued_at timestamp DEFAULT NULL,
  created_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
  PRIMARY KEY (client_registration_id, principal_name)
);

참조 링크
개발자 유미 - 스프링 OAuth2 클라이언트 세션

profile
찬이's 개발로그

0개의 댓글