김영한님의 스프링 mvc2편 - 벡엔트 웹 개발 활용 기술 을 듣고 정리한 내용입니다.
HomeController
public String home() {
return "home";
}
templet/home.html
<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>
Member
@Data
public class Member {
private Long id;
@NotEmpty
private String loginId; //로그인 ID
@NotEmpty
private String name; //사용자 이름
@NotEmpty
private String password;
}
MemverRepository
@Slf4j
@Repository
public class MemberRepository {
private static Map<Long, Member> store = new HashMap<>(); //static 사용
private static long sequence = 0L;//static 사용
public Member save(Member member) {
member.setId(++sequence);
log.info("save: member={}", member);
store.put(member.getId(), member);
return member;
}
public Member findById(Long id) {
return store.get(id);
}
public Optional<Member> findByLoginId(String loginId) {
return findAll().stream()
.filter(m -> m.getLoginId().equals(loginId))
.findFirst();
}
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
public void clearStore() {
store.clear();
}
}
동시성 문제 고려 안되어있으므로 실무에서는 ConcurrentHashMap, AtomicLong 사용 고려
테스트 코드
@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);
}
}
로그인 서비스
- 로그인의 핵심 비즈니스 로직은 회원을 조회한 다음에 파라미터로 넘어온
password와 비교해서 같으면 회원을 반환하고 , 만약 다르면 null 을 반환한다.
@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);
}
}
LoginForm
@Data
public class LoginForm {
@NotEmpty
private String loginId;
@NotEmpty
private String password;
}
@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {
private final LoginService loginService;
@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());
log.info("login? {}", loginMember);
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
//로그인 성공 처리 TODO
return "redirect:/";
}
}
로그인 컨트롤러는 로그인 서비스를 호출해서 로그인 성공 시 홈화면 이동,
로그인 실패 시 BindingResult.reject() 를 사용해서 글로벌 오류 ObjectError 생성,
그리고 정보를 다시 입력하도록 로그인 폼 뷰템플릿 이동.
로그인이 되면 홈화면에 고객 이름이 보여야한다는 요구사항을 보완하기 위해서는.
로그인 처리하기 쿠키 사용
쿼리 파라미터를 유지하면서 보내는 것은 매우 어렵고 번거로운작업
쿠키를 사요하자
로그인에 성공하면 HTTP 응답에 쿠키를 담아서 브라우저에 전달하자.
쿠키에는 영속 쿠키와 세션 쿠키가 있다.
영속 쿠키 : 만료날짜를 입력하면 해당 날짜까지 유지
세션 쿠키 : 만료 날짜를 생략하면 브라우저 종료 시 까지만 유지
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:/";
}
쿠키 생성 로직
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
response.addCookie(idCookie);
로그인 성공 시 쿠키생성
HttpServletResponse에 담는다.
쿠키이름은 memberId 이고 값은 회원 id를 담는다.
로그아웃 기능
@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.setMaxAge(0);
response.addCookie(cookie);
}
로그아웃도 응답 쿠키를 생성한다.
Max-Age=0 을 확인 가능
쿠키와 보안 문제
쿠키를 사용해서 LoginId를 전달해서 로그인 유지했다.
하지만 보안문제가 있다.
보안문제
쿠키값은 임의 변경 가능
쿠키의 보관 정보는 훔쳐갈 수 있다
해커가 쿠키를 한번 훔쳐감녀 계속 이용 가능하다.
대안
- 쿠키에 중요값 노출하지 않는다
- 예측불가능한 토큰을 노출하고, 서버에서 토큰과 사용자 Id 값을 매칭하여 인식
- 토큰은 서버에서 관리
- 토큰은 임의의 값
- 서버에서 해당 토큰의 만료시간 짧게, 해킹 의심 시 토큰 제거
로그인 처리하기 - 세션 동작 방식
쿠키는 보안 이슈가 있으므로 이를 해결하려면 중요 정보는 서버에서 저장해야한다.
클라이언트와 서버는 추정 불가능한 임의의 식별자 값으로 연결해야 한다.
세션 동작 방식
세션 ID 생성 시 추정 불가능해야한다.
UUID는 추정 불가능하다.
생성된 세션 ID와 보관 값을 서버의 세션 저장소에 보관
클라이언트와 서버는 결국 쿠키로 연결이 되어야 한다.
여기서 회원과 관련된 정보는 클라이언트에 전달하지 않는다는 것이다.
오직 추정 불가능한 세션 ID만 쿠키를 통해 클라이언트에 전달한다.
클라이언트는 요청 시 mySessionId 쿠키를 전달
서버에서는 클라이언트에서 전달한 쿠키정보 ( mySessionId ) 로 세션 저장소 조회 로그인 시 보관한 세션 정보 사용
세션을 사용해서 서버에서 중요한 정보를 관리하게 되면 ㅇ이점
쿠키값 변조가능 -> 예상 불가능한 복잡한 세션 Id 사용
쿠키 보관정보 해킹 가능성 -> 세션 Id가 털려도 중요정보 없음
쿠키 탈취 후 사용 -> 토큰을 털어가도 세션의 만료시간 짧고, 해킹 의심시 토큰 제거
로그인 처리 - 세션 직접
@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());
}
}
public 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);
}
}
동시 요청에 안전한 ConcurrentHashMap 사용
테스트코드
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.expire(request);
Object expired = sessionManager.getSession(request);
assertThat(expired).isNull();
}
}
수정 컨트롤러
private final SessionManager sessionManager;
@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:/";
}
로그인 성공 시 세션 등록 세션에 loginMember 저장해두고 쿠키 발행
로그아웃 시 세션 정보 제거
@PostMapping("/logout")
public String logoutV2(HttpServletRequest request) {
sessionManager.expire(request);
return "redirect:/";
}
홈컨트롤러 수정
@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";
}
로그인 처리하기 - 서블릿 HTTP 세션
세션이라는 개념은 대부분의 웹 어플리케이션에서 필요
서블릿은 세션을 위해 HTTPSession이라는 기능 제공
public class SessionConst {
public static final String LOGIN_MEMBER = "loginMember";
}
HttpSession에 데이터를 보관하고 조회 시 같은 이름 중복되어 사용되므로, 상수 정의
@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) 사용
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:/";
}
@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);
//세션에 회원 데이터가 없으면 home
if (loginMember == null) {
return "home";
}
//세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
request.getSession(false)
request.getSession() 를 사용하면 기본 값이 create: true이므로,
로그인 하지 않을 사용자도 의미없는 세션이 만들어진다.
따라서 세션을 찾아서 사용하는 시점에는 create: false 옵션을 사용해서 세션을 생성하지 않아야 한다.
로그인 처리 - 서블릿 HTTP 세션
스프링은 세션을 더 편리하게 사용하도록 @SessionAttribute를 지원한다.
@SessionAttribute(name = "loginMember", required = false) Member loginMember
이미 로그인 된 사용자를 찾을 때는 다음과 같이 사용하면 된다. 참고로 이 기능은 세션을 생성하지 않는다.
@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";
}
로그인을 처음 시도하면 URL이 다음과 같이 jsessionid 를 포함하고 있는 것을 확인할 수 있다.
http://localhost:8080/;jsessionid=D52912418B921KS62D09F0WFF83F577
이것은 웹 브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해서 세션을 유지하는 방법이다.
URL 전달 방식을 끄고 항상 쿠키를 통해서만 세션을 유지하고 싶으면 다음 옵션을 넣어주면 된다. 이렇게
하면 URL에 jsessionid 가 노출되지 않는다.
application.properties
server.servlet.session.tracking-modes=cookie
세션 정보와 타임아웃 설정
@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("getMaxInactiveInterval={}", session.getMaxInactiveInterval());
log.info("creationTime={}", new Date(session.getCreationTime()));
log.info("lastAccessedTime={}", new Date(session.getLastAccessedTime()));
log.info("isNew={}", session.isNew());
return "세션 출력";
}
}
- sessionId : 세션Id, JSESSIONID 의 값이다. 예) 34B14F008AA3527C9F8ED620EFD7A4E1
- maxInactiveInterval : 세션의 유효 시간, 예) 1800초, (30분)
- creationTime : 세션 생성일시
- lastAccessedTime : 세션과 연결된 사용자가 최근에 서버에 접근한 시간, 클라이언트에서 서버로
- sessionId ( JSESSIONID )를 요청한 경우에 갱신된다.
- isNew : 새로 생성된 세션인지, 아니면 이미 과거에 만들어졌고, 클라이언트에서 서버로
- sessionId ( JSESSIONID )를 요청해서 조회된 세션인지 여부
세션 타임아웃 설정
세션은 사용자가 로그아웃을 직접 호출해서 session.invalidate() 가 호출 되는 경우에 삭제된다.
그런데 대부분의 사용자는 로그아웃을 선택하지 않고, 그냥 웹 브라우저를 종료한다.
문제는 HTTP가 비연결성(ConnectionLess)이므로
서버 입장에서는 해당 사용자가 웹 브라우저를 종료한 것인지 아닌지를 인식할 수 없다.
따라서 서버에서 세션 데이터를 언제 삭제해야 하는지 판단하기가 어렵다.
세션 타임아웃 설정
스프링 부트로 글벌 설정
application.properties
server.servlet.session.timeout=60 : 60초, 기본은 1800(30분)
세션 타임아웃 발생
세션의 타임아웃 시간은 해당 세션과 관련된 JSESSIONID 를 전달하는
HTTP 요청이 있으면 현재 시간으로 다시 초기화 된다.
이렇게 초기화 되면 세션 타임아웃으로 설정한 시간동안 세션을 추가로 사용할 수 있다.
- session.getLastAccessedTime() : 최근 세션 접근 시간
- LastAccessedTime 이후로 timeout 시간이 지나면, WAS가 내부에서 해당 세션을 제거한다
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 인프런 | 강의
웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습할 수 있
www.inflearn.com
'스터디 > 2023_스프링부트' 카테고리의 다른 글
[study] 스프링MVC2 - 8. API 예외처리 (0) | 2023.08.14 |
---|---|
[study] 스프링MVC2 - 6. 로그인 처리 - 필터 , 인터셉터 (0) | 2023.08.13 |
[study] 스프링MVC2 - 4. 검증 (0) | 2023.08.08 |
[study] 스프링MVC2 - 3. 메세지 국제화 (0) | 2023.08.08 |
[study] 스프링MVC2 - 2. 타임리프 스프링 통합과 폼 (0) | 2023.08.08 |