TIL

Login

로그인 처리1 - 쿠키, 세션

로그인 처리하기 - 쿠키 사용

서버에서 로그인에 성공하면 HTTP 응답에 쿠키를 담아서 브라우저에 전달하자. 그러면 브라우저는 앞으로 해당 쿠키를 지속해서 보내준다.

쿠키 생성

쿠키에는 영속 쿠키와 세션 쿠키가 있다.

쿠키 생성 로직

//로그인 성공 처리 
//쿠키에 시간 정보를 주지 않으면 세션 쿠키(브라우저 종료시 모두 종료) 
Cookie idCookie = new Cookie("memberId", String.*valueOf*(loginMember.getId())); 
response.addCookie(idCookie);

로그인에 성공하면 쿠키를 생성하고 HttpServletResponse에 담는다

@GetMapping("/") 
public String home(@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"; 
}

로그인에 성공시 세션 쿠키가 지속해서 유지되고, 웹 브라우저에서 서버에 요청시 memberId 쿠키를 계속 보내준다

쿠키 삭제 로직

private void expireCookie(HttpServletResponse response, String cookieName) {  
                          Cookie cookie = new Cookie(cookieName, null);
	  cookie.setMaxAge(0);  
	  response.addCookie(cookie); 
}

쿠키와 보안 문제

대안

로그인 처리하기 - 세션 동작 방식

서버에 중요한 정보를 보관하고 연결을 유지하는 방법을 세션이라 한다

세션 동작 방식

img.png

img_2.png 클라이언트와 서버는 결국 쿠키로 연결이 되어야 한다.

클라이언트의 세션id 쿠키 전달

정리

세션을 사용해서 서버에서 중요한 정보를 관리하게 되었다. 덕분에 다음과 같은 보안 문제들을 해결할 수 있다.

로그인 처리하기 - 서블릿 HTTP 세션

HttpSession 소개

서블릿을 통해 HttpSession 을 생성하면 다음과 같은 쿠키를 생성한다. 쿠키 이름이 JSESSIONID 이고, 값은 추정 불가능한 랜덤 값이다.

Cookie: JSESSIONID=5B78E23B513F50164D6FDD8C97B0AD05

HttpSession 사용 – 로그인 후 세션 저장

//세션이 있으면 있는 세션 반환, 없으면 신규세션 생성 
HttpSession session = request.getSession(); 
//세션에 로그인 회원 정보 보관 
session.setAttribute(SessionConst.*LOGIN_MEMBER*, loginMember);

세션을 새로 생성하려면 request.getSession(true) 를 사용하면 된다.

하나의 세션에 여러 값을 저장할 수 있다.

HttpSession 사용 – 로그아웃 후 세션 삭제

HttpSession session = request.getSession(false);
if (session != null) {
    session.invalidate();
}

session.invalidate() : 세션을 제거한다.

HttpSession 사용 – 홈 화면에서 세션 유지

@GetMapping("/") 
public String homeV3(HttpServletRequest request, Model model) {   
		HttpSession session = request.getSession(false);   
		if (session == null) {
		   return "home";  
		}
   
		Member loginMember = (Member) session.getAttribute(SessionConst.*LOGIN_MEMBER*);   

		// 세션에 회원 데이터가 없으면home  
		if (loginMember == null) {
		   return "home";  
		}   
		
		//세션이 유지되면 로그인으로 이동  
		model.addAttribute("member", loginMember);  return "loginHome"; 
}

session.getAttribute(SessionConst.LOGIN_MEMBER) : 로그인 시점에 세션에 보관한 회원 객체를찾는다.

@SessionAttribute

스프링은 세션을 더 편리하게 사용할 수 있도록 @SessionAttribute을 지원한다.

@GetMapping("/") 
public String homeV3Spring (
		@SessionAttribute(name = SessionConst.*LOGIN_MEMBER*, required = false) Member loginMember, 
		Model model) {   

		// 세션에 회원 데이터가 없으면 home  
		if (loginMember == null) {   
				return "home";  
		}   
		
        //세션이 유지되면 로그인으로 이동  
		model.addAttribute("member", loginMember);  
		return "loginHome"; 
}

이미 로그인된 사용자를 찾을 때는 다음과 같이 사용하면 된다. 참고로 이 기능은 세션을 생성하지 않는다.

TrackingModes

로그인을 처음 시도하면 URL이 다음과 같이 jsessionid 를 포함하고 있는 것을 확인할 수 있다.

http://localhost:8080/;jsessionid=F59911518B921DF62D09F0DF8F83F872

이것은 웹 브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해서 세션을 유지하는 방법이다

URL 전달 방식을 끄고 항상 쿠키를 통해서만 세션을 유지하고 싶으면 application.properties에 다음 옵션을 넣어주면 된다.

server.servlet.session.tracking-modes=cookie

세션 정보와 타임아웃 설정

세션 정보 확인

세션 타임아웃 설정

남아있는 세션을 무한정 보관하면 다음과 같은 문제가 발생할 수 있다.

세션의 종료 시점을 사용자가 서버에 최근에 요청한 시간을 기준으로 30분 정도를 유지. . HttpSession 은 이 방식을 사용한다.

스프링 부트로 글로벌 설정

application.properties

특정 세션 단위로 시간 설정

로그인 처리2 - 필터, 인터셉터

공통 관심 사항

애플리케이션 여러 로직에서 공통으로 관심이 있는 것을 공통 관심사(cross-cutting concern)라고 한다. 여기서는 등록, 수정, 삭제, 조회 등등 여러 로직에서 공통으로 인증에 대해서 관심을 가지고 있다.

서블릿 필터 - 소개

필터는 서블릿이 지원하는 수문장이다. 필터의 특성은 다음과 같다.

필터 흐름

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러

필터 제한

필터 체인

HTTP 요청 -> WAS -> 필터1 -> 필터2 -> 필터3 -> 서블릿 -> 컨트롤러

필터 인터페이스

필터 인터페이스를 구현하고 등록하면 서블릿 컨테이너가 필터를 싱글톤 객체로 생성하고, 관리한다.

서블릿 필터 - 요청 로그

public class LogFilter implements Filter

doFilter(ServletRequest request, ServletResponse response, FilterChain chain)

chain.doFilter(request, response);

만약 이 로직을 호출하지 않으면 다음 단계로 진행되지 않는다.

WebConfig - 필터 설정

필터를 등록하는 방법은 여러가지가 있지만, 스프링 부트를 사용한다면 FilterRegistrationBean 을 사용해서 등록하면 된다.

서블릿 필터 - 인증 체크

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 {
		if (isLoginCheckPath(requestURI)) {
				HttpSession session = httpRequest.getSession(false);
				if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
				    //로그인으로 redirect
					httpResponse.sendRedirect("/login?redirectURL=" +requestURI);
					return; //여기가 중요, 미인증 사용자는 다음으로 진행하지 않고 끝!
				}
		}
				chain.doFilter(request, response);
		} catch (Exception e) {
				throw e; //예외 로깅 가능 하지만, 톰캣까지 예외를 보내주어야 함
		}
}
/**
화이트 리스트의 경우 인증 체크X
*/
private boolean isLoginCheckPath(String requestURI) {
		return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
}

(로그인 컨틀롤러의 리턴값)

return "redirect:" + redirectURL;

스프링 인터셉터 - 소개

스프링 인터셉터도 서블릿 필터와 같이 웹과 관련된 공통 관심 사항을 효과적으로 해결할 수 있는 기술이다.

서블릿 필터가 서블릿이 제공하는 기술이라면, 스프링 인터셉터는 스프링 MVC가 제공하는 기술이다. 둘 다 웹과 관련된 공통 관심 사항을 처리하지만, 적용되는 순서와 범위, 그리고 사용 방법이 다르다.

스프링 인터셉터 흐름

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러

스프링 인터셉터 제한

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러 //로그인 사용자

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터(적절하지 않은 요청이라 판단, 컨트롤러 호출 X) // 비 로그인 사용자

스프링 인터셉터 체인

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 인터셉터1 -> 인터셉터2 -> 컨트롤러

스프링 인터셉터 인터페이스

스프링의 인터셉터를 사용하려면 HandlerInterceptor 인터페이스를 구현하면 된다.

스프링 인터셉터 호출 흐름

정상 흐름

스프링 인터셉터 예외 상황

예외가 발생시

afterCompletion은 예외가 발생해도 호출된다.

스프링 MVC를 사용하고, 특별히 필터를 꼭 사용해야 하는 상황이 아니라면 인터셉터를 사용하는 것이 더 편리하다

public class LoginInterceptor implements HandlerInterceptor {

		@Override 
		public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {   }
		@Override 
		public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {  } 
		@Override 
		public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { } 
}

HandlerMethod

if (handler instanceof HandlerMethod) { 
		//호출할 컨트롤러 메서드의 모든 정보가 포함되어 있다  
		HandlerMethod hm = (HandlerMethod) handler; 
}

핸들러 정보는 어떤 핸들러 매핑을 사용하는가에 따라 달라진다. 스프링을 사용하면 일반적으로 @Controller, @RequestMapping 을 활용한 핸들러 매핑을 사용하는데, 이 경우 핸들러 정보로 HandlerMethod가 넘어온다.

ResourceHttpRequestHandler

@Controller가 아니라 /resources/static와 같은 정적 리소스가 호출 되는 경우 ResourceHttpRequestHandler가 핸들러 정보로 넘어오기 때문에 타입에 따라서 처리가 필요하다.

postHandle, afterCompletion

종료 로그를 postHandle이 아니라 afterCompletion에서 실행한 이유는, 예외가 발생한 경우 postHandle가 호출되지 않기 때문이다. afterCompletion은 예외가 발생해도 호출 되는 것을 보장한다.

WebConfig - 인터셉터 등록

@Configuration 
public class WebConfig implements WebMvcConfigurer {  
		@Override 
		public void addInterceptors(InterceptorRegistry registry) {
		    registry.addInterceptor(new LoginInterceptor())
                        .order(1)
                        .addPathPatterns("/**")
                        .excludePathPatterns("/css/**", "/*.ico", "/error");
        }
}

WebMvcConfigurer가 제공하는 addInterceptors()를 사용해서 인터셉터를 등록할 수 있다.

스프링 인터셉터 - 인증 체크

public class LoginCheckInterceptor implements HandlerInterceptor {  
		@Override 
		public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {   
				String requestURI = request.getRequestURI();

				HttpSession session = request.getSession();   

				if (session == null || session.getAttribute(SessionConst.*LOGIN_MEMBER*) == null) { 
						//로그인으로redirect   
						response.sendRedirect("/login?redirectURL=" + requestURI);   
						return false;   
				}  
				return true; 
		} 
}

인증이라는 것은 컨트롤러 호출 전에만 호출되면 된다.

따라서 preHandle만 구현하면 된다.

인터셉터 등록 - 순서 주의, 세밀한 설정 가능

@Override 
public void addInterceptors(InterceptorRegistry registry) {  
		registry.addInterceptor(new LoginInterceptor()) 
						.order(1)   
						.addPathPatterns("/**")   
						.excludePathPatterns("/css/**", "/*.ico", "/error");  
 
		registry.addInterceptor(new LoginCheckInterceptor())   
						.order(2)   
						.addPathPatterns("/**")   
						.excludePathPatterns("/", "/members/add", "/login", "/logout", "/css/**", "/*.ico", "/error"); 
}

ArgumentResolver 활용

@GetMapping("/") 
public String homeV3ArgumentResolver(@Login Member loginMember, Model model) {   
		// 세션에 회원 데이터가 없으면home  
		if (loginMember == null) {   
				return "home";  
		}   

		//세션이 유지되면 로그인으로 이동  
		model.addAttribute("member", loginMember);  
		return "loginHome"; 
}

@Login 애노테이션이 있으면 직접 만든 ArgumentResolver가 동작해서 자동으로 세션에 있는 로그인 회원을 찾아주고, 만약 세션에 없다면 null을 반환

@Login 애노테이션 생성

@Target(ElementType.*PARAMETER*) 
@Retention(RetentionPolicy.*RUNTIME*) 
public @interface Login { }

LoginMemberArgumentResolver 생성

@Slf4j 
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {  

@Override 
public boolean supportsParameter(MethodParameter parameter) {  
		return parameter.hasParameterAnnotation(Login.class) 
                    && Member.class.isAssignableFrom(parameter.getParameterType()); 
}  

@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*); 
}

WebMvcConfigurer에 설정 추가

@Configuration 
public class WebConfig implements WebMvcConfigurer {  
 
		@Override 
		public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {  
				resolvers.add(new LoginMemberArgumentResolver()); 
		}