From a99e990270aa8b26b4574ba772917273d7603293 Mon Sep 17 00:00:00 2001 From: Sipachev Igor Date: Fri, 14 Feb 2025 23:46:57 +0700 Subject: [PATCH] feat(jwt): add jwt helpers --- data/private | 27 +++++ data/public | 9 ++ go.mod | 14 +++ go.sum | 17 +++ jwt.go | 200 +++++++++++++++++++++++++++++++ jwt_test.go | 40 +++++++ token_authorization_info.go | 140 ++++++++++++++++++++++ token_authorization_info_test.go | 42 +++++++ 8 files changed, 489 insertions(+) create mode 100644 data/private create mode 100644 data/public create mode 100644 go.mod create mode 100644 go.sum create mode 100644 jwt.go create mode 100644 jwt_test.go create mode 100644 token_authorization_info.go create mode 100644 token_authorization_info_test.go diff --git a/data/private b/data/private new file mode 100644 index 0000000..c096536 --- /dev/null +++ b/data/private @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEAl6nmCqsNninrds4Y0MXJkonE/a8/ak+tIWn8/af0nR/r+wlx +ISfAMkoAaGuhpj39v6um+iQlnBjZoo/G8+HuanAv7wxERr9l995nUNBm+EelY0KL +mG4Xh6b4qEYY2bFbHYsKiDEvxDBiaALdRLeplELw/pyoWJp6ALusbDSDJ1AG9FZ7 +JYq0xNUgcydYN9PNao1pfe4A8ZkxBktJyoLK8CtjjAAZ54WwpzQx3abmVh506fS5 +3lrXYIOJaW0dxcI5tT5PrKJkkE4UP551HMW/V0GVtWfHebtCutW9Jq5/uBtQW3Kg +sdNdvm+DZVrME8TqKWMQ6utz9u8DQ+YF2/DB4wIDAQABAoIBADMFPdUy7BbcJVFH +TgPVtdTtMe7hqKS7/xMxk6FFgj3lgj5mU7+Cnt6MFI0MQEorqpRzS231AQ39MiHE +2noq9EisSwPRDZr7QnNbR0hhg3Jcr9+vgEScLKA+5IG/axa42l0a7EUavuXyHPi+ +le9LFepBhs8wplWASjC68etI0yJC/i5p5W8RNiBOTMrRB73LAgLYy0TcZ6OPMAor +PXyNLdTtDe5CXc4KbL3D1u8VARJ8b8+Ck7ObBg5p9qnr+VcTZLG13Af2/aEh9zDm +prU0i5obBNYsy/OFG+IZfnK0Pm56ZpkiOFSDI6f8d00BwVF0OBC4dftAgCACi62x +QDD7evECgYEAx2kARY5Nnk/98hAygiUpI8wdOlF6rCCjPUZNfJbhwqj2rame9Q4Y +nvBhqGX1SJBD5ps5zjlSL6ilwr+j3cyiAHQl3szgvdy9C9J48RkWjME6LsS8x2Yl +aDtomzZ2a4+iOzcYzaxVX3nVn4RoS+gsMiX7jDEVocyYCc5s2Hkt9GsCgYEAwrQm +hb7rX/yEQw3Ds4TbjKxIDaqsA1yRikE8hQPZ8p8SZmZIhtWjVSDvBgyCHJO1iOim +AW/72f2+kyrUXuffyzXZW7s/3KW1AHNlOKJCraBMRVtzvUt1oHL8ZrYYXbKDW6Eq +Zbae+s5n2kU8UrtaXs1qh4EhYnTO+M5NdgxXBmkCgYAk6gcm2ST9PYmhGeZ/uSlY +exyeAx9WZeRSH4WQns3EH0sq8s9+RdHA+nbZmaZCfJJVSj71Mh9Iu0uUNa28DXmf +4+Bu0jZ4bzh/y8KfvykxfUOsDLd1oi8ikHzY3sglOT2rAJQS3uge+IrXMMet5Zjo +36clWKDMhvdOOWxk1mnvaQKBgCbAnHo6SbbNF7YQ40azxs7061JtCdeRcRZHbbg7 +0AFOT+c5rG3Jz7x91ZUqoCr360XYqFHY7BOzQV8hQyuwkwZrLVvopQlRofj4/siK +4yKTqRqU3TBr+Hl66Wm4DJl5klOGfF3KP1JECr+S0DLXP2FnGTDnLrHd9ePni9tX +EWshAoGAIMMr4+eYD2MIZkST6Nwbw+0BNTfiIW18CQUUwPeEHG1+CA5KT9tL5Zvz +vJ/4aFNoFPZ04IBmNmGO5LLRGvMsIJs85niqRcGIPrQq948vQ2QCQ5/W9GwxE4lu +x+yGW+XUExRP4FtTISWDMb0ZQERLjo6oBiKJkGYowzapIHCnP5Q= +-----END RSA PRIVATE KEY----- diff --git a/data/public b/data/public new file mode 100644 index 0000000..bc70942 --- /dev/null +++ b/data/public @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl6nmCqsNninrds4Y0MXJ +konE/a8/ak+tIWn8/af0nR/r+wlxISfAMkoAaGuhpj39v6um+iQlnBjZoo/G8+Hu +anAv7wxERr9l995nUNBm+EelY0KLmG4Xh6b4qEYY2bFbHYsKiDEvxDBiaALdRLep +lELw/pyoWJp6ALusbDSDJ1AG9FZ7JYq0xNUgcydYN9PNao1pfe4A8ZkxBktJyoLK +8CtjjAAZ54WwpzQx3abmVh506fS53lrXYIOJaW0dxcI5tT5PrKJkkE4UP551HMW/ +V0GVtWfHebtCutW9Jq5/uBtQW3KgsdNdvm+DZVrME8TqKWMQ6utz9u8DQ+YF2/DB +4wIDAQAB +-----END PUBLIC KEY----- diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..20f02a4 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module git.rinsvent.ru/rinsvent/go-jwt + +go 1.23.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/ua-parser/uap-go v0.0.0-20250213224047-9c035f085b90 // indirect + gopkg.in/yaml.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..81de1ed --- /dev/null +++ b/go.sum @@ -0,0 +1,17 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/ua-parser/uap-go v0.0.0-20250213224047-9c035f085b90 h1:rB0J+hLNltG1Qv+UF+MkdFz89XMps5BOAFJN4xWjc+s= +github.com/ua-parser/uap-go v0.0.0-20250213224047-9c035f085b90/go.mod h1:BUbeWZiieNxAuuADTBNb3/aeje6on3DhU3rpWsQSB1E= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/jwt.go b/jwt.go new file mode 100644 index 0000000..88be516 --- /dev/null +++ b/jwt.go @@ -0,0 +1,200 @@ +package jwt + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "github.com/golang-jwt/jwt/v5" + "io" + "os" + "path/filepath" + "time" +) + +type RefreshTokenClaims struct { + JWT +} + +func CreateRefreshTokenByAccess(accessClaims JWT, ttl time.Duration) RefreshTokenClaims { + return RefreshTokenClaims{ + JWT: JWT{ + Type: "refresh", + Ttl: ttl, + SessionId: accessClaims.SessionId, + RegisteredClaims: jwt.RegisteredClaims{ + ID: accessClaims.ID, + }, + }, + } +} + +func ParseRefreshToken(token string, publicKey *rsa.PublicKey) (*RefreshTokenClaims, error) { + refreshDecodedClaims, err := Decode(token, &RefreshTokenClaims{}, publicKey) + if err != nil { + return nil, err + } + refreshTokenClaims, ok := refreshDecodedClaims.(*RefreshTokenClaims) + if !ok { + return nil, fmt.Errorf("invalid refresh token claims") + } + return refreshTokenClaims, nil +} + +type JWT struct { + Type string `json:"t"` + Ttl time.Duration `json:"td"` + SessionId string `json:"si,omitempty"` + AuthorizationInfo string `json:"ai,omitempty"` + jwt.RegisteredClaims +} + +func (j *JWT) WithTtl(ttl time.Duration) *JWT { + j.Ttl = ttl + j.ExpiresAt = jwt.NewNumericDate(time.Now().Add(ttl)) + j.IssuedAt = jwt.NewNumericDate(time.Now()) + j.NotBefore = jwt.NewNumericDate(time.Now()) + return j +} + +func (j *JWT) WithId(id string) *JWT { + j.ID = id + return j +} + +func (j *JWT) WithSessionId(sessionId string) *JWT { + j.SessionId = sessionId + return j +} + +func (j *JWT) WithAuthorizationInfo(tai TokenAuthorizationInfo, secret string) *JWT { + ciphertext, err := tai.WithSecret(secret).Encode() + if err != nil { + return j + } + j.AuthorizationInfo = ciphertext + return j +} + +func (j *JWT) GetAuthorizationInfo(secret string) *TokenAuthorizationInfo { + tai, err := DecodeTokenAuthorizationInfo(j.AuthorizationInfo, secret) + if err != nil { + return nil + } + + return tai +} + +func (j *JWT) IsRefreshToken() bool { + return j.Type == "refresh" +} + +func Encode(j interface{}, privateKey *rsa.PrivateKey) (string, error) { + payload, ok := j.(jwt.Claims) + if !ok { + return "", fmt.Errorf("invalid jwt claims") + } + token := jwt.NewWithClaims(jwt.SigningMethodRS256, payload) + ss, err := token.SignedString(privateKey) + return ss, err +} + +func Decode(token string, data jwt.Claims, publicKey *rsa.PublicKey, options ...jwt.ParserOption) (jwt.Claims, error) { + t, err := jwt.ParseWithClaims(token, data, func(token *jwt.Token) (interface{}, error) { + return publicKey, nil + }, options...) + if err != nil { + return nil, err + } + + if claims, ok := t.Claims.(jwt.Claims); ok { + return claims, nil + } + + return nil, fmt.Errorf("unknown claims type, cannot proceed") +} + +func ReadPublicKey(path string) (*rsa.PublicKey, error) { + b, err := readPublicKey(path) + if err != nil { + return nil, fmt.Errorf("reading file: %w", err) + } + + decrypted, err := decodePEMBlock(b) + if err != nil { + return nil, fmt.Errorf("decoding PEM block failed: %w", err) + } + + parsedKey, err := x509.ParsePKIXPublicKey(decrypted) + if err != nil { + return nil, fmt.Errorf("parsing decrypted public key failed: %w", err) + } + + publicKey, ok := parsedKey.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("parsing decrypted public key failed: %w", err) + } + + return publicKey, nil +} + +func readFile(path string) ([]byte, error) { + file, err := os.Open(filepath.Clean(path)) + if err != nil { + return nil, fmt.Errorf("opening file: %w", err) + } + defer file.Close() + + b, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("reading file: %w", err) + } + return b, nil +} + +func readPublicKey(path string) ([]byte, error) { + b, err := readFile(path) + if err != nil { + return nil, fmt.Errorf("reading file: %w", err) + } + return b, nil +} + +func ReadPrivateKey(path string) (*rsa.PrivateKey, error) { + b, err := readFile(path) + if err != nil { + return nil, fmt.Errorf("reading file: %w", err) + } + + decrypted, err := decodePEMBlock(b) + if err != nil { + return nil, fmt.Errorf("decode PEM block: %w", err) + } + + parsedKey, err := x509.ParsePKCS1PrivateKey(decrypted) + if err != nil { + return nil, fmt.Errorf("parsing decrypted private key: %w", err) + } + + return parsedKey, nil +} + +func decodePEMBlock(block []byte) ([]byte, error) { + decodedKey, _ := pem.Decode(block) + if decodedKey == nil { + return nil, fmt.Errorf("decoding PEM block failed") + } + var ( + decrypted = decodedKey.Bytes + err error + ) + //nolint:staticcheck,nolintlint + if x509.IsEncryptedPEMBlock(decodedKey) { + //nolint:staticcheck,nolintlint + if decrypted, err = x509.DecryptPEMBlock(decodedKey, []byte("")); err != nil { + return nil, fmt.Errorf("decrypting PEM key failed: %s", err) + } + } + + return decrypted, nil +} diff --git a/jwt_test.go b/jwt_test.go new file mode 100644 index 0000000..9d583c4 --- /dev/null +++ b/jwt_test.go @@ -0,0 +1,40 @@ +package jwt + +import ( + "github.com/stretchr/testify/assert" + "path/filepath" + "testing" + "time" +) + +type AccessTokenClaims struct { + UserId string `json:"id"` + FirstName string `json:"fn"` + LastName string `json:"ln"` + JWT +} + +func TestJWT(t *testing.T) { + dir := "./data/" + + accessClaims := AccessTokenClaims{ + UserId: "123", + FirstName: "Igor", + LastName: "Sypachev", + } + accessClaims. + WithId("sadfswdf"). + WithTtl(20 * time.Minute). + WithSessionId("wergwergw") + + privateKey, _ := ReadPrivateKey(filepath.Join(filepath.Dir(dir), "private")) + token, err := Encode(accessClaims, privateKey) + assert.Equal(t, true, err == nil) + + publicKey, _ := ReadPublicKey(filepath.Join(filepath.Dir(dir), "public")) + decodedClaims, _ := Decode(token, &AccessTokenClaims{}, publicKey) + f, _ := decodedClaims.(*AccessTokenClaims) + assert.Equal(t, f.UserId, "123") + assert.Equal(t, f.FirstName, "Igor") + assert.Equal(t, f.LastName, "Sypachev") +} diff --git a/token_authorization_info.go b/token_authorization_info.go new file mode 100644 index 0000000..fed1686 --- /dev/null +++ b/token_authorization_info.go @@ -0,0 +1,140 @@ +package jwt + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "fmt" + "github.com/ua-parser/uap-go/uaparser" + "net" +) + +type TokenAuthorizationInfo struct { + Ip net.IP `json:"ip,omitempty"` + UserAgent string `json:"ua,omitempty"` + DeviceId string `json:"di,omitempty"` + CookieData string `json:"cd,omitempty"` + secret string +} + +func (tai *TokenAuthorizationInfo) WithSecret(secret string) *TokenAuthorizationInfo { + tai.secret = secret + return tai +} + +func (tai *TokenAuthorizationInfo) GetUserAgentInfo() *uaparser.Client { + parser := uaparser.NewFromSaved() + return parser.Parse(tai.UserAgent) +} + +func (tai *TokenAuthorizationInfo) Encode() (string, error) { + payload, err := json.Marshal(tai) + if err != nil { + return "", err + } + + ciphertext, err := GetAESEncrypted(string(payload), tai.secret[0:32], tai.secret[0:16]) + if err != nil { + return "", err + } + + return ciphertext, nil +} + +func DecodeTokenAuthorizationInfo(ciphertext string, secret string) (*TokenAuthorizationInfo, error) { + payload, err := GetAESDecrypted(ciphertext, secret[0:32], secret[0:16]) + if err != nil { + return nil, err + } + + tai := TokenAuthorizationInfo{} + if err := json.Unmarshal(payload, &tai); err != nil { + return nil, err + } + + return &tai, nil +} + +func (tai *TokenAuthorizationInfo) Equal(requestAuthorizationInfo TokenAuthorizationInfo) bool { + // todo проработать логику проверки данных клиента. Возможно добавим finger print / http only cookie. Защита от потери токенов + if tai.Ip.String() == requestAuthorizationInfo.Ip.String() { + return true + } + + uaClient := tai.GetUserAgentInfo() + requestUaClient := tai.GetUserAgentInfo() + if uaClient.UserAgent.Family == requestUaClient.UserAgent.Family && + uaClient.UserAgent.Major == requestUaClient.UserAgent.Major && + uaClient.Os.Family == requestUaClient.Os.Family && + uaClient.Os.Major == requestUaClient.Os.Major && + uaClient.Device.Family == requestUaClient.Device.Family && + uaClient.Device.Model == requestUaClient.Device.Model && + uaClient.Device.Brand == requestUaClient.Device.Brand { + return true + } + + return false +} + +func GetAESDecrypted(encrypted string, key string, iv string) ([]byte, error) { + ciphertext, err := base64.StdEncoding.DecodeString(encrypted) + + if err != nil { + return nil, err + } + + block, err := aes.NewCipher([]byte(key)) + + if err != nil { + return nil, err + } + + if len(ciphertext)%aes.BlockSize != 0 { + return nil, fmt.Errorf("block size cant be zero") + } + + mode := cipher.NewCBCDecrypter(block, []byte(iv)) + mode.CryptBlocks(ciphertext, ciphertext) + ciphertext = PKCS5UnPadding(ciphertext) + + return ciphertext, nil +} + +// PKCS5UnPadding pads a certain blob of data with necessary data to be used in AES block cipher +func PKCS5UnPadding(src []byte) []byte { + length := len(src) + unpadding := int(src[length-1]) + + return src[:(length - unpadding)] +} + +// GetAESEncrypted encrypts given text in AES 256 CBC +func GetAESEncrypted(plaintext string, key string, iv string) (string, error) { + var plainTextBlock []byte + length := len(plaintext) + + if length%16 != 0 { + extendBlock := 16 - (length % 16) + plainTextBlock = make([]byte, length+extendBlock) + copy(plainTextBlock[length:], bytes.Repeat([]byte{uint8(extendBlock)}, extendBlock)) + } else { + plainTextBlock = make([]byte, length) + } + + copy(plainTextBlock, plaintext) + block, err := aes.NewCipher([]byte(key)) + + if err != nil { + return "", err + } + + ciphertext := make([]byte, len(plainTextBlock)) + mode := cipher.NewCBCEncrypter(block, []byte(iv)) + mode.CryptBlocks(ciphertext, plainTextBlock) + + str := base64.StdEncoding.EncodeToString(ciphertext) + + return str, nil +} diff --git a/token_authorization_info_test.go b/token_authorization_info_test.go new file mode 100644 index 0000000..c05e2c3 --- /dev/null +++ b/token_authorization_info_test.go @@ -0,0 +1,42 @@ +package jwt + +import ( + "github.com/stretchr/testify/assert" + "net" + "testing" +) + +func TestTAI(t *testing.T) { + ip := net.ParseIP("192.186.4.33") + userAgent := "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + deviceId := "6a22eeeb-966c-47fa-bff8-f83dc7929d84" + cookieData := "asdfasd" + + tai := TokenAuthorizationInfo{ + Ip: ip, + UserAgent: userAgent, + DeviceId: deviceId, + CookieData: cookieData, + } + + actual, err := tai.WithSecret("1234567890123456789012345678901234567890").Encode() + + assert.Equal(t, nil, err) + assert.Equal(t, + "BbFhu340tDQl9y8siWFlc7s1TpjaHpWWG9tlOGXGOLheBj+cOiF4HKUaBFou10WX8y/feoz6tz/9IPgiUTwbEuXetGIO1KdoygmYiRhxlBYqv0sRa55EjNnPS1DrM7KHOu4fyV57+dvfc4dR669lnuTwhQFE6Q51pq5FtLTnm02HisPGVl1G3JukKAjPRNWCwdZhOylGPuQCav1Egihcz2ZZ3RRDOwUu3SsKEZJJig56XAd1J5MMHzovEgg6B4J4", + actual, + ) + + tai2, err2 := DecodeTokenAuthorizationInfo( + "BbFhu340tDQl9y8siWFlc7s1TpjaHpWWG9tlOGXGOLheBj+cOiF4HKUaBFou10WX8y/feoz6tz/9IPgiUTwbEuXetGIO1KdoygmYiRhxlBYqv0sRa55EjNnPS1DrM7KHOu4fyV57+dvfc4dR669lnuTwhQFE6Q51pq5FtLTnm02HisPGVl1G3JukKAjPRNWCwdZhOylGPuQCav1Egihcz2ZZ3RRDOwUu3SsKEZJJig56XAd1J5MMHzovEgg6B4J4", + "1234567890123456789012345678901234567890", + ) + + assert.Equal(t, nil, err2) + assert.Equal(t, ip, tai2.Ip) + assert.Equal(t, userAgent, tai2.UserAgent) + assert.Equal(t, deviceId, tai2.DeviceId) + assert.Equal(t, cookieData, tai2.CookieData) + + assert.Equal(t, true, tai.Equal(*tai2)) +}