[작성일: 2023. 09. 21]
JWT
JWT는 JSON Web Token이라고 불린다. JWT는 당사자간에 정보를 JSON 객체로 안전하게 전송하기 위한 컴팩트하고 독립적인 방식을 정의하는 개방형 표준이다. 이 정보는 디지털 서명이 되어 있으므로 확인하고 신뢰할 수 있다. JWT는 비밀(HMAC 알고리즘 사용) 또는 RSA를 사용하는 공개/개인 키 쌍을 사용하여 서명할 수 있다.
JWT를 암호화해서 당사자간에 비밀을 제공할 수도 있지만 서명 된 토큰에 중점을 둔다. 서명된 토큰은 그 안에 포함 된 클레임(요구사항)의 무결성을 확인할 수 있는 반면 암호화 된 토큰은 다른 당사자로부터 여러한 클레임을 숨긴다.
JSON 웹 토큰 구조
xxxx.yyyy.zzzz
- Header
- Payload
- Signature
헤더는 일반적으로 토큰유형(JWT)과 사용중인 서명 알고리즘(예: HMAC SHA256 또는 RSA)의 두 부분으로 구성된다.
예를 들면,
{
"alg": "HS256",
"typ": "JWT"
}
그런 다음 JSON은 Base64Url(암호화/복호화)로 인코딩되어 있어 JWT의 첫 번째 부분을 형성한다.
토큰의 두 번째 부분은 클레임을 포함하는 페이로드이다.
- 등록된 클레임: 상호 운용 가능한 클레임을 제공하기 위해 필수는 아니지만 권장되는 미리 정의된 클레임 집합이다. 등록된 클레임은 토큰에 대한 정보를 담기 위해 이름이 정해져있는 클레임들이다. 그 중 일부는 iss(발행자), exp(만료 시간), sub(주제), aud(청중), nbf, iat(토큰이 발급된 시간), jti(JWT의 고유 식별자) 및 기타이다.
- 공개 소유권 주장: JWT를 사용하는 사람들이 원하는대로 정의할 수 있다.
- 개인 클레임: 사용에 동의하고 등록 또는 공개 클레임이 아닌 당사자간에 정보를 공유하기 위해 생성 된 사용자 지정 클레임이다.(userId, username 등)
예를 들면,
{
"sub": "123456789",
"name": "kim",
"admin": true // user id=1
}
페이로드는 Base64Url로 인코딩되어 JSON 웹 토큰의 두 번째 부분을 형성한다.
서명 부분을 만들려면 인코딩 된 header, 인코딩 된 payload, secret, 헤더에 지정된 알고리즘을 가져와서 서명해야 한다.
예를 들어 HMAC SHA256 알고리즘을 사용하려는 경우 서명은 다음과 같은 방식으로 생성된다.
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret) // secret은 서버만 알고 있는 키
서명은 메시지가 변경되지 않았음을 확인하는데 사용되며 개인 키로 서명된 토큰의 경우 JWT의 발신자가 자신이 말하는 사람인지 확인할 수 있다.
JWT 프로젝트 세팅
먼저 security 때처럼 프로젝트를 생성한 후 build.gradle에 jwt 관련 라이브러리를 따로 추가한다.
implementation group: 'com.auth0', name: 'java-jwt', version: '3.10.2'
User 엔티티를 생성해보고 DB에 유저 테이블이 생성되는지까지 확인한다.
@Entity
@Data
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password;
private String roles; // USER, ADMIN
public List<String> getRoleList() {
if (this.roles.length() > 0) {
return Arrays.asList(this.roles.split(","));
}
return new ArrayList<>();
}
}
getRoleList() 메서드는 roles 필드에 저장된 문자열을 , 기준으로 분리하여 List<String> 형태로 변환하는 메서드이다.
예를들어 roles에 "USER, ADMIN"이 저장되어 있다면 ["USER", "ADMIN"]과 같은 리스트를 반환한다.
roles가 빈 문자열이면 빈 리스트를 반환한다.
그 다음은 SecurityConfig 파일을 작성하자. Security에서 작성했던 config 파일과 거의 유사하다.
그리고 CorsConfig 파일도 작성한다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CorsFilter corsFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable();
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 사용하지 않음
.and()
.addFilter(corsFilter) // @CrossOrigin(인증X), 시큐리티 필터에 등록 인증(O)
.formLogin().disable()
.httpBasic().disable()
.authorizeHttpRequests()
.requestMatchers("/api/v1/user/**")
.hasAnyRole("USER", "MANAGER", "ADMIN")
.requestMatchers("/api/v1/manger/**")
.hasAnyRole("MANAGER", "ADMIN")
.requestMatchers("/api/v1/admin/**")
.hasRole("ADMIN")
.anyRequest().permitAll();
return http.build();
}
}
- http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
- 세션 생성 정책을 STATELESS로 설정해서 세션을 사용하지 않겠다는 의미이다.
- addFilter(corsFilter)
- CorsFilter를 보안 필터 체인에 추가한다.(클래스 따로 생성)
- http.Basic().disable()
- 기본 HTTP 인증을 비활성화한다.
- Authorization에 토큰을 넣는 방식을 사용할 것이기 때문에 비활성화 한 것이다.
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true); // 내 서버가 응답을 할 때 json을 자바스크립트에서 처리할 수 있게 할지를 설정함.
config.addAllowedOrigin("*"); // 모든 ip에 응답을 허용함.
config.addAllowedHeader("*"); // 모든 header에 응답을 허용함.
config.addAllowedMethod("*"); // 모든 메서드 요청을 허용함.
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}
CorsFilter 빈을 생성하는 메서드로 이 필터는 웹 요청에 대한 CORS 설정을 처리할 것이다.
UrlBasedCorsConfigurationSource 객체를 생성하는데, 이 객체는 URL 기반의 CORS 구성을 제공한다.
CORS 설정을 정의하는 CorsConfiguration 객체도 생성한다.
- config.addAllowedOrigin("*") : 모든 IP주소로부터의 요청을 허용한다.
- config.addAllowedHeader("*") : 모든 HTTP 헤더를 허용한다.
- config.addAllowedMethod("*") : 모든 HTTP 메서드(GET, POST, PUT, DELETE 등)을 허용한다.
- source.registerCorsConfiguration("/api/**", config);
- /api/** 경로에 대한 CORS 설정을 등록한다. 이 경로로 오는 모든 요청에 대해 위에서 정의한 CORS 설정이 적용되는 것이다.
JWT Filter
filter 클래스와 filterConfig 파일을 작성한다.
public class MyFilter1 implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println("필터1");
chain.doFilter(request, response);
}
}
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<MyFilter1> filter1() {
FilterRegistrationBean<MyFilter1> bean = new FilterRegistrationBean<>(new MyFilter1());
bean.addUrlPatterns("/**");
bean.setOrder(0); // 낮은 번호가 필터 중에서 가장 먼저 실행 됨.
return bean;
}
}
또 다른 필터를 만들고 싶다면 MyFilter2()를 만들고 Bean 등록 시 우선순위는 1로 주면 된다.
이 Bean은 request 요청이 올 때 실행된다.
따로 필터를 만들게 되면 굳이 SecurityConfig에 필터를 걸 필요가 없다.
그럼 이 필터는 SecurityFilter보다 먼저 실행될까, 나중에 실행될까?
MyFilter3를 만들고 SecurityConfig 파일에 addFilterBefore 메서드를 추가해보았다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CorsFilter corsFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.addFilterBefore(new MyFilter3(), BasicAuthenticationFilter.class);
// ...코드 생략
}
}
SecurityConfig에 추가했던 필터가 먼저 실행되는 것을 확인할 수 있다.
결론적으로 Security Filter가 먼저 실행되고 나머지 filter들이 실행된다.
참고: Before가 아닌 addFilterAfter 메서드여도 필터3이 먼저 실행된다.
JWT Token 테스트
MyFilter에 코드를 작성해서 임시 토큰을 발급받는 테스트를 해보자.
public class MyFilter1 implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
String headerAuth = req.getHeader("Authorization");
System.out.println("headerAuth = " + headerAuth);
System.out.println("필터1");
chain.doFilter(req, res);
}
}
지금은 /token에 가도 headerAuth는 null이 출력된다.
이제는 POST 요청일 때만 실행되도록 코드를 수정한다.
if (req.getMethod().equals("POST")) {
System.out.println("POST 요청");
String headerAuth = req.getHeader("Authorization");
System.out.println("headerAuth = " + headerAuth);
}
이제 Postman에서 POST 요청을 해보자.
이제는 토큰을 만들었다고 가정하고 토큰이 넘어오면 인증이 되게 하고, 그게 아니면 더이상 filter를 못 하게 해서 컨트롤러 진입조차 하지 못 하게 해볼 것이다.
// 토큰을 만들었다고 가정(cos)
if (req.getMethod().equals("POST")) {
System.out.println("POST 요청");
String headerAuth = req.getHeader("Authorization");
System.out.println("headerAuth = " + headerAuth);
if (headerAuth.equals("cos")) {
chain.doFilter(req, res);
} else {
PrintWriter out = res.getWriter();
out.println("인증이 되지 않았습니다.");
}
}
위와 같이 Header의 Authorization에 cos(임시 토큰)가 아닌 hello라는 토큰이 들어가면 컨트롤러조차 진입하지 못 한다.
이제는 id와 pw가 정상적으로 들어와서 로그인이 완료되면 토큰을 만들어주고, 그 토큰으로 응답을 해줘야 한다.
요청을 할 때마다 Header의 Authorization에 value값으로 토큰을 가지고 온다.
그 때 토큰이 넘어오면 이 토큰이 내가 만든 토큰이 맞는지 검증만 하면 된다.
JWT 로그인
PrincipalDetails, PrincipalDetailsService를 생성한다.
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;
}
}
// 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("PrincipalDetailsService의 loadUserByUsername() = ");
User user = userRepository.findByUsername(username);
return new PrincipalDetails(user);
}
}
우리는 지금 formLogin을 사용하지 않기로 했기 때문에 /login은 동작하지 않는다.
직접 PrincipalDetails를 해주는 필터를 만들어야 하는데 JwtAuthenticationFilter 클래스를 생성한다.
// 스프링 시큐리티에서 UsernamePasswordAuthenticationFilter
// /login 요청 시 username, password POST로 전송하면 필터가 동작됨.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
// 로그인 요청을 하면 로그인 시도를 위해 실행되는 함수
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
System.out.println("JwtAuthenticationFilter : 로그인 시도 중");
return super.attemptAuthentication(request, response);
}
}
스프링 시큐리티에 UsernamePasswordAuthenticationFilter가 있는데, /login으로 요청 시 username, password를 POST로 전송하면 이 필터가 동작된다.
하지만 이 필터가 지금 동작하지 않는 이유는 config에서 formLogin을 disable을 했기 때문이다.
이 필터를 작동시키려면 이 필터를 다시 SecurityConfig에 등록해줘야 한다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CorsFilter corsFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// http.addFilterBefore(new MyFilter3(), BasicAuthenticationFilter.class);
http.csrf().disable();
http.apply(new MyCustomDsl());
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 사용하지 않음
.and()
.addFilter(corsFilter) // @CrossOrigin(인증X), 시큐리티 필터에 등록 인증(O)
.formLogin().disable()
.httpBasic().disable()
.authorizeHttpRequests()
.requestMatchers("/api/v1/user/**")
.hasAnyRole("USER", "MANAGER", "ADMIN")
.requestMatchers("/api/v1/manger/**")
.hasAnyRole("MANAGER", "ADMIN")
.requestMatchers("/api/v1/admin/**")
.hasRole("ADMIN")
.anyRequest().permitAll();
return http.build();
}
public class MyCustomDsl extends AbstractHttpConfigurer<MyCustomDsl, HttpSecurity> {
@Override
public void configure(HttpSecurity http) throws Exception {
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
http
.addFilter(new JwtAuthenticationFilter(authenticationManager));
}
}
}
그럼 이제 username과 password를 받아서 정상인지 로그인 시도를 해본다.
500오류는 괜찮고, 로그인 시도중이라는 문구가 콘솔창에 뜨면 된다.
authenticationManager로 로그인 시도를 하면 PrincipalDetailsService가 호출되고, loadByUsername이 자동으로 실행된다.
그럼 이제 PrincipalDetails를 세션에 담고(권한 관리를 위해)
JWT 토큰을 만들어서 응답해주면 된다.
🐣 출처: 인프런 최주호님 강의
이 글은 인프런의 최주호님 SpringBoot Security & JWT 강의를 보고 작성한 글입니다.
강의를 들으면서 정리한 글이므로 틀린 내용이나 오타가 있을 수 있습니다.