- Spring mvc2 타임리프 기본기능
- Spring mvc2 타임리프 스프링 통합과 폼
- Spring mvc2(2) 메세지, 국제화
- Spring mvc2(3) 검증1 validation
- Spring mvc2 검증2 1 bean validation
- Spring mvc2 검증2 2 로그인 처리1 쿠키, 세션
- Spring mvc2 검증2 3 로그인 처리2 필터, 인터셉터
- Spring mvc2 검증2 4 예외 처리와 오류 페이지
- Spring mvc2(1) api 예외처리
- Spring mvc2(2) 스프링 타입 컨버터
- Spring mvc2(3) 파일 업로드
Spring mvc2 검증2 2 로그인 처리1 쿠키, 세션
0. 로그인 요구사항 / 패키지 설계 구조
로그인 요구사항
- 홈 화면 - 로그인 전 : 회원 가입, 로그인
- 홈 화면 - 로그인 후 : 본인 이름(누구님 환영합니다.), 상품 관리, 로그 아웃
- 보안 요구사항
- 로그인 사용자만 상품에 접근하고, 관리할 수 있음
- 로그인 하지 않은 사용자가 상품 관리에 접근하면 로그인 화면으로 이동
- 회원 가입, 상품 관리
패키지 설계 구조
- hello.login
- domain > item, member, login
- web > item, member, login
- 도메인이 가장 중요하다.
- 도메인 = 화면, UI, 기술 인프라 등등의 영역은 제외한 시스템이 구현해야 하는 핵심 비즈니스 업무 영역을 말한다.
향후 web을 다른 기술로 바꾸어도 도메인은 그대로 유지할 수 있어야 한다.
- web은 domain을 의존하지만, domain은 web을 의존하지 않도록 설계해야 한다.
- 예를 들어 web 패키지를 모두 삭제해도 domain에는 전혀 영향이 없도록 의존관계를 설계하는 것이 중요하다.
- 반대로 이야기하면 domain은 web을 참조하면 안된다.
1. 홈 화면 구성 (home.html)
HomeController
-
package hello.login.web; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @Slf4j @Controller public class HomeController { @GetMapping("/") public String home() { return "home"; } }
home.html
-
<!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> <div class="row"> <div class="col"> <button class="w-100 btn btn-secondary btn-lg" type="button" th:onclick="|location.href='@{/members/add}'|"> 회원 가입 </button> </div> <div class="col"> <button class="w-100 btn btn-dark btn-lg" onclick="location.href='items.html'" th:onclick="|location.href='@{/login}'|" type="button"> 로그인 </button> </div> </div> <hr class="my-4"> </div> <!-- /container --> </body> </html>
2. 회원 가입 화면 구성 (addMemberForm)
Member 도메인 설정
-
package hello.login.domain.member; import lombok.Data; import javax.validation.constraints.NotEmpty; @Data public class Member { private Long id; @NotEmpty private String loginId; @NotEmpty private String name; @NotEmpty private String password; }
토막 상식 (@NotNull vs @NotEmpty vs @NotBlank)
-
@NotNull : Null x / ““(빈공백) o / “ “(띄어쓰기) o
-
@NotEmpty : Null x / ““(빈공백) x / “ “(띄어쓰기) o
-
@NotBlank : Null x / ““(빈공백) x / “ “(띄어쓰기) x
@NotBlank 가 가장 좁은 범위로 볼 수 있다
MemberRepository
-
package hello.login.domain.member; import org.springframework.stereotype.Repository; import java.util.*; @Repository public class MemberRepository { private static Map<Long, Member> store = new HashMap<>(); private static long sequence = 0L; public Member save(Member member) { member.setId(++sequence); store.put(member.getId(), member); return member; } public Member findById(Long id) { return store.get(id); } //못찾을 수도 있으니 optional 로 반환 public Optional<Member> findByLoginId(String loginId) { /*List<Member> all = findAll(); for (Member member : all) { if (member.getLoginId().equals(loginId)){ return Optional.of(member); } } return Optional.empty();*/ return findAll().stream().filter(member -> member.getLoginId(). equals(loginId)).findFirst(); } public List<Member> findAll() { return new ArrayList<>(store.values()); } public void clearStore(){ store.clear(); } }
Optional 사용
- findByLoginId() 로 찾았는데 null 로 반환될 수도 있으므로 Optional 로 감싸서 반환한다.
- findFirst()와 findAny() 메소드는 해당 스트림에서 첫 번째 요소를 참조하는 Optional 객체를 반환한다. 두 메소드 모두 비어 있는 스트림에서는 비어있는 Optional 객체를 반환한다.
Optional test
-
@Test void findByLoginIdException(){ Member member = new Member(); member.setLoginId("LoginId"); member.setName("Name"); member.setPassword("Password"); memberRepository.save(member); Optional<Member> loginId = memberRepository.findByLoginId("LoginI"); System.out.println("loginId = " + loginId); System.out.println("loginId.getClass() = " + loginId.getClass()); System.out.println("loginId.empty() = " + loginId.empty()); System.out.println("loginId.isEmpty() = " + loginId.isEmpty()); }
-
다음 오류상황의 테스트코드를 출력하면 다음과 같이 나온다.
-
loginId = Optional.empty loginId.getClass() = class java.util.Optional loginId.empty() = Optional.empty loginId.isEmpty() = true
-
만약에 반환타입이 Optional 이 아니라 Member 였다면?
- loginId = null -> 이렇게 나올 것이다.
왜 Optional 씀??
- 나도 모른다(설명해주겠지 뭐)
- 일단 Optional 이면 isEmpty() 를 통해 boolean 으로 반환가능하다.
- 빈 값이면 Optional.empty 로 반환되는데 여기에도 isEmpty 메서드를 사용할 수 있다.
- 즉, loginId.isEmpty() = Optional.empty.isEmpty() => true or false 로 반환
- .filter 를 사용해서 람다식을 사용하기 위해??
왜 Map 에 static 을 사용하는가??
- static으로 사용하는 이유는 모든 곳에서 공유하기 위해서이다. 여기서는 store가 마치 데이터베이스 처럼 사용되기 때문에 하나만 존재하고 공유되어야 한다.
- 그런데 Repositoy가 싱글톤으로 사용되기 때문에 꼭 static을 사용하지 않아도 같은 결과를 얻을 수 있다.
- 다만, `store가 마치 데이터베이스 처럼 사용되기 때문에 하나만 존재하고 공유를 명시적으로 드러내기 위해 static을 사용했다.(라고 추측)
- https://www.inflearn.com/questions/240845/memorymemberrepository-line-number-7 읽어보도록
MemberController
-
package hello.login.web.member; import hello.login.domain.member.Member; import hello.login.domain.member.MemberRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import javax.validation.Valid; @Controller @RequiredArgsConstructor @RequestMapping("/members") public class MemberController { private final MemberRepository memberRepository; @GetMapping("/add") //타임리프에서 인식이 잘 안돼서 "member" 를 넘겨줌 public String addForm(@ModelAttribute("member") Member member) { return "members/addMemberForm"; } @PostMapping("/add") public String save(@Valid @ModelAttribute Member member, BindingResult bindingResult){ if(bindingResult.hasErrors()){ return "members/addMemberForm"; } memberRepository.save(member); return "redirect:/"; } }
회원 가입 뷰 템플릿
-
<!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"> <style> .container { max-width: 560px; } .field-error { border-color: #dc3545; color: #dc3545; } </style> </head> <body> <div class="container"> <div class="py-5 text-center"> <h2>회원 가입</h2> </div> <h4 class="mb-3">회원 정보 입력</h4> <form action="" th:action th:object="${member}" method="post"> <div th:if="${#fields.hasGlobalErrors()}"> <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p> </div> <div> <label for="loginId">로그인 ID</label> <input type="text" id="loginId" th:field="*{loginId}" class="form-control" th:errorclass="field-error"> <div class="field-error" th:errors="*{loginId}" /> </div> <div> <label for="password">비밀번호</label> <input type="password" id="password" th:field="*{password}" class="form-control" th:errorclass="field-error"> <div class="field-error" th:errors="*{password}" /> </div> <div> <label for="name">이름</label> <input type="text" id="name" th:field="*{name}" class="form-control" th:errorclass="field-error"> <div class="field-error" th:errors="*{name}" /> </div> <hr class="my-4"> <div class="row"> <div class="col"> <button class="w-100 btn btn-primary btn-lg" type="submit">회원 가입</button> </div> <div class="col"> <button class="w-100 btn btn-secondary btn-lg" onclick="location.href='items.html'" th:onclick="|location.href='@{/}'|" type="button">취소</button> </div> </div> </form> </div> <!-- /container --> </body> </html>
회원용 테스트 데이터 추가 (TestDataInit)
-
package hello.login; @Component @RequiredArgsConstructor public class TestDataInit { private final ItemRepository itemRepository; private final MemberRepository memberRepository; /** * 테스트용 데이터 추가 */ @PostConstruct public void init() { itemRepository.save(new Item("itemA", 10000, 10)); itemRepository.save(new Item("itemB", 20000, 20)); Member member = new Member(); member.setLoginId("test"); member.setPassword("test!"); member.setName("테스터"); memberRepository.save(member); }
3. 로그인 기능
LoginService
-
package hello.login.domain.login; import hello.login.domain.member.Member; import hello.login.domain.member.MemberRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class LoginService { private final MemberRepository memberRepository; /** * @return null -> 로그인 실패 */ public Member login(String loginId, String password){ return memberRepository.findByLoginId(loginId) .filter(m -> m.getPassword().equals(password)) .orElse(null); } }
람다식 대체 전
-
public Member login(String loginId, String password){ OPtional<Member> member = memberRepository.findByLoginId(loginId); Member findMember = member.get(); if(findMember.getPassword().equals(password)){ return findMember; }else{return null;} }
람다식 (.filter)
- Optional
를 .filter 로 하면 반환타입은 뭘까?? - Member!
오류 났던 것
- 람다식에서
.filter(m -> m.getPassword().equals(password))
를.filter(m -> m.getLoginId().equals(password))
라고 해서 오류가 났다. - 하지만 컴파일 오류로 잡히지 않아서 log.info 로 열심히 찾아서 해결했다.
LoginForm
-
package hello.login.web.login; import lombok.Data; import javax.validation.constraints.NotEmpty; @Data public class LoginForm { @NotEmpty private String loginId; @NotEmpty private String password; }
- 로그인할 때만 필요한 데이터
LoginController
-
package hello.login.web.login; import hello.login.domain.login.LoginService; import hello.login.domain.member.Member; import hello.login.domain.member.MemberRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Controller; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import javax.validation.Valid; @Slf4j @Controller @RequiredArgsConstructor public class LoginController { private final LoginService loginService; private final MemberRepository memberRepository; @GetMapping("/login") public String loginForm(@ModelAttribute("loginForm") LoginForm form) { return "login/loginForm"; } @PostMapping("/login") public String login(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult){ if(bindingResult.hasErrors()){ return "login/loginForm"; } Member loginMember = loginService.login(form.getLoginId(), form.getPassword()); if(loginMember == null){ bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다."); return "login/loginForm"; } //로그인 성공 처리 return "redirect:/"; } }
- 로그인 컨트롤러는 로그인 서비스를 호출해서 로그인에 성공하면 홈 화면으로 이동하고,
- 로그인에 실패하면 bindingResult.reject() 를 사용해서 글로벌 오류( ObjectError )를 생성한다.
- 그리고 정보를 다시 입력하도록 로그인 폼을 뷰 템플릿으로 사용한다.
오류났던 것
- @PostMapping 에 url(/”login”) 를 적어주지 않았다.
- 로그인을 시도하면 405 에러를 반환한다.
- 오류 로그
2023-03-15 10:45:49.983 WARN 109072 --- [nio-8080-exec-7] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.HttpRequestMethodNotSupportedException: Request method 'POST' not supported]
HttpRequestMethodNotSupportedException
이 터진다.Request method 'POST' not supported
라고 말한다.
로그인 폼 뷰 템플릿
-
templates/login/loginForm.html
-
<!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"> <style> .container { max-width: 560px; } .field-error { border-color: #dc3545; color: #dc3545; } </style> </head> <body> <div class="container"> <div class="py-5 text-center"> <h2>로그인</h2> </div> <form action="item.html" th:action th:object="${loginForm}" method="post"> <div th:if="${#fields.hasGlobalErrors()}"> <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p> </div> <div> <label for="loginId">로그인 ID</label> <input type="text" id="loginId" th:field="*{loginId}" class="form-control" th:errorclass="field-error"> <div class="field-error" th:errors= "*{loginId}" /> </div> <div> <label for="password">비밀번호</label> <input type="password" id="password" th:field="*{password}" class="form-control" th:errorclass="field-error"> <div class="field-error" th:errors="*{password}" /> </div> <hr class="my-4"> <div class="row"> <div class="col"> <button class="w-100 btn btn-primary btn-lg" type="submit"> 로그인</button> </div> <div class="col"> <button class="w-100 btn btn-secondary btn-lg" onclick="location.href='items.html'" th:onclick="|location.href='@{/}'|" type="button">취소</button> </div> </div> </form> </div> <!-- /container --> </body> </html>
4. 로그인 처리하기 - 쿠키 사용
- 쿠키를 사용해서 로그인, 로그아웃 기능을 구현
- 쿠키에는 영속 쿠키와 세션 쿠키가 있다.
- 영속 쿠키: 만료 날짜를 입력하면 해당 날짜까지 유지
- 세션 쿠키: 만료 날짜를 생략하면 브라우저 종료시 까지만 유지
- 브라우저 종료시 로그아웃이 되길 기대하므로, 우리에게 필요한 것은 세션 쿠키이다.
로그인 기능
LoginController - login()
-
로그인 성공 시 세션 쿠키를 생성한다.
-
@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:/"; }
- HttpServletResponse 에 담아야 한다.
- loginMember.getId() 가 Integer 인데, Cookie 는 String 으로 반환해야 해서 형변환이 필요하다.
- addCookie 메서드로 쿠키를 전달한다.
쿠키 확인
- 로그인을 하면 memberId=1 이라고 쿠키가 추가된 것을 확인할 수 있다.
- Application 탭에서도 확인가능하다.
홈 - 로그인 처리 (HomeController)
-
package hello.login.web; import hello.login.domain.member.Member; import hello.login.domain.member.MemberRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.GetMapping; @Slf4j @Controller @RequiredArgsConstructor public class HomeController { private final MemberRepository memberRepository; @GetMapping("/") //typeconverting 이 자동으로 일어남 public String homeLogin(@CookieValue(name = "memberId", required = false) Long memberId, Model model){ Member loginMember = memberRepository.findById(memberId); if(memberId == null){ return "home"; } //로그인 한 사용자 model.addAttribute("member", loginMember); return "loginHome"; } }
- 쿠키 값은
@CookieValue(name = "memberId", required = false)Long memberId
로 해서 memberId 로 받는다. - 이 때 쿠키 value 는 String 이지만 typeconverting 이 자동으로 일어난다.
- required = false 속성을 줘야 쿠키가 없어도 홈화면이 뜬다.
- 로그인한 사용자는 model 에 loginMember 값을 담아서 “loginHome” 을 반환한다.
- 쿠키 값은
홈 - 로그인 사용자 전용 (/loginHome.html)
-
templates/loginHome.html
-
<!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>
- 로그인에 성공시 세션 쿠키가 지속해서 유지되고, 웹 브라우저에서 서버에 요청시 memberId 쿠키를 계속 보내준다.
로그아웃 기능
LoginController - logout 기능 추가
-
@PostMapping("/logout") public String logout(HttpServletResponse response){ expireCookie(response, "memberId"); return "redirect:/"; } private static String expireCookie(HttpServletResponse response, String cookiename) { Cookie cookie = new Cookie(cookiename, null); cookie.setMaxAge(0); response.addCookie(cookie); return "redirect:/"; }
- 같은 쿠키이름(“memberId”) 을 새로 만든 후,
- setMaxAge() 메서드로 유지시간을 0 으로 만든 후 response 에 담아서 반환한다.
- 로그아웃도 응답 쿠키를 생성하는데 Max-Age=0 를 확인할 수 있다. 해당 쿠키는 즉시 종료된다.
5. 쿠키와 보안 문제 / 세션
쿠키 보안 문제
- 쿠키 값은 임의로 변경할 수 있다.
- 클라이언트가 쿠키를 강제로 변경하면 다른 사용자가 된다.
- 실제 웹브라우저 개발자모드 -> Application -> Cookie 변경이 가능하다.
- Cookie: memberId=1 -> Cookie: memberId=2 으로 변경하면 다른 사용자의 이름이 보인다.
- 쿠키에 보관된 정보는 훔쳐갈 수 있다.
- 만약 쿠키에 개인정보나, 신용카드 정보가 있다면?
- 이 정보가 웹 브라우저에도 보관되고, 네트워크 요청마다 계속 클라이언트에서 서버로 전달된다.
- 쿠키의 정보가 나의 로컬 PC에서 털릴 수도 있고, 네트워크 전송 구간에서 털릴 수도 있다.
- 해커가 쿠키를 한번 훔쳐가면 평생 사용할 수 있다.
- 해커가 쿠키를 훔쳐가서 그 쿠키로 악의적인 요청을 계속 시도할 수 있다.
대안
- 쿠키에 중요한 값을 노출하지 않고, 사용자 별로 예측 불가능한 임의의 토큰(랜덤 값)을 노출하고, 서버에서 토큰과 사용자 id를 매핑해서 인식한다. 그리고 서버에서 토큰을 관리한다.
- 토큰은 해커가 임의의 값을 넣어도 찾을 수 없도록 예상 불가능 해야 한다.
- 해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게(예: 30분) 유지한다. 또는 해킹이 의심되는 경우 서버에서 해당 토큰을 강제로 제거하면 된다.
로그인 처리하기 - 세션 동작 방식
- 클라이언트와 서버는 추정 불가능한 임의의 식별자 값으로 연결할 수 있다.
- 이렇게 서버에 중요한 정보를 보관하고 연결을 유지하는 방법을 세션이라 한다.
1. 로그인
- 사용자가 loginId , password 정보를 전달하면 서버에서 해당 사용자가 맞는지 확인한다.
2. 세션 생성
- 세션 ID를 생성하는데, 추정 불가능해야 한다.
- UUID는 추정이 불가능하다.
- Cookie: mySessionId=zz0101xx-bab9-4b92-9b32-dadb280f4b61
- 생성된 세션 ID와 세션에 보관할 값( memberA )을 서버의 세션 저장소에 보관한다.
3. 세션id를 응답 쿠키로 전달
-
클라이언트와 서버는 결국 쿠키로 연결이 되어야 한다.
- 서버는 클라이언트에 mySessionId 라는 이름으로 세션ID 만 쿠키에 담아서 전달한다.
- 클라이언트는 쿠키 저장소에 mySessionId 쿠키를 보관한다.
여기서 중요한 포인트는 회원과 관련된 정보는 전혀 클라이언트에 전달하지 않는다는 것이다. 오직 추정 불가능한 세션 ID만 쿠키를 통해 클라이언트에 전달한다.
4. 클라이언트의 세션id 쿠키 전달
- 클라이언트는 요청시 항상 mySessionId 쿠키를 전달한다.
- 서버에서는 클라이언트가 전달한 mySessionId 쿠키 정보로 세션 저장소를 조회해서 로그인시 보관한 세션 정보를 사용한다.
세션으로 보안문제 해결
- 쿠키 값을 변조 가능 -> 예상 불가능한 복잡한 세션Id를 사용한다.
- 쿠키에 보관하는 정보는 클라이언트 해킹시 털릴 가능성이 있다. -> 세션Id가 털려도 여기에는 중요한 정보가 없다.
- 쿠키 탈취 후 사용 -> 해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 세션의 만료시간을 짧게(예: 30분) 유지한다. 또는 해킹이 의심되는 경우 서버에서 해당 세션을 강제로 제거하면 된다.
6. 로그인 처리하기 - 세션 직접 만들기
세션 제공 기능
- 세션 생성
- sessionId 생성 (임의의 추정 불가능한 랜덤 값)
- 세션 저장소에 sessionId와 보관할 값 저장
- sessionId로 응답 쿠키를 생성해서 클라이언트에 전달
- 세션 조회
- 클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 값 조회
- 세션 만료
- 클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 sessionId와 값 제거
SessionManager - 세션 관리
-
주석 참고
-
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.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; //SpringBean으로 등록 @Component public class SessionManager { //"sessionId" 라는 값을 많이 쓰므로 상수로 만들어서 관리 //"sessionId" 값에 ctrl + alt + c 를 누르면 자동으로 만들어준다! wow public static final String SESSION_COOKIE_NAME = "sessionId"; //HashMap 은 동시 요청에 안전하지 않다. 동시 요청에 안전한 ConcurrentHashMap 을 사용했다. private Map<String, Object> sessionStore = new ConcurrentHashMap<>(); /** * 세션 생성 */ public void createSession(Object value, HttpServletResponse response){ //세션 id 를 UUID 로 생성하고, 값을 세션에 저장 String sessionId = UUID.randomUUID().toString(); sessionStore.put(sessionId, value); //쿠키 생성 Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId); //응답 메세지에 Cookie("sessionId", UUID) 전달 response.addCookie(mySessionCookie); } /** * 세션 조회 */ public Object getSession(HttpServletRequest request) { //request에 있는 session 중 "sessionId" 가 있는지 조회하고 Cookie 로 반환 (UUID) Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME); if(sessionCookie == null){ return null; } //UUID 를 통해서 SessionStore 에서 Value 값 찾아서 return (Object 타입) return sessionStore.get(sessionCookie.getValue()); } /** * 세션 만료 */ public void expire(HttpServletRequest request) { //request에 있는 session 중 "sessionId" 가 있는지 조회하고 Cookie 로 반환 (UUID) Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME); //쿠키가 있으면 UUID 를 통해서 sessionStore 제거 if(sessionCookie != null){ sessionStore.remove(sessionCookie.getValue()); } } //request 있는 session 중 "sessionId" 가 있는지 조회하고 Cookie 로 반환하는 로직 public Cookie findCookie(HttpServletRequest request, String cookieName){ //request.getCookies() 를 하면 모든 쿠키가 리스트로 반환된다. Cookie[] cookies = request.getCookies(); //없으면 null 반환 if(cookies == null){ return null; } //있으면 Arrays.stream(cookies) 를 통해서 "sessionId" 가 있는지 확인하고 있으면 findAny() 로 순서 상관없이 반환 //없으면 null 반환 return Arrays.stream(cookies) .filter(cookie -> cookie.getName().equals(SESSION_COOKIE_NAME)) .findAny() .orElse(null); } }
- 테스트 생략
7. 로그인 처리하기 - 직접 만든 세션 적용
LoginController - loginV2()
-
@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 안에서 addCookie 까지 처리해서 쿠키가 만들어진다. sessionManager.createSession(loginMember, response); return "redirect:/"; }
sessionManager.createSession(loginMember, response);
를 통해서 세션을 만든다.- sessionManager 안에서 addCookie 까지 처리해서 쿠키가 만들어진다.
LoginController - logoutV2()
-
@PostMapping("/logout") public String logoutV2(HttpServletRequest request){ sessionManager.expire(request); return "redirect:/"; }
sessionManager.expire(request);
을 통해서 sessionManger 에서 session 정보 삭
HomeController - homeLoginV2()
-
@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"; }
의문과 해결
- 로그아웃해도 “sessionId”가 남는다!
- 왜냐하면 logout 컨트롤러에 세션을 지우는 기능이 없기 때문
- 하지만 로그아웃 시 SessionManager에서 Map(UUID,Member) 쌍을 지우므로 sessionId(UUID) 를 통해 할 수 있는 건 없다 ㅎ
8. 로그인 처리하기 - 서블릿 HTTP 세션1
HttpSession 소개
- 서블릿은 세션을 위해 HttpSession 이라는 기능을 제공하는데, 지금까지 나온 문제들을 해결해준다. 우리가 직접 구현한 세션의 개념이 이미 구현되어 있고, 더 잘 구현되어 있다.
- 서블릿을 통해 HttpSession 을 생성하면 다음과 같은 쿠키를 생성한다. 쿠키 이름이 JSESSIONID 이고, 값은 추정 불가능한 랜덤 값이다.
Cookie: JSESSIONID=5B78E23B513F50164D6FDD8C97B0AD05
HttpSession 사용
SessionConst
-
package hello.login.web; public class SessionConst { public static final String LOGIN_MEMBER = "loginMember"; }
- HttpSession 에 데이터를 보관하고 조회할 때, 같은 이름이 중복 되어 사용되므로, 상수를 하나 정의했다
LoginController - loginV3()
-
@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 session = request.getSession(); //세션에 로그인 회원 정보 보관 session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember); return "redirect:/"; }
세션 생성과 조회
- 세션을 생성하려면 request.getSession(true) 를 사용하면 된다. (default 값이 true 라서 생성자없어도 된다.)
- request.getSession(true)
- 세션이 있으면 기존 세션을 반환한다.
- 세션이 없으면 새로운 세션을 생성해서 반환한다.
- request.getSession(false)
- 세션이 있으면 기존 세션을 반환한다.
- 세션이 없으면 새로운 세션을 생성하지 않는다. null 을 반환한다.
- request.getSession(true)
로그인 시에만 true 값을 넣어주면 될 듯!
- session.setAttribute(“세션 이름”, 세션에 넣을 정보) 로 정보를 보관한다.
- 하나의 세션에 여러 값을 저장할 수 있다.
LoginController - logoutV3()
-
@PostMapping("/logout") public String logoutV3(HttpServletRequest request){ HttpSession session = request.getSession(false); if (session != null) { session.invalidate(); } return "redirect:/"; }
- request 에서 session 을 받아서 세션이 Null 이 아니면 세션을 제거한다. (session.invalidate())
HomeController - homeLoginV3()
-
@GetMapping("/") public String homeLoginV3(HttpServletRequest request, Model model){ HttpSession session = request.getSession(false); if (session == null) { return "home"; } //세션 관리자에 저장된 회원 정보 조회 Member loginMember = (Member) session.getAttribute(SessionConst.LOGIN_MEMBER); //세션에 회원 데이터가 없으면 if(loginMember == null){ return "home"; } //로그인 한 사용자 model.addAttribute("member", loginMember); return "loginHome"; }
- session.getAttribute 메서를 통해 key 값 (“SessionConst.LOGIN_MEMBER”) 을 넘기고 정보인 loginMember 를 받는다.
- null 이 아니면 loginHome 호출
9. 로그인 처리하기 - 서블릿 HTTP 세션2
@SessionAttribute
- 이미 로그인 된 사용자를 찾을 때는 다음과 같이 사용하면 된다. 참고로 이 기능은 세션을 생성하지 않는다.
@SessionAttribute(name = "loginMember", required = false) Member loginMember
HomeController - homeLoginV3Spring()
-
@GetMapping("/") public String homeLoginV3Spring( @SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember, Model model){ //member 를 따로 호출할 필요도 없다. //참고로 이 기능은 세션을 생성하지 않는다. //세션에 회원 데이터가 없으면 if(loginMember == null){ return "home"; } //로그인 한 사용자 model.addAttribute("member", loginMember); return "loginHome"; }
- 세션이 있는지 없는지 체크할 필요가 없다.(이미 체크한다. required=false 속성으로 세션이 없어도 동작한다.)
- 참고로 이 기능은 세션을 생성하지 않는다.
TrackingModes
- 로그인을 처음 시도하면 URL이 다음과 같이 jsessionid 를 포함하고 있는 것을 확인할 수 있다.
http://localhost:8080/;jsessionid=F59911518B921DF62D09F0DF8F83F872
- 이것은 웹 브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해서 세션을 유지하는 방법이다.
- URL 전달 방식을 끄고 항상 쿠키를 통해서만 세션을 유지하고 싶으면
application.properties
에 다음 옵션을 넣어주면 된다. 이렇게 하면 URL에 jsessionid 가 노출되지 않는다.server.servlet.session.tracking-modes=cookie
10. 세션 정보와 타임아웃 설정
세션 정보 확인 (SessionInfoController)
-
package hello.login.web.session; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpSession; @RestController @Slf4j 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("getMaxInactiveInterval = {}", session.getMaxInactiveInterval()); log.info("creationTime = {}", session.getCreationTime()); log.info("lastAccessedTime = {}", session.getLastAccessedTime()); log.info("isNew = {}", session.isNew()); return "세션 출력"; } }
결과 값
-
session name = loginMember, value = Member(id=1, loginId=test, name=테스터, password=test!) sessionId = A2A30A29992BC54A877A712D5B8C6C40 getMaxInactiveInterval = 60 creationTime = 1678859072164 lastAccessedTime = 1678859073126 isNew = false
- sessionId : 세션Id, JSESSIONID 의 값이다. 예) 34B14F008AA3527C9F8ED620EFD7A4E1
- maxInactiveInterval : 세션의 유효 시간, 예) 1800초, (30분)
- creationTime : 세션 생성일시
- lastAccessedTime : 세션과 연결된 사용자가 최근에 서버에 접근한 시간, 클라이언트에서 서버로 sessionId ( JSESSIONID )를 요청한 경우에 갱신된다.
- isNew : 새로 생성된 세션인지, 아니면 이미 과거에 만들어졌고, 클라이언트에서 서버로 sessionId ( JSESSIONID )를 요청해서 조회된 세션인지 여부
세션 타임아웃 설정
세션 문제점
- 세션은 사용자가 로그아웃을 직접 호출해서 session.invalidate() 가 호출 되는 경우에 삭제된다.
- **그런데 대부분의 사용자는 로그아웃을 선택하지 않고, 그냥 웹 브라우저를 종료한다. **
- 문제는 HTTP가 비 연결성(ConnectionLess)이므로 서버 입장에서는 해당 사용자가 웹 브라우저를 종료한 것인지 아닌지를 인식할 수 없다. 따라서 서버에서 세션 데이터를 언제 삭제해야 하는지 판단하기가 어렵다.
세션이 남아서
- 세션과 관련된 쿠키( JSESSIONID )를 탈취 당했을 경우 오랜 시간이 지나도 해당 쿠키로 악의적인 요청을 할 수 있다.
- 세션은 기본적으로 메모리에 생성된다. 메모리의 크기가 무한하지 않기 때문에 꼭 필요한 경우만 생성해서 사용해야 한다. 10만명의 사용자가 로그인하면 10만개의 세션이 생성되는 것이다.
세션 타임아웃 설정
application.properties
:server.servlet.session.timeout=60
-> 60초, 기본은 1800(30분)- session.getLastAccessedTime() (최근 세션 접근 시간) 기준으로 세션이 초기화 된다.
- LastAccessedTime 이후로 timeout 시간이 지나면, WAS가 내부에서 해당 세션을 제거한다.
특정 세션 단위로 시간 설정
session.setMaxInactiveInterval(1800)
; //1800초
댓글남기기