빙응의 공부 블로그

[Spring]스프링 MVC 2편 - 쿠키와 보안 본문

Spring/인프런_개념

[Spring]스프링 MVC 2편 - 쿠키와 보안

빙응이 2024. 2. 1. 13:40

📝로그인과 쿠키

로그인을 할때 매번 로그인을 하게 만들면 매우 불편하다. 한번 로그인을 하면 어느 시점까지 자동로그인이 되게 해야

편리한 웹 사이트라 할 수 있다.

쿠키는 이러한 기능을 제공한다. 비상태적 웹 프로토콜에서 상태 정보를 가질 수 있다. 

 

이번 포스팅에서는 이러한 자동 로그인을 만들어보자 

 

일단 로그인 컨트롤러를 만들어보자, 사용자 로그인 정보를 보는 쿠키도 추가해서 

  @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());
    log.info("login? {}", loginMember);
    //로그인 가능 유무 
    if (loginMember == null) {
      bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
      return "login/loginForm";
    }
    //로그인 성공 처리
    //쿠키에 시간 정보를 주지 않으면 세션 쿠키(브라우저 종료시 모두 종료)
    Cookie idCookie = new Cookie("memberId",
        String.valueOf(loginMember.getId()));
    response.addCookie(idCookie);
    return "redirect:/";
  }

 

    //로그인 성공 처리
    //쿠키에 시간 정보를 주지 않으면 세션 쿠키(브라우저 종료시 모두 종료)
    Cookie idCookie = new Cookie("memberId",
        String.valueOf(loginMember.getId()));
    response.addCookie(idCookie);

해당 부분이 쿠키 부분이다. 

 

그런데 이렇게 개발하면은 매우 큰 보안 상의 문제가 발생한다.

 

보안 문제

  • 쿠키 값은 임의로 변경이 가능하다
    • 클라이언트가 쿠키를 강제로 변경하면 다른 사용자가 된다.
  • 쿠키에 보관된 정보는 훔쳐갈 수 있다.
    • 만약 쿠키에 민감한 정보가 있다면?
    • 네트워크 전송 구간에서 털릴 수도 있다.

 

 

해결 방법

  • 쿠키에 중요한 값을 노출시키지 않는다.
  • 사용자 별로 예측 불가능한 임의의 토큰 값만 노출 시킨다.(UUID) 서버에서는 토큰과 사용자 ID를 매핑하면 된다.
    • 참고로 이 방법은 Spring Security의 세션 관리에서 인코드 언코딩으로 동작한다.

 

UUID라 해서 생각난건데
보고 흥미로웠던 보안관련 뉴스가 있었다.
기초적인 보안도 안된 대학교 홈페이지이다. 
이건 UUID만 사용했어도 발생 안했을 문제였다.

(1) 지원서 보다가 "이상한데?"…숫자 바꾸자 94만 명 '와르르' / SBS 8뉴스 - YouTube

 

📝쿠키 보안 대책 - 세션 

 

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

 

클라리언트와 서버는 결국 쿠키로 연결이 되어야한다.

  • 서버는 클라이언트에 MySessionId라는 이름으로 세션ID만 쿠키에 담아 전달한다.
  • 클라리언트는 쿠키 저장소의 자신의 세션 아이디만 쿠키로 보관한다.
  • 이 쿠키는 UUID나 예상할 수 없는 코드로 보관하며 변조를 못하게 막는다.

중요한 점!

  • 회원과 관련된 정보는 쿠키에 절대 저장하지 않는다.
  • 오직 추정 불가능한 세션 ID만 쿠키를 통해 클라이언트에 전달한다.

 

 

세션 관리는 크게 3가지 기능을 제공한다.

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

 

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

세션 생성

  /**
   * 세션 생성
   */
  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);
  }

UUID로 세션을 생성하고 저장소에 저장한다.

이 저장소는 현재 JPA를 쓰고 있지 않아 Map으로 대체한다.

 

세션 조회

  /**
   * 세션 조회
   */
  public Object getSession(HttpServletRequest request) {
    Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
    if (sessionCookie == null) {
      return null;
    }
    return sessionStore.get(sessionCookie.getValue());
  }
  
 private 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);
  }

 

세션 만료 

  /**
   * 세션 만료
   */
  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) {
    if (request.getCookies() == null) {
      return null;
    }
    return Arrays.stream(request.getCookies())
        .filter(cookie -> cookie.getName().equals(cookieName))
        .findAny()
        .orElse(null);
  }

 

 

정리

세션은 사실 뭔가 특별한 것이 아니라 쿠키를 사용하는데, 서버에서 데이터를 유지하는 방법일 뿐이다.

그런데 프로젝트마다 이러한 세션 개념을 직접 개발하는 것은 상당히 불편하다. 서블릿도 세션 개념을 지원한다.

 

물론 쉽게 하려면 SpringSecurity를 사용하자 

 

📝세션 개선 - 서블릿 HTTP 

세션이라는 개념은 대부분의 웹 애플리케이션에 필요한 것이다. 

서블릿은 세션을 위해서 HttpSession 이라는 기능을 제공한다.

 

HttpSession 

서블릿을 통해 HttpSession을 생성하면 다음과 같은 쿠키를 생성한다. 쿠키 이름은 JSESSIONID 

추정 불가능한 랜덤 값이다.

 

@PostMapping("/login")
public String loginV3(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletRequest request) {
  if (bindingResult.hasErrors()) {
    return "login/loginForm";
  }
  Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
  log.info("login? {}", loginMember);
  if (loginMember == null) {
    bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
    return "login/loginForm";
  }
  //로그인 성공 처리
  //세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성
  HttpSession session = request.getSession();
  //세션에 로그인 회원 정보 보관
  session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
  return "redirect:/";
}

 

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

이부분만 보면 된다. 위의 처럼 세션의 모든 동작을 안만들어도 HttpSession에 모두 구현되어있어 그냥 쓰면된다.

 

세션 생성과 조회

세션을 생성하려면 request.getSession(true)를 사용하면 된다.

  • request.getSession(true)
    • 세션이 있으면 기존 세션을 반환
    • 세션이 없으면 새로운 세션을 생성
  • request.getSession(false)
    • 세션이 있으면 기존 세션 반환
    • 세션이 없으면 null 반환
  • 디폴트 값은 true이다.

 

세션 삭제 

  HttpSession session = request.getSession(false);
  if (session != null) {
    session.invalidate();
  }
  • 일단 request.getSession(false)로 조회를 한다.(false를 쓰는 이유는 true이면 생성되기 때문이다.)
  • 그리고 session.invalidate()를 쓰면 삭제된다.

🚩세션 애노테이션

항상 스프링은 업그레이드 시에 애노테이션을 사용하는 듯하다. 

@SessionAttribute를 사용하면 편리하게 세션을 사용할 수 있다.

 

이미 로그인 된 사용자를 찾을 때는 다음과 같이 사용하면 된다.

@SessionAttribute(name = "loginMember", required = false) Member loginMember

@GetMapping("/")
public String homeLoginV3Spring(
    @SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false)
    Member loginMember,
    Model model) {
    //세션에 회원 데이터가 없으면 home
    if (loginMember == null) {
        return "home";
    }
    //세션이 유지되면 로그인으로 이동
    model.addAttribute("member", loginMember);
    return "loginHome";
}

 

애노테이션 사용 주의점!

로그인을 처음 시도하면 다음과 같이 URL이 생긴다.

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

 

이것은 쿠키를 지원하지 않을 때 쿠키 대신 URL을 유지시키는 방법이다. 그러므로 해당 방식을 꺼야한다.

application.propoerties에 해당 구문을 넣어주어야 한다.

server.servlet.session.tracking-modes=cookie

 

 

📝세션 정보와 타임아웃 설정

세션 정보를 보는 방법을 알아보자

 

세션 정보
    //세션 데이터 출력
    session.getAttributeNames().asIterator()
        .forEachRemaining(name -> log.info("session name={}, value={}",
            name, session.getAttribute(name)));
    log.info("sessionId={}", session.getId());
    log.info("maxInactiveInterval={}", session.getMaxInactiveInterval());
    log.info("creationTime={}", new Date(session.getCreationTime()));
    log.info("lastAccessedTime={}", new Date(session.getLastAccessedTime()));
    log.info("isNew={}", session.isNew());
  • sessionId; 세션의 id이다. JSESSIONID의 값 예) 34B14F008AA3527C9F8ED620EFD7A4E1
  • maxInactiveInterval : 세션의 유효기간 예) 1800초
  • creationTime : 세션 생성일시
  • lastAccessedTime : 세션과 연결된 사용자가 최근에 서버에 접근한 시간 
  • isNew : 새로 생성된 세션인지 판별 
세션 타임아웃 설정

세션은 사용자가 로그아웃을 직접 호출해서 session.invalidate()를 호출해야 삭제된다. 그런데 대부분의 사용자는

로그아웃을 선택하지 않고, 그냥 웹 브라우저를 종료한다. 문제는 HTTP가 비 연결성이므로 서버 입장에서는 해당 사용자가 웹 브라우저가 종료한 것인지 아닌지 알 수 없다. 따라서 서버에서 세션 데이터를 언제 삭제해야 하는지 판단을 못한다.

 

이 경우 다음과 같은 문제가 발생한다.

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

세션의 종료 시점 설정 

  • 가장 단순하게 30분으로 설정해도 된다.
  • 더 나은 대안은 서버에 최근 요청한 시간을 기준으로 30분 정도를 유지하는 것이다. 
  • 이 방법은 HttpSession 방식을 사용한다.

세션 타임아웃 설정 

server.servlet.session.timeout=60 // 이것을 apprication.properties에 넣으면 갱신된다. 

특정 세션 단위로 설정도 가능하다.

session.setMaxInactiveInterval(1800);

 

정리

HttpSession을 통해 편리하게 사용할 수 있었다. 실무에서는 세션에 최소한의 데이터만 보관해야한다.

보관한 데이터 용량 x 사용자의 수로 세션의 메모리 용량이 급격히 늘어날 수 있어 적당한 시간을 선택하는 것이 중요하다.