최근 은행 프로젝트를 시작하게 되었다. 은행의 경우는 보안을 가장 중요시한다.
하지만 Stateless를 추구하는 RestApi 형식과 Stateful한 세션은 거리가 멀다고 생각한다.
본인은 jwt를 구현할 때 stateless를 조금이라도 버린다면 차라리 세션을 사용하는 것이 더 좋다고 생각하기도 했다.
하지만 jwt를 사용할 때 보안을 위해서 redis로 리프래쉬 토큰을 관리하고 로그아웃을 구현하는 것 처럼 보안을 위한 타협은 나쁘지 않다고 생각하기도 한다.
그래서 이번 프로젝트는 성격이 맞지 않는 RESTAPI와 세션을 섞어서 구현해보려한다.
로그인 기능을 만들어, 로그인을 할 경우 사용자의 id 값을 세션에 추가한다.
그 이후 요청이 올 경우 필터에서 요청 속 세션에 있는 id값을 기준으로 시큐리티를 통해 사용자의 인증 정보를 확인하는 시스템을 구현할 것이다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Users {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "id")
private List<Account> accounts;
private String name;
private String loginId;
private String password;
@Enumerated(EnumType.STRING)
private Role role;
@Builder
public Users(String name, String loginId,String password){
this.name = name;
this.loginId = loginId;
this.password = password;
this.role = Role.ROLE_USER;
}
}
@Getter
@RequiredArgsConstructor
public enum Role {
ROLE_USER("USER"),
ROLE_ADMIN("ADMIN");
private final String description;
}
@Getter
public class CustomUserDetails implements UserDetails {
private Users users;
public CustomUserDetails(Users users){
this.users = users;
}
@Override
public boolean isAccountNonExpired() {
return UserDetails.super.isAccountNonExpired();
}
@Override
public boolean isAccountNonLocked() {
return UserDetails.super.isAccountNonLocked();
}
@Override
public boolean isCredentialsNonExpired() {
return UserDetails.super.isCredentialsNonExpired();
}
@Override
public boolean isEnabled() {
return UserDetails.super.isEnabled();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_" + users.getRole().getDescription()));
}
@Override
public String getPassword() {
return null;
}
@Override
public String getUsername() {
return users.getId().toString();
}
}
기존에는 CustomUserDetails를 구현하지 않고 직접 User 엔티티를 UserDetails를 상속받아 구현하는 방식을 선택했지만, 해당 프로젝트에서는 두 엔티티를 분리하는 방식으로 구현했다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception{
httpSecurity.
csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session->session.maximumSessions(1));
httpSecurity
.authorizeHttpRequests(auth->auth
.requestMatchers("/swagger", "/swagger-ui.html", "/swagger-ui/**", "/api-docs", "/api-docs/**", "/v3/api-docs/**").permitAll()
.requestMatchers(HttpMethod.GET,"/users").hasAnyRole("USER","ADMIN")
.requestMatchers("/**").permitAll()
);
httpSecurity
.addFilterBefore(new SessionAuthenticationFilter(customUserDetailsService), UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(exception->exception.accessDeniedHandler(new CustomAccessDeniedHandler())
.authenticationEntryPoint(new CustomAuthenticationEntryPoint()));
return httpSecurity.build();
}
@Slf4j
public class SessionAuthenticationFilter extends OncePerRequestFilter {
private final CustomUserDetailsService customUserDetailsService;
public SessionAuthenticationFilter(CustomUserDetailsService customUserDetailsService){
this.customUserDetailsService = customUserDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
Long userId = (Long) request.getSession().getAttribute("user");
log.info("요청 유저 id :{}",userId);
if(userId!=null){
UserDetails loginUser = customUserDetailsService.loadUserByUsername(userId.toString());
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
catch (CustomException e){
setResponse(response,e.getErrorCode());
}
}
private void setResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(errorCode.getStatus().value());
response.getWriter().print(errorCode.getMessage());
}
}
요청이 들어올 경우 세션 속 저장된 유저의 정보를 받아온다.
받아온 유저의 정보가 없다면, 시큐리티 컨텍스트에 등록되지 않은 아무 권한이 없는 사용자로 인식되어 필터를 통과한다.
받아온 유저의 정보가 있다면 해당 유저의 엔티티를 가져와 시큐리티 컨텍스트에 등록해 @AuthenticationPrincipal 으로 간편하게 정보를 가져올 수 있게 설계했다.
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UsersRepository usersRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Users user = usersRepository.findById(Long.valueOf(username)).orElseThrow(()-> new CustomException(ErrorCode.NOT_FOUND_USER));
return new CustomUserDetails(user);
}
}
@PostMapping("/login")
public ResponseEntity<String> login(@Valid@RequestBody LoginDto loginDto, HttpSession session){
Long userId = usersService.checkLogin(loginDto);
session.setAttribute("user",userId);
return ResponseEntity.ok("로그인 성공");
}
@GetMapping("")
public ResponseEntity<UsersResponseDto> getUser(@AuthenticationPrincipal CustomUserDetails customUserDetails){;
return ResponseEntity.ok(usersService.getUserInformation(customUserDetails.getUsers().getId()));
}
@GetMapping("/logout")
public ResponseEntity<String> logout(HttpSession session){
session.invalidate();
return ResponseEntity.ok("로그아웃 성공");
}
로그인을 성공할 경우 세션에 해당 유저의 데이터베이스 id 값을 넣어준다.
로그아웃 시 해당 세션을 끊는다.
@Autowired
MockMvc mockMvc;
ObjectMapper objectMapper = new ObjectMapper();
private MockHttpSession session;
@BeforeEach
void setup() {
session = new MockHttpSession();
}
@Test
@DisplayName("로그인 성공 테스트 후 유저 정보 호출 테스트")
public void getUserTest() throws Exception {
String name = "테스트이름";
String loginId = "testId";
String password = "testPassword";
UsersRequestDto usersRequestDto = UsersRequestDto.builder().name(name).loginId(loginId).password(password).build();
Long userId = usersService.join(usersRequestDto);
LoginDto loginDto = LoginDto.builder().loginId(loginId).password(password).build();
String body = objectMapper.writeValueAsString(loginDto);
mockMvc.perform(post("/users/login").content(body).contentType(MediaType.APPLICATION_JSON).session(session))
.andExpect(status().isOk())
.andExpect(jsonPath("$").value("로그인 성공"))
.andDo(print());
mockMvc.perform(get("/users").contentType(MediaType.APPLICATION_JSON).session(session))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(userId))
.andExpect(jsonPath("$.name").value(name))
.andExpect(jsonPath("$.loginId").value(loginId))
.andDo(print());
}
로그인 api를 호출하고 mocksession으로 세션을 받아 사용자의 정보를 호출하는 방식으로 해당 세션의 기능이 유지되고 작동하는지 테스트한다.
기존 jwt를 필터에서 검증하는 방식을 세션의 방식으로 바꿔서 구현을 해 보았다.
해당 작동은 정상적으로 잘 이루어지며, 포스트맨에서도 정상 작동을 하는 것을 확인했다.
다음 단계는 스웨거에서도 잘 작동하는지 확인하는 단계이다.
해당 기능을 구현하면서 아쉬웠던 점은 필터에서의 해당 세션에 대한 예외처리 부분이었다.
세션의 만료의 경우 2가지의 경우가 있는데, 세션 타임 아웃으로 인한 만료와 로그아웃을 통한 만료이다.
두 경우의 통합적인 인증실패 예외처리는 가능했지만,
타임 아웃의 경우는 세션은 존재하지만 세션의 인증이 실패하는 경우이다.
이 경우를 나눠 예외처리는 가능하지만, 필터에서 예외처리 할 경우 다시 로그인을 시도할 시 필터에서 해당 예외에 걸려버리기에 한계점이 존재한다.