axios를 사용한 post request에서 header가 자꾸 request body로 들어가는 문제점이 있었다. 형식에 딱 맞춰야 하는 것 같은데 풀어서 쓰니까 계속 안됐다. 그냥 headers 따로, data 따로 변수로 선언해준 다음에 넣어주면 잘된다.
const headers = {
'Content-Type': 'application/json',
'Authorization' : "Bearer "+ cookie.load('access-token')
};
const data = {
accessToken: cookie.load('access-token')
};
axios.post("http://localhost:8080/newuser/out", data, {
headers: headers
})
HttpServletRequest request ~
if (request.getMethod().equals("POST")) {...}
등등 필터에서 파라미터로 받은 HttpServletRequest 객체에서 정보를 꺼내 이것저것 처리할 수 있다.
"안녕하세요, 000님" 헤더를 처리하기 위한 방법을 생각해봤다.
filter에서 header의 토큰을 보고 response header에 username 알려주기
구현하기는 간단한데
response.setHeader("username", username);
보안상 괜찮은건지를 잘 모르겠다
쿠키의 토큰을 직접 파싱해서 쓰기
이것도 쉽긴 한데 만료된 토큰일 수도 있고 서버가 컨펌한적도 없는데 계속 로컬 토큰에서 정보를 가져오는게 찝찝하다.
일단은 프론트에서 페이지가 렌더될 때 미리 POST Body로 토큰을 보내서 응답으로 username을 받아오는 식으로 처리했다. 이 때 access token은 만료됐지만 refresh token이 살아있는 경우에 대해서도 클라이언트에서 알아서 요청하도록 처리했다. refresh token 쓰니까 예외처리가 두배 ^^!
@PostMapping(path="/newuser/check")
public Map<String, Object> checker(@RequestBody Map<String, String> m) {
String username = null;
Map<String, Object> map = new HashMap<>();
try {
username = jwtTokenUtil.getUsernameFromToken(m.get("accessToken"));
} catch (IllegalArgumentException e) {
logger.warn("Unable to get JWT Token");
} catch (ExpiredJwtException e) {}
if (username != null) {
map.put("success", true);
map.put("username", username);
} else {
map.put("success", false);
}
return map;
}
componentDidMount(){
axios.post("http://localhost:8080/newuser/check", data, {
headers: headers
}).then(res => {
console.log(res);
if (res.data.success) {
this.setState({
isNormal:true,
username : res.data.username
});
} else {
this.setState({
isNormal:false
});
console.log("cannot validate access token. trying to get new..");
this.requestAccessToken();
}
}).catch(e => {
this.setState({
isNormal:false
});
console.log(e);
})
}
requestAccessToken = () => {
axios.post("http://localhost:8080/newuser/refresh", {
accessToken: this.state.accessToken,
refreshToken: cookie.load('refresh-token')
}).then(res => {
if (res.data.success) {
//console.log("success to refresh token to: " + res.data.accessToken);
cookie.save('access-token', res.data.accessToken, { path: '/' })
this.setState({
isNormal: true
})
} else {
alert("로그인연장 실패로 로그인이 필요합니다.");
console.log("failed to refresh access token. You need re-login.");
this.setState({
isNormal: false
})
}
}
).catch(e => {
console.log(e);
})
}
한번 손을 떠난 토큰에 대해서는 바꿀 수 없다는게 토큰계의 국룰인 것 같다. 그래서 아직 만료되지 않았지만 유저의 로그아웃으로 인해 더이상 로그인되지 않아야 할 때를(쿠키 삭제와는 또 다르다.) 위해 로그아웃 요청한 토큰을 Blacklist로 캐싱해두기로 했다. 매 요청마다 로그아웃한 토큰인지 확인과정이 추가되는게 걱정되기는 하지만 redis가 엄청나게 빠르다니까 괜찮을 것 같다.
로그아웃한다는건 Refresh token과 access token을 모두 다시 받아야 한다는 소리이므로 access token은 블랙리스트로, refresh token은 redis에 저장해두었던 것을 삭제만 하면 된다.
Redis를 쓸 일은
이 둘인데, 생각해보면 1번은 key를 username으로 두면 되고 2번은 토큰의 존재 여부만 알면 된다(사실 key도 필요없는 것 같다). 그래서 key의 형태가 절대 겹치지 않는다. redis의 database는 인덱스로 접근할 수 있다고 한다. Redis를 RDBMS에서 쓰듯이 Blacklist table, refresh token table처럼 쓰고 싶었지만 select 를 사용해 데이터베이스를 아예 따로 두거나, key 자체를 blacklist와 token으로 두고 hashmap으로 blacklist key 하나, token key 하나로 거대한 object value로 쓸 수 밖에 없는 것 같아서 굳이 나누지 않기로 했다. 0~12개의 인덱스 중에서 0번이 기본 database라고 하며 다른 데이터베이스를 쓰려면 LettuceConnectionFactory.setDatabase(index)
를 사용해 설정해주어야 한다. 근데 또 여러개를 함께 쓰려면 redis 설정 처음부터 나누어야 해서 골이 아프더라.. 나는 그냥 0번 데이터베이스에 key,value 형태로만 사용했다. redisTemplate.opsForValue()
로 set이나 get을 통해 쉽게 접근할 수 있다.
redisTemplate을 사용해 간단하게 제거했다.
try {
if (redisTemplate.opsForValue().get(username) != null) {
//delete refresh token
redisTemplate.delete(username);
}
} catch (IllegalArgumentException e) {
logger.warn("user does not exist");
}
일단 내 access token 만료기간과 같은 10분으로 설정했다. 사실 남은 시간을 계산해서 넣는게 정확하다.
//cache logout token for 10 minutes!
redisTemplate.opsForValue().set(accessToken, true);
redisTemplate.expire(accessToken, 10*6*1000, TimeUnit.MILLISECONDS);
모든 조건을 충족해야 Authentication을 부여할것이기 때문에 그냥 조건 하나로 뺐다.
if (username == null) {
logger.info("token maybe expired: username is null.");
} else if (redisTemplate.opsForValue().get(jwtToken) != null) {
logger.warn("this token already logout!");
} else {
//DB access 대신에 파싱한 정보로 유저 만들기!
Authentication authen = getAuthentication(jwtToken);
//만든 authentication 객체로 매번 인증받기
SecurityContextHolder.getContext().setAuthentication(authen);
response.setHeader("username", username);
}
여기서 또 기본 설정을 고쳐서 쓸 생각을 하니까 막막.. 어차피 handler를 따로 작성해 적용시킨다고 해도 post mapping으로 따로 빼두는 것과 하는일은 큰 차이가 없을 것 같다. 그래서 그냥 기본 설정을 안써보기로 했다.
@PostMapping(path="/newuser/out")
public ResponseEntity<?> logout(@RequestBody Map<String, String> m) {
String username = null;
String accessToken = m.get("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);
}
try {
if (redisTemplate.opsForValue().get(username) != null) {
//delete refresh token
redisTemplate.delete(username);
}
} catch (IllegalArgumentException e) {
logger.warn("user does not exist");
}
//cache logout token for 10 minutes!
logger.info(" logout ing : " + accessToken);
redisTemplate.opsForValue().set(accessToken, true);
redisTemplate.expire(accessToken, 10*6*1000, TimeUnit.MILLISECONDS);
return new ResponseEntity(HttpStatus.OK);
}