지난 포스팅에 이어서 로그인을 구현해보면 아래와 같다.
LoginService.java
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;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class LoginService {
private final MemberRepository memberRepository;
/****
* @return null이면 로그인 실패
*/
public Member login(String loginId, String password) {
/* Optional<Member> findMemberOptional = memberRepository.findByLoginId(loginId);
Member member = findMemberOptional.get();
if(member.getPassword().equals(password)) {
return member;
} else {
return null;
}*/
return memberRepository.findByLoginId(loginId)
.filter(m -> m.getPassword().equals(password))
.orElse(null);
}
}
로그인의 핵심 비즈니스 로직은 회원을 조회한 다음에 파라미터로 넘어온 password와 비교해서 같으면
회원을 반환하고, 만약 password와 다르면 null을 반환한다.
LoginForm.java
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.java
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.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
@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, HttpServletResponse response) {
if(bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
log.info("loginMember={}", loginMember);
if(loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
return "redirect:/";
}
}
로그인 컨트롤러는 로그인 서비스를 호출해서 로그인에 성공하면 홈 화면으로 이동하고,
로그인에 실패하면 bindingResult.reject() 를 사용해서 글로벌 오류(ObjectError)를 생성한다.
그리고 다시 입력하도록 로그인 폼을 뷰 템플릿으로 사용한다.
로그인 폼 뷰 템플릿
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>
로그인 폼 뷰 템플릿에는 특별한 코드는 없다.
loginId, password 가 틀리면 글로벌 오류가 나타난다.
실행해보면 로그인이 성공하면 홈으로 이동하고,
로그인에 실패하면 "아이디 또는 비밀번호가 맞지 않습니다."라는 경고와 함께 로그인 폼이 나타난다.
로그인을 성공하여 로그인 상태를 유지하면서 로그인 후 고객의 이름을 보여주려면 쿠키를 사용해야 한다.
쿠키 관련 포스팅은 하단 링크를 참고하자
https://drcode-devblog.tistory.com/390
https://drcode-devblog.tistory.com/403
로그인 상태를 유지하려면 쿠키를 사용하는 방법이 있다.
쿠키?
서버에서 로그인에 성공하면 HTTP 응답에 쿠키를 담아서 브라우저에 전달하면,
브라우저는 앞으로 해당 쿠키를 지속해서 보내준다.
쿠키 생성
클라이언트에 쿠키 전달 1
클라이언트 쿠키 전달 2
쿠키에는 영속 쿠키와 세션 쿠키가 있다.
- 영속 쿠키 : 만료 날짜를 입력하면 해당 날짜까지 유지
- 세션 쿠키 : 만료 날짜를 생략하면 브라우저 종료시 까지만 유지
브라우저 종료시 로그아웃이 되길 기대하므로, 우리에게 필요한 것은 세션 쿠키이다
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());
log.info("loginMember={}", loginMember);
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 를 서버에 계속 보내줄 것이다.
실행
크롬 브라우저를 통해 HTTP 응답 헤더에 쿠키가 추가된 것을 확인할 수 있다.
이제 요구사항에 맞추어 로그인에 성공하면 로그인 한 사용자 전용 홈 화면을 보여주자.
홈 - 로그인 처리
HomeController.java
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("/")
public String home() {
return "home";
}
@GetMapping("/")
// 자동으로 타입컨버팅 해준다. 쿠키는 원래 String
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";
}
}
- 기존 home()에 있는 @GetMapping("/") 은 주석 처리한다.
- @CookieValue 를 사용하면 편리하게 쿠키를 조회할 수 있다.
- 로그인 하지 않은 사용자도 홈에 접근할 수 있기 때문에 required = false 를 사용한다.
로직 분석
- 로그인 쿠키(memberId)가 없는 사용자는 기존 home으로 보낸다.
추가로 로그인 쿠키가 있어도 회원이 없으면 home으로 보낸다. - 로그인 쿠키(memberId)가 있는 사용자는 로그인 사용자 전용 홈 화면인 loginHome으로 보낸다.
추가로 홈 화면에 회원 관련 정보도 출력해야 해서 member 데이터도 모델에 담아서 전달한다.
홈 - 로그인 사용자 전용
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>
- th:text="|로그인: ${member.name}|" : 로그인에 성공한 사용자 이름을 출력한다.
- 상품 관리, 로그아웃 버튼을 노출한다.
실행
로그인에 성공하면 사용자 이름이 출력되면서 상품 관리, 로그아웃 버튼을 확인할 수 있다.
로그인에 성공시 세션 쿠키가 지속해서 유지되고, 웹 브라우저에서 서버에 요청시 memberId 쿠키를 계속 보내준다.
로그아웃 기능
- 세션 쿠키이므로 웹 브라우저 종료 시 서버에서 해당 쿠키의 종료 날짜를 0으로 지정
LoginController - logout 기능 추가
@PostMapping("/logout")
public String logout(HttpServletResponse response) {
// Control + Alt + M 누르면 리펙토링 가능
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 를 확인할 수 있다. 해당 쿠키는 즉시 종료된다.
'스프링 > 스프링 웹 개발 활용' 카테고리의 다른 글
[Spring] 로그인 처리 - 서블릿 필터 (1) | 2023.11.20 |
---|---|
[Spring] 쿠키와 보안 문제 & 세션 동작 방식 및 세션 직접 구현하기 (0) | 2023.11.15 |
[Spring] 회원가입 구현 (0) | 2023.11.10 |
[Spring] 로그인 처리 - 쿠키, 세션 프로젝트 설정, 홈 화면 (0) | 2023.11.10 |
[Spring] Bean Validation - HTTP 메시지 컨버터 (0) | 2023.11.10 |
댓글