본문 바로가기
Programming/Go

[GO 인증 구현 with JWT 2] 토큰 구현 및 테스트 코드 작성

by TinKerBellBass 2022. 4. 11.
728x90
반응형

이전 글

 

[GO 인증 구현 with JWT 1] 전체적인 인터페이스 작성

Use Case 간단히 구현해 볼 인증의 시나리오 유저 생성 → 생성된 유저 정보로 토큰 발급 → 발급된 토큰에 들어있는 유저ID 를 이용해서 유저 정보 획득 유저 생성 Use Case User ID, Password 를 입력받아

tinkerbellbass.tistory.com


이번에는 토큰, 인증 미들웨어를 구현해 보자.
능력 부족으로 완벽한 TDD 를 하는 것은 힘들어서,
개인적으로는 테스트 케이스 정도 정리하면서 구현할 로직을 떠올리고,
로직을 구현하고, 마지막으로 테스트 코드를 작성한다.

그리고 전반적인 변경사항으로
go pkg 들어가 보니 StandardClaims 가 deprecated 되어 있어서
go jwt 의 StandardClaims 대신 go jwt 의 MapClaims 으로 변경.

 

뭔가 go jwt 는 마음에 들지 않는다.

GO pkg 에서 jwt 로 검색하면 다음의 두 라이브러리가 검색되는데

dgrijalva 가보면 golang-jwt 로 마이그레이션을 안내해 주고 있다.

뭔가 보안 이슈가 있었던 것 같다. 

github.com/dgrijalva/jwt-go/v4

github.com/golang-jwt/jwt/v4

 

jwt 라이브러리는 golang- jwt 를 사용.

 

jwt package - github.com/golang-jwt/jwt/v4 - pkg.go.dev

Example (atypical) using the RegisteredClaims type by itself to parse a token. The RegisteredClaims type is designed to be embedded into your custom types to provide standard validation features. You can use it alone, but there's no way to retrieve other f

pkg.go.dev

 

토큰 테스트 케이스

NewClaim 메서드와 GenerateToken 메서드는 Claim 과 토큰을 생성하면 되고,
ValidateToken 메서드에서는 서명 암호화 방식, 토큰 파싱, 유효기간을 검증해서
User ID 를 넘기면 될 것이므로 다음과 같은 테스트 케이스가 필요할 것이다.
개인적으로 일단 틀만 만들어 놓고 로직 작성 후 테스트 코드를 작성하는 것을 좋아한다.

func TestNewClaims(t *testing.T) {
	t.Run("올바른 Claim 이 생성될 것", func(t *testing.T) {

	})
}

func TestGenerateToken(t *testing.T) {
	t.Run("올바른 Token 이 생성될 것", func(t *testing.T) {

	})
}

func TestValidateToken(t *testing.T) {
	t.Run("올바른 Token 일 경우, 올바른 User ID 가 반환될 것", func(t *testing.T) {

	})

	t.Run("토큰 서명 암호화 방식이 맞지 않을 경우, 에러가 반환될 것", func(t *testing.T) {

	})

	t.Run("토큰 파싱에 실패했을 경우, 에러가 반환될 것", func(t *testing.T) {

	})

	t.Run("유효기간이 지난 Token 일 경우, 에러가 반환될 것", func(t *testing.T) {

	})
}

 

토큰 관련 메서드 구현

테스트 케이스를 만들며 정리한 로직을 구현해보자.

NewClaim 메서드

JWT 기본 항목 중 sub, iat, exp 를 사용해서 jwt 의 MapClaims 을 생성.

func NewClaim(userID string) jwt.MapClaims {
	now := time.Now()
	claim := jwt.MapClaims{
		"sub": userID,
		"iat": now.Unix(),
		"exp": now.Add(time.Hour * 24).Unix(),
	}

	return claim
}

 

GenerateToken 메서드

claim 을 가지는 페이로드와,
대칭키 암호화인 HS256 으로 암호화된 서명을 사용해서 토큰을 생성.
인터페이스 작성할 때와 달라진 점은 암호화 키를 메서드 입력 변수에 추가한 것인데,
상수를 쓸까 생각했는데 그러면 테스트가 힘들어질 것 같다는 생각이 들어서 입력 변수에 추가한 것이다.
그리고 키는 바이트 배열로 변환해서 사용.

func GenerateToken(claim jwt.MapClaims, secret string) (string, error) {
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claim)
	return token.SignedString([]byte(secret))
}

 

ValidateToken 메서드

토큰을 파싱하는 메서드가 살짝 복잡한데,
메서드 시그니처는 다음과 같다.
Parse(tokenString string, keyFunc Keyfunc) (*Token, error)
keyFunc 타입 파라미터를 입력해야 하는데 keyFunc 타입은 다음과 같다.
type Keyfunc func(*Token) (interface{}, error)
토큰을 입력받아서 무엇인가를 반환하는 함수 타입인데
GO pkg 에는 verify key 를 반환하라고 기재되어 있다.

파싱을 하고 에러가 나면 에러를 반환하고,
파싱이 성공하면 파싱한 토큰의 페이로드에서 claim 을 가져오고
가져온 cliam 을 검증하고 검증이 통과된 claim 을 반환.
인터페이스 작성할 때와 달라진 점은
추후 Claim 에서 필요한 정보가 User ID 이외의 정보도 있을 경우를 생각해서
반환 값을 User ID 에서 jwt 의 MapClaims 으로 변경

jwt.Parse 메서드 내부 처리에 Valid 함수를 호출해서 만료기간을 검증하고 있어서
만료기간 체크는 구현 안 해도 됨.
그리고 이 경우 에러 정보가 구체적이면 해킹이 들어왔을 때 메시지로 구체적인 정보 추측이 가능하므로
에러 메시지는 전부 invalid token 으로 통일.

func ValidateToken(token string, secret string) (jwt.MapClaims, error) {
	parsedToken, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
		_, ok := t.Method.(*jwt.SigningMethodHMAC)
		if !ok {
			return nil, errors.New(authentication.ErrInvalidToken)
		}
		return []byte(secret), nil
	})
	if err != nil {
		return nil, err
	}

	mapClaims, ok := parsedToken.Claims.(jwt.MapClaims)
	if !ok {
		return nil, errors.New(authentication.ErrInvalidToken)
	}

	return mapClaims, nil
}

구현이 끝났으니 testify 를 사용해서 테스트 코드를 작성.

테스트 코드 작성

테스트에 필요한 데이터, 함수는 생략. 깃헙에 올라가 있어요.
그리고 go jwt 의 MapClaims 에 다음과 같은 이슈가 있어 iat, exp 테스트는 작성 안 함.
json struct tag 가 달려 있어서 언마샬 할 때 float64 로 반환되어 오는 이슈.
굳이 json struct tag 를 달 필요가 있었을까. 아니면 달린 거 안 달린 거 선택하게 해 주든지.

 

Should exp and nbf really be float64 or int64? · Issue #103 · dgrijalva/jwt-go

The example given in the readme shows something like: token.Claims["exp"] = time.Now().Add(time.Hour * 72).Unix() And this makes sense, feels like idiomatic Go. However, the actual implem...

github.com


NewClaim 메서드 테스트

생성된 Claim 의 sub 가 입력한 User ID 인가?

func TestNewClaim(t *testing.T) {
	t.Run("올바른 Claim 이 생성될 것", func(t *testing.T) {
		got := NewClaim(userID)

		sub := getSub(got, t)

		assert.Equal(t, userID, sub)
	})
}


GenerateToken 메서드 테스트
생성된 토큰을 검증해서 가져오 Claim 의 sub 가 User ID 인가?
토큰 검증을 위해 ValidateToken 메서드에 의존하는 부분이 마음에 들지는 않음.
테스트를 위해 같은 메서드를 또 작성하는 것이 더 내키지 않으므로 패스.

func TestGenerateToken(t *testing.T) {
	t.Run("올바른 Token 이 생성될 것", func(t *testing.T) {
		got, err := GenerateToken(claim, secret)
		if err != nil {
			t.Error(err)
		}

		// 테스트를 검증하기 위해서는 ValidateToken 메서드가 구현되어 있을 것
		want, err := ValidateToken(got, secret)
		if err != nil {
			t.Error(err)
		}

		assert.Equal(t, getSub(want, t), getSub(claim, t))
	})
}


ValidateToken 메서드 테스트
올바른 토큰을 검증했을 때 반환되는 claim 의 sub 가 올바른 User ID 인가?
토큰 서명 방식이 맞지 않을 경우 에러가 발생하는가?
토큰 파싱에 실패했을 경우 에러가 발생하는가?
유효기간이 지난 토큰일 경우 에러가 발생하는가?

func TestValidateToken(t *testing.T) {
	t.Run("올바른 Token 일 경우, 올바른 User ID 가 반환될 것", func(t *testing.T) {
		// 토큰 생성 테스트에서 확인 완료
		TestGenerateToken(t)
	})

	t.Run("토큰 서명 암호화 방식이 맞지 않을 경우, 에러가 반환될 것", func(t *testing.T) {
		token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
		signedToken, _ := token.SignedString([]byte(secret))

		claim, err := ValidateToken(signedToken, secret)
		assert.NotNil(t, err)
		assert.Nil(t, claim)
	})

	t.Run("토큰 파싱에 실패했을 경우, 에러가 반환될 것", func(t *testing.T) {
		claim, err := ValidateToken("I am Not Token", secret)
		assert.NotNil(t, err)
		assert.Nil(t, claim)
	})

	t.Run("유효기간이 지난 Token 일 경우, 에러가 반환될 것", func(t *testing.T) {
		expiredClaim := claim
		expiredClaim["exp"] = time.Now().Add(time.Hour * -5).Unix()
		claim, err := ValidateToken("I am Not Token", secret)
		assert.NotNil(t, err)
		assert.Nil(t, claim)
	})
}


전체 소스는 https://github.com/jongwon-hyun/go_auth_jwt

다음 글

 

[GO 인증 구현 with JWT 3] 인증 미들웨어 구현 및 테스트 코드 작성

이전 글 [GO 인증 구현 with JWT 1] 전체적인 인터페이스 작성 Use Case 간단히 구현해 볼 인증의 시나리오 유저 생성 → 생성된 유저 정보로 토큰 발급 → 발급된 토큰에 들어있는 유저ID 를 이용해서

tinkerbellbass.tistory.com

 

728x90
반응형

댓글