728x90

개요

이번에는 OAuth2 로그인 인증 인가 부분을 구현할 예정이다.

관련해서 세션 방식으로는 아래 블로그에 정리해두었고 이번에는 토큰으로 저장하는 방법이다.

Spring Security + OAuth2 + Session 를 사용한 로그인, 로그아웃 구현 - Kakao, Naver :: 미정 (tistory.com)


 

 

1. Oauth2 응답 객체

 

 

 

  • NaverResponse.java
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");
        System.out.println("naver attributes: " + attribute);
    }

    @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();
    }

    @Override
    public Map<String, Object> getAttributes() {
        return attribute;
    }

}

 

 

  • KakaoResponse.java
public class KakaoResponse implements OAuth2Response{

    private Map<String, Object> attribute;

    public KakaoResponse(Map<String, Object> attribute) {
        this.attribute = attribute;
        System.out.println("Kakao attributes: " + attribute);
    }

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

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

    @Override
    public String getEmail() {
        Map<String, Object> kakaoAccount = (Map<String, Object>) attribute.get("kakao_account");
        return kakaoAccount.get("email").toString();
    }

    @Override
    public String getName() {
        Map<String, Object> properties = (Map<String, Object>) attribute.get("properties");
        return properties.get("nickname").toString();
    }

    @Override
    public Map<String, Object> getAttributes() {
        return attribute;
    }
}

 

 

  • googleResponse.java
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();
    }

    @Override
    public Map<String, Object> getAttributes() {
        return attribute;
    }
}

 

  • CustomOAuth2User.java
@Slf4j
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 oAuth2Response.getAttributes();
    }

    // role에 해당하는 값
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add(new SimpleGrantedAuthority(role));
        return collection;
    }

    @Override
    public String getName() {
        return oAuth2Response.getName();
    }

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

    /**
     * 각 소셜 로그인마다 OAuth2 제공자의 ID 형식이 다르기 때문에 각 ID에 맞는 처리
     * @return getProviderId
     * Google : 매우 큰 숫자 BigInt
     * Naver : 문자열 String
     * Kakao : 작은 숫자 Long
     */
    public Long getUserIdAsLong() {
        String providerId = oAuth2Response.getProviderId();

        if (providerId == null) {
            return null;
        }

        try {
            // providerId가 정수인지 확인
            if (providerId.matches("\\d+")) {
                BigInteger bigInt = new BigInteger(providerId);
                if (bigInt.compareTo(BigInteger.valueOf(Long.MAX_VALUE)) > 0) {
                    return Long.MAX_VALUE;
                } else if (bigInt.compareTo(BigInteger.valueOf(Long.MIN_VALUE)) < 0) {
                    return Long.MIN_VALUE;
                } else {
                    return bigInt.longValue();
                }
            } else {
                // providerId가 정수가 아닌 경우 문자열의 해시코드를 사용하여 Long으로 변환
                return (long) providerId.hashCode();
            }
        } catch (NumberFormatException e) {
            log.error("user ID parsing 오류 :: {}", providerId, e);
            return null;
        }
    }

    public List<String> getRoles() {
        return Collections.singletonList(role);
    }
}
  • 시큐리티 세션 방식처럼 UserDetails 역할을 하는 것
  • 카카오, 네이버, 구글 서비스로부터 받은 특정 사이트의 응답 값과 롤에 대한 값을 받는 클래스이다.
  • 이 클래스를 통해 특정 값과 롤에 대해 정의한다.

 

 

 

2. OAuth2UserService 구현

@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOauth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;
    private final RoleRepository roleRepository;

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

        OAuth2User oAuth2User = super.loadUser(userRequest);
        log.info("OAuth2User attributes: {}", oAuth2User.getAttributes());
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        OAuth2Response oAuth2Response = null;

        switch (registrationId) {
            case "naver":
                log.info("naver 로그인");
                oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
                break;
            case "kakao":
                log.info("kakao 로그인");
                oAuth2Response = new KakaoResponse(oAuth2User.getAttributes());
                break;
            case "google":
                log.info("google 로그인");
                oAuth2Response = new GoogleResponse(oAuth2User.getAttributes());
                break;
            default:
                log.error("로그인 실패: 지원하지 않는 로그인 제공자입니다. 등록 ID: {}", registrationId);
                throw new IllegalArgumentException("지원하지 않는 로그인 제공자입니다.");
        }

        String provider = oAuth2Response.getProvider();
        String providerId = oAuth2Response.getProviderId();
        String username = provider + " " + providerId; // 유저명 생성 (OAuth2 공급자명 + 공급자ID)

        Optional<User> userOptional = userRepository.findByUsername(username);

        if (userOptional.isPresent()) {
            // 기존 유저가 이미 존재하는 경우, 추가 작업 없이 바로 반환
            log.info("기존 유저 로그인: {}", username);
            return new CustomOAuth2User(oAuth2Response, userOptional.get().getRoles().stream().map(Role::getName).collect(Collectors.joining(",")));
        }

        // 새로운 유저에 대한 처리
        String roleName = "ROLE_USER";
        Optional<Role> roleOptional = roleRepository.findByName(roleName);
        Role role;
        if (roleOptional.isEmpty()) {
            role = new Role(roleName);
            role = roleRepository.save(role);
        } else {
            role = roleOptional.get();
        }

        User newUser = User.builder()
                .name(oAuth2Response.getName())
                .username(username)
                .roles(Set.of(role))
                .providerId(oAuth2Response.getProviderId())
                .provider(oAuth2Response.getProvider())
                .password("")
                // 마이페이지에서 직접 설정할 필드들
                .phoneNumber("01000000000")
                .birthdate(LocalDate.from(LocalDateTime.now()))
                .gender("여자")
                .registrationDate(LocalDateTime.now())
                .usernick(oAuth2Response.getEmail())
                .build();
        userRepository.save(newUser);

        log.info("새로운 유저 생성: {}", username);

        return new CustomOAuth2User(oAuth2Response, roleName);
    }
}
  • Spring Security의 OAuth2 인증을 처리하는 커스텀 서비스이다.
  • 주로 네이버, 카카오, 구글 등의 OAuth2 제공자에서 사용자 인증 정보를 받아와 데이터베이스에 저장하거나 업데이트하는 역할을 한다.
  • loadUser는 네이버나 카카오의 사용자 인증 정보를 받아오는 메서드이다.
  • 외부 사이트로부터 사용자 정보를 받아오고 그 값을 디비에 저장하는 클래스이다.

 

 

 

3. 성공 핸들러

@Component
@Slf4j
@RequiredArgsConstructor
public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtTokenizer jwtTokenizer;
    private final RefreshTokenService refreshTokenService;
    private final UserRepository userRepository;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        try {
            CustomOAuth2User customUserDetails = (CustomOAuth2User) authentication.getPrincipal();

            String username = customUserDetails.getUsername();
            Optional<User> user = userRepository.findByUsername(username);

            if (user.isEmpty()) {
                System.out.println("유저기ㅏ 없어요");
                throw new UsernameNotFoundException("User not found with username: " + username);
            }
            Long userId = user.get().getId();
            System.out.println("userId :: " + userId);
            String name = customUserDetails.getName();
            List<String> roles = customUserDetails.getRoles();

            log.info("Oauth2 로그인 성곻했습니다. ");
            log.info("jwt 토큰 생성 :: userId: {}, username: {}, name: {}, roles: {}", userId, username, name, roles);

            String accessToken = jwtTokenizer.createAccessToken(userId, username, name, roles);
            String refreshToken = jwtTokenizer.createRefreshToken(userId, username, name, roles);

            log.info("Access Token :: {}", accessToken);
            log.info("Refresh Token :: {}", refreshToken);

            // 쿠키에 토큰 저장
            Cookie accessTokenCookie = new Cookie("accessToken", accessToken);
            accessTokenCookie.setHttpOnly(true);
            accessTokenCookie.setPath("/");
            accessTokenCookie.setMaxAge(Math.toIntExact(jwtTokenizer.ACCESS_TOKEN_EXPIRE_COUNT / 1000));

            Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
            refreshTokenCookie.setHttpOnly(true);
            refreshTokenCookie.setPath("/");
            refreshTokenCookie.setMaxAge(Math.toIntExact(jwtTokenizer.REFRESH_TOKEN_EXPIRE_COUNT / 1000));

            response.addCookie(accessTokenCookie);
            response.addCookie(refreshTokenCookie);

            // 리프레시 토큰 DB 저장
            Date date = new Date(System.currentTimeMillis() + jwtTokenizer.REFRESH_TOKEN_EXPIRE_COUNT);
            RefreshToken refreshTokenEntity = new RefreshToken();
            refreshTokenEntity.setValue(refreshToken);
            refreshTokenEntity.setUserId(userId);
            refreshTokenEntity.setExpiration(date.toString());

            refreshTokenService.addRefreshToken(refreshTokenEntity);

            // 추가 정보가 없을 때만 oauthPage로 리다이렉트
            if (user.get().getPhoneNumber().equals("01000000000")) {
                response.sendRedirect("/oauthPage");
            } else {
                response.sendRedirect("/my"); // 이미 추가 정보가 있을 경우 메인 페이지로 리다이렉트
            }

        } catch (Exception e) {
            log.error("Oauth2 로그인에 실패했습니다.", e);
            if (!response.isCommitted()) {
                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "An error occurred during authentication");
            }
        }
    }
}
  • 나머지는 기존 세션 방식의 소셜로그인과 동일하다면 jwt 토큰 발급에서는 이 부분이 필요하다.
  • 로그인에 성공하면 jwt 토큰을 발급해줘야 하기 때문이다.

 

 

4.SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
    private final JwtBlacklistService jwtBlacklistService;
    private final RefreshTokenService refreshTokenService;
    private final JwtTokenizer jwtTokenizer;
    private final CustomOauth2UserService customOAuth2UserService;
    private final CustomSuccessHandler customSuccessHandler;

    // 모든 유저 허용 페이지
    String[] allAllowPage = new String[] {
            "/",        // 메인페이지
            "/signup", // 회원가입 페이지
            "/signin", // 로그인 페이지
            "/css/**", "/js/**", "/files/**", // css, js, 이미지 url
            "/api/users/login", // 로그인 페이지
            "/api/users/signup", // 회원가입 페이지
            "/api/users/mail","/api/users/verify-code", "/api/users/check-email","/api/users/check-usernick", // 인증 메일 페이지
            "/oauth2/**", "/login/oauth2/**", // OAuth2 로그인 허용
            "/api/users/randomNickname", // 랜덤 닉네임 생성
            "/api/users/reset-password", "/api/users/verify-temporary-password", "/my/change-password" // 임시 비밀번호 발급
    };

    // 관리자 페이지
    String[] adminAllowPage = new String[] {
            "/admin",
            "/admin/**"
    };

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorize -> authorize
                        .requestMatchers(allAllowPage).permitAll()
                        .requestMatchers(adminAllowPage).hasRole("ADMIN")
                        .anyRequest().authenticated()
                )
                .addFilterBefore(new JWTFilter(jwtTokenizer, jwtBlacklistService, refreshTokenService), UsernamePasswordAuthenticationFilter.class) // JWT 필터 사용
                .formLogin(form -> form.disable()) // 로그인 폼 비활성화
                .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 관리 Stateless 설정(서버가 클라이언트 상태 저장x)
                .csrf(csrf -> csrf.disable()) // cors 허용
                .httpBasic(httpBasic -> httpBasic.disable()) // http 기본 인증(헤더) 비활성화
                .cors(cors -> cors.configurationSource(configurationSource()))
                .exceptionHandling(exception -> exception
                        .authenticationEntryPoint(customAuthenticationEntryPoint)
                )
                .oauth2Login(oauth2 -> oauth2
                        .loginPage("/signin")
                        .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userService(customOAuth2UserService))
                        .successHandler(customSuccessHandler)
                        .failureUrl("/loginFailure")
                        .authorizationEndpoint(authorization -> authorization.baseUri("/oauth2/authorization"))
                        .permitAll()
                );
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    public CorsConfigurationSource configurationSource(){
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOriginPattern("*"); // 모든 도메인 허용
        config.addAllowedOrigin("http://localhost:3000"); // 프론트의 주소
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        config.setAllowedMethods(List.of("GET", "POST", "DELETE"));
        config.setExposedHeaders(Arrays.asList("Set-Cookie", "Authorization"));
        config.setAllowCredentials(true);
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}
  • OAuth2 설정을 해준다.

그럼 JWT + OAuth2 + Security 에 대한 인증/인가는 끝이 났다.

728x90

+ Recent posts