SpringBoot - JWT ( JSON WEB TOKEN ) - 코드편
HS512 알고리즘을 사용할 것이기 때문에 secret key는 512bit, 즉 64byte 이상을 사용해야 합니다.
터미널에서 secret key를 base64로 인코딩하여 secret 항목에 채워넣습니다.
$ echo ‘silvernine-tech-spring-boot-jwt-tutorial-secret-silvernine-tech-spring-boot-jwt-tutorial-secret’|base64
application.yml
jwt:
header: Authorization
secret: 4oCYc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXTigJkK
token-validity-in-seconds: 86400
( 저는 그냥 하드 코딩했습니다. 하지만 yml 파일에서 관리하는게 좋습니다. )
Filter방식으로 JWT 토큰 활용하기
1. WebSecurityConfig
- corsFilter는 cors방지용 필터
- JwtAuthenticationFilter : 로그인 성공시 토큰발급해주는 필터
- JwtAuthorizationFilter : 해당 토큰 보안여부 판단해주는 필터
( /joinMember와 /readBoard 이외에는 이 필터가 판단)
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable() // jwt토큰 방식을 쓰기에 필요한 설정
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션을 사용하지 않기에 stateless
.and()
.addFilter(corsFilter) // @CrossOrigin(인증X), 시큐리티 필터에 등록 인증(O)
.formLogin().disable() // 폼로그인 사용X
.httpBasic().disable() // Bearer 방식을 쓸거기 때문
.addFilter(new JwtAuthenticationFilter(authenticationManager())) // AuthenticationManager
.addFilter(new JwtAuthorizationFilter(authenticationManager(),memberMapper))
.authorizeRequests()
.antMatchers("/joinMember","/readBoard").permitAll() //login페이지
.anyRequest().authenticated();
}
}
2. CorsConfig
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true); // 내 서버가 응답할 때 json을 JS에서 처리할 수 있게 설정
config.addAllowedOrigin("*"); // 모든 ip에 응답을 허용
config.addAllowedHeader("*"); // 모든 header에 응답 허용
config.addAllowedMethod("*"); // 모든 post,get,put,delete,patch 요청허용
source.registerCorsConfiguration("/**",config);
return new CorsFilter(source);
}
}
3. JwtAuthenticationFilter
// 스프링 시큐리티에서 UsernamePasswordAuthenticationFilter 가 있음
// /login 요청해서 username, password 전송하면 (post)
// UsernamePasswordAuthenticationFilter 동작함
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
// /login요청을 하면 로그인 시도를 위해서 실행되는 함수
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
// 1. username,password 받아서
try {
// json데이터를 parsing해줌
ObjectMapper om = new ObjectMapper();
MemberVO mem = om.readValue(request.getInputStream(), MemberVO.class);
System.out.println("request요청 값들 :"+mem);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(mem.getM_id(),mem.getM_pw());
// PrincipalDetailsService의 loadUserByUsername() 함수가 실행된 후 정상이면 authentication이 리턴
// DB에 있는 username과 password가 일치한다.
// pw는 스프링이 알아서 처리 해줌
Authentication authentication =
authenticationManager.authenticate(authenticationToken);
// => 로그인이 되었다는 뜻
PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
System.out.println("로그인 완료 여부: "+principalDetails.getUsername());
// authentication 객체가 session영역에 저장을 해야하고 그 방법이 return 해주면 됨
// 리턴의 이유는 권한 관리를 security가 대신 해주기 떄문에 편하려고 하는 거임
// 굳이 JWT토큰을 사용하면서 세션을 만들 이유가 없음. 근데 단지 권한 처리때문에 session넣어줌
return authentication;
} catch (IOException e) {
e.printStackTrace();
System.out.println(111111);
}
// 2. 정상인지 로그인 시도를 해보는 것, authenticationManager로 로그인 시도를 하면!!
// PrincipalDetailsService가 호출 -> loadUserByUsername이 자동으로 실행
// 3. PrincipalDetails를 세션에 담고 ( 권한 관리를 위해서 )
// 4. JWT토큰을 만들어서 응답해주면 됨.
return null;
}
// attemptAuthentication 실행 후 인증이 정상적으로 되었으면 successfulAuthentication 함수 실행
// JWT 토큰을 만들어서 request요청한 사용자에게 JWT토큰을 response해주면 됨
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
System.out.println("successfulAuthentication 실행됨 : 인증완료되었다는 뜻");
PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();
// RSA방식은 아니구 Hash암호방식
String jwtToken = JWT.create()
.withSubject("cos토큰")
.withExpiresAt(new Date(System.currentTimeMillis()+(60000*10000)))
.withClaim("username",principalDetails.getUsername())
.withClaim("m_seq", Integer.toString(principalDetails.getMem().getM_seq()))
.sign(Algorithm.HMAC512("cos"));
response.addHeader("Authorization","Bearer "+jwtToken);
}
}
- token의 규칙은 { "Authorization" : "Bearer 토큰명" } 이다.
( 안지키면 token 판별을 못하더라구요.... )
- attemptAuthentication의 authenticationManager.authenticate가 실행 시 PrincipalDetailService의
loadUserByUsername이 실행되므로 다음 파일로 보겠다.
4. PrincipalDetailsService
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
private final MemberMapper memberMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
System.out.println("loadUserByUsername 로그인요청");
MemberVO user = memberMapper.selectId(username);
return new PrincipalDetails(user);
}
}
- loadUserByUsername의 반환변수가 UserDetails이므로 PrincipalDetails 파일도 첨부
+ PrincipalDetails
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PrincipalDetails implements UserDetails {
private MemberVO mem;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return mem.getM_role();
}
});
return authorities;
}
@Override
public String getPassword() {
return mem.getM_pw();
}
@Override
public String getUsername() {
return mem.getM_id();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
5. JwtAuthorizationFilter
- 토큰 인증해주는 필터
// 시큐리티가 filter가지고 있는 그 필터중에 BasicAuthenticationFilter 라는 것이 있음
// 권한이나 인증이 필요한 특정 주소를 요청했을 때 위 필터를 무조건 타게 되어있음
// 만약에 권한이 인증이 필요한 주소가 아니라면 이 필터를 안탄다.
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
private MemberMapper memberMapper;
public JwtAuthorizationFilter(AuthenticationManager authenticationManager, MemberMapper memberMapper) {
super(authenticationManager);
this.memberMapper = memberMapper;
}
// 인증이나 권한이 필요한 주소요청이 있을 때 해당 필터를 타게 됨
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println("인증이나 권한이 필요한 주소 요청이됨");
String jwtHeader = request.getHeader("Authorization");
System.out.println("jwtHeader : " + jwtHeader);
// header가 있는 확인
if (jwtHeader == null || jwtHeader.trim().isEmpty() || !jwtHeader.startsWith("Bearer")) {
chain.doFilter(request, response);
}else {
// JWT 토큰을 검증을 해서 정상적인 사용자인지 확인
String jwtToken = request.getHeader("Authorization").replace("Bearer ", "");
String username =
JWT.require(Algorithm.HMAC512("cos")).build().verify(jwtToken).getClaim("username").asString();
// 서명이 정상적으로 됨
if (username != null) {
MemberVO mem = memberMapper.selectId(username);
PrincipalDetails principalDetails = new PrincipalDetails(mem);
// Jwt 토큰 서명을 통해서 서명이 정상이면 Authentication 객체를 만들어준다.
Authentication authentication =
new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());
// 강제로 시큐리티의 세션에 접근하여 Authentication 객체를 저장.
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}
}
}
}
+ Controller에서 토큰 복호화해서 쓰기
@PostMapping("/")
public void 토큰복화화(@RequestHeader("Authorization") String jwtToken){
// 토큰 복호화 과정
String token = jwtToken.substring(7);
System.out.println("post에서 받은 토큰 : "+token);
String m_seq =
JWT.require(Algorithm.HMAC512("cos")).build().verify(token).getClaim("m_seq").asString();
String username =
JWT.require(Algorithm.HMAC512("cos")).build().verify(token).getClaim("username").asString();
System.out.println("post user : "+m_seq+ " / " + username);
}
마무리
Spring-Security와 JWT토큰 적용시키기위해 4일동안 공부하고 코드쓰고... 쉽지 않았다...
@Override때문에 기존의 스프링 라이브러리 읽는 것도 연습해야겠고 자동적으로 실행하는 순서때문에 많이 애먹은거같다. 나중에는 편하다고 느껴지겠지만 지금은 이 자동적으로 실행하는거 때문에 많은 시간을 썼다.
정리하자면
1. 로그인과정 + 토큰 발급
WebScurityConfig.java
-> JwtAuthenticationFilter.java( extends UsernamePasswordAuthenticationFilter ) 의
attemptAuthentication ( @Override )에서 로그인을 처리해주는데 해당 함수에서
-> PrincipalDetailsService( implements UserDetailsService )의 loadUserByUsername( @Override )을 실행하고
-> PrincipalDetails( implements UserDetails )를 반환한다.
-> 로그인을 성공하면 attemptAuthentication( @Override ) 실행 후 바로
successfulAuthentication( @Override )을 실행해서 토큰을 발급해준다.
2. 토큰 인증
WebScurityConfig.java
-> WebScurityConfig.java에서 권한이나 인증이 필요한 특정 주소가 요청이 온다면
JwtAuthorizaionFilter( extends BasicAuthenticationFilter )의
doFilterInternal( @Override )이 실행되고 토큰 인증을 하게 된다.
implements ,extends, @Override 의 중요성을 많이 깨닫고 간다...
다음은 JWT를 인터셉터로 한번해보거나 JWT토큰 코드를 좀 더 리펙토링해봐야겠다.
github : https://github.com/PHyeonMIN/boardDemo
( config만 보시면됩니다. config파일과 같은 라인에있는 jwt는 실험용이었습니다..ㅠ )