RE-Heat 개발자 일지

스프링 MVC 2편 - [6] 로그인 처리 1 - 쿠키·세션(하편) 본문

백엔드/스프링

스프링 MVC 2편 - [6] 로그인 처리 1 - 쿠키·세션(하편)

RE-Heat 2023. 7. 21. 01:21

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 인프런 | 강의

웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습할 수 있

www.inflearn.com

인프런 김영한님의 스프링 MVC 2편 강의를 토대로 정리한 내용입니다.

 

 

[7] 쿠키와 보안 문제

 

■ 보안 문제

  • 쿠키 값은 임의로 변경할 수 있다
    • 클라이언트가 쿠키를 강제로 변경하면 다른 사용자로 바뀐다.
    • 실제 웹브라우저 개발자모드 Application Cookie 변경으로 확인
    • Cookie : memberId=1 → Cookie: memberId=2 (다른 사용자의 이름이 보임)
  • 쿠키에 보관된 정보는 훔쳐갈 수 있다.
    • 만약 쿠키에 중요한 개인정보나, 신용카드 정보가 담겨 있다면?
    • 이 정보가 웹 브라우저에도 보관되고, 네트워크 요청마다 계속 클라이언트에서 서버로 전달된다.
    • 쿠키의 정보가 나의 로컬 PC에서 털릴 수도 있고, 네트워크 전송 구간에서 털릴 수도 있다.
  • 해커가 쿠키를 한번 훔쳐가면 평생 사용할 수 있다.
    • 해커가 쿠키를 훔쳐가서 그 쿠키로 악의적인 요청을 계속 시도할 수 있다.

쿠키에서 Value값을 3으로 바꾸고 새로고침을 누르니 test1아이디가 test2아이디로 바뀌었다. 아이디가 탈취당한 셈.

 

■ 대안

  • 쿠키에서 중요한 값을 빼고, 사용자 별로 예측 불가능한 임의의 토큰을 노출한다. 그리고 서버에서 토큰과 사용자 id를 매핑해 인식하면 된다.
  • 토큰은 해커가 임의의 값을 넣어도 찾을 수 없도록 예상 불가능해야 한다.(UUID)
  • 해커가 토큰을 훔쳐도 시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게 유지한다. 추가로 해킹의 의심되면 해당 토큰을 세션 저장소에서 강제로 제거하면 된다.

 

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

목표 : 중요한 정보는 모두 서버에 저장. 클라이언트와 서버는 추정 불가능한 임의의 식별자로 연결

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

 

■ 세션 동작 방식

① 로그인

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

 

② 세션 생성

- 추정 불가능한 세션 ID를 생성해야 한다.

- UUID는 추정이 불가능하므로 UUID를 적용해 세션 ID 생성

- 생성한 세션 ID와 세션에 보관할 값(memberA)을 서버의 세션 저장소에 보관한다.

 

③ 세션 id를 응답 쿠키로 전달

클라이언트와 서버는 결국, 쿠키로 연결돼야 한다.

- 서버는 클라이언트에 mySessionId라는 이름으로 세션 ID만 쿠키에 담아 전달한다.

- 클라이언트는 쿠키 저장소에 mySessionId 쿠키를 보관한다.

=> 회원 관련 정보는 클라이언트 측에 전달하지 않는다는 게 포인트다. 오로지 추정 불가능한 세션 ID로만 클라이언트와 서버가 상호작용.

 

- 클라이언트는 요청 시 항상 mySessionId 쿠키를 전달한다.

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

 

 

[9] 로그인 처리하기 - 세션 직접 만들기

■ 세션 관리 

 

SessionManager

@Component
public class SessionManager {

    public static final String SESSION_COOKIE_NAME = "mySessionId";
    private Map<String, Object> sessionStore = new ConcurrentHashMap<>();
    
    /**
     * 세션 생성
     * sessionId 생성 (임의의 추정 불가능한 랜덤 값)
     * 세션 저장소에 sessionId와 보관할 값 저장
     * sessionId로 응답 쿠리르 생성해서 클라이언트에 전달 
     * */
    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){
        Cookie[] cookies = request.getCookies();
        if(cookies==null){
            return null;
        }
        return Arrays.stream(cookies)
                .filter(cookie -> cookie.getName().equals(cookieName))
                .findAny()
                .orElse(null);
    }
}

1] 세션 생성

    String sessionId = UUID.randomUUID().toString();로 랜덤한 세션 id 생성하고 값을 저장.

    쿠키도 생성한 뒤 response에 이 쿠키를 담아 보내 줌.

2] 세션 조회

    findCookie()로 쿠키이름이 일치한 값이 있는지 확인. 없으면 null. 있으면 세션 저장소에서 값을 가져 옴.

3] 세션 만료

    sessionCookie가 있는지 찾고 있으면 세션 저장소에서 제거.

 

■ 테스트

SessionManagerTest

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()); //my sessionId 뭐뭐

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

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

① HttpServletResponse, HttpServletRequest를 직접 사용할 순 없어서 MockHttpServletResponse, MockHttpServletRequet를 사용한다.

 

참고]

- 테스트 만들기 단축키 : ctrl+ shift +t

- 상수로 만들기 : ctrl+alt+c

 


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

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

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

    if (loginMember == null){
        //특정 필드 문제 아니므로 글로벌 오류[@ScriptAssert()론 불가능. 한 번 찾아야 하기 때문.]
        bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
        return "login/loginForm";
    }

    //로그인 성공 처리
    //세션 관리자를 통해 세션을 생성하고, 회원 데이터를 보관
    sessionManager.createSession(loginMember, response);

    return "redirect:/";
}

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

■ loginV2()

① private final SessionManager sessionManager;로 주입

② 로그인 성공하면 세션을 등록. 세션에 loginMember를 저장하고 쿠키도 발행

 

■ logoutV2()

로그아웃이 되면 해당 세션 정보를 세션 저장소에서 제거한다. (브라우저에 세션 쿠키는 남아있음)

 

HomeController - homeLoginV2

@GetMapping("/")
public String homeLoginV2(HttpServletRequest request, Model model) {
    //세션 관리자에 저장된 회원 정보 조회
    Member member = (Member) sessionManager.getSession(request);

    //로그인
    if (member == null){
        return "home";
    }
    model.addAttribute("member", member);
    return "loginHome";
}

세션 관리자에 저장된 회원 정보 조회 => Object로 받아오니까 (Member) 캐스팅해줘야 함.

세션 관리자에 저장된 회원 정보를 조회한 후 정보가 없으면 로그인되지 않은 것으로 처리

 

로그인 화면

 

로그아웃 후 화면(쿠키는 남아있지만, 서버에선 지워진 상태)

 

 

 

[11] 로그인 처리하기 - 서블릿 HTTP 세션 1

목표 : 서블릿이 공식 지원하는 세션으로 개발하기

 

■ HttpSession

서블릿이 제공하는 HttpSession도 우리가 직접 만든 SessionManager와 같은 방식으로 동작한다. 쿠키 이름은 JSESSIONID이며 값은 추정 불가능한 랜덤 값이다.

 

SessionConst

public class SessionConst {
    public static final String LOGIN_MEMBER = "loginMember";
}

HttpSession에 데이터를 보관하고 조회할 때 같은 이름이 중복되므로 상수 정의

참고] abstract class나 interface로 이 객체를 생성(new)하는 걸 애초에 막는 게 좋은 방법이다.

 

LoginController - loginV3

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

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

    if (loginMember == null){
        //특정 필드 문제 아니므로 글로벌 오류[@ScriptAssert()론 불가능. 한 번 찾아야 하기 때문.]
        bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
        return "login/loginForm";
    }

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

    return "redirect:/";
}

 

■ 세션 생성과 조회

① 세션 생성 옵션

    request.getSession(true) <세션 생성용

    - 세션이 있으면 기존 세션을 반환한다.

    - 세션이 없으면 새로운 세션을 생성해서 반환한다.

    - 기본값이 true. 그래서 request.getSession()은 request.getSession(true)와 동일하다.

    request.getSession(false) <세션 조회용

    - 세션이 있으면 기존 세션을 반환한다

    - 세션이 없으면 새로운 세션을 생성하지 않고 null을 반환한다.

② 세션에 로그인 회원 정보 보관

    session.setAttribute() <세션에 멤버 객체 값 저장.

 

LoginController - logoutV3()

@PostMapping("/logout")
public String logoutV3(HttpServletRequest request){
    HttpSession session = request.getSession(false);//세션이 없다고 새로 만들면 안되니까 false
    if (session !=null){
        session.invalidate();
    }
    return "redirect:/";
}

session이 있는지 확인한 후 있으면 session.invalidate()로 세션을 제거

 

HomeController - homeLoginV3

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

    //세션에 회원 데이터가 없으면 home으로
    if (loginMember == null){
        return "home";
    }
    //세션이 유지되면 로그인으로 이동
    model.addAttribute("member", loginMember);
    return "loginHome";
}

세션 조회해서 없으면 home화면으로, 세션이 유지되면 로그인 상태 홈 화면으로 이동.

 


[12] 로그인 처리하기 - 서블릿 HTTP 세션 2

@SessionAttribute

스프링은 세션을 더 편리하게 사용할 수 있도록 이 애노테이션을 지원한다.

 

HomeController - homeLoginV3Spring

@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에 jessionid가 포함돼 있다.

 

이는 웹 브라우저가 쿠키를 제공하지 않을 때 쿠키 대신 URL을 통해서 세션을 유지하는 방법이다.

 

서버 입장에선 브라우저의 쿠키 제공 여부를 알 수 없으므로 쿠키도 보내고 URL에 jessionid도 담아 만약의 사태를 대비하는 셈이다.

 

물론 거의 대다수의 브라우저가 쿠키 방식을 제공하며, 이 옵션을 끄고 싶으면

application.porperties에 이 코드를 추가하면 된다.

 


[13] 세션 정보와 타임아웃 설정

 

SessionInfoController

@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("getMaxInactiveInterval={}", 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 )를 요청해서 조회된 세션인지 여부

 

■ 세션 타임아웃 설정

1] 대다수의 사용자들은 로그아웃을 하지 않고 웹 브라우저를 종료해 사이트 이용을 마무리한다.
2] 이 탓에 서버는 사용자가 웹 브라우저를 종료했는지 여부를 알 수 없다. 이는 HTTP의 '비연결성' 특성 때문이다.
3] 이러한 상황에서 서버는 세션 데이터를 언제 삭제해야 할지 판단하기 어려워진다.
4] 그렇다고 무한정 세션 데이터를 보관할 수는 없다.
① 세션과 관련된 쿠키가 탈취되면 오랜 시간이 지나도 해당 쿠키로 악의적인 요청이 가능해진다.
② 세션은 기본적으로 메모리에 생성되며, 메모리 크기는 무한하지 않기 때문에 계속해서 세션을 유지하는 것은 리소스 낭비다.

따라서 위와 같은 이유로 세션 타임아웃을 설정해야 한다.

 

■ 세션의 종료 시점

세션 생성 시점으로부터 30분을 정하면 30분마다 계속 로그인해야 하는 번거로움이 발생한다.

더 나은 대안은 사용자가 서버에 요청한 시간(lastAccessedTime)을 기준으로 30분 정도를 유지해 주는 것이다. 사용자가 서비스를 이용하고 있으면 서버에 요청한 최신 시간이 갱신되므로 30분마다 로그인하는 불편함은 사라진다.

 

그리고 서블릿이 공식 지원하는 HttpSession는 이 방식을 사용한다.

 

■ 세션 타임아웃 설정 방법

① 글로벌 적용

    - application.properties 

    server.servlet.session.timeout=60 (60초. 기본값은 1800초[30분])

② 특정 세션 단위로 시간 설정

    sesssion.setMaxInactiveInterval(1800);