1. 쿠키의 보안 문제
쿠키는 클라이언트에서 서버로 전달하는 것이기 때문에 위·변조가 가능하다. 예를 들어 아래 사진과 같이 Application 탭에서 memberId 쿠키의 값을 임의로 변경할 수 있다. 이렇게 변경하면 마치 다른 memberId를 가진 사용자로 로그인 한것처럼 되어 정보를 탈취할 수 있게 된다.
또한 혹시 쿠키에 신용카드 정보나 민감정보를 담고 있는 경우, 나의 PC가 해킹을 당하거나 네트워크 전송 구간에서 정보를 탈취당하는 경우가 생길 수 있다. 그래서 다음과 같은 방법을 통해서 쿠키의 보안 취약점을 보완할 수 있다.
쿠키에 중요한 값을 넣지 않는다. 그리고 예측불가능한 랜덤값을 넣어준 다음 서버에서 토큰과 사용자 id를 매핑하여 인식하도록 한다. 그리고 토큰값은 서버에서만 관리하도록 한다. 또한 혹시나 쿠키가 탈취되더라도 토큰의 만료시간이 짧아서 더이상 사용할 수 없도록 서버에서 토큰의 만료시간을 짧게 관리해야 한다.
2. 세션 만들기
세션 개념
서버에 중요한 정보를 보관하고 연결을 유지하는 방법을 세션이라고 한다. 클라이언트에서 로그인을 하면, 임의의 추정이 불가능한 sessionId값(스프링 기초에서 배운 UUID값 활용)을 만들어서 서버의 세션 저장소에 유저의 정보와 매핑이 되도록 보관한다. 그리고 이 sessionId값을 쿠키로 활용하면, 추정이 불가능하고 실제 사용자의 정보는 서버에서만 관리하는 방식을 적용한다. 쿠키 자체가 탈취되는 경우에는 서버에서 세션의 만료 시간을 짧게 하고, 해킹이 의심되는 경우에는 서버에서 세션을 강제로 제거하는 방법을 사용한다.
세션 만들고 테스트하기
세션은 크게 3가지 기능으로, 생성, 조회, 만료가 가능해야한다. 직접 세션을 만들어보면서 세션의 개념에 대해서 자세히 이해해보자.
ConcurrentHashMap 형태로 session 저장소를 만들고, key는 session 이름, value는 Object로 지정한다. createSession 메서드를 보면 Object value를 받아오는데, 이것은 나중에 서버에 저장할 값으로 객체로 받아오도록 하였다.
그외에 상수값으로 SESSION_COOKIE_NAME 값을 static으로 지정하여 다른 곳에서도 쓸 수 있도록 한다는 점을 기억하자. 그리고 한 객체에 동시에 요청이 들어왔을 때의 문제점을 해소하기 위해 ConcurrentHashMap을 적용하였다. 또한 Arrays.stream().filter() 문법도 눈여겨보자.
java/hello/login/web/session/SessionManager.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
|
@Component
public class SessionManager {
private 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 expires(HttpServletRequest request) {
Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
if (sessionCookie != null) {
sessionStore.remove(sessionCookie.getValue());
}
}
public Cookie findCookie(HttpServletRequest request, String cookieName) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
return Arrays.stream(cookies).filter(c -> c.getName().equals(cookieName))
.findAny()
.orElse(null);
}
}
|
cs |
MockHttpServletRequest, MockHttpServletResponse
잘 작동하는지 테스트를 해본다. 그런데, HttpServletReqeust와 HttpServletResponse는 인터페이스라서 바로 테스트에서 활용이 어렵다. 이를 위해 Mock 객체들을 활용할 수 있다. 테스트 코드에서 MockHttp 객체들을 사용하면 요청과 응답을 가정하고 각 객체들의 메서드들을 사용할 수 있다.
java/hello/login/web/session/SessionManagerTest.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
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);
assertThat(result).isEqualTo(member);
//세션 만료
sessionManager.expires(request);
assertThat(sessionManager.getSession(request)).isNull();
}
}
|
cs |
3. 로그인에 세션 적용
로그인에 만들어둔 세션을 적용해본다.
우선 컨터롤러에서 로그인 시에 쿠키를 만들어서 response 객체에 담아주던 것을 이제 session을 만드는 로직으로 변경한다. 세션에 저장되는 정보는 loginService.login 메서드를 통해 생성된 회원정보가 된다.
java/hello/login/web/login/LoginController.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
@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:/";
}
|
cs |
그리고 로그아웃 시에도 세션의 expires 메서드를 활용한다.
1
2
3
4
5
|
@PostMapping("logout")
public String logoutV2(HttpServletRequest request) {
sessionManager.expires(request);
return "redirect:/";
}
|
cs |
마지막으로 Home 화면에서 세션에 저장된 회원 정보를 보여준다. 세션 매니저에서 getSession을 했을 때, 반환 객체의 확장성을 위해 Object 객체를 반환하도록 지정해놓았었는데, 여기서 Member 클래스로 캐스팅을 해주었다.
java/hello/login/web/HomeController.java
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@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";
}
|
cs |
4. 서블릿 HTTP 세션
세션은 기본적이고 공통적인 기능이기 떄문에, 이미 서블릿에서 HTTP 세션 기능을 제공한다. 서블릿이 제공하는 HttpSession도 결국은 위에서 실습해본 방식과 동일한 방식을 사용한다.
상수 객체 사용하기
우선 쿠키 이름값을 담아주기 위해 상수 객체를 작성한다.
java/hello/login/web/SessionConst.java
1
2
3
|
public class SessionConst {
public static final String LOGIN_MEMBER = "loginMember";
}
|
cs |
로그인
request.getSession()
로그인부분에서는 다음 코드를 추가하면 된다. HttpServletRequest 객체인 request에서 getSession() 메서드를 활용하면 된다. 파라미터로 true값을 넘겨주는데, 이 create라는 이름을 갖는 파라미터는 true/false 지정 시 다음과 같은 내용으로 지정된다.
true : 세션이 있으면 있는 세션 반환, 없으면 새로 생성. default 값
false : 세션이 있으면 있는 세션 반환, 없으면 null 반환
그리고 .setAttribute() 메서드를 이용해서 세션에 로그인 회원 정보를 보관하면 된다.
java/hello/login/web/login/LoginController.java
1
2
3
4
5
|
//로그인 성공 처리
//세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성
HttpSession session = request.getSession(true);
//세션에 로그인 회원 정보 보관
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
|
cs |
로그아웃
로그아웃에서도 request 객체를 받아온다. 로그아웃이 되었을 때 세션이 없다고해서 새로운 세션을 만들면 안되므로 create 파라미터를 false로 처리해주었다. 그리고 세션이 있다면, session.invalidate() 메서드를 사용하여 세션을 만료처리 해준다.
java/hello/login/web/login/LoginController.java
1
2
3
4
5
6
7
8
|
@PostMapping("logout")
public String logoutV3(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
return "redirect:/";
}
|
cs |
로그인 해보면 서블릿이 구현해준 JSESSIONID가 쿠키에 적용된 것을 확인할 수 있다. 로그아웃을 해도 이 세션ID가 남아있는데, 어짜피 서버에서는 invalidate 처리가 되었기 때문에 굳이 삭제할 필요는 없다.
5. 스프링 @SessionAttribute 사용
@SessionAttribute
스프링이 제공하는 @SessionAttribute를 사용해보자. 이 어노테이션을 적용하면 세션과 세션 정보를 바탕으로 쿠키의 value값(세션 생성시 전달해준 Member 객체값)을 편리하게 조회할 수 있다. 다만, 세션 생성이나 만료는 여전히 HttpServlet의 기능을 사용한다. 따라서 HomeController에만 적용해본다.
java/hello/login/web/HomeController.java
1
2
3
4
5
6
7
8
9
10
11
12
|
@GetMapping("/")
public String homeLoginV3Spring(@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember, Model model) {
//세션에 회원 데이터가 없으면 home
if (loginMember == null) {
return "home";
}
//세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
|
cs |
name으로 쿠키 이름을 설정하고, value값을 Member 객체로 선언한다. required 속성을 지정해서 세션 정보가 없더라도 home 페이지에 접근할 수 있도록 해준다.
TrackingModes
로그인을 처음하면 URL 주소에 jsessionid가 따라붙는 것을 볼 수 있다. 이 기능은 tracking-mode 기능으로, 혹시라도 웹 브라우저에서 쿠키 기능을 제공하지 않는 경우에 URL에 sessionId 정보를 추가하는 방식이다. 그러나 이 방식은 웹 사이 트의 모든 URL 상에 세션의 정보를 읽고 URL에 추가해주어야 하기 때문에 불편한 방식이다. 따라서 굳이 사용하지 않는 방식이며, 이 모드를 사용하지 않고 싶다면 아래와 같이 application.properties 파일에 코드를 추가해주면 된다.
server.servlet.session.traking-modes=cookie
6. 세션 정보 및 타임아웃 설정
세션 정보
세션과 관련된 정보들을 조회할 수 있다.
-sessionId : JSESSIONID의 값을 나타낸다.
-maxInactiveInterval : 세션의 유효시간으로 초단위로 나타낸다. ex) 1800 = 30분
-creationTime : 세션 생성 일시
-lastAccessedTime : 세션과 연결된 사용자가 최근에 서버에 접근한 시간, 클라이언트에서 서버로 sessionId를 요청한 경우 갱신된다.
-isNew : 새로 생성된 세션인지 아니면 이미 과거에 만들어졌고 다시 요청을한 세션인지의 여부
java/hello/login/web/session/SessionInfoController.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
@Slf4j
@RestController
public class SessionInfoController {
@GetMapping("/session-info")
public String sessionInfo(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return "세션이 없습니다.";
}
//세션 데이터 출력
session.getAttributeNames().asIterator()
.forEachRemaining(name -> log.info("session name={}, value={}", name, session.getAttribute(name)));
log.info("sessionId={}", session.getId());
log.info("maxInactiveInterval={}", session.getMaxInactiveInterval());
log.info("creationTime={}", new Date(session.getCreationTime()));
log.info("lastAccessedTime={}", new Date(session.getLastAccessedTime()));
log.info("isNew={}", session.isNew());
return "세션 출력";
}
}
|
cs |
세션 타임아웃 설정
보통 사용자들은 위에서 작성한 것처럼 로그아웃을 클릭하여 요청을 보내서 session.invalidate()를 호출하지 않고 웹 브라우저를 바로 종료시켜버린다. 서버에서는 HTTP의 비연결성으로 인해 사용자가 웹브라우저를 종료했는지 알 방법이 없다. 그래서 세션을 그냥 계속 보관하는 경우, 탈취당할 위험도 있이 있을 뿐만 아니라 서버의 메모리 공간을 지속적으로 차지하고 있어서 불필요한 메모리 공간을 차지하게 된다. 그래서 세션의 타임아웃 설정을 반드시 해줘야한다.
세션 타임아웃은 아래의 구문을 application.properties에 작성해주면 된다.
server.servlet.session.timeout=1800
글로벌로 설정하지 않고, 세션 단위로 설정할려면 session.setMaxInactiveInterval(1800) 이라고 설정해주면 된다.
위에서 배웠듯이 1800이란 초단위이기 때문에 30분을 의미한다. 보통 30분이나 1시간 정도로 세션 timeout 시간을 잡는데, 스프링의 HttpSession은 사용자가 서버에 최근에 요청한 시간을 기준으로 timeout을 갱신한다. 접속한지 30분이 지났다고 세션을 만료시키는 것이 아니라 요청시간을 기준으로 하기 때문에 타임아웃이 설정 안 됬을때의 문제점을 해결할 뿐만 아니라 사용자의 세션이 갑자기 종료되는 위험성도 미리 고려하여 해결해놓게 된다.
세션 전달 객체 간소화
예제에서는 세션에 Member 객체 전체를 전달하는 방식을 적용했다. 그러나 세션도 메모리를 차지하기 때문에 많은 사용자가 몰릴 경우 세션 자체가 메모리에 부하를 줄 수 있다. 따라서 세션 객체에는 필수적인 정보만 담아놓는 것이 중요하다.
참조
1. 인프런_스프링 MVC 2편 - 백엔드 웹개발 핵심 기술_김영한 님 강의
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2
'Programming-[Backend] > Spring' 카테고리의 다른 글
[스프링 웹MVC-2] 13. 로그인 - 스프링 인터셉터, ArgumentResolver 활용 (0) | 2021.09.05 |
---|---|
[스프링 웹MVC-2] 12. 로그인 - 서블릿 필터 (0) | 2021.09.01 |
[스프링 웹MVC-2] 10. 로그인 - 기본 기능, 쿠키 적용 (0) | 2021.08.29 |
[스프링 웹MVC-2] 9. 검증(Validation) - groups, 객체 분리 적용, API 전송에서의 성공, 실패 케이스 (0) | 2021.08.29 |
[스프링 웹MVC-2] 8. 검증(Validation) - Bean Validation 사용하기 (0) | 2021.08.24 |