로그인 처리 - 쿠키, 세션

slee2·2022년 2월 14일
0

로그인 요구사항

  • 홈 화면 - 로그인 전
    • 회원 가입
    • 로그인
  • 홈 화면 - 로그인 후
    • 본인 이름(누구님 환영합니다.)
    • 상품 관리
    • 로그아웃
  • 보안 요구사항
    • 로그인 사용자만 상품에 접근하고, 관리할 수 있음
    • 로그인 하지 않은 사용자가 상품 관리에 접근하면 로그인 화면으로 이동
  • 회원가입, 상품 관리

패키지 구조 설계

  • hello.login
    • domain
      • item
      • member
      • login
    • web
      • item
      • member
      • login

도메인이 가장 중요하다.
도메인 = 화면, UI, 기술 인프라 등등의 영역을 제외한 비즈니스 업무 영역을 말함

web은 domain을 의존하지만, domain은 web을 의존하지 않도록 설계

form 같은 복잡한 케이스는 예외로 둔다.

홈 화면

회원 가입

Member

package hello.login.domain.member;

import lombok.Data;

import javax.validation.constraints.NotEmpty;

@Data
public class Member {

    private Long id;

    @NotEmpty
    private String loginId; //로그인 ID
    @NotEmpty
    private String name; //사용자 이름
    @NotEmpty
    private String password;
}

MemberRepository

package hello.login.domain.member;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;

import java.util.*;

@Slf4j
@Repository
public class MemberRepository {

    private static Map<Long, Member> store = new HashMap<>(); //static 사용
    private static long sequence = 0L;//static 사용

    public Member save(Member member) {
        member.setId(++sequence);
        log.info("save: member={}", member);
        store.put(member.getId(), member);
        return member;
    }

    public Member findById(Long id) {
        return store.get(id);
    }

    public Optional<Member> findByLoginId(String loginId) {
        return findAll().stream()
                .filter(m -> m.getLoginId().equals(loginId))
                .findFirst();
    }

    public List<Member> findAll() {
        return new ArrayList<>(store.values());
    }

    public void clearStore() {
        store.clear();
    }
}

MemberController

package hello.login.web.member;

import hello.login.domain.member.Member;
import hello.login.domain.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
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 javax.validation.Valid;

@Controller
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("/add")
    public String addForm(@ModelAttribute("member") Member member) {
        return "members/addMemberForm";
    }

    @PostMapping("/add")
    public String save(@Valid @ModelAttribute Member member, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "members/addMemberForm";
        }

        memberRepository.save(member);
        return "redirect:/";
    }
}

이제는 보이는 기본적인 로직

addMemberForm.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>

    <h4 class="mb-3">회원 정보 입력</h4>

    <form action="" th:action th:object="${member}" 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>
        <div>
            <label for="name">이름</label>
            <input type="text" id="name" th:field="*{name}" class="form-control"
                   th:errorclass="field-error">
            <div class="field-error" th:errors="*{name}" />
        </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>

테스트용 데이터 추가

package hello.login;

import hello.login.domain.item.Item;
import hello.login.domain.item.ItemRepository;
import hello.login.domain.member.Member;
import hello.login.domain.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;

@Component
@RequiredArgsConstructor
public class TestDataInit {

    private final ItemRepository itemRepository;
    private final MemberRepository memberRepository;

    /**
     * 테스트용 데이터 추가
     */
    @PostConstruct
    public void init() {
        itemRepository.save(new Item("itemA", 10000, 10));
        itemRepository.save(new Item("itemB", 20000, 20));

        Member member = new Member();
        member.setLoginId("test");
        member.setPassword("test!");
        member.setName("테스터");

        memberRepository.save(member);

    }

}

로그인 기능

LoginService

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;

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

LoginController

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">
    <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>

로그인 처리 - 쿠키 사용

이전 강의 모든 개발자를 위한 HTTP 기본 지식 강의를 참고

homeController 변경

쿠키값이 null이거나 해당하는 회원이 없으면 home으로 로그인에 성공했다면 loginHome으로 보낸다.

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" onclick="location.href='items.html'" type="submit">
                    로그아웃
                </button>
            </form>
        </div>
    </div>

    <hr class="my-4">

</div> <!-- /container -->

</body>
</html>

로그아웃 기능

쿠키값을 없애기 위해서는 쿠키의 남은 시간을 0으로 만들어주면 된다.

이런식으로 쿠키가 없어지게 된다.

그런데 이러한 방식은 보안상 정말 심각한 문제가 발생할 수 있다.

쿠키와 보안 문제

쿠키를 사용해서 memberId를 전달해서 로그인을 유지할 수 있었다. 그런데 여기서 심각한 문제가 있다.

쿠키의 값은 임의로 바꿀 수 있다.

실제 웹 브라우저의 개발자 모드에서 쿠키값을 임의로 변경이 가능하다.
그렇게 다른 값을 넣으면 다른 사용자로 이동하게 된다.

쿠키에 보관된 정보는 훔쳐갈 수 있다.

개인정보, 신용카드 정보 등을 쿠키에 있다면, 쿠키의 정보가 털리면서 내 정보도 털릴 수 있다.

해커가 쿠키를 한 번 훔쳐가면 평생 쓸 수 있다.

지속적으로 요청을 시도해서 계속 사용할 수 있다.

대안

  • 쿠키에 중요한 값을 노출하지 않고, 사용자 별로 예측 불가능한 임의의 토큰(랜덤 값)을 노출하고, 서버에서 토큰과 사용자 id를 매핑해서 인식한다.
  • 해커가 토큰을 털어가도 일정 시간이 지나가서 다음에는 쓰지 못하도록 만료시간을 짧게(예:30분)을 유지해야한다. 또는 해킹이 의심되는 경우 쿠키를 강제로 제거하면 된다.(중국에서 IP가 감지되었다던지)

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

앞서 쿠키에 중요한 정보를 보관하는 방법은 여러가지 보안 이슈가 있었다. 이 문제를 해결하려면 결국 중요한 정보를 모두 서버에 저장해야 한다. 그리고 클라이언트와 서버는 추정 불가능한 임의의 식별자 값으로 연결해야 한다.

이렇게 서버에 중요한 정보를 보관하고 연결을 유지하는 방법을 세션이라고 한다.

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

세션 ID를 생성하는데, 추정 불가능해야 한다.
UUID를 쓰면 된다.
생성된 세션 ID와 세션에 보관함 값(memberA)을 서버의 세션 저장소에 보관한다.

sessionID를 쿠키에 저장한다.

여기서 중요한 포인트는 회원과 관련된 정보는 전혀 클라이언트에 전달하지 않는다는 것이다.

다시 쿠키를 불러오면 이 쿠키값을 세션 저장소에서 찾아서 해당 value를 찾아서 사용한다.

정리
이와 같이 한다면 다음과 같은 보안 문제를 해결할 수 있다.

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

세션 직접 만들기

  • 세션 생성
    • sessionId 생성(랜덤)
    • 세션 저장소에 sessionId와 보관할 값 저장
    • sessionId로 응답 쿠키를 생성해서 클라이언트에 전달
  • 세션 조회
    • 클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 값 조회
  • 세션 만료
    • 클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 sessionId와 값 제거

SessionManager

package hello.login.web.session;

import org.springframework.stereotype.Component;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 세션 관리
 */
@Component
public class SessionManager {

    public static final String SESSION_COOKIE_NAME = "mySessionId";
    private Map<String, Object> sessionStore = new ConcurrentHashMap<>();

    /**
     * 세션 생성
     */
    public void createSession(Object value, HttpServletResponse response) {

        //세션 id를 생성하고, 값을 세션에 저장
        String sessionId = UUID.randomUUID().toString();
        sessionStore.put(sessionId, value);

        //쿠키 생성
        Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
        response.addCookie(mySessionCookie);
    }

    /**
     * 세션 조회
     */
    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 sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
        if (sessionCookie != null) {
            sessionStore.remove(sessionCookie.getValue());
        }
    }


    public Cookie findCookie(HttpServletRequest request, String cookieName) {
        if (request.getCookies() == null) {
            return null;
        }
        return Arrays.stream(request.getCookies())
                .filter(cookie -> cookie.getName().equals(cookieName))
                .findAny()
                .orElse(null);
    }

}

SessionMaanagerTest

package hello.login.web.session;

import hello.login.domain.member.Member;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;

import javax.servlet.http.HttpServletResponse;

import static org.assertj.core.api.Assertions.*;

class SessionManagerTest {

    SessionManager sessionManager = new SessionManager();

    @Test
    void sessionTest() {

        //세션 생성
        MockHttpServletResponse response = new MockHttpServletResponse();
        Member member = new Member();
        sessionManager.createSession(member, response);

        //요청에 응답 쿠키 저장
        MockHttpServletRequest request = new MockHttpServletRequest();
        request.setCookies(response.getCookies());

        //세션 조회
        Object result = sessionManager.getSession(request);
        assertThat(result).isEqualTo(member);

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

    }
}

직접 만든 세션 적용하기

로그인

로그아웃

이런식으로 세션값이 랜덤으로 등록되게 된다.
그런데 프로젝트마다 이러한 세션 개념을 직접 개발하는 것은 불편하다.
그래서 서블릿이 지원한다.

서블릿은 앞서 만든 기능 말고도 일정시간 사용하지 않으면 해당 세션을 삭제하는 기능도 제공한다.

서블릿 HTTP 세션

HttpSession

서블릿이 제공하는 HttpSessionSessionManager와 같은 방식으로 동작한다.
서블릿을 통해 HttpSession을 생성하면 아래와 같은 쿠키값을 생성한다.
Cookie: JSESSIONID=5B78E23B513F50164D6FDD8C97B0AD05

세션 상수


세션 상수의 경우 자바 클래스로 예시는 생성했지만, 인터페이스나 abstract 등을 이용해서 직접 생성하는 것은 막아두는 것이 좋다.

로그인

세션을 얻기 위해 request.getSession(true)를 이용한다.

request.getSession(true)

  • 세션이 있으면 기존 세션을 반환한다.
  • 세션이 없으면 새로운 세션을 생성해서 반환한다.

request.getSession(false)

  • 세션이 있으면 기존 세션을 반환한다.
  • 세션이 없으면 새로운 세션을 생성하지 않는다. null을 반환한다.

로그아웃

세션을 생성할 의도가 없다면 false로 두면 된다.

이제 더 편한 사용방법을 알아보자

@SessionAttribute

이미 로그인 된 사용자를 찾을 때는 이와 같이 사용하면 된다. 참고로 이 기능은 세션을 생성하지 않는다.
@SessionAttribute(name = "loginMember", required = false) Member loginMember

TrackingModes

로그인을 처음 시도하면 URL이 밑에처럼 jsessionid를 포함하고 있다.

http://localhost:8080/;jsessionid=F59911518B921DF62D09F0DF8F83F872

이것은 웹 브라우저가 쿠키를 지원하지 않을시 url로 이용하기 위해 넣은 것이다. 그런데 현실성이 없어서 잘 안쓴다.

이 방식을 끄고 싶으면 application.properties에 아래 내용을 추가하면 된다.
server.servlet.session.tracking-modes=cookie

세션정보와 타임아웃 설정

SessionInfoController

sessionId : 세션 id 값
maxInactiveInterval : 세션의 유효시간, 예) 1800초(30분)
creationTime : 세션 생성일시
lastAccessedTime : 세션과 연결된 사용자가 최근에 서버에 접근한 시간, 클라이언트에서 서버로
isNew : 새로 생성된 세션인지. 이미 있는 세션을 불러온 것이므로 false

세션 타임아웃

세션은 사용자가 로그아웃을 직접 호출해서 session.invalidate()가 호출 되는 경우에 삭제된다. 그런데 대부분의 사용자는 로그아웃 안하고 그냥 근다.
문제는 HTTP가 비 연결성이므로 서버 입장에서는 해당 사용자가 웹 브라우저를 종료한 것인지 인식할 수 없다. 따라서 서버에서 세션 데이터를 언제 삭제해야 하는지 판단하기가 어렵다.

하지만 남아있는 세션을 무한정 보관하면 다음과 같은 문제가 발생할 수 있다.

세션과 관련된 쿠키를 탈취 당했을 경우 오랜 시간이 지나도 계속 요청이 가능하다.
세션은 기본적으로 메모리에 생성된다. 메모리의 크기가 무한하지 않기 때문에 꼭 필요한 경우에만 생성해서 사용해야 한다.
10만명의 사용자가 로그인하면 10만개의 세션이 생성된다.

세션의 종료 시점

가장 단순하게 보면 생성 시점으로부터 30분 정도로 잡는 것이다. 그런데 30분마다 다시 로그인을 해야한다는 것은 사용자 입장에서 불편하다.

더 나은 방법은 세션 생성 시점이 아니라 사용자가 최근에 요청한 시간을 기준으로 30분 정도를 유지해주는 것이다. 이렇게 되면 사용자가 서비스를 사용하고 있다면 이용한 시점에서 30분동안 유지되는 것이다. HttpSession은 이 방식을 사용한다.

세션 타임아웃 설정

application.properties
server.servlet.session.timeout=60: 60초, 기본은 1800(30분)

특정 세션 단위로 시간을 설정하고 싶다면, 세션을 얻어와서
session.setMaxInactiveInterval(1800);
로 설정해주면 된다.

세션 타임아웃 발생

세션은
LstAccessedTime 이후로 time-out시간이 되면, WAS가 내부에서 해당 세션을 제거한다.

실무에서 주의할 점은 세션에는 최소한의 데이터만 보관해야 한다는 점이다. 보관한 데이터 용량 * 사용자 수로 세션의 메모리 사용량이 급격하게 늘어나서 장애로 이어질 수 있다. 예제에서는 멤버 전체를 담았지만, 멤버의 아이디만 보관한다던지 여러 방법으로 최소한만 사용하도록 하자.

0개의 댓글