[Java] 로그인 처리 - 쿠키 vs 세션

dondonee·2023년 12월 17일
0
post-thumbnail

로그인 처리 - 쿠키 vs 세션

HTTP는 기본적으로 무상태 프로토콜이기 때문에 클라이언트와 서버가 요청을 한 번 주고받으면 연결이 끊어지고 정보를 유지하지 않는다.

로그인의 경우 하나의 상태가 계속 유지되어야 하므로 기존의 무상태 방식으로는 어려움이 있다. 쿠키와 세션을 통해 문제를 해결해보자.


로그인 기능

요구사항

  • 홈 화면에서 로그인 여부에 따라 보여지는 화면이 다르다.
    • 로그인 전 : 회원가입, 로그인
    • 로그인 후 : 회원 이름, 상품관리, 로그아웃
  • 로그인 하지 않은 상태에서 회원 전용 페이지에 접근하면 로그인 화면으로 이동

로그인 폼

@Data
public class LoginForm {

    @NotEmpty
    private String loginId;

    @NotEmpty
    private String password;
}

로그인 화면에서 사용자에게 받은 로그인 정보를 저장한다.

로그인 로직(서비스 계층)

public Member login(String loginId, String password) {

	return memberRepository.findById(loginId)
               .filter(m -> m.getPassword().equals(password))
               .orElse(null);
}

사용자가 알맞은 아이디와 패스워드를 입력한 경우 리포지토리에서 일치하는 회원 객체(Member)를 찾아 반환한다. 비정상 로그인의 경우 null을 반환한다.



쿠키


쿠키란

로그인에 성공한 경우 로그인 정보를 쿠키로 만들어 브라우저의 쿠키 저장소에 저장하고, 이후에 저장소에서 쿠키를 조회하는 방식으로 상태를 유지할 수 있다.

쿠키는 영속 쿠키와 세션 쿠키가 있다. 로그인 처리에는 세션 쿠키가 적합하다.

  • 영속 쿠키: 쿠키의 만료 일자를 지정한 경우로, 지정한 시점까지 쿠키가 유지된다.
  • 세션 쿠키: 브라우저 세션이 종료되면 사라지는 쿠키이다. (만료 일자 생략시)

쿠키의 동작

로그인에 성공하면 로그인 정보(memberId)를 갖는 쿠키를 생성하여 쿠키 저장소에 저장한다. 이후 사용자가 페이지에 접근하면 쿠키 저장소에서 로그인 정보를 조회하여 로그인 상태를 판단한다.


쿠키 적용

로그인 컨트롤러

@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:/";           //홈으로 이동
}
  1. 로그인 화면에서 사용자가 아이디와 패스워드를 입력한다. 입력한 정보는 loginForm 객체의 loginId, password에 각각 바인딩된다.
  2. 로그인 정보(loginForm)는 @ModelAttribute로 컨트롤러에 넘어온다.
  3. 로그인 로직을 실행한다. 정상 로그인이면 해당하는 Member가 반환되고 그렇지 않으면 null이 반환된다.
  4. 로그인 실패 ☞ bindingResult에 오류 정보를 추가하고 로그인 화면으로 이동한다.
  5. 로그인 성공 ☞ 쿠키에 memberId를 저장하고 HTTP 응답에 쿠키를 담아 보낸 뒤, 홈으로 이동한다.

홈 화면 컨트롤러

@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를 통해 쿠키를 조회할 수 있다. 쿠키에 담긴 memberId값으로 리포지토리에서 회원을 조회하여 로그인 상태인지 체크한다. 일치하는 회원(loginMember)이 있는 경우 모델에 회원 객체를 담은 뒤 회원 전용 홈 화면인 loginHome으로 이동한다. 쿠키 값이 없거나 리포지토리에서 일치하는 회원을 찾을 수 없다면 정상 로그인 상태가 아니므로 일반 홈 화면(home)으로 이동한다.

@CookieValuerequired 속성을 false로 지정해야 한다. 해당 쿠키가 반드시 존재하지 않아도 된다는 의미이다. 로그인 상태가 아닌 경우, 즉 쿠키 값이 없는 경우에도 컨트롤러가 일반 홈 화면(home)으로 이동시킬 수 있도록 로그인 컨트롤러가 동작해야 하기 때문이다. 기본값이 true인 경우 쿠키 값이 존재해야만 컨트롤러가 동작한다.


로그아웃

@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);
}

쿠키의 memberId 값을 null로 초기화하여 생성하고 setMaxAge(0)로 쿠키가 곧바로 만료되게 설정한 뒤, 응답에 쿠키 정보를 담아 보낸다.

브라우저가 응답을 받으면 로그인 정보를 담고 있던 memberId 쿠키가 null을 갖는 새 쿠키로 대체된 뒤 바로 만료된다.

  • 쿠키 저장소는 같은 이름의 쿠키가 추가되면 새로운 것으로 대체한다.

쿠키의 보안 문제

  • 쿠키 값은 임의로 변경이 가능하다.
    • 클라이언트에서 전달하는 값은 위조가 쉽다. (크롬 개발자도구, 포스트맨)
  • 쿠키에 보관된 값은 훔칠 수 있다.
    • 쿠키는 웹브라우저에도 보관되고 네트워크 요청마다 클라이언트에서 계속 전달되므로 로컬 PC에서 훔칠 수도 있고 네트워크 전송 구간에서 가로챌 수도 있다.
  • 해커카 한 번 훔치면 계속 사용할 수 있다.
    • memberId를 한 번 훔치면 그 멤버로 계속해서 로그인 할 수 있다.


세션


세션이란

쿠키의 보안 문제를 해결하기 위해서는 중요한 정보는 서버에만 저장하고, 클라이언트와 서버는 그 자체로는 의미가 없으며 추정이 불가능한 임의의 식별자 값으로 연결해야 한다. 이렇게 서버에만 중요 정보를 보관하고 연결을 유지하는 방법을 세션이라 한다.

세션은 크게 다음 3가지 기능을 지원한다 :

  • 세션 생성
    • 세션 ID(추정 불가능한 임의의 값) 생성
    • 세션 저장소에 세션 ID-데이터(키-값) 저장
    • 세션 ID로 쿠키 생성하여 HTTP 응답으로 클라이언트에 전달
  • 세션 조회
    • 클라이언트가 요청한 세션 ID로 세션저장소에서 데이터 조회
  • 세션 만료
    • 클라이언트가 요청한 세션 ID로 세션저장소에 보관한 데이터 제거

세션의 동작

로그인

  1. 클라이언트에서 로그인 정보(loginId, password)를 서버에 전송한다.
  2. 서버는 로그인 로직을 처리하고 올바른 로그인이면 세션 ID를 생성한다.
    • UUID(Universally unique identifier)는 식별이 불가능해 세션 ID로 사용된다.
    • 자바의 UUID를 사용하면 확실한 랜덤 값을 얻을 수 있다. (중복 가능성 희박)
  3. 서버의 세션 저장소에 세션 ID와 멤버를 묶어 보관한다.

로그인 상태

  1. 서버는 클라이언트에 mySessionId라는 이름으로 세션 ID만 쿠키에 담아 전달한다.
  2. 클라이언트는 쿠키 저장소에 mySessionId를 저장한다.

중요한 점은 클라이언트는 회원과 관련된 중요한 정보를 갖고있지 않는다는 점이다. 클라이언트가 가지고 있는 세션 ID는 추정불가능하여 그 값을 통해 다른 정보를 가져올 수 없다.

세션 방식 또한 서버-클라이언트가 결국 쿠키로 연결된다는 점을 기억하자!!


세션 적용 1 - HttpSession

HttpSession

HttpSession은 세션 생성, 조회, 만료라는 세션의 기본적인 기능을 제공하는 서블릿 객체이다. 추정 불가능한 세션 ID 값으로 JSESSIONID를 만들어준다. 추가로 오래 사용하지 않은 세션을 삭제하는 등 생명주기 관리 기능도 제공한다.

로그인 로직

 //로그인 성공
 //세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성
 HttpSession session = request.getSession();
 //세션에 로그인 회원 정보 보관
 session.setAttribute("loginMember", loginMember);
 return "redirect:/";

로그인에 성공하면 쿠키를 생성하는 대신 request에서 HttpSession을 획득한다. 그리고 session에 로그인한 회원과 일치하는 loginMember 객체를 담아 리다이렉트한다.

나머지 로직은 쿠키를 사용할 때와 동일하다.

getSession() - create 옵션

public HttpSession getSession(boolean create)

  • request.getSession(true) → 기본값
    • 세션이 있으면 - 기존 세션을 반환한다.
    • 세션이 없으면 - 새로운 세션을 생성해서 반환한다.
  • request.getSession(false)
    • 세션이 있으면 - 기존 세션을 반환한다.
    • 세션이 없으면 - 새로운 세션을 생성하지 않는다. null을 반환한다.

홈 화면 컨트롤러

@GetMapping("/")
public String homeLoginV3(HttpServletRequest request, Model model) {

 //세션이 없으면 일반 홈 화면
 HttpSession session = request.getSession(false);
 if (session == null) {
	 return "home";
 }
 
 //세션 조회
 Member loginMember = (Member) session.getAttribute("loginMember");

 //세션에 회원 데이터가 없으면 일반 홈 화면
 if (loginMember == null) {
	 return "home";
 }

 //세션이 유지되면 회원 전용 화면으로 이동
 model.addAttribute("member", loginMember);
 return "loginHome";
}
  • getSession(false) → 사용자가 로그인 없이 접속했을 때 세션이 불필요하게 생성되는 것을 방지
  • session.getAttribute() → 세션에 정보가 있는지 조회(로그인이 되어있는지 체크)

로그아웃

@PostMapping("/logout")
public String logoutV3(HttpServletRequest request) {

    HttpSession session = request.getSession(false);
    if (session != null) {
        session.invalidate();
    }
    return "redirect:/";
}
  • getSession(false) → 세션 정보를 제거하는 것이 목적이므로 세션이 없다고 새로 생성하면 안된다.
  • session.invalidate() → 세션의 모든 정보를 무효화한다.

세션 적용 2 - @SessionAttribute

@SessionAttribute를 사용하면 로그인 화면 코드를 훨씬 간략하게 쓸 수 있다.

@GetMapping("/")
public String homeLoginV3Spring(@SessionAttribute(name = "loginMember", required = false) Member loginMember, Model model) {

    if (loginMember == null) {
        return "home";
    }

    //세션이 유지되면 회원전용 화면 loginHome
    model.addAttribute("member", loginMember);
    return "loginHome";
}

세션 타임아웃

지금까지 만든 기능으로는 사용자가 로그아웃 버튼을 눌러야 session.invalidate()를 통해 세션이 만료된다. 그러나 실제로 로그아웃 버튼을 누르는 사용자는 별로 없다. 세션을 방치하면 해킹의 위험과 메모리 부족의 염려가 있다.

세션의 유효시간을 지정하여 마지막 사용으로부터 일정 시간이 지난 세션은 제거하도록 설정해보자.

  • 세션의 종료 시점은 생성 시점보다 마지막 사용 시점을 기준으로 삼는 것이 합리적이다. 기본 30분을 기준으로 상황에 따라 고려하면 된다.

스프링부트 글로벌 설정

application.properties에 다음 설정을 추가한다. 기본은 1800초(30분)이다.

server.servlet.session.timeout=60

로그인 후 60초가 지난 뒤 홈에 접속하면 로그아웃 상태로 홈 페이지가 열린다.


세션 정리

세션 덕분에 다음과 같은 쿠키의 보안 문제들을 해결할 수 있다.

  • 쿠키 값 변조 가능 ⇒ 예상 불가능한 복잡한 세션 ID를 사용한다.
  • 쿠키에 보관하는 정보는 클라이언트에서 탈취 위험 ⇒ 세션 ID가 해킹당해도 여기에는 중요한 정보가 없다.
  • 쿠키 탈취 후 사용 ⇒ 해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 세션의 만료시간을 짧게(예: 30분) 유지한다. 또는 해킹이 의심되는 경우 서버에서 해당 세션을 강제로 제거하면 된다.

세션의 한계

세션을 통해 쿠키의 보안 문제를 해결했지만, 홈 화면 컨트롤러처럼 로그인 상태에서만 접근할 수 있는 모든 페이지와 기능에서 세션을 통해 로그인 상태를 체크해야 한다는 문제점이 남아있다.

로그인 여부처럼 여러 로직에서 관심이 있는 것을 공통 관심사(cross-cutting concern)라고 한다. 공통 관심사는 스프링 AOP로도 처리할 수 있지만 웹과 관련된 공통 관심사는 서블릿 필터나 스프링 인터셉터를 사용하는 것이 좋다.




🔗 Reference

0개의 댓글