[스프링 MVC 2편 - 백엔드 웹 개발 활용 기술] 06. 로그인 처리1 - 쿠키, 세션

Turtle·2024년 7월 15일
0
post-thumbnail

🙄로그인 처리 - 쿠키 사용

  • ✔️쿠키 활용 - 서버에서 로그인에 성공하면 HTTP 응답에 쿠키를 담아서 브라우저에 전달 → 클라이언트는 쿠키를 받아 스토리지에 쿠키를 저장
    • 영속 쿠키 : 만료 날짜를 입력하면 그 날짜까지 유지
    • 세션 쿠키 : 만료 날짜를 생략하면 브라우저 종료 시까지 유지
    • 로그인에 성공하면 HttpServletResponse 응답 객체에 쿠키를 저장한 후 이를 웹 브라우저에 반환
    • 클라이언트는 이 응답 내의 쿠키를 쿠키 저장소에 별도 보관
    • 로그인 이후 인증을 필요로 하는 페이지로 이동하고자 하는 경우 해당 쿠키 정보를 자동 포함 시킨다.
    • 로그아웃도 응답 쿠키를 생성하는데 Max-Age=0으로 설정한 새로운 응답 쿠키를 만들어 이를 웹 브라우저에 반환하면 해당 쿠키는 즉시 소멸된다.
@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {

	private final MemberRepository memberRepository;
	private final LoginService loginService;

	@GetMapping("/login")
	public String loginForm(@ModelAttribute(name = "loginForm") LoginForm loginForm) {
		return "login/loginForm";
	}

	@PostMapping("/login")
	public String login(@Valid @ModelAttribute(name = "loginForm") LoginForm loginForm, BindingResult bindingResult, HttpServletResponse response) {
		if (bindingResult.hasErrors()) {
			return "login/loginForm";
		}

		Member login = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
		log.info("login={}", login);
		if (login == null) {
			bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
			return "login/loginForm";
		}

		// 로그인에 성공 → 쿠키를 만들어 웹 브라우저 응답으로 보낸다.
		// 날짜 정보 생략시 → 디폴트는 세션 쿠키
		// public Cookie(String name, String value)
		Cookie cookie = new Cookie("memberId", String.valueOf(login.getId()));
		response.addCookie(cookie);
		return "redirect:/";
	}

	// 쿠키 제거 방법
	// 만료 시간을 0으로 설정한 쿠키를 새로 만들고 이걸 발급한다.
	@PostMapping("/logout")
	public String logout(HttpServletResponse response) {
		Cookie cookie = new Cookie("memberId", null);
		cookie.setMaxAge(0);
		response.addCookie(cookie);
		return "redirect:/";
	}
}
@Slf4j
@Controller
@RequiredArgsConstructor
public class HomeController {

    private final MemberRepository memberRepository;

	//@GetMapping("/")
    public String home() {
        return "redirect:/items";
    }

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

🙄쿠키와 보안 문제👍

쿠키를 사용해서 로그인 ID를 전달하여 로그인을 유지할 수 있었다. 허나 이 방식은 심각한 보안 문제를 초래한다.

  • ❗쿠키 값은 임의 변경이 가능하다.
    • 클라이언트가 쿠키를 강제로 변경하면 다른 사용자가 된다.
    • 실제 웹 브라우저 개발자 모드를 통해서 변경을 시도하면 바로 변경이 된다.
  • ❗쿠키에 보관된 정보는 훔쳐갈 수 있다.
    • 쿠키에 예민한 정보가 포함된다면 로컬 PC에서 털릴 수도 있고 네트워크를 통한 전송 구간에서 털릴 수도 있다.
    • 해커가 쿠키를 훔쳐가서 악의적 요청을 계속 시도할 수 있다.
  • ✔️결론
    • 쿠키에 중요한 값을 노출하지 않고 사용자 별로 예측 불가능한 임의의 토큰을 노출하고 서버에서 토큰과 사용자 ID를 매핑해서 인식하도록 한다. 그리고 서버에서 토큰을 관리한다.
    • 토큰은 무차별 대입 공격으로도 찾을 수 없도록 해야 한다.
    • 해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 토큰 만료 시간을 짧게 지정하는 것이 좋다.

🙄로그인 처리하기 - 세션 동작 방식

사용자가 loginIdpassword 정보를 전달하면 서버에서 해당 사용자가 맞는지 확인한다.

세션 ID를 생성하는데 이 때, 세션 ID는 추정 불가능해야 한다. UUID는 추정이 불가능하다. 생성된 세션 ID와 세션에 보관할 값을 서버의 세션 저장소에 보관한다.
만든 세션의 세션 ID를 응답 쿠키에 넣어 웹 브라우저로 보낸다.

클라이언트와 서버는 결국 쿠키로 연결이 되어야 한다. 서버는 클라이언트에 mySessionId라는 이름으로 세션 ID만 쿠키에 담아서 전달한다. 클라이언트는 쿠키 저장소에 mySessionId 쿠키를 보관한다.

로그인 이후 클라이언트는 요청 시 항상 mySessionId 쿠키를 전달한다. 서버에서는 클라이언트가 전달한 쿠키 정보로 세션 저장소를 조회해서 로그인시 보관한 세션 정보를 사용한다.

  • ✔️결론
    • 쿠키 대신 세션을 사용하면서 변조 가능한 쿠키 대신 추정 불가능한 세션 ID를 사용하여 보안을 강화한다.
    • 쿠키에 보관하는 정보는 클라이언트 해킹 시 털릴 가능성이 있지만 이 세션 ID에는 중요한 정보가 없어 털려도 쿠키보다 상대적으로 안전하다.
    • 서버에서 세션의 만료 시간을 짧게 유지한다. 또는 해킹이 의심된다면 서버에서 해당 세션을 강제로 제거하면 된다.

🙄로그인 처리하기 - 세션 직접 만들기

  • ✔️세션 관리
    • 세션 생성
    • 세션 조회
    • 세션 만료
@Component
public class SessionManager {

	private static final String SESSION_COOKIE_NAME = "mySessionId";
	// 세션 스토어 구성 : <세션 ID, value>
	private Map<String, Object> sessionStore = new ConcurrentHashMap<>();

	// 세션 생성
	public void createSession(Object value, HttpServletResponse response) {
		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 cookie = findCookie(request, SESSION_COOKIE_NAME);
		if (cookie == null) {
			return null;
		}
		return sessionStore.get(cookie.getValue());
	}

	private 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))
				.findAny()
				.orElse(null);
	}

	// 세션 만료
	public void expire(HttpServletRequest request) {
		Cookie cookie = findCookie(request, SESSION_COOKIE_NAME);
		if (cookie != null) {
			sessionStore.remove(cookie.getValue());
		}
	}
}
  • ✔️테스트 코드 작성
    • 실제 요청과 응답을 주고 받는 것이 아니기 때문에 가짜 객체를 만드는데 이 때, Mock을 사용한다.
    • HttpServletRequest, HttpServletResponse 대신에 MockServletRequest, MockServletResponse를 사용한다.
class SessionManagerTest {

	private SessionManager sessionManager = new SessionManager();

	@Test
	@DisplayName("세션 테스트 코드")
	void sessionTest() {
		// Mock : 가짜 객체
		// 세션 생성
		MockHttpServletResponse response = new MockHttpServletResponse();
		Member member = new Member();
		// 세션 생성한다는 것 = 클라이언트의 로그인이 성공했다는 것
		sessionManager.createSession(member, response);

		// 요청에도 역시 Mock 가짜 객체를 활용한 요청이 있어야 함
		// 세션 조회
		MockHttpServletRequest request = new MockHttpServletRequest();
		request.setCookies(response.getCookies());

		Object session = sessionManager.getSession(request);
		Assertions.assertThat(session).isEqualTo(member);

		// 세션 만료
		sessionManager.expire(request);
		Object findSession = sessionManager.getSession(request);
		Assertions.assertThat(findSession).isNull();
	}
}

🙄로그인 처리하기 - 직접 만든 세션 적용

@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {

	private final SessionManager sessionManager;
	private final MemberRepository memberRepository;
	private final LoginService loginService;

	// ...
    
	@PostMapping("/login")
	public String loginV2(@Valid @ModelAttribute(name = "loginForm") LoginForm loginForm, BindingResult bindingResult, HttpServletResponse response) {
		if (bindingResult.hasErrors()) {
			return "login/loginForm";
		}

		Member login = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
		log.info("login={}", login);
		if (login == null) {
			bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
			return "login/loginForm";
		}

		// 로그인 성공 처리
		// 세션 관리자를 통해 세션을 생성
		sessionManager.createSession(login, response);
		return "redirect:/";
	}

	@PostMapping("/logout")
	public String logoutV2(HttpServletRequest request) {
		sessionManager.expire(request);
		return "redirect:/";
	}
}
@Slf4j
@Controller
@RequiredArgsConstructor
public class HomeController {

    private final MemberRepository memberRepository;
    private final SessionManager sessionManager;

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

        Object member = sessionManager.getSession(request);
        if (member == null) {
            return "home";
        }

        model.addAttribute("member", member);
        return "loginhome";
    }
}
  • ✔️세션 관리자 역할
    • 세션을 생성, 세션을 조회, 세션을 삭제
    • 직접 세션을 개발하는 것은 상당히 불편하다. 서블릿이 공식 지원하는 세션을 도입해보자.

🙄로그인 처리하기 - 서블릿 HTTP 세션1

서블릿에서 세션을 위해 HttpSession이라는 기능을 제공한다. 서블릿을 통해 HttpSession을 생성하면 다음과 같은 쿠키를 생성한다. 쿠키 이름이 JSESSIONID 이고, 값은 추정
불가능한 랜덤 값이다.

Cookie: JSESSIONID=5B78E23B513F50164D6FDD8C97B0AD05
@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {

	private final SessionManager sessionManager;
	private final MemberRepository memberRepository;
	private final LoginService loginService;

	@PostMapping("/login")
	public String loginV3(@Valid @ModelAttribute(name = "loginForm") LoginForm loginForm, BindingResult bindingResult, HttpServletRequest request) {
		if (bindingResult.hasErrors()) {
			return "login/loginForm";
		}

		Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
		log.info("login={}", loginMember);
		if (loginMember == null) {
			bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
			return "login/loginForm";
		}

		// HttpSession 도입
		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:/";
	}
}
  • ✔️세션 생성과 조회
    • 세션을 생성하려면 request.getSession(true)를 사용한다.
    • 세션의 create 옵션에는 boolean 타입의 truefalse가 들어갈 수 있다.
    • true의 경우 세션이 있으면 기존 세션을 반환하고 세션이 없으면 새로운 세션을 생성해서 반환한다. → login
    • false의 경우 세션이 있으면 기존 세션을 반환하고 세션이 없으면 새로운 세션을 생성하지 않는다. → logout
    • 로그아웃 시 만약 세션이 존재한다면 이를 무효화해주면 된다.
@Slf4j
@Controller
@RequiredArgsConstructor
public class HomeController {

    private final MemberRepository memberRepository;
    private final SessionManager sessionManager;

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

        HttpSession session = request.getSession(false);
        if (session == null) {
            return "home";
        }

        Member loginMember = (Member) session.getAttribute(SessionConst.LOGIN_MEMBER);
        model.addAttribute("member", loginMember);
        return "loginhome";
    }
}

🙄로그인 처리하기 - 서블릿 HTTP 세션2

TrackingModes

로그인을 처음 시도하면 URL이 다음과 같이 나오는 것을 볼 수 있다.

http://localhost:8080/;jsessionid=4A1CEB05E35DEFC202610867420EB480

이것은 웹 브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해서 세션을 유지하는 방법이다. 이 방법을 사용하려면 URL에 이 값을 계속 포함해서 전달해야 한다. 타임리프 같은 템플릿은 엔진을 통해서 링크를 걸면 jsessionid를 URL에 자동으로 포함해준다. 서버 입장에서 웹 브라우저가 쿠키를 지원하는지 안하는지 여부는 최초에 판단하지 못하므로 쿠키 값도 전달하고 jsessionid도 함께 전달한다. URL 전달 방식을 끄고 항상 쿠키를 통해서만 세션을 유지하고 싶다면 application.properties에 옵션을 지정해주면 된다.

server.servlet.session.tracking-modes=cookie

🙄로그인 처리하기 - 세션 정보와 타임아웃 설정

@Slf4j
@RestController
public class SessionInfoController {

	@GetMapping("/session-info")
	public String sessionInfo(HttpServletRequest request) {
		HttpSession session = request.getSession(false);
		if (session == null) {
			return "세션이 없습니다.";
		}

		session.getAttributeNames().asIterator()
				.forEachRemaining(name -> log.info("session name={}, value={}", name, session.getAttribute(name)));

		log.info("sessionId={}", session.getId());
		log.info("getMaxInactiveInternal={}", session.getMaxInactiveInterval());
		log.info("getCreationTime={}", new Date(session.getCreationTime()));
		log.info("isNew={}", session.isNew());
		return "세션 출력";
	}
}

실행 결과

2024-07-16 17:17:56.570  INFO 15524 --- [nio-8080-exec-6] hello.login.web.login.LoginController    : login=Member(id=1, loginId=test, password=password, name=test)
2024-07-16 17:18:06.553  INFO 15524 --- [nio-8080-exec-7] h.l.d.session.SessionInfoController      : session name=login_member, value=Member(id=1, loginId=test, password=password, name=test)
2024-07-16 17:18:06.553  INFO 15524 --- [nio-8080-exec-7] h.l.d.session.SessionInfoController      : sessionId=87414411A91B454D9C07A9412712B312
2024-07-16 17:18:06.553  INFO 15524 --- [nio-8080-exec-7] h.l.d.session.SessionInfoController      : getMaxInactiveInternal=1800
2024-07-16 17:18:06.553  INFO 15524 --- [nio-8080-exec-7] h.l.d.session.SessionInfoController      : getCreationTime=Tue Jul 16 17:17:56 KST 2024
2024-07-16 17:18:06.554  INFO 15524 --- [nio-8080-exec-7] h.l.d.session.SessionInfoController      : isNew=false
  • ✔️세션 타임아웃
    • 세션은 사용자가 로그아웃을 직접 호출해서 session.invalidate()가 호출되는 경우에 삭제된다.
    • 그런데 대부분의 사용자는 로그아웃을 선택하지 않고 웹 브라우저를 종료한다.
    • HTTP는 비연결성이므로 서버 입장에서 해당 사용자가 웹 브라우저를 종료한 것인지 아닌지를 인식할 수 없다. 따라서 서버에서 세션 데이터를 언제 삭제해야 하는지 판단하기가 어렵다.
    • 세션을 무한정 보관하면 오랜 시간이 지나도 해당 쿠키로 악의적 요청이 가능하다.
    • 사용자의 증가와 비례하여 세션의 개수 역시 증가한다. 따라서 이 방식은 서버 부하가 증가할 수 밖에 없다.
    • 사용자가 서버에 최근에 요청한 시간을 기준으로 30분 정도로 유지해주는 것, 이렇게 하면 사용자가 서비스를 사용하고 있으면 세션 생존 시간이 30분으로 계속 늘어난다. HttpSession은 이 방식을 사용한다.
    • 스프링 부트로 세션 타임아웃을 글로벌 설정하려면 application.properties에 아래와 같이 설정한다.
    • 서블릿의 HttpSession이 제공하는 타임아웃 기능 덕분에 세션을 안전하고 편리하게 사용할 수 있다. 실무에서 주의할 점은 세션에는 최소한의 데이터만 보관해야 한다는 점이다. 보관한 데이터 용량 x 사용자 수로 세션 메모리 사용량이 급증하면서 장애로 이어질 수 있다.
    • 추가로 세션 시간을 너무 길게 가져가면 메모리 사용이 계속 누적될 수 있으므로 적당한 시간을 선택하는 것이 필요하다. 기본이 30분이라는 것을 기준으로 고민하면 된다.
server.servlet.session.timeout=60 // 60초(=1분)

0개의 댓글