로그인 처리 V1 - 쿠키 직접 생성
쿠키를 직접 생성하고 값을 HttpServletResponse를 통해 저장하는 방식을 사용하였다.
LoginControllerV1 - login에 적용
로그인 성공 시 쿠키를 생성하고 HttpServletResponse에 담는다.
@PostMapping("/login")
public String login(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletResponse response) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(form.getLoginId(),form.getPassword()); // 로그인
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
//로그인 성공시
//쿠키에 시간 정보를 주지 않으면 세션 쿠키(브라우저 종료시 모두 종료)
Cookie idCookie = new Cookie("memberId",String.valueOf(loginMember.getId()));
response.addCookie(idCookie);
return "redirect:/";
}
- new Cookie() : 쿠키 이름은 meberId로 id 값을 갖는 쿠키를 생성한다.
LoginControllerV1 - logout에 적용
@PostMapping("/logout")
public String logout(HttpServletResponse response) {
Cookie cookie = new Cookie("memberId", null);
cookie.setMaxAge(0);
response.addCookie(cookie);
return "redirect:/";
}
- memberId라는 쿠키를 재생성하고 setMaxAge를 통해 쿠키의 종료 날짜를 0으로 지정하여 쿠키를 종료시킨다.
HomeControllerV1 - homeLogin에 적용
@CookieValue로 쿠키를 memberId로 받고 값이 있으면 모델에 넣어 반환시킨다.
@GetMapping("/")
public String homeLogin(
@CookieValue(name = "memberId", required = false) Long memberId, Model model) {
if (memberId == null) {
return "home";
}
//로그인
Member loginMember = memberRepository.findById(memberId);
if (loginMember == null) {
return "home";
}
model.addAttribute("member", loginMember);
return "loginHome";
}
- @CookieValue : 쿠키 조회 애노테이션
- 로그인하지 않은 사용자도 홈 화면에 접근하기 때문에 required 는 false로 지정해야 한다.
로그인 처리 V2 - 세션 동작 방식
로그인 처리 V1 처럼 개발을 하면 로그인 정보를 유지할 수 있다. 하지만 여기에는 심각한 보안 문제가 발생한다.
- 쿠키 값은 임의로 변경할 수 있기 때문에 강제 변경 시 문제가 발생할 수 있다.
- 쿠키에 보관된 정보는 해킹에 대처할 수 없다.
- 쿠키를 한번 해킹하면 그 정보는 평생 사용할 수 있다.
이런 보안 문제들을 해결하기 위해서는 쿠키에 임의의 토큰값을 저장시키고 서버에서 토근과 사용자 id를 매핑시켜서 인식해야한다. 또한 토큰을 해킹해가도 시간이 지나면 사용할 수없도록 토큰의 만료시간을 짧게 유지해야한다.
세션 id를 쿠키로 전달할때
로그인 시 서버에서 임의의 토큰 값을 생성하여 세션 저장소에 저장하고 MySessionId 라는 이름으로 세션 id만 쿠키에 담아서 클라이언트에 전달한다.
로그인 이후 접근
클라이언트는 요청시 항상 MySessionId 쿠키를 전달한다. 서버에서는 해당 쿠키 정보로 세션 저장소를 조회한다.
이처럼 세션을 사용하여 서버에서 중요한 정보를 관리하게 되었고 이를 통해 많은 보안문제를 해결할 수 있다.
SessionManager 생성
UUID를 통해 임의의 토큰 값을 생성하고 sessionStore라는 세션 저장소에 세션을 저장하였다.
@Component
public class SessionManager {
public static final String SESSION_COOKIE_NAME = "mySessionId";
private Map<String, Object> sessionStore = new ConcurrentHashMap<>();
/* 세션 생성*/
public void createSession(Object value, HttpServletResponse response) {
//세션 id를 생성하고, 값을 세션에 저장
String sessionId = UUID.randomUUID().toString();
sessionStore.put(sessionId, value);
//쿠키 생성
Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
response.addCookie(mySessionCookie);
}
/*세션 조회 */
public Object getSession(HttpServletRequest request) {
Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
if (sessionCookie == null) { return null;}
return sessionStore.get(sessionCookie.getValue());
}
/*세션 만료*/
public void expire(HttpServletRequest request) {
Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
if (sessionCookie != null) { sessionStore.remove(sessionCookie.getValue());}
}
private Cookie findCookie(HttpServletRequest request, String cookieName) {
if (request.getCookies() == null) { return null;}
return Arrays.stream(request.getCookies())
.filter(cookie -> cookie.getName().equals(cookieName))
.findAny()
.orElse(null);
}
}
LoginControllerV2 - login에 적용
로그인 성공 시 쿠키를 생성하고 HttpServletResponse에 담는다.
@PostMapping("/login")
public String login(@Valid @ModelAttribute LoginForm form, BindingResult
bindingResult, HttpServletResponse response) {
// ... 동일
//로그인 성공시
//쿠키에 시간 정보를 주지 않으면 세션 쿠키(브라우저 종료시 모두 종료)
sessionManager.createSession(loginMember, response);
return "redirect:/";
}
- private final SessionManager 주입
LoginControllerV2 - logout에 적용
@PostMapping("/logout")
public String logout(HttpServletResquest resquest) {
sessionManager.expire(request)
return "redirect:/";
}
HomeControllerV2 - homeLogin에 적용
@CookieValue로 쿠키를 memberId로 받고 값이 있으면 모델에 넣어 반환시킨다.
@GetMapping("/")
public String homeLogin(HttpServletRequest request, Model model) {
//세션 관리자에 저장된 회원 정보 조회
Member member = (Member)sessionManager.getSession(request);
if (memberId == null) {
return "home";
}
//로그인
model.addAttribute("member", loginMember);
return "loginHome";
}
로그인 처리 V3 - HttpSession
로그인 처리 V2 처럼 로직을 작성하여 세션 기능을 사용하였다. 하지만 이렇게 코드를 작성하면 상당히 불편할 것이다. 그래서 서블릿에서 HttpSession 이라는 세션 기능을 제공해준다. 동작 방식은 V2 에서 작성한 SessionManager과 매우 유사하다.
LoginControllerV3 - login에 적용
HttpServletRequest를 통해 HttpSession 기능을 매우 간단하게 사용할 수 있다. loginMember 라는 이름으로 회원 정보를 보관한다.
@PostMapping("/login")
public String login(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult,HttpServletRequest request) {
// ... 동일
//로그인 성공 처리
//세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성
HttpSession session = request.getSession();
//세션에 로그인 회원 정보 보관
session.setAttribute("loginMember", loginMember)
return "redirect:/";
}
- getSession() - 기본값은 request.getSession(true) 이다.
- true : 세션이 있으면 기존 세션 반환, 없으면 새로운 세션 생성하여 반환한다.
- false : 세션이 있으면 기존 세션 반환, 없으면 null을 반환한다.
LoginControllerV3 - logout에 적용
@PostMapping("/logout")
public String logout(HttpServletResquest resquest) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
return "redirect:/";
}
HomeControllerV3 - homeLogin에 적용
getSession()은 기본적으로 세션을 생성하기 때문에 세션을 찾아야 하는 시점에서는 getSession에 false를 입력해야 한다.
@GetMapping("/")
public String homeLogin(HttpServletRequest request, Model model) {
//세션이 없으면 home
HttpSession session = request.getSession(false);
if (session == null) {
return "home";
}
Member loginMember = (Member) session.getAttribute("loginMember");
//세션에 회원 데이터가 없으면 home
if (loginMember == null) {
return "home";
}
//세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
HttpSession
세션이 제공하는 다양한 기능을 설정하고 조회할 수 있다.
- sessinId : 세션 id
- maxInactiveInterval : 세션의 유효 시간
- creationTime : 세션 생성일시
- lastAccessedTime : 세션과 연결된 사용자가 최근에 서버에 접근한 시간
세션 타임 아웃 설정
서버 입장에서는 사용자가 언제 웹 브라우저를 종료하였는지 알 수 없다. 그래서 세션을 종료하는 시점은 사용자가 접속한 시간 lastAccessedTime 을 기준으로 30분 정도 유지시켜주는 것이 좋은 방법이다.
세션 타임 아웃 글로벌 설정은 application.properties에서 할 수 있다. 글로벌 설정 시 분 단위로 설정해야 하며 단위는 초단위이다. 기본 값은 1800(30분) 이다.
server.servlet.session.timeout=60
로그인 처리 V4 - @SessionAttribute
스프링에서는 세션을 매우 편리하게 사용할 수있도록 @SessionAttribute 기능을 제공한다. 이 기능은 세션을 생성하지는 않는다!
HomeControllerV4 - homeLogin에 적용
loginMember 라는 이름의 세션을 찾아서 반환해준다.
@GetMapping("/")
public String homeLogin(@SessionAttribute(name = "loginMember", required = false) Member loginMember, Model model ,HttpServletRequest request) {
//세션이 없으면 home
HttpSession session = request.getSession(false);
if (session == null) {
return "home";
}
//세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
TrackingMode
로그인 처음 시도시 URL에 세션 정보가 포함된다. 세션 정보를 노출시키지 않으려면 application.properties에 옵션을 추가하면 된다.
server.servlet.session.tracking-modes=cookie
로그인 처리 2 - 서블릿 필터
로그인한 이후 로그인한 사용자만 들어갈 수 있는 페이지가 있다. 이런 페이지를 만들기 위해서는 로그인 여부를 체크하는 로직을 하나하나 작성하여 컨트롤러에 넣으면 되지만 이렇게 하면 관리가 어려울 뿐만아니라 코드가 길어지게 된다.
이러한 불편한 점을 해결하기 위해서 서블릿 필터를 사용한다.
서블릿 필터 흐름
필터 적용시 필터가 호출된 이후 서블릿이 호출된다.
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러
서블릿 필터 제한
필터에서 요청을 확인하고 이후 서블릿 호출 여부를 결정할 수 있다.
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러 //로그인 사용자
HTTP 요청 -> WAS -> 필터(적절하지 않은 요청이라 판단, 서블릿 호출X) //비 로그인 사용자
서블릿 필터 인터페이스 - Filter
필터 인터페이스를 구현하고 등록하면 서블릿 컨테이너가 필터를 싱글톤 객체로 생성하고, 관리한다.
public interface Filter {
public default void init(FilterConfig filterConfig) throws ServletException{}
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException;
public default void destroy() {}
}
- init() : 필터 초기화 메서드
- doFilter() : 고객의 요청이 올 때마다 호출, 다음 필터가 있으면 필터를 호출하고 없으면 서블릿을 호출한다. 만약 해당 로직을 호출하지 않으면 다음 단계로 진행되지 않는다.
- destory() : 필터 종료 메서드
LogFilter - doFilter()
모든 요청을 로그로 남기는 필터 함수
@Slf4j
public class LogFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
String uuid = UUID.randomUUID().toString();
try {
log.info("REQUEST [{}][{}]", uuid, requestURI);
chain.doFilter(request, response);
} catch (Exception e) {
throw e;
} finally {
log.info("RESPONSE [{}][{}]", uuid, requestURI);
}}
}
LoginCheckFilter - doFilter()
로그인 인증 여부를 체크하는 필터 함수로 화이트리스트(whitelist)를 통해 해당 경로를 제외한 모든 경로에 인증 체크 로직을 적용한다.
@Slf4j
public class LoginCheckFilter implements Filter {
private static final String[] whitelist = {"/", "/members/add", "/login", "/ logout","/css/*"};
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
HttpServletResponse httpResponse = (HttpServletResponse) response;
try {
log.info("인증 체크 필터 시작 {}", requestURI);
if (isLoginCheckPath(requestURI)) {
log.info("인증 체크 로직 실행 {}", requestURI);
HttpSession session = httpRequest.getSession(false);
if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
log.info("미인증 사용자 요청 {}", requestURI);
//로그인으로 redirect
httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
return; //여기가 중요, 미인증 사용자는 다음으로 진행하지 않고 끝!
}
}
chain.doFilter(request, response);
} catch (Exception e) {
throw e; //예외 로깅 가능 하지만, 톰캣까지 예외를 보내주어야 함
} finally {
log.info("인증 체크 필터 종료 {}", requestURI);
}
}
/**
* 화이트 리스트의 경우 인증 체크X
*/
private boolean isLoginCheckPath(String requestURI) {
return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
}
}
- httpResponse.sendRedirect(경로) : 미인증 사용자는 로그인 페이지로 가기 때문에 로그인 이후 다시 예전에 있던 페이지로 돌아가기 위해 이전 경로를 리다이렉트 한다.
WebConfig - 필터 설정
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean logFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(1);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
@Bean
public FilterRegistrationBean loginCheckFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LoginCheckFilter());
filterRegistrationBean.setOrder(2);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
}
- FilterRegistrationBean : 필터를 등록하는 클래스
- setFilter() : 등록할 필터를 지정
- setOrder() : 필터 순서를 지정
- addUrlPatterns() : 필터를 적용할 URL을 지정, "/*"은 모든 요청에 필터를 적용한다는 의미
LoginController - login에 RedirectURL 처리
@RequestParam을 통해 값이 있으면 리다이렉트한 URL을 반환하고 없으면 기본 값을 반환한다.
@PostMapping("/login")
public String loginV4(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, @RequestParam(defaultValue = "/") String redirectURL){
// .. 이전과 동일
//로그인 성공 처리
//redirectURL 적용
return "redirect:" + redirectURL;
}
로그인 처리 2 - 스프링 인터셉터
스프링에서는 서블릿 필터와 같이 웹 관련 공통 사항을 효과적으로 처리할 수 있는 스프링 인터셉터라는 기능을 제공한다. 작용되는 순서와 범위, 사용방법이 다르지만 서블릿 필터보다 훨씬 간단하게 공통 사항 처리를 수행할 수 있다.
스프링 인터셉터 흐름
서블릿 필터와 다르게 컨트롤러 호출 직전에 호출된다.
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러
스프링 인터셉터 제한
인터셉터에서 적절하지 않는 요청이라고 판단하면 컨트롤러를 호출하지 않는다.
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러 //로그인 사용자
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터(적절하지 않은 요청이라 판단, 컨트롤러 호출X) // 비 로그인 사용자
스프링 인터셉터 인터페이스 - HandlerInterceptor
서블릿 필터에서는 doFilter() 로 필터기능을 제공했지만 스프링 인터셉터에서는 3가지의 기능으로 세분화 되어있다.
public interface HandlerInterceptor {
default boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {}
default void postHandle(HttpServletRequest request, HttpServletResponse response,
Object handler, @Nullable ModelAndView modelAndView) throws Exception {}
default void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, @Nullable Exception ex) throws Exception {}
}
- preHandle() : 컨트롤러 호출 전
- postHandle() : 컨트롤러 호출 후
- afterCompletion() : 컨트롤러 요청 완료 이후
스프링 인터셉터 호출 흐름
스프링 인터셉터 예외 상황
예외가 발생하면 postHandle()은 호출되지 않는다. 예외와 무관하게 공통 처리를 수행하고 싶으면 afterCompletion()을 사용해야 한다.
LogInterceptor - preHandle()
모든 요청을 로그로 남기는 인터셉터 함수
@Slf4j
public class LogInterceptor implements HandlerInterceptor {
public static final String LOG_ID = "logId";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requestURI = request.getRequestURI();
String uuid = UUID.randomUUID().toString();
request.setAttribute(LOG_ID, uuid);
//@RequestMapping: HandlerMethod
//정적 리소스: ResourceHttpRequestHandler
if (handler instanceof HandlerMethod) {
HandlerMethod hm = (HandlerMethod) handler; //호출할 컨트롤러 메서드의 모든 정보가 포함되어 있다.
}
log.info("REQUEST [{}][{}][{}]", uuid, requestURI, handler);
return true; //false 진행X
}
}
- request.setAttribute() : 스프링 인터셉터는 호출 시점이 완전히 분리되어 있기 때문에 preHandle에서 지정한 값을 postHandle이나 afterCompletion 에 함께 사용하려면 어딘가에 담아두어야 한다.
- retrun true : true는 정상 호출 , false는 진행 중지
LogInterceptor - afterCompletion()
발생한 예외를 로그로 남기는 인터셉터 함수
@Slf4j
public class LogInterceptor implements HandlerInterceptor {
public static final String LOG_ID = "logId";
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
String requestURI = request.getRequestURI();
String logId = (String)request.getAttribute(LOG_ID);
log.info("RESPONSE [{}][{}]", logId, requestURI);
if (ex != null) {
log.error("afterCompletion error!!", ex);
}
}
}
LoginCheckInterceptor - preHandle()
서블릿 필터와 달리 기본적으로 HttpServeltRequest, HttpservletResponse를 제공하기 때문에 코드를 더욱 간결하게 사용할 수 있다.
@Slf4j
public class LoginCheckInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requestURI = request.getRequestURI();
log.info("인증 체크 인터셉터 실행 {}", requestURI);
HttpSession session = request.getSession(false);
if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
log.info("미인증 사용자 요청");
//로그인으로 redirect
response.sendRedirect("/login?redirectURL=" + requestURI);
return false;
}
return true;
}
}
WebConfig - 필터 설정
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/*.ico", "/error");
registry.addInterceptor(new LoginCheckInterceptor())
.order(2)
.addPathPatterns("/**")
.excludePathPatterns(
"/", "/members/add", "/login", "/logout",
"/css/**", "/*.ico", "/error"
);
}
}
- registry.addInterceptor() : 인터셉터를 등록
- order() : 인터셉터 호출 순서 지정
- addPathPatterns() : 인터셉터에 지정할 URL 패턴 지정
- excludePathPatterns() : 화이트 리스트처럼 인터셉터에서 제외할 패턴을 지정
'웹 개발' 카테고리의 다른 글
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 스프링 타입 컨버터 (0) | 2024.06.01 |
---|---|
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 예외 처리 (0) | 2024.05.30 |
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 검증 (0) | 2024.05.30 |
intellij 단축키 (0) | 2024.04.16 |
스프링 DB 1편 - 자바 예외 처리 (0) | 2024.03.04 |