https://github.com/tlatldms/boot-security-jwt-redis
@Secured annotation으로 간편하게 Role별로 설정하려면 Security Config file에 옵션을 추가해주어야 한다.
@EnableGlobalMethodSecurity(ecuredEnabled = true) 어노테이션이 포인트.
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) //
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtRequestFilter jwtRequestFilter;
@Autowired
private UserDetailsService jwtUserDetailsService;
@Autowired
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Override
public void configure(WebSecurity web) throws Exception
{
web.ignoring().antMatchers("/**", "/css/**", "/script/**", "image/**", "/fonts/**", "lib/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception
{
http.
csrf().disable().
cors().and().
authorizeRequests().
antMatchers("/**").permitAll().
anyRequest().authenticated()
.and().exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint).and().sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
// Add a filter to validate the tokens with every request
http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
}
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
// configure AuthenticationManager so that it knows from where to load
// user for matching credentials
// Use BCryptPasswordEncoder
auth.userDetailsService(jwtUserDetailsService).passwordEncoder(passwordEncoder());
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
Controller에서는 @Secured("ROLE_ADMIN") 지정하면 ADMIN만 접근가능하다.
@Secured("ROLE_ADMIN")
@GetMapping(path="/hello")
public String hello() {
return "hello~";
}
하지만 secured로 하나하나 설정하기보다는 라우팅으로 관리하는게 훨씬 편할 것 같다. 여러 가지 시도 후 드디어 다 적용되는 방법을 발견했는데 깔끔하게 and로 분기해주니까 적용이 된다.. 그리고 jwt 토큰이 필요없는 경우 filter 자체를 안먹이고 싶어서 interceptor라는걸 찾아봤는데 일단은 /newuser에 들어오는 요청에 대해서는 permitall 해주는걸로 합의를 봤다. 나중에 interceptor도 공부해보기로
@Override
protected void configure(HttpSecurity http) throws Exception
{
http.
httpBasic().disable().
cors().and().
csrf().disable().
sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).
and().
authorizeRequests().
antMatchers("/newuser/**").permitAll().
and().
authorizeRequests().
antMatchers("/admin/**").hasRole("ADMIN").
and().
authorizeRequests().
antMatchers("/user/**").hasRole("USER").
and().
authorizeRequests().
anyRequest().
authenticated().
and().
exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint).
and().
addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
}
repository를 통한 삭제 요청에 No EntityManager with actual transaction available for current thread... Error 만났을 때 해당 method 위에 @Transactional annotation을 붙이니 잘 동작했다. 정확한 이유는 더 알아보기
refresh의 로직은 크게 client가 expired 응답을 받고 refresh 해주는 컨트롤 메서드 url로 refresh url을 보냈다고 가정했다.
5번의 경우 나는 아직 username밖에 담고있지 않기 때문에 잘 변하지 않는 정보라서 DB에 접근하지 않고 expired access token의 정보로 만들어도 상관은 없다. 하지만 보통 다른 정보들도 있다고 가정했을 때 갱신해주는게 맞는 것 같아서 User DB에 직접 접근했다.
근데 정말 예외처리할게 많다. 이 메소드만큼은 처음부터 끝까지 스스로 짜서 뿌듯하다ㅠ 먼저 Jwts parser를 사용해 JWT를 파싱하는 함수에
private Claims getAllClaimsFromToken(String token) {
return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
}
token 자체를 잘못 넘기면 IllegalArgumentException, 파싱은 했는데 만료가 되면 ExpiredJwtException 를 반환한다. 이 에러를 직접 처리해줘야 하는데, ExpiredJwtException 객체에서
try {
username = jwtTokenUtil.getUsernameFromToken(accessToken);
} catch (IllegalArgumentException e) {
} catch (ExpiredJwtException e) { //expire됐을 때
username = e.getClaims().getSubject();
logger.info("username from expired access token: " + username);
}
ExpiredJwtException.getClaims()로 만료된 토큰의 payload를 가져올 수 있다. 원래 공개 된 정보니까. 직접 파싱하려고 했는데 이미 메소드가 있었다. 에러에서 직접 어떤 값을 꺼내보는건 처음인데 앞으로 항상 생각하고 있어야겠다.
—전체 코드—
@PostMapping(path="/newuser/refresh")
public Map<String, Object> requestForNewAccessToken(@RequestBody Map<String, String> m) {
String accessToken = null;
String refreshToken = null;
String refreshTokenFromDb = null;
String username = null;
Map<String, Object> map = new HashMap<>();
try {
accessToken = m.get("accessToken");
refreshToken = m.get("refreshToken");
logger.info("access token in rnat: " + accessToken);
try {
username = jwtTokenUtil.getUsernameFromToken(accessToken);
} catch (IllegalArgumentException e) {
} catch (ExpiredJwtException e) { //expire됐을 때
username = e.getClaims().getSubject();
logger.info("username from expired access token: " + username);
}
if (refreshToken != null) { //refresh를 같이 보냈으면.
try {
ValueOperations<String, Object> vop = redisTemplate.opsForValue();
Token result = (Token) vop.get(username);
refreshTokenFromDb = result.getRefreshToken();
logger.info("rtfrom db: " + refreshTokenFromDb);
} catch (IllegalArgumentException e) {
logger.warn("illegal argument!!");
}
//둘이 일치하고 만료도 안됐으면 재발급 해주기.
if (refreshToken.equals(refreshTokenFromDb) && !jwtTokenUtil.isTokenExpired(refreshToken)) {
final UserDetails userDetails = userDetailsService.loadUserByUsername(username);
String newtok = jwtTokenUtil.generateAccessToken(userDetails);
map.put("success", true);
map.put("accessToken", newtok);
} else {
map.put("success", false);
map.put("msg", "refresh token is expired.");
}
} else { //refresh token이 없으면
map.put("success", false);
map.put("msg", "your refresh token does not exist.");
}
} catch (Exception e) {
throw e;
}
logger.info("m: " + m);
return map;
}
Refresh에 성공한 모습. 집에가서 할 일: 401 에러 말고 좀 더 이쁘게 처리하기