종우의 삶 (전체 공개)

[지난 프로젝트 refactoring] - PetBack #1 - Security 본문

개발/Spring

[지난 프로젝트 refactoring] - PetBack #1 - Security

jonggae 2024. 3. 25. 16:50

작년 9월 경에 진행했던 팀 프로젝트에 대한 리팩토링을 진행한다.

https://github.com/PetCommunityWeb

Version :  Java 17,  Spring boot 3.1.2

 

 

PetCommunityWeb

PetCommunityWeb has 2 repositories available. Follow their code on GitHub.

github.com

 

게시판 CRUD 말고는 내가 만들지 않은 부분이 많기 때문에 우선 프로젝트를 천천히 살펴보고 기능을 파악해야겠다.

 

DB가 초기화 되었기 때문에  Mysql -  root 계정으로 DB를 연결하여 다시 실행시키는 것에 성공하였다. 

 

지난 프로젝트에서는 상관이 없었는데 이 프로젝트에서는 MySql의 유저 비밀번호를 

영어 대소문자, 숫자, 특수문자 까지 전부 활용해야 했다. 어쨌든 제대로 돌아가는 것을 확인하였다.


market 프로젝트에서와의 다른점은 로그인 시 AccessTokenRefreshToken을 사용한다는 것인데, 이것부터 찬찬히 살펴보자.

 

Json Web Token (JWT)

-> JWT는 토큰 자체의 형식을 말하며 액세스 토큰과 리프레시 토큰의 내용을 담는 데 사용될 수 있다. 토큰의 포맷 같은 것.

 

액세스 토큰 (AccessToken)

-> 사용자가 인증 후, 서버의 리소스에 접근할 때 사용하는 임시 토큰. 짧은 유효기간 (만료 후에는 유효하지 않음)

-> eg) 사용자가 로그인을 하면 서버는 액세스 토큰을 발급하고, 사용자는 이 토큰을 사용하여 인증이 필요한 API 호출

 

리프레시 토큰 (RefreshToken)

-> 액세스 토큰이 만료되었을 때 새로운 액세스 토큰을 발급 받기 위해 사용하는 토큰. 액세스 토큰보다 더 긴 유효기간.

사용자가 로그인을 반복하지 않아도 서비스를 지속적으로 이용할 수 있게 한다.

-> 액세스 토큰이 만료되면, 클라이언트는 리프레시 토큰을 서버에 전송하여 새로운 액세스 토큰을 요청한다.


프로세스를 좀 더 정확히 정리해보자.

참고)

https://auth0.com/blog/refresh-tokens-what-are-they-and-when-to-use-them/ 

google에서 찾은 블로그들 (감사합니다)

 

기본적으로 JWT를 사용한다면 JWT의 유효기간 내에 탈취를 당할 보안상의 위험이 있다. 유효기간을 짧게 설정하면 되겠지만 그렇게 되면 사용자는 5분마다 로그인을 해야하는 불편함이 있을 것이다.

그래서 두개의 다른 토큰을 사용 한다.

 

액세스 토큰은 짧은 유효기간, 리프레시 토큰은 긴 유효기간을 갖고있다.

서버는 사용자의 인증 정보를 계속 확인 할 필요 없이, 액세스 토큰이 만료되면 새로운 액세스 토큰을 발급 받기 위해 리프레시 토큰을 이용한다. -> 로그인을 반복 할 필요가 없다는 뜻.

 

하지만 이러한 과정에서도 보안은 중요하고, 두 토큰또한 탈취될 가능성이 있기 때문에 반드시 HTTPS 를 사용해야 한다.

 

결론적으로 두 토큰의 사용은 사용자 인증을 유연하게 만든다. 로그인 상태를 유지하면서도 보안을 강화할 수 있다. 

 

두 토큰의 유효기간은 서버에서 알아서 정하면 되겠다.


역시 알고보니 그리 어려운 개념은 아니었다.

JWT가 사용자 인증에 대한 결과물로서 애플리케이션 내에서 사용되는 것처럼, 액세스 토큰과 리프레시 토큰도 같은 역할을 하는 것이었다. 

 

기존에 내가 사용했던 JWT가 곧 액세스 토큰이었고 액세스 토큰이 있어야 사용자 인증이 필요한 접근이 가능하다.
이 액세스 토큰은 유효기간을 짧게 설정하여 탈취 가능성을 낮춘다. 이러한 액세스 토큰을 다시 발급받으려면 리프레시 토큰을 이용하면 된다. 검증 로직은 서버별로 설정이 되어있다.
리프레시 토큰 자체에 사용자의 인증 상태를 증명하는 정보가 있으므로 최초 로그인 시 생성된 리프레시 토큰만 유효하다면 계속 액세스 토큰을 만들어 접근할 수 있다.

 

 

본 프로젝트의 내용을 살펴보자.

JwtUtil.java

 

@Slf4j(topic = "JwtUtil")
@Component
@Getter
public class JwtUtil {
    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String REFRESH_HEADER = "refreshToken";
    public static final String AUTHORIZATION_KEY = "auth";
    public static final String BEARER_PREFIX = "Bearer ";
    private final long TOKEN_TIME = 5 * 60 * 1000L; // 5분
    private final long REFRESH_TOKEN_TIME = 60 * 60 * 1000L * 24 * 7; // 일주일

    @Value("${jwt.secret.key}")
    private String secretKey;
    private Key key;
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    @PostConstruct
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secretKey);
        key = Keys.hmacShaKeyFor(bytes);
    }

    // 토큰 생성
    public String createToken(String username, UserRoleEnum role, Long userId) {
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(username) // 사용자 식별자값(ID)
                        .claim(AUTHORIZATION_KEY, role) // 사용자 권한
                        .claim("userId", userId)
                        .setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
                        .setIssuedAt(date) // 발급일
                        .signWith(key, signatureAlgorithm) // 암호화 알고리즘
                        .compact();
    }
    
    // refreshToken 발급
    public String createRefreshToken() {
        Date date = new Date();
        return Jwts.builder()
                .setIssuedAt(date)
                .setExpiration(new Date(date.getTime() + REFRESH_TOKEN_TIME))
                .signWith(key, signatureAlgorithm)
                .compact();
    }

    // header에서 accessToken 가져오기
    public String resolveToken(HttpServletRequest request) {
        String bearerToken= request.getHeader(AUTHORIZATION_HEADER);
        if(StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)){
            return bearerToken.substring(7);
        }
        return request.getHeader(AUTHORIZATION_HEADER);
    }

    // header에서 refreshToken 가져오기
    public String resolveRefreshToken(HttpServletRequest request) {
        String token = request.getHeader(REFRESH_HEADER);
        if(StringUtils.hasText(token)){
            return token;
        }
        return null;
    }

    // 토큰 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException e) {
            log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
        } catch (UnsupportedJwtException e) {
            log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
        } catch (IllegalArgumentException e) {
            log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
        }
        return false;
    }

    public Claims getUserInfoFromToken(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();

    }
}

 

 

 

최초 로그인 시 이렇게 두개의 토큰이 생성된다.

 

 

로컬 스토리지에 저장된 accessToken의 모습. 유효 기간이 지나면 새롭게 발급될 것이다.

뒷부분이라 짤렸지만 5분 후 다른 엔드포인트로 접근 시 새로운 액세스 토큰이 발급되었다.

 

최초의 리프레시 토큰은 Repository를 이용하여 DB에 저장이 되었다.

리프레시 토큰은 Redis에 따로 저장이 된 것을 확인하였다.

 

@Getter
@RedisHash(value = "refreshToken", timeToLive = 604800) // 7일, 토큰의 유효기간과 동일하게
public class RefreshToken {
    @Id
    private String refreshToken;
    private Long userId;

    public RefreshToken(final String refreshToken, final Long userId) {
        this.refreshToken = refreshToken;
        this.userId = userId;
    }

}

 

window powershell에서 redis-cli를 이용하면 확인할 수 있었다.

 


 

내가 기존에 만들었던 market 프로젝트와 다른점이 있어 잘 파악을 해야 한다.

리팩토링할 거리는 그렇게 많이 없는 것 같다. 현재 Security 관련하여 살펴보았으므로...

 

TokenProvider나 LoginProvider를 사용한 market 프로젝트와 달라서 찾기가 쉽지 않은데, 이것을 고쳐보면 어떨까?

 

2편에 계속

Comments