본문 바로가기

spring

[ Spring Boot 3 + Spring Security 6 + OAuth2.0 + JWT ] 마이그레이션과 같이 진행되는 OAuth2 로그인/회원가입 1

 

 

 

 

스프링 부트 공부를 하는데 이번에 OAuth에 대해 알게 되어서 적용해보는 시간을 가졌다.

그 전에 앞서, spring이 6으로 바뀌면서 더이상 JAVA 17 이전은 프로젝트에 적용할 수 없게 되었다.

11을 쓰던 나는 17로 바꾸고 기존 프로젝트들도 마이그레이션 작업을 해야했는데, 자바17로 바꾸는 건 쉬웠지만

마이그레이션 작업이 힘들었다ㅠ 공식문서를 봐도 잘 이해 못하는 초급이고

블로그들 대다수가 예전 버전의 프로젝트 예제들이 많아서 참고하기도 힘들고 오래걸렸다.

그래서 내 나름의 방식을 저장해 두고, 나중에 나에게 좋은 공부였음을 깨닫게 하는 데에 목적을 두고 글을 작성한다.

 

시작!

 

 

 

 프로젝트 환경 build.gradle

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'

//swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2'

// JWT Token
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'com.sun.xml.bind:jaxb-impl:4.0.1'
implementation 'com.sun.xml.bind:jaxb-core:4.0.1'
implementation 'javax.xml.bind:jaxb-api:2.4.0-b180830.0359'

JPA, Srping Boot, Spring security, maria DB에

Swagger 스웨거도 만들 거고 Oauth Client도 넣었다.

나중에 OAuth authorization server도 구축하려고 하는데 그건 ㅠ좀 어려운듯...

아무튼 저렇게 하고 빌드 끝.

아! 디펜더시 위에는 저렇게 되어 있다. spring 3.2.0 버전이고 java 17!!

이 프로젝트명은 clientserver이다. ㅎ뭔가 잘못된 이름인 건 아는ㄷㅔ..그냥 지었다..

프로젝트 파일들이다. 다른 거도 진행중이라 많은데, 제목에 있는 로그인만 구현하는 건

config랑 controller에 Oauth2LoginController만 있으면 된다.

 

 

 

Oauth2를 적용하는 건 일반적인 security와는 조금 다른데, 나는 구글에 있는 내 계정의 정보를 가져와

내 프로젝트  '로그인/회원가입'을 한 번에 퉁치려고 한다 ㅎ실제 프로젝트에선 아무도 그렇게는 안하겠지만 일단 되는 거나 좀 해보려고,,

대충 해봤다..

package com.oauth.clientserver.config;

import com.oauth.clientserver.service.OAuth2UserService;
import com.oauth.clientserver.utils.JwtUtil;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import java.nio.charset.StandardCharsets;


@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@Tag(name = "login", description = "API Document")
public class OauthSecurityConfig {
    private final OAuth2UserService oAuth2UserService;


    final Log logger = LogFactory.getLog(getClass());

    @Bean
    @Order(1)
    SecurityFilterChain defaultSecurityFilterChain (HttpSecurity http) throws Exception {
        http
                .httpBasic(AbstractHttpConfigurer::disable)
                .csrf(AbstractHttpConfigurer::disable)
//                .cors(AbstractHttpConfigurer::disable)
//                .sessionManagement(sessionManagement -> {
//                    sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
//                })
                .authorizeHttpRequests((authorize) -> authorize
                        .requestMatchers("/login/token", "/v3/api-docs/**", "/user/login", "/swagger-ui/index.html", "/swagger-ui/**", "/swagger-resources/**", "/webjars/**", "http://localhost:3000").permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(new JwtFilter(), UsernamePasswordAuthenticationFilter.class)
                .oauth2Login(oauth2Configurer -> oauth2Configurer
                        .successHandler(successHandler())
                        .userInfoEndpoint(userInfo ->
                                userInfo.userService(oAuth2UserService)));
        return http.build();
    }

    @Bean
    public AuthenticationSuccessHandler successHandler () {
        return ((request, response, authentication) -> {
            long tokenPeriod = 1000L * 60L * 10L;
            long refreshTokenPeriod = 1000L * 60L * 60L * 24L * 7L; // 1 week
            DefaultOAuth2User defaultOAuth2User = (DefaultOAuth2User) authentication.getPrincipal();

            String email = defaultOAuth2User.getAttributes().get("email").toString();


            String accessToken = JwtUtil.createJwt(email, tokenPeriod);
            String refreshToken = JwtUtil.createJwt(email, refreshTokenPeriod);

            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.setCharacterEncoding(StandardCharsets.UTF_8.name());
            response.addHeader("refreshToken", "Bearer " + refreshToken);
            response.setHeader("Authorization", "Bearer " + accessToken);


            // SecurityContext에 Authentication 객체를 설정.
            SecurityContextHolder.getContext().setAuthentication(authentication);

            response.sendRedirect("/login");

        });
    }
}

전체적인 건 이건데, 자세히 뜯어보겠다.

참고로 내 수준은 이번에 처음으로 스프링 부트 프로젝트 공부를 하는 개초보로 개판으로 알고 있다.

혹시 누군가 내 플젝으로 도움 받아 복붙 하려하신다면 막고 싶다..^^ㅎ

일단 내가 이해한 방식대로 설명을 해보자면!

스프링6 이전에는 SecuritFilterChain을 적용하기 전에!!! 항상 Security Config설정을 하기 위해서는

extends로

를 상속 받아 시큐리티 컨피그를 작성하는데 ㅡㅡ이게 진짜 스프링 6부터 아예 삭제가 됐다. 당연히 구글에 의존성 주입한 나로써는 블로거들은 죄다 WebSeurityConfigurerAdapter이거 상속 받는데 내가 저거 상속 받으려고 하면

아예 목록에서 삭제 되서 뜨지도 않음. 

응 절대 안떠 ㅋㅋ임

근데 이거 사라진지는 오래됐다. 스프링 공식 문서에서

심지어 스프링6부터도 아니다 스프링 시큐리티디 5.7.0부터는  사용자가 컴포넌트 기반 보안 구성으로 전환하도록 권장하기 때문에 더이상

WebSeurityConfigurerAdapter는 못쓴다.고 명시되어 있었따ㅋ 그러니 블로그들은 죄다 시큐리티가 5.7 이전의 프로젝트였던 것....공부하는 입장으로써는 이제 더이상 5.x대는 쓰지도 못하니 걍....알아서 마이그레이션 해야한다.,

스프링은 정말 친절하게도,,이걸 어떻게 마이그레이션 해야하는지도 설명해주고 있따

너네 그전까지는 extends 해서

WebSeurityConfigurerAdapter가져다가 썼으면 5.4이후부터는 컨피규 죄다

@Bean으로 등록부터 해라ㅎ

그리고 그냥 http.로 바로 연결해서 람다로 써라 

이소리 같음.,,

자세히 보면 SecurityFilterChain을 쓰고 있음.  우리는 이제 config는 @Bean으로 등록하고 void 하는 게 아니고SecurityFilterChaind을 사용해 Bean을 등록해야함. 

돌아와서 내 소스를 자세히 보면 나 역시 시큐리티 필터 체인을 두고, Bean으로 등록해놨다. order는 무시해도 좋다!

없애두 될 듯ㅋ 그리고 httpBasice이나 csrf설정을 보면 옛날에는 그냥 .disabled()이런식으로 메소드 불러와 붙여넣었던 거 같은데 이제는 저렇게 써야 한다. 그래야 먹힘,,,,,,,다른 것들도 마찬가지 화살표 함수 써야함,,

저것들 외에 크게 바뀐 건 뭐가 없는 듯...?WebSeurityConfigurerAdapter처럼 없어진 것들은 https://docs.spring.io/spring-framework/docs/current/javadoc-api/index.html

 

Overview (Spring Framework 6.1.2 API)

Support for registering the need for reflection, resources, java serialization and proxies at runtime. Annotation support for the Application Context, including JSR-250 "common" annotations, component-scanning, and Java-based metadata for creating Spring-m

docs.spring.io

여기나..스프링 공식에 찾아보면 나오긴 함..뭐로 대체하라고까지 알려줌. 근데 찾기가 좀 힘들어서 ㅠ고생ㅎ나듯..

 

여기는 말 그대로 석세스 핸들러이다. OAuth를 이용하여 인증서버를통해 내 정보를 받아온 걸 성공했을 때!

이 때 토큰을 발급하여 헤더에 담아 내보내면 프론트에서 이제 그걸 받아다가 로컬스토리지에 저장해 쓴다든가

자기들만의 저장방식대로 사용하면 될 듯. 난 프론트 공부는 지금 하는 게 아니라 그냥 내보내는 것까지만 구현했음.

그리고 다른 데서 일단 꺼내오기위해 SecurityContext에 값을 저장하고 login페이지로 다시 이동시켰다.

defaultSecurityFilterChain

내가 만든 시큐리티컨피그인 디폴트 시큐리티 필터 체인을 다시 와서 보면 오어스 로그인과 관련된 메서드가 있다. 석세스 핸들러는 말그대로 OAuth2로그인이 성공했을 때 발생하는 핸들러인데 그걸 내가 만든 핸들러로 끼워넣은 것. 그러고나서 유저의 정보 엔드포인트 지정하는 메서드에는 내가 만든 서비스를 적용시킨다. 이 서비스는 무슨 역할을 하느냐면

package com.oauth.clientserver.service;


import com.oauth.clientserver.repository.UserRepository;
import com.oauth.clientserver.repository.entity.UserEntity;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.crypto.password.PasswordEncoder;
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.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class OAuth2UserService extends DefaultOAuth2UserService {

    final Log logger = LogFactory.getLog(getClass());


    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;


    public OAuth2UserService (UserRepository userRepository, PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
    }


    @Override
    public OAuth2User loadUser (OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        String email = oAuth2User.getAttributes().get("email").toString();
        String name = oAuth2User.getAttributes().get("name").toString();
        String password = "1234";
        UserEntity userEntity = userRepository.findByEmail(email);

        logger.info("userEntity???===> " + userEntity);
        logger.info("password???===> " + password);

        // User info generate for DB
        if (userEntity == null) {
            // 사용자가 데이터베이스에 없으면 새로운 사용자를 생성.
            userEntity = new UserEntity();
            userEntity.setEmail(email);
            userEntity.setName(name);
            userEntity.setRole("ADMIN");
            userEntity.setPassword(passwordEncoder.encode(password));

            userRepository.save(userEntity);
        }

        // User Role generate for session
        List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList("ADMIN");

        // nameAttributeKey for Google ='sub'
        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails()
                .getUserInfoEndpoint()
                .getUserNameAttributeName();

        // DefaultOAuth2User 객체 생성 => 세션에 저장되어 사용자가 인증된 상태를 유지하는 데 사용.
        // authorities : userEntity.getRole()의 권한 목록
        // oAuth2User.getAttributes : OAuth2 제공자로부터 받은 사용자 속성 정보 ex) Google name, email
        // userNameAttributeName : 사용자의 고유 이름을 식별하는 키로, OAuth2 제공자가 제공하는 사용자 정보에서 중요한 역할을 함.
        return new DefaultOAuth2User(authorities, oAuth2User.getAttributes(), userNameAttributeName);
        // DefaultOAuth2User 객체는 스프링 시큐리티 컨텍스트에 저장되어,
        // 사용자가 인증된 상태를 나타내며, 애플리케이션 내에서 사용자의 권한을 확인하는 데 사용된다.
    }


}

바로! 여기서 DB와 대조를 하여 회원 로그인/회원가입을 퉁쳐서 진행한다.

저장된 유저 정보를 가져와서 내 db에 있는 유저 이메일과 비교하여 db에 있으면 세션에 뭐 어쩌구고 없으면 디비에 저장해주는 건데

석세스 핸들러랑 같이 발동한다 ㅎ

비밀번호같은 경우엔 OAtuh를 통해 유저 정보를 가져오고나서 그 정보로 또 로그인/회원가입하는 화면이 나는 없기때문에 그냥 바로 1234 하드코딩ㅋㅋ햇다

저 비번은 암호화

하여  db에 저장된다. 너무 내 마음대로 개발 하는 거 같은데 뭐 어때ㅎ

저렇게 하고나서 컨트롤러로 이동한당.

아!!!그 전에!!! 저 패스워드 인코딩을 쓰려면 저거 역시도 @Bean으로 등록해줘야 한다.

이렇게 해줘야 좀 편하게 쓸 수 있음. 컨트롤러에서 저거 긁어다 쓰려니까 참조가 너무 안되는 거임? 찾아보니 bean해줘야 하더라고요

선언만 해주셈 

 

그 다음은 다음편으로 ㄱ (모든 소스코드는 3편에 깃허브 공개하겟슴)