Spring

[Spring] Security,Jwt,쿠키,OAuth2를 이용한 로그인과 인증처리 (feat. Front End) [4] - Filter 및 Handler 커스터 마이징

PlatinumeOlive 2024. 6. 19. 18:04

이전 포스팅

2024.06.09 - [Spring] - [Spring] Security,Jwt,쿠키,OAuth2를 이용한 로그인과 인증처리 (feat. Front End) [3] - Jwt와 쿠키를 사용한 인증 구현 + OAuth2 간편 로그인

 

[Spring] Security,Jwt,쿠키,OAuth2를 이용한 로그인과 인증처리 (feat. Front End) [3] - Jwt와 쿠키를 사용한

이전 포스팅2024.05.25 - [Spring] - [Spring] Security,Jwt 그리고 쿠키를 이용한 로그인과 인증처리 (feat. Front End) [2] - Jwt와 쿠키를 사용한 인증 [Spring] Security,Jwt 그리고 쿠키를 이용한 로그인과 인증처리 (

100cblog.tistory.com

 

이전 포스팅 부터 내용을 이것 저것 넣어 통합 로그인에 대한 글을 작성하려다 보니, 가독성이 매우 떨어지는 부분이 있습니다. 감안하고 봐주시면 감사하겠습니다.

 

아직 내용이 너무 많습니다. 상세한 설명은 좀 생략하고, 코드 위주로 빠르게 진행해보겠습니다.


 

이 포스팅에서는 스프링 부트 3.2.4 버전을 사용하고, 스프링 시큐리티 6.2.3 버전을 사용합니다.

 

저번 포스팅에 이어, 이번에는 시큐리티 필터를 직접 커스터마이징 해보겠습니다.

JsonUsernamePasswordAuthenticationFilter

RESTFUL API 기반의 로그인을 구현할것이 때문에, Json을 처리할 수 있는 필터를 구현해보겠습니다.

public class JsonUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    private static final String DEFAULT_LOGIN_REQUEST_URL = "/api/member/login";  // /login/oauth2/ + ????? 로 오는 요청을 처리할 것이다
    private static final String HTTP_METHOD = "POST";    //HTTP 메서드의 방식은 POST 이다.
    private static final String CONTENT_TYPE = "application/json";//json 타입의 데이터로만 로그인을 진행한다.
    private final ObjectMapper objectMapper;
    private static final String MEMBER_ID_KEY="memberId";
    private static final String PASSWORD_KEY="password";

    private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER =
            new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD); //=>   /login 의 요청에, POST로 온 요청에 매칭

    public JsonUsernamePasswordAuthenticationFilter(ObjectMapper objectMapper) {

        super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER);   // 위에서 설정한  /api/member/login 의 요청에, POST 온 요청을 처리하기 위해 설정

        this.objectMapper = objectMapper;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        if(request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE)  ) {
            throw new AuthenticationServiceException("Authentication Content-Type not supported: " + request.getContentType());
        }

        String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);

        Map<String, String> usernamePasswordMap = objectMapper.readValue(messageBody, Map.class);

        String memberId = usernamePasswordMap.get(MEMBER_ID_KEY);
        String password = usernamePasswordMap.get(PASSWORD_KEY);

        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(memberId, password);//principal 과 credentials 전달

        return this.getAuthenticationManager().authenticate(authRequest);
    }
}
  • DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD, CONTENT_TYPE 를 통해 "/api/member/login"으로 들어오는 "Post"인 "JSON"형식의 요청에 대해서만 작동하도록 설정했습니다.
  • attemptAuthentication 메서드
    • username과 password를 받아와 FormLogin과 동일하게 UsernamePasswordAuthenticationToken을 사용했습니다. username과 password를 사용하여 로그인하는 전략은 폼로그인과 똑같기 때문에 굳이 따로 구현하지 않고 기존에 있는걸 사용하였습니다.
    • return값은 authenticationManager의 authenticate 메서드를 실행했습니다. 여기서 사용되는 AuthenticationManager는 ProviderManager입니다.  SecurityConfig 파일에서 설정하였습니다.

JwtAuthenticationFilter

발급받은 토큰을 헤더에 실어서 보내면, 토큰을 기반으로 인증을 진행해주는 필터입니다.

 

OncePerRequestFilter을 상속받아 구현해보겠습니다.

OncePerRequestFilter는 모든 서블릿 컨테이너에서 요청 디스패치당 단일 실행을 보장하는 것을 목표로 하는 필터 기본 클래스 입니다.

 

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

	private final JwtService jwtService;
	private final MemberRepository memberRepository;
	private ObjectMapper objectMapper = new ObjectMapper();

	private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();//5

	private static final String NO_CHECK_URL = "/api/member/login";//1
	private static final String NO_CHECK_URL_2 = "/api/member/login/oauth2";
	private static final String NO_CHECK_URL_3 = "/api/member/login-extension";

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
		if(request.getRequestURI().equals(NO_CHECK_URL) || request.getRequestURI().contains(NO_CHECK_URL_2) ||
				request.getRequestURI().equals(NO_CHECK_URL_3)) {
			filterChain.doFilter(request, response);
			return;//안해주면 아래로 내려가서 계속 필터를 진행해버림
		}

		checkAccessTokenAndAuthentication(request, response, filterChain);

	}

	private void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
		try {
			jwtService.extractAccessToken(request).filter(jwtService::isTokenValid).flatMap(jwtService::extractMemberId)
					.flatMap(memberRepository::findByMemberId).ifPresent(this::saveAuthentication);
			filterChain.doFilter(request, response);
		} catch (NullPointerException e) {
			response.setCharacterEncoding(StandardCharsets.UTF_8.name());
			response.setStatus(ErrorStatus.MEMBER_AUTHORIZATION_NOT_VALID.getHttpStatus().value());
			response.setContentType("application/json");
			response.getWriter().write(objectMapper.writeValueAsString(ApiResponse.onFailure(ErrorStatus.MEMBER_AUTHORIZATION_NOT_VALID.getCode(),
					ErrorStatus.MEMBER_AUTHORIZATION_NOT_VALID.getMessage(), e.getMessage())));
			log.info("Authentication failed: " + e.getClass().toString() + " : " + e.getMessage());
		}
	}


	private void saveAuthentication(Member member) {
		CustomUserDetails userDetails = CustomUserDetails.create(member);

		Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null,authoritiesMapper.mapAuthorities(userDetails.getAuthorities()));


		SecurityContext context = SecurityContextHolder.createEmptyContext();//5
		context.setAuthentication(authentication);
		SecurityContextHolder.setContext(context);

		log.info("Authentication success: memberId = {}", member.getMemberId());
	}
}
  1. 로그인, 소셜로그인, 로그인 연장 URI 으로 들어오는 요청에 대해서는 작동하지 않습니다.
  2. refreshToken이 없다면 AccessToken을 검사하는 로직을 수행합니다.
    request에서 AccessToken을 추출한 후, 있다면 해당 AccessToken에서 email을 추출합니다. email이 추출되었다면 해당 회원을 찾아와서 그 정보를 가지고 인증처리를 합니다. 이때 SecurityContextHolder에 Authentication 객체를 만들어 반환하는데, NullAuthoritiesMapper가 쓰입니다.

이는 스프링 시큐리티에서 제공해주는 것입니다.

 

SilentReAuthenticationFilter

쿠키에 있는 RefreshToken을 바탕으로, 사용자가 눈치채지 못하게 AccessToken을 재발급 해주어 로그인을 연장해주는 기능을 수행하는 필터입니다.

 

/api/member/login-extension로 오는 POST 동작에 대해 동작합니다.

@Slf4j
@RequiredArgsConstructor
public class SilentReAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final MemberRepository memberRepository;
    private ObjectMapper objectMapper = new ObjectMapper();

    private static final String MATCH_URL = "/api/member/login-extension";//1

    private static final String REFRESH_TOKEN = "refreshToken";
    private static final String HTTP_METHOD = "POST";


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if(!request.getRequestURI().equals(MATCH_URL)) {
            filterChain.doFilter(request, response);
            return;//안해주면 아래로 내려가서 계속 필터를 진행해버림
        }

        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");

        if (!request.getMethod().equals(HTTP_METHOD)) {
            response.setStatus(ErrorStatus._BAD_REQUEST.getHttpStatus().value());
            response.getWriter().write(objectMapper.writeValueAsString(ApiResponse.onFailure(ErrorStatus._BAD_REQUEST.getCode(),
                    ErrorStatus._BAD_REQUEST.getMessage(), null)));
            return ;
        }

        String refreshToken = resolveRefreshToken(request);
        if (refreshToken == null) {
            response.setStatus(ErrorStatus.MEMBER_COOKIE_NOT_FOUND.getHttpStatus().value());
            response.getWriter().write(objectMapper.writeValueAsString(ApiResponse.onFailure(ErrorStatus.MEMBER_COOKIE_NOT_FOUND.getCode(),
                    ErrorStatus.MEMBER_COOKIE_NOT_FOUND.getMessage(), null)));
            log.info("Access Denied : RefreshToken이 없습니다. serverName : {}", request.getServerName());
        }

        if (refreshToken != null && jwtService.isTokenValid(refreshToken)) {
            checkRefreshTokenAndReIssueAccessToken(request, response, refreshToken);
        }

    }


    private void checkRefreshTokenAndReIssueAccessToken(HttpServletRequest request, HttpServletResponse response, String refreshToken) throws IOException {
        Optional<Member> member = memberRepository.findByRefreshToken(refreshToken);
        if (member.isEmpty()) {
            // 유효하지만, DB에 저장된 정보와 다른 경우
            return;
        }
        log.info("RefreshToken을 재발급합니다. memberId : {} refreshToken : {}", member.get().getMemberId(), refreshToken);

        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");

        String newRefreshToken = jwtService.reIssueAndSaveRefreshToken(member.get().getMemberId());
        String reIssuedAccessToken = jwtService.reIssueAccessToken(member.get().getMemberId());
        if (request.getServerName().equals("localhost")) {
            setCookieForLocal(response, newRefreshToken);
        } else {
            setCookieForProd(response, newRefreshToken);
        }

        LoginResponseDto.LoginDto loginDto = LoginResponseDto.LoginDto.builder()
                .memberId(member.get().getMemberId())
                .accessToken(reIssuedAccessToken)
                .role(member.get().getRole().getTitle())
                .build();

        response.getWriter().write(objectMapper.writeValueAsString(ApiResponse.onSuccess(loginDto)));

    }

    private void setCookieForLocal(HttpServletResponse response, String refreshToken) {
        Cookie cookie = new Cookie(REFRESH_TOKEN, refreshToken);
        cookie.setPath("/"); // 모든 곳에서 쿠키열람이 가능하도록 설정
        cookie.setMaxAge(60 * 60 * 24); //쿠키 만료시간 24시간

        response.addCookie(cookie);
    }

    private void setCookieForProd(HttpServletResponse response, String refreshToken) {
        Cookie cookie = new Cookie(REFRESH_TOKEN, refreshToken);
        cookie.setHttpOnly(true);  //httponly 옵션 설정
        cookie.setSecure(true); //https 옵션 설정
        cookie.setPath("/"); // 모든 곳에서 쿠키열람이 가능하도록 설정
        cookie.setMaxAge(60 * 60 * 24); //쿠키 만료시간 24시간

        response.addCookie(cookie);
    }

    private String resolveRefreshToken(HttpServletRequest request){
        Cookie[] cookies = request.getCookies();
        if (Objects.isNull(cookies)) return null;
        for (Cookie cookie : cookies) {
            if (REFRESH_TOKEN.equals(cookie.getName())) {
//                log.info("cookie value = {}, {}",cookie.getValue(), cookie.getName());
                if (cookie.getValue().equals("undefined"))
                    continue;
                return cookie.getValue();
            }
        }

        return null;
    }
}

 

RefreshToken이 유효하다면, AccessToken과 RefreshToken 모두 재발급합니다. (RTR 기법. 이전 포스팅 참고)

 

SecurityConfig 설정

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@Lazy
public class SecurityConfig {

    private final ObjectMapper objectMapper;
    private final CustomUserDetailsService userDetailsService;
    private final JwtService jwtService;
    private final MemberRepository memberRepository;
    private final CorsProperties corsProperties;
    private final AccessTokenAuthenticationProvider provider;

    // 스프링 시큐리티 기능 비활성화
    @Bean
    public WebSecurityCustomizer configure() {
        return (web -> web.ignoring()
//				.requestMatchers(toH2Console())
                .requestMatchers("/fcm", "/static/**", "/h2-console/**",
                        "/favicon.ico", "/error", "/swagger-ui/**",
                        "/swagger-resources/**", "/v3/api-docs/**")
        );
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http	.csrf(AbstractHttpConfigurer::disable)
                .cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer
                        .configurationSource(corsConfigurationSource()))
                .headers(headersConfigurer -> headersConfigurer.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) // For H2 DB
                .httpBasic(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests((authorize) -> authorize
                        .requestMatchers( "/api/member/signup", "/", "/api/member/isDuplicated", "/api/member/login/oauth").permitAll()
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        ;
        http
                .exceptionHandling(ex -> ex
                        .accessDeniedHandler(new CustomAccessDeniedHandler(jwtService))
                        .authenticationEntryPoint(new CustomAuthenticationEntryPoint()))
                .addFilterAfter(silentReAuthenticationFilter(), LogoutFilter.class)
                .addFilterAfter(jsonUsernamePasswordLoginFilter(), LogoutFilter.class)
                .addFilterBefore(jwtAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(auth2AccessTokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();

        daoAuthenticationProvider.setUserDetailsService(userDetailsService);
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());

        return daoAuthenticationProvider;
    }

    @Bean
    public static PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager() throws Exception {//2 - AuthenticationManager 등록
        DaoAuthenticationProvider provider = daoAuthenticationProvider();//DaoAuthenticationProvider 사용
        return new ProviderManager(provider);
    }

    @Bean
    public LoginSuccessJWTProvideHandler loginSuccessJWTProvideHandler(){
        return new LoginSuccessJWTProvideHandler(jwtService, memberRepository);
    }

    @Bean
    public LoginFailureHandler loginFailureHandler(){
        return new LoginFailureHandler();
    }



    @Bean
    public JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordLoginFilter() throws Exception {
        JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordLoginFilter = new JsonUsernamePasswordAuthenticationFilter(objectMapper);
        jsonUsernamePasswordLoginFilter.setAuthenticationManager(authenticationManager());
        jsonUsernamePasswordLoginFilter.setAuthenticationSuccessHandler(loginSuccessJWTProvideHandler());
        jsonUsernamePasswordLoginFilter.setAuthenticationFailureHandler(loginFailureHandler());
        return jsonUsernamePasswordLoginFilter;
    }

    @Bean
    public JwtAuthenticationFilter jwtAuthenticationProcessingFilter(){
        return new JwtAuthenticationFilter(jwtService, memberRepository);
    }

    @Bean
    OAuth2AccessTokenAuthenticationFilter auth2AccessTokenAuthenticationFilter() {
        return new OAuth2AccessTokenAuthenticationFilter(provider, loginSuccessJWTProvideHandler(), loginFailureHandler());
    }

    @Bean
    public SilentReAuthenticationFilter silentReAuthenticationFilter() {
        return new SilentReAuthenticationFilter(jwtService, memberRepository);
    }


    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration corsConfiguration = new CorsConfiguration();

        corsConfiguration.setAllowedHeaders(Arrays.asList(corsProperties.getAllowedHeaders().split(",")));
        corsConfiguration.setAllowedMethods(Arrays.asList(corsProperties.getAllowedMethods().split(",")));
        corsConfiguration.setAllowedOrigins(Arrays.asList(corsProperties.getAllowedOrigins().split(",")));
        corsConfiguration.setExposedHeaders(Arrays.asList("Authorization", "Authorization-refresh"));
        corsConfiguration.setAllowCredentials(true);
        corsConfiguration.setMaxAge(corsConfiguration.getMaxAge());

        UrlBasedCorsConfigurationSource corsConfigSource = new UrlBasedCorsConfigurationSource();
        corsConfigSource.registerCorsConfiguration("/**", corsConfiguration);
        return corsConfigSource;
    }
}

 

  1. 기존 SpringSecurityFilterChain에는 커스텀한 필터가 존재하지 않으므로, 필터체인의 http 설정에 .addFilterAfter(jsonUsernamePasswordLoginFilter(), LogoutFilter.class) 메서드를 통해 Logout필터 이후에 커스텀 필터를 추가했습니다.
  2. 이전 포스팅에서 사용하기로 한 PasswordEncoder를 AuthenticationProvider에 등록해줍니다. 폼 로그인 방식과 동일하게 DaoAuthenticationProvider를 사용합니다.
  3. AuthemticationManager는 폼 로그인과 동일한 ProviderManager를 사용합니다.
  4. 앞에서 생성한 JsonUsernamePasswordAuthenticationFilter을 빈으로 등록해줍니다. 이때 AuthenticationManager도 함께 등록해주지 않으면 오류가 발생합니다.

이제  UserDetails를 반환하는 UserDetailsService 를 상속받은 클래스를 만들어 보겠습니다.

CustomUserDetails

@Builder
public class CustomUserDetails implements UserDetails {

	private String id;
	private String email;
	private String password;
	private String name;
	private SocialType socialType;
	private Collection<? extends GrantedAuthority> authorities;
	private Map<String, Object> attributes;

	@ElementCollection(fetch = FetchType.EAGER)
	@Builder.Default
	private List<String> roles = new ArrayList<>();

	public static CustomUserDetails create(Member member) {
		return CustomUserDetails.builder()
				.id(member.getMemberId())
				.email(member.getEmail())
				.password(member.getPassword())
				.socialType(member.getSocialType())
				.authorities(AuthorityUtils.createAuthorityList(member.getRole().toString()))
				.build();
	}

	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		return authorities;
	}

	public void updateAuthorities(Member member) {
		this.authorities = AuthorityUtils.createAuthorityList(member.getRole().toString());
	}

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

	@Override
	public String getUsername() {
		return this.id;
	}

	public String getMemberId() {
		return this.id;
	}

	public String getMemberName() {
		return this.name;
	}

	public String getEmail() {
		return this.email;
	}

	public SocialType getSocialType() {
		return this.socialType;
	}

	@Override
	public boolean isAccountNonExpired() {
		return true;
	}

	@Override
	public boolean isAccountNonLocked() {
		return true;
	}

	@Override
	public boolean isCredentialsNonExpired() {
		return true;
	}

	@Override
	public boolean isEnabled() {
		return true;
	}
}

 

UserDetailsServiceImpl

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

	private final MemberRepository memberRepository;

	@Override
	public UserDetails loadUserByUsername(String memberId) throws UsernameNotFoundException {
		Member member = memberRepository.findByMemberId(memberId)
				.orElseThrow(() -> new UsernameNotFoundException("해당하는 회원을 찾을 수 없습니다."));
		return CustomUserDetails.create(member);
	}

}

DB에서 email에 해당하는 유저를 찾아 반환해줍니다.

비밀 번호 검증은 DaoAuthenticationProvider에서 해줍니다. 더 정확히는 AbstractUserDetailsAuthenticationProvider 에서 진행합니다.

additionalAuthenticationChecks도 retriveUser와 마찬가지로 추상 메서드이므로 구현 클래스에게 처리를 맡깁니다.

구현클래스인 DaoAuthenticationProvider에서 구현 되어있는 것을 아래 코드에서 볼 수 있습니다.

비밀번호 (Credential)의 일치 여부를 판단합니다.

userDetails는 UserDetailsService에서 만들어준 Member 객체이고, 이것의 password 와JsonUsernamePasswordAuthenticationFilter (정확히는 AbstractAuthenticationProcessingFilter)에서 Request의 정보를 통해 인자로 전달해준 Authentication 객체(여기서는 UsernamePasswordAuthenticationToken)의  Credentials (우리는 password를 넣어주었다)를 비교합니다.

 

그렇기 때문에 저희는 DB에서 유저 정보만 조회해서 반환만하면 됩니다.

이제 성공 처리와 실패 처리를 할 Handler을 구현해보겠습니다.

LoginSuccessJWTProvideHandler

@Slf4j
@RequiredArgsConstructor
@Transactional
public class LoginSuccessJWTProvideHandler extends SimpleUrlAuthenticationSuccessHandler {

	private final JwtService jwtService;

	private final MemberRepository memberRepository;

	private ObjectMapper objectMapper = new ObjectMapper();

	private static final String REFRESH_TOKEN = "refreshToken";

	@Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
										Authentication authentication) throws IOException, ServletException {
		String memberId = extractMemberId(authentication);
		JwtToken jwtToken = jwtService.createJwtToken(authentication);

		Member member = memberRepository.findByMemberId(memberId).orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다."));
		member.updateRefreshToken(jwtToken.getRefreshToken());

		jwtService.sendAccessToken(response, jwtToken);
		log.info( "로그인에 성공합니다. memberId: {}" , memberId);
		log.info( "AccessToken 을 발급합니다. AccessToken: {}" ,jwtToken.getAccessToken());
		log.info( "RefreshToken 을 발급합니다. RefreshToken: {}" ,jwtToken.getRefreshToken());

		SecurityContext context = SecurityContextHolder.createEmptyContext();//5
		context.setAuthentication(authentication);
		SecurityContextHolder.setContext(context);

		response.setContentType("application/json");
		response.setCharacterEncoding("utf-8");

		LoginDto loginDto = LoginDto.builder()
				.memberId(memberId)
				.accessToken(jwtToken.getAccessToken())
				.role(member.getRole().getTitle())
				.build();

		setCookieForLocal(response, jwtToken); // 개발단계에서 사용

		response.getWriter().write(objectMapper.writeValueAsString(ApiResponse.onSuccess(loginDto)));



//		if(request.getServerName().equals("localhost")){
//			setCookieForLocal(response, jwtToken);
//		}
//		else{
//			setCookieForProd(response, jwtToken);
//		}

	}

	private void setCookieForLocal(HttpServletResponse response, JwtToken jwtToken) {
		Cookie cookie = new Cookie(REFRESH_TOKEN, jwtToken.getRefreshToken());
		cookie.setHttpOnly(true);  //httponly 옵션 설정
		cookie.setPath("/"); // 모든 곳에서 쿠키열람이 가능하도록 설정
		cookie.setMaxAge(60 * 60 * 24); //쿠키 만료시간 24시간

		response.addCookie(cookie);
	}

	private void setCookieForProd(HttpServletResponse response, JwtToken jwtToken) {
		Cookie cookie = new Cookie(REFRESH_TOKEN, jwtToken.getRefreshToken());
		cookie.setHttpOnly(true);  //httponly 옵션 설정
		cookie.setSecure(true); //https 옵션 설정
		cookie.setPath("/"); // 모든 곳에서 쿠키열람이 가능하도록 설정
		cookie.setMaxAge(60 * 60 * 24); //쿠키 만료시간 24시간

		response.addCookie(cookie);
	}


	private String extractMemberId(Authentication authentication) {
		UserDetails userDetails = (UserDetails) authentication.getPrincipal();
		return userDetails.getUsername();
	}
	private String extractPassword(Authentication authentication) {
		UserDetails userDetails = (UserDetails) authentication.getPrincipal();
		return userDetails.getPassword();
	}
}

SimpleUrlAuthenticationSuccessHandler을 상속받아, 로그인 성공시 JWT를 발급하도록 합니다. 

 

Body에 직접 response.write() 메서드를 통해 응답 형식을 작성하고, 쿠키에 refreshToken을 넣어 반환합니다.

LoginFailureHandler

@Slf4j
public class LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {

	private ObjectMapper objectMapper = new ObjectMapper();

	@Override
	public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
		response.setCharacterEncoding(StandardCharsets.UTF_8.name());
		if (exception.getMessage().contains("Content-Type")){
			response.setStatus(ErrorStatus.MEMBER_LOGIN_NOT_SUPPORT.getHttpStatus().value());
			response.getWriter().write(objectMapper.writeValueAsString(ApiResponse.onFailure(ErrorStatus.MEMBER_LOGIN_NOT_SUPPORT.getCode(),
					ErrorStatus.MEMBER_LOGIN_NOT_SUPPORT.getMessage(), exception.getMessage())));
		} else {
			response.setContentType("application/json");
			response.setStatus(ErrorStatus.MEMBER_EMAIL_PASSWORD_NOT_MATCH.getHttpStatus().value());
			response.getWriter().write(objectMapper.writeValueAsString(ApiResponse.onFailure(ErrorStatus.MEMBER_EMAIL_PASSWORD_NOT_MATCH.getCode(),
					ErrorStatus.MEMBER_EMAIL_PASSWORD_NOT_MATCH.getMessage(), exception.getMessage())));
		}
		log.info("Authentication failed: " + exception.getMessage());
	}
}

SimpleUrlAuthenticationFailureHandler을 상속받은 핸들러입니다.

상태 코드는 인증을 실패하였다는 의미로, 401 Unauthorized를 반환하도록 하였습니다.

 

이 핸들러는 Security Config에 설정 되어있습니다.

 

CustomAuthenticationEntryPoint

인증이 되지 않은 사용자가 경로에 접근하려고 시도할 경우, 401에러를 반환하도록 하는 AuthenticationEntryPoint를 상속받아 커스터마이징한 클래스입니다. 원래 스프링 시큐티에서 정해진 규약대로 동작하는 것을, 제가 임의로 401 에러를 발생시키도록 하였습니다.

@Slf4j
@RequiredArgsConstructor
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authenticationException) throws IOException {

        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(ErrorStatus._UNAUTHORIZED.getHttpStatus().value());
        response.getWriter().write(objectMapper.writeValueAsString(ApiResponse.onFailure(ErrorStatus._UNAUTHORIZED.getCode(),
                ErrorStatus._UNAUTHORIZED.getMessage(), null)));
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        log.info("Access denied : {}, requestURI = {} " ,ErrorStatus._UNAUTHORIZED.getMessage(), request.getRequestURI());
    }
}

 

CustomAccessDeniedHandler

권한이 부족한 사용자가 접근을 시도했을때 403에러를 반환하도록 하였습니다.  

@Slf4j
@RequiredArgsConstructor
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    private ObjectMapper objectMapper = new ObjectMapper();

    private final JwtService jwtService;


    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException {
        Optional<String> accessToken = jwtService.extractAccessToken(request);
        if (accessToken.isPresent()) {
            Optional<String> memberId = jwtService.extractMemberId(accessToken.get());
            log.info("Access denied: memberId = " + memberId.get());
        }
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());


        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(ErrorStatus.MEMBER_NOT_AUTHENTICATED.getHttpStatus().value());
        response.getWriter().write(objectMapper.writeValueAsString(ApiResponse.onFailure(ErrorStatus.MEMBER_NOT_AUTHENTICATED.getCode(),
                ErrorStatus.MEMBER_NOT_AUTHENTICATED.getMessage(), null)));
        response.setStatus(HttpStatus.FORBIDDEN.value());
    }
}

 

이제 일반적인 Spring Security 설정은 얼추 다 추가한 것 같습니다. 다음 포스팅에서는 이렇게 설정한 스프링 시큐리티에, 구글 카카오 네이버 모두 동작하는 소셜 로그인 로직을 추가해 보겠습니다.

 

언급되지 않은 코드는 아래 깃허브에서 보실 수 있습니다.

 

https://github.com/dh1010a/trippy_server

 

GitHub - dh1010a/trippy_server

Contribute to dh1010a/trippy_server development by creating an account on GitHub.

github.com

 

 

출처 및 참고