mirror of
				https://github.com/elyby/chrly.git
				synced 2025-05-31 14:11:51 +05:30 
			
		
		
		
	Rework security module, replace JWT library, invalidate JWT tokens signed for Chrly v4, generate RSA key in runtime when not provided via configuration
This commit is contained in:
		
							
								
								
									
										82
									
								
								internal/security/jwt.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								internal/security/jwt.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| package security | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/golang-jwt/jwt/v5" | ||||
|  | ||||
| 	"ely.by/chrly/internal/version" | ||||
| ) | ||||
|  | ||||
| var now = time.Now | ||||
| var signingMethod = jwt.SigningMethodHS256 | ||||
|  | ||||
| const scopesClaim = "scopes" | ||||
|  | ||||
| type Scope string | ||||
|  | ||||
| const ( | ||||
| 	ProfileScope Scope = "profiles" | ||||
| ) | ||||
|  | ||||
| func NewJwt(key []byte) *Jwt { | ||||
| 	return &Jwt{ | ||||
| 		Key: key, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| type Jwt struct { | ||||
| 	Key []byte | ||||
| } | ||||
|  | ||||
| func (t *Jwt) NewToken(scopes ...Scope) (string, error) { | ||||
| 	if len(scopes) == 0 { | ||||
| 		return "", errors.New("you must specify at least one scope") | ||||
| 	} | ||||
|  | ||||
| 	token := jwt.NewWithClaims(signingMethod, jwt.MapClaims{ | ||||
| 		"iss":       "chrly", | ||||
| 		"iat":       now().Unix(), | ||||
| 		scopesClaim: scopes, | ||||
| 	}) | ||||
| 	token.Header["v"] = version.MajorVersion | ||||
|  | ||||
| 	return token.SignedString(t.Key) | ||||
| } | ||||
|  | ||||
| // Keep those names generic in order to reuse them in future for alternative authentication methods | ||||
| var MissingAuthenticationError = errors.New("authentication value not provided") | ||||
| var InvalidTokenError = errors.New("passed authentication value is invalid") | ||||
|  | ||||
| func (t *Jwt) Authenticate(req *http.Request) error { | ||||
| 	bearerToken := req.Header.Get("Authorization") | ||||
| 	if bearerToken == "" { | ||||
| 		return MissingAuthenticationError | ||||
| 	} | ||||
|  | ||||
| 	if !strings.HasPrefix(strings.ToLower(bearerToken), "bearer ") { | ||||
| 		return InvalidTokenError | ||||
| 	} | ||||
|  | ||||
| 	tokenStr := bearerToken[7:] | ||||
| 	token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { | ||||
| 		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { | ||||
| 			return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) | ||||
| 		} | ||||
|  | ||||
| 		return t.Key, nil | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		return errors.Join(InvalidTokenError, err) | ||||
| 	} | ||||
|  | ||||
| 	if _, vHeaderExists := token.Header["v"]; !vHeaderExists { | ||||
| 		return errors.Join(InvalidTokenError, errors.New("missing v header")) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
							
								
								
									
										75
									
								
								internal/security/jwt_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								internal/security/jwt_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | ||||
| package security | ||||
|  | ||||
| import ( | ||||
| 	"net/http/httptest" | ||||
| 	"testing" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| const jwtString = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsInYiOjV9.eyJpYXQiOjE3MDY3ODY3NzUsImlzcyI6ImNocmx5Iiwic2NvcGVzIjpbInByb2ZpbGVzIl19.LrXrKo5iRFFHCDlMsVDhmJJheZqxbxuEVXB4XswHFKY" | ||||
|  | ||||
| func TestJwtAuth_NewToken(t *testing.T) { | ||||
| 	jwt := NewJwt([]byte("secret")) | ||||
| 	now = func() time.Time { | ||||
| 		return time.Date(2024, 2, 1, 11, 26, 15, 0, time.UTC) | ||||
| 	} | ||||
|  | ||||
| 	t.Run("with scope", func(t *testing.T) { | ||||
| 		token, err := jwt.NewToken(ProfileScope, "custom-scope") | ||||
| 		require.NoError(t, err) | ||||
| 		require.Equal(t, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsInYiOjV9.eyJpYXQiOjE3MDY3ODY3NzUsImlzcyI6ImNocmx5Iiwic2NvcGVzIjpbInByb2ZpbGVzIiwiY3VzdG9tLXNjb3BlIl19.Iq673YyWWkJZjIkBmKYRN8Lx9qoD39S_e-MegG0aORM", token) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("no scopes", func(t *testing.T) { | ||||
| 		token, err := jwt.NewToken() | ||||
| 		require.Error(t, err) | ||||
| 		require.Empty(t, token) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func TestJwtAuth_Authenticate(t *testing.T) { | ||||
| 	jwt := NewJwt([]byte("secret")) | ||||
| 	t.Run("success", func(t *testing.T) { | ||||
| 		req := httptest.NewRequest("POST", "http://localhost", nil) | ||||
| 		req.Header.Add("Authorization", "Bearer "+jwtString) | ||||
| 		err := jwt.Authenticate(req) | ||||
| 		require.NoError(t, err) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("request without auth header", func(t *testing.T) { | ||||
| 		req := httptest.NewRequest("POST", "http://localhost", nil) | ||||
| 		err := jwt.Authenticate(req) | ||||
| 		require.ErrorIs(t, err, MissingAuthenticationError) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("no bearer token prefix", func(t *testing.T) { | ||||
| 		req := httptest.NewRequest("POST", "http://localhost", nil) | ||||
| 		req.Header.Add("Authorization", "trash") | ||||
| 		err := jwt.Authenticate(req) | ||||
| 		require.ErrorIs(t, err, InvalidTokenError) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("bearer token but not jwt", func(t *testing.T) { | ||||
| 		req := httptest.NewRequest("POST", "http://localhost", nil) | ||||
| 		req.Header.Add("Authorization", "Bearer seems.like.jwt") | ||||
| 		err := jwt.Authenticate(req) | ||||
| 		require.ErrorIs(t, err, InvalidTokenError) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("invalid signature", func(t *testing.T) { | ||||
| 		req := httptest.NewRequest("POST", "http://localhost", nil) | ||||
| 		req.Header.Add("Authorization", "Bearer "+jwtString+"123") | ||||
| 		err := jwt.Authenticate(req) | ||||
| 		require.ErrorIs(t, err, InvalidTokenError) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("missing v header", func(t *testing.T) { | ||||
| 		req := httptest.NewRequest("POST", "http://localhost", nil) | ||||
| 		req.Header.Add("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MDY3ODY3NzUsImlzcyI6ImNocmx5Iiwic2NvcGVzIjpbInByb2ZpbGVzIl19.zOX2ZKyU37kjwt1p9uCHxALxWQD2UC0wWcAcNvBXGq0") | ||||
| 		err := jwt.Authenticate(req) | ||||
| 		require.ErrorIs(t, err, InvalidTokenError) | ||||
| 		require.ErrorContains(t, err, "missing v header") | ||||
| 	}) | ||||
| } | ||||
							
								
								
									
										40
									
								
								internal/security/signer.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								internal/security/signer.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | ||||
| package security | ||||
|  | ||||
| import ( | ||||
| 	"crypto" | ||||
| 	"crypto/rand" | ||||
| 	"crypto/rsa" | ||||
| 	"crypto/sha1" | ||||
| 	"encoding/base64" | ||||
| ) | ||||
|  | ||||
| var randomReader = rand.Reader | ||||
|  | ||||
| func NewSigner(key *rsa.PrivateKey) *Signer { | ||||
| 	return &Signer{Key: key} | ||||
| } | ||||
|  | ||||
| type Signer struct { | ||||
| 	Key *rsa.PrivateKey | ||||
| } | ||||
|  | ||||
| func (s *Signer) SignTextures(textures string) (string, error) { | ||||
| 	message := []byte(textures) | ||||
| 	messageHash := sha1.New() | ||||
| 	_, err := messageHash.Write(message) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
|  | ||||
| 	messageHashSum := messageHash.Sum(nil) | ||||
| 	signature, err := rsa.SignPKCS1v15(randomReader, s.Key, crypto.SHA1, messageHashSum) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
|  | ||||
| 	return base64.StdEncoding.EncodeToString(signature), nil | ||||
| } | ||||
|  | ||||
| func (s *Signer) GetPublicKey() (*rsa.PublicKey, error) { | ||||
| 	return &s.Key.PublicKey, nil | ||||
| } | ||||
							
								
								
									
										43
									
								
								internal/security/signer_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								internal/security/signer_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| package security | ||||
|  | ||||
| import ( | ||||
| 	"crypto/rsa" | ||||
| 	"crypto/x509" | ||||
| 	"encoding/pem" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| type ConstantReader struct { | ||||
| } | ||||
|  | ||||
| func (c *ConstantReader) Read(p []byte) (int, error) { | ||||
| 	return 1, nil | ||||
| } | ||||
|  | ||||
| func TestSigner_SignTextures(t *testing.T) { | ||||
| 	randomReader = &ConstantReader{} | ||||
|  | ||||
| 	rawKey, _ := pem.Decode([]byte("-----BEGIN RSA PRIVATE KEY-----\nMIIBOwIBAAJBANbUpVCZkMKpfvYZ08W3lumdAaYxLBnmUDlzHBQH3DpYef5WCO32\nTDU6feIJ58A0lAywgtZ4wwi2dGHOz/1hAvcCAwEAAQJAItaxSHTe6PKbyEU/9pxj\nONdhYRYwVLLo56gnMYhkyoEqaaMsfov8hhoepkYZBMvZFB2bDOsQ2SaJ+E2eiBO4\nAQIhAPssS0+BR9w0bOdmjGqmdE9NrN5UJQcOW13s29+6QzUBAiEA2vWOepA5Apiu\npEA3pwoGdkVCrNSnnKjDQzDXBnpd3/cCIEFNd9sY4qUG4FWdXN6RnmXL7Sj0uZfH\nDMwzu8rEM5sBAiEAhvdoDNqLmbMdq3c+FsPSOeL1d21Zp/JK8kbPtFmHNf8CIQDV\n6FSZDwvWfuxaM7BsycQONkjDBTPNu+lqctJBGnBv3A==\n-----END RSA PRIVATE KEY-----\n")) | ||||
| 	key, _ := x509.ParsePKCS1PrivateKey(rawKey.Bytes) | ||||
|  | ||||
| 	signer := NewSigner(key) | ||||
|  | ||||
| 	signature, err := signer.SignTextures("eyJ0aW1lc3RhbXAiOjE2MTQzMDcxMzQsInByb2ZpbGVJZCI6ImZmYzhmZGM5NTgyNDUwOWU4YTU3Yzk5Yjk0MGZiOTk2IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly9lbHkuYnkvc3RvcmFnZS9za2lucy82OWM2NzQwZDI5OTNlNWQ2ZjZhN2ZjOTI0MjBlZmMyOS5wbmcifX0sImVseSI6dHJ1ZX0") | ||||
| 	require.NoError(t, err) | ||||
| 	require.Equal(t, "IyHCxTP5ITquEXTHcwCtLd08jWWy16JwlQeWg8naxhoAVQecHGRdzHRscuxtdq/446kmeox7h4EfRN2A2ZLL+A==", signature) | ||||
| } | ||||
|  | ||||
| func TestSigner_GetPublicKey(t *testing.T) { | ||||
| 	randomReader = &ConstantReader{} | ||||
|  | ||||
| 	rawKey, _ := pem.Decode([]byte("-----BEGIN RSA PRIVATE KEY-----\nMIIBOwIBAAJBANbUpVCZkMKpfvYZ08W3lumdAaYxLBnmUDlzHBQH3DpYef5WCO32\nTDU6feIJ58A0lAywgtZ4wwi2dGHOz/1hAvcCAwEAAQJAItaxSHTe6PKbyEU/9pxj\nONdhYRYwVLLo56gnMYhkyoEqaaMsfov8hhoepkYZBMvZFB2bDOsQ2SaJ+E2eiBO4\nAQIhAPssS0+BR9w0bOdmjGqmdE9NrN5UJQcOW13s29+6QzUBAiEA2vWOepA5Apiu\npEA3pwoGdkVCrNSnnKjDQzDXBnpd3/cCIEFNd9sY4qUG4FWdXN6RnmXL7Sj0uZfH\nDMwzu8rEM5sBAiEAhvdoDNqLmbMdq3c+FsPSOeL1d21Zp/JK8kbPtFmHNf8CIQDV\n6FSZDwvWfuxaM7BsycQONkjDBTPNu+lqctJBGnBv3A==\n-----END RSA PRIVATE KEY-----\n")) | ||||
| 	key, _ := x509.ParsePKCS1PrivateKey(rawKey.Bytes) | ||||
|  | ||||
| 	signer := NewSigner(key) | ||||
|  | ||||
| 	publicKey, err := signer.GetPublicKey() | ||||
| 	require.NoError(t, err) | ||||
| 	require.IsType(t, &rsa.PublicKey{}, publicKey) | ||||
| } | ||||
		Reference in New Issue
	
	Block a user