스프링 MVC(11)

5 minute read

로그인 처리1 - 쿠키, 세션

쿠키를 사용하여 로그인 처리하기

로그인 성공시 세션 쿠키를 생성한다.

@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:/";
}
  • 로그인에 성공하면 쿠키를 생성하고 HttpServletResponse에 담는다.

홈 - 로그인 처리

@Controller
@RequiredArgsConstructor
public class HomeController {
 
 private final MemberRepository memberRepository;

    @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를 사용하면 편리하게 쿠키 조히 가능
  • 로그인 하지 않은 사용자도 홈에 접근할 수 있기 때문에 required=false를 사용한다.

로그아웃 기능

@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);
}
  • 세션 쿠키이므로 웹 브라우저 종료시
  • 서버에서 해당 쿠키의 종료 날짜를 0으로 지정
쿠키와 보안 문제
  • 쿠키 값은 임의로 변경할 수 있다.
    • 클라이언트가 쿠키를 강제로 변경하면 다른 사용자가 된다.
    • 실제 웹브라우저 개발자모드 Application Cookie 변경으로 확인
    • Cookie: memberId=1 Cookie: memberId=2 (다른 사용자의 이름이 보임)
  • 쿠키에 보관된 정보는 훔쳐갈 수 있다.
    • 만약 쿠키에 개인정보나, 신용카드 정보가 있다면?
    • 이 정보가 웹 브라우저에도 보관되고, 네트워크 요청마다 계속 클라이언트에서 서버로 전달된다.
    • 쿠키의 정보가 나의 로컬 PC가 털릴 수도 있고, 네트워크 전송 구간에서 털릴 수도 있다.
  • 해커가 쿠키를 한번 훔쳐가면 평생 사용할 수 있다.
    • 해커가 쿠키를 훔쳐가서 그 쿠키로 악의적인 요청을 계속 시도할 수 있다.

대안

  1. 쿠키에 중요한 값을 노출하지 않고, 사용자 별로 예측 불가능한 임의의 토큰(랜덤 값)을 노출하고, 서버에서 토큰과 사용자 id를 매핑해서 인식한다. 그리고 서버에서 토큰을 관리한다.
  2. 토큰은 해커가 임의의 값을 넣어도 찾을 수 없도록 예상 불가능 해야 한다.
  3. 해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게(예: 30분) 유지한다. 또는 해킹이 의심되는 경우 서버에서 해당 토큰을 강제로 제거하면 된다.

세션으로 로그인 처리하기

  1. 세션 생성 - sessionId생성(임의의 추정 불가능한 랜덤 값), 세션 저장소에 sessionId와 보관할 값 저장,sessionId로 응답 쿠키를 생성해서 클라이언트에 전달
  2. 세션 조회 - 클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 값 조회
  3. 세션 만료 - 클라이언트가 요청한 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);
    }
}

  • @Component : 스프링 빈으로 자동 등록한다.
  • ConcurrentHashMap : HashMap은 동시 요청에 안전하지 않기 때문에 동시 요청에 안전한 것을 사용

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

  • HttpsSession : 서블릿이 제공하는 HttpSession은 위에서 만든 SessionManager아 같은 방식으로 동작한다, 서블릿을 통해 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:/";
}
  • 세션을 생성하려면 request.getSession(true)를 사용
  • request.getSession(true)
    • 세션이 있으면 기존 세션을 반환
    • 세션이 없으면 새로운 세션을 생성해서 반환
  • request.getSession(false)
    • 세션이 있으면 기존 세션을 반환
    • 세션이 없으면 새로운 세션을 생성하지 않음 null 반환

홈 접근

@GetMapping("/")
public String homeLoginV3(HttpServletRequest request, Model model) {
    //세션이 없으면 home
    HttpSession session = request.getSession(false);
    if (session == null) {
        return "home";
    }
    Member loginMember = (Member)session.getAttribute(SessionConst.LOGIN_MEMBER);
    //세션에 회원 데이터가 없으면 home
    if (loginMember == null) {
        return "home";
    }
    //세션이 유지되면 로그인으로 이동
    model.addAttribute("member", loginMember);
    return "loginHome";
}
  • request.getSession(false) : request.getSession() 를 사용하면 기본 값이 create: true 이므로, 로그인 하지 않을 사용자도 의미없는 세션이 만들어진다. 따라서 세션을 찾아서 사용하는 시점에는 create: false 옵션을 사용해서 세션을 생성하지 않아야 한다.
  • session.getAttribute(SessionConst.LOGIN_MEMBER) : 로그인 시점에 세션에 보관한 회원 객체를 찾는다.

로그아웃

@PostMapping("/logout")
public String logoutV3(HttpServletRequest request) {
    //세션을 삭제한다.
    HttpSession session = request.getSession(false);
    if (session != null) {
        session.invalidate();
    }
    return "redirect:/";
}

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

  • @SessionAttribute : 스프링은 세션을 더 편리하게 사용할 수 있도록 @SessionAttribute를 지원한다.

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

TrackingModes

  • 위와 같은 방식으로= 로그인을 처음 시도하면 URL이 다음과 같이 jsessionid 를 포함하고 있는 것을 확인할 수 있다.
    • http://localhost:8080/;jsessionid=F59911518B921DF62D09F0DF8F83F872
  • 이것은 웹 브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해서 세션을 유지하는 방법이다.
  • URL 전달 방식을 끄고 쿠키를 통해서만 세션을 유지하고 싶으면 application.properties에 다음과 같은 옵션을 넣어준다.
    • server.servlet.session.tracking-modes=cookie

세션 정보와 타임아웃 설정

세션이 제공하는 정보들을 확인해보자

@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("maxInactiveInterval={}", session.getMaxInactiveInterval());
        log.info("creationTime={}", new Date(session.getCreationTime()));
        log.info("lastAccessedTime={}", new
        Date(session.getLastAccessedTime()));
        log.info("isNew={}", session.isNew());
        return "세션 출력";
    }
}
  • sessionId : 세션Id, JSESSIONID 의 값이다. 예) 34B14F008AA3527C9F8ED620EFD7A4E1
  • maxInactiveInterval : 세션의 유효 시간, 예) 1800초, (30분)
  • creationTime : 세션 생성일시
  • lastAccessedTime : 세션과 연결된 사용자가 최근에 서버에 접근한 시간, 클라이언트에서 서버로 sessionId ( JSESSIONID )를 요청한 경우에 갱신된다.
  • isNew : 새로 생성된 세션인지, 아니면 이미 과거에 만들어졌고, 클라이언트에서 서버로 sessionId ( JSESSIONID )를 요청해서 조회된 세션인지 여부
세션 타임아웃 설정

세션은 사용자가 로그아웃을 직접 호출해서 session.invalidate()가 호출 되는 경우에 삭제된다. 하지만 대부분 사용자는 그냥 웹 브라우저를 종료한다. 여기서 문제는 HTTP가 비연결성이므로 서버 입장에서는 해당 사용자가 브라우저를 종료한 것인지 알 수 없다.

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

스프링 부트로 글로벌 설정

application.properties

server.servlet.session.timeout=60
  • 기본은 1800(30분), 60(1분)

특정 세션 단위로 시간 설정

session.setMaxInactiveInterval(1800); //1800초

세션 타임아웃 발생

  • 세션의 타임아웃 시간은 해당 세션과 관련된 JSESSIONID 를 전달하는 HTTP 요청이 있으면 현재 시간으로 다시 초기화 된다. 이렇게 초기화 되면 세션 타임아웃으로 설정한 시간동안 세션을 추가로 사용할 수 있다.
    • session.getLastAccessedTime() : 최근 세션 접근 시간
  • LastAccessedTime 이후로 timeout 시간이 지나면, WAS가 내부에서 해당 세션을 제거한다.

출처 - 김영한의 스프링 MVC 2편

Leave a comment