본문 바로가기
스프링 & Jpa/📌Spring Security 강좌

인프런 시큐리티 강좌 #3 - jwt

by IMSfromSeoul 2022. 1. 22.

📌 JWT 동작 원리

▸ MyFilter3.java

public class MyFilter3 implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse res = (HttpServletResponse) response;
        if(req.getMethod().equals("POST")){
            String headerAuth = req.getHeader("Authorization");
            if(headerAuth.equals("token")){
                chain.doFilter(req,res);
            }else{
                System.out.println("인증안됨");
            }
        }
    }
}

Authorization 이라는 이름을 가진 Key에 알맞은 token을 보냈을 때만 chain.doFilter() 를 실행하게 하고, 아닐 때는 예외를 날리든가, 사용자를 막든가 한다.

📌 Principal 생성

▸ PrincipalDetails.java

package com.jwtpractice.auth;

import com.jwtpractice.model.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;

public class PrincipalDetails implements UserDetails {
    private User user;

    public PrincipalDetails(User user) {
        this.user = user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        user.getRoleList().forEach(r->{
            authorities.add(()->r);
        });
        return authorities;
    }

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

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

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

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

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

    @Override
    public boolean isEnabled() {
        return true;
    }
}
  • UserDetails 상속받은 다음 implements
    • getPassword() 랑, getUsername() user걸로 설정해주고, 나머지 값은 다 false에서 true로 변경

▸ PrincipalDetailsService.java

package com.jwtpractice.auth;

import com.jwtpractice.model.User;
import com.jwtpractice.model.UserRepository;
import lombok.RequiredArgsConstructor;
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.Service;

// http://localhost:8080/login
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
    private final UserRepository userRepository;
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        System.out.println("principal detail");
        User user = userRepository.findByUsername(username);
        return new PrincipalDetails(user);
    }
}
  • 원래 /login 에 대한 출력을 여기서 하는데, 현재 securityConfig에서 formLogin()을 disable() 했기 때문에 동작을 하지 않는다.
    • SpringSecurity에 UsernamePasswordAuthenticationFilter가 있다.
    • login 요청해서 username과 password를 post로 요청하면 해당 필터가 동작한다.
    • (만약 formLogin()이 disable()이 아니라면)

▸ JwtAuthenticationFilter.java

package com.jwtpractice.jwt;

import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
}

▸ SecurityConfig.java

    protected void configure(HttpSecurity http) throws Exception {
        http.addFilterBefore(new MyFilter3(),BasicAuthenticationFilter.class);
        http.csrf().disable();
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .addFilter(corsFilter)
                .formLogin().disable()
                .httpBasic().disable()
                .addFilter(new JwtAuthenticationFilter(authenticationManager())) // <--- 이 부분 추가
                ...
}

로그인을 진행하는 Filter는 authenticationManager() 객체를 이용해서 로그인을 진행한다.

login 요청을 하면 로그인 시도를 위해서 실행되는 함수를 override해주어야 한다.

해당 함수는 attempAuthentication()이다.

▸ JwtAuthenticationFilter.java

package com.jwtpractice.jwt;

import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final AuthenticationManager authenticationManager;

    // login 요청을 하면 로그인 시도를 위해서 실행되는 함수
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        System.out.println("JwtAuthenticationFilter");
        return super.attemptAuthentication(request, response);
    }
}

위의 attempAuthentication() 에서 할 일은 다음과 같다.

  • 1. request로부터 username, password를 받아온다.
  • 2. 정상인지 로그인 시도를 해본다.
    • authenticationManager로 로그인 시도를 해보면, PrincipalDetailService가 호출된다.
    • PrincipalDetailService가 호출되는 이유는 UserDetailService를 상속받고 있기 때문이다.
      • UserDetailService로 부터 상속받은 loadUserByUsername() 메서드가 실행된다. 
  • 3. PrincipalDetails를 세션에 담는다.
    • 세션에 담는 이유는 권한 체크를 하기 위함이다.
    • SecurityConfig에 있는 권한 체크를 이용하려면 해당 PrincipalDetails를 세션에 담아야 한다.
  • 4. JWT 토큰을 만들어서 반환해준다.

📌 회원 가입

▸ RestApiController.java

@RestController
@RequiredArgsConstructor
public class RestApiController {
    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    private final UserRepository userRepository;

    @GetMapping("/home")
    public String home(){
        return "<h1>home</h1>";
    }

    @PostMapping("token")
    public String token(){
        return "<h1>token</h1>";
    }

    @PostMapping("/join")
    public String join(@RequestBody User user){
        user.setPassword(bCryptPasswordEncoder.encode(user.getPassword()));
        user.setRoles("ROLE_USER");
        userRepository.save(user);
        return "회원 가입 완료";
    }
}

▸ @SpringBootApplication 위치에 Bcrypt 생성자 추가

@SpringBootApplication
public class JwtpracticeApplication {
	@Bean
	public BCryptPasswordEncoder passwordEncoder(){
		return new BCryptPasswordEncoder();
	};
	public static void main(String[] args) {
		SpringApplication.run(JwtpracticeApplication.class, args);
	}

}

📌 로그인

▸ JwtAuthenticationFilter.java

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final AuthenticationManager authenticationManager;
    // login 요청을 하면 로그인 시도를 위해서 실행되는 함수
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        System.out.println("JwtAuthenticationFilter");
        try{
            BufferedReader br = request.getReader();
            String input = null;
            while((input = br.readLine()) !=null) System.out.println(input);
        }catch (IOException e){
            e.printStackTrace();
        }
        return super.attemptAuthentication(request, response);
    }
}

로그인을 해서 한번 request값을 찍어보자.

json 으로 요청시

▸ json 파싱하기

import com.fasterxml.jackson.databind.ObjectMapper;

...

ObjectMapper om = new ObjectMapper();
User user = om.readValue(request.getInputStream(), User.class);
System.out.println(user);

▸ JwtAuthenticationFilter.java

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final AuthenticationManager authenticationManager;
    // login 요청을 하면 로그인 시도를 위해서 실행되는 함수
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        System.out.println("JwtAuthenticationFilter");
        try{
            ObjectMapper om = new ObjectMapper();
            User user = om.readValue(request.getInputStream(), User.class);
            System.out.println(user);

            UsernamePasswordAuthenticationToken authenticationToken =
                    new UsernamePasswordAuthenticationToken(user.getUsername(),user.getPassword());

            Authentication authentication =
                    authenticationManager.authenticate(authenticationToken);

            PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
            System.out.println(principalDetails.getUser().getUsername());

            return authentication;
        }catch (IOException e){
            e.printStackTrace();
        }
        return null;
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        System.out.println("JwtAuthenticationFilter.successfulAuthentication");
        super.successfulAuthentication(request, response, chain, authResult);
    }
}
  • ObjectMapper로 json 데이터를 parsing 할 수 있다.
  • 토큰을 만들어서 manager에게 넘겨준다.
  • authenticationManger.authenticate() 가 실행되면 principalDetailServiceloadUserByUsername() 이 실행된다.
  • username은 가져와서 비교하고, password는 security가 내부 로직으로 돌려서 확인한다.
    • 인증이 성공하면 authentication에서 principal을 가져올 수 있다.
    • 인증이 성공하지 못하면 다음 단계로 나아가지 못한다.
  • attemptAuthentication()이 실행된 이후 성공적으로 로그인이 됐다면, successfulAuthentication() 이 실행된다.
    • 여기 함수에서 jwt를 만들어서 반환해주면 된다.

📌 jwt 토큰 만들어서 응답해주기

@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
    PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();

    String jwtToken = JWT.create()
            .withSubject(principalDetails.getUsername())
            // 기준 1/1000 초 - 1000 = 1초 60000 = 1분
            .withExpiresAt(new Date(System.currentTimeMillis() + JwtProperties.EXPIRATION_TIME))
            .withClaim("id",principalDetails.getUser().getId())
            .withClaim("username",principalDetails.getUser().getUsername())
            .sign(Algorithm.HMAC512(JwtProperties.SECRET));

    response.addHeader("Authorization","Bearer " + jwtToken);
}
  • 코드는 어렵지 않다.
  • authentication 객체를 이용해서 해당 인증된 유저 정보를 가져온다.
  • build.gradle 등록했던 JWT libraray를 이용해 JWT 토큰을 생성한다.
    • implementation group: 'com.auth0', name: 'java-jwt', version: '3.18.2'

📌 JWT token 사용자한테 받고 인증처리

▸ JwtAuthorizationFilter.java

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
    public JwtAuthorizationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
        System.out.println("인증이나 권한이 필요한 주소 요청됨.");
    }
}
  • 해당 필터를 SecurityConfig에 추가해주자.

▸ SecurityConfig.java

    protected void configure(HttpSecurity http) throws Exception {
        http.addFilterBefore(new MyFilter3(),BasicAuthenticationFilter.class); // <-- 이 부분 추가
        http.csrf().disable();
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .addFilter(corsFilter)
                .formLogin().disable()
                .httpBasic().disable()
                .addFilter(new JwtAuthenticationFilter(authenticationManager()))
                .addFilter(new JwtAuthorizationFilter(authenticationManager())) <-- 이 부분 추가
                ...
                }
  • Security Filter중 BasicAuthenticationFilter가 있다.
    • 권한이나 인증이 필요한 특정 주소를 요청하면 무조건 위 필터를 타게 돼있다.
    • 만약에 권한이나 인증이 필요하지 않은 주소라면 해당 필터를 타지 않는다.
  • 인증이 필요한 주소에 대하여 BasicAuthenticationFilter에서 doFilterInternal() 함수가 호출된다.

▸ JwtAuthenticationFilter.java

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
    public JwtAuthorizationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        super.doFilterInternal(request, response, chain);

        String jwtHeader = request.getHeader("Authorization");
        System.out.println("jwtHeader = " + jwtHeader);
    }
}

▸ JwtAuthenticationFilter.java

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final AuthenticationManager authenticationManager;
    // login 요청을 하면 로그인 시도를 위해서 실행되는 함수
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        System.out.println("JwtAuthenticationFilter");
        try{
            ObjectMapper om = new ObjectMapper();
            User user = om.readValue(request.getInputStream(), User.class);
            System.out.println(user);

            UsernamePasswordAuthenticationToken authenticationToken =
                    new UsernamePasswordAuthenticationToken(user.getUsername(),user.getPassword());

            // PrincipalDetailsService 의 loadUserByUsername() 함수가 실행된 후 정상이면 authentication 이 return 된다.
            // DB 에 있는 username 과 password 가 일치하다는 뜻
            Authentication authentication =
                    authenticationManager.authenticate(authenticationToken);

            // authentication 객체가 session 영역에 저장된다. --> 로그인이 됐다는 뜻이다.
            PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
            System.out.println(principalDetails.getUser().getUsername());

            // return 이유 : 권한 관리를 security 가 대신 해주기 때문에 편하려고 하는 것
            // 굳이 JWT 토큰을 사용하면서 세션을 만들 이유는 없다.
            return authentication;
        }catch (IOException e){
            e.printStackTrace();
        }
        return null;
    }

    // attemptAuthentication 실행 후 인증이 정상적으로 됐으면 아래 함수가 실행
    // JWT 토큰을 만들어서 request 요청한 사용자에게 jwt 토큰을 response 해주면 된다.
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();

        String jwtToken = JWT.create()
                .withSubject(principalDetails.getUsername())
                // 기준 1/1000 초 - 1000 = 1초 60000 = 1분
                .withExpiresAt(new Date(System.currentTimeMillis() + JwtProperties.EXPIRATION_TIME))
                .withClaim("id",principalDetails.getUser().getId())
                .withClaim("username",principalDetails.getUser().getUsername())
                .sign(Algorithm.HMAC512(JwtProperties.SECRET));

        response.addHeader("Authorization","Bearer " + jwtToken);
    }
}

▸ JwtProperites.java

public class JwtProperties {
    static String SECRET = "임얼쑤"; // 우리 서버만 알고 있는 비밀값
    static int EXPIRATION_TIME = 864000000; // 10일 (1/1000초)
    static String TOKEN_PREFIX = "Bearer ";
    static String HEADER_STRING = "Authorization";
}

 

▸ JwtAuthorizationFilter.java

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
    private final UserRepository userRepository;
    public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserRepository userRepository) {
        super(authenticationManager);
        this.userRepository =userRepository;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        // super 를 안 지우면 응답이 2번 된다.
        //        super.doFilterInternal(request, response, chain);
        String jwtHeader = request.getHeader("Authorization");

        if(jwtHeader == null || !jwtHeader.startsWith("Bearer")){
            chain.doFilter(request,response);
            return ;
        }

        String jwtToken = request.getHeader("Authorization").replace("Bearer ","");
        String username = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET)).build().verify(jwtToken).getClaim("username").asString();
        if(username == null) {
            chain.doFilter(request,response);
            return ;
        }
        System.out.println(username);
        User userEntity = userRepository.findByUsername(username);
        System.out.println("userEntity : " + userEntity.getUsername());

        PrincipalDetails principalDetails = new PrincipalDetails(userEntity);

        // Jwt 토큰 서명을 통해서 서명이 정상이면 Authentication 객체를 만들어준다.
        Authentication authentication = new UsernamePasswordAuthenticationToken(principalDetails,null,principalDetails.getAuthorities());

        // 강제로 시큐리티 세션에 접근하여 Authentication 객체를 저장한다.
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
}

 

 

 


해당 강좌 github

https://github.com/codingspecialist/Springboot-Security-JWT-Easy

 

 

 

 

 

댓글