기존에는 JWT를 사용하여 회원가입 및 로그인을 구현하였다면, 이번에는 Session을 사용하여 인증을 구현해보려고 합니다. 이를 통해 JWT를 사용한 방식과 세션을 사용한 방식이 어떤 차이가 있으며 장단점은 무엇인지 직접 느껴보고자 했습니다.
세션 정책
기본적으로 Spring Security를 사용하면 세션 기반 방식으로 동작하게 됩니다. 이는 Spring Security 세션 정책과 관련이 있습니다.
- SessionCreationPolicy.Always : 스프링 시큐리티가 항상 세션 생성합니다.
- SessionCreationPolicy.If_Required : 스프링 시큐리티가 필요 시 생성합니다.(default)
- SessionCreationPolicy.Never : 스프링 시큐리티가 생성하지 않지만 이미 존재하면 사용합니다.
- SessionCreationPolicy.Stateless : 스프링 시큐리티가 생성하지 않고 존재해도 사용하지 않습니다. (JWT와 같이 세션을 사용하지 않는 경우 사용)
기본적으로 Spring Security는 세션 기반으로 인증을 처리하기 때문에 오히려 회원가입 및 로그인 기능을 구현하는데 간단하지 않을까 생각했었습니다.
401 unauthorized
다음과 같이 Spring Security Config를 설정하고 UserDetailsServiceImpl과 UserDetailsImpl을 구현하였습니다.
@Configuration
@EnableWebSecurity(debug = true)
public class WebSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf((csrf) -> csrf.disable());
http.authorizeHttpRequests((authorization) ->
authorization.requestMatchers("/api/members/**").permitAll()
.anyRequest().authenticated()
);
http.exceptionHandling((exceptionConfig) ->
exceptionConfig.authenticationEntryPoint(unauthorizedEntryPoint).accessDeniedHandler(accessDeniedHandler)
);
return http.build();
}
...
}
그 후 회원가입과 로그인을 진행했을 때 정상적으로 세션ID가 클라이언트에 반환되는 것을 확인했습니다.
문제는 정상적으로 로그인 되었으나 다른 API(여기서는 가게들을 조회하는 /api/stores)를 호출할 때 401 에러가 발생합니다.
SpringSecurityContext를 조회하여 원인을 확인해보니 Spring Security에서 /api/member/** API는 모든 인증을 허용하기 때문에 SpringSecurityContext에 사용자 정보가 익명으로 저장되기 때문입니다. 이 상태에서 아무리 클라이언트가 세션 아이디를 가지고 있다 하더라도 특정 사용자 인증 정보가 없기 때문에 401 에러가 발생했던 것입니다.
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
System.out.println("User is not authenticated.");
} else {
System.out.println("User authenticated: " + authentication.getName());
}
세션을 이용한 로그인
로그인 시 우선 인증된 사용자인지 확인하고 세션을 발급하는 Controller와 Service 로직을 생성합니다.
@RestController
@RequestMapping("/api/members")
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@PostMapping("/login")
public RspTemplate<LoginResponse> login(HttpServletRequest httpRequest, @RequestBody @Valid LoginRequest req) {
// 요청에 세션이 없는 경우 세션 생성
HttpSession session = httpRequest.getSession(true);
LoginResponse res = memberService.login(session, req);
return new RspTemplate<>(HttpStatus.OK, "로그인에 성공하였습니다.", res);
}
}
@Service
@RequiredArgsConstructor
@Transactional
public class MemberService {
private final MemberRepository memberRepository;
private final BCryptPasswordEncoder passwordEncoder;
...
public LoginResponse login(HttpSession session, LoginRequest req) {
String phone = req.phone();
String rawPassword = req.password();
Member member = memberRepository.findByPhone(phone).orElseThrow(() ->
new MemberException("회원정보가 존재하지 않습니다.")
);
if (!passwordEncoder.matches(rawPassword, member.getPassword())) {
throw new MemberException("패스워드가 일치하지 않습니다.");
}
// 세션에는 인증된 멤버 객체 속성을 저장
session.setAttribute("LOGIN_MEMBER", member);
return LoginResponse.from(member);
}
}
- session.setAttribute("LOGIN_MEMBER", member)로 속성을 저장할 경우 세션은 key로 sessionId를 갖고, value로 Map("LOGIN_MEMBER", Object)를 갖는 구조가 됩니다.
발급된 세션을 가지고 다른 API를 호출할 때 발생하는 401에러를 해결하기 위해 SpringSecurityContext에 인증된 사용자 정보를 저장하는 Custom Filter를 생성하였습니다.
@Configuration
@EnableWebSecurity(debug = true)
@RequiredArgsConstructor
public class WebSecurityConfig {
private final UserDetailsServiceImpl userDetailsService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf((csrf) -> csrf.disable());
http.sessionManagement((session) ->
session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
);
http.authorizeHttpRequests((authorization) ->
authorization.requestMatchers("/api/members/**").permitAll()
.anyRequest().authenticated()
);
http.exceptionHandling((except) ->
except.authenticationEntryPoint(new CustomAuthenticationEntryPoint())
);
http.exceptionHandling((except) ->
except.accessDeniedHandler(new CustomAccessDeniedHandler())
);
http.addFilterBefore(memberAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
...
}
@RequiredArgsConstructor
public class MemberAuthenticationFilter extends OncePerRequestFilter {
private final UserDetailsServiceImpl userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
String requestURI = request.getRequestURI();
HttpSession session = request.getSession(false); // Session 정보를 가져오는데 없으면 세션을 새로 생성X
if (session == null) {
if(requestURI.startsWith("/api/searches")){ // /api/searches 로 시작하는 API는 익명 사용자도 접근이 가능
chain.doFilter(request, response);
} else {
throw new MemberException("requestURI : " + requestURI + "/인증되지 않은 사용자 입니다.");
}
}
Member member = (Member) session.getAttribute("LOGIN_MEMBER");
setAuthentication(member.getId());
chain.doFilter(request, response);
}
private void setAuthentication(Long memberId) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = createAuthentication(memberId);
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
private Authentication createAuthentication(Long memberId) {
UserDetails userDetails = userDetailsService.loadUserByMemberId(memberId);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
return StringUtils.startsWithIgnoreCase(request.getRequestURI(), "/api/members");
}
}
- HttpSession session = request.getSession(false);
- 요청에서 세션 정보를 가져오고 만약, 세션 정보가 없는 경우 null을 반환합니다.
- if (session == null) {...}
- 세션이 null 인 경우에 예외처리를 반환합니다.
- Member member = (Member) session.getAttribute("LOGIN_MEMBER");
setAuthentication(member.getId());- 세션 정보에서 사용자 정보를 가져와 SpringSecurityContext에 저장합니다.
마지막으로 테스트를 진행해보겠습니다. 로그인 후 /api/stores를 조회하면 정상적으로 응답이 오는 것을 확인할 수 있습니다.
참고
쿠키와 세션의 동작 원리와 세션의 구조
쿠키의 동작원리와 세션의 동작원리를 살펴보고, 세션을 직접 만들었다. 이때 서블릿에서 지원하는 세션과 직접 만든 세션의 구조의 차이를 알아보고 filter에서 세션의 사용법 중 한가지 상황에
velog.io
'Spring' 카테고리의 다른 글
[Spring] 쿠키와 세션 (0) | 2024.11.26 |
---|---|
[Spring] 인증 방식(세션과 JWT) (0) | 2024.11.25 |
[Spring] SpringSecurity 403에러 해결 (0) | 2024.11.20 |
[Spring] JPA(Java Persistent API) (0) | 2024.11.17 |
[Spring] IoC(제어의 역전), DI(의존성 주입) 이해하기 (0) | 2024.11.17 |