728x90

멋사 파이널 프로젝트에서 인증/인가를 담당했다.

이때 로그인시 인증에 대해서 JWT 로그인을 구현하였다.

 

JWT 토큰을 이용한 로그인에는 Access-Token과 Refresh-Token이 쓰이는데,

Access-Token은 인증을 위한 토큰으로 사용되고, Refresh-Token은 액세스 토큰을 재발급 하는데 사용된다.

 

[기존의 Refresh Token 사용 방식]

입력한 회원정보와 가입한 회원정보가 일치할 시, Login을 할때, Refresh-Token을 서버에서 만들어 쿠키에 저장하고 MySQL database에 저장후 액세스 토큰의 유효기간 만료로 인한 토큰 재발급 시, 리프레시 토큰값을 비교하여 일치 할 경우, 새로운 액세스 토큰과 리프레시 토큰을 발급하도록 구현했다.

 

하지만 이 방식의 아쉬운 점은, Refresh-Token을 발급한 이후, 별도의 로그아웃 API 호출이 없는 경우 깔끔하게 토큰이 DB에서 삭제되지 못한다는 점이다.

 

그래서 프로젝트 리팩토링을 진행하면서, MySQL에 저장했던 Refresh-Token을 Redis로 바꾸었다.!!

 


왜 Redis 인가? Redis vs RDB

Redis 는 리스트, 배열 형식의 데이터 처리에 특화되어 있다. 리스트 형 데이터의 입력과 삭제가 MySQL보다 10배 정도 빠르다. 이런 Redis 를 RefreshToken 의 저장소로 사용할 경우, 빠른 접근 속도로 사용자가 로그인시(리프레시 토큰 발급시) 병목이 발생할 가능성을 낮출 수 있다.

 

또 Refresh Token 은 발급된 이후 일정시간 이후 만료가 되어야한다. 리프레시 토큰을 RDB 등에 저장하면, 스케줄러등을 사용해서 만료된 토큰을 주기적으로 DB 에서 제거해 줘야 한다.

그러나 Redis 는 기본적으로 데이터의 유효기간(time to live) 을 지정할 수 있다. 이런 특징들이 바로 Refresh Token 을 저장하기에 적합하다고 생각했다.

 

또한 Redis 는 휘발성이라는 특징으로 인해 데이터가 손실될수도 있는 위험이 있으나, Redis 에 저장될 리프레시 토큰은 손실되더라도 그리 손해가 큰 편은 아니다. 기껏해봤자 RefreshToken 이 없어져서 다시 로그인을 시도해야 하는 정도이다.

Trand-Off를 고려했을 때, 이는 큰 문제가 아니라 생각해서 Redis에 Refresh-Token을 저장하려는 결정을 내렸다.

 


Redis 사용 시 이점

1. 빠른 액세스와 만료 관리

  • Redis는 메모리 기반 데이터 저장소이므로 매우 빠른 읽기/쓰기를 제공
  • 이를 통해 refreshToken의 유효성을 빠르게 확인하고 필요 시 만료시킬 수 있다.
  • 짧은 응답 시간은 보안 이벤트(예: 토큰 탈취 시도)에 빠르게 대응할 수 있게 해준다.

 

2. 간편한 만료 및 삭제

  • Redis는 TTL(Time to Live) 기능을 제공하여 키에 유효기간을 설정할 수 있다.
  • 이를 통해 refreshToken의 자동 만료가 가능하며, 특정 조건에서 토큰을 신속히 무효화할 수 있다.
  • 이는 RDBMS보다 효율적이며 보안 사고 시 피해를 줄일 수 있다

 

3. 세션 무효화 용이성

  • 유저가 로그아웃하거나 비정상적인 활동이 감지될 경우 Redis에서 특정 refreshToken을 쉽게 삭제할 수 있다.
  • RDBMS에서는 복잡한 쿼리와 트랜잭션 처리가 필요할 수 있지만, Redis에서는 단일 명령으로 처리할 수 있어 더 빠르고 효과적

 

4. 스케일링의 용이성

  • Redis는 분산 캐시 시스템을 지원하며, 이를 통해 큰 규모의 사용자를 처리할 때에도 성능을 유지할 수 있다.
  • 이는 특히 대규모 시스템에서 보안 모니터링과 대응 속도를 유지하는 데 중요

 

5. 분리된 저장소

  • refreshToken을 RDBMS가 아닌 Redis에 저장함으로써 데이터베이스와 캐시 간에 책임을 분리할 수 있다.
  • 이는 데이터베이스에 대한 접근을 최소화하여 공격 벡터를 줄일 수 있다.
  • 예를 들어, 데이터베이스가 공격을 받더라도 캐시에 저장된 토큰 정보는 노출되지 않을 수 있다.
728x90
728x90

1. jwt 토큰의 소통, 인증, 발급 방식

 

a. 홍길동 유저가 로그인 시

로그인 시 ac, re 토큰 발급 과정

 

1.1 클라이언트는 적절한 폼 로그인 html을 작성한다.

1.2 클라이언트는 서버로 로그인 요청을 보낸다.

1.3 서버는 클라이언트로부터 받은 로그인 요청에서 로그인 유저의 정보가 담긴 Access, Refresh Token을 제작한다.

1.4 서버는 클라이언트로 로그인 성공 응답과 함께 ac, re 토큰을 반환한다.

1.5 이때 re 토큰은 클라이언트의 쿠키에 등록되어 보내진다. 

 

** 참고**

나는 ac 토큰도 쿠키에 담아서 보냈지만 실제로 ac 토큰은 헤더에 담아서 보내는 것이 일반적이다. 클라이언트 측 쿠키에 엑세스 토큰이 담겨있나 확인차 쿠키에 담은 것이고 실제로는 헤더에 담아서 보내야 한다.

 

 

- ac, re 토큰 발급 코드

Cookie accessTokenCookie = new Cookie("accessToken",accessToken);
 accessTokenCookie.setHttpOnly(true);
 accessTokenCookie.setPath("/");
accessTokenCookie.setMaxAge(Math.toIntExact(jwtUtil.ACCESS_TOKEN_EXPIRE_COUNT/1000)); //30분 

Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
refreshTokenCookie.setHttpOnly(true);
refreshTokenCookie.setPath("/");
refreshTokenCookie.setMaxAge(Math.toIntExact(jwtUtil.REFRESH_TOKEN_EXPIRE_COUNT/1000)); //7일

response.addCookie(accessTokenCookie);
response.addCookie(refreshTokenCookie);
  • ac, re 토큰을 둘다 쿠키에 저장했다.
  • 엑세스, 리프레시 토큰은 쉽게 탈취당하지 않도록 http only 속성으로 쿠키에 저장을 해주었기 때문에, js 브라우저에서나 타인이 쉽게 볼 수 없고 탈취가 어렵다.
HTTP Only란?
- document.cookie와 같은 자바스크립트로 쿠키를 조회하는 것을 막는 옵션
- 브라우저에서 HTTP Only가 설정된 쿠키를 조회할 수 없다.
- 서버로 HTTP Request 요청을 보낼때만 쿠키를 전송한다.
- 이를 통해 XSS(Cross Site Scripting) 공격을 차단할 수 있다.

 

 

b. 로그인 후 요청

상황 1 : 로그인 후 요청 보낼 때 (엑세스 토큰이 유효한 경우)

1.1 ac 토큰이 유효하다면 인증/ 인가가 성공하여 응답을 반환한다.

1.2 요청했던 작업을 정상 수행한다.

 

 

상황 2 : 로그인 후 요청 보낼 때 (엑세스 토큰이 유효하지 않은 경우)

1.1 ac 토큰 에러 응답을 보낸다

1.2 이때 아래의 과정을 통해 엑세스 토큰 재발급 api로 이동해서 새로운 엑세스 토큰을 발급받아야 한다.

 

 

상황 2 -1 : 엑세스 토큰을 리프레시 토큰을 통해 재발급 과정

 

1-1. 서버는 클라이언트가 액세스 재발급 해주세요 라는 api를 발동시키게 되면, 쿠키에 저장돼있는 refreshToken을 잡아와서 이 refreshToken이 유효한 지 검사한다.
1-2 유효하다면 액세스를 재발급해준다.
1-3 액세스 재발급 api는 Authorization에 Bearer 방식으로 리프레쉬 토큰을 담아서 요청을 보내면 서버에서는 리프레쉬토큰 기간 만료 여부, 유효 여부를 따진다.
1-4 refreshToken기간이 만료됐다면, refreshToken 재발급이 필요하다는 에러를 throw 해야 한다.
1-5 이때 refreshToken이 유효하지 않아서 재발급 해야한다는 말은 사용자를 강제로그아웃 시킨 뒤, 재로그인하도록 만들어야 한다는 것과 똑같다.

 

 


어떻게 활용?

 

그럼 위의 로직을 바탕으로 프로젝트를 구성한다면 CSR 방식과 SSR 방식이 있을 것이다.

일단,
1. CSR 방식

이 방식은 클라이언트와 서버가 분리된 환경에서 api 통신으로 엑세스 토큰은 로컬스토리지, 헤더에 담고 리프레시 토큰은 쿠키에 담아서 보내면 된다.

2. SSR 방식

이 방식에서는 보통 jwt 방식을 잘 사용하지 않는다고 한다. 그래도 구현을 할 수는 있는데, 엑세스 토큰과 리프레시 토큰을 클라이언트 쿠키에 담아주는 방식이다.

근데 일단 이 방식에서는 폼에서 탬플릿엔진(jsp, 타임리프 등)을 사용해서 보여주기 때문에 따로 자바스크립트 코드를 사용해서 쿠키에 담는 방식을 이용해야 한다. 

또한 jwt가 stateless 한 특징을 갖고 있는데 SSR 방식에서는 stateless 한 특성이 잘 보이지 않는다. 

그래서 SSR 방식에서 jwt 를 사용한다면 왜 사용했는지, 그 이유를 명확히 말할 수 있어야 한다.

 


일단 나는 SSR 방식에서 jwt 토큰을 발급했기 때문에.. 코드는 아래와 같다.

 

 

 

2. Login form

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>로그인</title>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script src="/static/js/auth.js"></script> // 리프레시 토큰으로 엑세스 토큰 재발급
</head>
<body>
<form id="loginForm" action="/api/login" method="post">
    <input type="text" id="username" name="username" placeholder="아이디" required />
    <input type="password" id="password" name="password" placeholder="비밀번호" required />
    <button type="button" onclick="loginUser()">로그인</button>
</form>

<script>
    function loginUser() {
        var username = document.getElementById('username').value;
        var password = document.getElementById('password').value;

        var data = {
            username: username,
            password: password
        };

        axios.post('/api/login', data)
            .then(response => {
                // 로그인 성공 시 토큰을 쿠키에 저장
                var accessToken = response.data.accessToken;
                var refreshToken = response.data.refreshToken;

                document.cookie = "accessToken=" + accessToken + "; HttpOnly; path=/";
                document.cookie = "refreshToken=" + refreshToken + "; HttpOnly; path=/";

                // 성공 시 다음 페이지로 이동 혹은 필요한 작업 수행
                window.location.href = '/api/users/home';
            })
            .catch(error => {
                console.error('로그인 에러:', error);
                // 실패 시 에러 처리 혹은 사용자에게 알림
                alert('로그인 실패. 다시 시도해주세요.');
            });
    }
</script>
</body>
</html>
  • 나는 SSR 방식으로 프로젝트를 진행 중이기 때문에 폼에서 axios, fetch 등으로 쿠키를 클라이언트에 보내주는 작업을 했어야 한다.
  • 그래서 위의 폼에서의 자바스크립트 코드를 보면 엑세스, 리프레시 토큰을 앤드포인트에서 받아와 클라이언트 쿠키에 넣어주는 역할을 하고 있다.
  • 참고로 위의 코드에서는 단순히 로그인 시에 엑세스와 리프레시 토큰을 받아오는 코드만 있고 엑세스 토큰이 만료되면 리프레시 토큰으로 재발하는 과정은 없다.
  • 아래에 있는 코드가 재발급 과정에 맞는 코드이다. 
  • 아래 코드는 모든 요청마다 쿠키를 검증하는데, 이때 쿠키에 엑세스 토큰의 요청이 만료되면 리프레시 토큰의 유무를 판단하여 엑세스 토큰을 재발급하던지, 로그아웃을 하는 방향으로 진행된다.
// Axios 인터셉터를 설정하여 요청 전에 Access Token의 유효성을 검사하고 만료 시 갱신
axios.interceptors.request.use(
    async config => {
        // 쿠키에서 accessToken을 읽어온다.
        var token = getCookie('accessToken');
        if (token) {
            config.headers['Authorization'] = 'Bearer ' + token;
        }

        try {
            // Access Token이 만료되었는지 확인하는 로직 추가
            var exp = parseJwt(token).exp;
            var now = Date.now() / 1000;

            if (exp < now) {
                // Access Token이 만료되었으므로 Refresh Token을 사용해 갱신
                var refreshToken = getCookie('refreshToken');
                if (refreshToken) {
                    const response = await axios.post('/api/refreshToken', null, {
                        headers: {
                            'Authorization': 'Bearer ' + refreshToken
                        }
                    });
                    var newAccessToken = response.data.accessToken;

                    // 새로 발급받은 Access Token을 요청 헤더에 포함
                    config.headers['Authorization'] = 'Bearer ' + newAccessToken;
                } else {
                    throw new Error('리프레시 토큰이 존재하지 않습니다.');
                }
            }
        } catch (error) {
            console.error('토큰 갱신 오류:', error);
        }
        return config;
    },
    error => {
        return Promise.reject(error);
    }
);

// 쿠키에서 값 읽기
function getCookie(name) {
    let cookieArr = document.cookie.split(";");
    for (let i = 0; i < cookieArr.length; i++) {
        let cookiePair = cookieArr[i].split("=");
        if (name == cookiePair[0].trim()) {
            return decodeURIComponent(cookiePair[1]);
        }
    }
    return null;
}

// JWT 파싱
function parseJwt(token) {
    try {
        return JSON.parse(atob(token.split('.')[1]));
    } catch (e) {
        return null;
    }
}

 

 

 

나처럼 백엔드와 프론트엔드가 분리된 환경이 아니라면 jwt 토큰을 사용하는 것에 있어서 깊은 생각을 해봐야 할 것이다. !

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

728x90
728x90

 

개요

개인 프로젝트를 진행하는 중 시큐리티 부분은 아주 중요하고 헷갈리기 때문에 정리해놓기 위해 포스팅 하기로 했다.

gradle 의존성을 추가 한 후 -> 프로젝트 파일 구조에 맞게 패키지와 관련 클래스를 작성한 후 -> 프로젝트에 필요한 환경변수를 세팅하면 기본적인 작업은 끝난다. 

 

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6 

jwt 0.11.5

 

 

프로젝트 파일 구조

 

<- 다음과 같은 구조로 프로젝트를 진행하는데
이 중 시큐리티+jwt를 사용하기에 필요한 패키지는 

"config, controller, domain, dto, repository, security, service" 이다.

이 패키지 중 만들 클래스들은 연두색으로 표시한 부분만 만들면 된다.

 

 

 

 

의존성 설치

// security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'

// lombok
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

// 타임리프에서 스프링시큐리티를 쓸수있게.
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.2.RELEASE'
implementation 'org.jsoup:jsoup:1.14.3'

// jwt & json
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'

// gson - json 메시지를 다루기 위한 라이브러리
implementation 'com.google.code.gson:gson'

// validation사용
implementation 'jakarta.validation:jakarta.validation-api:3.0.2'

 

필요한 환경변수 세팅

 

jwt secretKey와 refreshKey에 대해서 .yml 파일에 작성해준다. 

시크릿 키를 얻는 방법은 아래에 적어놨다. 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

PowerShell의 Get-Random cmdlet을 사용하여 64바이트의 랜덤 데이터를 생성하고 이를 16진수로 변환하여 시크릿 키를 얻는다.

# 64바이트의 랜덤 데이터 생성
$bytes = @(0..63 | ForEach-Object { Get-Random -Maximum 256 })

# 16진수로 변환
$hex = $bytes.ForEach({ "{0:x2}" -f $_ }) -join ""

# 결과 출력
$hex

 

참고로 맥에서는 다음과 같이 하면 시크릿 키를 얻을 수 있다.

openssl rand -hex 64

 

 

 

2편에서.. Security 설정에 대해 알아볼 것이다.

728x90

+ Recent posts