Springboot/security

Spring security jwt Oauth2 로그인 - 구현(2)

기발개발 2025. 3. 17. 14:28

전체코드 : https://github.com/gks930620/spring_securty_all

 

구현 자체는  JWT access token,refresh token에 

Oauth2 몇몇 기능을 추가한 거밖에 없다. 

그래서 여기는 전체코드를 전부 설명하는것이 아니라 추가된 내용만 작성하는걸로 하고

전체코드는 github를 참고하도록 하자. 

 

 

Custom-oauth2/login/kakao 컨트롤러

이 떄 이 url은 permitALL 되어야한다.

 

 

Oauth2LoginController

@Controller
@RequestMapping("/custom-oauth2/login")
@RequiredArgsConstructor
public class Oauth2LoginController {


    private final InMemoryAuthorizationRequestRepository authorizationRequestRepository;


    @Value("${spring.security.oauth2.client.registration.kakao.client-id}")
    private String kakaoClientId;

    @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}")
    private String kakaoRedirectUri;

    @RequestMapping("/kakao")
    public ResponseEntity<?> kakaoOauth2Login(HttpServletRequest request, HttpServletResponse response){
        String state=""+UUID.randomUUID();

        // ✅ OAuth2AuthorizationRequest 직접 생성 == 로그인사용자가 로그인페이지 요청한사용자인지 확인하는 용.  클라이언트 구별!!
        OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode()
            .authorizationUri("https://kauth.kakao.com/oauth/authorize")
            .clientId(kakaoClientId)
            .redirectUri(kakaoRedirectUri)
            .state(state)
            .attributes(attrs -> attrs.put("registration_id", "kakao"))
            .build();

        authorizationRequestRepository.saveAuthorizationRequest(authorizationRequest, request, response);
        //기본은 session에 저장해서 구별하는건데 여기서는 session없으니까 그냥 map에다..  서버여러대면 DB 등

        // ✅ Authorization URL 생성
        String authorizationUrl = "https://kauth.kakao.com/oauth/authorize"
            + "?client_id=" + kakaoClientId
            + "&redirect_uri=" + kakaoRedirectUri
            + "&response_type=code"
            + "&state=" + state;

        return ResponseEntity.ok(Map.of("authorizationUrl", authorizationUrl)); //client에는 카카오 restAPI문서에서 요구하는 형태로!
    }
}

 

 

InMemoryAuthorizationRequestRepository

@Component
public class InMemoryAuthorizationRequestRepository implements
    AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
    
    private final Map<String, OAuth2AuthorizationRequest> authorizationRequests = new ConcurrentHashMap<>();

    @Override
    public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
        String state = request.getParameter("state");
        if (state == null) {
            return null;
        }
        return authorizationRequests.get(state);
    }

    @Override
    public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
        if (authorizationRequest == null) {
            return;
        }
        
        String state = authorizationRequest.getState();
        authorizationRequests.put(state, authorizationRequest);

        System.out.println("✅ OAuth2AuthorizationRequest 저장: " + state);

        // 5분 후 자동 삭제 (보안 상 Authorization Request를 계속 들고 있을 필요 없음)
        new Thread(() -> {
            try {
                TimeUnit.MINUTES.sleep(5);
                authorizationRequests.remove(state);
                System.out.println("🗑️ OAuth2AuthorizationRequest 만료 삭제: " + state);
            } catch (InterruptedException ignored) {}
        }).start();
    }

    @Override
    public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request,HttpServletResponse response) {
        String state = request.getParameter("state");
        if (state == null) {
            return null;
        }
        System.out.println("🚀 OAuth2AuthorizationRequest 조회 후 삭제: " + state);
        return authorizationRequests.remove(state);
    }
}

 

 

 

 

 

Oauth2LoginSuccessHandler

@Component
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtUtil jwtUtil;
    private  final RefreshRepository refreshRepository;

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


        String accessToken = jwtUtil.createAccessToken(customUserAccount.getUsername());
        String refreshToken=jwtUtil.createRefreshToken(customUserAccount.getUsername());


        // ✅ 새 Refresh Token 저장 (기존 Token 삭제 X)
        RefreshEntity refreshEntity = new RefreshEntity();
        refreshEntity.setUserEntity(customUserAccount.getUserEntity());  // 이전 필터에서 사용자정보저장할 떄  userEntity는  entity상태로 저장됨.
        refreshEntity.setToken(refreshToken);
        refreshRepository.save(refreshEntity);


        // 토큰을 응답에 포함
        response.setContentType("application/json");
//        response.getWriter().write("{\"access_token\": \"" + accessToken + "\"}");
        response.getWriter().write(new ObjectMapper().writeValueAsString(
            Map.of("access_token", accessToken, "refresh_token", refreshToken)
        ));


    }
}

 

 


이후 로그인성공을 했다면 클라이언트에게  JWT 방식에 맞게  access token, refresh token을 보내주면 된다.  

successHandler는 위의 config에  successHandler 등록하면 된다.

 

 

 

 

 

SecurityConfig


@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtUtil jwtUtil;
    private final CustomUserDetailsService customUserDetailsService;  //내가 빈으로 등록한것들
    private final CustomOAuth2UserService customOAuth2UserService;

    private final AuthenticationConfiguration authenticationConfiguration;  //authenticationManger를 갖고있는 빈.

    private final RefreshRepository refreshRepository;
    private final AuthorizationRequestRepository authorizationRequestRepository;


    private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;


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


    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http  //내부H2DB  확인용.  진짜 1도 안중요함.
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/h2-console/**").permitAll() // H2 콘솔 접근 허용
            )
            .csrf(csrf -> csrf.ignoringRequestMatchers("/h2-console/**")) // H2 콘솔 CSRF 비활성화
            .headers(
                headers -> headers.frameOptions(frame -> frame.disable())); // H2 콘솔을 iframe에서 허용

        http    //기본 session방식관련 다 X
            .csrf(csrf -> csrf.disable())
            .sessionManagement(
                session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .formLogin(form -> form.disable())
            .logout(logout -> logout.disable())  //기본 로그아웃 사용X
            .httpBasic(basic -> basic.disable());

        http   //경로와 인증/인가 설정.
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(
                    "/login", "/api/join", "/api/refresh/reissue" , "/custom-oauth2/login/**") .permitAll() //  oauth2도 추가
                .requestMatchers("/api/my/info","/api/logout").authenticated()  //security 기본 로그아웃 url인 /logout은 사용X
            );

        http.oauth2Login(oauth2 -> oauth2
            .authorizationEndpoint(authEndpoint -> authEndpoint
                .authorizationRequestRepository(authorizationRequestRepository)) // ✅ 직접 구현한 저장소 적용
            .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
            .successHandler(oAuth2LoginSuccessHandler) // ✅ 로그인 성공 시 JWT 발급
            .failureHandler((request, response, exception) -> {
                exception.printStackTrace();
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
            })  // ✅ 실패 시 로그 찍기
        );


        http          //필터
            .userDetailsService(customUserDetailsService)
            .addFilterAt(
                new JwtLoginFilter(authenticationConfiguration.getAuthenticationManager(), jwtUtil,
                    refreshRepository),
                UsernamePasswordAuthenticationFilter.class)  //기존 세션방식의 로그인 검증필터 대체.
            .addFilterBefore(
                new JwtAccessTokenCheckAndSaveUserInfoFilter(jwtUtil, customUserDetailsService),
                UsernamePasswordAuthenticationFilter.class);

       
        http
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint((request, response, authException) -> {
                    String errorCause =
                        request.getAttribute("ERROR_CAUSE") != null ? (String) request.getAttribute(
                            "ERROR_CAUSE") : null;
                    //인증없이(access token없이) 인증필요한 곳에 로그인했을 떄.
                    if (errorCause == null) {
                        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                        response.setContentType("application/json;charset=UTF-8");
                        response.getWriter().write("{\"error\": \"인증이 필요합니다.\"}");
                        return;
                    }
                    // JwtAccessTokenCheckAndSaveUserInfoFilter  토큰체크하는부분
                    if (errorCause.equals("토큰만료")) {
                        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 응답
                        response.setContentType("application/json");
                        response.setCharacterEncoding("UTF-8");
                        response.getWriter().write("{\"error\": \"Access Token expired\"}");
                        return;
                    }
                    if (errorCause.equals("로그인실패")) { //jwtLoginFilter 로그인시도부분.
                        response.setStatus(
                            HttpServletResponse.SC_UNAUTHORIZED); //로그인실패도 401로 하는게 보통
                        response.setContentType("application/json;charset=UTF-8");
                        response.getWriter().write("{\"error\": \"아이디 비번 틀림.\"}");
                        return;
                    }
                })
            );
        return http.build();
    }
}

 

 

 

 

 

CustomOauth2UserService

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    @Autowired
    private UserRepository userRepository;


    @Transactional(readOnly = false)
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        // 카카오로그인이든 폼로그인이든 똑같은 서비스를 제공하기 위해 DB 저장

        OAuth2User oAuth2User = super.loadUser(userRequest);//  user-info-uri : https://kapi.kakao.com/v2/user/me 에서 유저정보를 가져옴.

        String registrationId = userRequest.getClientRegistration().getRegistrationId(); //"kakao"



        // OAuth2 제공자(Kakao)에서 가져온 사용자 정보
        Map<String, Object> attributes = oAuth2User.getAttributes();
        /**
         {
         "id": 1234567890,  문자아님 Long
         "kakao_account": {
         "email": "user@example.com",
         "profile": {
         "nickname": "홍길동"
         }
         }
         }
         */
        Long id= (Long) attributes.get("id");  //카카오는 id가 Long.
        Map<String,Object> kakaoAccount=(Map<String,Object>)attributes.get("kakao_account");
        String email= (String) kakaoAccount.get("email");
        Map<String,Object> profile=(Map<String,Object>)kakaoAccount.get("profile");
        String  nickname=(String)profile.get("nickname");



        //  DB에서 사용자 조회 (없으면 생성)
        UserEntity user = userRepository.findByUsername("kakao"+id);
        if (user == null) {  //카카오로 처음 로그인하는 분
            user = new UserEntity();
            user.setUsername("kakao"+id);
            user.setPassword("{noop}oauth2user"); // OAuth2 로그인은 비밀번호 없음. {noop}은 security가  암호화 안된 비밀번호임을 암시.
            user.setEmail(email);
            user.setNickname(nickname);
            user.getRoles().add("USER");

            user.setProvider(registrationId);
            userRepository.save(user);
        }else{  //처음 로그인은 아님. 카카오에서 nickname변경됐다면 우리 DB에도  반영해야지
            //username password는 안 바뀜.
            user.setEmail(email);
            user.setNickname(nickname);
        }

        //CustomUserAccount로 반환 (OAuth2User + UserDetails 통합)
        return new CustomUserAccount(user, attributes);
    }
}

 

 

 

 

CustomUserAccount   (getUserEntity 추가)

package com.security.jwt.model;

import com.security.jwt.entity.UserEntity;
import java.util.Collection;
import java.util.Map;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
public class CustomUserAccount implements UserDetails, OAuth2User {
    private UserEntity userEntity;
    private final Map<String, Object> attributes; // OAuth2 로그인

    public CustomUserAccount(UserEntity userEntity) {  //일반 사용자로 로그인 한 경우
        this.userEntity = userEntity;
        this.attributes=null;
    }


    public CustomUserAccount(UserEntity userEntity,  Map<String, Object> attributes) {  //Oauth2로 로그인한 경우
        this.userEntity = userEntity;
        this.attributes=attributes;
    }


  public UserEntity getUserEntity() {
        return userEntity;
    }

    // userDetails
    @Override
    public String getUsername() {
        return userEntity.getUsername();
    }

    @Override
    public String getPassword() {
        return userEntity.getPassword();
    }

    //이 메소드는 oauth2User와 UserDetails의 getAuthroties()를 전부 override
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return userEntity.getRoles().stream()
            .map(SimpleGrantedAuthority::new)
            .toList();
    }


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


    // 카카오로그인이든 폼로그인이든 똑같은 서비스를 제공하기 위해 DB 저장했던 userEntity 사용


    /**
     * oauth2User는  사용자를 getName()으로 식별하지만, 통합User객체를 사용하기때문에
     * userDetails와 맞춰줌.
     */
    @Override
    public String getName() {   //Spring SecurityContext에서 OAuth2 로그인한 사용자의 식별자로 사용됨.
        return getUsername();
    }

    public String getEmail() {
        return userEntity.getEmail();
    }

    public String getNickname() {
        return userEntity.getNickname();
    }

    public String getProvider(){
        return userEntity.getProvider();
    }

}

 

 

로그인과정 

 

우리가 설정할건  카카오로 인가코드 보내기 전에 Oauth2AuthorizationRequest를 검사하는 것이다.

이는 SeucirtyConfig의    authorizationRequestRepository를 위에서 만든 

InMemoryAuthorzationRequestRepository로 지정해주면 된다. 

http.oauth2Login(oauth2 -> oauth2
    .authorizationEndpoint(authEndpoint -> authEndpoint
        .authorizationRequestRepository(authorizationRequestRepository)) // ✅ 직접 구현한 저장소 적용
    .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
    .successHandler(oAuth2LoginSuccessHandler) // ✅ 로그인 성공 시 JWT 발급
    .failureHandler((request, response, exception) -> {
        exception.printStackTrace();
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    })  // ✅ 실패 시 로그 찍기
);

 

 

 

 

 

 

 

 

 

 

 

 

 

 

실행결과

 

로그인페이지로 요청 후 authorizationURL을 받는다.  

 

 

 

authorizationURL로 요청하면 다음과 같은 화면이 나온다.  

(지금 우리 서버는 백엔드서버지만 딱히 로그인페이지에서 id,pw를 입력할  클라이언트를 안 만들어서

브라우저로 진행한다.)

 

 

 

 

 

redirect-uri를 처리한 서버로그. 인가코드가 포함되어있다.

 

브라우저는 로그인 후 자동으로 redirect-uri로  요청한다.  

로그를 보면 /login/oauth2/code/kakao?code= 가 있다. 인가코드가 포함된 요청이다.

(브라우저가 아닐 때는 redirect-uri로 클라이언트가 직접 백엔드서버에 요청하면 된다.)

 

브라우저가 /login/oauth2/code/kakao?code=   로 요청을 하는 순간 

우리 서버에서는  카카오 인증서버 + 리소스 서버 요청해서 데이터받고 자체 DB에 저장하는 과정을 다 한다.

이 후    OAuth2LoginSuccessHandler의  onAuthenticationSuccess메소드에서  

response에 직접 access_token과 refresh_token을 json 형태로 담아 return 했기에 

브라우저화면에 표시된다.

 

 

 

 

이후 이 access token으로  /my/info에 요청하면 내 정보를 보게된다.

 

나머지 토큰만료, refresh 토큰등은  일반회원과 똑같이 처리된다.
(카카오로 로그인을 했다면 일반회원처럼 DB에 저장되어있기떄문에)

 

 

 

 

 

※ 사실 이 글에서는 Client-side를 post-man과 브라우저로 하기 때문에 브라우저에

access_token과 refresh_token이 노출되었다.  Client -side가 브라우저일 때는 cookie 등에

포함시켜 노출을 피해야 한다.   

클라이언트가 브라우저 ,앱 등 다양하고, 하나의 서버에서 Oauth2 JWT 를 처리하려한다면 

OAuth2LoginSuccessHandler의  onAuthenticationSuccess메소드 안에서 

다음과 같이 따로 처리해줄  필요가 있다.

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                    Authentication authentication) throws IOException, ServletException {
                                    
        if(브라우저라면){
           쿠키에 token 감추기 
           return ; 
        }
        
        if( 앱이라면){ 
         response.setContentType("application/json");
         response.getWriter().write(new ObjectMapper().writeValueAsString(
            Map.of("access_token", accessToken, "refresh_token", refreshToken)
          ));
          return ;
        }
        
        if(그외 기타 등등){
        }
        
 }