로그인 처리 - 쿠키, 세션

byeol·2023년 3월 26일
0

김영한님의 스프링 mvc2 중 로그인 처리에 대해서 정리해보려고 한다.


구조 분석

아래와 같이 패키지 구조가 생긴다.
(나는 실무 경험이 없기 때문에 수업에서 만드는 패키지 구조를 눈여겨 보려고 노력한다.)

domain과 web을 나누고 각 페이지를 중심으로 더 세부적으로 나눈다.

상품페이지, 로그인페이지, 회원가입 페이지를 중심으로 생기는 domain과 web을 나눈다.

일단 domain패키지에 LoginServie.class를 보면 아래와 같다.

@Service
@RequiredArgsConstructor // final이 붙거나 @Notnull이 붙은 필드의 생성자를 자동 생성해주는 롬복 애노테이션
public class LoginService {

    private final MemberRepository memberRepository;

    public Member login(String loginId, String password){
        return memberRepository.findByLoginId(loginId)
                .filter(m->m.getPassword().equals(password))
                .orElse(null);
    }
}

MVC에서 Service는 무엇인지 다시 한번 정리한다.

Controller는 View단으로부터 넘어오는 데이터를 받는다.
따라서 View단은 Controller에만 관심이 있다.

Service는 Controller로부터 데이터를 받아서 비즈니스로직을 구현한다. 따라서 Service는 HttpServlet을 상속받지 않는 순수한 자바 객체로 구현되도록 구성해야 한다!

재사용성이 높다. 왜냐하면 View단에 종속되지 않기 때문이다. 따라서 Service는 인터페이스를 만들고 이를 구현한 다른 클래스로 객체를 만들도록 한다. 근데 위 수업에서는 굳이 요청사항이 바뀔 이유가 없기 때문에 인터페이스까지 구현하지 않은거 같다.

도메인이 가장 중요하다고 한다.

domain은 web을 모르고 web만 domain을 알고 의존한다.

향후 web을 다른 기술로 바꾸어도 도메인은 그대로 유지할 수 있어야 한다.

이제 구조 분석이 끝났으니 수업 내용을 정리한다.

요구사항

  • 홈 화면 - 로그인 전
    • 회원가입
    • 로그인
  • 홈 화면 - 로그인 후
    • 본인 이름
    • 상품 관리
    • 로그아웃
  • 보완 요구사항
    • 로그인 사용자만 상품에 접근하고, 관리할 수 있음.
    • 로그인 하지 않은 사용자가 상품 관리에 접근하여 로그인 화명으로 이동
  • 회원가입, 로그인 전

회원가입 페이지

domain

Meber.class

@Data
public class Member {

    private  Long id;//Long은 null값도 가능

    @NotEmpty
    private String loginId; // 로그인 ID

    @NotEmpty
    private String name;

    @NotEmpty
    private String password;
}

모두다 private인걸 볼 수 있다. 이는 Member 클래스의 객체로 위 변수들을 접근하지 못하도록 막은 것이다. 오직 메서드를 통해서만 가능하다.

MemberRepository.class

DB와 연결되어 저장하는 역할
여기서는 그냥 Map에 저장하지만
실제는 JPA를 이용하는 경우
Entity에 의해 생성된 DB에 접근하는 메서드들을 사용하기 위한 인터페이스를 구현하여 만든다.

Respository는 CRUD가 구체적으로 정의된다.

@Repository
public class MemberRepository {

    private static long sequence= 0L; 

    private static Map<Long,Member> store = new HashMap<>();

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

}

web

MemberController.class

@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 result){
        if(result.hasErrors()){
            return "members/addMemberForm";
        }

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

}

Controller는
View단으로부터 데이터를 받아 이를 Service에 전달하고 Service의 결과를 Model객체에 저장해서 View단에 넘겨준다.

회원가입 페이지를 통해서 전체적인 MVC 구조를 파악했고 이제 로그인 처리에 대해서 정리한다.


로그인 - 쿠키

쿠키가 왜 필요할까?

그 이유는 쿠키가 없다면 Client는 항상 자신이 누군인지 쿼리 파라미터 정보를 서버에 보내야 한다. 이 때 사용하는 것이 쿠키이다.

쿠키는 Client에 저장되는 정보인데

Client가 쿼리 파라미터를 보내고
이 정보를 바탕으로 쿠키를 생성해서 응답에 보낸다.
그러면 Client가 쿠키를 저장해 놓고 있다가 모든 요청에 이 쿠키를 담아서 보낸다. 이로써 로그인 상태가 유지되는 것이다.

로그인

Controller에서 쿠키를 만들어서 응답에 실어서 보내기

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

쿠키의 이름은 MemberId이며 값은 회원의 id이다.

Cookie에 대한 API문서에 따르면

A cookie has a name, a single value, and optional attributes such as a comment, path and domain qualifiers, a maximum age, and a version number.
...
Several cookies might have the same name but different path attributes.

쿠키는 이름과 하나의 값, 그리고 선택적 속성들을 가지고 있다고 되어 있다. 처음에 들었던 의문 모든 쿠키가 "memberId"라는 쿠키 이름을 가지고 있는데 어떻게 구별이 되는가?라는 물음이었는데
보니까 이름이 같아도 다른 path속성을 가지고 있는다고 한다.


또한 생성자를 보면 둘다 String이다.

쿠키 유무에 따른 HomeController

쿠키가 있으면 로그인 상태의 홈페이지로 보이고 -> "loginHome"
쿠키가 없으면 로그아웃 상태의 홈페이지가 보인다. -> "home"

이를 HomeController에 구현한다.

@Slf4j
@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) {
            System.out.println("memberId==null?");
            return "home";
        }
        //로그인
        Member loginMember = memberRepository.findById(memberId);
        if (loginMember == null) {
            return "home";
        }
        model.addAttribute("member", loginMember);
        return "loginHome";
    }
}

매개변수 부분을 자세히 살펴보면

 @CookieValue(name = "memberId", required = false) Long memberId,

@CookieValue라는 애노테이션을 통해서 "memberId"의 이름을 가진 쿠키의 값을 바로 memberId에 준다. required라는 속성의 값을 false로 줌으로써 쿠키가 없는 경우 null을 넘겨준다.

로그아웃

Controller에서 쿠키의 값을 null로 만들고 종료날짜를 0으로 지정한다.

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

쿠키 - 보안

쿠키는 보안 문제가 있다.

  • 클라이언트가 쿠키를 강제로 변경하면 다른 사용자가 된다.
  • 쿠키에 보관된 정보는 훔쳐갈 수 있다.
  • 해커가 쿠리를 한번 훔쳐가면 평생 사용할 수 있다.

따라서 쿠키에 중요한 값을 노출하지 않고
사용자별로 예측이 불가능한 임의의 토큰을 노출
서버에서 예측이 불가능한 임의의 토큰과 사용자 id를 매핑 해서 인식하도록 한다.
서버에서 토큰을 관리한다.

또한 헤커가 토큰을 털어가도 토큰에 대한 만료 시간을 짧게 유지해서 사라지도록 한다.
해킹이 의심되는 경우 서버에서 토큰을 강제로 제거한다.

이 방식을 세션이라고 한다.

로그인 - 세션

Client가 쿼리 파라미터 정보를 보내면
해당 사용자가 맞는지 서버에서 확인
서버는 예측 불가능한 세션 ID를 만든다.
이를 사용자 정보와 맵핑해서 세션저장소에 저장한다.
그리고 세션ID를 쿠키에 담아 Client에 응답한다.

Client는 쿠키를 저장해서 모든 요청에 대해 이 쿠키를 담아서 준다.
서버는 쿠키에 담긴 세션ID로 세션저장소에서 매핑된 사용자 정보를 찾고
다시 쿠키에 세션ID만 담아서 응답한다.

중요한 것은 쿠키에 중요한 회원정보가 담겨져 있지않다. 따라서 쿠키 값을 변조해서 다른 사용자로 서버에 접속할 수 없다. 또한 쿠키를 탈취 후 사용한다고 하더라도 시간이 지나면 이 토큰은 사라진다.
서버에서 이 토근을 강제로 제거할 수도 있다.

따라서 크게 3가지 기능이 필요하다

  • session ID 만들어서 Cookie에 담아서 전달
    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가 등장한다.

    불변의 UUID (유니버설 고유 식별)를 나타내는 클래스입니다. UUID는 128 비트치를 나타냅니다.
    이러한 글로벌 식별자에는 다양한 형식이 존재합니다. 생성자를 사용하면 임의의 형식의 UUID를 작성할 수 있습니다만, 이 클래스의 메서드는 Leach-Salz 형식의 조작용 메서드입니다.

따라서 UUID는

  • 업로드된 파일명의 중복을 방지하기 위해 파일명 변경
  • 첨부파일 다운로드시 다른 파일을 예측하여 다운로드하는 것을 방지
  • 일련번호 대신 유추하기 힘든 식별자를 사용하여 다른 컨텐츠의 임의 접근을 방지
  • 들어온 요청에 담긴 Cookie를 통해 session 조회하기
 public Object getSession(HttpServletRequest request) {
    Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
    if (sessionCookie == null) {
      return null;
     }
    return sessionStore.get(sessionCookie.getValue());
 }
  • 로그아웃하면 session을 만료
public void expire(HttpServletRequest request) {
 Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
   if (sessionCookie != null) {
   sessionStore.remove(sessionCookie.getValue());
  }
  }

로그인 - HttpSession

하지만 위와 같이 3가지 기능을 직접 만들어 사용하지 않게
서블릿은 HttpSession이라는 기능을 제공한다.

HttpSession은 쿠키를 생성해주는
쿠키 이름은 "JSESSIONID"이며 값은 추정 불가능한 랜덤값이다.

로그인 - Controller

 Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
...
 //세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성
 HttpSession session = request.getSession();
 //세션에 로그인 회원 정보 보관
 session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);

이제 쿠키가 아닌 세션을 생성하고 Cookie에 저장한다.

  • 여기서 보면 HttpServletRequest에 getSession()이라는 메서드를 보자

    • 첫번째 getSession()은 요청과 연결된 현재의 세션을 반환하거나
      없는 경우 생성한다고 되었다.

    • 두번째 getSession(boolean create)를 보면 create가 true인 경우 세션이 있으면 기존 세션을 반환하고 없으면 세션을 생성해서 반환한다고 되어 있다.

      If create is false and the request has no valid HttpSession, this method returns null.
      그러나 create가 false인 경우 세션이 없으면 null을 반환한다.

  • HttpSession의 setAtrribute를 보자

    Binds an object to this session, using the name specified. If an object of the same name is already bound to the session, the object is replaced.
    같은 이름의 개체가 이미 세션에 바딘딩되어 있으면 개체가 바뀐다고 되어 있다.

로그아웃 Controller

@PostMapping("/logout")
    public String logoutV3(HttpServletRequest request) {
        // 세션을 삭제한다.

        HttpSession session = request.getSession(false);
        if(session !=null){
            session.invalidate();
        }
        return "redirect:/";
    }

HttpSession의 invalidate()메서드를 이용하고 있다.

로그인 - @SessionAttribute

세션을 직접 생성하기 않더라고 직접 스프링이 생성해주고
기존에 있다면 찾아서 준다.

@GetMapping("/")
public String homeLoginV3Spring(
 @SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false)
Member loginMember,
 Model model) {...}

TrackingModes

웹 브라우저 쿠키를 지원하고 있는지 않는지 모르기 때문에 쿠키에 sessionID를 담아서 주기도 하지만 URL에 파라미터로 전달하기도 한다.

그래서 최초에는 쿠키와 URL 파라미터 둘다 넘겨준다.
만약 URL 파라미터를 끄고 싶다면
아래와 같은 설정이 필요하다.
application.properties

server.servlet.session.tracking-modes=cookie
profile
꾸준하게 Ready, Set, Go!

0개의 댓글