스프링 MVC-스프링 로그인-쿠키와 세션
로그인
웹 페이지에서 페이지에 접근할 권한을 부여하고 회원의 데이터를 관리하기 위해서는 로그인 기능이 필수적이다.
우리는 앞으로 로그인 기능을 통해 가입된 회원의 로그인과 로그아웃 및 로그인 되지 않은 회원에 대한 페이지 접근 거부를 구현할 것이다.
그리고 해당 기능의 구현을 위해 이전 HTTP 포스팅에서 배웠던 쿠키를 스프링에서 어떻게 사용하는지를 알아보고 세션이란 무엇인지에 대해 배워볼 것이다.
로그인 구현
회원의 등록과 회원 찾기 등의 기능은 이전 포스티에서 질리도록 구현했던 것들과 거의 동일하니 넘어가도록 하겠다.
@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);
}
}
id와 password를 받아온 후 db에 있는지 확인하는 로직을 서비스로 따로 만들어주었다. 이후 작성할 컨트롤러에서 사용할 것이다.
@Controller
@Slf4j
@RequiredArgsConstructor
public class LoginController {
final LoginService loginService;
final SessionManager sessionManager;
@GetMapping("/login")
public String loginForm(@ModelAttribute("loginForm") LoginForm form) {
return "login/loginForm";
}
//@PostMapping("/login")
public String login(@Validated @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("/logout")
public String logout(HttpServletResponse response) {
expireCookie(response, "memberId");
return "redirect:/";
}
private static void expireCookie(HttpServletResponse response, String cookieName) {
Cookie cookie = new Cookie(cookieName, null);
cookie.setMaxAge(0);
response.addCookie(cookie);
}
}
쿠키를 사용해 구현한 로그인과 로그아웃 기능이다. 예외가 발생하면 폼으로 다시 돌아가도록 했고, 위에 작성한 로그인 서비스에서 id와 password를 확인하여 유효하지 않아도 폼으로 다시 돌아가도록 했다.
만약 로그인에 성공했다면 시간정보가 없는 세션 쿠키를 로그인 멤버의 id를 value로 발행한 후 홈으로 리다이렉트 하도록 하였다.
로그아웃 할 경우 같은 이름의 수명이 0인 쿠키를 발행하여 쿠키를 삭제하여 더이상 로그인 상태가 아니도록 하였다.
발행한 쿠키는 아래 그림과 같이 작동한다.
우리가 key와 value 값을 넣어 쿠키를 브라우저에 발행하면, 브라우저는 쿠키를 쿠키 저장소에 넣어뒀다가 이후 request를 보낼 때마다 쿠키의 값도 같이 서버에 전송한다.
쿠키엔 일정한 수명이 정해져있는 영속 쿠키와 수명이 없는 세션 쿠키가 있는데, 세션 쿠키는 브라우저를 종료하면 같이 사라진다.
우리는 브라우저 종료 시에 같이 사라지는 세션 쿠키를 사용할 것이다.
로그인 홈 구현
로그인을 하지 않은 유저는 홈에서 로그인으로 가는 폼을 띄워야하고, 로그인 한 유저라면 상품 관리로 가는 폼을 띄워야 한다.
로그인의 유무 구분은 우리가 발행한 쿠키를 브라우저가 같이 보내줄 것이므로 해당 데이터를 request에서 확인하여 쉽게 구분할 수 있다.
@Slf4j
@Controller
@RequiredArgsConstructor
public class HomeController {
final MemberRepository memberRepository;
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";
}
}
@CookieValue() 어노테이션을 활용하면 쉽게 쿠키값에 접근할 수 있다. 매개변수의 required 값을 true로 하면 로그인하지 않은 사용자는 홈에 접근할 수 없게 되기 때문에 false로 해준다.
로그인과 로그아웃, 그에 따라 바뀌는 홈 기능을 모두 구현했지만 위의 방식엔 심각한 보안 문제가 있다.
쿠키는 클라이언트에서 손쉽게 조작이 가능하기 때문이다. id와 password가 없어도 쿠키 값을 알고 위변조하면 손쉽게 로그인 한 것처럼 서버에 접근할 수 있다.
따라서 우리는 중요한 데이터는 모두 서버에서 관리하고, 클라이언트에는 추정이 불가능한 값인 토큰을 발행하여 서버에서 매칭하는 식으로 구현해야 한다.
이러한 방식을 세션이라고 한다.
세션
세션의 동작 방식은 아래 그림과 같다.
사용자가 id, password를 폼으로 전달하면 서버에서 해당 회원 데이터가 있는지 확인한다.
회원 데이터가 있다면, 추정 불가능한 값인 토큰을 생성한다. 방금 생성한 토큰을 key로, 회원 데이터를 value로 하여 서버에 별도로 존재하는 세션 저장소에 저장한다.
쿠키에는 토큰값만 담아서 발행한다. 위 그림에선 이름을 mySessionId로 전달하였다.
앞으로 브라우저는 쿠키에 담긴 토큰값을 서버로 계속 전송하고, 서버는 토큰값을 세션 저장소에서 매칭하여 로그인을 확인한다.
위 과정에서 중요한 부분은 회원과 관련된 정보들은 일절 클라이언트로 전송하지 않는다는 것이다. 오직 추정 불가능한 토큰값만을 전송한다.
이러한 방식을 통해 우리는 쿠키값의 변조를 막고 클라이언트에 전달하는 정보를 제한하는 효과를 얻을 수 있다.
또한 세션 값이 유출되더라도 세션의 만료기간을 짧게 잡거나 해킹이 의심되는 경우 세션을 말소하여 침입을 방지할 수 있다.
이러한 세션을 구현하기 위해서는
- 세션 id 생성(UUID로 쉽게 해결)
- 세션 저장소 구현 및 쿠키 생성하여 응답
- 요청의 쿠키에 담긴 세션값으로 세션 저장소 조회
- 요청의 쿠키에 담긴 세션값으로 세션 저장소의 세션 말소
와 같은 기능들을 구현해야 한다. 이제부터 하나씩 구현해보자.
@Component
public class SessionManager {
public static final String SESSION_COOKIE_NAME = "mySessionId";
Map<String, Object> sessionStore=new ConcurrentHashMap<>();
public void createSession(Object value, HttpServletResponse response) {
//세션 id를 생성하고 값을 세션에 저장
String sessionId = UUID.randomUUID().toString();
sessionStore.put(sessionId,value);
Cookie cookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
response.addCookie(cookie);
}
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 cookie = findCookie(request, SESSION_COOKIE_NAME);
if(cookie!=null) {
sessionStore.remove(cookie.getValue());
}
}
public Cookie findCookie(HttpServletRequest request, String cookieName) {
Cookie[] cookies = request.getCookies();
if (cookies == null) return null;
return Arrays.stream(cookies)
.filter(cookie -> cookie.getName().equals(cookieName))
.findFirst()
.orElse(null);
}
}
해당 기능들을 구현한 세션 매니저 코드이다. 세션 저장소는 동시성을 고려한 ConcurrentHashMap으로 구현하였고, 람다식과 스트림을 사용하여 세션 조회를 구현하였다.
이제 이 코드를 우리가 만든 로그인 기능에 추가해보자.
@PostMapping("/login")
public String loginV2(@Validated @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:/";
}
@PostMapping("/logout")
public String logoutV2(HttpServletRequest request) {
sessionManager.expire(request);
return "redirect:/";
}
직접 쿠키를 생성하던 방식에서 주입받은 세션매니저를 통해 세션을 생성하는 방식으로 변경되었고, 로그아웃도 세션매니저에서 세션을 말소하는 방식으로 변경되었다.
HTTP 세션
그런데 매번 이렇게 세션을 구현하는 것은 매우 번거롭다. 거의 다 비슷한 역할을 하기 때문이다.
따라서 서블렛에서는 위에서 구현한 세션과 동일한 방식으로 구현된 기능을 HttpSession 클래스로 제공한다. 세션매니저 대신 HttpSession을 사용하도록 코드를 변경해보자.
@PostMapping("/login")
public String loginV3(@Validated @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";
}
//sessionManager.createSession(loginMember, response);
//세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성
HttpSession session = request.getSession();
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:/";
}
@PostMapping("/logout")
public String logoutV3(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if(session!=null) {
session.invalidate();
}
return "redirect:/";
}
이제 세션의 생성은 세션매니저 대신 HttpServletRequest에서 getSession() 메서드를 통해 HttpSession 객체를 생성하여 이루어진다.
메서드의 매개변수는 true로 하면 세션이 없을 때 세션을 생성하고, false로 하면 세션이 없을 때 세션을 새로 생성하지 않고 null을 반환한다.
둘 다 세션이 있다면 기존 세션을 반환한다.
세션 저장소에는 setAttribute() 메서드를 사용하여 객체를 저장한다. 이름을 key로, 객체를 value로 하여 저장하면 된다.
로그아웃은 HttpSession의 invalidate() 메서드를 사용하면 된다. 해당 세션을 제거하는 역할을 한다.
로그인은 똑같이 request에서 getSession()으로 세션을 받아온 뒤 getAttribute() 메서드로 세션 저장소에 저장된 객체를 찾는 방식으로 구현하면 된다.
세션은 setMaxInactiveInterval() 메서드로 만료시간을 설정할 수 있는데, 이 만료시간은 절대적인 것이 아닌 마지막 요청으로부터 지난 시간을 의미한다.
최근 세션 접근 시간(LastAccessedTime) 이후로 MaxInactiveInterval만큼 지나면 세션이 말소되는 구조이다.
@SessionAttribute
@ModelAttribute와 마찬가지로, 스프링에서는 @SessionAttribute 어노테이션을 사용하여 세션을 찾아오는 파트를 생략하고 매개변수 단계에서 객체를 받아올 수 있다.
@GetMapping("/")
public String homeLoginV3Spring(@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member member, Model model) {
//세션 관리자에 저장된 회원정보 조회
if(member==null) {
return "home";
}
model.addAttribute("member", member);
return "loginHome";
}
name은 객체의 이름, required는 getSession()의 매개변수와 동일하게 false로 두면 새로운 세션을 생성하지 않는다.
끝
로그인에 대한 포스팅 첫 번째가 끝났다.
Http 강의에서 개념으로만 배웠던 쿠키에 대해, 그리고 쿠키의 보안상 단점을 해결하는 세션의 구조와 사용에 대해 자세히 알 수 있었다.
다음 포스팅에서는 로그인 하지 않는 사용자의 접근을 차단하는 서블릿 필터, 스프링 인터셉터에 대해 알아보도록 하겠다.