쿠키를 이용한 로그인 처리
로그인 상태 유지에 쿼리 파라미터를 유지하는 방법은 어렵고 번거로우며
때에 따라서는 보안 문제에 취약하다는 단점이 있다.
따라서 우리는 쿼리 파라미터를 이용하는 방법이 아닌 웹 브라우저에 쿠키를 담아보내는 방법으로 요청시마다 웹브라우저가 쿠키를 함께 발송하게 끔하여 로그인 세션을 유지하는 방법을 공부해보겠다.

웹 서버에서 클라이언트 브라우저에 쿠키를 전송하는 방식은 위와 같고
클라이언트 브라우저에서 수신한 쿠키는 아래 그림과 같이 모든 요청시마다 서버로 전송된다.
쿠키의 종류
쿠키의 종류에는 두가지가 있다
- 영속 쿠키 : 만료 날짜를 입력하면 해당 날짜까지 유지
- 세션 쿠키 : 만료 날짜를 생략하면 브라우저 종료시까지만 유지
로그인 구현 - 쿠키 사용
1) LoginController
구현
로그인 성공시 세션 쿠키를 생성하는 코드는 다음과 같다.
//@PostMapping("/login")
public String loginFormV1(@Validated LoginForm loginForm, BindingResult bindingResult, HttpServletResponse response){
if(bindingResult.hasErrors()){
return "login/loginForm";
}
Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
if(loginMember == null){
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
// 로그인 처리 TODO 쿠키를 만들어서 브라우저로 전송하면 브라우저에서 요청시마다 쿠키를 함께 전달함
// 쿠키에 시간 정보를 주지 않으면 세션쿠키로 자동 설정됨.
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
response.addCookie(idCookie);
// HttpServletResponse가 쿠키 설정을 위해 필요함.
return "redirect:/";
}
쿠키 생성 로직은 아래와 같다
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
response.addCookie(idCookie);
로그인이 성공하면 쿠키가 생성되고 컨트롤러는 그 쿠키를 HttpServletResponse
에 담는다.
쿠키이름은 memberId
이며 값은 회원의 id
를 담아둔다 (key
, value
) 형태와 비슷한 것 같다.
웹 브라우저는 종료전까지 회원의 id
를 서버에 계속 보내줄 것이다.
영속쿠키로 설정하는 경우 말이 다를 수 있다.
Home
화면에서 로그인 유무에 따라 다음과 같이 화면 렌더링에 차이를 둘 수 있다.
@GetMapping("/")
public String homeLogin(@CookieValue(name="memberId", required = false) Long memberId, Model model){
// 쿠키 값은 String인데 스프링이 알아서 Converting 해줌
if(memberId==null){
return "home";
}
Member loginMember = memberRepository.findById(memberId);
if(loginMember == null){
return "home";
}
model.addAttribute("member", loginMember);
return "loginHome";
}
@CookieValue
를 통해 편리하게 Cookie
를 조회할 수 있다.
이 때 조회할 Cookie
의 Key(또는 name)
을 명시해주어야 하며, required=true
로 설정하면 그 쿠키를 의무적으로 갖고 있어야하기 때문에 여기서는 로그인 하지 않은 사용자도 home
에 접근할 수 있으므로 false
로 설정한다.
memberId
라는 쿠키를 갖고 있지 않은 경우 이 컨트롤러는 if
문 분기를 통해 home
으로 보내지만
로그인 한 사용자의 경우 loginHome
이라는 별도의 홈 화면 페이지로 보낸다.
이 때 추가로 member
에 대한 정보도 loginHome
에서는 별도로 출력해야하기 때문에 모델에 담아서 전달한다.
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link th:href="@{/css/bootstrap.min.css}"
href="../css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container" style="max-width: 600px">
<div class="py-5 text-center">
<h2>홈 화면</h2>
</div> <h4 class="mb-3" th:text="|로그인: ${member.name}|">로그인 사용자 이름</h4>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-secondary btn-lg" type="button"
th:onclick="|location.href='@{/items}'|">
상품 관리
</button>
</div> <div class="col">
<form th:action="@{/logout}" method="post">
<button class="w-100 btn btn-dark btn-lg" type="submit">
로그아웃
</button>
</form> </div> </div> <hr class="my-4">
</div> <!-- /container -->
</body>
</html>
로그아웃 기능
로그아웃 기능은 다음과 같이 쿠키의 세션 시간을 0으로 설정하여 만료시킴으로써 구현할 수 있다.
또는 세션쿠키로 설정하기 때문에 브라우저 종료 시에 쿠키가 만료된다.
해당 기능을 구현한 컨트롤러 코드는 아래와 같다.
@PostMapping("/logout")
public String logout(HttpServletResponse response){
expireCookie(response);
return "redirect:/";
}
private void expireCookie(HttpServletResponse response) {
Cookie cookie = new Cookie("memberId", null);
cookie.setMaxAge(0);
response.addCookie(cookie);
// 클라이언트에 정보 저장시 뭐든 문제가 생길 수 있음 쿠키 위변조 또는 탈취시 큰 문제가 생긴다.
// 전송 관련한 문제는 HTTPS인데 PC가 털려버리면 답이 없다.
// 네트워크 전송구간에서 왔다갔다하는게 보여서 털릴 수 있음.
}
로그아웃도 응답 쿠키를 형성하는데 이름이 같으면 브라우저에 존재하는 쿠키가 갱신된다.
쿠키와 보안 문제
- 쿠키값은 임의로 변경하면 다른 사용자가 될 수 있다.
- 쿠키에 보관된 정보는 탈취가 가능하다.
- 쿠키를 탈취자가 한번 가져가면 계속적으로 악의적인 요청을 전송할 수 있다.
대안은 UUID
를 사용함으로써 중복될 확률이 거의 없는 값을 쿠키값으로 제시하고
쿠키에 대해 사용자를 인식하는 과정 자체는 웹서버에서 진행함으로써 웹서버 내의 사용자 정보와 쿠키값을 1:1 대응 시키지 않는 방법이 있다 (핵심 토큰은 서버에서 관리)
만료시간을 짧게 가져가자, 해킹 의심시 웹 서버에서 토큰 강제로 제거하면 되는 해결책도 있다.
-> 다만 이 경우 웹서버에 쿠키 관련한 메모리를 최소화해야한다, 조금만 쿠키에 관한 정보가 늘어나도 수백만 사용자가 사용한다고 가정하면 최적화에 문제가 생길 수 있다.
따라서 우리는 다음과 같이 로그인을 세션 동작방식으로 다시 구현해보겠다.
로그인 구현 - 세션 동작 방식
- 중요한 정보는 모두 서버에 저장한다
- 클라이언트와 서버는 추적 불가능한 임의 식별자 값을 이용하여 상호 연결된다.(비연결성임에 주의)
세션 동작 방식의 WorkFlow는 다음과 같다.
- 사용자가
loginId
,password
정보를 전달하면 서버에서 해당 사용자가 맞는지 확인한다. - 확인되는 경우 서버에서는 세션ID를 생성한다(
UUID
를 이용) - 생성된 세션ID와 세션에 보관할 값(
member
정보)를 서버의 세션 저장소에 보관한다. - 이후 세션 ID를 응답 쿠키에 담아 전달한다. (회원과 관련된 정보는 전혀 클라이언트로 전달되지 않는다.)
클라이언트에서는....
- 클라이언트는 요청시 전달받았던 세션ID 쿠키를 함께 전달한다.
- 서버에서는 클라이언트가 전달한 쿠키정보로 세션 저장소를 조회하고 로그인시 보관한 세션정보를 사용한다.
세션 직접 만들기
package hello.login.web.session;
import org.springframework.stereotype.Component;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@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 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){
Cookie[] cookies = request.getCookies();
if(cookies == null){
return null;
}
return Arrays.stream(cookies)
.filter(cookie -> cookie.getName().equals(cookieName))
.findAny()
.orElse(null);
}
}
package hello.login.web.session;
import hello.login.domain.member.Member;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import static org.junit.jupiter.api.Assertions.*;
class SessionManagerTest {
SessionManager sessionManager = new SessionManager();
@Test
void sessionTest(){
MockHttpServletResponse response = new MockHttpServletResponse();
Member member = new Member();
sessionManager.createSession(member, response);
// 요청에 응답 쿠키 저장
MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(response.getCookies());
Object result = sessionManager.getSession(request);
Assertions.assertThat(result).isEqualTo(member);
sessionManager.expire(request);
Object expired = sessionManager.getSession(request);
Assertions.assertThat(expired).isNull();
}
}
MockHttpServletRequest
의 경우 스프링을 사용하지 못하는 테스트에서 가상으로 HttpServletRequests
를 사용할 수 있도록 해준다.
로그인 처리 - 직접 만든 세션 적용해보기
// @Autowired를 통한 SessionManager 주입
private final SessionManager sessionManager;
@PostMapping("/login")
public String loginFormV2(@Validated LoginForm loginForm, BindingResult bindingResult, HttpServletResponse response){
if(bindingResult.hasErrors()){
return "login/loginForm";
}
Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
if(loginMember == null){
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
// 로그인 처리 TODO 쿠키를 만들어서 브라우저로 전송하면 브라우저에서 요청시마다 쿠키를 함께 전달함
// 쿠키에 시간 정보를 주지 않으면 세션쿠키로 자동 설정됨.
//Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId())); //response.addCookie(idCookie); // HttpServletResponse가 쿠키 설정을 위해 필요함.
sessionManager.createSession(loginMember, response);
return "redirect:/";
}
@PostMapping("/logout")
public String logoutV2(HttpServletRequest request){
sessionManager.expire(request);
return "redirect:/";
}
로그아웃시 해당 세션의 정보를 제거한다.
클라이언트 브라우저에서는 쿠키가 남아있어도 웹 서버에서는 의미가 없어지기 때문에 사실상 없는게 된다.
'스프링 공부 (인프런 김영한 선생님) > 스프링 MVC 2편' 카테고리의 다른 글
[스프링 웹 MVC 2편] 010. 로그인 처리 - 필터 (0) | 2023.08.18 |
---|---|
[스프링 웹 MVC 2편] 009. 로그인 처리 - 서블릿 HTTP 세션 (0) | 2023.08.17 |
[스프링 웹 MVC 2편] 007. Bean Validation (0) | 2023.08.15 |
[스프링 웹 MVC 2편] 006. Validation (2) (0) | 2023.08.15 |
[스프링 웹 MVC 2편] 005. Validation (1) (0) | 2023.08.13 |