본문 바로가기

개인적으로 공부한 것을 정리해 놓은 블로그입니다 틀린 것이 있으면 댓글 부탁 드립니다!


Spring Security

스프링 시큐리티 공부 19 - JWT 을 이용한 로그인

반응형

세션을 이용한 로그인에서는 로그인 성공시 사용자에게 세션을 부여하여 해당 세션이 살아있는 동안 로그인이 유지된 상태로 

사이트 리소스에 접근할 수 있게 하듯이  

 

 JWT를 사용할때도 같은 역할을 해줄 수 있어야한다 .  JWT는 로그인시 발급 받은 토큰으로 매번 요청에 대해서 토큰의 claim을 확인하여 리소스 접근여부를 결정한다 (Authentication을 발급해준다.)

 

JWT를 이용해서 로그인을 시키고 로그인을 유지시키는 방법을 알아보자 

 

먼저 로그인을 시키고 토큰을 발급해주는 것은 

POST /login 요청으로 들어오는 데이터(username , password)를 통해 인증 하는 UsernamePasswordFilter를 상속받아서 필요한 로직을 추가하는 방식으로 구현해 보려한다 .

 

JWT를 생성,인증 해줄 메서드들을 갖는 Util클래스를 만들어보자 

 

JWTUtil(JWT 생성, 인증 해주는 유틸클래스)

 

//JWT 발행 해주는 Util class
public class JWTUtil {

    private static final Algorithm ALGORITHM =Algorithm.HMAC256("ugogo");
    private static final long AUTH_TIME = 20 * 60 ;
    private static final long REFRESH_TIME = 60 * 60 *24 * 7 ;

    public static String createAuthToken(SpUser user){
        return JWT.create()
                .withSubject(user.getUsername())
                .withClaim("exp", Instant.now().getEpochSecond() + AUTH_TIME)
                .sign(ALGORITHM);
    }

    public static String createRefreshToken(SpUser user){
        return JWT.create()
                .withSubject(user.getUsername())
                .withClaim("exp", Instant.now().getEpochSecond() + REFRESH_TIME)
                .sign(ALGORITHM);
    }

    public static VerifyResult verify(String token){
        try {
            //인증 성공시
            DecodedJWT result = JWT.require(ALGORITHM).build().verify(token);
            return VerifyResult.builder()
                    .success(true)
                    .username(result.getSubject())
                    .build();
        }catch (Exception e){
            //인증 실패시
            DecodedJWT decode = JWT.decode(token);
            return VerifyResult.builder()
                    .success(false)
                    .username(decode.getSubject())
                    .build();
        }
    }
}

 

위의 클래스는 auth0 JWT 라이브러리를 통해 토큰을 만들고 인증 하고 있고  토큰 생성,인증시 필요한 공통적인 값을 상수로 저장하고 있다 . 

 

 

 

 

 

JWTLoginFilter  (JWT기반의 커스텀 로그인 필터)

 

//UsernamePassword필터의 기반으로 한다.
//UsernamePassword를 체크하고 인증이 성공하면 JWT 토큰을 넘겨준다
public class JWTLoginFilter extends UsernamePasswordAuthenticationFilter {

    private ObjectMapper objectMapper = new ObjectMapper();

    public JWTLoginFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
        //longin post 요청을 처리
        setFilterProcessesUrl("/login");
    }

    @SneakyThrows
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        //리퀘스트에서 인풋스트림을 읽어 objectmapper를 통해 UserLoginForm으로 변환한다.
        UserLoginForm userLoginForm = objectMapper.readValue(request.getInputStream(), UserLoginForm.class);

        //받은 값으로 로그인을 할 수 있게 토큰을 발행한다 .
        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
                userLoginForm.getUsername(),userLoginForm.getPassword()
        );

        // AuthenticationManager 가 DaoAuthenticationProvider를 통해
        // UserService로 유저를 검증하고  성공시 해당 유저를 리턴해준다.
        return getAuthenticationManager().authenticate(token);
    }

    //인증 성공시 아래 메서드로 들어온다
    @Override
    protected void successfulAuthentication(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain,
            Authentication authResult
    ) throws IOException, ServletException {
        //성공시 위에서 리턴한 유저가 여기 담긴다
        SpUser user = (SpUser) authResult.getPrincipal();

        //토큰을 심어준다.
        response.setHeader(HttpHeaders.AUTHORIZATION,"Bearer " + JWTUtil.createAuthToken(user));
        //response 헤더에 컨텐츠타입을 json으로 지정한다.
        response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
        //response outputstream에 user를 써서 내려준다 .
        response.getOutputStream().write(objectMapper.writeValueAsBytes(user));
    }
}

 

 

위의 클래스를 위에서부터  쪼개서 살펴보자

 

public class JWTLoginFilter extends UsernamePasswordAuthenticationFilter {

    private ObjectMapper objectMapper = new ObjectMapper();

    public JWTLoginFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
        //longin post 요청을 처리
        setFilterProcessesUrl("/login");
    }

 

UsernamePasswordAuthenticationFilter 를 상속받아 사용하고 있다 .  ObjectMapper는 json 형식으로 들어오는 폼 요청 정보를 읽어 dto로 변환하기 위해 선언해 두었다 .  

 생성자에서는 AuthenticationManager를  부모생성자로 넘기고  ,로그인 프로세스 url 을 지정해 뒀다 /login요청이 들어오면 여기로 이 필터로 들어올 것이다 ( 부모 클래스인 UsernamePasswordAuthenticationFilter에 모두 이미 정의되 있지만 명시적으로 정의해둔 것이니 없어도 무관함)

 

 

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {


        //리퀘스트에서 인풋스트림을 읽어 objectmapper를 통해 UserLoginForm으로 변환한다.
        UserLoginForm userLoginForm = null;
        try {
            userLoginForm = objectMapper.readValue(request.getInputStream(), UserLoginForm.class);
        } catch (IOException e) {
            e.printStackTrace();
        }

        //받은 값으로 로그인을 할 수 있게 토큰을 발행한다 .
        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
                userLoginForm.getUsername(),userLoginForm.getPassword()
        );
         // AuthenticationManager 가 DaoAuthenticationProvider를 통해
        // UserService로 유저를 검증하고  성공시 해당 유저를 리턴해준다.
        return getAuthenticationManager().authenticate(token);
    }

 

 

인증을 시도하는 메서드인 attemptAuthentication을 오버라이딩 했다   UserLoginForm은 username 과 password를 담는 dto 이다  스트림을 가져오는 부분에서 IOException이 발생할 수 있기떄문에 try catch로 감싸 줬다 . 

리퀘스트에서 인풋스트림을 가져와 오브젝트 mapper로 읽어서 UserLoginForm으로 변환 시켜주고 있다 .  

 

다음으론 빈 토큰을 만들어서 폼전송으로 들어온 username 과 password를 넣어준다 . 다음으로  authenticationManager에게 넘겨 인증을 하게한다 .  authenticationManager는 해당토큰을 처리해 줄 수 있는 provider 를 찾고 provider가 인증을 처리하여 인증이 성공한다면 토큰의 isAuthenticated  값을 true로 바꿔 리턴해 준다 . 

 

 

    //인증 성공시 아래 메서드로 들어온다
    @Override
    protected void successfulAuthentication(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain,
            Authentication authResult
    ) throws IOException, ServletException {
        //성공시 위에서 리턴한 유저가 여기 담긴다
        SpUser user = (SpUser) authResult.getPrincipal();

        //토큰을 심어준다.
        response.setHeader(HttpHeaders.AUTHORIZATION,"Bearer " + JWTUtil.createAuthToken(user));
        //response 헤더에 컨텐츠타입을 json으로 지정한다.
        response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
        //response outputstream에 user를 써서 내려준다 .
        response.getOutputStream().write(objectMapper.writeValueAsBytes(user));
    }

 

 

인증이 성공하면 successfulAuthentication()이 호출되고 인증의 결과가 파라미터로 넘어온다 .  파라미터로 들어온 authResult가 

위의 attemptAuthentication()에서 리턴한 Authentication 이다   해당 Authentication에서 principal 을 꺼내고 principal 정보를 통해 Bearer 토큰을 생성해서  헤더에 Authorization에  심어준다  앞에 Bearer를 적어주는 것이 규약이니 꼭 적어줘야한다.  

확인을 위해 응답 바디에 user를 넘겨주었다 . 토큰을 생성할때는 유저를 식별할 수 있는 최소한의 정보만 넣어주는 것이 좋다.

 

 

 

 

 

 

JWT를 사용할 경우 로그인시  토큰을 넘겨주면 프런트에서는 해당 토큰을 브라우저 쿠키 , 로컬 스트로리지 같은 곳에  저장하고 리퀘스트 마다 헤더에 해당 토큰을 담아서 요청한다고 한다. 서버에서는 매번 요청에 대해서 토큰을 열어보고 확인한 후에 리소스로 접근시킨다면

세션에서 로그인 상태가 유지되어 로그인된 상태로 사이트 리소스에 접근하는 것 처럼  동작이 가능하다.

 

 

먼저 로그인 후에 브라우저에 토큰이 부여된 상태로 가정하고 서버에서는 어떻게 처리해야 될지 알아보자 

 

모든 요청에 대해서 토큰을 확인하는 BasicAuthenticationFilter를 상속받아서   기능을 구현해 봤다 .

 

//리퀘스트가 올때 토큰을 검사해서
//시큐리티 컨텍스트 홀더에 Authentication을 올려주는 역할을 한다.
//BasicAuthenticationFilter 은 모든 요청에서 토큰을 감시한다.
public class JWTCheckFilter extends BasicAuthenticationFilter {

    private final UserService userService;

    public JWTCheckFilter(AuthenticationManager authenticationManager, UserService userService) {
        super(authenticationManager);
        this.userService = userService;
    }

    //토큰에 대한 검사를 하는 메서드
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        String bearer = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (bearer == null || !bearer.startsWith("Bearer ")){
            //만약 비어러 토큰이 없다면 요청을 흘려보내서 다음 필터 혹은 인터셉터에서 인증을 받게한다.
            chain.doFilter(request,response);
            return;
        }

        //토큰이 있다면 Bearer를 때고
        String token = bearer.substring("Bearer ".length());
        //만들어 놓은 JWTUtil을 통해 토큰을 검증한다 .
        VerifyResult result = JWTUtil.verify(token);

        //만약 검증에 성공한다면
        if (result.isSuccess()){
            //토큰에서 username을 꺼내고 userService로 찾아본다.
            SpUser user = (SpUser) userService.loadUserByUsername(result.getUsername());
            //null 처리 필요
            //새로운 토큰을 만들어서
            UsernamePasswordAuthenticationToken verifiedToken = new UsernamePasswordAuthenticationToken(
                    user.getUsername(),null,user.getAuthorities()
            );
            //시큐리티 컨텍스트 홀더에 올려놓으면 요청을 수행할 수 있다 .
            SecurityContextHolder.getContext().setAuthentication(verifiedToken);
            //요청을 다음으로 넘긴다.
            chain.doFilter(request,response);
        }else {
            //401 error 발생
            throw new AuthenticationException("Token is not valid");
        }
    }
}

 

JWTCheckFilter는 BasicAuthenticationFilter을 상속 받아서 doFilterInternal 메서드를  JWT를 검증받아 Authentication을 발행해주는 방식으로 오버라이딩 했다. 

 위에서 부터 살펴보면 생성자로 authenticationManager 와 userService 를 받고 있고

dofilterInternal에서는 리퀘스트에서 토큰을 꺼내와 "Bearer " 문자열을 제거한후에 해당 토큰을 JWT 라이브러리를 통해 검증한다 . 

VerifyResult는 토큰에 대한 검증 결과와 유저를 식별할 수 있는 최소한의 값을 갖는 dto이다 필드로 검증후에  토큰이 유효한지 여부를 

담는 boolean success  가 있는데 

 위에서는 토큰 검증후에 result에서 sucess가 true 라면 Authentication을 발행해서 시큐리티 컨텍스트 홀더에 등록시켜 리소스에 접근할 수 있도록 해주고 있다 . 만약 토큰이 유효하지 않다면 isSuccess는 false 가 되고 AuthenticationException이 발생한다. 

 

SecurityConfig 파일에서 직접 구현한 필터들을  꽂아주자 

 

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class JWTSecurityConfig extends WebSecurityConfigurerAdapter {

    private final UserService userService;

    @Bean
    public void initDB(){
        userService.save(SpUser.builder()
                .email("user1")
                .password("1111")
                .enabled(true)
                .build());
    }

    @Bean
    PasswordEncoder passwordEncoder(){
        return NoOpPasswordEncoder.getInstance();
    }


    @Override
    protected void configure(HttpSecurity http) throws Exception {


        JWTLoginFilter loginFilter = new JWTLoginFilter(authenticationManager());
        JWTCheckFilter checkFilter = new JWTCheckFilter(authenticationManager(),userService);

        //Test에서 요청이 날라올때 필터체인으로 들어와서
        //아래 설정한 필터들을 하나씩 거치고
        http
                .csrf().disable()
                .sessionManagement(session->{
                    //세션을 쓰지 않도록 한다.
                    session.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
                })
                //로그인 처리
                //("/login"(POST)에 대한 요청이 여기서 걸린다.)
                .addFilterAt(loginFilter, UsernamePasswordAuthenticationFilter.class)
                //토큰을 검증
                .addFilterAt(checkFilter, BasicAuthenticationFilter.class);

    }
}

 

username = usesr1 , password = 1111 로 유저가 한명 등록 시켰다.

 

테스트이기 때문에 편의를 위해 NoOpPasswordEncoder 를 지정했다 . 

 

configure(HttpSecurity http) 메서드안에서 직접구현한 필터를 생성해 줬다 . 

 

(현재 상황이 모바일 환경이라는 가정하에 csrf().disable() 시켰다 . csrf를 활성화시키려면 프런트에서 csrf토큰에 대한 부분을 해결해야한다.   자바스크립트 공부할때 자세히 알아봐야겠다.)

 

sessionManagement() 에서는 session에 관한 설정들을 할 수 있는데 JWT 는 세션을 사용하지않는 STATELESS한 인증 방식이니 세션의 정책을 STATELESS로 설정한다 . 

 

addFilterAt() 으로  상속받았던 필터들 자리에 직접 구현한 필터를 꽂아주고 있다 . 

 

 

 

 

API 테스터로 로그인을 시도해보자 

 

 

요청

 

 

 

 

응답

 

 

응답 헤더에 토큰이 잘 담겼고 ,  바디에 유저 정보가 리턴되었다 .

 

발급 받은 토큰 헤더에 담아  아래의 컨트롤러에 접근해보자 

 

@RestController
public class HomeController {


    @PreAuthorize("isAuthenticated()")
    @GetMapping("/greeting")
    public String greeting(){
        return "hello";
    }


}

 

 

요청

 

 

 

응답

 

 

컨트롤러에 접근이 잘되었다 . 

 

 

 

위의 방식만으로는 로그인 및 로그인 유지에서 발생할 수 있는 문제들을 대응하기 어렵기 때문에 추가적인 방법들을 알아봐야겠다 .  

 

 

반응형