이전 글
이번에는 토큰, 인증 미들웨어를 구현해 보자.
능력 부족으로 완벽한 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 를 사용.
토큰 테스트 케이스
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 를 달 필요가 있었을까. 아니면 달린 거 안 달린 거 선택하게 해 주든지.
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
다음 글
'Programming > Go' 카테고리의 다른 글
[GO 인증 구현 with JWT 4] User Repository, Service 구현 및 테스트 (0) | 2022.04.13 |
---|---|
[GO 인증 구현 with JWT 3] 인증 미들웨어 구현 및 테스트 코드 작성 (0) | 2022.04.11 |
[GO 인증 구현 with JWT 1] 전체적인 인터페이스 작성 (0) | 2022.04.09 |
Go 로 MinIO 에 파일 업다운로드 (0) | 2022.04.06 |
고 채널을 이용해서 옵저버 패턴 구현해보기 (0) | 2022.04.05 |
댓글