@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class AdvancedSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private SpUserService spUserService;
@Bean
PasswordEncoder passwordEncoder(){
return NoOpPasswordEncoder.getInstance();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// LoginFilter의 경우 UserAuthenticationManager가 있기에 매니저에게 검증을 위임하면 되지만
// CheckFilter는 사용자를 직접 가져올 상황이 생김
JWTLoginFilter loginFilter = new JWTLoginFilter(authenticationManager());
JWTCheckFilter checkFilter = new JWTCheckFilter(authenticationManager(), spUserService);
http
.csrf().disable() // 토큰을 쓸려면 csrf disable 하는 것이 좋음(비용이 많이듬)
.sessionManagement(session ->{
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
})
.addFilterAt(loginFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAt(checkFilter, BasicAuthenticationFilter.class);
}
}
public class JWTCheckFilter extends BasicAuthenticationFilter {
private SpUserService spUserService;
// 토큰을 검사해서 security context holder에 user principal 정보를 채워 주는 역할
public JWTCheckFilter(AuthenticationManager authenticationManager, SpUserService spUserService) {
super(authenticationManager);
this.spUserService = spUserService;
}
// 이를 통해 토큰을 통한 검사
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String bearer = request.getHeader(HttpHeaders.AUTHORIZATION);
if(bearer == null || !bearer.startsWith("Bearer ")){
chain.doFilter(request, response); // 그냥 흘려보내줘야 함(ex 인증이 필요하지 않은 경우)
return;
}
String token = bearer.substring("Bearer ". length());
VerifyResult result = JWTUtil.verifyResult(token);
if(result.isSuccess()){
SpUser user = (SpUser)spUserService.loadUserByUsername(result.getUsername());
UsernamePasswordAuthenticationToken userToken = new UsernamePasswordAuthenticationToken(
user.getUsername(), null, user.getAuthorities()
);
SecurityContextHolder.getContext().setAuthentication(userToken);
chain.doFilter(request, response);
} else{
throw new AuthenticationException("Token is not valid");
}
}
}
public class JWTLoginFilter extends UsernamePasswordAuthenticationFilter {
// 유효한 사용자에게 인증 필터를 내려주는 필터
private ObjectMapper objectMapper = new ObjectMapper();
public JWTLoginFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
setFilterProcessesUrl("/login"); // POST LOGIN 처리
}
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException
{
UserLoginForm userLogin = objectMapper.readValue(request.getInputStream(), UserLoginForm.class);
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
userLogin.getUsername(), userLogin.getPassword(), null
);
// user details 처리 부분 ...
return getAuthenticationManager().authenticate(token);
}
@Override
protected void successfulAuthentication(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) throws IOException, ServletException
{
SpUser user = (SpUser) authResult.getPrincipal();
// login이 끝날려면 토큰을 발행해 줘야 함
response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + JWTUtil.makeAuthToken(user));
response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
response.getOutputStream().write(objectMapper.writeValueAsBytes(user));
}
}
public class JWTUtil {
private static final Algorithm ALGORITHM = Algorithm.HMAC256("wookjong");
private static final long AUTH_TIME = 20 * 60; // 20분
private static final long REFRESH_TIME = 60 * 60 * 24 * 7; // 1주일
public static String makeAuthToken(SpUser user){
return JWT.create()
.withSubject(user.getUsername())
// ExpiresAt으로도 가능
.withClaim("exp", Instant.now().getEpochSecond() + AUTH_TIME)
.sign(ALGORITHM);
}
public static String makeRefreshToken(SpUser user){
return JWT.create()
.withSubject(user.getUsername())
.withClaim("exp", Instant.now().getEpochSecond() + REFRESH_TIME)
.sign(ALGORITHM);
}
public static VerifyResult verifyResult(String token){
try{
DecodedJWT verify = JWT.require(ALGORITHM).build().verify(token);
return VerifyResult.builder().success(true)
.username(verify.getSubject()).build();
}catch(Exception e){
DecodedJWT decode = JWT.decode(token);
return VerifyResult.builder().success(false)
.username(decode.getSubject()).build();
}
}
}
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserLoginForm {
private String username;
private String password;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class VerifyResult {
private boolean success; // verify success
private String username;
}
@RestController
public class HomeController {
@PreAuthorize("isAuthenticated()") // 로그인을 했을 때 리퀘스트 보낼수 있음
@GetMapping("/greeting")
public String greeting(){
return "hello";
}
}
public class JWTRequestTest extends WebIntegrationTest {
@Autowired
private SpUserRepository userRepository;
@Autowired
private SpUserService userService;
@BeforeEach
void before(){
userRepository.deleteAll();
SpUser user = userService.save(SpUser.builder()
.email("user1")
.password("1111")
.enabled(true)
.build());
userService.addAuthority(user.getUserId(), "ROLE_USER");
}
@DisplayName("1. hello 메세지 받아오기 ")
@Test
void test_1(){
RestTemplate client = new RestTemplate();
HttpEntity<UserLoginForm> body = new HttpEntity<>(
UserLoginForm.builder().username("user1").password("1111").build()
);
ResponseEntity<SpUser> resp1 = client.exchange(uri("/login"), HttpMethod.POST, body, SpUser.class);
System.out.println(resp1.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0));
System.out.println(resp1.getBody());
// greeting 메세지 받아오기
HttpHeaders header = new HttpHeaders();
// Bearer + Authentication Token이 와있음
header.add(HttpHeaders.AUTHORIZATION, resp1.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0));
body = new HttpEntity<>(null, header);
ResponseEntity<String> resp2 = client.exchange(uri("/greeting"), HttpMethod.GET, body, String.class);
assertEquals("hello", resp2.getBody());
}
}
public class JWTLoginFilter extends UsernamePasswordAuthenticationFilter {
// 유효한 사용자에게 인증 필터를 내려주는 필터
private ObjectMapper objectMapper = new ObjectMapper();
private SpUserService userService;
public JWTLoginFilter(AuthenticationManager authenticationManager, SpUserService userService) {
super(authenticationManager);
this.userService = userService;
setFilterProcessesUrl("/login"); // POST LOGIN 처리
}
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException
{
UserLoginForm userLogin = objectMapper.readValue(request.getInputStream(), UserLoginForm.class);
if(userLogin.getRefreshToken() == null) {
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
userLogin.getUsername(), userLogin.getPassword(), null
);
// user details 처리 부분 ...
return getAuthenticationManager().authenticate(token);
} else{
// refreshtoken이 내려온 경우
VerifyResult verify = JWTUtil.verifyResult(userLogin.getRefreshToken());
if(verify.isSuccess()){
SpUser user = (SpUser) userService.loadUserByUsername(verify.getUsername());
return new UsernamePasswordAuthenticationToken(
user, user.getAuthorities() // user를 principal로
);
}else{
throw new TokenExpiredException("refresh token expired");
}
}
}
@Override
protected void successfulAuthentication(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) throws IOException, ServletException
{
SpUser user = (SpUser) authResult.getPrincipal();
// Bearer 토큰은 사실 서버에서 필요한 것이고, response에 꼭 담아서 갈 필요는 없다
// response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + JWTUtil.makeAuthToken(user));
response.setHeader("auth_token", JWTUtil.makeAuthToken(user));
// 다시 들어오면 refresh token 이 들어온 시점부터 다시 일주일이 늘어나는 설정..
response.setHeader("refresh_token", JWTUtil.makeRefreshToken(user));
response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
response.getOutputStream().write(objectMapper.writeValueAsBytes(user));
}
}
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class TokenBox {
private String authToken;
private String refreshToken;
}
public class JWTRequestTest extends WebIntegrationTest {
@Autowired
private SpUserRepository userRepository;
@Autowired
private SpUserService userService;
@BeforeEach
void before(){
userRepository.deleteAll();
SpUser user = userService.save(SpUser.builder()
.email("user1")
.password("1111")
.enabled(true)
.build());
userService.addAuthority(user.getUserId(), "ROLE_USER");
}
private TokenBox getToken(){
RestTemplate client = new RestTemplate();
HttpEntity<UserLoginForm> body = new HttpEntity<>(
UserLoginForm.builder().username("user1").password("1111").build()
);
ResponseEntity<SpUser> resp1 = client.exchange(uri("/login"), HttpMethod.POST, body, SpUser.class);
return TokenBox.builder().authToken(resp1.getHeaders().get("auth_token").get(0))
.refreshToken(resp1.getHeaders().get("refresh_token").get(0))
.build();
}
private TokenBox refreshToken(String refreshToken){
RestTemplate client = new RestTemplate();
HttpEntity<UserLoginForm> body = new HttpEntity<>(
UserLoginForm.builder().refreshToken(refreshToken).build()
);
ResponseEntity<SpUser> resp1 = client.exchange(uri("/login"), HttpMethod.POST, body, SpUser.class);
return TokenBox.builder().authToken(resp1.getHeaders().get("auth_token").get(0))
.refreshToken(resp1.getHeaders().get("refresh_token").get(0))
.build();
}
@DisplayName("1. hello 메세지 받아오기 ")
@Test
void test_1(){
TokenBox token = getToken();
RestTemplate client = new RestTemplate();
// greeting 메세지 받아오기
HttpHeaders header = new HttpHeaders();
// Bearer + Authentication Token이 와있음
header.add(HttpHeaders.AUTHORIZATION, "Bearer" + token.getAuthToken());
HttpEntity body = new HttpEntity<>(null, header);
ResponseEntity<String> resp2 = client.exchange(uri("/greeting"), HttpMethod.GET, body, String.class);
assertEquals("hello", resp2.getBody());
}
@DisplayName("2. 토큰 만료 테스트")
@Test
void test_2() throws InterruptedException {
// Auth_time을 2초로 지정하였음
TokenBox token = getToken();
Thread.sleep(3000); // 이렇게 만료된다면 갱신 받아서 사용해야 함
HttpHeaders header = new HttpHeaders();
header.add(HttpHeaders.AUTHORIZATION, "Bearer" + token.getAuthToken());
RestTemplate client = new RestTemplate();
assertThrows(Exception.class, () -> {
HttpEntity body = new HttpEntity<>(null, header);
ResponseEntity<String> resp2 = client.exchange(uri("/greeting"), HttpMethod.GET, body, String.class);
});
token = refreshToken(token.getRefreshToken());
HttpHeaders header2 = new HttpHeaders();
header2.add(HttpHeaders.AUTHORIZATION, "Bearer " + token.getAuthToken());
HttpEntity body = new HttpEntity<>(null, header2);
ResponseEntity<String> resp3 = client.exchange(uri("/greeting"), HttpMethod.GET, body, String.class);
assertEquals("hello", resp3.getBody());
}
refresh token을 브라우저 환경에 내려주면 사용자는 자동 로그인이 되기떄문에 접속시 편리
-> 그러나 보안에 취약
위 코드의 경우는 refresh_token이 탈취된다면 이 토큰으로 접근하려는 사용자를 막을 수 없다
-> 서버에서 보안이 문제가 된다면 갱신된 토큰에 대한것을 추적하고, 만약 예외적인 경우에 사용된다고 판단된다면 refresh_token을 무력화 할수 있는 방법을 구현해야 함
-> ex) Db에 시리즈 형식으로 저장