본문 바로가기
Back-End/🌱 Spring Boot (java)

[Spring] HTTP 쿠키의 개념과 로그인 구현

by 코딩하는 동현😎 2023. 3. 27.
  • 보통 현업에서는 Spring Security(스프링 시큐리티)라이브러리를 사용하거나, 세션을 이용합니다.
  • 오로지 쿠키로만 로그인을 구현하면 심각한 보안 문제가 있으므로 토큰이나 세션, 라이브러리를 추가로 사용해야됩니다.
  • 그래도 쿠키의 개념과 용도는 알아야하므로 쿠키를 다뤄보는 실습을 제작하게 됐습니다.

Stateless(무상태) 프로토콜

우리가 현재 사용하는 HTTP 프로토콜은 Stateless(무상태), 비연결성 프로토콜 입니다. (연결성이 필요한 경우 소켓 프로토콜이라고 따로 이용합니다.)
클라이언트와 서버가 요청과 응답을 주고 받으면 연결이 끊어지고, 클라이언트가 다시 요청하면 서버는 이전 내용을 기억하지 못합니다.
클라이언트와 서버는 서로 상태를 유지하지 못하는 대신의 서버의 확장성이 좋다는 장점을 가지고 있습니다.


그러나 위에 사진처럼 상태를 기억하지 못해서 대화가 진행이 안되는 것에대한 해결책에는 아래 사진 처럼 클라이언트가 매 요청마다 지금까지 누적된 모든 상태를 모두 말해줘서 서버가 기억할수 있도록 하는 것입니다.

 


웹서비스에서 위와 같은 대표적인 예가 로그인 서비스 입니다.
로그인하면 홍길동이라는 사용자로 서버가 인식을 하는데, 로그인하고 상품 구매 페이지로 들어가면, 이미 끊고 새로운 연결을 한 상태이므로, 서버는 이 클라이언트가 홍길동이었는지 인식을 못합니다.
즉 서버는 그 클라이언트의 상태를 기억하지 않으므로 클라이언트가 그 정보를 매 요청마다 보내줘야 합니다.

그럼 위 사진과 같이 모든 링크에 사용자 정보를 포함하면 작동은 하겠지만, 아래 사진과 같은 모든 요청과 링크에 사용자 정보를 포함해야합니다.

그러나 이렇게 하면 모든 요청에 사용자 정보가 포함되도록 개발을 해야하는 문제가 생기고, 브라우저를 완전히 종료하고 다시 열면 지금까지 모앗던 정보가 다 날아가서 처음부터 링크에 하나씩 정보를 추가해야합니다.
 

그래서 이 문제를 해결하기 위해 쿠키라는 기술을 이용해서 로그인을 구현하는 것입니다.


쿠키(Cookie)란?

쿠키는 웹사이트에서 사용자의 컴퓨터나 기기에 저장하는 작은 데이터 파일입니다.
쿠키는 사용자가 웹사이트를 방문하고 상호작용할때, 그 사용자의 정보를 저장하고 추적하는 데 사용합니다.
 
위에서 다뤘던 로그인 문제에서도 브라우저의 쿠키저장소에 사용자의 정보를 저장하고, 브라우저를 종료하던 다시 그 사이트로 들어가던 쿠키저장소에 있던 내용을 HTTP Header에 넣어서 전송하는 것입니다.


1. 로그인 요청시 서버(백엔드)에서 사용자 정보를 저장할 쿠키를 생성한다음에 클라이언트한테 보내서 클라이언트의 쿠키 저장소에 저장하도록 시킵니다.

2. 그리고 나중에 그 사이트에 다시 접속할때 아래처럼 저장해놨던 쿠키내용을 꺼내서 서버로 다시 요청하고, 서버는 그 쿠키에 내용을 보고 사용자를 기억하는 것입니다.

 


쿠키는 사용자 로그인 세션 관리 할때나 광고 정보 트래킹 할 때 쓰입니다.
쿠키 정보는 항상 서버에 전송되기 때문에 트래픽이 추가로 유발되고, 이 이유로 id같은 최소한의 정보를 저장해서 보내야합니다. (백엔드에서 그 id로 조회해서 나머지 정보들을 다룹니다)
물론 쿠키는 변조와 조회가 가능하므로 민감한 데이터는 저장하면 안됩니다. (사실 사용자 id도 해킹의 우려가 있으므로 쿠키에 토큰을 넣어서 세션으로 관리하는 것입니다...)


쿠키의 내용

쿠키는 key와 value로 이루어져 있고, 만료기간, 도메인, 경로등의 정보를 가지고 있습니다
 
쿠키는 브라우저에서 관리되는데, 일반적으로 만료 날짜가 지나거나 사용자가 수동으로 삭제하기 전까지 계속해서 유지됩니다.


쿠키 - 생명주기

  • 세션 쿠키 : 만료날짜를 생략하면 브라우저 종료시 까지 유지합니다
  • 영속 쿠키 : 만료날짜를 입력할때 생성되는데, 해당 날짜까지 유지됩니다.

쿠키는 expires, max-age 값을 이용해서 생명주기를 설정할 수 있습니다.
 
Set-Cookie: expires=Sat, 26-Dec-2020 04:39:21 GMT
Set-Cookie: max-age=3600 (3600초)


쿠키 - 도메인

쿠키를 설정할때 도메인을 명시하거나 생략함에 따라 다르게 작동합니다.
 

명시: domain=example.org를 지정해서 쿠키 생성했을때

명시한 문서 기준 도메인 + 서브 도메인 포함합니다.
ex) example.org는 물론이고 dev.example.org도 쿠키 접근하게 됩니다.
 

생략: example.org 에서 쿠키를 생성하고 domain 지정을 생략했을때

현재 문서 기준 도메인만 적용합니다.
example.org 에서만 쿠키 접근하고 dev.example.org는 쿠키 접근을 제한합니다.


쿠키 - 경로

예시로 path=/home 으로 경로를 설정하면, 이 경로를 포함한 하위 경로 페이지만 쿠키 접근하게 됩니다.
• /home -> 가능
• /home/level1 -> 가능
• /home/level1/level2 -> 가능
• /hello -> 불가능


Spring Boot(java)로 로그인 실습

페이지는 thyemeleaf로 렌더링했습니다.


회원 엔티티와 로그인DTO

Member.java

package jpabook.jpastore.domain;
import javax.validation.constraints.NotEmpty;

import lombok.Getter;
import lombok.Setter;

@Entity
@Getter
@Setter
public class Member {
    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    @NotEmpty
    private String name;
    @NotEmpty
    private String loginId;
    @NotEmpty
    private String password;
}

 
LoginForm.java

package jpabook.jpastore.controller;

import javax.validation.constraints.NotEmpty;

import lombok.Data;

@Data
public class LoginForm {
    @NotEmpty
    private String loginId;
    @NotEmpty
    private String password;
}

Template 페이지 (resources/templates에 저장)

홈 화면

home.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">

<head>
    <meta charset="utf-8">git
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD" crossorigin="anonymous">
</head>

<body>
    <div class="container" style="max-width: 600px">
        <div class="py-5 text-center">
            <h2>홈 화면</h2>
        </div>
        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg" type="button"
                    th:onclick="|location.href='@{/members/add}'|">
                    회원 가입
                </button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-dark btn-lg" onclick="location.href='items.html'"
                    th:onclick="|location.href='@{/login}'|" type="button">
                    로그인
                </button>
            </div>
        </div>
        <hr class="my-4">
    </div> <!-- /container -->
</body>

</html>

로그인 했을때 나오는 홈 화면

loginHome.html

<!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>

로그인 화면

login/loginForm.html

<!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">
    <style>
        .container {
            max-width: 560px;
        }

        .field-error {
            border-color: #dc3545;
            color: #dc3545;
        }
    </style>
</head>

<body>
    <div class="container">
        <div class="py-5 text-center">
            <h2>로그인</h2>
        </div>
        <form action="item.html" th:action th:object="${loginForm}" method="post">
            <div th:if="${#fields.hasGlobalErrors()}">
                <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
            </div>
            <div>
                <label for="loginId">로그인 ID</label>
                <input type="text" id="loginId" th:field="*{loginId}" class="formcontrol" th:errorclass="field-error">
                <div class="field-error" th:errors="*{loginId}" />
            </div>
            <div>
                <label for="password">비밀번호</label>
                <input type="password" id="password" th:field="*{password}" class="form-control"
                    th:errorclass="field-error">
                <div class="field-error" th:errors="*{password}" />
            </div>
            <hr class="my-4">
            <div class="row">
                <div class="col">
                    <button class="w-100 btn btn-primary btn-lg" type="submit">
                        로그인</button>
                </div>
                <div class="col">
                    <button class="w-100 btn btn-secondary btn-lg" onclick="location.href='items.html'"
                        th:onclick="|location.href='@{/}'|" type="button">취소</button>
                </div>
            </div>
        </form>
    </div> <!-- /container -->
</body>

</html>

LoginService

사용자의 id와 password를 받았을때 실질적인 검증 기능을 작성합니다

package jpabook.jpastore.service;

import org.springframework.stereotype.Service;

import jpabook.jpastore.domain.Member;
import jpabook.jpastore.repository.MemberRepository;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class LoginService {
    private final MemberRepository memberRepository;

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

LoginController

아래는 쿠키 생성과 삭제 로직 입니다.

HttpServletResponse response

// 쿠키를 생성해서 응답(res)으로 브라우저에게 보내줌
Cookie cookie = new Cookie("memberId", String.valueOf(member.getId()));
response.addCookie(cookie);


// 기존 쿠키를 만료된 쿠키로 덮어씌워서 삭제합니다
Cookie cookie = new Cookie("memberId", null);
cookie.setMaxAge(0);
response.addCookie(cookie);

 

로그인 요청을 받았을때 쿠키를 제작해서 응답으로 넘겨주고, 로그아웃 요청을 받았을때 쿠키를 삭제해줍니다.

package jpabook.jpastore.controller;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;

import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

import jpabook.jpastore.domain.Member;
import jpabook.jpastore.service.LoginService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Controller
@RequiredArgsConstructor
@Slf4j
public class LoginController {
    private final LoginService loginService;

    @GetMapping("/login")
    public String loginForm(@ModelAttribute("loginForm") LoginForm loginForm){
        return "login/loginForm";
    }

    @PostMapping("/login")
    public String login(
        @Valid @ModelAttribute("loginForm") LoginForm loginForm,
        BindingResult bindingResult,
        HttpServletResponse response)
    {
        if (bindingResult.hasErrors()) {
            return "login/loginForm";
        }//폼 부족하게 입력
        Member member = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
        log.info("login? {}", member);
        if (member == null) {
            bindingResult.reject("loginFail","id or password incorrect");
            return "login/loginForm";
        }

        Cookie cookie = new Cookie("memberId", String.valueOf(member.getId()));
        response.addCookie(cookie);
        return "redirect:/";
    }

    @PostMapping("/logout")
    public String logout(HttpServletResponse response){
        Cookie cookie = new Cookie("memberId", null);
        cookie.setMaxAge(0);
        response.addCookie(cookie);
        return "redirect:/";
    }
}

HomeController

루트 경로로 즉 홈페이지 접근 요청을 받는 파일입니다.
사용자 로그인 여부에 따라서 처음보는 사용자는 home.html로 보내고, 로그인해서 쿠키가 남아있는 사용자는 loginHome.html로 보내도록 작성하겠습니다.

package jpabook.jpastore.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;

import jpabook.jpastore.domain.Member;
import jpabook.jpastore.service.MemberService;
import lombok.extern.slf4j.Slf4j;

@Controller
public class HomeController {

    @GetMapping("/")
    public String homeLogin(@CookieValue(name = "memberId" , required = false) Long memberId, Model model){
        if (memberId == null) {// 멤버 로그인 정보가 담겨진 쿠키가 없다면 = 미로그인 상태
            return "home";
        }

        //로그인 (쿠기 존재)
        Member member = memberService.findOne(memberId);
        if (member == null) { // 쿠키오류. 해당 멤버 존재하지 않음
            return "home";
        }

        // 로그인 완료
        model.addAttribute("member" , member);
        return "loginHome";
    }
}

쿠키만 사용했을때 생기는 보안 문제

쿠키를 사용해서 Id를 전달해서 로그인을 유지할 수 있지만 심각한 보안 문제가 있습니다.
보안 문제 쿠키 값은 임의로 변경할 수 있고, 보관된 정보는 훔쳐갈 수 있습니다.
만약 쿠키에 개인정보나, 신용카드 정보가 있다면, 쿠키의 정보가 나의 로컬 PC에서 털릴 수도 있고, 네트워크 전송 구간에서 털릴 수도 있습니다.
게다가 해커가 쿠키를 한번 훔쳐가면 평생 사용할 수 있어서 해커가 쿠키를 훔쳐가서 그 쿠키로 악의적인 요청을 계속 시도할 수 있습니다.


대안

쿠키에 중요한 값을 노출하지 않고, 사용자 별로 예측 불가능한 임의의 토큰(랜덤 값)을 노출하고, 서버에서 토큰과 사용자 id를 매핑해서 인식하면 됩니다.
그리고 서버에서 토큰을 관리하고 발급하고, 해킹이 의심되는 경우 서버에서 해당 토큰을 강제로 제거하면 됩니다.
 
이 대안들을 적용한 게 '세션'방식입니다.
쿠키를 사용할때 쿠키 값에다가 사용자 id같은것을 넣지 않고,  sessionId라는 토큰을 넣는 것입니다.
그리고 서버에서 그 세션id토큰으로 실제 회원id를 매핑해서 식별하는 방법입니다.
 
다음에는 세션을 이용해서 로그인 구현하는 포스트로 뵙겠습니다!

https://konkukcodekat.tistory.com/entry/Spring-HTTP-%EC%84%B8%EC%85%98%EC%9D%98-%EA%B0%9C%EB%85%90%EA%B3%BC-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84

 

[Spring] HTTP 세션의 개념과 로그인 구현

세션이라는것은 쿠키 기반이기 때문에 쿠키에 대한 이해가 필요하므로 이전 포스트를 보고 오시면 좋습니다. 자바 스프링에서 HttpSession을 이미 지원하기 때문에, 이것을 이용해 간단한 세션 로

konkukcodekat.tistory.com

 

반응형

댓글