홈 화면 - 로그인 전
홈 화면 - 로그인 후
보안 요구사항
회원 가입, 상품 관리
이전 프로젝트에 이어서 로그인 기능을 학습해보자❗️
1️⃣ login-start
의 폴더 이름을 login
으로 변경
2️⃣ ItemServiceApplication.main()
을 실행해서 프로젝트가 정상 수행되는지 확인
💡 도메인이 가장 중요하다 💡
@GetMapping("/")
public String home() {
return "home";
}
<!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>
package hello.login.domain.member;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
@Data
public class Member {
private Long id;
@NotEmpty
// 로그인 ID
private String loginId;
@NotEmpty
// 사용자 이름
private String name;
@NotEmpty
private String password;
}
package hello.login.domain.member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import java.util.*;
/**
* 동시성 문제가 고려되어 있지 않음, 실무에서는 ConcurrentHashMap, AtomicLong 사용 고려
*/
@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();
}
}
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.validation.annotation.Validated;
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")
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:/";
}
}
📌
@ModelAttribute("member")
를@ModelAttribute
로 변경해도 결과는 같다.
여기서는 IDE에서 인식 문제가 있어서 적용했다.
templates/members/addMemberForm.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>
<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>
회원용 테스트 데이터 추가
loginId
: test
password
: test!
name
: 테스터
package hello.login;
import hello.login.domain.item.Item;
import hello.login.domain.item.ItemRepository;
import hello.login.domain.member.Member;
import hello.login.domain.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
@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);
}
}
로그인 ID, 비밀번호를 입력하는 부분에 집중하여 로그인 기능을 개발해보자😀
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);
}
}
⬆️ 로그인의 핵심 비즈니스 로직은 회원을 조회한 다음에 파라미터로 넘어온 password와 비교해서, 같으면 회원을 반환하고, 다르면 null을 반환한다!
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;
}
package hello.login.web.login;
import hello.login.domain.login.LoginService;
import hello.login.domain.member.Member;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import java.util.Objects;
@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";
}
return "redirect:/";
}
}
bindingResult.reject()
를 사용해서 글로벌 오류(ObjectError
)를 생성위의 문제를, 쿠키를 사용해서 로그인, 로그아웃 기능을 구현해 해결해보자😊
📌 쿠키에는 영속 쿠키와 세션 쿠키가 있다.
- 영속 쿠키 : 만료 날짜를 입력하면 해당 날짜까지 유지
- 세션 쿠키 : 만료 날짜를 생략하면 브라우저 종료시 까지만 유지
➡️ 브라우저 종료시 로그아웃이 되어야 하므로, 우리에게 필요한 것은 세션 쿠키이다!
로그인에 성공했을 때 세션 쿠키를 생성하자!
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
를 담아둔다.- 웹 브라우저는 종료 전까지 회원의
id
를 서버에 계속 보내줄 것이다.
요구사항에 맞추어 로그인에 성공하면 로그인 한 사용자 전용 홈 화면을 보여줘야 한다!
코드를 입력하세요
➡️ @CookieValue
를 사용하면 편리하게 쿠키를 조회할 수 있다.
➡️ 로그인 하지 않은 사용자도 홈에 접근할 수 있기 때문에 required = false
를 사용한다.
memberId
)가 없는 사용자는 기존 home으로 보낸다. 만약 로그인 쿠키가 있어도 회원이 없다면 home으로 보낸다memberId
)가 있는 사용자는 로그인 사용자 전용 홈 화면인 loginHome으로 보낸다.public String logout(HttpServletResponse response) {
expireCookie(response, "memberId");
return "redirect:/";
}
쿠키를 사용하면 로그인을 유지할 순 있지만 심각한 보안문제가 뒤따른다.
바로!!!!!!!!!!!!!!
1️⃣ 쿠키 값은 임의로 변경할 수 있다.
2️⃣ 쿠키에 보관된 정보는 훔쳐갈 수 있다.
3️⃣ 해커가 쿠키를 한번 훔쳐가면 평생 사용할 수 있다.
이를 위해 우리는 앞으로 세션을 통해 문제를 해결해 보려고 한다😊
세션을 어떻게 개발할지 먼저 개념을 이해해보자.
⬆️ 사용자가 loginId , password 정보를 전달하면 서버에서 해당 사용자가 맞는지 확인한다.
⬆️ 세션 ID를 생성하는데, 추정 불가능해야 한다.
⬆️ 서버는 클라이언트에 mySessionId
라는 이름으로 세션ID 만 쿠키에 담아서 전달하고, 클라이언트는 쿠키 저장소에 mySessionId
쿠키를 보관한다.
💡 중요
- 회원과 관련된 정보는 전혀 클라이언트에 전달하지 않는다는 것이다.
- 오직 추정 불가능한 세션 ID만 쿠키를 통해 클라이언트에 전달한다.
⬆️ 클라이언트는 요청시 항상 mySessionId
쿠키를 전달하고, 서버에서는 클라이언트가 전달한 mySessionId
쿠키 정보로 세션 저장소를 조회해서 로그인시 보관한 세션 정보를 사용한다.
세션 관리는 다음 3가지 기능을 제공한다😊
1️⃣ 세션 생성
➡️ sessionId 생성 (임의의 추정 불가능한 랜덤 값)
➡️ 세션 저장소에 sessionId와 보관할 값 저장
➡️ sessionId로 응답 쿠키를 생성해서 클라이언트에 전달
2️⃣ 세션 조회
➡️ 클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 값 조회
3️⃣ 세션 만료
➡️ 클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 sessionId와 값 제거
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";
// 스프링 아이디와 객체를 맵으로 저장
// 동시성을 위해 ConcurrentHashMap<>() 사용
private Map<String, Object> sessionStore = new ConcurrentHashMap<>();
/**
* 세션 생성
*/
public void createSession(Object value, HttpServletResponse response) {
// 세션 id를 생성하고, 값을 세션에 저장
// randomUUID() : 확실한 랜덤값을 얻을 수 있음. 자바가 제공
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);
}
}
1️⃣ 로그인
private final SessionManager sessionManager;
주입sessionManager.createSession(loginMember, response);
2️⃣ 로그아웃
sessionManager.expire(request);
private final SessionManager sessionManager;
주입바로 이전과 같이, 프로젝트마다 이러한 세션 개념을 직접 개발하는 것은 상당히 불편하다☹️
이제 직접 만드는 세션 말고, 서블릿이 공식 지원하는 세션을 알아보자!!
서블릿이 공식 지원하는 세션은 우리가 직접 만든 세션과 동작 방식이 거의 같고, 추가로 세션을 일정시간 사용하지 않으면 해당 세션을 삭제하는 기능을 제공한다👍🏻
📌 HttpSession 소개
- 서블릿이 제공하는 HttpSession 도 결국 우리가 직접 만든 SessionManager 와 같은 방식으로 동작한다.
- 서블릿을 통해 HttpSession 을 생성하면 다음과 같은 쿠키를 생성한다.
- 쿠키 이름이 JSESSIONID 이고, 값은 추정 불가능한 랜덤 값이다.
1️⃣ 로그인
request.getSession(true)
를 사용한다.public HttpSession getSession(boolean create);
request.getSession(true)
request.getSession(false)
request.getSession()
request.getSession(true)
와 동일하다2️⃣ 로그아웃
session.invalidate()
request.getSession(false)
request.getSession()
를 사용하면 기본 값이 create: true
이므로, 로그인 하지 않을 사용자도 의미없는 세션이 만들어진다. create: false
옵션을 사용해서 세션을 생성하지 않아야 한다.session.getAttribute(SessionConst.LOGIN_MEMBER)
📌 @SessionAttribute
스프링은 세션을 더 편리하게 사용할 수 있도록@SessionAttribute
을 지원한다.
이미 로그인 된 사용자를 찾을 때는 다음과 같이 사용하면 된다. (참고로 이 기능은 세션을 생성하지 않는다!)
➡️@SessionAttribute(name = "loginMember", required = false) Member loginMember
@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember, Model model)
📌 TrackingModes
URL 전달 방식을 끄고 항상 쿠키를 통해서만 세션을 유지하고 싶으면 다음과 같이 속성 변경을 해주자!
application.properties
➡️server.servlet.session.tracking-modes=cookie
세션에 타임아웃을 설정해보자😊
1️⃣ 스프링 부트로 글로벌 설정
➡️ application.properties server.servlet.session.timeout=60
2️⃣ 특정 세션 단위로 시간 설정
➡️ session.setMaxInactiveInterval(1800);
@Slf4j
@Controller
@RequiredArgsConstructor
public class HomeController {
private final MemberRepository memberRepository;
private final SessionManager sessionManager;
// @GetMapping("/")
public String home() {
return "home";
}
// @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";
}
// 직접 만든 세션 사용
// @GetMapping("/")
public String homeLoginV2(HttpServletRequest request, Model model) {
// 세션 관리자에 저장된 회원 정보 조회
// Member 로 캐스팅
Member member = (Member)sessionManager.getSession(request);
// 로그인
if (member == null) {
return "home";
}
model.addAttribute("member", member);
return "loginHome";
}
// 서블릿 HTTP 세션 사용
// @GetMapping("/")
public String homeLoginV3(HttpServletRequest request, Model model) {
// getSession(true) 를 사용하면 처음 들어온 사용자도 세션이 만들어지기 때문에 false로 받음
HttpSession session = request.getSession(false);
if (session == null) {
return "home";
}
// Member 로 타입 캐스팅
Member loginMember = (Member)session.getAttribute(SessionConst.LOGIN_MEMBER);
// 세션에 회원 데이터가 없으면 home
if (loginMember == null) {
return "home";
}
// 세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
// @SessionAttribute 사용
@GetMapping("/")
public String homeLoginV3Spring(
// @SessionAttribute 사용
@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember, Model model) {
// 세션에 회원 데이터가 없으면 home
if (loginMember == null) {
return "home";
}
// 세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
// @GetMapping("/")
public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model model) {
//세션에 회원 데이터가 없으면 home
if (loginMember == null) {
return "home";
}
//세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
}
@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {
private final LoginService loginService;
// 세션
private final SessionManager sessionManager;
@GetMapping("/login")
public String loginForm(@ModelAttribute("loginForm") LoginForm form) {
return "login/loginForm";
}
// @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:/";
}
// 직접 만든 세션 사용
// @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:/";
}
// 서블릿 HTTP 세션 사용
// @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";
}
// 로그인 성공 처리
// 세션이 있으면 있는 세션 반환, 없으면 신규 세션을 생성해서 반환
// getSession() : 디폴트가 true, false는 세션이 없을 때 새로 만들지 않고 null을 반환
HttpSession session = request.getSession();
// 세션에 로그인 회원 정보 보관
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:/";
}
@PostMapping("/login")
public String loginV4(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult,
@RequestParam(defaultValue = "/") String redirectURL,
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:" + redirectURL;
}
// @PostMapping("/logout")
public String logout(HttpServletResponse response) {
expireCookie(response, "memberId");
return "redirect:/";
}
// 직접 만든 세션 사용
// @PostMapping("/logout")
public String logoutV2(HttpServletRequest request) {
// 세션 만료 적용
sessionManager.expire(request);
return "redirect:/";
}
// 서블릿 HTTP 세션 사용
@PostMapping("/logout")
public String logoutV3(HttpServletRequest request) {
// getSession(false) 를 사용해야 함 (세션이 없더라도 새로 생성하면 안되기 때문)
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
return "redirect:/";
}
private void expireCookie(HttpServletResponse response, String cookieName) {
Cookie cookie = new Cookie(cookieName, null);
cookie.setMaxAge(0);
response.addCookie(cookie);
}
}
배고파요.. 밥 먹으러 갈래요....