본문 바로가기

SPRING-SECURITY

JWT + OAuth2 구현 - (5)

 

CustomJsonUsernamePasswordAuthenticationFilter

public class CustomJsonUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    private static final String DEFAULT_LOGIN_REQUEST_URL = "/login"; // "/login"으로 오는 요청을 처리
    private static final String HTTP_METHOD = "POST"; // 로그인 HTTP 메소드는 POST
    private static final String CONTENT_TYPE = "application/json"; // JSON 타입의 데이터로 오는 로그인 요청만 처리
    private static final String USERNAME_KEY = "email"; // 회원 로그인 시 이메일 요청 JSON Key : "email"
    private static final String PASSWORD_KEY = "password"; // 회원 로그인 시 비밀번호 요청 JSon Key : "password"
    private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER =
            new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD); // "/login" + POST로 온 요청에 매칭된다.

    private final ObjectMapper objectMapper;

    public CustomJsonUsernamePasswordAuthenticationFilter(ObjectMapper objectMapper) {
        super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER); // 위에서 설정한 "login" + POST로 온 요청을 처리하기 위해 설정
        this.objectMapper = objectMapper;
    }

  
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException {
        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 email = usernamePasswordMap.get(USERNAME_KEY);
        String password = usernamePasswordMap.get(PASSWORD_KEY);

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

        return this.getAuthenticationManager().authenticate(authRequest);
    }
}

 

생성자

public CustomJsonUsernamePasswordAuthenticationFilter(ObjectMapper objectMapper) {
    super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER); // 위에서 설정한 "login" + POST로 온 요청을 처리하기 위해 설정
    this.objectMapper = objectMapper;
}

super()를 통해 부모클래스의 생성자 파라미터로 위에서 선언한 "/login" URL 설정

=> 이 설정을 통해 "/login"과 POST 요청이 들어올 시 로그인 기능을 하게 된다.

attempAuthentication

@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException {
    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 email = usernamePasswordMap.get(USERNAME_KEY);
    String password = usernamePasswordMap.get(PASSWORD_KEY);

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

    return this.getAuthenticationManager().authenticate(authRequest);
}

기본적으로 부모클래스의 인증처리 메소드.

if(request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE)  ) {
    throw new AuthenticationServiceException("Authentication Content-Type not supported: " + request.getContentType());
}

ContentType이 numll이거나 application/json이 아니면 예외를 발생시킨다

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

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

String email = usernamePasswordMap.get(USERNAME_KEY);
String password = usernamePasswordMap.get(PASSWORD_KEY)

Json을 String으로 변환해 mssageBody 담아주고

objectMapper.readValue()를 통해 Map으로 변환해 각각 email과 password 저장

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

FormLogin 필터인 UsernamePasswordAuthenticationFilter와 동일하게 

UsernamePasswordAuthenticationToken 객체를 사용해 인증 처리 객체인 AuthenticationManager가 인증 시 사용할 인증 대상 객체가 된다

 

파라미터로 넘겨준 email이 인증 대상 객체의 principal, password가

인증 대상 객체의 credentials이 되는데 여기서 만든 UsernamePasswordAuthenticationToken 인증 대상 객체를 통해

인증 처리 객체인 AuthenticationManager가 인증 성공/인증 실패 처리를 하게 된다.

 

LoginService

@Service
@RequiredArgsConstructor
public class LoginService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        Member member = memberRepository.findByEmail(email)
                .orElseThrow(() -> new UsernameNotFoundException("해당 이메일이 존재하지 않습니다."));

        return org.springframework.security.core.userdetails.User.builder()
                .username(member.getEmail())
                .password(member.getPassword())
                .roles(member.getRole().name())
                .build();
    }
}

 

LoginSuccessHandler

@Slf4j
@RequiredArgsConstructor
public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtService jwtService;
    private final MemberRepository memberRepository;

    @Value("${spring.jwt.access.expiration}")
    private String accessTokenExpiration;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) {
        String email = extractUsername(authentication); // 인증 정보에서 Username(email) 추출
        String accessToken = jwtService.createAccessToken(email); // JwtService의 createAccessToken을 사용하여 AccessToken 발급
        String refreshToken = jwtService.createRefreshToken(); // JwtService의 createRefreshToken을 사용하여 RefreshToken 발급

        jwtService.sendAccessAndRefreshToken(response, accessToken, refreshToken); // 응답 헤더에 AccessToken, RefreshToken 실어서 응답

        memberRepository.findByEmail(email)
                .ifPresent(user -> {
                    user.updateRefreshToken(refreshToken);
                    memberRepository.saveAndFlush(user);
                });
        log.info("로그인에 성공하였습니다. 이메일 : {}", email);
        log.info("로그인에 성공하였습니다. AccessToken : {}", accessToken);
        log.info("발급된 AccessToken 만료 기간 : {}", accessTokenExpiration);
    }

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

JSON 로그인 필터를 정상적으로 통과해서 인증처리가 됐기 때문에,

내부에서 AccessToken과 RefreshToken을 생성해서 Response에 보내준다.

 

유저 회원가입 시에는 RefreshToken이 없기 때문에 RefreshToken Column이 null이기 때문에 로그인 성공 시 RefeshToken을 발급하고, DB에 저장

DB에 발급된 RefreshToken을 저장하기 위해 유저 테이블의 RefreshToken Column에 업데이트하는

updateRefreshToken()으로 업데이트 후  saveAndFlush()로 DB에 반영

LoginFailureHandler

@Slf4j
public class LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                        AuthenticationException exception) throws IOException {
        response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
        response.setCharacterEncoding("UTF-8");
        response.setContentType("text/plain;charset=UTF-8");
        response.getWriter().write("로그인 실패! 이메일이나 비밀번호를 확인해주세요.");
        log.info("로그인에 실패했습니다. 메시지 : {}", exception.getMessage());
    }
}

로그인에 실패가게 되면에러 코드 반환

 

SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final LoginService loginService;
    private final JwtService jwtService;
    private final MemberRepository memberRepository;
    private final ObjectMapper objectMapper;

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

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(cs -> cs.disable())
                .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .formLogin(f -> f.disable())
                .httpBasic(h -> h.disable())
                .authorizeHttpRequests(auth -> {
                    auth.anyRequest().permitAll();
                });
        return http.build();
    }

아직 OAuth2 코드를 작성중이기 때문에 전체적으로 제약을 걸지 않고 JWT로그인을 확인하기 위해

csrf,sessionManagement,formLogin,httpBasic을 사용하지 않도록 걸어놨고

모든 요청에 대해 허가하도록 걸어놓고 테스트를 진행했다.

 

※첫 글에서도 작성했지만 이 프로젝트는 스프링부트 3.1.6으로 진행했기 때문에 deprecated된것들이 많아 기존에 SecurityConfig에 쓰던 코드와 진행방식이 조금 다르다.

'SPRING-SECURITY' 카테고리의 다른 글

JWT AccessToken, RefreshToken의 흐름  (0) 2024.01.17
Spring Security의 흐름  (0) 2023.12.28
JWT + OAuth 구현 - (4)  (0) 2023.12.14
JWT + OAuth2 구현 - (3)  (0) 2023.12.13
JWT + OAuth2 구현 - (2)  (0) 2023.12.13