로그인 구현: #1. 식별자 제공

bien·2023년 9월 9일
0

프로젝트

목록 보기
1/5

로그인이란 무엇이고, 어떻게 구현할 수 있을지 알아보자!


로그인이란? (feat. 인증 & 인가)

HTTP의 무상태성(Stateless)

웹 페이지를 방문할때마다 컴퓨터는 HTTP(HyperText Transfer Protocol)를 사용하여 인터넷 어딘가 다른 컴퓨터에서 해당 페이지를 다운로드한다. 로그인이라는 정보 통신의 두 주체(클라이언트, 서버)간의 식별 행위에 있어 Http는 주의해야 할 특성을 가지고 있는데, 바로 무상태 프로토콜(Stateless)이다.

무상태 프로토콜(Statleless)이란 서버가 클라이언트를 식별할 수 있는 정보를 갖고있지 않는 것을 의미한다. 무상태 프로토콜에서, 하나의 요청과 하나의 응답이 트랜잭션으로 묶여 독립적으로 다뤄진다. 모든 요청이 불연속적이고 개별적으로 다뤄지므로, 클라이언트가 같더라도 서버는 이를 인식하지 못한다.

그러나 실제로 웹 사이트를 이용할 때, 서비스 제공자(서버)가 고객(클라이언트)을 식별하지 못한다면 제공할 수 있는 서비스는 매우 제한된다. 옷 한벌을 사더라도 고객이 누구인지 알지 못한다면 어떻게 거래가 가능할까?

로그인 기능 구현이란 이 "HTTP의 무상태성(Stateless)"이라는 특성 속에서 서버가 클라이언트를 식별하려는 다양한 노력을 의미한다. 모든 독립적이고 개별적인 통신들 사이에서 우리는 어떻게 사용자를 식별할 수 있을까? 그 구체적인 구현 방법을 알아보자!

관련 용어

로그인과 관련된 용어들을 알아보자.

인증(Authentication): (식별 가능한 정보로) 서비스에 등록된 유저의 신원을 입증하는 과정
인가(Authorization): 인증된 사용자에 대한 자원 접근 권한.

웹에서의 인증과 인가란, 자원적절한/유효한 사용자에게 전달/공개 하기 위한 방법이다.


로그인 구현 방법

사용자를 식별하기 위한 정보를 '신분증'에 비유해보자. 모든 통신이 단절되어 있는 http 통신 세상에서 우리는 어떻게 사용자를 구별할 수 있을까?

1. 인증하기: Request Header

다른 여러 요구사항을 떠나서 "인증" 그 자체에만 초점을 맞춰보자. 사용자는 어떻게 자신을 서비스 제공자에게 알릴 수 있을까? 신분증 정보를 제공하면 된다. 사용자(클라이언트) 측에서 자신을 알리기 위해 선택할 수 있는 선택지는 다음과 같다.

  • 요청 query parameter
  • 요청 Body
  • 헤더 Cookie
  • 헤더 Authorization

📁 Authorization

그 중 Authorization에 대해 간단히 살펴보면 다음과 같다.

Authorization: <type> <credentials>

  • type: 인증 타입. 보통 "Basic" (이후 살펴볼 JWT는 Bearer type을 사용한다.)
  • credentials: 만약 Basic인 경우, 입력된 사용자명과 비밀번호를 합치고,(id:password)이를 base64로 인코딩해 전송한다.
    출처:mdn(Authorization)

그러나 이 방법에는 여전히 HTTP의 무상태성이 유발하는 인증 유지라는 문제점이 남아있다. 모든 요청이 독립적이기에, 사용자는 자신이 수행하는 모든 요청에 자신의 신분증을 제출해야만 한다. 이 같은 경우를 해결하기 위해, 우리는 Browser의 힘을 빌릴 수 있다.

2. 인증 유지하기 Browser

모든 요청에 신분증을 첨부하는 것은 정말 골치아픈 일일것이다. 우리는 Browser의 힘을 빌려 이 일을 위임할수있다!

브라우저에서 데이터를 저장하는 것에는 크게 3가지 방법이 있다. 각 방법들에 대해 대략적으로 알아보자.

  • 서버와 클라이언트간 교환하는 조그마한 데이터
    • (name/value의 String 쌍)
  • 쿠키를 이용해 서버는 사용자에 대한 정보를 브라우저에 저장할 수 있다.
  • 서버가 클라이언트로 쿠키를 보내면, 클라이언트는 새로운 요청을 보낼 때 쿠키를 돌려보낸다. (자동으로)
  • 참고: 쿠키는 도메인에 따라 제한된다.
    • (유튜브가 준 쿠키는 유튜브에만 전송된다.)

📁 웹 스토리지(Web storage) API: sessionStorage, localStorage

  • 기존 쿠키(cookie)의 문제점 개선
  • 모든 요청에 서버에 전송되지 않는다.
  • 오리진(origin)마다 하나의 스토리지가 존재한다.
    • 오리진: 도메인(domain), 프로토콜(protocol) 한쌍으로 이루어진 식별자.
    • 하나의 오리진에 속하는 모든 웹 페이지는 같은 데이터(data)를 저장하며, 데이터에 접근 가능하다.

📋 sessionStorage

  • 하나의 세션(session)만을 위한 데이터를 저장하는 객체
  • 사용자가 브라우저 탭이나 창을 닫으면 데이터가 삭제된다.

📋 localStorage

  • 보관 기한이 없는 데이터를 저장한다.
  • 브라우저 탭, 창이 닫히거나, 컴퓨터를 재부팅해도 저장된 데이터는 없어지지 않는다.

Chrome의 개발자도구에서 Application으로 들어가면 웹 브라우저가 제공하고 있는 저장 공간들을 확인할 수 있다.

🚨 문제점: 보안

특히 쿠키의 힘을 빌리면 우리는 간단하게 사용자 식별정보(신분증)를 모든 요청에 첨부할 수 있다. 그러나, 이 방식에도 큰 문제점이 있다. 바로 보안의 문제이다. 쿠키에 첨부된 이 데이터들은 사용자에 대한 중요한 정보를 담고 있기 때문에, 이 쿠키가 탈취당하면 아주 간단히 사용자를 사칭할 수 있다. 이 보안 문제를 해결하기 위해 우리는 session을 이용할 수 있다.

3. 안전하게 인증하기 Server

앞서 언급된 쿠키 로그인 방식은 핵심 정보(신분증)의 보관 주체가 사용자이기 때문에 여러 문제가 야기되었다. HTTP 통신 프로토콜에서 모든 요청은 독립적이므로 웹에서 화면이 전환될때마다 로그인을 반복해야 하는데, 이를 해결하기 위해 우리는 Browser의 힘을 빌렸다. 그러나 브라우저에서 핵심적인 정보를 다루는 것은 보안적 측면에서 취약할 수 있다(브라우저는 서버에 비해 노출 위험도가 높으므로). 세션은 이 문제를 해결하기 위해, 핵심 정보(신분증)를 서버에서 다룬 다는 것이 주요 차이점이다.

📁 session

  • 사용자가 올바른 id & password로 로그인 시도 시, 서버는 세션 저장소에 (사용자 & 세션ID)를 매핑해 저장해둔다.
  • 세션 ID는 쿠키를 통해 브라우저에 저장된다.
  • 서버는 브라우저에서 쿠키를 통해 자동으로 전송되는 (세션 ID를 담고있는)쿠키를 확인하고, 해당 ID를 DB에서 조회해 일치하는 유저를 확인한다.

📌 포인트

  • 서버에서 유저 식별자(신분증)을 저장한다.
  • 브라우저에 저장되고 통신에 오가는 정보는 사용자의 핵심정보를 담고있지 않은 임의의 값(세션 ID)이다.

장점

우리는 세션을 사용하면서 다음과 같은 장점을 기대할 수 있다.

  • 사용자 측에서 핵심 정보(신분증)를 보관하지 않는다.
    • 세션ID는 서버 핵심정보를 담고 있지않으므로, 탈취되더라도 그 위험도가 낮다.
  • 만료기간 설정이 가능하다.
  • 관리의 주체가 서버이므로, 탈취된 세션을 서버에서 삭제해 이용을 방지할 수 있다.
    • 따라서, "사용기기 일괄 로그아웃" 서비스가 제공 가능하다.

📖 session 기반 로그인 구현

🔍 세션에 대한 이해

HttpSession 인터페이스는 두 개 이상의 웹사이트 방문에 대해 유저를 식별하는 방법과 유저 관련 정보를 제공한다. 이 HttpSession interface를 구현한 StandrdServlet은 대강 이런 형태로 생겼다.

public class StandardSession implements HttpSession {
    private String id; // 세션ID
    private Map<String, Object> attributes; // 세션에 저장된 데이터
    // ...
}

여기서 id에 세션ID가 담기고, 세션에 부여하는 데이터는 attributes라는 Map에 담긴다. 여기서 id자체는 서블릿 컨테이너에서 만들어 넣어주고, 우리는 유저 식별정보를 attribute에 넣어두고 환자를 구별하는데 이용할 것이다.

🔍 HttpSession session = request.getSession();

  • Response 객체에 세션 쿠키 보내기
    • request.getSession() 메서드를 호출하면 컨테이너가 직접 세션 ID를 생성하고, 새로운 Cookie 객체를 만든 후 쿠키안에 세션 ID 값을 채우며, Response의 헤더(Set-Cookie)에 쿠키를 설정한다. 모든 쿠키 관련된 일은 서블릿 컨테이너가 대신 수행해준다.
  • Request 객체로부터 세션 ID 가져오기
    • reuqest.getSession(boolean) 메서드를 호출하면 세션이 이미 존재하는 경우 기존 세션을, 세션이 없는 경우 새로운 세션을 생성해 반환한다.
    • 이때, true를 인수로 넘기면 새로운 세션을 생성하고, false를 넘기면 생성하지 않는다.

🔍 session.setAttribute(String, Object);

이 메서드를 통해 앞서 살펴본 attributes Map 공간에 저장하고자 하는 사용자 관련 데이터를 담을 수 있다.

💻 로그인 로직

 @PostMapping("/login")
    public String login(@ModelAttribute LoginDTO loginDTO,
                        @RequestParam(defaultValue = "/") String redirectURL,
                        HttpServletRequest request) {

        AdminDTO loginAdmin = loginService.loginAdmin(loginDTO.getId(), loginDTO.getPassword());
        log.info("loginAdmin = {}", loginAdmin);

        // 로그인 성공 처리
        HttpSession session = request.getSession();
        session.setAttribute(SessionConst.LOGIN_ADMIN, loginAdmin);

        return "redirect:" + redirectURL;
    }
// 로그인 성공 처리
HttpSession session = request.getSession();
session.setAttribute(SessionConst.LOGIN_ADMIN, loginAdmin);

유저가 적절한 id & password 정보를 입력했다면, request.getSession()메서드를 호출하여 세션 ID를 쿠키로 반환하고, 세션에 유저와 관련된 정보도 추가한다.

💻 session 정보 호출 로직

HttpSession session = request.getSession();
AdminDTO adminDTO = (AdminDTO) session.getAttribute(SessionConst.LOGIN_ADMIN);

컨트롤러에서 이런식으로 부여한 session의 속성으로 값을 찾아 사용할 수 있다.

💻 로그아웃 로직 (세션 제거)

세션은 사용자가 로그아웃을 호출하여 session.invalidate()가 호출되는 경우에만 삭제된다.

	@PostMapping("/logout")
    public String logout(HttpServletRequest request) {

        HttpSession session = request.getSession(false);

        if (session != null) {
            session.invalidate(); // 세션 삭제
        }

        return "redirect:/";
    }
  • request.getSession(false): false를 인수로 넘기며 해당 메서드 호출 시, 새로운 session을 생성하지 않는다.
    • 기존의 세션이 없는 경우 null을 반환한다.
    • 따라서, null이 아닌경우 session.invalidate()를 호출한다.

tip) 세션은 invalidate()가 호출되는 경우에만 삭제된다. 세션은 톰켓의 메모리에 저장되므로 적절하게 삭제되지 않으면 서버에 큰 부하가 갈 수 있다. 보통 대부분 유저는 로그아웃을 하지 않고 웹 사이트를 종료한다. 따라서 세션 사용시 적절하게 유효기간을 설정해야 한다.

🚨 문제점: 스케일 문제

세션은 쿠키가 가지는 보안의 문제점에 대한 훌륭한 해결책이다. 그러나 유저의 식별 정보를 서버에 저장한다는 아이디어는 새로운 문제점을 야기한다. 세션을 통한 로그인 방식은, 로그인한 모든 유저들의 세션 ID를 서버에 저장하고, 요청이 들어올때마다 쿠키의 세션 ID와 일치하는 유저를 조회해야한다. 즉 사용자가 증가할수록 서버에서 요구되는 리소스 부담 역시 증가함을 의미한다. 이 스케일 문제를 해결하기 위해 우리는 Token을 이용할 수 있다.

4. 효율적으로 인증하기 Token

핵심 정보의 보관처가 사용자 일때에는 보안의 문제점이, 서버일때는 리소스의 부담이 있었다. 이 문제를 해결하기 위해 Token(JWT)을 사용할 수 있는데, 이는 각 통신 주체들 사이에 오가는 데이터(요청과 응답)에 핵심 정보를 담는 것이다. 단, 이 Token은 해당 정보를 암호화하며, 세션의 유효기간이라는 장점도 함께 가져간다.

📁 JWT란?

  • 암호화된 긴 String. 서버에서 받아 요청 마다 전송한다.
  • 사용자 ID & password 일치 시, 유저의 ID를 가져가 알고리즘을 이용해 사인한다. 그리고 이 사인된 정보를 String 형태로 사용자에게 전송한다.
    • 세션과 달리 db에 뭔가를 생성하지 않는다. 따라서, 서버가 유저 인증의 부담을 가질 필요 없다.
  • 쿠키와 달리 공간 제약이 없어 길이가 길 수 있다.
  • 사용자가 전송한 JWT의 사인이 유효한지 체크(토큰 조작이 있는지)하고 사용자로 인증한다.
    • 유저 입증 정보가 토큰에 있으므로 서버는 토큰의 유효성만 검증하면 된다.(디비를 걸칠 필요가 없다.)

🔍 JWT 자세히 알아보기

  • 헤더 (Header)
    • typ: 토큰의 타입(JWT)
    • alg: 서명의 해싱 알고리즘(HMAC, SHA256,...)
  • 내용 (payload)
    • 시스템에 사용될 정보(클레임 Claim: name/value 한쌍)
    • 하나의 토큰에 여러개의 클레임을 넣을 수 있다.
    • sub: Sbuject, 토큰의 주인 식별자.
    • iat: issued at, 토큰 발행 날짜와 시간을 의미
    • exp: expiration, 토큰이 만료되는 시간.
  • 서명 (signature)
    • 토큰 발행 주체가 발행한 서명으로 토큰의 유효성 검사에 사용된다.
    • 헤더의 인코딩값과, 정보의 인코딩값을 합친 후 비밀키로 해쉬하여 생성한다.
    • header, payload, secretkey를 조합하고 암호화하여 토큰을 생성하므로, 토큰(payload 등) 조작 시 이를 인지할 수 있다.

장점

  • 오가는 통신이 데이터 보관 주체이므로 서버의 리소스 부담을 경감할 수 있다.
    • 세션(Session)의 스케일 문제 해결
  • 브라우저에 저장되고 통신에 오가는 정보는 암호화된 값이므로 보안상의 장점도 있다.
    • 쿠키(Cookie) 로그인의 보안 문제 해결

🚨 문제점: 사용자 편의성 & 탈취

토큰은 정보 보관주체가 서버가 아니므로 악의적으로 토큰을 탈취하여 사용자를 사칭하더라도 서버가 대응할 수 없다는 단점이 있다. (토큰 자체가 권한을 입증하니까) 이 문제를 해결하기 위해 토큰 유효기간을 짧게 설정해 빈번히 갱신하도록 할 수 있다. 그러나 이는 사용자 편의성 저하와 직결된다.(토큰 유효기간이 1분일때, 이는 1분마다 재로그인을 의미하고, 그 누구도 1분마다 로그인을 해야하는 사이트를 이용하지 않을 것이다..) 이 문제를 해결하기 위해 Access Token과 Refresh Token을 함께 사용할 수 있다.

🔍 JWT의 종류: Access Token, Refresh Token.

Access Token과 Refresh Token 모두 동일한 JWT이다. 다만 토큰의 저장 위치유효기간에 차이를 둔다.

  • Access Token: 클라이언트의 요청과 응답에 사용되는 실제 유저 정보가 담긴 토큰. 유효기간을 5분 정도로 짧게 설정한다.
  • Refresh Token: Access Token 갱신을 위해 존재하는 토큰. 유효기간을 2주 정도로 길게 설정한다. 데이터베이스에 유저 정보와 함께 기록한다.

Access Token과 Refresh Token을 함께 사용할 경우 시나리오는 다음과 같다.

  1. 만료된 Access Token을 서버에 보내면, 서버에서 에러를 반환한다.
  2. 클라이언트에서 Refresh Token을 전송한다.
  3. 서버는 Refresh Token을 DB와 일치 여부를 확인하고 Access Token을 재발급 한다.

🔍 Refresh Token에 대한 고찰

🤔 ..? 어차피 refresh token이 탈취되면 계속 재발급이 가능하니 access token은 의미가 없는거 아닌가? 그럼 그냥 access token 유효기간을 2주로 정하는거랑 뭐가 달라? 그리고 DB에 데이터를 저장할거면 이게 session이랑 다를게 뭐지? 그럼 세션 사용하면 되지 뭐하러 토큰을 사용해?

처음 refrsh 토큰을 알게되면서 위와 같이 필요성에 대한 의문을 가졌었다. 그래서 프로젝트를 진행하면서 하나의 토큰만 사용했었다.(access token만 사용했다고 생각할 수 있겠다.) 그리고 프로젝트를 진행하면서 refresh 토큰 도입의 필요성을 느꼈다.

문제점

  1. 사용자 편의성
    가장 처음 토큰에 설정한 유효기간은 1시간이었다. 이정도면 적절하다는 판단이었으나 프로젝트를 진행하면서 끊임없이 반복되는 로그인 지옥이었다. 그래서 유효기간을 2시간, 8시간, 12시간 늘려가면서 느꼈다. 나는 하루에 한 번 로그인 하는것도 번거로웠다. 실제로 서비스되고 있는 웹 사이트라면 사용자들은 이처럼 로그인을 자주 요구하는 웹 사이트를 사용하지 않을 것이다.

  2. 탈취 위험성
    그래서 이번에는 유효기간을 2주로 늘렸다. 반복되지 않는 로그인에 스트레스를 덜었으나, 로그인 기능을 구현해 내가면서 이런식으로 구현하면 큰일나겠다는 생각이 들었다. 프로젝트를 수행하면서 토큰은 뭐든지 할 수 있는 만능키였다. 웹 브라우저에서 토큰만 복사하면 나는 뭐든지 다 할 수 있었다.(실제로 만들어둔 토큰을 활용하면서 프로젝트를 구현해나갔다.) 토큰을 탈취당한다는 것은 악의적 공격자에게 2주간 뭐든지 사용 가능한 프리패스를 발급해주는 것이나 마찬가지였다.

왜 refresh token을 사용해야 할까?

  1. 훌륭한 절충안
    refresh token 사용 시, 사용자는 2주간 재로그인을 하지 않아도 된다.(사용자 편의성) 또한 access token은 계속 갱신되므로 특정 시점에 access 토큰이 탈취되더라도 갱신으로 인한 보안상의 장점도 가져갈 수 있다.

  2. 서버 측 대응 가능
    refresh token은 db에 payload에 담길 정보를 저장해둔다. 따라서 탈취가 의심되는 경우 db에 사용자와 매핑되어있는 정보를 삭제함으로써 서버가 악의적 사용에 조치를 취할 수 있다. JWT 토큰은 그 자체로 신분증 역할을 하고 서버측에서 관련된 정보를 갖고있지 않으므로 한번 발행된 토큰은 유효기간 만료 이전까지는 계속 사용 가능하다. 그러나 session처럼 db에 정보를 가지고 있으면 이미 발행한 토큰을 무효화시키는 것이 가능해진다.

더 나아가, RTR(Refresh Token Rotation)

🤔 그래봐야 refresh token이 탈취되면 계속 사용 가능한거 아닌가?

이를 예방하고 싶은 경우, Refresh Token Rotation을 사용할 수 있다. access token 갱신을 위해 refresh token을 확인할때, access token뿐 아니라 refresh token도 갱신하는 것이다.

만약 특정 시점에 access token(3분), refresh token(2주)을 모두 탈취당한 상황이라면, 해당 공격자에게는 2주가 아니라 6분만 사용 가능한 token 정보들이 된다. 여전히 부족한 점이 있지만, 보안상으로 크게 강화되는 방법이 아닐까 생각된다.

📖 JWT 기반 로그인 구현 (Spring Security없이)

부족한 수준에서 혼자 고민해가며 구현한 코드라 엉망이고 부족한 부분이 매우 많습니다.

📜 시나리오 : refresh 토큰 생성 & 삭제

  • refresh 테이블에 회원 id와 uuid를 저장한다.
    • uuid는 refresh token의 payload로 담긴다. 이후 토큰의 uuid를 추출하여 db와 비교해 토큰의 유효성을 검사한다.
  1. 회원가입 시, refresh 테이블에 member 공간을 생성한다.
  2. 로그인 시, refresh 테이블에 uuid를 저장(& 갱신)한다.
  3. 로그아웃 시, refresh 테이블의 uuid를 삭제한다.

💻 회원가입 로직

    @PostMapping("/signup")
    public ResponseEntity<APIResult> signUp(@RequestBody MemberDTO memberDTO) {

        // 회원 정보 저장
        UUID uuid = UUID.randomUUID();
        memberService.saveMember(memberDTO);
        refreshService.createRefresh(memberDTO.getId());

        APIResult apiResult = new APIResult();
        apiResult.setState(State.SUCCESS);
        return ResponseEntity.ok(apiResult);
    }

💻 로그인 로직

로그인 시,

  • refresh token을 위해 uuid를 생성하고 db에 저장한다.
  • 생성한 access 토큰과 refresh 토큰은 클라이언트 측으로 전송한다.
    @PostMapping("/login")
    public ResponseEntity<APIResult> login(@RequestBody MemberDTO memberDTO) {

        String accessToken = jwtUtil.createAccessToken(memberDTO.getId());
        String refreshToken = refreshService.updateToken(memberDTO.getId());

        APIResult apiResult = new APIResult();
        apiResult.putResult("accessToken", accessToken);
        apiResult.putResult("refreshToken", refreshToken);
        apiResult.setState(State.SUCCESS);

        return ResponseEntity.ok(apiResult);
    }

updateToken()

public String updateToken(String memberId) {
        UUID uuid = UUID.randomUUID();
        refreshRepository.update(memberId, uuid); // db에 uuid 갱신
        return jwtUtil.createRefreshToken(uuid); // 생성한 토큰 반환
    }

💻 로그아웃 로직

    @PostMapping("/logout")
    public ResponseEntity logout(@RequestBody String memberId) {

        // 회원 id로 저장된 refreshToken을 삭제합니다.
        refreshService.deleteByMemberId(memberId);
        return ResponseEntity.ok().build();
    }

필터, 인터셉터를 이용한 식별자 검증


Reference

profile
Good Luck!

0개의 댓글