feat(jwt): add jwt helpers

This commit is contained in:
Sipachev Igor 2025-02-14 23:46:57 +07:00
parent fbbfdf8df6
commit a99e990270
8 changed files with 489 additions and 0 deletions

27
data/private Normal file
View File

@ -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-----

9
data/public Normal file
View File

@ -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-----

14
go.mod Normal file
View File

@ -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
)

17
go.sum Normal file
View File

@ -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=

200
jwt.go Normal file
View File

@ -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
}

40
jwt_test.go Normal file
View File

@ -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")
}

140
token_authorization_info.go Normal file
View File

@ -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
}

View File

@ -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))
}