Spring🌸

Spring Security - JWT 자격 증명을 위한 로그인 인증 구현

Jeein0313 2023. 6. 7. 11:04

JWT 자격 증명을 위해 로그인 인증 기능부터 구현해 보자.

 

Username과 Password로 로그인 인증에 성공하면 로그인 인증에 성공한 사용자에게 JWT를 생성 및 발급하는 것까지를 먼저 구현해본다.

 

JWT 자격 증명을 위한 로그인 인증 기능을 구현하기 위해 먼저 로그인 인증 흐름을 간단하게 확인해보자.

 

사용자의 로그인 인증 성공 후, JWT가 클라이언트에게 전달되는 과정은 다음과 같다.

 

1. 클라이언트가 서버 측에 로그인 인증 요청(Username / Password를 서버 측에 전송)

2. 로그인 인증을 담당하는 Security Filter(JwtAuthenticationFilter)가 클라이언트의 로그인 인증 정보 수신

3. Security Filter가 수신한 로그인 인증 정보를 AuthenticationManager에게 전달해 인증 처리를 위임

4. AuthenticationManager가 Custom UserDetailsService(MemberDetailsService)에게 사용자의 UserDetails 조회를 위임.

5. Custom UserDetailsService(MemberDetailsService)가 사용자의 크리덴셜을 DB에서 조회한 후, AuthenticationManager에게 사용자의 UserDetails를 전달.

6. AuthenticationManager가 로그인 인증 정보와 UserDetails의 정보를 비교해 인증 처리

7. JWT 생성 후 , 클라이언트의 응답으로 전달

 

 

1번부터 7번 과정에서 우리는 JwtAuthenticationFilter 구현(2번~3번, 7번), MemberDetailsService(5번)을 구현한다.

 

4번, 6번은 Spring Security의 AuthenticationManager가 대신 처리해 주므로 신경 쓸 필요가 없다.

 

 

1️⃣ SecurityConfiguration 

 

커스터마이징한 Configuration을 추가하여, JwtAuthenticationFilter를 Spring Security Filter에 추가한다.

package com.codestates.todoapp.config;

import com.codestates.todoapp.auth.filter.JwtAuthenticationFilter;
import com.codestates.todoapp.auth.jwt.JwtTokenizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
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.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.Arrays;

@Configuration
@EnableWebSecurity(debug = true)
public class SecurityConfiguration {
    private final JwtTokenizer jwtTokenizer;

    public SecurityConfiguration(JwtTokenizer jwtTokenizer) {
        this.jwtTokenizer = jwtTokenizer;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
        http
                .headers().frameOptions().sameOrigin()
                .and()
                .csrf().disable()
                .cors(Customizer.withDefaults())
                .formLogin().disable()
                .httpBasic().disable()
                .apply(new CustomFilterConfigurer())//커스터마이징된 Configuration 추가
                .and()
                .authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll());
        return http.build();
    }

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

    @Bean
    CorsConfigurationSource corsConfigurationSource(){
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("*"));
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PATCH", "DELETE"));
        UrlBasedCorsConfigurationSource  source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

    //JwtAuthenticationFilter를 등록하는 역할
    public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity>{
        @Override
        public void configure(HttpSecurity builder) throws Exception{
            AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);

            JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer);
            jwtAuthenticationFilter.setFilterProcessesUrl("/auth/login");

            //JwtAuthenticationFilter를 Spring Security Filter Chain에 추가한다.
            builder.addFilter(jwtAuthenticationFilter);
        }
    }

}

 

 

2️⃣ JwtAuthenticationFilter

 

attemptAuthentication 메서드에서 클라이언트가 전송한 Username, Password 정보로 생성한 UsernamePasswordAuthenticationToken을 AuthenticationManager에게 전달하면서 인증 처리 위임

package com.codestates.todoapp.auth.filter;

import com.codestates.todoapp.auth.dto.LoginDto;
import com.codestates.todoapp.auth.jwt.JwtTokenizer;
import com.codestates.todoapp.member.entity.Member;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.servlet.FilterChain;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

//클라이언트의 로그인 인증 요청을 처리하는 엔트리포인트 역할.
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    //로그인 인증 정보를 전달받아 UserDetailsService와 인터랙션 한 뒤 인증 여부 판단
    private final AuthenticationManager authenticationManager;

    //클라이언트가 인증에 성공할 경우, JWT를 생성 및 발급하는 역할
    private final JwtTokenizer jwtTokenizer;

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtTokenizer jwtTokenizer) {
        this.authenticationManager = authenticationManager;
        this.jwtTokenizer = jwtTokenizer;
    }

    //메서드 내부에서 인증을 시도하는 로직.
    @SneakyThrows
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response){
        //클라이언트에서 전송한 Username, Password를 DTO 클래스로 역직렬화하기 위해 ObjectMapper 인스턴스 생성
        ObjectMapper objectMapper = new ObjectMapper();

        //ServletInputStream을 LoginDto 클래스의 객체로 역직렬화
        LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class);

        //UsernamePasswordAuthenticationToken을 생성
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());

        //UsernamePasswordAuthenticationToken을 AuthenticationManager에게 전달하면서 인증 처리 위임
        return authenticationManager.authenticate(authenticationToken);
    }

    //클라이언트의 인증 정보를 이용해 인증에 성공할 경우 호출된다.
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authReesult){
        //AuthenticationManger 내부에서 인증에 성공하면 인증된 Authentication 객체가 생성되면서 principal 필드에 Member 객체 할당
        Member member = (Member) authReesult.getPrincipal();

        String accessToken = delegateAccessToken(member);
        String refreshToken = delegateRefreshToken(member);

        //response header에 Access Token 추가. 클라이언트 측에서 백엔드 애플리케이션 측에 요청을 보낼 때마다 request header에 추가해서 클라이언트 측의 자격을 증명하는 데 사용된다.
        response.setHeader("Authorization", "Bearer " + accessToken);
        //repsonse header에 Refresh Token 추가. Access Token이 만료될 경우, 클라이언트 측이 Access Token을 새로 발급받기 위해 클라이언트에게 추가적으로 제공될 수 있음.
        response.setHeader("Refresh", refreshToken);

    }


    private String delegateAccessToken(Member member) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", member.getEmail());
        claims.put("roles", member.getRoles());

        String subject = member.getEmail();
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());

        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);

        return accessToken;
    }

    private String delegateRefreshToken(Member member) {
        String subject = member.getEmail();
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);

        return refreshToken;
    }
}

 

 

3️⃣ MemberDetailsService

 

AuthenticationManager가 MemberDetailsService(Custom UserDetailsService)에게 UserDetails 정보 조회를 위임.

loadUserByUsername 메서드에서 크리덴셜 조회하여 AuthenticationManager에게 다시 전달

package com.codestates.todoapp.auth.userdetails;

import com.codestates.todoapp.auth.utils.CustomAuthorityUtils;
import com.codestates.todoapp.exception.BusinessLogicException;
import com.codestates.todoapp.exception.ExceptionCode;
import com.codestates.todoapp.member.entity.Member;
import com.codestates.todoapp.member.repository.MemberRepository;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;

import java.util.Collection;
import java.util.Optional;

@Component
public class MemberDetailsService implements UserDetailsService {
    private final MemberRepository memberRepository;
    private final CustomAuthorityUtils authorityUtils;

    public MemberDetailsService(MemberRepository memberRepository, CustomAuthorityUtils authorityUtils) {
        this.memberRepository = memberRepository;
        this.authorityUtils = authorityUtils;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<Member> optionalMember = memberRepository.findByEmail(username);
        Member findMember = optionalMember.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));
        return new MemberDetails(findMember);
    }

    private final class MemberDetails extends Member implements UserDetails{
        MemberDetails(Member member){
            setMemberId(member.getMemberId());
            setEmail(member.getEmail());
            setName(member.getName());
            setPassword(member.getPassword());
            setRoles(member.getRoles());
        }

        @Override
        public Collection<? extends GrantedAuthority> getAuthorities(){
            return authorityUtils.createAuthorities(this.getRoles());
        }

        @Override
        public String getUsername() {
            return getEmail();
        }

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

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

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

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

 

 

4️⃣ AuthenticationManager가 로그인 인증 정보와 UserDetails의 정보를 비교해 인증 처리

 

인증에 성공하면 successfulAuthentication 메서드가 호출되면서, Access Token과 Refresh Token을 response header에 추가

package com.codestates.todoapp.auth.filter;

import com.codestates.todoapp.auth.dto.LoginDto;
import com.codestates.todoapp.auth.jwt.JwtTokenizer;
import com.codestates.todoapp.member.entity.Member;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.servlet.FilterChain;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

//클라이언트의 로그인 인증 요청을 처리하는 엔트리포인트 역할.
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    //로그인 인증 정보를 전달받아 UserDetailsService와 인터랙션 한 뒤 인증 여부 판단
    private final AuthenticationManager authenticationManager;

    //클라이언트가 인증에 성공할 경우, JWT를 생성 및 발급하는 역할
    private final JwtTokenizer jwtTokenizer;

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtTokenizer jwtTokenizer) {
        this.authenticationManager = authenticationManager;
        this.jwtTokenizer = jwtTokenizer;
    }

    //메서드 내부에서 인증을 시도하는 로직.
    @SneakyThrows
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response){
        //클라이언트에서 전송한 Username, Password를 DTO 클래스로 역직렬화하기 위해 ObjectMapper 인스턴스 생성
        ObjectMapper objectMapper = new ObjectMapper();

        //ServletInputStream을 LoginDto 클래스의 객체로 역직렬화
        LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class);

        //UsernamePasswordAuthenticationToken을 생성
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());

        //UsernamePasswordAuthenticationToken을 AuthenticationManager에게 전달하면서 인증 처리 위임
        return authenticationManager.authenticate(authenticationToken);
    }

    //클라이언트의 인증 정보를 이용해 인증에 성공할 경우 호출된다.
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authReesult){
        //AuthenticationManger 내부에서 인증에 성공하면 인증된 Authentication 객체가 생성되면서 principal 필드에 Member 객체 할당
        Member member = (Member) authReesult.getPrincipal();

        String accessToken = delegateAccessToken(member);
        String refreshToken = delegateRefreshToken(member);

        //response header에 Access Token 추가. 클라이언트 측에서 백엔드 애플리케이션 측에 요청을 보낼 때마다 request header에 추가해서 클라이언트 측의 자격을 증명하는 데 사용된다.
        response.setHeader("Authorization", "Bearer " + accessToken);
        //repsonse header에 Refresh Token 추가. Access Token이 만료될 경우, 클라이언트 측이 Access Token을 새로 발급받기 위해 클라이언트에게 추가적으로 제공될 수 있음.
        response.setHeader("Refresh", refreshToken);

    }


    private String delegateAccessToken(Member member) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", member.getEmail());
        claims.put("roles", member.getRoles());

        String subject = member.getEmail();
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());

        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);

        return accessToken;
    }

    private String delegateRefreshToken(Member member) {
        String subject = member.getEmail();
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);

        return refreshToken;
    }
}

 

 

 

 

👉 이렇게 되면 로그인한 사용자에게 JWT Access Token, Refresh Token이 부여되면서, 서버 측의 리소스를 사용하기 위한 request를 전송할 때마다 전달받은 JWT를 request header에 포함한 후, 클라이언트의 자격 증명 정보로 사용하면 된다.

 

 

 

여기까지 하면 포스트맨으로 다음과 같이 로그인했을 때 response header에 Authorization 에는 Access Token이, Refresh에는 Refresh Token이 담겨있는 것을 확인할 수 있다.

 

 

 

 

 

 

이제 로그인 인증 성공 및 실패에 따른 추가 처리를 해보자.

 

Spring Security에서는 Username/Password 기반의 로그인 인증에 성공했을 때, 로그를 기록한다거나 로그인에 성공한 사용자 정보를 repsonse로 전송하는 등의 추가 처리를 할 수 있는 핸들러(AuthenticationSuccessHandler)를 지원하며, 로그인 인증 실패 시에도 마찬가지로 인증 실패에 대해 추가 처리를 할 수 있는 핸들러(AuthenticationFailureHandler)를 지원한다.

 

 

1️⃣ MemberAuthenticationSuccessHandler 

 

로그인 인증 성공 시 추가 작업을 할 수 있는 클래스

현재 코드는 인증 성공 후, 로그를 기록하고 사용자 이름을 response로 전송하였다.

package com.codestates.todoapp.auth.handler;

import com.codestates.todoapp.member.entity.Member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Slf4j
public class MemberAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        // 인증 성공 후 로그를 기록.
        log.info("# Authenticated successfully!");

        Member member = (Member)authentication.getPrincipal();
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(HttpStatus.OK.value());

        //HttpServletResponse의 getWriter는 PrintWriter 객체를 반환
        response.getWriter().write("Authentication success! User : " + member.getName());

        //PrintWriter의 버퍼를 비우고, 현재까지 버퍼에 쌓인 데이터를 실제 출력 스트림으로 보냄.
        response.getWriter().flush();
    }
}

 

 

2️⃣ MemberAuthenticationFailureHandler

 

로그인 인증 실패 시 추가 작업을 할 수 있는 클래스.

현재 코드는 sendErrorResponse 메서드를 호출해 출력 스트림에 Error 정보를 담고 있다.

package com.codestates.todoapp.auth.handler;

import com.codestates.todoapp.response.ErrorResponse;
import com.google.gson.Gson;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Slf4j
public class MemberAuthenticationFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        log.error("# Authentication failed: {}", exception.getMessage());

        sendErrorResponse(response);
    }

    private void sendErrorResponse(HttpServletResponse response) throws IOException {
        //Error 정보가 담긴 객체를 JSON 문자열로 변환하는 데 사용되는 Gson 라이브러리의 인스턴스를 생성한다.
        Gson gson = new Gson();

        //ErrorResponse 객체를 생성
        ErrorResponse errorResponse = ErrorResponse.of(HttpStatus.UNAUTHORIZED);

        //response의 ContentType이 application/json 이라는 것을 클라이언트에게 알려주기 위함.
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);

        response.setStatus(HttpStatus.UNAUTHORIZED.value());

        //Gson을 이용해 ErrorResponse 객체를 JSON 포맷 문자열로 변환 후, 출력 스트림 생성
        response.getWriter().write(gson.toJson(errorResponse, ErrorResponse.class));
        /*
            {
                "status": 401,
                "message": "Unauthorized"
            }
         */
    }

}

 

 

3️⃣ AuthenticationSuccessHandler 인터페이스의 구현 클래스와 AuthenticationFailureHandler 인터페이스의 구현 클래스를 JwtAuthenticationFilter에 등록하면 로그인 인증 시, 두 핸들러를 사용할 수 있다.

package com.codestates.todoapp.config;

import com.codestates.todoapp.auth.filter.JwtAuthenticationFilter;
import com.codestates.todoapp.auth.handler.MemberAuthenticationFailureHandler;
import com.codestates.todoapp.auth.handler.MemberAuthenticationSuccessHandler;
import com.codestates.todoapp.auth.jwt.JwtTokenizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
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.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.Arrays;

@Configuration
@EnableWebSecurity(debug = true)
public class SecurityConfiguration {
    private final JwtTokenizer jwtTokenizer;

    public SecurityConfiguration(JwtTokenizer jwtTokenizer) {
        this.jwtTokenizer = jwtTokenizer;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
        http
                .headers().frameOptions().sameOrigin()
                .and()
                .csrf().disable()
                .cors(Customizer.withDefaults())
                .formLogin().disable()
                .httpBasic().disable()
                .apply(new CustomFilterConfigurer())//커스터마이징된 Configuration 추가
                .and()
                .authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll());
        return http.build();
    }

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

    @Bean
    CorsConfigurationSource corsConfigurationSource(){
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("*"));
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PATCH", "DELETE"));
        UrlBasedCorsConfigurationSource  source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

    //JwtAuthenticationFilter를 등록하는 역할
    public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity>{
        @Override
        public void configure(HttpSecurity builder) throws Exception{
            AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);

            JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer);
            jwtAuthenticationFilter.setFilterProcessesUrl("/auth/login");
            jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler());
            jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler());

            //JwtAuthenticationFilter를 Spring Security Filter Chain에 추가한다.
            builder.addFilter(jwtAuthenticationFilter);
        }
    }

}

 

 

4️⃣ AuthenticationSuccessHandler의 onAuthenticationSuccess() 메서드는 로그인 인증에 성공하고 호출되어야 하므로, JwtAuthenticationFilter의 successfulAuthentication 메서드에 추가되어야 한다.

package com.codestates.todoapp.auth.filter;

import com.codestates.todoapp.auth.dto.LoginDto;
import com.codestates.todoapp.auth.jwt.JwtTokenizer;
import com.codestates.todoapp.member.entity.Member;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

//클라이언트의 로그인 인증 요청을 처리하는 엔트리포인트 역할.
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    //로그인 인증 정보를 전달받아 UserDetailsService와 인터랙션 한 뒤 인증 여부 판단
    private final AuthenticationManager authenticationManager;

    //클라이언트가 인증에 성공할 경우, JWT를 생성 및 발급하는 역할
    private final JwtTokenizer jwtTokenizer;

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtTokenizer jwtTokenizer) {
        this.authenticationManager = authenticationManager;
        this.jwtTokenizer = jwtTokenizer;
    }

    //메서드 내부에서 인증을 시도하는 로직.
    @SneakyThrows
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response){
        //클라이언트에서 전송한 Username, Password를 DTO 클래스로 역직렬화하기 위해 ObjectMapper 인스턴스 생성
        ObjectMapper objectMapper = new ObjectMapper();

        //ServletInputStream을 LoginDto 클래스의 객체로 역직렬화
        LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class);

        //UsernamePasswordAuthenticationToken을 생성
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());

        //UsernamePasswordAuthenticationToken을 AuthenticationManager에게 전달하면서 인증 처리 위임
        return authenticationManager.authenticate(authenticationToken);
    }

    //클라이언트의 인증 정보를 이용해 인증에 성공할 경우 호출된다.
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authReesult) throws ServletException, IOException {
        //AuthenticationManger 내부에서 인증에 성공하면 인증된 Authentication 객체가 생성되면서 principal 필드에 Member 객체 할당
        Member member = (Member) authReesult.getPrincipal();

        String accessToken = delegateAccessToken(member);
        String refreshToken = delegateRefreshToken(member);

        //response header에 Access Token 추가. 클라이언트 측에서 백엔드 애플리케이션 측에 요청을 보낼 때마다 request header에 추가해서 클라이언트 측의 자격을 증명하는 데 사용된다.
        response.setHeader("Authorization", "Bearer " + accessToken);
        //repsonse header에 Refresh Token 추가. Access Token이 만료될 경우, 클라이언트 측이 Access Token을 새로 발급받기 위해 클라이언트에게 추가적으로 제공될 수 있음.
        response.setHeader("Refresh", refreshToken);

        this.getSuccessHandler().onAuthenticationSuccess(request, response, authReesult);

    }


    private String delegateAccessToken(Member member) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", member.getEmail());
        claims.put("roles", member.getRoles());

        String subject = member.getEmail();
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());

        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);

        return accessToken;
    }

    private String delegateRefreshToken(Member member) {
        String subject = member.getEmail();
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);

        return refreshToken;
    }
}

 

 

 

이렇게 까지 작성하면 로그인 성공 시에는 다음과 같은 repsonse body를 확인할 수 있다.

로그인 실패 시는 아래와 같다.

 

 

 

✅ 이제 로그인 인증 후, response로 전달받은 JWT를 request header에 포함하여 request를 전송할 때마다 서버 측에서 request header에 포함된 JWT를 검증하는 기능만 구현하면 JWT를 이용한 인증 및 자격 검증 기능이 완성된다.😃