0. 개요
대부분의 사이트들은 회원가입과 로그인 기능이 있다. 이때 로그인을 할 경우, 메인 페이지에 환영합니다 ooo님이라는 메시지가 뜨는 것을 확인 가능하다. 그런데, 웹사이트 입장에서는 어떤 사용자가 로그인을 하였는지 어떻게 알 수 있을까? HTTP Stateless 프로토콜에 의해 서버는 이전 요청을 기억하지 못하는데 말이다. 이번 게시물에서는 쿠키와 세션을 활용하여 이 로그인 기능을 구현해 볼 것이다.
1. 로그인 성공 시 쿠키 생성
우선, 우리는 로그인에 성공할 경우 로그인이 된 상태를 유지해야 한다. 따라서, 쿠키를 사용해보자. 순서는 다음과 같다.
- 로그인 성공 시, HTTP 응답에 쿠키를 담아서 브라우저에 전달
- 브라우저는 앞으로 모든 요청에 해당 쿠키를 지속해서 보냄
- 서버에서는 해당 쿠키 정보를 통해 사용자 식별이 가능해짐
쿠키에는 영속 쿠키과 세션 쿠키가 있다. 만료 날짜를 입력하고 해당 날짜까지 유지되는 것을 영속 쿠키, 만료 날짜를 생략하여 브라우저 종료시 까지만 유지되는 세션 쿠키가 있으며, 우리는 세션 쿠키를 활용해 볼 것이다.
@Controller
@RequiredArgsController
public class LoginController {
private final LoginService loginService;
@GetMapping("/login")
public String loginForm(@ModelAttribute LoginForm form) {
return "login/loginForm";
}
@PostMapping("/login")
public String login(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletResponse response) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
//id, pw를 받아 유저가 있을 경우 객체 반환
//없을 경우 null 반환
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:/";
}
}
위 코드를 보면, 로그인 양식에서 보낸 정보에 문제점이 없고, 실제로 있는 사용자일 경우 쿠키를 생성하고 HttpServletResponse에 담는다. 이 경우, 브라우저는 종료 전까지 해당 회원의 id 정보를 계속 보내줄 것이다!
그러면 로그인 시 쿠키를 저장하였으므로, 홈 화면에서 사용자를 인식할 수 있게 만들어보자.
@Controller
@RequiredArgsController
public class HomeController {
private final MemberRepository memberRepository;
@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를 사용한다. 로직을 살펴보면, 로그인 쿠키가 없는 경우와 있지만, 회원이 없으면 모두 "home"화면으로 보낸다. 반면에 로그인에 성공한 경우, 회원 관련 정보를 출력하기 위해 member데이터를 모델에 담은 후 "loginHome"화면으로 보낸다.
실행해보면 실제로 잘 동작한다! 이번에는 로그아웃 기능도 만들어 보자.
@PostMapping("/logout")
public String logout(HttpServletResponse response) {
expireCookie(response, "memberId");
return "redirect:/";
}
private void expireCookie(HttpServletResponse response, String cookieName) {
Cookie cookie = new Cookie(cookieName, null);
cookie.setAge(0);
response.addCookie(cookie);
}
해당 쿠키의 종료 날짜를 0으로 지정하므로서, 브라우저가 더이상 해당 쿠키를 전달하지 않도록 만든다.
보안 문제
코드는 잘 작동하였으나, 사실 심각한 보안 문제가 있다.
- 쿠키 값은 브라우저에서 변경이 가능하다
클라이언트가 쿠키를 강제로 변경하면, 다른 사용자가 된다. 그런데 쿠키 이름이 "memberId", 값은 "1"이라면? 너무나도 쉽게 규칙을 이해하고, 다른 사용자인 척 데이터를 2로 조작해 접속할 수 있다.
- 쿠키에 보관된 정보는 쉽게 훔쳐질 수 있다
만일 쿠키에 중요한 개인정보가 있다면? 쿠키 정보는 pc에서 유출될 수도 있으며, 네트워크 전송 구간에서 유출 될 수도 있다. 따라서 해커가 쿠키를 한번 훔쳐가면, 평생 사용할 수도 있다....
그럼 어떻게 하지?
- 쿠키에 사용자가 예측이 불가능한 렌덤 값을 노출하고, 서버에서 해당 값과 사용자 id를 매핑해서 인식하자.
- 쿠키가 유출되어도 다른 해커가 사용할 수 없도록 토큰의 만료시간을 짧게 유지하자.
2. 쿠키와 세션
위에서 우리는 여러 보안이슈에 대한 해결방법을 찾았다.
- 중요한 정보는 서버에 저장한다.
- 임의의 값을 생성, 정보와 매핑한다.
- 브라우저에는 임의의 값만 알려주고, 서버에서 인식한다.
로그인
로그인 시, 세션 저장소에 임의의 세션 Id를 만들어 보관할 값과 함께 보관한다.
세션 id를 쿠키로 전달
그 후, 이전과 다르게 세션 id를 쿠키에 담아 전달한다. 여기서 포인트는 회원과 관련된 정보는 클라이언트에 전혀 전달하지 않았다! 이렇게 되면 쿠키 값 변조 문제, 쿠키 값 탈취 문제들을 해결할 수 있다. 한번 직접 세션을 만들어 해당 기능을 구현해보자.
3. 세션 직접 만들어보기
@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);
}
}
세션을 직접 개발해서 사용해보자. 크게 세션 생성, 세션 조회, 세션 만료 3가지 기능으로 나누어져 있으며, 위에서 정리한 바와 같이 쿠키에 임의의 렌덤 값을 넣어서 전달하고 있음을 알 수 있다. UUID.randomUUID().toString()를 활용하면, 추정 불가능한 렌덤 값을 얻을 수 있다.
이제 직접 만든 세션을 활용하여 실제 기능에 접목시켜 보자.
LoginController
@PostMapping("/login")
public String loginV2(@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";
}
//로그인 성공
//직접 구현한 세션 관리자를 통해 세션 생성, 데이터 보관
sessionManager.createSession(loginMember, response);
return "redirect:/";
}
LoginController의 필드값으로 private final SessionManager sessionManager를 주입받고, 해당 코드를 사용해야 한다. 이를 통해 로그인 성공시 세션에 loginMember를 저장해두고, 쿠키도 HttpServletResponse에 저장한다.
@PostMapping("/logout")
public String logoutV2(HttpServletRequest request) {
sessionManager.expire(request);
return "redirect:/";
}
로그 아웃시 해당 세션의 정보를 제거한다.
HomeController
@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";
}
마찬가지로 private final SessionManager sessionManager;를 주입받아야 한다.
실제로 실행해보면, 세션을 사용하기 전과 별 차이 없이 잘 동작할 것이다. 단, 쿠키를 확인해보면 유저의 중요한 정보가 아닌 "mySessionId"에 임의의 값이 들어가있음을 확인 할 수 있다.
그런데 의문점이 하나 든다. 프로젝트마다 이런 세션 개념을 직접 개발하는 것은 상당히 귀찮은 일이다.. 이를 위해 서블릿이 세션 개념을 지원한다! 다음 내용부터는 서블릿이 공식 지원하는 세션을 활용하여 기능을 구현해보자.
4. 서블릿 HTTP 세션
서블릿은 HttpSession이라는 기능을 제공한다. 우리가 직접 구현한 개념은 사실 이미 구현되어 있다! 서블릿을 통해 HttpSession을 생성하면, 쿠키 이름은 JSESSIONID, 값은 추정 불가능한 렌덤 값을 넣어준다.
SessionConst
public class SessionConst {
public static final String LOGIN_MEMBER = "loginMember";
}
HttpSession에 데이터를 보관, 조회할 때 같은 이름이 중복되어 사용된다. 우리가 직접 만든 방식과 다르게 여러 클래스에서 사용되므로, 별도의 클래스를 만들어 상수를 하나 정의하였다.
LoginController
@PostMapping("/login")
public String loginV3(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, 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 사용
//세션이 있으면 해당 세션 반환, 없으면 신규 세션 생성
HttpSession session = request.getSession();
//세션에 로그인 회원 정보 보관
session.setAttribute(SessonConst.LOGIN_MEMBER, loginMember);
return "redirect:/";
}
HttpSession에서 새션을 생성하려면 request.getSession(boolean create)를 사용하자. 괄호 안에 값을 적지 않을 경우, default값은 true이다.
- request.getSession(true)
: 세션이 있으면 기존 세션을 반환한다. 세션이 없으면 새로운 세션을 생성해서 반환한다.
- request.getSession(false)
: 세션이 있으면 기존 세션을 반환한다. 세션이 없으면 새로운 세션을 생성하지 않는다. null을 반환한다.
@PostMapping("/logout")
public String logoutV3(HttpServletRequest request) {
//세션 삭제
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate(); //세션 제거
}
return "redirect:/";
}
세션을 삭제하기 위해 기존 세션을 찾는 과정이다. 따라서 request.getSession(false)를 사용하며, 만일 session값이 있을 경우 session.invalidate() 함수를 통해 세션을 제거한다.
HomeController
@GetMapping("/")
public String homeLoginV3(HttpServletRequest request, Model model) {
HttpSession session = request.getSession(false);
if (session == null) {
return "home"; //세션 없으면 home
}
Member loginMember = (Member) session.getAttribute(SessionConst.LOGIN_MEMBER);
if (loginMember == null) {
return "home"; //세션에 회원 데이터 없으면 home
}
model.addAttribute("member", loginMember);
return "loginHome";
}
마찬가지로 로그인 여부를 확인하기 위한 함수이므로, 세션을 찾아서 사용하기만 하면 된다. 따라서 request.getSession(false)를 활용하여 무의미한 세션 생성을 방지하자.
5. @SessionAttribute
@SessionAttribute은 스프링이 제공해주는 어노테이션으로, 세션을 더 편리하게 사용할 수 있도록 도와준다. 이 기능은 세션을 생성하지 않으며, 새션을 찾아서 사용해야 할 경우 활용하자.
HomeController
@GetMapping("/")
public String homeLoginV3Spring(
@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember,
Model model) {
if (loginMember == null) {
return "home";
}
model.addAttribute("member", loginMember);
return "loginHome";
}
직접 HttpServletRequest에서 세션을 찾고, 세션에서 필요한 정보를 찾을 필요 없이 스프링이 한번에 처리해주는 것을 확인 가능하다.
6. TrackingModes
프로젝트 실행 후 로그인을 처음 시도할 경우, URL이 조금 긴것을 확인할 수 있다.
- http://localhost:8080/;jessionid=......
이는 웹 브라우저가 쿠키를 지원하지 않을 경우, 쿠키 대신 URL을 통해 세션을 유지하는 방법이다. 하지만 최근 스프링에서 URL 매칭 전략이 변경되어, 위와 같이 출력되면 컨트롤러를 찾지 못하고 404 오류가 발생한다.
따라서 URL 전달 방식을 끄기 위해서는 application.properties에 다음 설정을 추가하자
- server.servlet.session.tracking-modes=cookie
7. 세션 타임아웃
세션은 사용자가 로그아웃 버튼을 눌러, session.invalidate()가 호출 되는 경우에만 삭제된다. 그런데 일반적으로 사용자는 로그아웃을 누르기 보단 웹 브라우저를 종료하는 선택을 한다. 하지만 서버 입장에서는 사용자가 웹 브라우저를 종료하였는지 인식할 방법이 없다.
그러면 서버에선 세션 데이터를 언제 삭제해야 할까? 계속 보관하기에는 여러 문제가 발생할 수 있다. 만일 세션과 관련된 쿠키를 탈취 당했을 경우, 오랜 시간이 지나도 해당 쿠키 정보로 악의적 요청을 할 수 있다. 또한 세션은 메모리에 생성되므로, 메모리는 무한하지 않기 때문에 꼭 필요한 경우에만 사용해야 한다.
HttpSession은 가장 최근에 요청한 시간을 기준으로 세션을 30분 유지시킨다. 생성 시점으로부터 30분으로 잡으면 사용자가 서비스를 사용하던 중 로그인을 다시 해야하기 때문에, 해당 방식을 사용한다.
위 내용은 김영한 님의 인프런 강의 "스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술"의 내용과 강의자료를 토대로 작성된 게시글입니다.
강의 링크:
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2
'Web > spring study' 카테고리의 다른 글
[Spring] 예외 처리, 오류 페이지 (0) | 2024.03.25 |
---|---|
[Spring] 서블릿 필터, 스프링 인터셉터 (0) | 2024.03.20 |
[Spring] Bean Validation (1) | 2024.01.30 |
[Spring] 검증 Validation (1) | 2024.01.30 |
[Spring] 메시지, 국제화 (0) | 2024.01.18 |
댓글