Spring Security, JWT, Redis (1)

Ajisai·2024년 2월 17일
0

Spring Security

목록 보기
2/7

참고한 내용

https://github.com/CptBluebear/KCS-S1-Dev-UR1PICK/tree/main/Kafe-Auth/src/main/java/org/corodiak/kcss1devt1auth

친구의 깃허브다.
똑똑한 청년.

하지만 그럼에도 불구하고 너무 어려워서 Security를 제대로 활용하지 못했다.

JWT가 뭐냐면

https://velog.io/@kirisame/JWT

요약

  1. JSON 구조(중괄호로 둘러싸인 key-value의 집합)다.
  2. 머리 가슴 배...가 아니라 header, payload, signature의 세 부분으로 구성된다.
  3. 각 부분은 서로 다른 성격의 내용을 포함하며, 모두 Base64로 인코딩 된다.
  4. 사용자 ID 등 필요한 정보는 payload에 포함된다.
  5. signature는 header와 payload의 내용을 해싱하고 인코딩한 것이다.
    즉 다음과 같다.
Base64(
  ENCRYPT(
    hash(`{base64(HEADER)}.{base64(PAYLOAD)}`, SECRET)
  )
)

Spring에서 JWT를 쓰려면

implementation 'io.jsonwebtoken:jjwt:0.12.3'

위와 같은 jjwt(Java JWT) 의존성 추가가 필요하다(Gradle 기준).
사실 없어도 되긴 하는데, 그럼 일일이 인코딩하고 해싱을 해줘야 한다.
직접 했다가 실수하느니 안정적인 라이브러리를 사용하자.

이 글은 0.12.3 버전을 기준으로 한다.
jjwt에 대한 내용은 공식 문서를 참고 바란다. 설명이 꽤나 잘 되어 있어 다른 문서에 비해 읽을 만 할 것이다.

2024.04.28 의존성 수정

implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-gson:0.12.3'

이름이 바뀐 모양이다.
참고로 runtimeOnly 'io.jsonwebtoken:jjwt-gson:0.12.3'은 문자열을 JWT 형태로 바꾸거나, 그 반대의 작업에 필요한 의존성이기 때문에 runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' 등으로 대체될 수 있다.

무엇이 필요한가?

정해진 건 없지만 일단 다음을 생각해볼 수 있다.

  1. 토큰을 표현하는 클래스
  2. 토큰 서비스
    1. 이걸 인터페이스와 구현 클래스로 나눌지는 선택
    2. ...이긴 하지만 나누는 게 소위 '국룰'이다(OOP의 원칙 중 하나인 OCP를 따르기 위해).

어떤 기능이 필요한가?

일단 어느 기능을 어디에 넣을지는 차치하고, 뭐가 필요한지만 생각해보면 다음과 같다.

  1. 토큰 생성
  2. 토큰 유효성 검사
  3. 토큰 만료 여부 검사
    • 유효성 검사는 말 그대로 토큰이 변조되거나 이상한 값이 포함되어있는지 검사하는 것이다.
    • 만료 여부 검사는 유효한 토큰에 대해 만료 여부를 검사하는 것이다.
    • 그런데 애초에 만료된 토큰으로 뭔가를 하려고 하면 ExpiredJwtException이 발생하므로 그냥 이 예외에 대한 처리를 해주면 된다.
  4. 토큰에서 특정 정보 가져오기
    • 토큰에 어떤 정보를 포함할 것인가?

토큰에는 어떤 정보가 포함되어야 하는가?

정확히 말하면 claim에 포함되어야 하는 내용.

  1. 사용자 식별자(일단 인증을 위한 거니까)
    • JWT는 Base64로 인코딩된 값이다. 즉 사실상 평문이다.
    • 게다가 프론트엔드와 백엔드 사이에서 왔다갔다 한다.
    • 따라서 패스워드 등의 민감한 정보는 포함되어선 안 된다.
    • 가장 편리한 건 데이터베이스에서 사용하는 사용자 sequence number가 아닐까 싶다.
  2. 발행 시점(iat)
  3. 만료 시점(exp)
    • 특히 토큰 만료 여부를 검사해야 하므로 꼭 필요한 항목이다.

이제 코드를 써보자.

토큰을 사용하기 위해 어떤 정보가 필요한가?

  1. secret 값
    • 이 값은 서명 생성 시 hashing에 쓰이는 salt 값이다.
  2. 만기 정보
    • 토큰의 만기를 얼마로 할 것인가?

이런 정보는 보통 프로퍼티 파일(application.yml, application.properties 등)에 저장해둔다.
민감한 정보는 소스코드에 포함하지 않는 것이 기본이다.

JwtConfig

@Configuration
public class JwtConfig {
	@Value("${jwt.secret-key}")
	private String secretKey;

	@Value("${jwt.access-token.lifetime}")
	private String accessTokenLifetimeSeconds;
}

필요한 값을 @Configuration으로 작성한다.
왜 이렇게 하는 이유는 스프링을 시작하면 미리 설정해둔 Java Bean(@Bean, @Component 등)이 생성되는데,
secret 등의 값은 이 Java Bean들 보다 먼저 설정되어야 하기 때문이다(꼭 그래야 한다! 라기 보다는 그게 안정적이니까).

AuthToken

import javax.crypto.SecretKey;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class AuthToken {
    // 실제 토큰 값
	private final String token;

    // 토큰으로부터 claim이 들어있는 객체(Claims 객체)를 얻어낸다.
	public Claims getClaims(SecretKey key) throws JwtException {
		return Jwts.parser()
			.verifyWith(key)
			.build()
			.parseSignedClaims(token)
			.getPayload();
	}
}

AuthTokenProvider

import java.time.Duration;

import org.springframework.security.core.Authentication;

import io.jsonwebtoken.ExpiredJwtException;

public interface AuthTokenProvider {

	String createToken(String userId, Role role);

	boolean validate(AuthToken token) throws ExpiredJwtException;
}

우선 인터페이스에 필요한 기능을 먼저 적어둔다.

AuthTokenProviderImpl

import java.nio.charset.StandardCharsets;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;

import org.springframework.context.annotation.PropertySource;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;

@PropertySource("classpath:application.yml") //기본 경로라면 없어도 된다.
public class AuthTokenProviderImpl implements AuthTokenProvider {
	private final SecretKey key;

	private final long accessTokenLifetimeSeconds;

	public AuthTokenProviderImpl(String secret, long accessTokenLifetime) {
		this.key = new SecretKeySpec(
			secret.getBytes(StandardCharsets.UTF_8),
			"HmacSha256"
		);

		this.accessTokenLifetimeSeconds = accessTokenLifetime;
	}

	@Override
	public String createToken(String userId, Role role) {
		Date currentDate = new Date();
		Date expiration = new Date(currentDate.getTime() + (accessTokenLifetimeSeconds * 1000L));

        //0.12 버전부터 바뀌었다.
		return Jwts.builder()
			.header()
				.add("typ", "JWT")
			.and()
			.claim("userId", userId)
			.claim("role", role)
			.issuedAt(currentDate)
			.expiration(expiration)
			.signWith(key, Jwts.SIG.HS256)
			.encodePayload(true)
			.compact();
	}

	@Override
	public boolean validate(AuthToken token) throws ExpiredJwtException {
		// token의 claim을 얻는 과정에서 예외 발생 ≡ token이 유효하지 않다
		Claims claims = token.getClaims(this.key);

		return claims != null;
	}
}

현재(2024.02.18) 기준으로 0.12.3이 최신 버전인데, 예전과 토큰 생성 메소드가 조금 다르다.
예전에는 setIssuedAt 등 Setter를 사용했는데, 최근에는 Builder 패턴으로 모두 업데이트되었다.
참고로 Setter는 deprecate되었다.

getClaims()도 토큰 서비스에 있어야 하는 거 아닌가요?

  • 개인적으로 claim은 그 토큰의 고유한 정보이므로 토큰 클래스에 있는 게 더 적절하다고 판단했다.

서비스래놓고 왜 이름은 provider인가요?

결국엔 뉘앙스 차이인데, Service는 토큰을 위한 로직을 수행하는 쪽에 가깝고, Provider는 토큰과 관련된 것들을 제공하는 쪽에 가깝다.

앞에서는 토큰 '서비스'라고 했지만 사실 토큰 자체를 생성(해서 제공)하고, 토큰에서 claim을 얻(어서 제공하)는 등의 작업을 수행하므로, provider가 더 적절하다고 판단했다.

와 다 했다!

하하 어림도 없지
아직 Spring Security를 곁들이지 않았다.
이 글에서 한 작업은 토큰 클래스를 정의하고, 그걸 사용하는 서비스를 작성한 것 뿐이다.

다음에 할 일

  • Spring security 곁들이기
    • OAuth가 아닌 ID, PW로 하는 일반적인 로그인을 상정한다.
    • 요청 시 헤더에서 토큰을 끄집어내고, 토큰을 검증하는 작업이 필요하다.
      • 즉 토큰 인증을 위한 Custom filter가 필요하다.
    • 토큰 인증 후에는 인증 정보가 Spring security에 등록되어야 한다.
      • 정확히는 Security context라는 곳에 저장되어야 한다.
      • Security context는 Bean으로 치면 Spring container같은 것.
      • 인증 정보는 org.springframework.security.core.Authentication 타입이다.
profile
고도로 발달한 공유는 메모와 구분할 수 없다

0개의 댓글