우아한테크코스 레벨2 기간에 학습한 인증 with Spring & Spring MVC Config 강의 내용을 정리한다.
인증(Authentication) 이란 사용자가 "누구"인지 확인
하는 과정이다.
사용자는 인증을 위한 정보
를 서버로 보내고, 서버로부터 인증 결과 정보
를 받는 과정이 인증이다.
예를 들어 토큰을 이용한 인증 방법의 흐름은, 사용자가 로그인
을 하고 서버는 유효한 로그인인지를 검증한다.
(이 때, 아이디 및 비밀번호에 대한 정보는 DB에 저장되어 있을 수 있고, DB로부터 데이터를 조회해 사용자가 입력한 아이디 & 비밀번호와의 일치여부를 판별할 수 있다.) 만약 유효한 사용자의 로그인 시도인 경우에는 인증 정보를 담은 Token
을 생성해 사용자에게 반환해줄 수 있다.
인가(Authentication) 이란 사용자에게 "권한"이 있는지 확인
하는 과정이다.
인가를 위해선 우선 인증 절차가 필요하다 사용자가 인증을 위한 정보
를 서버로 보내고, 서버로부터 인증 결과 정보
를 받는다. 이 때 어떤 권한이 있는지도 함께 받게 될 것이다.
이후에 사용자는 데이터 요청을 하면서 인증 결과를 함께 보낸다. 이 때 서버는 적절한 권한이 있는지를 검사하고 요청된 데이터를 응답으로 보냊루 것이다.
예를 들어, 세션을 통한 인가 방법은 로그인을 하고 그에 대한 Session ID를 받을 것이다. 이후 서버로 데이터 요청을 하면서 함께 쿠키를 전달하게 되고, 서버는 이를 받아 세션 저장소에서 인증 정보를 검사한 후 적절한 권한이 있는 경우에 요청 데이터에 대한 응답을 줄 것이다.
토큰을 통한 인가방법은 조금 더 편리할 수 있다. (세션 저장소가 없기 때문에)
앞서 인증과 동일한 과정을 거친 후 데이터 요청을 보내면서 Token을 함께 보낸다. 서버는 Token으로 부터 권한을 확인한 후 적절한 권한을 가진 사용자인 경우 요청 데이터를 응답으로 보내준다.
세션이란 사용자가 웹 브라우저를 통해 웹서버에 접속한 시점으로부터 웹 브라우저를 종료하여 연결을 끊는 시점까지 같은 사용자로부터 오는 일련의 요청을 하나의 상태로 보고 그 상태를 일정하게 유지하는 기술이라고 한다.
참고
세션은 세션ID를 서버에서 클라이언트로 발급해주며, 클라이언트는 서버에서 클라이언트로 발급해준 세션 ID를 쿠키를 사용해서 저장한다. 그리고 클라이언트는 이후 서버 요청시에 쿠키에 담은 세션ID 값을 함께 전달한다.
JWT는 Json Web Token
의 약자로 선택적 서명 및 선택적 암호화를 사용하여 데이터를 만들기 위한 인터넷 표준으로, 페이로드는 몇몇 클레임 표명을 처리하는 JSON을 보관하고 있다. 라고 위키백과에서 설명하고 있다.
즉, JWT는 인터넷 표준 인증 방식
이라고 이야기할 수 있으며 페이로드, 즉 인증에 필요한 정보들을 Token에 담아 암호화시켜 사용하는 토큰인 것이다.
JWT를 통한 인증은 다음과 같은 순서로 진행된다.
이러한 JWT는 이미 토큰 자체가 인증된 정보이기 때문에 별도의 저장소가 불필요하다는 장점이 있으며, 세션과는 달리 클라이언트의 상태를 서버가 저장해두지 않아도 된다.
Bearer는 하나의 인증 스킴
이다. Bearer 이외의 인증 스킴에는
세션 기반으로 로그인 요청시에는 POST Method 바디에 인증을 위한 데이터(email혹은 id, password 등)을 실어서 서버로 보내게 된다. 그러면 서버는 로그인 응답으로 Set-Cookie
헤더에 세션ID를 실어서 보내게 되고, 이후 사용자는 데이터 요청 시에 cookie
헤더에 서버로 부터 받은 세션 값을 함께 보내게 된다.
앞서 세션과 동일하게 사용자는 POST Method 바디에 인증을 위한 데이터를 실어서 서버로 보내게 된다. 그러면 서버는 JSON 형식의 accessToken을 body에 실어서 보내주게 될 것이다. 사용자는 해당 토큰 값을 가지고 이후 요청에는 authorization
헤더에 해당 값을 실어 보내게 된다.
세션과 토큰의 인증 방식의 차이점은 인증 결과 데이터에 있다. 인증 결과로 세션은 세션ID를 주고 토큰은 헤더, 페이로드, 서명 3가지 구성요소롤 이루어진 암호화된 문자열의 나열을 준다. 즉 세션은 토큰에 비해 크기가 작다는 차이점이 있다.
또한 저장되어 관리되는 위치에도 차이가 있다. 세션은 서버에서 저장 및 관리한다. 반면 토큰의 경우 클라이언트측에 저장된다.(local storage 등) 이러한 이유로 토큰의 유효기간은 짧게 설정해주는 것이 좋다. (refresh token을 함께 사용하는 이유이며 이 refresh token은 서버쪽에(안전한 곳에) 저장한다.)
WebMvcConfig
클래스는 기본적인 서블릿 설정을 하는 클래스라고 생각하면 된다. WebMvcConfig
는 인터페이스로 다음과 같이 Interceptor, argumentResolver 등을 추가하는 default 메소드가 정의되어 있다.
public interface WebMvcConfigurer {
default void addInterceptors(InterceptorRegistry registry) {
}
default void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
}
...
}
우리는 사용자 요청에 따라서 수행할 로직을 분기한다. 즉, 요청에 따라서 적절한 Controller의 메소드를 찾아가 수행시킨다. 그런데 만약 권한을 체크하는 로직, 즉 accessToken
을 받아서 이로부터 email 등을 추출해 로직을 수행해야 한다면? 그리고 이러한 로직이 인증이 필요한 컨트롤러마다 모두 존재해야한다면 어떻게 될까? 중복되는 로직이 매우 많아질 것이다.
이 때, 인증 여부를 확인하는 역할을 분리할 수 있으며, Interceptor 나 ArgumentResolver등을 활용할 수 있다. 그리고 이 둘은 WebMvcConfig를 구현하는 구체(Concrete) 클래스를 구현함으로써 설정해줄 수 있다.
public class LoginInterceptor extends HandlerInterceptorAdapter {
private AuthService authService;
public LoginInterceptor(AuthService authService) {
this.authService = authService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
try {
String accessToken = request.getHeader("Authorization");
authService.checkValidation(accessToken);
} catch (Exception e) {
throw new AuthrizationException();
}
return super.preHandle(request, response, handler);
}
}
@Configuration
public class AuthenticationPrincipalConfig implements WebMvcConfigurer {
private final AuthService authService;
public AuthenticationPrincipalConfig(AuthService authService) {
this.authService = authService;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor(authService))
.addPathPatterns("/members/me");
}
}
위의 코드는 "/members/me" 를 통해서 들어오는 요청에 대해서 LoginInterceptor
를 등록해주어 preHandle
로직이 수행되는 것이다. preHandle 로직은 요청의 Authorization
헤더에서 accessToken을 가져오고 토큰의 유효성을 검사하는 역할을 수행한다.
그리고 이러한 Interceptor는 Dispatcher Servlet 이후 클라이언트의 요청을 Controller가 처리하기 이전에 수행된다. (Controller를 찾고 Interceptor가 확인할 url과 일치하면 Interceptor의 preHandle이 실행된다.)
public class AuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver {
private final AuthService authService;
public AuthenticationPrincipalArgumentResolver(AuthService authService) {
this.authService = authService;
}
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthenticationPrincipal.class);
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
final String token = AuthorizationExtractor.extract(request);
return authService.findCustomerByToken(token);
}
}
@Configuration
public class AuthenticationPrincipalConfig implements WebMvcConfigurer {
private final AuthService authService;
public AuthenticationPrincipalConfig(AuthService authService) {
this.authService = authService;
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(createAuthenticationPrincipalArgumentResolver());
}
@Bean
public AuthenticationPrincipalArgumentResolver createAuthenticationPrincipalArgumentResolver() {
return new AuthenticationPrincipalArgumentResolver(authService);
}
}
위의 코드(AuthenticationPrincipalArgumentResolver)는 supportsParameter
에서 AuthenticationPrincipal
어노테이션이 붙은 arugment에 대해서 resolveArgument
을 수행하겠다는 것을 의미한다.
즉, 지원되는 파라미터(AuthenticationPrincipal
) 이 붙은 곳에 대해서는 resolveArgument가 수행되어 요청으로부터 token을 가져오고 이 토큰으로 authService.findCustomerByToken(token);
로직을 수행한다는 것이다.
그리고 앞서 Interceptor와 동일하게 WebMvcConfigurer를 구현하는 구현체 클래스에 설정을 추가해주었다.
CORS(교차 출처 리소스 공유)는 한 출처에서 실행 중인 웹 애플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라우저에 알려주는 체제라고 한다. 참고
브라우저에서는 OPTIONS헤더를 포함한 예비 요청을 서버로 보내 해당 서버가 정상 통신이 가능한지를 실제 요청을 보내기 전에 보내 확인한다고 한다. 이 때, 앞서 보인 코드와 같은 Interceptor는 에러가 발생한다. 왜냐하면 Controller로 요청이 전해지기 이전에 먼저 토큰의 유효성을 검사하는데, 이 때 Authorization 헤더의 부분은 비어있을 것이기 때문이다.(OPTIONS)
하지만 위의 코드와 같은 ArgumentResolver의 경우에는 이러한 문제가 발생하지 않는다.
정확하지는 않지만 Interceptor가 처리하는 위치와 ArgumentResolver가 처리하는 위치가 다르기 때문이라고 생각한다. ArgumentResolver는 요청이 해당 Controller로 오고나서 해당 argument에 대해서 수행된다. 즉, Controller로 요청이 도달하기 전에 수행되는 Interceptor(Controller로 요청이 가기전에 가로챔)에서는 OPTIONS 요청이 문제가 되지만 ArgumentResolver에서는 문제가 되지 않는 것이다.