2024.10.04.(금)
참고 자료: GDG on Campus: Seoultech Back Session
3주차
인증과 인가
요구사항: 로그인하지 않은 사용자는 질문과 답변을 작성하지 못하게 해주세요!!
대부분의 사이트에는 로그인이 있고 로그인한 사용자와 아닌 사용자가 할 수 있는 기능이 다르다. 우리 프로젝트에서는 나중에 질문과 답변을 구현할 때 로그인하지 않은 사용자는 질문을 작성하거나 수정할 수 없어야 한다.
로그인 유무를 서버에서 진행하는데, 이때 진행되는 절차가 인증과 인가이다.
인증: 사용자가 누구인지 확인하는 절차, 회원가입하고 로그인하는 것 인가: 유저에 대한 권한을 허락하는 것. 로그인을 했지만 다른 사용자가 작성한 질문을 수정할 수 없게 막는 것이 인가의 예시
인증 방식 (쿠키, 세션, JWT) 알아보기
- 쿠키(Cookie)
클라이언트에 저장될 목적으로 생성한 작은 정보를 담은 파일
브라우저는 서버에서 받은 쿠키를 저장해두었다가 동일한 서버로 재요청할 때 쿠키를 함께 전송한다. 이후 사용자가 로그인을 하면 서버는 사용자의 로그인 정보를 쿠키에 담아 브라우저로 보낸다. 이후 요청마다 쿠키에 로그인 정보를 확인하여 로그인 유무를 판단한다.
브라우저는 서버에서 받은 쿠키를 저장해두었다가 동일한 서버로 재요청할 때 쿠키를 함께 전송한다. 이후 사용자가 로그인을 하면 서버는 사용자의 로그인 정보를 쿠키에 담아 브라우저로 보낸다. 이후 요청마다 쿠키에 로그인 정보를 확인하여 로그인 유무를 판단한다.
- 세션(Session)
서버에서 일정시간 동안 클라이언트의 상태를 유지하기 위해 사용한다.
서버에서 클라이언트별 유일한 세션 ID를 부여하고 세션 정보를 서버에 저장한다.
서버에서 생성한 세션 ID는 클라이언트의 쿠키 값으로 저장되고 클라이어트에서 요청을 보낼 때 이 세션 쿠키를 함께 보낸다. 서버에서는 클라이언트별 세션 쿠키 값이 저장되어 있으므로 요청으로 온 쿠키의 세션값을 보고 로그인 유무를 판단한다.
- JWT (JSON Web Token)
JWT는 인증에 필요한 정보들을 암호화시킨 토큰이다. JWT 토큰을 HTTP 헤더에 담아 서버가 클라이언트를 식별한다.
JWT의 구조
Header: 토큰 타입, 해쉬 알고리즘으로 구성되어 있다. Payload: 사용자의 정보가 담겨져 있다. Signature: 인코딩된 헤더와 페이로드를 더한 뒤, secret key로 해싱해서 생성한다. 서버에서 관리하는 secret key가 있어야 복호화가 가능하다.
클라이언트가 로그인 요청을 보내면 로그인 성공 시, 서버는 로그인 정보를 Payload에 담고, Secret Key를 사용해서 JWT를 발급한다.
클라이언트는 전달받은 토큰을 브라우저에 저장 후 서버에 요청할 때마다 토큰을 서버에 전달한다.
서버는 클라이언트가 전달한 토큰을 복호화한 후 유효한지 판단한다.
Spring Interceptor
우리 프로젝트에서는 세션으로 로그인 유무를 판단하려고 한다. 컨트롤러 코드에서 판단하면 코드가 복잡해지므로 Spring Interceptor를 활용하자
장점: 관심사가 분리되고 컨트롤러 코드가 실행되기 전에 로그인 유무를 판단할 수 있어 서버 자원이 절약된다.
-
요청 들어올 때
- 클라이언트 요청 -> 서블릿 -> 인터셉터 -> 컨트롤러 -> 서비스 -> 레포지토리
-
응답을 반환할 때
- 컨트롤러 -> 인터셉터 -> 서블릿 -> 클라이언트
-
참고: DispatcherServlet이란?
DispatcherServlet이란?
-
Spring 애플리케이션에서 요청이 들어오면, 서블릿 컨테이너는 이를
DispatcherServlet
에 전달한다.DispatcherServlet
은 Spring MVC의 핵심 서블릿으로, 모든 요청을 중앙에서 처리한다. -
DispatcherServlet
은 요청에 따라 적절한 컨트롤러를 찾아야 하므로, 핸들러 매핑을 통해 어떤 컨트롤러가 해당 요청을 처리할지 결정한다. -
스프링 인터셉터란?
HTTP 요청을 가로채어 처리하는 컴포넌트. 즉, 클라이언트의 요청이 컨트롤러로 전달되기 전에 사전 작업을 수행하거나 컨트롤러의 실행 이후에 작업을 수행할 수 있다. 코드의 중복을 중리고 관심사를 분리하고 개발자가 비즈니스 로직 작성에만 집중할 수 있다.
- 세션 사용시 자주 쓰이는 상수 클래스 생성
// util/SessionConst.java
public class SessionConst {
public static final String LOGIN_MEMBER = "loginMember";
public static final String REQUEST_USER_ID = "requestUserId";
}
- application.yml에 세션 설정
세션 값이 url에 나오지 않게 설정한다.
...
server:
servlet:
session:
tracking-modes: cookie
- LoginCheckInterceptor 클래스 생성 커스텀 인터셉터를 구현할 때 HandlerInterceptor를 구현한다. preHandle 메서드는 컨트롤러 코드가 실행되기 전에 실행되는 메서드이다. 반환 값이 false 이면 컨트롤러로 넘어가지 않고 true가 반환되어야 넘어간다.
HttpSession session = request.getSession(false); 를 통해 사용자의 요청에서 온 세션을 가져온다.
이후 세션이 없거나 세션의 “loginMember” 값이 없으면 로그인하지 않은 사용자로 판단해 false를 반환하며 401 코드를 클라이언트로 보낸다.
ObjectMapper: 객체를 JSON으로, JSON을 객체로 변환해주는 클래스
// config/interceptor/LoginCheckInterceptor.java
@Slf4j
public class LoginCheckInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 서버에 저장된 세션 가져오기
// false는 세션이 없을 경우 생성하지 않음
// 세션 유효기간 만료시 자동 삭제되며 null값이 반환됨.
HttpSession serverSession = request.getSession(false);
// 서버에 세션이 없으면 세션이 만료된 상태로 간주 -> 에러 반환
if (serverSession == null) {
sendErrorResponse(response, SessionError.NO_SESSION.getMessage());
return false;
}
// 클라이언트가 보낸 세션 ID 가져오기 (쿠키에서 가져옴)
String clientSessionId = null;
if (request.getCookies() != null) {
for (Cookie cookie: request.getCookies()) {
if ("JSESSIONID".equals(cookie.getName())) {
clientSessionId = cookie.getValue();
break;
}
}
}
// 클라이언트가 보낸 세션 ID가 없는지? 서버에서 설정한 유저 객체가 있는지? 확인
if (clientSessionId = null || serverSession.getAttribute(SessionConst.LOGIN_MEMBER) == null ) {
sendErrorResponse(response, SessionError.NO_SESSION.getMessage());
return false;
}
// 서버 세션의 ID와 클라이언트 세션 ID 비교
String serverSessionId = serverSession.getId();
if (!clientSessionId.equals(serverSessionId)) {
sendErrorResponse(response, SessionError.SESSION_NOT_MATCH.getMessage());
return false;
}
// 질문 수정, 삭제 기능 구현시에 질문 작성자와 요청 보낸 사용자가 같은지 판단하기 위해 컨트롤러로 유저 아이디를 넘겨줌.
UserInfo user = (UserInfo) serverSession.getAttribute(SessionConst.LOGIN_MEMBER);
Long id = user.getId();
request.setAttribute(SessionConst.REQUEST_USER_ID, id);
return true;
}
private void sendErrorResponse(HttpServletResponse response, String message) throws IOException {
ErrorResponse errorResponse = ErrorResponse.builder()
.code(HttpStatus.UNAUTHORIZED.value())
.message(message)
.build();
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(errorResponse);
response.getWriter().write(json);
}
@Getter
private enum SessionError {
NO_SESSION("세션이 존재하지 않습니다"),
SESSION_NOT_MATCH("세션이 일치하지 않습니다");
final String message;
SessionError(String message) {
this.message = message;
}
}
}
- LoginCheckInterceptor를 스프링에 추가
스프링의 WebMvcConfigurer를 구현해서 작성한다. 서버 시작시에 @Configuration이 붙은 클래스를 읽어서 설정 정보에 추가한다. addInterceptors 메서드를 오버라이딩하면 서버 시작시에 LoginCheckInterceptor를 설정 정보에 추가해서 매번 요청시마다 도작할 수 있게 된다. 모든 요청 url을 addPathPatterns()에 넣고 로그인과 회원가입 요청 url을 excludePathPatterns()를 사용해서 제외했다.
// config/WebConfig.java
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginCheckInterceptor())
.order(1)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/user/login", "/api/user/signup");
}
}
- UserController에 세션 생성 로직 추가
로그인 요청시에 로그인이 정상적으로 완료되면 반환할 때 세션을 추가한다.
HttpSession session = request.getSession(); 에서 세션을 가져온다.
session.setAttribute(SessionConst.LOGIN_MEMBER, loginUser); 에서 세션 속성에 “loginMember” 값에 유저 객체를 넣는다.
session.setMaxInactiveInterval(1800); 에서 세션의 만료기간을 30분으로 설정한다. 만료 기간이 길다면 보안상 문제가 생길 수 있다.
// user/controller/UserController.java
@PostMapping("/login")
public ResponseEntity<String> login(@RequestBody @Validated LoginRequest loginRequest, HttpServletRequest request) {
UserInfo loginUser = userService.login(loginRequest);
setSession(request, loginUser);
return new ResponseEntity<>("로그인 완료", HttpStatus.OK);
}
private void setSession(HttpServletRequest request, UserInfo loginUser) {
// 세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성
HttpSession session = request.getSession();
session.setAttribute(SessionConst.LOGIN_MEMBER, loginUser);
session.setMaxInactiveInterval(1800);
}
- UserService에 세션 무효화 로직 추가
사용자가 로그아웃을 하면 기존의 세션을 초기화한다. 초기화하지 않으면 서버에서 로그아웃을 인식하지 못해서 보안에 문제가 생길 수 있다.
// user/service/UserService.java
public void logout(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
}
- UserController에 로그아웃 메서드 추가 및 테스트 메서드 수정
// user/controller/UserController.java
@PostMapping("/logout")
public ResponseEntity<String> logout(HttpServletRequest request) {
userService.logout(request);
return new ResponseEntity<>("로그아웃 완료", HttpStatus.OK);
}
@PostMapping("/test")
public String test(@RequestAttribute(SessionConst.REQUEST_USER_ID) Long requestUserId) {
log.info("test");
log.info("loginUser : {}", requestUserId);
return "ok";
}
리팩토링 - ArgumentResolver
매번 로그인한 유저의 정보를 가져올 때 @RequestAttribute(SessionConst.REQUEST_USER_ID) Long requestUserId 를 계속 적는 것은 불편하다.
그리고 인터셉터에 검증과 데이터 추가의 책임을 맡겨 놓았다. 오류를 해결하는데 시간이 걸릴 수 있다. 만약 검증과 데이터 추가를 분리한다면? 검증에 오류가 생기면 인터셉터를 찾아보고 유저 데이터가 안넘어온다면 해당 책임을 가진 클래스를 찾아보면 된다. 생각하기 편해진다.
다른 사람이 보고 활용할 때 SessionConst를 찾지 못해서 구현에 어려움이 생길 수 있다. @Login 어노테이션만 작성해도 된다면 다른 사람이 내가 만든 코드를 편리하게 활용할 수 있을 것 같다.
검증은 인터셉터에 맡기고 유저 객체를 받아오는 기능은 따로 분리하려면 어떻게 해야할까?
어떤 요청이 컨트롤러에 들어왔을 때, 요청에 들어온 값으로부터 원하는 객체를 만들어내는 일을 ArgumentResolver가 간접적으로 해줄 수 있다.
- ArgumentResolver
클라이언트의 요청으로부터 값을 참조하거나 객체를 생성해서 컨트롤러의 파라미터에 바인딩할 때 사용된다.
- ArgumentResolver 사용법
HandlerMethodArgumentResolver를 구현하면 된다.
boolean supportsPararmeter(MethodParameter parameter);
@Nullable
Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer, NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception;
- LoginCheckInterceptor 주석처리
// config/interceptor/LoginCheckInterceptor.java
// 삭제 -> ArgumentResolver 사용
// User user = (User)serverSession.getAttribute(SessionConst.LOGIN_MEMBER);
// Long id = user.getId();
// request.setAttribute(SessionConst.REQUEST_USER_ID, id);
- SessionConst 주석처리
// util/SessionConst.java
// 삭제 -> ArgumentResolver 사용
// public static final String REQUEST_USER_ID = "requestUserId";
- Login 어노테이션 생성
@Target(ElementType.PARAMETER): 파라미터에만 사용 @Retention(RetentionPolicy.RUNTIME): 리플렉션 등을 활용할 수 있도록 런타임까지 애노테이션 정보가 남아있음
// config/argumentresolver/Login.java
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}
- LoginUserArgumentResolver 작성
supportsParameter(): @Login 애노테이션이 있으면서 User 타입이면 해당 ArgumentResolver가 사용된다. resolveArgument(): 컨트롤러 호출 직전에 호출 되어서 필요한 파라미터 정보를 생성해준다. 여기서는 세션에 있는 로그인 회원 정보인 User 객체를 찾아서 반환해준다. 이후 스프링은 컨트롤러의 메서드를 호출하면서 여기에서 반환된 User 객체를 파라미터에 전달해준다.
// config/argumentresolver/LoginUserArgumentResolver.java
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
// 호출하는 메서드가 Login 어노테이션이 있는가?
boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class);
// 파라미터의 타입이 UserInfo 클래스인가?
boolean hasMemberType = UserInfo.class.isAssignableFrom(parameter.getParameterType());
// Login 어노테이션이 있으면서 파라미터가 UserInfo이면 true를 반환 -> resolveArgument 메서드가 실행됨.
return hasLoginAnnotation && hasMemberType;
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
// 요청정보 가져오기
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
// 세션 꺼내기
HttpSession session = request.getSession(false);
if (session == null) {
return null;
}
// 로그인시에 넣어놨던 유저 객체 꺼내기
return session.getAttribute(SessionConst.LOGIN_MEMBER);
}
}
- WebConfig 설정 추가
// config/WebConfig.java
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginCheckInterceptor())
.order(1)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/user/login", "/api/user/signup");
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new LoginUserArgumentResolver());
}
}
- UserController 수정
// user/controller/UserController.java
...
@PostMapping("/test")
public Long test(@Login UserInfo loginUser) {
log.info("test");
log.info("loginUser : {}", loginUser.getId());
return loginUser.getId();
}
- 결론
컨트롤러에 @Login UserInfo loginUser
이런식으로 적으면 요청을 보낸 사용자의 정보가 넘어온다. 나중에 질문, 답변을 만들 때 질문 작성자와 요청을 보낸 사용자가 서로 같은지 체크하고 수정이나 삭제를 하게 만들어주는 식으로 활용할 수 있다.