[SpringBoot] RESTAPI + 세션을 구현해보자

이혜성·2025년 1월 15일

SpringBoot

목록 보기
6/9

서론

최근 은행 프로젝트를 시작하게 되었다. 은행의 경우는 보안을 가장 중요시한다.
하지만 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;
    }
}

ROLE enum

@Getter
@RequiredArgsConstructor
public enum Role {
    ROLE_USER("USER"),
    ROLE_ADMIN("ADMIN");
    private final String description;
}

CustomUserDetails

@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를 상속받아 구현하는 방식을 선택했지만, 해당 프로젝트에서는 두 엔티티를 분리하는 방식으로 구현했다.

SecurityConfig

@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();
    }

Filter

@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 으로 간편하게 정보를 가져올 수 있게 설계했다.

CustomUserDetailsService

@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가지의 경우가 있는데, 세션 타임 아웃으로 인한 만료와 로그아웃을 통한 만료이다.
두 경우의 통합적인 인증실패 예외처리는 가능했지만,
타임 아웃의 경우는 세션은 존재하지만 세션의 인증이 실패하는 경우이다.
이 경우를 나눠 예외처리는 가능하지만, 필터에서 예외처리 할 경우 다시 로그인을 시도할 시 필터에서 해당 예외에 걸려버리기에 한계점이 존재한다.

profile
반갑습니다

0개의 댓글