Spring Security 구현과 JWT 토큰 로그인

본 내용은 최주호님의 스프링부트 시큐리티 & JWT 강의를 듣고 정리한 내용 입니다. 주석의 내용까지 차근차근 살펴 읽어 보시면 크게 어렵지 않으실 겁니다.

기본형태

제일 기본적인 형태를 보려면 Config쪽에 WebMvcConfig 만 남기고 주석처리, controller 에는 아래 / 경로만 설정해준다.

@GetMapping({ "", "/" })
public @ResponseBody String index() {
   return "인덱스 페이지입니다.";
}

시큐리티 의존성을 설치 해주면 처음엔 기본적으로 http://localhost:8080/login으로 이동했을때 로그인 페이지가 생긴다. application.yml 에 아래처럼 시큐리티 설정을 해주면 해당 계정으로 로그인이 가능하다.

spring:
  security:
    user:
      name: manager
      password: 1234

로그인을 하면 / 경로로 이동한다.

로그인 및 권한설정

/ 경로를 로그인한 유저만 접근가능하고, /admin 경로는 어드민만, /manager 경로는 매니저만 접근 가능하도록 권한설정을 해주는 방법이다.

config 아래 SecurityConfig 를 생성하여 시큐리티 설정을 해준다. 원래는 /login 으로 이동하면 스프링 시큐리티가 낚아 챘는데 이 설정을 해주면 작동안한다.

@Configuration // IoC 빈(bean)을 등록
@EnableWebSecurity // 필터 체인 관리 시작 어노테이션, 
public class SecurityConfig extends WebSecurityConfigurerAdapter{
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.authorizeRequests()
                .antMatchers("/user/**").authenticated() //이주소로 들어오면 인증이 필요하다.
                .antMatchers("/manager/**").access("hasRole('ROLE_ADMIN') or hasRole('ROLE_MANAGER')") //이 권한이 있는 사람만 접근가능
                .antMatchers("/admin/**").access("hasRole('ROLE_ADMIN')") //이 권한이 있는 사람만 접근가능
                .anyRequest().permitAll() //저 주소가 아니면 다 권한 허용
                .and()
                .formLogin()
                .loginPage("/login")
                .loginProcessingUrl("/loginProc") //로그인 주소가 호출이 되면 시큐리티가 낚아채서 로그인을 진행해준다.
                .defaultSuccessUrl("/"); //로그인 성공시 메인페이지로 이동
    }
}
  • @EnableWebSecurity : 스프링 시큐리티 필터가 스프링 필터 체인에 등록이 됨
  • 위와 같은 방식으로 특정 경로에 권한 설정을 해줄수있다. loginPage() 설정으로 인해 권한없는 페이지로 이동시 에러페이지가 뜨는게 아니라 로그인 경로로 이동됨
  • loginProcessingUrl로 인해서 시큐리티가 로그인을 대신 진행해주어 컨트롤러에 따로 만들 필요가 없다.

패스워드

패스워드를 꼭 암호화 해주어야 하기 때문에 SecurityConfig에 아래와 같은 코드도 설정해준다.

@Bean
public BCryptPasswordEncoder encodePwd() {
   return new BCryptPasswordEncoder();
}

컨트롤러에서 회원가입시에 아래처럼 암호화해주면 된다.

@PostMapping("/joinProc")
public String joinProc(User user) {
   System.out.println("회원가입 진행 : " + user);
   String rawPassword = user.getPassword();
   String encPassword = bCryptPasswordEncoder.encode(rawPassword);
   user.setPassword(encPassword);
   user.setRole("ROLE_USER");
   userRepository.save(user);
   return "redirect:/";
}

UserDetails

  • 시큐리티가 낚아채서 로그인을 진행 시킬때 로그인이 완료가 되면 시큐리티 session을 만들어낸다. 시큐리티의 세션이 존재함. (Security ContextHolder)
  • 세션에 들어갈수있는 오브젝트 => Authenticaion 타입 객체로 정해져 있다.
  • Authenticaion 안에 User 정보가 있어야 됨
  • User 오브젝트 타입 => UserDetails 타입 객체로 정혀져 있다.

UserDetails 를 구현하는 PrincipalDetails 가 필요하다.

@Data
public class PrincipalDetails implements UserDetails{

	private User user;

	public PrincipalDetails(User user) {
		super();
		this.user = user;
	}
	
	@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;
	}

	//해당 유저의 권한을 리턴하는 곳
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		Collection<GrantedAuthority> collet = new ArrayList<GrantedAuthority>();
		collet.add(()->{ return user.getRole();});
		return collet;
	}
	
}

getAuthorities

getAuthorities 는 해당 유저의 권한을 리턴하는데 현재 유저의 권한은 role로 스트링타입이다. 근데 타입이 정해져있으니 Collection<GrantedAuthority> 객체를 생성해주어야한다. (ArrayList는 Collection의 자식)

isEnabled

사이트에서 1년동안 로그인 안하면 휴면 계정으로 변경할때, 컬럼에 login할때마다 날짜를 저장하는 컬럼을 두고 그걸로 현재시간과 로그인 시간의 차이가 1년을 초과했을때 리턴은 false로 하면됨

UserDetailsService

  • 로그인시 스프링은 IoC컨테이너에서 UserDetailsService 빈을 찾아 loadUserByUsername을 호출함
  • username 파라미터를 가져옴(클라이언트에서 넘겨준 이름 그대로)
  • UserDetails 로 리턴된 값은 Authentication 내부로 들어감. 그리고 그 객체는 또 세션으로 들어가니 다음과 같은 모양새가 됨. 시큐리티 session(내부 Authentication(내부 UserDetails)
// 시큐리티 설정에서 loginProcessingUrl 걸어놨기 때문에 로그인 요청이 오면 자동으로
// UserDetailsService 타입으로 IoC되어있는 loadUserByUsername 함수가 실행됨 (규칙임)
@Service
public class PrincipalDetailsService implements UserDetailsService{

   @Autowired
   private UserRepository userRepository;

   //알아서 다해줌 
   @Override
   public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
      User user = userRepository.findByUsername(username);
      if(user == null) {
         return null;
      }
      return new PrincipalDetails(user);
   }

}

특정주소에 직접 권한걸기

SecurityConfig 에 아래와같은 어노테이션을 걸어주면 특정어노테이션이 활성화가 된다.

securedEnabled는 @Secured 를 활성화 하고

prePostEnabled는 @PreAuthorize 와 @PostAuthorize 를 활성화 시킨다

@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) // 특정 주소 접근시 권한 및 인증을 위한 어노테이션 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter{

그리고 아래와 같이 사용한다.

//권한 하나만 걸고싶으면 secured
@Secured("ROLE_ADMIN")
@GetMapping("/info")
public @ResponseBody String info() {
   return "개인정보";
}

//권한 여러개를 걸고싶으면 preAuthorize
@PreAuthorize("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')") //data메서드 실행되기 전 실행
@PostAuthorize("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')") //data메서드 실행후에 실행됨, 잘 안씀
@GetMapping("/info")
public @ResponseBody String data() {
   return "데이터 정보";
}

websecurityconfigureradapter deprecated 문제

이 문제로 인해 SecurityConfig를 다르게 변경해주어야한다.

@Configuration
@EnableWebSecurity // 시큐리티 활성화 -> 기본 스프링 필터체인에 등록
public class SecurityConfig {

   @Autowired
   private UserRepository userRepository;

   @Autowired
   private CorsConfig corsConfig;

   @Bean
   SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
      return http
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .formLogin().disable()
            .httpBasic().disable() //기본인증방식 - 보안에 안좋음
            .apply(new MyCustomDsl()) // 커스텀 필터 등록
            .and()
            .authorizeRequests(authroize -> authroize.antMatchers("/api/v1/user/**")
                  .access("hasRole('ROLE_USER') or hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
                  .antMatchers("/api/v1/manager/**")
                  .access("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
                  .antMatchers("/api/v1/admin/**")
                  .access("hasRole('ROLE_ADMIN')")
                  .anyRequest().permitAll())
            .build();
   }

   // corsConfig : @CrossOrigin 같은 어노테이션을 걸어주는건 인증이 없을때, 인증이 있을땐 필터에 아래처럼 등록해줘야한다.
   public class MyCustomDsl extends AbstractHttpConfigurer<MyCustomDsl, HttpSecurity> {
      @Override
      public void configure(HttpSecurity http) throws Exception {
         AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
         http
               .addFilter(corsConfig.corsFilter())
               .addFilter(new JwtAuthenticationFilter(authenticationManager))
               .addFilter(new JwtAuthorizationFilter(authenticationManager, userRepository));
      }
   }

}

filter

기본적인 필터구현에 관한 내용이다.

아래처럼 필터를 구현하면 doFilter 를 오버라이드한다.

필터에 걸려서 프로그램이 끝나지 않도록 filterChain.doFilter 로 리퀘스트와 리스펀스를 넘겨주어야한다.

import javax.servlet.*;
import java.io.IOException;

public class myfilter implements Filter {

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) 
            throws IOException, ServletException {
        System.out.println("filter");
        filterChain.doFilter(servletRequest,servletResponse);
    }
}

해당 필터를 시큐리티에도 아래와 같은 방식으로 등록할 수도 있지만

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
   return http
         .addFilterBefore(new myfilter(), BasicAuthenticationFilter.class)
         //필터 등록하였음, BasicAuthenticationFilter보다 먼저 동작하도록 설정한것임

따로 클래스를 만들어서 필터를 관리할 수도 있다. 아래처럼 필터의 순서를 정하여 여러개로 등록이 가능하다.

@Configuration
public class FilterConfig {

    @Bean
    public FilterRegistrationBean<myfilter> filter(){
        FilterRegistrationBean<myfilter> bean = new FilterRegistrationBean<>(new myfilter());
        bean.addUrlPatterns("/*");
        bean.setOrder(0); //낮은 번호가 필터중에서 가장먼저 실행됨
        return bean;
    }
}

다만, FilterConfig는 시큐리티에 건 필터보다 이후에 실행된다.

원래 일반적인 웹환경에서 브라우저가 서버에게 요청을 보내면 DispatcherServlet이 요청을 받기 이전에 서블릿 필터를 거치게 된다. 스프링 시큐리티도 서블릿 필터로 작동하여 인증,권한처리를 진행한다.

시큐리티와 관련한 서블릿 필터는 실제로 연결된 여러 필터들로 구성되어있어 이런 모습때문에 체인이라는 표현을 쓴다. 해당 필터의 역할과 흐름을 알아야 필터를 커스터 마이징 가능하다.

SecurityFilterChain 의 구조

JWT 토큰으로 로그인

  • 로그인시 원래는 localhost:8080/login 을 호출하면 스프링 시큐리티가 알아서 UserDetailsService 빈을 찾아 loadUserByUsername을 호출하는데 filterChain 에서 formLogin을 disable하고 커스텀 필터를 등록해서 로그인을 할것이다.

SecurityConfig.java

security 기본에 적어놓은 config와 같다.

@Configuration
@EnableWebSecurity // 시큐리티 활성화 -> 기본 스프링 필터체인에 등록
public class SecurityConfig {

   @Autowired
   private UserRepository userRepository;

   @Autowired
   private CorsConfig corsConfig;

   @Bean
   SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
      return http
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .formLogin().disable()
            .httpBasic().disable()
            .apply(new MyCustomDsl()) // 커스텀 필터 등록
            .and()
            .authorizeRequests(authroize -> authroize.antMatchers("/api/v1/user/**")
                  .access("hasRole('ROLE_USER') or hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
                  .antMatchers("/api/v1/manager/**")
                  .access("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
                  .antMatchers("/api/v1/admin/**")
                  .access("hasRole('ROLE_ADMIN')")
                  .anyRequest().permitAll())
            .build();
   }

   public class MyCustomDsl extends AbstractHttpConfigurer<MyCustomDsl, HttpSecurity> {
      @Override
      public void configure(HttpSecurity http) throws Exception {
         AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
         http
               .addFilter(corsConfig.corsFilter())
               .addFilter(new JwtAuthenticationFilter(authenticationManager))
               .addFilter(new JwtAuthorizationFilter(authenticationManager, userRepository));
      }
   }

}

JwtAutenticationFilter.java

  • 로그인시 실행되는 필터를 상속받은 필터를 만들어줘서 SecurityConfig 에 등록해줘야함
  • UsernamePasswordAuthenticationFilter는 AuthenticationManager가 매개변수로 필요함. 위의 SecurityConfig.java의 필터를 등록하는 부분을 보면 AuthenticationManager 객체를 생성해서 넘겨주는걸 볼 수 있음
  • PrincipalDetailsService 와 PrincipalDetails는 security 기본에 작성되어 있음, 생성해줘야함

//스프링 시큐리티에서 UsernamePasswordAuthenticationFilter 가 있음
// /login 요청해서 username,password 전송하면 이 필터가 동작을 함, 근데 formlogin을 disable해놔서 지금 자동으로 작동안함
//그래서 필터를 상속받아 만들어주고 securityconfig에 등록시켜 주는것임
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter{

   private final AuthenticationManager authenticationManager;
   
   // Authentication 객체 만들어서 리턴 => 의존 : AuthenticationManager
   // 인증 요청시에 실행되는 함수 => /login (로그인시도시 실행됨)
   @Override
   public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
         throws AuthenticationException {
      
      System.out.println("JwtAuthenticationFilter : 진입");
      /*
      * 1. username,password 받아서
      * 2. 정상인지 로그인 시도를 함. authenticationManager로 로그인 시도를 하면 PrincipalDetailsService가 호출됨
      * 3. loadUserByUsername 함수 실행됨
      * 4. PrincipalDetail를 세션에 담고 -> 세션에 안담아주면 권한 관리가 안됨
      * 5. JWT토큰을 만들어서 응답
      */
      // request에 있는 username과 password를 파싱해서 자바 Object로 받기
      ObjectMapper om = new ObjectMapper();
      LoginRequestDto loginRequestDto = null;
      try {
         loginRequestDto = om.readValue(request.getInputStream(), LoginRequestDto.class);
      } catch (Exception e) {
         e.printStackTrace();
      }
      
      System.out.println("JwtAuthenticationFilter : "+loginRequestDto);
      
      // 유저네임패스워드 토큰 생성
      UsernamePasswordAuthenticationToken authenticationToken = 
            new UsernamePasswordAuthenticationToken(
                  loginRequestDto.getUsername(), 
                  loginRequestDto.getPassword());
      
      System.out.println("JwtAuthenticationFilter : 토큰생성완료");
      
      // authenticate() 함수가 호출 되면 인증 프로바이더가 유저 디테일 서비스의
      // loadUserByUsername(토큰의 첫번째 파라메터) 를 호출하고
      // UserDetails를 리턴받아서 토큰의 두번째 파라메터(credential)과
      // UserDetails(DB값)의 getPassword()함수로 비교해서 동일하면
      // Authentication 객체를 만들어서 필터체인으로 리턴해준다.
      
      // Tip: 인증 프로바이더의 디폴트 서비스는 UserDetailsService 타입
      // Tip: 인증 프로바이더의 디폴트 암호화 방식은 BCryptPasswordEncoder
      // 결론은 인증 프로바이더에게 알려줄 필요가 없음.
      Authentication authentication = 
            authenticationManager.authenticate(authenticationToken);
      
      PrincipalDetails principalDetailis = (PrincipalDetails) authentication.getPrincipal();
      System.out.println("Authentication : "+principalDetailis.getUser().getUsername());
      //authentication객체가 session영역에 저장을 해야하고 그 방법이 return 해주면됨
      //그 이유는 권한 관리를 시큐리티가 대신 해주기 때문에 편하려고 하는거임
      //굳이 JWT 토큰을 사용하면서 세션을 만들 이유가 없지만 단지 권한처리 때문에 세션에 넣어주는것
      return authentication;
   }

   //위의 함수가 실행이 종료되면(인증이 정상으로 되면) 이어서 successfulAuthentication가 실행됨
   //JWT토큰을 만들어서 requset요청한 사용자에게 JWT토큰을 reponse해줌
   // JWT Token 생성해서 response에 담아주기
   @Override
   protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
         Authentication authResult) throws IOException, ServletException {
      
      PrincipalDetails principalDetailis = (PrincipalDetails) authResult.getPrincipal();
      
      //RSA방식은 아니고 Hash암호 방식
      String jwtToken = JWT.create()
            .withSubject(principalDetailis.getUsername()) //크게 중요하지 않음
            .withExpiresAt(new Date(System.currentTimeMillis()+JwtProperties.EXPIRATION_TIME)) //언제까지 유효할지 만료시간
            .withClaim("id", principalDetailis.getUser().getId())
            .withClaim("username", principalDetailis.getUser().getUsername())
            .sign(Algorithm.HMAC512(JwtProperties.SECRET));
      
      response.addHeader(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX+jwtToken);
   }
   
}

JwtProperties.java

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