쇼핑몰 프로젝트에서 구현했던 인증 프로세스 과정을 하나하나 곱씹어 보면서, 하나의 구현도로 표현해보
았습니다.
첫째로 LoginFilter 를 통해서 JWT 로그인 프로세스를 진행 했으며, LoginFilter 은 AbstractAuthenticationProcessingFilter 를 상속받아 생성된 CustomJsonEmailPasswordAuthenticationFilter 를 한번 더 상속한 클래스 입니다.
public class CustomJsonEmailPasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "email";
public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login",
"POST");
private static final String CONTENT_TYPE = "application/json"; // JSON 타입의 데이터로 오는 로그인 요청만 처리
private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;
private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;
private boolean postOnly = true;
private final ObjectMapper objectMapper;
public CustomJsonEmailPasswordAuthenticationFilter(AuthenticationManager authenticationManager, ObjectMapper objectMapper) {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
this.objectMapper = objectMapper;
}
/**
* 인증 처리 메소드
*
* UsernamePasswordAuthenticationFilter와 동일하게 UsernamePasswordAuthenticationToken 사용
* StreamUtils를 통해 request에서 messageBody(JSON) 반환
* 요청 JSON Example
* {
* "email" : "aaa@bbb.com"
* "password" : "test123"
* }
* 꺼낸 messageBody를 objectMapper.readValue()로 Map으로 변환 (Key : JSON의 키 -> email, password)
* Map의 Key(email, password)로 해당 이메일, 패스워드 추출 후
* UsernamePasswordAuthenticationToken의 파라미터 principal, credentials에 대입
*
* AbstractAuthenticationProcessingFilter(부모)의 getAuthenticationManager()로 AuthenticationManager 객체를 반환 받은 후
* authenticate()의 파라미터로 UsernamePasswordAuthenticationToken 객체를 넣고 인증 처리
* (여기서 AuthenticationManager 객체는 ProviderManager -> SecurityConfig에서 설정)
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException {
if(request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE) ) {
throw new AuthenticationServiceException("Authentication Content-Type not supported: " + request.getContentType());
}
String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
// Map<String, String> usernamePasswordMap = objectMapper.readValue(messageBody, Map.class);
// 제네릭 타입을 new TypeReference 선언해줌으로써 컴파일시 제네릭타입으로 검증.
Map<String, String> usernamePasswordMap = objectMapper.readValue(messageBody, new TypeReference<Map<String, String>>() {});
String email = usernamePasswordMap.get(SPRING_SECURITY_FORM_USERNAME_KEY);
String password = usernamePasswordMap.get(SPRING_SECURITY_FORM_PASSWORD_KEY);
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(email, password);
//principal 과 credentials 전달
return this.getAuthenticationManager().authenticate(authRequest);
}
}
로그인 필터 (/login 경로로 request 되면, 그 요청을 가로 채는 필터)
public class LoginFilter extends CustomJsonEmailPasswordAuthenticationFilter {
private final MemberService memberService;
private final JWTUtil jwtUtil;
private final RefreshService refreshService;
private final ObjectMapper objectMapper;
private static final String CONTENT_TYPE = "application/json"; // JSON 타입의 데이터로 오는 로그인 요청만 처리
public LoginFilter(AuthenticationManager authenticationManager, ObjectMapper objectMapper, MemberService memberService, JWTUtil jwtUtil, RefreshService refreshService, ObjectMapper objectMapper1) {
super(authenticationManager, objectMapper);
this.memberService = memberService;
this.jwtUtil = jwtUtil;
this.refreshService = refreshService;
this.objectMapper = objectMapper1;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException {
로그인 필터를 통해서, 해당 회원에 대한 검증과 로그인 처리가 클라이언트로 응답 되어 집니다.
위 사진에서 실제적으로 인증을 처리하는 서비스 로직은 JWTFilterV3 입니다.
위 필터에서는 클라이언트에게 받은 HttpOnly Cookie 에 있는 리프레쉬 토큰과, 헤더에 담긴 Authorization 내의 엑세스토큰에 대해
유효성 검사를 진행하며, 인증이 완료되었으면 SecurityContextHolder 에 사용자 인증 정보 토큰 (Authentication)을 넘겨주며
서버에서는 Authentication 의 Granted Authority 를 확인하고, 접근이 허용된 리소스(controller, resource data, etx ...)에 대해
request 를 허가 해줍니다. (관리자, 일반 사용자 등등에 대한 권한이 Authentication 의 Granted Authority에 존재한다)
JWT + Social Login (Kakao, Google, Naver) 흐름도 에서 가장 중요한 점은
Href 하이퍼링크 클릭을 통해 백엔드 소셜 로그인 (필터를 통한 구현) 같은 경우
Response Data, Header 를 통한 응답이 불가하며
Cookie 나 Query Parameter 를 통해 토큰을 전달 해야 한다
는 것 입니다. 이는 구현하는 데에 굉장히 신경 쓰이는 부분 이었습니다.
이를 고려 했을 때,
- Query Parameter 에 Token을 담아주는 것은 보안상 굉장히 좋지 않습니다.
- Refresh Token 을 HttpOnly, https 등 보안을 높이기 위한 방식의 Cookie 로 클라이언트에게 전달 해야 합니다. (로컬 환경에서는 http, 배포 환경에서는 https 설정을 해줍니다)
- 그렇다고 Access Token 을 HttpOnly 쿠키로 보내버리면, 자바스크립트로 Access Token 를 쿠키에서 꺼내서 로컬 스토리지에 저장 할 수 없기 때문에 HttpOnly 쿠키로 보내주지 않았습니다.
- 결국에 Custom Social Login Success Filter 가 성공 하고 나서, 응답에는 Refresh Token 만을 HttpOnly Cookie 에 담아 주었습니다.
- 소셜 로그인 성공과 함께, 백엔드 -> 클라이언트 메인 페이지로 Redirect 시켜주면서 Query Parameter 에
" https://도메인?redirectedFromSocialLogin=true "
redirectedFromSocialLogin 를 담아 주었고 - 리액트(클라이언트)에서는 Query Parameter 에 redirectedFromSocialLogin 가 담긴 시점에 서버로부터 Access Token 을 받는 api 를 요청하도록 서비스 로직을 결정했습니다.
이해가 안되시거나, 궁금하신 점이 있으시면 댓글 남겨 주시면 감사하겠습니다.