티스토리 뷰
회원정보 수정 페이지에 접근하는 모든 요청을 어떤 방식으로 접근하든 원천적으로 봉쇄하고
무조건 비밀번호 입력 페이지로 리다이렉트 되도록 설계해보고 싶었다.
방법이 뭐가 없을까 이리 저리 찾아보던 도중 Interceptor를 이용하여 구현하면 딱 적절할 것같다는 생각이 들어 열심히 구글링해보았으나 결과가 잘 나오지 않아 직접 공식문서와 여러 글들을 읽어가며 구현했다
Interceptor 구현
@Component
public class PasswordInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response, final Object handler) throws Exception {
if (request.getSession().getAttribute("password") == null) {
response.sendRedirect("/me/reconfirm");
}
return true;
}
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final PasswordInterceptor passwordInterceptor;
@Override
public void addInterceptors(final InterceptorRegistry registry) {
registry.addInterceptor(passwordInterceptor)
.addPathPatterns("/me/update")
;
}
인터셉터를 preHandle로 구현하고 등록하면 해당 URI에 한해서는 어떤 경우에도 Redirect 되게 된다.
하지만 여기서
1.어떻게 Redirect 지옥을 탈출할 것인가
2.비밀번호를 Session에 저장하는 행위는 매우 바람직하지 못함
등의 문제가 발생했다.
Redirect 무한 루프를 탈출하는 방법은 preHandle에서와 같이 flag를 세우고(위에서는 password를 flag로 사용함) 그 flag의 유무에 따라서 탈출을 하도록 하여 해결하였지만
비밀번호를 Session에 저장하는 문제가 발생한다.
BCrypt의 단방향 패스워드 인코더를 사용하여 넘기게 되면 이후에 UsernamePasswordAuthenticationToken를 새로 발급할 수가 없는 문제가 발생했다.
Salt가 RandomGenerator에 의해 생성되므로 평문의 패스워드를 얻을 수가 없고 비밀번호의 일치 여부만 확인이 가능했다.
직접 Salt를 생성하여 properties에 관리하고 그것을 이용하여 양방향 패스워드 인코더를 사용하는 방법도 생각해보았지만 좀 더 근본적인 해결책을 찾아보고 싶었다.
비밀번호를 Session으로 넘겨야 하는 이유
@PostMapping("/privacy/update")
public ResponseEntity<ErrorResponse> updateMe(@Valid @RequestBody UserUpdateRequest request, BindingResult bindingResult,
@CurrentUser User user, HttpServletRequest servletRequest) {
updateValidator.validate(request, bindingResult);
if (bindingResult.hasErrors()) {
...생략..
}
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Authentication newAuthentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(authentication.getPrincipal(), servletRequest.getSession().getAttribute("password")));;
SecurityContextHolder.getContext().setAuthentication(newAuthentication);
return ResponseEntity.ok().body(null);
개인정보를 수정한 이후에 User DB의 내용은 변경되지만 현재 접속한 User의 Session은 변경되지 않고 그대로 남아있는 문제가 발생한다.
이를 해결하기 위해서는 현재 User의 Session을 만료시키고 새로운 Token을 username과 password를 이용하여 생성하고 이를 SecurityContextHolder에 새롭게 넣어주어야 한다.
하지만 현재 User의 Session에서 얻을 수 있는 정보는 username이나 인가 여부, role 정도 밖에 알 수 없었다. 즉 어떤 경우에도 평문의 패스워드가 필요했다.
너무 위험해보여 다른 방법이 없을까 열심히 찾아보았지만 더 좋은 방법을 찾을 수 없었음 (구글링 실력 부족..)
@PostMapping("/reconfirm")
public String reConfirmAction(@Valid PasswordCheckRequest passwordCheckRequest, BindingResult result, @CurrentUser User user, HttpServletRequest request, Model model, RedirectAttributes redirectAttributes) {
//Validation 생략
request.getSession().setAttribute("password", passwordCheckRequest.getPassword());
return "redirect:/me/update";
}
Redirect된 템플릿에서 Password를 받아 Session에 setAttribute한다.
그럼 이제 맨 처음에 보았던 preHandle의 통과 flag는 완성된 것이다.
이 위에까지는 쉽게 했다 자료도 많았고.
하지만 가장 큰 문제는 password가 저장된 세션을 제거하는 것이 문제였다.
고민해본 Session을 제거하는 방법
프론트단에서 페이지 탈출하는 것을 인지해서 제거하는 방법.
=> 원천봉쇄가 불가능하며 보안상 좋은 방법이 아님
유저가 정보를 한번 updateRequest를 보내고 나면 attribute를 제거하는 방법
=> update 요청을 보내지 않고 나간다면? Session에 계속 비밀번호가 저장되어 있음. 또한 한 화면에서 요청을 여러 번 보내고 싶다면? 문제 발생.
RedirectAttribute의 FlashAttribute를 사용하는 방법.
=> 이쪽이 제대로 공부가 되지 않아 이유는 확실하지 않지만 계속 Interceptor에 의해 다시 비밀번호 입력 페이지로 붙잡혀 오는 문제 발생함.
여기까지 고민하고도 안 되길래 Interceptor로 구현하고자 했던 것이 나의 오기였나 시작부터 잘못된건가 싶은 생각이 들었었다.
하지만 정답은 너무 가까이 있었다. 코앞에.
afterCompletion
간단히 말하면 맨 처음에 등장한 Interceptor에서 afterCompletion을 사용하면 되는 것이었다.
afterCompletion은 렌더링이 전부 끝나고나서 실행되는 Interceptor이다.
즉 preHandler처럼 return값에 따라 분기점이 생기는 것이 아닌 반드시 실행되는 코드이다.
@Component
public class PasswordInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response, final Object handler) throws Exception {
..생략..
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
@Nullable Exception ex) throws Exception {
request.getSession().removeAttribute("password");
}
Interceptor에 afterCompletion을 오버라이딩해서 removeAttribute 한줄만 적으면 끝난다.
추가로 Interceptor를 작성하거나 등록할 필요가 전혀 없다
<input type="hidden" class="form-control" id="password" th:value="${#session.getAttribute('password')}"
readonly>
그리고 마지막으로 템플릿 엔진을 통해서 렌더링을 해주면 된다.
위 과정처럼 작성하게 되면 페이지는 비밀번호 입력 페이지와 개인정보 수정페이지가 나뉘지만 비밀번호는 개인정보 수정페이지를 벗어나지 않는 이상 (물론 이를 위해서는 fetch나 axios 등의 비동기 통신을 해야한다)
User는 개인정보를 계속 수정할 수 있다 (얼마나 많은 사람이 그러겠냐마는..)
그리고 어떤 경우에라도 페이지를 벗어나게 되면 반드시 비밀번호 입력 페이지로 인터셉트되게 된다
비밀번호를 평문으로 렌더링해야 한다는 문제가 있긴 하지만 payload에 평문으로 담기는 것과 HTML에 렌더링 되는 것은 큰 차이가 없다고 생각한다.
결국 더 보안을 철저하기 하기 위해서는 SSL과 서버사이드에서 저장하는 Salt를 이용한 암호화가 더 필요할 거라는 생각이 들고
아직 많이 부족하지만 현재 수준에서 최선을 다한 것같아 뿌듯하다
'Spring' 카테고리의 다른 글
Page<Entity>를 Page<EntityDTO> 형태로 반환하는 방법과 Optional을 끼얹는 방법 (0) | 2022.05.30 |
---|---|
하나의 form에서 여러개 field의 file을 받는 방법 (0) | 2022.05.29 |
로그인 이후에 이전 페이지 스크롤 위치로 이동하는 방법 구현하기 (0) | 2022.05.26 |
스프링 시큐리티로 로그인 이후에 현재 페이지로 되돌아오는 방법 (0) | 2022.05.26 |
Entity를 다룰때 Primitive Type가 아닌 Wrapper Class를 쓰는 이유 (0) | 2022.04.21 |
- Total
- Today
- Yesterday
- RequestParam
- IDE
- neovim
- Dap
- 배포
- 도커
- 레디스
- 아키텍처
- 루나빔
- vim
- RequestPart
- JavaScript
- RequestBody
- ModelAttribute
- lunarvim
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |