모두가 알다시피 인증 기능 구현은 매우 무겁다 😬
현재 진행 중인 프로젝트는 모바일 기반 플랫폼 서비스로, OAuth2.0를 사용한 카카오, 구글, 애플 로그인을 구현하는 것을 목표로 하고 있다. 그러나 소셜 로그인 기능의 경우 메인로직이 아닐 뿐더러, 유저 입장에서 거부감도 심하게 들 것이고, 우리 팀도 OAuth2.0를 모두 이해해 구현하기에는 2주 정도의 시간이 허비될 것 같아 간단히 유저만 구분할 수 있는 로직으로 1차 배포를 진행하기로 하였다.
SpringSecurity 를 사용한 이유
SpringSecurity는 Springboot로 들어오는 Request/Response와, 실질적인 CRUD를 처리하는 DispatcherServlet 사이의 Filter chain 형태로 이루어져 있다. (Request → Filter Chain → Servlet(DispatcherServlet))
따라서 Dto에 일일이 해당 User 정보(ex.userId) 에 대한 내용을 추가하지 않아도 Header 형태로 요청/응답을 받을 수 있어 코드도 깔끔해지고, 인증과 CRUD의 관심사를 분리할 수 있다.
어떤 방식으로 인증할 것인가?
Spring Security의 인증 매커니즘의 종류에는 Username and Password (사용자 이름/비밀번호로 인증하는 방법), OAuth 2.0, SAML 2.0 로그인, CAS(Central Authentication Server), Remember Me (사용자의 과거 세션 만료를 기억하는 방법) 등이 존재한다.
나는 간단히 Header 값으로 유저를 구분할 것이기 때문에 Username and Password 인증 매커니즘 방식을 선택하였고 password를 ''로 설정하여 사용하였다. Spring Security를 사용할 것이라면, 사용자의 정보를 담을 때 어떤 형식과 기준을 가지고 저장할지는 SpringSecurity의 주어진 정책과 툴에 따라 결정하면 된다고 한다. 따라서 Username만 사용하고 Password를 null로 설정해도 괜찮다.
Spring Security속에 인증 정보가 어떻게 담길까?
SecurityContextHolder는 인증된 사람의 세부정보를 저장하는 곳이다. 만약 자격증명이 되지 않아 인증이 되지 않으면 더이상 ContextHolder에 정보가 저장되지 않고 유저에게 403 Unauthorized 오류를 반환한다. 자격증명에 성공하였다면 이 SpringContextHolder 안에 인증정보를 넣어두고 사용하게 된다.
Authentication 내부에는 principal(주로 사용자 이름/비밀번호로 인증할 때 UserDetails의 인스턴스), credentials(대부분 비밀번호), authorities(권한, ROLE_ADMIN, ROLE_USER 등의 권한이나 Scope)의 정보가 들어있다.
Spring Security 인증 전반적인 로직
우선 이 그림을 보며 간단히 로직을 요약한 후, 차근차근 실질적인 인증 로직을 처리하는 과정에 대해 자세히 알아가보도록 하겠다.
요청 -> Authentication Filter -> AuthenticationManager로 요청 위임 -> AuthenticationProvider를 조회하여 인증 요구 -> DB에서 Member 객체를 조회(UserDetailsService)하여 UserDetails 결과 돌려줌 → 다시 돌아와 이를 SecurityContextHolder에 넣음
Filter에서 일어나는 일
JwtAuthenticationFilter & UsernamePasswordAuthenticationFilter
우선 짚고 가야 할 것이 있는데, JwtAuthenticationFilter는 JWT와는 관계가 없다..! 이름만 들으면 accessToken, refreshToken 같은 걸 만들어주는 로직이 있을 것 같은데 전혀 그렇지 않다. 주로 JWT를 구현할 때 활용하긴 하나, 커스텀하여 사용할 수 있다. 나는 현재 로직에서 커스텀화하여 Authorization Header 속 String 값을 꺼내오는 로직으로 활용하였다.
@Component
@RequiredArgsConstructor
class JwtAuthenticationFilter extends OncePerRequestFilter {
private final CustomUserDetailsService customUserDetailsService;
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String memberName = resolveToken(request);
if (StringUtils.hasText(memberName)) {
try {
UserDetails userDetails = customUserDetailsService.loadUserByUsername(memberName);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception e) {
logger.error("Could not set user authentication in security context", e);
}
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (bearerToken != null && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
}
간단히 코드를 살펴보면, 외부에서 받은 헤더의 문자열 값(ex. "Authorization: Bearer {String}")을 기반으로 인증을 처리한다. 이 과정에서 UserDetails 객체를 생성하여 UsernamePasswordAuthenticationToken으로 변환시켜 SecurityContextHolder에 넣고 다음 필터로 요청을 전달한다.
그러면 아래와 같이 jwtAuthenticationFilter의 다음 필터인 UsernamePasswordAuthenticationFilter에 접근하게 된다.
이제 UsernamePasswordAuthenticationFilter에서 (username, password)를 이용해 정상적인 로그인 여부를 검증하고 DI로 받은 AuthenticationManager 객체로 로그인을 시도하게 된다.
인증 객체는 어디로 이동해갈까?
AuthenticationManager/AuthenticationProvider
- 이 둘은 filter로부터 인증처리를 지시받는 클래스이다. AuthenticationManager는 ID/Password를 받아 Token 형태로 만들어 Authentication 인증 객체(아직 인증 전)에 저장하고 이 객체를 적절한 AuthenticationProvider에게 전달한다.
- 이 두 인터페이스를 Springboot에서 까보면 공통적으로 authenticate 함수를 발견할 수 있는데, 매개변수로는 인증 요청 객체를 받고, 반환값으로는 자격 증명을 포함한 완전히 인증된 객체를 내보내는 것을 확인할 수 있었다.
DB에서 사용자를 조회하여 인증을 처리해야 겠다.
그러면 이제 위의 authenticationManager 메서드로도 확인할 수 있듯이 빈 주입 메서드로 인해, AuthenticationProvider는 UserDetailsService 속 loadUserbyUsername(String username)을 호출한다.
loadUserByUsername 메소드 에서는 DB 속 member 객체를 조회하여 현재 접근한 유저의 이름(구분을 위한 이름이다)이 DB에 있다면 이를 이용해 인증 허가(인가)를 받아 사용자 객체를 반환받게 된다.
이 때 반환값은 SpringSecurity가 처리할 수 있도록 Member가 아닌 UserDetails로 반환해야 하며, UserDetails는 아래와 같이 권한정보, Username, Password 를 필드로 가지고 있다.
이 때 보통 UserDetails의 password와 사용자가 넘겨준 password(Hashed password)를 바탕으로 하여 확인하는 절차가 필요한데, 나의 경우에는 password 없이 간단히 username으로 유저 구분만 해주기로 했으므로 이 절차는 생략되었다.
암튼 이렇게 되면 인증 객체 정보가 SpringContext에 안전하게 들어오게 된다! 인가(허가) 되었다!
결론
이제 모든 메서드에 공통적으로 Header를 씌워 인증을 요청하면, 서버에서는 DTO에 회원정보를 담지 않아도 SpringContextHolder에서 인증 정보를 꺼내올 수 있게 된다.
SpringSecurity Filter 기능을 이용하여 중복되는 코드도 줄이고, 이후 배포에서 구현할 OAuth2.0에서 Token 형태로 들어오는 로직에 대해서도 수정을 최소화하도록 구현할 수 있었다.!
'BackEnd > JAVA\SPRING' 카테고리의 다른 글
[Java|Spring|AWS] OpenSearch Java API로 검색 기능 구현 (2) | 2024.07.21 |
---|---|
[S3|AWS] S3와 CloudFront로 이미지 저장소 만들기 (0) | 2024.06.30 |
[Java/Spring] 템플릿 콜백 패턴으로 JDBC 템플릿 구현해보기 (0) | 2024.04.02 |
[SpringSecurity] Koala 프로젝트 간단한 인증처리 (0) | 2024.02.13 |
[Java|Spring] Koala 프로젝트 구현 과정 중 이슈 정리 (0) | 2024.02.01 |