[Spring boot] [JWT] Spring security와 Redis를 기반으로 한 인증 구현(3) - JWT, Redis를 이용한 회원가입 기능 구현

김영후·2023년 4월 18일
0

SpringBoot-JWT Auth

목록 보기
3/4

이전 글에서는 Docker에 Redis이미지를 이용하여 컨테이너를 실행시키는 것에 대해 작성해보았다. 이번 글에서는 access token과 refresh token의 발행, Redis를 이용한 refresh token의 저장 및 이후 이 토큰들을 이용한 Spring security기반 인증 과정에 대해 작성해보려고 한다.

JWT

우리 서비스는 JWT를 이용한 인증절차를 이용한다. 우선 JWT가 무엇인지 알아보자.

JWT?
JWT(JSON Web Token)란 인증에 필요한 정보들을 암호화시킨 JSON 토큰을 의미한다. 그리고 JWT 기반 인증은 JWT 토큰(Access Token)을 HTTP 헤더에 실어 서버가 클라이언트를 식별하는 방식이다.
JWT는 JSON 데이터를 Base64 URL-safe Encode를 통해 인코딩하여 직렬화한 것이며, 토큰 내부에는 위변조 방지를 위해 개인키를 통한 전자서명도 들어있다. 따라서 사용자가 JWT를 서버로 전송하면 서버는 서명을 검증하는 과정을 거치게 되며 검증이 완료되면 요청한 응답을 돌려준다.

JWT의 개념은 이렇다. 개념과 더불어 JWT의 구조에 대해서도 알아보자.

  • 헤더(Header)
    헤더는 typ, alg의 두 가지 정보를 지닌다. typ은 토큰의 타입을 지정하는 것이고, 우리는 JWT를 이용하므로 'jwt'가 된다. alg는 해싱 알고리즘을 칭하며, 보통 HMAC SHA256 혹은 RSA가 사용된다.

  • 내용(payload)
    내용에는 토큰에 담을 정보가 들어있다. 여기에 담는 정보의 한 ‘조각’을 클레임(claim) 이라고 부르고, 이는 name / value 의 한 쌍으로 이뤄져있다. 토큰에는 여러개의 클레임들을 넣을 수 있다. 클레임에는 등록된 클레임, 공개 클레임, 비공개 클레임 세 가지 부류가 있다.
    1. 등록된 클레임
    등록된 클레임이란 서비스에서 필요한 정보들이 아닌, 토큰에 대한 정보들을 담기위하여 이름이 이미 정해진 클레임이다. 등록된 클레임의 사용은 모두 선택 적이며, 이에 포함된 클레임 이름들은 다음과 같다.
    - iss: 토큰 발급자 (issuer)
    - sub: 토큰 제목 (subject)
    - aud: 토큰 대상자 (audience)
    - exp: 토큰의 만료시간 (expiraton), 시간은 NumericDate 형식으로 되어있어야 하며 (예: 1480849147370) 언제나 현재 시간보다 이후로 설정되어있어야한다.
    - nbf: Not Before 를 의미하며, 토큰의 활성 날짜와 비슷한 개념이다. 여기에도 NumericDate 형식으로 날짜를 지정하며 이 날짜가 지나기 전까지는 토큰이 처리되지 않는다.(사용 불가)
    - iat: 토큰이 발급된 시간 (issued at), 이 값을 사용하여 토큰의 age 가 얼마나 되었는지 판단 할 수 있다.
    - jti: JWT의 고유 식별자로서 주로 중복적인 처리를 방지하기 위하여 사용된다. 일회용 토큰에 사용하면 유용하다.
    2. 공개 클레임
    공개 클레임은 충돌이 방지된 (collision-resistant) 이름을 가지고 있어야 한다. 충돌을 방지하기 위해서는, 클레임 이름을 URI 형식으로 짓는다.
    3. 비공개 클레임
    비공개 클레임은 등록된 클레임도아니고, 공개된 클레임들도 아니다. 클라이언트 <->서버 양 측간 협의하에 사용되는 클레임 이름이다. 공개 클레임과는 달리 이름이 중복되어 충돌이 될 수 있으니 사용할때에 유의해야 한다.

  • 서명(signature)
    JSON Web Token 의 마지막 부분은 바로 서명(signature)이다. 이 서명은 헤더의 인코딩값과 정보의 인코딩값을 합친 후 주어진 비밀키로 해쉬를 하여 생성한다.
    이렇게 만든 해쉬를 hex -> base64 인코딩하여 나타내면 된다.

이런 형식을 지닌 JWT 토큰은 https://jwt.io/ 에서 디코드해볼 수 있다. 우리서버가 생성한 JWT 토큰을 이 사이트에서 디코드해본 결과를 위의 내용을 참고해서 보도록 하자.

좌측이 우리서버가 생성한 access token이고 우측이 그를 decode한 값이다. Header에 해싱 알고리즘인 HS256이 보인다. payload에는 우리 서버의 유저 pk인 sub, 그 유저의 role인 roles와 iat, exp가 보인다. verify signature의 your-256-bit-secret에 우리 서버에서 지정한 secret-key를 입력해준 후 아래에

과 같이 표시되면 인증이 된 것이다. 만약 다른 secret-key를 입력하면

과 같이 표시된다. Header, payload는 쉽게 탈취 및 복호화가 가능하지만 signature는 로컬에 저장된 secret-key값이 노출되지 않는 한은 복호화가 불가능하다. JWT는 이러한 구조로 인증을 거치고, 정보를 전달한다. 이제 Spring boot에서 이 JWT를 이용하여 인증을 구현하는 방법에 대해서 알아보도록 하자.

Spring boot에서 JWT, Redis를 이용하기

spring boot는 JWT를 생성(암호화) 복호화와 관련된 라이브러리를 제공한다.

    //jwt
    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'

나의 경우는 위와 같은 의존성을 추가해줬다. 각자의 버전과 모듈 구조에 맞게 build.gradle에 의존성을 추가해주자. 이제 JWT관련 라이브러리를 이용해 토큰의 생성, 복호화 기능을 하는 코드를 구현해주면 된다. 그 전에 회원가입의 전체적인 flow를 다시 한 번 보도록 하자.

여기서 JWT가 생성되는 곳은 9번이다. 여기서 access token과 refresh token을 생성한다. 이를 controller, service 각 layer의 관점으로 도식화하면,

  • 회원가입 정보 수령(controller) -> 수령한 정보를 기반으로 토큰 생성(service) -> 생성한 토큰 클라이언트에 전달(controller)

가 된다. 이들을 코드 레벨에서 보자. 자세한 로직은 공개 못하지만, 코드에서 무엇무엇을 호출하는지, 그들의 역할이 무엇인지 정도를 작성하고자 한다.

Controller


Controller에서는 path variable로 소셜 플랫폼 종류인 provider(naver, kakao 등), request body로 회원가입에 필요한 정보를 받는다. 이를 기반으로 유저의 정보를 RDB(MySQL, H2 등)에 저장, access token, refresh token을 생성하고 refresh token을 Redis에 저장해둔다. 그 후 토큰들을 쿠키에 set하고, response body에 요청 결과와 그에 대한 메세지를 함께 실어 보내준다. 현재는 중복 가입(Http.CONFLICT, 409)을 제외한 에러 처리 로직은 구현하지 않은 단순한 기능만이 존재하는 점 참고 바란다. 이의 자세한 처리는 service layer에서 처리한다.

Service


Service layer에서는 요청 시 받은 유저의 정보를 이용하여 중복가입여부 확인, DB에 유저 저장 및 이 정보를 기반으로 토큰 생성을 수행한다. 조건문에서는 소셜 플랫폼에 따른 요청을 분기처리하여 그에 맞는 user의 DTO를 생성한다. 그 후 이 DTO를 기반으로 builder pattern을 이용, user의 entity를 생성하고 RDB에 저장한다. DTO의 정보를 기반으로 토큰 또한 생성된다. 토큰을 생성하는 함수는 아래 그림과 같다.

이 함수에서는 유저 DTO에 저장된 role, 유저의 pk(proviederUserId)를 이용하여 access token을 생성하고, refresh token을 무작위 string으로 생성한다.(refresh token은 access token 재발급 시 사용되는 것으로, 어떠한 정보도 담고 있지 않다. 이 토큰이 Redis에 저장되고 access token을 재발급하는 데에 사용되는 것이다.) 최종적으로 클라이언트에 반환할 DTO가 이 함수에서 만들어진다고 보면 된다.(반환할 DTO는 개인이 정의하는대로 사용하는 것이므로 따로 첨부하지 않겠다.) 그럼 이제 이들을 생성하는 함수, refresh token의 Redis에의 저장을 살펴보자.

토큰들의 생성, refresh token의 Redis에의 저장

토큰들의 생성은 generate{Access, Refresh}Token을 통해 이루어진다. 먼저 access token의 생성을 보자.

public String generateAccessToken(String providerUserId, List<String> roles) {
        Claims claims = Jwts.claims().setSubject(providerUserId);
        claims.put("roles", roles);
        Date now = new Date();
        String accessToken = Jwts.builder()
                .setClaims(claims) // 데이터
                .setIssuedAt(now) // 토큰 발행일자
                .setExpiration(new Date(now.getTime() +  ACCESS_TOKEN_VALIDITY_IN_MILLISECONDS)) // set Expire Time(30분)
                .signWith(SignatureAlgorithm.HS256, secretKey) // 암호화 알고리즘, secret값 세팅
                .compact();

        return accessToken;
    }

위의 JWT 디코딩 예제에서 볼 수 있듯 우리 서버 토큰의 payload에는 sub, roles, iat, exp만이 실려있다. 이 함수에서 보이는 setClaims, setIssuedAt(), setExpiration()메서드가 그를 담당한다고 보면 된다.
claims에 우선 우리 서버의 유저 pk, 그 유저의 권한을 set해준다. 그 후 그 claim을 setCloaims()를 이용하여 set해주고, setIssuedAt(), setExpiration()을 이용해 생성시점과 만료시점을 set해준다.
signWith 함수에 서버의 secretKey를 이용(application.yml에서 Valid 어노테이션을 통해 가져와 이 클래스의 필드 값으로 지정해뒀음)하여 서명을 한 뒤 그를 compact() 메서드를 통해 토큰화 하는 방식이다. payload에 set하고 싶은 claim들이 있다면 그에 맞게 함수를 추가해주면 된다. 다음은 refresh token의 생성이다.

public String generateRefreshToken() {
        Date now = new Date();
        Date validity = new Date(now.getTime() + REFRESH_TOKEN_VALIDITY_IN_MILLISECONDS);
        String refreshToken = Jwts.builder()   // Refresh token 생성
                .setIssuedAt(now)
                .setExpiration(validity)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        return refreshToken;
    }

refresh token은 access token과 달리 아무런 정보도 토큰에 넣지 않고, 단순히 iat와 exp만을 set해준 후 서명을 거친다. 이런 간단한 정보만을 가지고 있는 것이 refresh token의 특징이다. 이 token은 redisService라는 Service class의 함수를 통해 Redis에 저장된다. 우선 Redis를 사용하기 위한 의존성을 먼저 살펴보자.

// Redis
		implementation group: 'org.springframework.boot', name: 'spring-boot-starter-data-redis', version: '3.0.0'

위와 같은 의존성 추가를 해주면 Redis와 관련된 라이브러리의 이용이 가능해진다. 이후 Redis와 관련된 Config를 작성해야한다.

@Configuration
@EnableRedisRepositories
public class RedisConfig {

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

} 

이 Config에서는 Redis repository의 enable을 해주는 annotation, Redis의 host, port를 설정해주는 간단한 것만 작성하였다. Redis repository를 이용하기 위한 방법은 두 가지로 나뉘는 것 같다.(이와 관련된 것은 이 글을 참고하기 바란다.) 나의 경우 Redis와 spring boot의 연동을 위해 stringRedisTemplate을 이용했다. 이유는 지금으로써의 Redis의 사용은 key로 refresh token을, value로 user의 pk를 저장하는 용도이기 때문에 string값만 이용하기 때문이다. 이의 사용은 각자 정의한 service에서 Autowired annotation을 통해 가능하다. 나의 RedisService와 그 안에서 현재 사용하는 기능까지의 함수를 살펴보겠다.

@Service
@RequiredArgsConstructor
public class RedisService {

    @Autowired
    private final StringRedisTemplate stringRedisTemplate;    // String value을 redis에 저장하기 위한 template

    public void setStringValue(String token, String data, Long expirationTime) {
        ValueOperations<String, String> stringValueOperations = stringRedisTemplate.opsForValue();
        stringValueOperations.set(token, data, (int) (expirationTime / 1), TimeUnit.MILLISECONDS);
    }

}

위에서 언급했듯 나는 StringRedisTemplate을 사용할 것이고, 이의 사용은 ValueOperations<String, String> 타입의 opsForValue()를 동반한다. 이 객체를 생성 후 set(key, value, {ttl(expiration time)})함수에 parameter를 넣어 사용할 수 있다.(이 함수의 사용에서 ttl은 선택사항이다.) 이를 이용해 저장된 값은 Redis에서 확인이 가능하다. 아래는 저장된 리프레시 토큰의 예제이다.

  • keys *
    이 명령어를 통해 현재 저장된 모든 key들을 불러온다. 나는 한 명의 회원가입만 수행했으므로 key는 하나가 있다.
  • get {key}
    이 명령어를 통해 key의 value를 조회할 수 있다. 우리는 key(refresh token) : value(user pk) 형태로 데이터를 저장했으므로 value로 user의 pk가 조회되는 것을 확인할 수 있다.
  • ttl {key}
    이 명령어는 해당 key를 가진 데이터의 ttl(time-to-live, 유효시간)을 조회할 수 있다. 나는 2주를 set해줬는데, 대충 계산(/(24x60x60x1000))하면 14일이 나오는 것을 알 수 있을 것이다.

이렇게 저장된 refresh token을 이용해 access token을 갱신, 재로그인 없이 갱신만으로 자동 로그인을 하는 방식으로 사용할 수 있다. 이 마저도 만료됐다면, 사용자는 재로그인을 통해 access token, refresh token을 모두 재발급해서 사용해야한다.
이렇게 생성된 토큰들이 회원가입의 플로우 그림에서 보이는 11번 과정으로 클라이언트에 반환이 되는 것이다.

정리

관련해서 내가 헤맸던 부분도 있었고, 정리가 필요한 것 같아 이번 글은 꽤 길어졌던 것 같다. JWT의 생성, Redis의 이용에 관해 정리를 해봤는데, 길이 길어지며 흐름이 흐트러진 건 아닌가 걱정이 되지만 이로써 이번 글을 마루리하려고 한다. 다음 글에서는 생성된 토큰들의 인증과정에 대해서 알아보도록 하겠다.

참고
JSON Web Token 소개 및 구조
Spring boot Redis 두 가지 사용 방법
[JWT] 프로젝트에 JWT 적용하기

profile
PNU CSE 16th / Busan, South Korea

0개의 댓글