스프링 공부 (인프런 김영한 선생님)/스프링 MVC 2편

[스프링 웹 MVC 2편] 008. 로그인 처리 - 쿠키. 세션

ProgYun. 2023. 8. 16. 21:54

쿠키를 이용한 로그인 처리

로그인 상태 유지에 쿼리 파라미터를 유지하는 방법은 어렵고 번거로우며
때에 따라서는 보안 문제에 취약하다는 단점이 있다.

따라서 우리는 쿼리 파라미터를 이용하는 방법이 아닌 웹 브라우저에 쿠키를 담아보내는 방법으로 요청시마다 웹브라우저가 쿠키를 함께 발송하게 끔하여 로그인 세션을 유지하는 방법을 공부해보겠다.

 

 

웹 서버에서 클라이언트 브라우저에 쿠키를 전송하는 방식은 위와 같고
클라이언트 브라우저에서 수신한 쿠키는 아래 그림과 같이 모든 요청시마다 서버로 전송된다.


쿠키의 종류

쿠키의 종류에는 두가지가 있다

  • 영속 쿠키 : 만료 날짜를 입력하면 해당 날짜까지 유지
  • 세션 쿠키 : 만료 날짜를 생략하면 브라우저 종료시까지만 유지

로그인 구현 - 쿠키 사용

1) LoginController 구현

로그인 성공시 세션 쿠키를 생성하는 코드는 다음과 같다.

//@PostMapping("/login")  
public String loginFormV1(@Validated LoginForm loginForm, BindingResult bindingResult, HttpServletResponse response){  
    if(bindingResult.hasErrors()){  
        return "login/loginForm";  
    }  

    Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());  

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

    // 로그인 처리 TODO 쿠키를 만들어서 브라우저로 전송하면 브라우저에서 요청시마다 쿠키를 함께 전달함  

    // 쿠키에 시간 정보를 주지 않으면 세션쿠키로 자동 설정됨.  
    Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));  
    response.addCookie(idCookie);  
    // HttpServletResponse가 쿠키 설정을 위해 필요함.  

    return "redirect:/";  

}

쿠키 생성 로직은 아래와 같다

Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));  
    response.addCookie(idCookie); 

로그인이 성공하면 쿠키가 생성되고 컨트롤러는 그 쿠키를 HttpServletResponse에 담는다.
쿠키이름은 memberId이며 값은 회원의 id를 담아둔다 (key, value) 형태와 비슷한 것 같다.

웹 브라우저는 종료전까지 회원의 id를 서버에 계속 보내줄 것이다.
영속쿠키로 설정하는 경우 말이 다를 수 있다.

Home화면에서 로그인 유무에 따라 다음과 같이 화면 렌더링에 차이를 둘 수 있다.

    @GetMapping("/")  
    public String homeLogin(@CookieValue(name="memberId", required = false) Long memberId, Model model){  
        // 쿠키 값은 String인데 스프링이 알아서 Converting 해줌  

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

        Member loginMember = memberRepository.findById(memberId);  
        if(loginMember == null){  
            return "home";  
        }  

        model.addAttribute("member", loginMember);  
        return "loginHome";  


    }

@CookieValue를 통해 편리하게 Cookie를 조회할 수 있다.

이 때 조회할 CookieKey(또는 name)을 명시해주어야 하며, required=true로 설정하면 그 쿠키를 의무적으로 갖고 있어야하기 때문에 여기서는 로그인 하지 않은 사용자도 home에 접근할 수 있으므로 false로 설정한다.

memberId라는 쿠키를 갖고 있지 않은 경우 이 컨트롤러는 if문 분기를 통해 home으로 보내지만
로그인 한 사용자의 경우 loginHome이라는 별도의 홈 화면 페이지로 보낸다.

이 때 추가로 member에 대한 정보도 loginHome에서는 별도로 출력해야하기 때문에 모델에 담아서 전달한다.

<!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" type="submit">  
                로그아웃  
             </button>  
          </form>       </div>    </div>    <hr class="my-4">  
</div> <!-- /container -->  
</body>  
</html>

로그아웃 기능

로그아웃 기능은 다음과 같이 쿠키의 세션 시간을 0으로 설정하여 만료시킴으로써 구현할 수 있다.
또는 세션쿠키로 설정하기 때문에 브라우저 종료 시에 쿠키가 만료된다.
해당 기능을 구현한 컨트롤러 코드는 아래와 같다.

@PostMapping("/logout")  
public String logout(HttpServletResponse response){  
    expireCookie(response);  
    return "redirect:/";  
}

private void expireCookie(HttpServletResponse response) {  
    Cookie cookie = new Cookie("memberId", null);  
    cookie.setMaxAge(0);  
    response.addCookie(cookie);  
    // 클라이언트에 정보 저장시 뭐든 문제가 생길 수 있음 쿠키 위변조 또는 탈취시 큰 문제가 생긴다.  
    // 전송 관련한 문제는 HTTPS인데 PC가 털려버리면 답이 없다.  
    // 네트워크 전송구간에서 왔다갔다하는게 보여서 털릴 수 있음.  
}

로그아웃도 응답 쿠키를 형성하는데 이름이 같으면 브라우저에 존재하는 쿠키가 갱신된다.

쿠키와 보안 문제

  • 쿠키값은 임의로 변경하면 다른 사용자가 될 수 있다.
  • 쿠키에 보관된 정보는 탈취가 가능하다.
  • 쿠키를 탈취자가 한번 가져가면 계속적으로 악의적인 요청을 전송할 수 있다.

대안은 UUID를 사용함으로써 중복될 확률이 거의 없는 값을 쿠키값으로 제시하고
쿠키에 대해 사용자를 인식하는 과정 자체는 웹서버에서 진행함으로써 웹서버 내의 사용자 정보와 쿠키값을 1:1 대응 시키지 않는 방법이 있다 (핵심 토큰은 서버에서 관리)

만료시간을 짧게 가져가자, 해킹 의심시 웹 서버에서 토큰 강제로 제거하면 되는 해결책도 있다.

-> 다만 이 경우 웹서버에 쿠키 관련한 메모리를 최소화해야한다, 조금만 쿠키에 관한 정보가 늘어나도 수백만 사용자가 사용한다고 가정하면 최적화에 문제가 생길 수 있다.

따라서 우리는 다음과 같이 로그인을 세션 동작방식으로 다시 구현해보겠다.


로그인 구현 - 세션 동작 방식

  1. 중요한 정보는 모두 서버에 저장한다
  2. 클라이언트와 서버는 추적 불가능한 임의 식별자 값을 이용하여 상호 연결된다.(비연결성임에 주의)

세션 동작 방식의 WorkFlow는 다음과 같다.

  1. 사용자가 loginId, password 정보를 전달하면 서버에서 해당 사용자가 맞는지 확인한다.
  2. 확인되는 경우 서버에서는 세션ID를 생성한다(UUID를 이용)
  3. 생성된 세션ID와 세션에 보관할 값(member 정보)를 서버의 세션 저장소에 보관한다.
  4. 이후 세션 ID를 응답 쿠키에 담아 전달한다. (회원과 관련된 정보는 전혀 클라이언트로 전달되지 않는다.)

클라이언트에서는....

  1. 클라이언트는 요청시 전달받았던 세션ID 쿠키를 함께 전달한다.
  2. 서버에서는 클라이언트가 전달한 쿠키정보로 세션 저장소를 조회하고 로그인시 보관한 세션정보를 사용한다.

세션 직접 만들기

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){  
        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());  
        }  
    }  

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

}
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.HttpServletRequest;  
import javax.servlet.http.HttpServletResponse;  

import static org.junit.jupiter.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);  

        Assertions.assertThat(result).isEqualTo(member);  

        sessionManager.expire(request);  

        Object expired = sessionManager.getSession(request);  

        Assertions.assertThat(expired).isNull();  

    }  

}

MockHttpServletRequest의 경우 스프링을 사용하지 못하는 테스트에서 가상으로 HttpServletRequests를 사용할 수 있도록 해준다.

로그인 처리 - 직접 만든 세션 적용해보기

// @Autowired를 통한 SessionManager 주입
private final SessionManager sessionManager;

@PostMapping("/login")  
public String loginFormV2(@Validated LoginForm loginForm, BindingResult bindingResult, HttpServletResponse response){  
    if(bindingResult.hasErrors()){  
        return "login/loginForm";  
    }  

    Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());  

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

    // 로그인 처리 TODO 쿠키를 만들어서 브라우저로 전송하면 브라우저에서 요청시마다 쿠키를 함께 전달함  

    // 쿠키에 시간 정보를 주지 않으면 세션쿠키로 자동 설정됨.  
    //Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));    //response.addCookie(idCookie);    // HttpServletResponse가 쿠키 설정을 위해 필요함.  

    sessionManager.createSession(loginMember, response);  

    return "redirect:/";  

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

로그아웃시 해당 세션의 정보를 제거한다.
클라이언트 브라우저에서는 쿠키가 남아있어도 웹 서버에서는 의미가 없어지기 때문에 사실상 없는게 된다.