Notice
Recent Posts
Recent Comments
Link
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
Tags
more
Archives
Today
Total
관리 메뉴

josolha

JWT를 위해 본문

Spring

JWT를 위해

josolha 2023. 8. 21. 16:08

정리 순서 :

          1.쿠키 개념과 단점 

          2.세션 개념과 단점 

          3.JWT 등장, CORS

          4.프론트, 백앤드 전체 순서/흐름


쿠키 로그인 

쿠키 
쿠키란
인터넷을 사용하는 유저가 어떤 웹사이트를 방문했을 때
그 사이트가 사용하는 서버를 통해 로컬에 저장되는 작은 데이터이다.
쿠키는 KEY 와 VALUE로 이루어져 있으며 만료기간, 도메인, 경로 등의 정보를 가지고 있다.
쿠키를 사용한 로그인 처리 과정

1.서버에서 로그인 성공 시 쿠키를 담아 브라우져에게 전달

2.브라우져는 해당 쿠키를 저장하고 해당 사이트에 접속할 때마나 지속해서 해당되는 쿠키를 보낸다.


쿠키의 종류

사용자는 상황에 따라 입맛에 맞게끔 쿠키의 생명주기를 설정해 사용할 수 있다.

위에서 말한 쿠키의 개념은 공통이며, 추가적인 설정을 통해 쿠키의 특성이나 동작 방식이 달라진다.

 

  • 영속 쿠키: 만료 날짜를 입력하면 해당 날짜까지 유지한다.
Cookie cookie = new Cookie("name", "value");
cookie.setMaxAge(60*60*24);  // 1일 동안 유지되는 쿠키
response.addCookie(cookie);
  • 세션 쿠키: 만료 날짜를 생략하면 브라우저 종료시 까지만 유지한다.
Cookie cookie = new Cookie("name", "value");
response.addCookie(cookie);
  • 보안 쿠키 : HTTPS를 사용하는 웹 페이지에서만 전송되는 쿠키이다.
Cookie cookie = new Cookie("name", "value");
cookie.setSecure(true);
response.addCookie(cookie);
  • HttpOnly 쿠키 :
    • 웹 서버와 브라우저 간에만 사용되며, JavaScript에서는 접근할 수 없다.
    • 이는 XSS(크로스 사이트 스크립팅) 공격을 방지하기 위한 목적으로 사용된다.
Cookie cookie = new Cookie("name", "value");
cookie.setHttpOnly(true);
response.addCookie(cookie);

 


사용해보기

java.servlet.http에는 Cookie라는 클래스를 제공해주는데

이 클래스를 이용해 클라이언트에 응답할 쿠키정보를 쉽게 핸들링 가능하다.

 

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());
    if (loginMember == null) {
        bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
        return "login/loginForm";
    }

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

    return "redirect:/";
}
  • @ModelAttribute LoginForm form은 클라이언트로부터 전송받은 데이터를 LoginForm 객체에 바인딩한다.
  • @Valid 어노테이션은 LoginForm 객체의 유효성을 검사한다.
    이 검사는 LoginForm 클래스에 있는 검증 어노테이션(예: @NotNull, @Size 등)을 기반으로 한다.
  • bindingResult.hasErrors()를 사용하여 바인딩 오류나 유효성 검사 오류가 있는지 확인한다.
    오류가 있으면 "login/loginForm" 뷰로 돌아가 사용자에게 오류를 표시할 수 있다.

핵심

  • new Cookie("memberId", String.valueOf(loginMember.getId()));
    - Cookie 라는 클래스 생성자로 key/value 를 인수로 넘겨주어 생성한다.
  • response.addCookie(idCookie);
    - 생성된 쿠키(idCookie)를 서버 응답 객체(HttpServletResponse) 에 addCookie를 이용해 담아준다.
    -그럼 실제로 웹 브라우저에서는 Set-Cookie 프로퍼티에 쿠키정보가 담겨져 반환된다.

2.쿠키 조회

@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(name="memberId",required=false) Long memberId
    -쿠키를 편하게 조회할 수 있도록 도와주는 애노테이션이다.
    -전송된 쿠키정보중 key가 memberId인 쿠키값을 찾아 memberId 변수에 할당해준다.
    -required가 false이기에 쿠키정보가 없는 비회원도 접근 가능하다.

3.쿠키 로그아웃

로그인을 했으면 로그아웃도 있어야 한다.

로그아웃 기능은 쿠키를 삭제하는게 아니라 종료 날짜를 0으로 줘서 바로 만료시킴으로써 삭제할 수 있다.

@PostMapping("/logout")
public String logout(HttpServletResponse response) {
    expiredCookie(response, "memberId");
    return "redirect:/";
}

private void expiredCookie(HttpServletResponse response, String cookieName) {
    Cookie cookie = new Cookie(cookieName, null);
    cookie.setMaxAge(0);
    response.addCookie(cookie);
}

응답 쿠키의 정보를 보면 Max-Age = 0 으로 되어있어 해당 쿠키는 즉시 종료된다.

기술적으로는 setMaxAge를 사용하여 영속 쿠키를 설정하는 것이 맞다.

하지만, 값에 따라 그 쿠키의 수명이 달라지며, setMaxAge(0)는 특별히 쿠키를 즉시 만료시키는 특별한 경우이다.


단점

위 코드는 정상적으로 동작하지만 다음과 같은 문제점이 존재함.

  • 쿠키 값을 임의대로 변경 가능.
    • 사용자 A가 로그인하면서 memberId의 쿠키 값으로 1을 받았다고 가정하자.
    • 악의적인 사용자 B가 브라우저의 개발자 도구를 사용하여 memberId 쿠키 값을 2로 변경할 수 있다.
@GetMapping("/")
public String homeLogin(@CookieValue(name = "memberId", required = false) Long memberId, Model model) {
    if (memberId == null) {
        return "home";
    }

    // 여기서 사용자 B가 변경한 memberId 값인 2로 사용자의 정보를 조회합니다.
    Member loginMember = memberRepository.findById(memberId);  // 여기서 memberId는 2
    if (loginMember == null) {
        return "home";
    }

    model.addAttribute("member", loginMember);  // memberId가 2인 사용자의 정보가 model에 추가됨
    return "loginHome";
}

결과적으로, 사용자 B는 memberId 쿠키 값을 변경함으로써 다른 사용자의 정보에 접근할 수 있게 되었다.. 

 

  • 쿠키에 보관된 정보를 타인이 훔쳐갈수 있다.
    • 위의 코드에는 쿠키에 관련된 설정(예: Secure, HttpOnly)이 없다. 이로 인해 만약 웹사이트가 HTTPS를 사용하지 않으면, 중간자 공격을 통해 쿠키를 탈취당할 위험이 있다.
  • 한번 도용된 쿠키 정보는 계속 악용될수있다.
    • 한번 "memberId" 쿠키 값이 탈취당하면, 그 값을 가진 악의적인 사용자는 해당 쿠키 값을 사용하여 웹사이트에 로그인한 것처럼 접근할 수 있다.제공된 코드에서는 쿠키 값의 유효성을 검증하는 추가적인 메커니즘이 없기 때문에 이런 위험이 존재한다.

즉, 중요한 개인정보가 클라이언트에 저장 되었어 변조 및 도용이 쉽다.

이런걸 해결하기 위해 서버에서 관리하도록하고 그게 외부로 노출되지 않도록 해야함.

그래서 클라이언트는 서버가 보관하고 있는 중요 정보에 접근할 수 있는 키만 가지고, 이 키 또한 유효시간을 짧게 둬서

갱신되도록 하면 보안적으로 많이 안전해질 것이다.

이러한 방법을 세션이라고한다.


쿠키 & 세션 로그인 

세션 개념과 단점 
세션이란
웹 서버의 세션은 클라이언트별로 유지되는 정보를 저장하기 위한 서버 측 저장소이며,
웹 애플리케이션은 사용자의 상호 작용 내역이나 사용자 특정 정보를 세션에 저장하여
사용자의 연속적인 요청 사이에 상태 정보를 유지할 수 있다.

추가 정보

세션 정보는 여러 방식으로 저장될 수 있다.

메모리에 직접 저장하는 인메모리 세션, 데이터베이스, 파일 시스템, 캐시 시스템(Redis 등)에 저장하는 방식 등 다양한 방식이 있다.

 

쿠키 & 세션을 사용한 로그인 처리 과정

그럼 결국 세션 로그인을 한다는건 서버의 세션 저장소에 key/value로 저장한 뒤,
브라우저에서는 (쿠키를 사용해) key값만 가지고 있도록 하는 것이다. 이 개념을 그림으로 표현하면 다음과 같다.

 

이제 다음 로그인이나 페이지 접근시 쿠키에서 저장하고 있는 sessionId를 같이 전달하면 서버의 세션 저장소에서는
해당 sessionIdkey로 가지고 있는 value값을 조회해서 로그인 여부와 중요 정보를 확인한다.

 

결국, 클라이언트와 서버는 쿠키로 연결되어 있지만 중요한 점은 다음과 같다.

  • 회원과 관련된 정보는 클라이언트에서 가지고 있지 않다.
  • 추정 불가능한 세션 아이디만 쿠키를 토해 주고받기에 보안에서 많이 안전해졌다.

추가적으로 세션아이디가 저장된 쿠키의 만료시간을 짧게 유지한다면, 해커가 해당 키를 도용한다 하더라도 금새

갱신되며 사용하지 못하게 되어 보안적으로 좀 더 안전해질 수 있다.

 

세션 관리 기능

3가지 기능을 제공해야 한다.

  • 세션 생성
    • 세션 키는 중복 X, 추정 불가능한 랜덤 값이어야함.
    • 세션 키에 매칭될 값(value)가 있어야함.
    • 이렇게 생성된 세션 키를 응답 쿠키에 저장해 클라이언트에 전달.
  • 세션 조회
    • 클라이언트가 요청한 세션아이디 쿠키 값으로 세션 저장소에 저장된 값을 조회할 수 있어야한다.
  • 세션 만료
    • 클라이언트가 요청한 세션아이디 쿠키 값으로 세션 저장소에 보관한 세션 엔트리를 제거해야함.

사용해보기(2가지 방법)

1.SessionManager는 여러 세션을 관리하는 데 중점을 둔 구성 요소이다.

2.HttpSession(서블릿 API 제공)은 개별 사용자의 세션 데이터를 나타낸다.

(저장방식 : 둘다 인메모리의 세션 저장 방식을 사용한다)

 

 

SessionManager 컴포넌트 만들어서 사용하기

1.세션 생성.
@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) {
        //세션 생성
        String sessionId = UUID.randomUUID().toString();
        sessionStore.put(sessionId, value);

        //쿠키 생성 후 저장
        Cookie cookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
        response.addCookie(cookie);
    }

    public Object getSession(HttpServletRequest request) {
        Cookie cookie = findCookie(request, SESSION_COOKIE_NAME);
        if (cookie == null) {
            return null;
        }
        return sessionStore.get(cookie.getValue());
    }

    public void expire(HttpServletRequest request) {
        Cookie cookie = findCookie(request, SESSION_COOKIE_NAME);
        if (cookie != null) {
            sessionStore.remove(cookie.getValue());
        }
    }

    public Cookie findCookie(HttpServletRequest request, String cookieName) {
        if (request.getCookies() == null) {
            return null;
        }

        return Arrays.stream(request.getCookies())
                .filter(c -> c.getName().equals(cookieName))
                .findAny()
                .orElse(null);
    }
}

@Component 어노테이션을 붙여서 스프링 빈으로 자동 등록.

 

2.로그인(세션 생성)

@PostMapping("login")
public String loginV2(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletResponse response) {
    //...기존 코드와 동일
    //세션 매니저를 통해 세션 생성및 회원정보 보관
    sessionManager.createSession(loginMember, response);
    return "redirect:/";
}

로그인 성공시 세션을 등록, 이때 생성된 세션아이디를 쿠키로 발행 저장.

 

3.로그아웃(세션 만료)

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

 

4.홈 화면 이동(세션 조회)

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

HttpSession(서블릿 API 제공) 사용하기

 

세션 조회용 상수

public interface SessionConst {
		String LOGIN_MEMBER = "loginMember";
}

 

로그인 컨트롤러

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

    Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
    if (loginMember == null) {
        bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
        return "login/loginForm";
    }

    //세션 매니저를 통해 세션 생성및 회원정보 보관
    //세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성
    HttpSession session = request.getSession();
    session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);

    return "redirect:/";
}

@PostMapping("/logout")
public String logoutV3(HttpServletResponse response, HttpServletRequest request) {
    HttpSession session = request.getSession(false);
    if (session != null) {
        session.invalidate();
    }

    return "redirect:/";
}

reqquest.getSession()

-getSession 메서드는 세션을 생성 혹은 조회하는 메서드 이다.

public HttpSession getSession(boolean create); //default true

 

여기서 create 옵션의 의미는 다음과 같다.

  • true 일 경우
    • 세션이 있으면 기존 세션을 반환한다.
    • 세션이 없으면 새로운 세션을 생성해 반환한다.
  • false 일 경우
    • 세션이 있으면 기존 세션을 반환한다.
    • 세션이 없으면 새로운 세션을 생성하지 않고 nulll을 반환한다.

추가적으로 인수를 전달하지 않을 경우 기본 값은 TRUE 이다.

 

session.invalidate();

-세션을 제거하는 메서드다.


@SessionAttribute 애노테이션 활용

스프링은 세션을 더 편리하게 사용할 수 있도록 @SessionAttribute라는 애노테이션을 제공한다.

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

    if (loginMember == null) {
        return "home";
    }

    model.addAttribute("member", loginMember);
    return "loginHome";
}

 

  • @SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false)
    • 이전에 사용한 @CookieValue와 비슷하다. 클라이언트로부터 전달받은 내용의 세션중에서 key가 일치하는게 있는지 찾는다. required가 false이니 만약 못찾으면 null이 할당될 것이다.

HttpSession에서 제공하는 정보

HttpSession에서는 많은 세션정보를 제공하는데 다음과 같다.

public void printSessionInfo(HttpServletRequest request, String sessionId){
		HttpSession session = request.getSession(false);    

    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());
}
sessionId : 세션 아이디(JSESSIONID)의 값(ex:754BE5D4DD969894D958AC278370D06E)
maxInactiveInterval : 세션의 유효 시간(ex: 1800초, (30분))
creationTime: 세션 생성일시
lastAccessedTime : 세션과 연결된 사용자가 최근에 서버에 접속한 시간. (클라이언트에서 서버로 sessionId(JSESSIONID)를 요청한 경우 갱신된다.)
isNew : 새로 생성된 세션인지, 아니면 이미 과거에 만들어졌고, 클라이언트에서 서버로 sessionId(JSESSIONID)를 요청해서 조회된 세션인지 여부

 

세션 타임아웃 설정하기
대부분의 사용자는 직접 명시적으로 로그아웃 버튼을 누르지 않는다.
그냥 웹 브라우저를 종료할 뿐인데 HTTP는 비연결성 이기에 서버측에선 클라이언트가 웹 브라우저를 종료했는지 알 수없다. 그렇기에 세션을 언제 삭제해야 할지 판단하기 어렵다.
 
그렇다고 세션을 무한정 유지되도록 한다면, 여러가지 문제가 발생할 수 있다.
  •  JESSTIONID를 탈취당한 경우 시간이 흘러도 해당 쿠키로 악용될수 있다.
  • 세션은 기본적으로 메모리에 생성되는데 메모리의 크기가 무한핮 않기에 사용하지 않는 세션이 관리되지 않으면 성능저하가 필연적이고 OutOfMemoryException이 발생할 수 있다.

이러한 이유로 세션에는 타임아웃이 되어야하는데, 종료 시점은 어떻게 설정하는게 좋을까?

너무 빠르면 로그인 유지가 무관하게 계속 로그인을 해야한다. 그렇다고 너무 길게 잡으면 위에 말한 문제가 생긴다.

기본적으로는 세션 생성 시점으로부터 30분 정도를 잡고는 한다.

 

하지만 여기서 문제가 하나 더 있다. 종료 시점을 30분으로 둔다고 하면 사용자가 30분간 활동하다가 다시 로그인을 해야하는 것일까? 이보다는 사용자가 가장 최근 요청한 시간을 기준으로 30분 정도를 유지하는 것이다.

 

HttpSession은 기본적으로 이방식을 사용하는데 기획에 따라 이 설정을 변경할 수도 있다.

스프링 부트에서는 application.properties에 글로벌 설정을 해 줄 수 있다.

session.setMaxInactiveInterval(1800);//1800초
 
이렇게 1800초(30분)으로 설정을 해두면 LastAccessTime 이후 timeout 시간이 지나면 WAS 내부에서 해당 세션을 삭제한다.

 

단점
  • 쿠키를 포함한 요청이 외부에 노출되더라도 세션 ID 자체는 유의미한 개인정보를 담고 있지 않는다.
    그러나 해커가 세션 ID 자체를 탈취하여 클라이언트인척 위장할 수 있다는 한계가 존재한다.
    (이는 서버에서 IP특정을 통해 해결 할 수 있긴 하다)
  • 서버에서 세션 저장소를 사용하므로 요청이 많아지면 서버에 부하가 심해진다.

 


JWT

이러한 단점을 극복하기 위해 "토큰 기반 인증 시스템" JWT가 나왔다.

인증받은 사용자에게 토큰을 발급해주고,
서버에 요청을 할 때 HTTP 헤더에 토큰을 함께 보내 인증받은 사용자(유효성 검사)인지 확인한다.
서버 기반 인증 시스템과 달리 사용자의 인증 정보를 서버에 저장하지 않고 클라이언트의 요청으로만 인가를 처리하므로
Stateless 한 구조를 가진다.
JWT는 Json Web Token의 약자로 인증에 필요한 정보를 암호화시킨 토큰을 뜻한다.
세션/쿠키 방식과 유사하게 클라이언트는 Access Token(JWT)을 HTTP 헤더에 실어 서버로 보낸다.

 

 

JWT(JSON Web Token)란 인증에 필요한 정보들을 암호화시킨 JSON 토큰을 의미한다. 그리고 JWT 기반 인증은 JWT 토큰(Access Token)을 HTTP 헤더에 실어 서버가 인증받은 사용자(유효성 검사)인지 확인한다.

JWT는 JSON 데이터를 Base64 URL-safe Encode 를 통해 인코딩하여 직렬화한 것이며, 토큰 내부에는 위변조 방지를 위해 개인키를 통한 전자서명도 들어있다. 따라서 사용자가 JWT 를 서버로 전송하면 서버는 서명을 검증하는 과정을 거치게 되며 검증이 완료되면 요청한 응답을 돌려준다.

 

JWT 구조

JWT는 . 을 구분자로 나누어지는 세 가지 문자열의 조합이다.

. 을 기준으로 좌측부터 Header, Payload, Signature를 의미한다.

Header 에는 JWT 에서 사용할 타입과 해시 알고리즘의 종류가 담겨있으며, Payload 는 서버에서 첨부한 사용자 권한 정보와 데이터가 담겨있다. 마지막으로 Signature 에는 Header, Payload 를 Base64 URL-safe Encode 를 한 이후 Header 에 명시된 해시함수를 적용하고, 개인키(Private Key)로 서명한 전자서명이 담겨있다.

실제 디코딩된 JWT는 다음과 같은 구조를 지닌다.


Header

PayLoad

토큰에서 사용할 정보의 조각들인 Claim 이 담겨있다. (실제 JWT 를 통해서 알 수 있는 데이터)

즉, 서버와 클라이언트가 주고받는 시스템에서 실제로 사용될 정보에 대한 내용을 담고 있는 섹션이다.

Signature

시그니처에서 사용하는 알고리즘은 헤더에서 정의한 알고리즘 방식(alg)을 활용한다.

시그니처의 구조는 (헤더 + 페이로드)와 서버가 갖고 있는 유일한 key 값을 합친 것을 헤더에서 정의한 알고리즘으로 암호화를 한다.

 



JWT 인증 과정

  1. 1.사용자가 ID, PW를 입력하여 서버에 로그인 인증을 요청한다.
  2.  
  3. 2.서버에서 클라이언트로부터 인증 요청을 받으면, Header, PayLoad, Signature를 정의한다.
    Hedaer, PayLoad, Signature를 각각 Base64로 한 번 더 암호화하여 JWT를 생성하고 이를 쿠키에 담아 클라이언트에게 발급한다.
  4.  
  5. 3.클라이언트는 서버로부터 받은 JWT를 로컬 스토리지에 저장한다. (쿠키나 다른 곳에 저장할 수도 있음)
    API를 서버에 요청할때 Authorization header에 Access Token을 담아서 보낸다.
  6.  
  7. 4.서버가 할 일은 클라이언트가 Header에 담아서 보낸 JWT가 내 서버에서 발행한 토큰인지 일치 여부를 확인하여 일치한다면 인증을 통과시켜주고 아니라면 통과시키지 않으면 된다.
    인증이 통과되었으므로 페이로드에 들어있는 유저의 정보들을 select해서 클라이언트에 돌려준다.
  8.  
  9. 5.클라이언트가 서버에 요청을 했는데, 만일 액세스 토큰의 시간이 만료되면 클라이언트는 리프레시 토큰을 이용해서
  10.  
  11. 6.서버로부터 새로운 엑세스 토큰을 발급 받는다.

토큰 인증 신뢰성을 가지는 이유

유저 JWT: A(Header) + B(Payload) + C(Signature) 일 때 (만일 임의의 유저가 B를 수정했다고 하면 B'로 표시한다.)

  1. 1.다른 유저가 B를 임의로 수정 -> 유저 JWT: A + B' + C
  2. 2.수정한 토큰을 서버에 요청을 보내면 서버는 유효성 검사 시행
    • 유저 JWT: A + B' + C
    • 서버에서 검증 후 생성한 JWT: A + B' + C' => (signature) 불일치
  3. 3. 대조 결과가 일치하지 않아 유저의 정보가 임의로 조작되었음을 알 수 있다.
  4.  

정리하자면, 서버는 토큰 안에 들어있는 정보가 무엇인지 아는게 중요한 것이 아니라 해당 토큰이 유효한 토큰인지 확인하는 것이 중요하기 때문에, 클라이언트로부터 받은 JWT의 헤더, 페이로드를 서버의 key값을 이용해 시그니처를 다시 만들고 이를 비교하며 일치했을 경우 인증을 통과시킨다.


JWT 장단점


HTTP 헤더에 토큰을 실어 보내는 이유

1.상태 저장의 회피: RESTful 웹 서비스는 상태 저장을 지양한다. 이는 서비스 간의 결합도를 낮추기 위한 것이다.

세션/쿠키 방식은 사용자의 상태 정보를 서버에 저장하기 때문에, 클라이언트의 각 요청에 대해 이 상태를 확인해야 한다. JWT를 HTTP 헤더에 실어 보내면 클라이언트는 자신의 상태 정보를 담은 토큰을 함께 보낼 수 있고, 서버는 이를 통해 상태를 인식할 수 있으므로 별도의 상태 저장이 필요 없다

 

2.보안: 쿠키는 브라우저에 의해 자동으로 전송된다. 이는 CSRF(크로스 사이트 요청 위조) 공격에 취약하게 할 수 있다.

반면, JWT를 HTTP 헤더에 포함시켜 전송하는 것은 브라우저에서 자동으로 이루어지지 않기 때문에 이러한 종류의 공격으로부터 좀 더 안전할 수 있다.

 

3.플랫폼 및 도메인 독립성:  JWT는 플랫폼 및 언어에 독립적이다. HTTP 헤더에 토큰을 실어 보내는 방식은 웹, 모바일, 서버 간 통신 등 다양한 환경에서 쉽게 적용할 수 있다. 또한, 쿠키는 도메인에 종속적이라서 크로스 도메인 요청에 제약이 있습니다. 헤더를 사용하면 이러한 제약 없이 다양한 도메인 간에 토큰을 전송할 수 있다.

 

4.간결함 및 확장성: 토큰을 헤더에 포함시키는 것은 표준화된 방법이다. 대부분의 API 및 프레임워크는 Authorization 헤더를 통해 인증 정보를 받아들이도록 설계되어 있다. 또한 필요한 경우 헤더를 확장하여 추가 정보를 포함시킬 수 있다.

 

5.모든 요청에 대한 인증: JWT가 헤더에 포함되면, 클라이언트는 모든 요청에 이 토큰을 포함시켜 서버로 보낸다. 이렇게 하면 서버는 요청의 유효성을 검증하고, 클라이언트의 신원을 확인할 수 있다.

 

결론적으로, JWT를 HTTP 헤더에 실어 보내는 방식은 RESTful 웹 서비스의 원칙, 보안, 확장성, 플랫폼 독립성 등 여러 이점을 충족시키기 때문에 널리 사용되고 있다.


HTTP 헤더  요청 형태

예를 들어, JWT (JSON Web Token)를 사용하여 인증할 때, 보통의 경우 다음과 같이 Authorization 헤더에 정보를 담아 보낸다.

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

HTTP 요청을 할 때, 특히 인증이 필요한 API에 접근하려 할 때 Authorization 헤더를 사용하여 토큰 정보를 보내기도 한다.

여기서 <type>은 토큰의 종류를 나타내며, <credentials>는 실제 토큰 값을 의미한다.

 

Bearer는 단순히 "이 토큰은 Bearer 토큰이다"라는 의미를 가진다. 다른 종류의 인증 방식 (예: Basic 인증)에서는 다른 타입 값을 사용할 수 있다.


CORS (cross-Origin Resource Sharing)란?

교차 출처 리소스 공유를 뜻하는 CORS는, '서로 다른 출처에서 리소스를 공유하는 것'을 뜻한다. 여기서 말하는 출처란 다음과 같다.

URL은 https://www.domain.com:3000/user?query=name&page=1 과 같은 형태로 이루어져 있다.

 

이 중, 'Protocol, Host, Port' 를 합쳐 부르는 말이 Origin(출처)이다.

 

이제 이해가 갈 것이다.

 

CORS 허용 설정을 하지 않았을 경우,
서버와 다른 Origin을 가진 곳에서 서버의 Origin에 요청을 보내면 CORS 에러가 발생하는 것이다.

 

예를들어 만약 내가 개발 중인 스프링부트 서버의 Origin은 'http://localhost:8080'

서버에 요청을 보낸 React의 Origin은 'http://localhost:3000' 이라면

Origin의 port 부분이 상이하기 때문에 문제가 발생하는 것이다.

 

이유는 Origin: 'http://localhost:3000' 과 같이 출처를 담아서 서버로 보내면, 서버는 응답 헤더의 Access-Control-Allow-Origin이라는 값에 이 리소스를 접근하는 것이 허용된 출처 목록을 담아준다.

이후 응답을 받은 브라우저는 자신이 보냈던 요청의 Origin과 서버가 보내준 응답의 Access-Control-Allow-Origin을 비교하고

만약 허용되지 않는 Origin이면 CORS 정책 위반 이슈가 발생하기 때문이다.

 

해결방안1.

스프링부트 서버에 http://localhost:3000 Origin의 요청에 대하여 CORS를 허용해주면 되기 때문에 WebMvcConfigurer 를 implements한 WebMvcConfig 클래스를 생성하여 해당 오리진에 대한 CORS를 허용하여 문제를 해결할 수 있다.

 

WebMvcConfg.java

package com.jul.jumpropetornamentchecker.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@EnableWebMvc
public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("http://localhost:3000")
                .allowedMethods("OPTIONS","GET","POST","PUT","DELETE");
    }

}

config 패키지를 생성 후, 패키지 내부에 해당 클래스를 생성하여 설정하니 문제가 해결된다고 한다.

addCorsMappings 메서드를 오버라이딩하여

모든 RequestMapping에 대하여, 통신을 주고 받는 React Origin의 요청에 대해 CORS를 허용해주면된다.

 

해결방안2.

spring mvc 말고 spring security 에서도 설정 가능하다.

 

스프링 시큐리티 CORS 허용

@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable()
                .cors().configurationSource(corsConfigurationSource())  ---------- (1)
//                .headers().frameOptions().disable()
                .and()
                    .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                    .authorizeRequests()
                        .antMatchers("/api/v1/shops/**").hasRole("OWNER")
                        .antMatchers(HttpMethod.PUT, "/api/v1/users/**").hasAnyRole("USER", "OWNER")
                        .antMatchers(HttpMethod.DELETE, "/api/v1/users/**").hasAnyRole("USER", "OWNER")
                        .antMatchers(HttpMethod.GET, "/api/v1/users/").hasAnyRole("USER", "OWNER")
                        .anyRequest().permitAll()
                .and()
                    .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                            UsernamePasswordAuthenticationFilter.class);


    }
    
    // CORS 허용 적용
    @Bean --------- (2)
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

        configuration.addAllowedOrigin("*");
        configuration.addAllowedHeader("*");
        configuration.addAllowedMethod("*");
        configuration.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
 }

(1) - > CorsConfigurationSource 를 cors 정책의 설정파일 등록하는 부분 이거 안해주면...설정한 의미가 없음.

(2) -> Cors 허용 정책 설정하는 Bean 

                       * addAllowedOrigin() : 허용할 URL

                       * addAllowedHeader() : 허용할 Header

                       * addAllowedMethod() : 허용할 Http Method


프로젝트 JWT 백앤드, 프론트 상호작용 순서 

  • 인증:
    • 사용자가 로그인 폼을 통해 자신의 정보 (예: 이메일, 비밀번호)를 제출하면,
      프론트엔드는 이 정보를 백엔드에 전송한다.
  • 토큰 생성:
    • 백엔드에서는 사용자 정보를 검증하고, 유효한 정보인 경우 JWT Access TokenRefresh Token을 생성한다.
      (Access Token은 짧은 만료 시간을 가지며, 사용자의 요청을 인증하는데 사용된다.)
      (Refresh Token은 더 긴 만료 시간을 가지며, Access Token이 만료됐을 때 새로운 Access Token을 발급받는 데 사용된다.)
    • Redis에 Refresh Token 저장: Refresh Token을 Redis에 저장한다.
      이 때, 사용자의 고유 ID나 email을 키로 사용하고,으로는 해당 사용자의 Refresh Token을 저장한다.
      (Redis에 토큰을 저장할 때, 해당 토큰의 만료 시간도 함께 설정한다. 이렇게 하면, Redis가 자동으로 만료된 토큰을 삭제한다
       추가적으로 Redis에서는 EXPIRE 또는 SETEX와 같은 명령을 사용하여 특정 키에 대한 만료 시간을 설정할 수 있음.)
  • 토큰 전송:
    • 백엔드는 생성된 Access TokenRefresh Token을 응답으로 프론트에 전송한다.
  • 토큰 저장:
    • 프론트엔드는 받은 Access TokenRefresh Token을 적절한 위치에 저장한다.
    • 이 위치는 메모리, *localStorage, sessionStorage, 쿠키 등 다양할 수 있다.
      (하지만 보안 측면에서는 메모리 저장이나 HttpOnly 쿠키가 권장된다.)
  • 매 요청 시 헤더에 포함:
    • 이후 프론트가 백엔드에 요청을 보낼 때마다, 저장된 Access TokenAuthorization 헤더에 포함시켜 전송한다.
  • 백엔드에서의 검증:
    • 백엔드는 매 요청마다 Authorization 헤더에 포함된 엑세스 토큰을 검증하여 사용자의 인증 상태와 권한(security)를 확인한다.
    • 만약 Access Token이 만료된 경우, 프론트는 저장된  Refresh Token을 사용하여 새로운 Access Token을 요청할 수 있다.
      (Access Token을 재발급 받기 위해 /reissue API를 호출한다. 이 요청의 Header의 Authorization에는 Refresh Token이 포함된다.)
  • Access Token 재발급 요청:
    • 프론트가 리프레시 토큰을 사용하여 새로운 엑세스 토큰을 요청하면, 백앤드는 Refresh Token의 유효성을 검사한다.
      • Token의 Payload에서 토큰의 타입 (Access/Refresh)를 확인한다.
        이는 잘못된 토큰 타입이 전송되는 것을 방지하기 위함.
      • Payload 내의 사용자 정보 (예: email, id)를 사용하여 Redis에서 해당 사용자의 Refresh Token을 조회한다.
      • Redis에서 원래의 Refresh Token이 확인되면, Server는 새로운 Access TokenRefresh Token을 생성한다.
      • 생성된 토큰들은 프론트에게 응답으로 전송한다.
      • 새로 발급된 Refresh Token은 다시 Redis에 저장하며, 기존의 Refresh Token은 제거된다.
        (이는 Token 재사용을 방지하기 위함입니다.)
  • Refresh Token 유효성 확인:
    • 백엔드는 Redis에서 해당 Refresh Token의 유효성을 확인한다. Redis에 존재하고 만료되지 않은 Refresh Token 유효하다.
  • 로그아웃 또는 토큰 무효화:
    • 사용자가 로그아웃하거나 특정 이유로 토큰을 무효화해야 할 경우, 해당 사용자의 리프레시 토큰을 Redis에서 삭제한다.
      (*블랙리스트)

*localstorage란

로컬 스토리지(localStorage)는 웹 브라우저에서 제공하는 클라이언트 측 저장 메커니즘이다.

백엔드에서 받은 JWT를 로컬 스토리지에 저장하고 이후  API 요청을 할 때, 로컬 스토리지에서 JWT를 가져와 HTTP 헤더나 본문에 포함시켜 백엔드에 전송할 수 있다.

 

문제점 :

  • 로컬 스토리지는 스크립트에서 쉽게 접근할 수 있어서 따라서 XSS(크로스 사이트 스크립팅) 공격에 취약하게 된다.
    따라서 악의적인 스크립트가 웹 페이지에 주입되면, 그 스크립트는 로컬 스토리지에서 정보를 읽거나 수정할 수 있다.
  • 데이터를 저장할 수 있는 크기 제한이 있다. 대부분의 브라우저에서는 5~10MB 정도로 제한된다.
  • 로컬 스토리지에 저장된 토큰의 만료를 관리하는 것은 클라이언트 측에서 별도로 처리해야 한다. 즉, 토큰이 만료되었을 때 적절한 조치를 취하는 것은 개발자의 몫이라서 HTTP-only 쿠키를 사용하는게 훨씬 이득이다.

블랙리스트 : 

기능은 로그아웃 시나리오나 특정 보안 문제로 인한 토큰 무효화 시나리오를 위해

 

'Spring' 카테고리의 다른 글

Eco-reading 트러블 슈팅(실시간 알림 기능)  (1) 2023.11.27
AWS S3 (이미지 다운로드 에러)  (1) 2023.11.20
JPA의 @Builder , @Builder.Default  (1) 2023.11.13
스프링 (AOP)  (0) 2023.11.09
NCT PROJECT 트러블 슈팅(시큐리티)  (1) 2023.10.09