로그인 처리를 위한 코드 준비
- AuthInfo(로그인 성공 후, 인증 상태 정보를 세션에 보관할 때 사용할 클래스)
package spring;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public class AuthInfo {
private final Long id;
private final String email;
private final String name;
}
- Member(암호 일치 여부 메소드 추가)
package spring;
import java.time.LocalDateTime;
public class Member {
private Long id;
private String email;
private String password;
private String name;
private LocalDateTime registerDateTime;
... 생략
public boolean matchPassword(String password){
return this.password.equals(password);
}
}
- AuthService(이메일과 비밀번호가 일치하는 지 확인해서 AuthInfo 객체 생성하는 클래스)
package spring;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
@RequiredArgsConstructor
public class AuthService {
private final MemberDao memberDao;
public AuthInfo authenticate(String email, String password){
Member member = memberDao.selectByEmail(email);
if(member == null){
throw new WrongIdPasswordException();
}
if (!member.matchPassword(password)) {
throw new WrongIdPasswordException();
}
return new AuthInfo(member.getId(),
member.getEmail(),
member.getName());
}
}
- LoginController(폼에 입력 값을 전달 받기 위한 클래스)
package controller;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class LoginCommand {
private String email;
private String password;
private boolean rememberEmail;
}
- LoginCommandValidator(입력 값 검증)
package controller;
import org.springframework.validation.Errors;
import org.springframework.validation.ValidationUtils;
import org.springframework.validation.Validator;
public class LoginCommandValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return LoginCommand.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
ValidationUtils.rejectIfEmptyOrWhitespace(errors, "email", "required");
ValidationUtils.rejectIfEmpty(errors, "password", "required");
}
}
- LoginController(로그인 요청 처리)
package controller;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.validation.Errors;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import spring.AuthInfo;
import spring.AuthService;
import spring.WrongIdPasswordException;
@Controller
@RequiredArgsConstructor
@RequestMapping("/login")
public class LoginController {
private final AuthService authService;
@GetMapping
public String form(LoginCommand loginCommand) {
return "login/loginForm";
}
@PostMapping
public String submit(LoginCommand loginCommand, Errors errors) {
new LoginCommandValidator().validate(loginCommand, errors);
if (errors.hasErrors()) {
return "login/loginForm";
}
try{
AuthInfo authInfo = authService.authenticate(
loginCommand.getEmail(),
loginCommand.getPassword());
return "login/loginSuccess";
}catch (WrongIdPasswordException e){
errors.reject("idPasswordNotMatching");
return "login/loginForm";
}
}
}
- MemberConfig(AuthService 빈 등록)
package config;
import org.apache.tomcat.jdbc.pool.DataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import spring.AuthService;
import spring.ChangePasswordService;
import spring.MemberDao;
import spring.MemberRegisterService;
@Configuration
@EnableTransactionManagement
public class MemberConfig {
... 생략
@Bean
public AuthService authService(){
return new AuthService(memberDao());
}
}
- ControllerConfig(LoginController 빈 등록)
package config;
import controller.LoginController;
import controller.MainController;
import controller.RegisterController;
import controller.SurveyController;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import spring.AuthService;
import spring.MemberRegisterService;
@Configuration
public class ControllerConfig {
@Autowired
private MemberRegisterService memberRegisterService;
@Autowired
private AuthService authService;
... 생략
@Bean
public LoginController loginController(){
return new LoginController(authService);
}
}
컨트롤러에서 HttpSession 사용하기
로그인 상태를 유지하는 방법에는 크게 두 가지가 있다.
- HttpSession을 이용하는 방법
- 쿠키를 이용하는 방법
외부 데이터베이스에 세션 데이터를 보관하는 방법도 사용하는데, 큰 틀에서 보면 위와 같이 두 가지로 나뉘어진다.
실습 예제에서는 HttpSession을 이용해서 로그인 상태를 유지하는 코드를 추가할 것이다.
컨트롤러에서 HttpSession을 사용하려면 다음의 두 가지 방법 중 한 가지를 사용하면 된다.
- 요청 매핑 애노테이션 적용 메서드에 HttpSession 파라미터를 추가한다 .
@PostMapping
public String form(LoginCommand loginCommand, Errors errors, HttpSession session) {
… // 파라미터에 HttpSession이 존재할 경우 스프링 MVC는 컨트롤러의 메서드를 호출할 때
// HttpSession 객체를 파라미터로 전달한다.
// 생성하기 전이면 새로운 HttpSession을 생성하고, 그렇지 않으면 기존에 존재하는 객체를 전달.
// 항상 HttpSession을 생성한다.
}
- 요청 매핑 애노테이션 적용 메서드에 HttpServletRequest 파라미터를 추가하고 HttpServlet Request를 이용해서 HttpSession을 구한다.
@PostMapping
public String submit(LoginCommand loginCommand, Erro errors, HttpServletRequest req) {
HttpSession session = req.getSession();
// 이 방법은 필요한 시점에만 HttpSession을 생성할 수 있다.
}
- LoginController(HttpSession 적용)
@PostMapping
public String submit(LoginCommand loginCommand, Errors errors, HttpSession session) {
new LoginCommandValidator().validate(loginCommand, errors);
if (errors.hasErrors()) {
return "login/loginForm";
}
try{
AuthInfo authInfo = authService.authenticate(
loginCommand.getEmail(),
loginCommand.getPassword());
session.setAttribute("authInfo", authInfo);
return "login/loginSuccess";
}catch (WrongIdPasswordException e){
errors.reject("idPasswordNotMatching");
return "login/loginForm";
}
}
- LogoutController
package controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.servlet.http.HttpSession;
@Controller
public class LogoutController {
@RequestMapping("/logout")
public String logout(HttpSession session){
session.invalidate(); // 세션 제거
return "redirect:/main";
}
}
- JSP
<%@ page contentType="text/html; charset=utf-8" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!DOCTYPE html>
<html>
<head>
<title> 메인 </title>
</head>
<body>
<c:if test="${empty authlnfo}">
<p>환영합니다.</p>
<p>
<a href="<c:url value="/register/step1" />"> [회원 가입 하기] </a>
<a href="<c:url value="/login" />">[로그인]</a>
</p>
</c:if>
<c:if test="${! empty authlnfo}">
<p>${authlnfo.name} 님, 환영합니다. </p>
<p>
<a href="<c:url value="/edit/changePassword" />"> [비밀번호 변경] </a>
<a href="<c:url value="/logout" />">[로그아웃]</a>
</p>
</c:if>
</body>
</html>
비밀번호 변경 기능 구현
- ChangePwdCommand(현재 비밀번호, 새 비밀번호 받아올 클래스)
package controller;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ChangePwdCommand {
private String currentPassword;
private String newPassword;
}
- ChangePwdCommandValidator(데이터 검증)
package controller;
import org.springframework.validation.Errors;
import org.springframework.validation.ValidationUtils;
import org.springframework.validation.Validator;
public class ChangePwdCommandValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return ChangePwdCommand.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
ValidationUtils.rejectIfEmptyOrWhitespace(errors, "currentPassword", "required");
ValidationUtils.rejectIfEmpty(errors, "newPassword", "required");
}
}
- ChangePwdController
package controller;
import org.springframework.stereotype.Controller;
import org.springframework.validation.Errors;
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 spring.AuthInfo;
import spring.ChangePasswordService;
import spring.WrongIdPasswordException;
import javax.servlet.http.HttpSession;
@Controller
@RequestMapping("/edit/changePassword")
public class ChangePwdController {
private ChangePasswordService changePasswordService;
public void setChangePasswordService(ChangePasswordService changePasswordService){
this.changePasswordService = changePasswordService;
}
@GetMapping
public String form(@ModelAttribute("command") ChangePwdCommand pwdCommand){
return "edit/changePwdForm";
}
@PostMapping
public String submit(@ModelAttribute("command") ChangePwdCommand pwdCommand, Errors errors, HttpSession session){
new ChangePwdCommandValidator().validate(pwdCommand, errors);
if(errors.hasErrors()){
return "edit/changePwdForm";
}
AuthInfo authInfo = (AuthInfo)session.getAttribute("authInfo");
try {
changePasswordService.changePassword(authInfo.getEmail(),
pwdCommand.getCurrentPassword(),
pwdCommand.getNewPassword());
return "edit/changePwd";
}catch (WrongIdPasswordException e){
errors.rejectValue("currentPassword", "notMatching");
return "edit/changePwdForm";
}
}
}
인터셉터 사용하기
로그인 하지 않은 상태에서 비밀번호 변경 주소를 입력하면 변경 폼이 출력된다. 로그인하지 않은 상태에서 비밀번호 변경 폼을 요청하면 로그인 화면으로 이동시켜야 한다.
이를 위해 HttpSession에 authInfo 객체가 존재하는지 검사하고 존재하지 않으면 로그인 경로로 리다이렉트하도록 ChangePwdController 클래스를 수정할 수 있다.
그런데 실제 웹 어플리케이션에서는 비밀번호 변경 기능 외에 더 많은 기능에 로그인 여부를 확인해야한다. 이는 많은 중복을 일으킬 수 있다는 이야기이다.
이렇게 다수의 컨트롤러에 대해 동일한 기능을 적용해야 할 때 사용할 수 있는 것이 HandlerIntercepter이다.
HandlerIntercepter 인터페이스 구현하기
org.springframework.web.HandlerInterceptor 인터페이스를 사용하면 다음의 세 시점에 공통 기능을 넣을 수 있다
- 컨트롤러(핸들러) 실행 전
boolean preHandle( HttpServletRequest request , HttpServletResponse response,
Object handler) throws Exception;
// 다음 작업이 가능함
// 로그인하지 않은 경우 컨트롤러 실행X
// 컨트롤러를 실행하기 전에 컨트롤러에서 필요로 하는 정보를 생성
- 컨트롤러(핸들러) 실행 후, 아직 뷰를 실행하기 전
void postHandle( HttpServletRequest request , HttpServletResponse response,
Object handler, ModelAndView modelAndView) throws Exception;
// 컨트롤러에 익셉션이 발생하면 이 메서드는 실행되지 않음
- 뷰를 실행한 이후
void afterCompletion( HttpServletRequest request, HttpServletResponse response ,
Object handler, Exception ex) throws Exception;
// 컨트롤러에 익셉션 여부에 따라 네 번째 파라미터에 던져줌. 따라서 컨트롤러 실행 이후에
// 예기치 않게 익셉션을 로그로 남긴다거나 실행 시간을 기록하는 등의 후처리에 적합하다.
HandlerInterceptor 인터페이스의 각 메서드는 아무 기능도 구현하지 않은 자바 의 디폴트 메서드이다. 따라서 HandlerInterceptor 인터페이 스의 메서드를 모두 구현할 필요가 없다. 이 인터페이스를 상속받고 필요한 메서드만 재정의하면 된다.
- AuthCheckInterceptor
package interceptor;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
public class AuthCheckInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception{
HttpSession session = request.getSession(false);
if(session != null){
Object authInfo = session.getAttribute("authInfo");
if(authInfo != null){
return true; // 로그인 상태
}
}
response.sendRedirect(request.getContextPath() + "/login");
return false; // 로그인 상태 아님
}
}
HandlerInterceptor 설정하기
package config;
import controller.RegisterRequestValidator;
import interceptor.AuthCheckInterceptor;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.validation.Validator;
import org.springframework.web.servlet.config.annotation.*;
@Configuration
@EnableWebMvc
public class MvcConfig implements WebMvcConfigurer {
... 생략
@Override
public void addInterceptors(InterceptorRegistry registry){ // 인터셉터 설정 메서드
registry.addInterceptor(authCheckInterceptor()).addPathPatterns("/edit/**"); // 지정할 인터셉터와 경로 설정
}
@Bean
public AuthCheckInterceptor authCheckInterceptor(){
return new AuthCheckInterceptor();
}
}
Ant 패턴은 * , **,?의 세 가지 특수 문자를 이용해서 경로를 표현한다. 각 문자는 다음의 의미를 갖는다 . • * : 0개 또는 그 이상의 글자 • ? : 1개 글자 • ** : 0개 또는 그 이상의 폴더 경로
이들 문자를 사용한 경로 표현 예는 다음과 같다.
- @RequestMapping("/member/?*.info") : /member/로 시작하고 확장자가 .info로 끝나는 모든 경로
- @RequestMapping("/faq/f?00.fq") : /faq/f로 시작하고, 글자가 사이에 위치하고 00.fq로 끝나는 모든 경로
- @RequestMapping("/folders/**/ftles") : /folders 로 시작하고, 중간에 0개 이상의 중간 경로가 존재하고 /files로 끝나는 모든 경로. 예를 들어 /folders/files, /folders/1/2/3/files 이 일치 한다.
컨트롤러에서 쿠키 사용하기
시용자 편의를 위해 아이디를 기억해 두었다가 다음에 로그인할 때 아이디를 자동으로 넣어주는 사이트가 많다. 이 기능을 구현할 때 쿠키를 사용한다.
이메일 기억하기 기능을 구현하는 방식은 다음과 같다.
- 로그인 폼에 ‘이메일 기억하기’ 옵션을 추가한다.
- 로그인 시에 ‘이메일 기억하기’ 옵션을 선택했으면 로그인 성공 후 쿠키에 이메일을 저장한다. 이 때 쿠키는 웹 브라우저를 닫더라도 삭제되지 않도록 유효시간을 길게 설정한다.
- 이후 로그인 폼을 보여줄 때 이메일을 저장한 쿠키가 존재하면 입력 폼에 이메일을 보여준다.
- LoginController(쿠키 설정)
package controller;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.validation.Errors;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import spring.AuthInfo;
import spring.AuthService;
import spring.WrongIdPasswordException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
@Controller
@RequiredArgsConstructor
@RequestMapping("/login")
public class LoginController {
private final AuthService authService;
@GetMapping // 쿠키 이름 지정
public String form(LoginCommand loginCommand, @CookieValue(value="REMEMBER", required = false)Cookie rCookie) {
if(rCookie != null){
loginCommand.setEmail(rCookie.getValue());
loginCommand.setRememberEmail(true);
}
return "login/loginForm";
}
@PostMapping
public String submit(LoginCommand loginCommand, Errors errors, HttpSession session, HttpServletResponse response) {
new LoginCommandValidator().validate(loginCommand, errors);
if (errors.hasErrors()) {
return "login/loginForm";
}
try{
AuthInfo authInfo = authService.authenticate(
loginCommand.getEmail(),
loginCommand.getPassword());
session.setAttribute("authInfo", authInfo);
// 실제서비스에서는 이메일은 개인정보이니 암호화해야한다.
Cookie rememberCookie = new Cookie("REMEMBER", loginCommand.getEmail());
rememberCookie.setPath("/");
if(loginCommand.isRememberEmail()){
rememberCookie.setMaxAge(60 * 60 * 24 * 30);
}else{
rememberCookie.setMaxAge(0);
}
response.addCookie(rememberCookie);
return "login/loginSuccess";
}catch (WrongIdPasswordException e){
errors.reject("idPasswordNotMatching");
return "login/loginForm";
}
}
}
나중에 참고할 사이트
https://catsbi.oopy.io/0c27061c-204c-4fbf-acfd-418bdc855fd8
Reference
초보 웹 개발자를 위한 스프링5 프로그래밍 입문
'Spring > 스프링5 프로그래밍 입문' 카테고리의 다른 글
Chapter 15. 간단한 웹 어플리케이션 구조 (0) | 2023.04.12 |
---|---|
Chapter 14. MVC 4: 날짜 값 변환, @PathVariable, 익셉션 처리 (0) | 2023.04.11 |
Chapter12. MVC 2: 메시지, 커맨드 객체 검증 (0) | 2023.04.07 |
Chapter 10. 스프링 MVC 프레임워크 동작 방식 (0) | 2023.04.03 |
Chapter8. DB연동 (0) | 2023.02.26 |