목차
Previous
대부분의 웹/앱 서비스에서 로그인 기능은 당연하게 들어가는 기능이다.
근데 이러한 로그인 기능에는 고려할 부분이 생각보다 많다. 로그인 페이지에서 아이디와 비밀번호를 서버로 전송했을 때, 서버에서 로그인을 처리하는 로직의 위치도 파악해야 하고, 이렇게 한 번 로그인 한 뒤에는 이 로그인 상태가 유지가되야 한다. 그런데 어떻게 로그인 상태를 유지해야할까?
보통 이러한 로그인 상태는 쿠키 혹은 세션으로 관리를 하며 여기에 해당 키의 유효시간 관리를 통해 일정시간만 유지되도록도 할 수 있다.
스프링에서는 스프링 시큐리티라는 프레임워크로 로그인, 계층화, 리멤버미까지 다양한 기능을 제공하지만, 결국 이러한 스프링 시큐리티도 쿠키, 세션을 통해 관리하는 것이고 여러 리졸버를 이용한다.
그래서 이번 포스팅에서는 쿠키와 세션을 통해 로그인을 처리하는 과정과 이를 처리하기 위해 필터와 인터셉터에 대해 알아본다.
쿠키를 사용한 로그인 처리
서버에서 로그인 성공 시 쿠키를 담아 브라우저에 전달하면 브라우저는 해당 쿠키를 저장해두고
해당 사이트에 접속할 때마다 지속해서 해당하는 쿠키를 보내준다.
로그인시 쿠키 생성
클라이언트에서 쿠키 전달
쿠키의 종류
사용자는 상황에따라 입맛에 맞게끔 쿠키의 생명주기를 설정해 사용할 수 있다.
•
영속 쿠키: 만료 날짜를 입력하면 해당 날짜까지 유지
•
세션 쿠키: 만료 날짜를 생략하면 브라우저 종료시 까지만 유지
스프링부트에서 쿠키 핸들링
이제 스프링부트를 이용해 서버에서 쿠키를 생성 및 조회를 해보자.
java.servlet.http에는 Cookie라는 클래스를 제공해주는데 이 클래스를 이용해 클라이언트에 응답할 쿠키정보를 쉽게 핸들링할 수 있다.
서버에서 쿠키 생성하기 - Version 1
@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:/";
}
Java
복사
•
new Cookie("memberId", String.valueOf(loginMember.getId()));
: Cookie 라는 클래스 생성자로 key/value 를 인수로 넘겨주어 생성한다.
•
response.addCookie(idCookie);
: 생성된 쿠키(idCookie)를 서버 응답 객체(HttpServletResponse) 에 addCookie를 이용해 담아준다. 그럼 실제로 웹 브라우저에서는 Set-Cookie 프로퍼티에 쿠키정보가 담겨져 반환된다.
서버에서 쿠키 조회하기 - Version 1
@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";
}
Java
복사
•
@CookieValue(name = "memberId", required = false) Long memberId
: 쿠키를 편하게 조회할 수 있도록 도와주는 애노테이션이다. 전송된 쿠키정보중 key가 memberId인 쿠키값을 찾아 memberId 변수에 할당해준다.
required가 false이기에 쿠키정보가 없는 비회원도 접근 가능하다.
서버에서 쿠키 없애기(로그아웃)
로그인을 했으면 로그아웃도 있어야 한다. 근데 쿠키값이 있어서 자동으로 로그인이 되는데 어떻게 로그아웃을 해야할까? 로그아웃 기능은 쿠키를 삭제하는게 아니라 종료 날짜를 0으로 줘서 바로 만료시킴으로써 삭제할 수 있다.
@PostMapping("/logout")
public String logout(HttpServletResponse response) {
expiredCookie(response, "memberId");
return "redirect:/";
}
private void expiredCookie(HttpServletResponse response, String cookieName) {
Cookie cookie = new Cookie(cookieName, null);
cookie.setMaxAge(0);
response.addCookie(cookie);
}
Java
복사
•
응답 쿠키의 정보를 보면 Max-Age=0으로 되어있어 해당 쿠키는 즉시 종료된다.
Version 1의 문제점
위 코드는 정상적으로 동작하지만 다음과 같은 보안 문제점을 가지고 있다.
•
쿠키 값을 임의대로 변경할 수 있다.
•
쿠키에 보관된 정보(memberId) 를 타인이 훔쳐갈 수 있다.
•
한 번 도용된 쿠키정보는 계속 악용될 수 있다.
Version 1 의 문제점을 정리해보면 결국 중요한 개인정보들이 클라이언트에 저장되어있기 때문에 위변조 및 도용이 쉽다는 문제가 있다는 것이다. 그럼 문제점을 해결하기 위해서는 이런 중요 정보들은 클라이언트가 아니라 서버에서 관리하도록 하고 그게 외부로 노출되지 않도록 해야 한다는 점이다. 그래서 클라이언트는 서버가 보관하고있는 중요 정보에 접근할 수 있는 키만 가지고 있고, 이 키 또한 유효시간을 짧게 둬서 갱신되도록 하면 보안적으로 많이 안전해질 것이다. 그리고 이렇게 중요한 정보를 보관 및 연결 유지 방법을 세션이라 한다.
세션을 통한 로그인 처리
세션으로 동작하는 로그인
위에서 했던 내용을 요약하면 중요 정보는 서버의 세션 저장소에 key/value로 저장한 뒤 브라우저에서는 key값만 가지고 있도록 하는 것이다. 이 개념을 그림으로 표현하면 다음과 같다.
세션 기반 로그인 과정
이제 다음 로그인이나 페이지 접근시 쿠키에선 저장하고 있는 sessionId를 같이 전달하면 서버의 세션저장소에서는 해당sessionId를 key로 가지고 있는 value값을 조회해서 로그인 여부와 중요 정보를 확인한다.
결국, 클라이언트와 서버는 쿠키로 연결되어 있지만 중요한점은 다음과 같다.
•
회원과 관련된 정보는 클라이언트에서 가지고 있지 않다.
•
추정 불가능한 세션 아이디만 쿠키를 통해 주고받기에 보안에서 많이 안전해졌다.
여기서 더하여 세션아이디가 저장된 쿠키의 만료시간을 짧게 유지한다면, 해커가 해당 키를 도용한다 하더라도 금새 갱신되며 사용하지 못하게 되어 보안적으로 좀 더 안전해질 수 있다.
세션 관리 기능
이러한 세션은 다음과 같이 크게 3가지 기능을 제공해야 한다.
•
세션 생성
◦
세션 키는 중복이 안되며 추정 불가능한 랜덤 값이어야 한다.
◦
세션 키에 매칭될 값(value)가 있어야 한다.
◦
이렇게 생성된 세션 키를 응답 쿠키에 저장해 클라이언트에 전달해야 한다.
•
세션 조회
◦
클라이언트가 요청한 세션아이디 쿠키 값으로 세션 저장소에 저장된 값을 조회할 수 있어야 한다.
•
세션 만료
◦
클라이언트가 요청한 세션아이디 쿠키 값으로 세션 저장소에 보관한 세션 엔트리를 제거해야 한다.
세션관리 기능 코드
:세션 관리기능은 SessionManager라는 컴포넌트를 만들어 작성한다.
•
세션 생성
@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) {
//세션 생성
String sessionId = UUID.randomUUID().toString();
sessionStore.put(sessionId, value);
//쿠키 생성 후 저장
Cookie cookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
response.addCookie(cookie);
}
public Object getSession(HttpServletRequest request) {
Cookie cookie = findCookie(request, SESSION_COOKIE_NAME);
if (cookie == null) {
return null;
}
return sessionStore.get(cookie.getValue());
}
public void expire(HttpServletRequest request) {
Cookie cookie = findCookie(request, SESSION_COOKIE_NAME);
if (cookie != null) {
sessionStore.remove(cookie.getValue());
}
}
public Cookie findCookie(HttpServletRequest request, String cookieName) {
if (request.getCookies() == null) {
return null;
}
return Arrays.stream(request.getCookies())
.filter(c -> c.getName().equals(cookieName))
.findAny()
.orElse(null);
}
}
Java
복사
◦
@Component 애노테이션을 붙혀 스프링 빈으로 자동 등록한다.
이렇게 잘 만든 SessionManager 스프링 빈을 이용해 위에서 기존에 작성한 컨트롤러를 변경해보자.
•
로그인(세션 생성)
@PostMapping("login")
public String loginV2(@Valid @ModelAttribute LoginForm form,
BindingResult bindingResult,
HttpServletResponse response) {
//...기존 코드와 동일
//세션 매니저를 통해 세션 생성및 회원정보 보관
sessionManager.createSession(loginMember, response);
return "redirect:/";
}
Java
복사
◦
로그인 성공시 세션을 등록하고 이때 생성된 세션아이디를 쿠키로 발행해 저장한다.
•
로그아웃(세션 만료)
@PostMapping("/logout")
public String logoutV2(HttpServletResponse response, HttpServletRequest request) {
sessionManager.expire(request);
return "redirect:/";
}
Java
복사
•
홈 화면 이동(세션 조회)
@GetMapping("/")
public String homeLoginV2(HttpServletRequest request, Model model) {
Member member = (Member) sessionManager.getSession(request);
if (member == null) {
return "home";
}
model.addAttribute("member", member);
return "loginHome";
}
Java
복사
정리
•
쿠키를 가지고 로그인 상태를 관리하는것은 코드도 복잡해지고 보안상 취약하다.
•
중요한 정보는 클라이언트에 노출시켜선 안되고 서버에서 관리해야 한다.
•
중요 정보는 서버에서 관리하며 정보에 접근할 추리불가능하고 중복이 되지않는 키를 이용해 서버와 클라이언트를 연결하도록 하며 이를 세션이라 한다.
서블릿에서는 세션매니저 역할(HttpSession)객체를 제공하고 있다.
우리가 만들었던 SessionManager의 역할을 하는 객체를 서블릿에서는 HttpServlet 클래스를 통해 제공하고 있다. 즉, HttpSession을 이용하면 우리는 세션 생성, 조회, 삭제를 편하게 사용할 수 있고 추적 불가능한 키를 가진 쿠키를 생성할 수 있다. 이 때 쿠키 이름은 JSESSIONID이며 HttpOnly이기에 클라이언트에서 조작할 수 없다.
서블릿 HttpSession을 이용한 로그인 처리
쿠키만을 이용해서, 그리고 직접 세션 매니저를 만들면서 로그인 상태 관리를 구현해봤다.
하지만, 이런 로그인 상태 관리에 대한 고민은 이미 다른 선배 개발자들이 했던 내용이고 그렇기에 이미 우리가 원하는 기능을 제공하는 객체가 있고 서블릿에서는 세션 관리를 위해 HttpSession이라는 객체를 제공한다.
이 HttpSession은 지금까지 나온 문제들을 해결해주며, 우리가 구현한 것보다 더 잘 구현되어 있다.
HttpSession을 사용하는 코드
•
세션 조회용 상수
public interface SessionConst {
String LOGIN_MEMBER = "loginMember";
}
Java
복사
•
로그인 컨트롤러
@PostMapping("login")
public String loginV3(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletResponse response, HttpServletRequest request) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
//세션 매니저를 통해 세션 생성및 회원정보 보관
//세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성
HttpSession session = request.getSession();
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:/";
}
@PostMapping("/logout")
public String logoutV3(HttpServletResponse response, HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
return "redirect:/";
}
Java
복사
◦
request.getSession()
: getSession 메서드는 세션을 생성 혹은 조회하는 메서드이다.
public HttpSession getSession(boolean create); //default true
여기서 create 옵션의 의미는 다음과 같다.
•
true일 경우
◦
세션이 있으면 기존 세션을 반환한다.
◦
세션이 없으면 새로운 세션을 생성해 반환한다.
•
false일 경우
◦
세션이 있으면 기존 세션을 반환한다.
◦
세션이 없으면 새로운 세션을 생성하지 않고 null을 반환한다.
추가적으로 인수를 전달하지 않을 경우 기본 값으로 true이다.
◦
session.invalidate();
: 세션을 제거하는 메서드다.
@SessionAttribute 애노테이션 활용
스프링은 세션을 더 편리하게 사용할 수 있도록 @SessionAttribute라는 애노테이션을 제공한다.
@GetMapping("/")
public String homeLoginV3Spring(
@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false)Member loginMember,
HttpServletRequest request, Model model) {
if (loginMember == null) {
return "home";
}
model.addAttribute("member", loginMember);
return "loginHome";
}
Java
복사
•
@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false)
:이전에 사용한 @CookieValue와 비슷하다. 클라이언트로부터 전달받은 내용의 세션중에서 key가 일치하는게 있는지 찾는다. required가 false이니 만약 못찾으면 null이 할당될 것이다..
TrackingModes
위에서 소개한 방법들로 코드를 작성해 동작해보면 최초 로그인시 브라우저 URL 입력창이 다음과 같은 형식일 것이다.
http://localhost:8080/;jsessionid=F5511518B921DF6209l.......
이는 웹 브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해 세션을 유지하는 방법인데, 이를 없애기 위해서는 스프링 설정 파일(application.properties)에 다음과 같은 설정을 추가해주면 된다.
server.servlet.session.tracking-modes=cookie
YAML
복사
HttpSession에서 제공하는 정보
HttpSession에서는 많은 세션정보를 제공하는데 다음과 같다.
public void printSessionInfo(HttpServletRequest request, String sessionId){
HttpSession session = request.getSession(false);
log.info("sessionId={}", session.getId());
log.info("getMaxInactiveInterval={}", session.getMaxInactiveInterval());
log.info("creationTime={}", new Date(session.getCreationTime()));
log.info("lastAccessedTime={}", new Date(session.getLastAccessedTime()));
log.info("isNew={}", session.isNew());
}
Java
복사
•
sessionId : 세션 아이디(JSESSIONID)의 값(ex:754BE5D4DD969894D958AC278370D06E)
•
maxInactiveInterval : 세션의 유효 시간(ex: 1800초, (30분))
•
creationTime: 세션 생성일시
•
lastAccessedTime : 세션과 연결된 사용자가 최근에 서버에 접속한 시간.
(클라이언트에서 서버로 sessionId(JSESSIONID)를 요청한 경우 갱신된다.)
•
isNew : 새로 생성된 세션인지, 아니면 이미 과거에 만들어졌고, 클라이언트에서 서버로 sessionId(JSESSIONID)를 요청해서 조회된 세션인지 여부
세션 타임아웃 설정하기
대부분의 사용자는 직접 명시적으로 로그아웃 버튼을 누르지 않는다.
그냥 웹 브라우저를 종료할 뿐인데 HTTP는 비연결성(ConnectionLess)이기에 서버측에선 클라이언트가 웹 브라우저를 종료했는지를 알 수 없다. 그렇기에 세션을 언제 삭제해야할지 판단하기 어렵다.
그렇다고 세션을 무한정 유지되도록 한다면, 여러가지 문제가 발생할 수 있다.
•
JSESSIONID를 탈취당한 경우 시간이 흘러도 해당 쿠키로 악용될 수 있다.
•
세션은 기본적으로 메모리에 생성되는데 메모리의 크기가 무한하지 않기에 사용하지 않는 세션이 관리되지 않으면 성능저하가 필연적이고 OutOfMemoryException이 발생할 수 있다.
이러한 이유로 세션에는 타임아웃이 되어야하는데, 종료 시점은 어떻게 설정하는게 좋을까?
너무 빠르면 로그인 유지가 무관하게 계속 로그인을 해야한다. 그렇다고 너무 길게 잡으면 위에 말한 문제 문제가 발생한다. 기본적으로는 세션 생성 시점으로부터 30분 정도를 잡고는 한다.
하지만, 여기서 문제가 한 가지 더 있다. 종료 시점을 30분으로 둔다고하면 사용자가 30분간 활동하다가 다시 로그인을 해야하는 것일까? 이보다는 사용자가 가장 최근 요청한 시간을 기준으로 30분 정도를 유지하는 것이다.
HttpSession은 기본적으로 이 방식을 사용하는데 기획에따라 이 설정을 변경할수도 있다.
스프링 부트에서는 application.properties에 글로벌 설정을 해 줄 수 있다.
session.setMaxInactiveInterval(1800);//1800초
Java
복사
이렇게 1800초(30분)으로 설정을 해두면 LastAccessTime 이후 timeout 시간이 지나면 WAS 내부에서 해당 세션을 삭제한다.
정리
쿠키만을 사용해서, 직접 세션관리 스프링 빈을 만들어서, 그리고 서블릿에서 제공하는 HttpSession 객체를 이용해서 로그인 상태 유지및 관리를 해보았다.
첫 포스팅 당시에는 그냥 결국 어떻게 쓰이는지에 대해서만 포스팅을 하고싶었고 그러려고 했다.
강의에서는 모든걸 알려줬지만, 블로그에 굳이 다 써야할 이유를 잘 모르겠었기 때문이다. 하지만, 포스팅을 위해 복습을 하면서 강의자료를 다시 보니 HttpSession이라는 결과점에 도달하기 위해서는 그 과정을 알아야 하겠다는 생각을 할 수밖에 없었고, 결과만 포스팅을 해서는 이해없는 사용일 뿐이고, 진정 이 객체가 왜 유용한지도 모른채 그냥 블로그에서 쓰니까 나도 써야지. 정도로 끝날수 있다는 생각이 들었다.
다음장에서는 필터와 인터셉터를 이용해서 로그인 처리를 묶어서 공통관심사를 처리해버린다.
그럼 이제 각 컨트롤러마다 로그인 여부를 검사하고 리다이렉트해줄 필요가 사라지는 것이다. 학습용 토이프로젝트는 기껏해야 1~3개의 컨트롤러와 서비스로 동작하기에 이런 처리가 크게 문제가 되지 않는다.
하지만, 컨트롤러가 수십 수백개가 넘는다면 어떨까?
힘들게 다 만들었지만 기획변경으로 인해 로그인 로직이나 로그인관련 기능이 바뀌어야해서 모든 컨트롤러의 로그인 검증 로직을 수정해야한다면 어떨까? 생각만해도 눈앞이 아찔해진다. 그래서 아찔해지지 않기 위해 다음 챕터를 학습하고 공통 관심사 처리를 해주도록 하자.