mirror of
				https://github.com/elyby/chrly.git
				synced 2025-05-31 14:11:51 +05:30 
			
		
		
		
	Remove profiles endpoint and textures signing mechanism
This commit is contained in:
		| @@ -165,68 +165,3 @@ paths: | ||||
|           description: The profiles has been successfully deleted. | ||||
|         401: | ||||
|           $ref: "#/components/responses/UnauthorizedError" | ||||
|  | ||||
|   /api/signer: | ||||
|     post: | ||||
|       operationId: signData | ||||
|       summary: Signs the sent data. | ||||
|       tags: | ||||
|         - signer | ||||
|         - api | ||||
|       security: | ||||
|         - BearerAuth: [ sign ] | ||||
|       requestBody: | ||||
|         content: | ||||
|           "*": | ||||
|             schema: | ||||
|               description: Accepts data in any format and generates a signature for it. | ||||
|               type: string | ||||
|       responses: | ||||
|         200: | ||||
|           description: Successfully signed data. | ||||
|           content: | ||||
|             application/octet-stream+base64: | ||||
|               schema: | ||||
|                 description: A base64 encoded signature for the passed data. | ||||
|                 type: string | ||||
|         401: | ||||
|           $ref: "#/components/responses/UnauthorizedError" | ||||
|  | ||||
|   /api/signer/public-key.pem: | ||||
|     get: | ||||
|       operationId: signerPublicKeyPem | ||||
|       summary: Get signer's public key in PEM format. | ||||
|       tags: | ||||
|         - signer | ||||
|         - api | ||||
|       security: | ||||
|         - BearerAuth: [ sign ] | ||||
|       responses: | ||||
|         200: | ||||
|           description: The public file in PEM format. | ||||
|           content: | ||||
|             application/x-pem-file: | ||||
|               schema: | ||||
|                 type: string | ||||
|         401: | ||||
|           $ref: "#/components/responses/UnauthorizedError" | ||||
|  | ||||
|   /api/signer/public-key.der: | ||||
|     get: | ||||
|       operationId: signerPublicKeyPem | ||||
|       summary: Get signer's public key in DER format. | ||||
|       tags: | ||||
|         - signer | ||||
|         - api | ||||
|       security: | ||||
|         - BearerAuth: [ sign ] | ||||
|       responses: | ||||
|         200: | ||||
|           description: The public file in PEM format. | ||||
|           content: | ||||
|             application/octet-stream: | ||||
|               schema: | ||||
|                 type: string | ||||
|                 format: binary | ||||
|         401: | ||||
|           $ref: "#/components/responses/UnauthorizedError" | ||||
|   | ||||
| @@ -1,34 +0,0 @@ | ||||
| package signer | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"io" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| type Signer interface { | ||||
| 	Sign(data io.Reader) ([]byte, error) | ||||
| 	GetPublicKey(format string) ([]byte, error) | ||||
| } | ||||
|  | ||||
| type LocalSigner struct { | ||||
| 	Signer | ||||
| } | ||||
|  | ||||
| func (s *LocalSigner) Sign(ctx context.Context, data string) (string, error) { | ||||
| 	signed, err := s.Signer.Sign(strings.NewReader(data)) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
|  | ||||
| 	return string(signed), nil | ||||
| } | ||||
|  | ||||
| func (s *LocalSigner) GetPublicKey(ctx context.Context, format string) (string, error) { | ||||
| 	publicKey, err := s.Signer.GetPublicKey(format) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
|  | ||||
| 	return string(publicKey), nil | ||||
| } | ||||
| @@ -1,104 +0,0 @@ | ||||
| package signer | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"io" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/mock" | ||||
| 	"github.com/stretchr/testify/suite" | ||||
| ) | ||||
|  | ||||
| type SignerMock struct { | ||||
| 	mock.Mock | ||||
| } | ||||
|  | ||||
| func (m *SignerMock) Sign(data io.Reader) ([]byte, error) { | ||||
| 	args := m.Called(data) | ||||
| 	var result []byte | ||||
| 	if casted, ok := args.Get(0).([]byte); ok { | ||||
| 		result = casted | ||||
| 	} | ||||
|  | ||||
| 	return result, args.Error(1) | ||||
| } | ||||
|  | ||||
| func (m *SignerMock) GetPublicKey(format string) ([]byte, error) { | ||||
| 	args := m.Called(format) | ||||
| 	var result []byte | ||||
| 	if casted, ok := args.Get(0).([]byte); ok { | ||||
| 		result = casted | ||||
| 	} | ||||
|  | ||||
| 	return result, args.Error(1) | ||||
| } | ||||
|  | ||||
| type LocalSignerServiceTestSuite struct { | ||||
| 	suite.Suite | ||||
|  | ||||
| 	Service *LocalSigner | ||||
|  | ||||
| 	Signer *SignerMock | ||||
| } | ||||
|  | ||||
| func (t *LocalSignerServiceTestSuite) SetupSubTest() { | ||||
| 	t.Signer = &SignerMock{} | ||||
|  | ||||
| 	t.Service = &LocalSigner{ | ||||
| 		Signer: t.Signer, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (t *LocalSignerServiceTestSuite) TearDownSubTest() { | ||||
| 	t.Signer.AssertExpectations(t.T()) | ||||
| } | ||||
|  | ||||
| func (t *LocalSignerServiceTestSuite) TestSign() { | ||||
| 	t.Run("successfully sign", func() { | ||||
| 		signature := []byte("mock signature") | ||||
| 		t.Signer.On("Sign", mock.Anything).Return(signature, nil).Run(func(args mock.Arguments) { | ||||
| 			r, _ := io.ReadAll(args.Get(0).(io.Reader)) | ||||
| 			t.Equal([]byte("mock body to sign"), r) | ||||
| 		}) | ||||
|  | ||||
| 		result, err := t.Service.Sign(context.Background(), "mock body to sign") | ||||
| 		t.NoError(err) | ||||
| 		t.Equal(string(signature), result) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("handle error during sign", func() { | ||||
| 		expectedErr := errors.New("mock error") | ||||
| 		t.Signer.On("Sign", mock.Anything).Return(nil, expectedErr) | ||||
|  | ||||
| 		result, err := t.Service.Sign(context.Background(), "mock body to sign") | ||||
| 		t.Error(err) | ||||
| 		t.Same(expectedErr, err) | ||||
| 		t.Empty(result) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func (t *LocalSignerServiceTestSuite) TestGetPublicKey() { | ||||
| 	t.Run("successfully get", func() { | ||||
| 		publicKey := []byte("mock public key") | ||||
| 		t.Signer.On("GetPublicKey", "pem").Return(publicKey, nil) | ||||
|  | ||||
| 		result, err := t.Service.GetPublicKey(context.Background(), "pem") | ||||
| 		t.NoError(err) | ||||
| 		t.Equal(string(publicKey), result) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("handle error", func() { | ||||
| 		expectedErr := errors.New("mock error") | ||||
| 		t.Signer.On("GetPublicKey", "pem").Return(nil, expectedErr) | ||||
|  | ||||
| 		result, err := t.Service.GetPublicKey(context.Background(), "pem") | ||||
| 		t.Error(err) | ||||
| 		t.Same(expectedErr, err) | ||||
| 		t.Empty(result) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func TestLocalSignerService(t *testing.T) { | ||||
| 	suite.Run(t, new(LocalSignerServiceTestSuite)) | ||||
| } | ||||
| @@ -16,7 +16,7 @@ var serveCmd = &cobra.Command{ | ||||
| 	Use:   "serve", | ||||
| 	Short: "Starts HTTP handler for the skins system", | ||||
| 	RunE: func(cmd *cobra.Command, args []string) error { | ||||
| 		return startServer(di.ModuleSkinsystem, di.ModuleProfiles, di.ModuleSigner) | ||||
| 		return startServer(di.ModuleSkinsystem, di.ModuleProfiles) | ||||
| 	}, | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -12,7 +12,6 @@ func New() (*di.Container, error) { | ||||
| 		loggerDiOptions, | ||||
| 		mojangDiOptions, | ||||
| 		profilesDiOptions, | ||||
| 		securityDiOptions, | ||||
| 		serverDiOptions, | ||||
| 	) | ||||
| } | ||||
|   | ||||
| @@ -17,13 +17,11 @@ import ( | ||||
|  | ||||
| const ModuleSkinsystem = "skinsystem" | ||||
| const ModuleProfiles = "profiles" | ||||
| const ModuleSigner = "signer" | ||||
|  | ||||
| var handlersDiOptions = di.Options( | ||||
| 	di.Provide(newHandlerFactory, di.As(new(http.Handler))), | ||||
| 	di.Provide(newSkinsystemHandler, di.WithName(ModuleSkinsystem)), | ||||
| 	di.Provide(newProfilesApiHandler, di.WithName(ModuleProfiles)), | ||||
| 	di.Provide(newSignerApiHandler, di.WithName(ModuleSigner)), | ||||
| ) | ||||
|  | ||||
| func newHandlerFactory( | ||||
| @@ -65,26 +63,6 @@ func newHandlerFactory( | ||||
| 		mount(router, "/api/profiles", profilesApiRouter) | ||||
| 	} | ||||
|  | ||||
| 	if slices.Contains(enabledModules, ModuleSigner) { | ||||
| 		var signerApiRouter *mux.Router | ||||
| 		if err := container.Resolve(&signerApiRouter, di.Name(ModuleSigner)); err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
|  | ||||
| 		var authenticator Authenticator | ||||
| 		if err := container.Resolve(&authenticator); err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
|  | ||||
| 		authMiddleware := NewAuthenticationMiddleware(authenticator, security.SignScope) | ||||
| 		conditionalAuth := NewConditionalMiddleware(func(req *http.Request) bool { | ||||
| 			return req.Method != "GET" | ||||
| 		}, authMiddleware) | ||||
| 		signerApiRouter.Use(conditionalAuth) | ||||
|  | ||||
| 		mount(router, "/api/signer", signerApiRouter) | ||||
| 	} | ||||
|  | ||||
| 	// Resolve health checkers last, because all the services required by the application | ||||
| 	// must first be initialized and each of them can publish its own checkers | ||||
| 	var healthCheckers []*namedHealthChecker | ||||
| @@ -107,14 +85,12 @@ func newHandlerFactory( | ||||
| func newSkinsystemHandler( | ||||
| 	config *viper.Viper, | ||||
| 	profilesProvider ProfilesProvider, | ||||
| 	texturesSigner SignerService, | ||||
| ) (*mux.Router, error) { | ||||
| 	config.SetDefault("textures.extra_param_name", "chrly") | ||||
| 	config.SetDefault("textures.extra_param_value", "how do you tame a horse in Minecraft?") | ||||
|  | ||||
| 	skinsystem, err := NewSkinsystemApi( | ||||
| 		profilesProvider, | ||||
| 		texturesSigner, | ||||
| 		config.GetString("textures.extra_param_name"), | ||||
| 		config.GetString("textures.extra_param_value"), | ||||
| 	) | ||||
| @@ -134,15 +110,6 @@ func newProfilesApiHandler(profilesManager ProfilesManager) (*mux.Router, error) | ||||
| 	return profilesApi.Handler(), nil | ||||
| } | ||||
|  | ||||
| func newSignerApiHandler(signer Signer) (*mux.Router, error) { | ||||
| 	signerApi, err := NewSignerApi(signer) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return signerApi.Handler(), nil | ||||
| } | ||||
|  | ||||
| func mount(router *mux.Router, path string, handler http.Handler) { | ||||
| 	router.PathPrefix(path).Handler( | ||||
| 		http.StripPrefix( | ||||
|   | ||||
| @@ -1,59 +0,0 @@ | ||||
| package di | ||||
|  | ||||
| import ( | ||||
| 	"crypto/rand" | ||||
| 	"crypto/rsa" | ||||
| 	"crypto/x509" | ||||
| 	"encoding/pem" | ||||
| 	"errors" | ||||
| 	"log/slog" | ||||
|  | ||||
| 	"ely.by/chrly/internal/client/signer" | ||||
| 	"ely.by/chrly/internal/http" | ||||
| 	"ely.by/chrly/internal/security" | ||||
|  | ||||
| 	"github.com/defval/di" | ||||
| 	"github.com/spf13/viper" | ||||
| ) | ||||
|  | ||||
| var securityDiOptions = di.Options( | ||||
| 	di.Provide(newSigner, | ||||
| 		di.As(new(http.Signer)), | ||||
| 		di.As(new(signer.Signer)), | ||||
| 	), | ||||
| 	di.Provide(newSignerService), | ||||
| ) | ||||
|  | ||||
| func newSigner(config *viper.Viper) (*security.Signer, error) { | ||||
| 	var privateKey *rsa.PrivateKey | ||||
| 	var err error | ||||
|  | ||||
| 	keyStr := config.GetString("chrly.signing.key") | ||||
| 	if keyStr == "" { | ||||
| 		privateKey, err = rsa.GenerateKey(rand.Reader, 2048) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
|  | ||||
| 		slog.Warn("A private signing key has been generated. To make it permanent, specify the valid RSA private key in the config parameter chrly.signing.key") | ||||
| 	} else { | ||||
| 		keyBytes := []byte(keyStr) | ||||
| 		rawPem, _ := pem.Decode(keyBytes) | ||||
| 		if rawPem == nil { | ||||
| 			return nil, errors.New("unable to decode pem key") | ||||
| 		} | ||||
|  | ||||
| 		privateKey, err = x509.ParsePKCS1PrivateKey(rawPem.Bytes) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return security.NewSigner(privateKey), nil | ||||
| } | ||||
|  | ||||
| func newSignerService(s signer.Signer) http.SignerService { | ||||
| 	return &signer.LocalSigner{ | ||||
| 		Signer: s, | ||||
| 	} | ||||
| } | ||||
| @@ -1,96 +0,0 @@ | ||||
| package http | ||||
|  | ||||
| import ( | ||||
| 	"encoding/base64" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
|  | ||||
| 	"github.com/gorilla/mux" | ||||
| 	"go.opentelemetry.io/otel/metric" | ||||
| 	"go.uber.org/multierr" | ||||
|  | ||||
| 	"ely.by/chrly/internal/otel" | ||||
| ) | ||||
|  | ||||
| type Signer interface { | ||||
| 	Sign(data io.Reader) ([]byte, error) | ||||
| 	GetPublicKey(format string) ([]byte, error) | ||||
| } | ||||
|  | ||||
| func NewSignerApi(signer Signer) (*SignerApi, error) { | ||||
| 	metrics, err := newSignerApiMetrics(otel.GetMeter()) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return &SignerApi{ | ||||
| 		Signer:  signer, | ||||
| 		metrics: metrics, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| type SignerApi struct { | ||||
| 	Signer | ||||
|  | ||||
| 	metrics *signerApiMetrics | ||||
| } | ||||
|  | ||||
| func (s *SignerApi) Handler() *mux.Router { | ||||
| 	router := mux.NewRouter().StrictSlash(true) | ||||
| 	router.HandleFunc("/", s.signHandler).Methods(http.MethodPost) | ||||
| 	router.HandleFunc("/public-key.{format:(?:pem|der)}", s.getPublicKeyHandler).Methods(http.MethodGet) | ||||
|  | ||||
| 	return router | ||||
| } | ||||
|  | ||||
| func (s *SignerApi) signHandler(resp http.ResponseWriter, req *http.Request) { | ||||
| 	signature, err := s.Signer.Sign(req.Body) | ||||
| 	if err != nil { | ||||
| 		apiServerError(resp, req, fmt.Errorf("unable to sign the value: %w", err)) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	resp.Header().Set("Content-Type", "application/octet-stream+base64") | ||||
|  | ||||
| 	buf := make([]byte, base64.StdEncoding.EncodedLen(len(signature))) | ||||
| 	base64.StdEncoding.Encode(buf, signature) | ||||
| 	_, _ = resp.Write(buf) | ||||
| } | ||||
|  | ||||
| func (s *SignerApi) getPublicKeyHandler(resp http.ResponseWriter, req *http.Request) { | ||||
| 	format := mux.Vars(req)["format"] | ||||
| 	publicKey, err := s.Signer.GetPublicKey(format) | ||||
| 	if err != nil { | ||||
| 		apiServerError(resp, req, fmt.Errorf("unable to retrieve public key: %w", err)) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if format == "pem" { | ||||
| 		resp.Header().Set("Content-Type", "application/x-pem-file") | ||||
| 		resp.Header().Set("Content-Disposition", `attachment; filename="yggdrasil_session_pubkey.pem"`) | ||||
| 	} else { | ||||
| 		resp.Header().Set("Content-Type", "application/octet-stream") | ||||
| 		resp.Header().Set("Content-Disposition", `attachment; filename="yggdrasil_session_pubkey.der"`) | ||||
| 	} | ||||
|  | ||||
| 	_, _ = resp.Write(publicKey) | ||||
| } | ||||
|  | ||||
| func newSignerApiMetrics(meter metric.Meter) (*signerApiMetrics, error) { | ||||
| 	m := &signerApiMetrics{} | ||||
| 	var errors, err error | ||||
|  | ||||
| 	m.SignRequest, err = meter.Int64Counter("chrly.app.signer.sign.request", metric.WithUnit("{request}")) | ||||
| 	errors = multierr.Append(errors, err) | ||||
|  | ||||
| 	m.GetPublicKeyRequest, err = meter.Int64Counter("chrly.app.signer.get_public_key.request", metric.WithUnit("{request}")) | ||||
| 	errors = multierr.Append(errors, err) | ||||
|  | ||||
| 	return m, errors | ||||
| } | ||||
|  | ||||
| type signerApiMetrics struct { | ||||
| 	SignRequest         metric.Int64Counter | ||||
| 	GetPublicKeyRequest metric.Int64Counter | ||||
| } | ||||
| @@ -1,144 +0,0 @@ | ||||
| package http | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"errors" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/http/httptest" | ||||
| 	"strings" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/mock" | ||||
| 	"github.com/stretchr/testify/suite" | ||||
| ) | ||||
|  | ||||
| type SignerMock struct { | ||||
| 	mock.Mock | ||||
| } | ||||
|  | ||||
| func (m *SignerMock) Sign(data io.Reader) ([]byte, error) { | ||||
| 	args := m.Called(data) | ||||
| 	var result []byte | ||||
| 	if casted, ok := args.Get(0).([]byte); ok { | ||||
| 		result = casted | ||||
| 	} | ||||
|  | ||||
| 	return result, args.Error(1) | ||||
| } | ||||
|  | ||||
| func (m *SignerMock) GetPublicKey(format string) ([]byte, error) { | ||||
| 	args := m.Called(format) | ||||
| 	var result []byte | ||||
| 	if casted, ok := args.Get(0).([]byte); ok { | ||||
| 		result = casted | ||||
| 	} | ||||
|  | ||||
| 	return result, args.Error(1) | ||||
| } | ||||
|  | ||||
| type SignerApiTestSuite struct { | ||||
| 	suite.Suite | ||||
|  | ||||
| 	App *SignerApi | ||||
|  | ||||
| 	Signer *SignerMock | ||||
| } | ||||
|  | ||||
| func (t *SignerApiTestSuite) SetupSubTest() { | ||||
| 	t.Signer = &SignerMock{} | ||||
|  | ||||
| 	t.App, _ = NewSignerApi(t.Signer) | ||||
| } | ||||
|  | ||||
| func (t *SignerApiTestSuite) TearDownSubTest() { | ||||
| 	t.Signer.AssertExpectations(t.T()) | ||||
| } | ||||
|  | ||||
| func (t *SignerApiTestSuite) TestSign() { | ||||
| 	t.Run("successfully sign", func() { | ||||
| 		signature := []byte("mock signature") | ||||
| 		t.Signer.On("Sign", mock.Anything).Return(signature, nil).Run(func(args mock.Arguments) { | ||||
| 			buf := &bytes.Buffer{} | ||||
| 			_, _ = io.Copy(buf, args.Get(0).(io.Reader)) | ||||
| 			r, _ := io.ReadAll(buf) | ||||
|  | ||||
| 			t.Equal([]byte("mock body to sign"), r) | ||||
| 		}) | ||||
|  | ||||
| 		req := httptest.NewRequest("POST", "http://chrly/", strings.NewReader("mock body to sign")) | ||||
| 		w := httptest.NewRecorder() | ||||
|  | ||||
| 		t.App.Handler().ServeHTTP(w, req) | ||||
|  | ||||
| 		result := w.Result() | ||||
| 		t.Equal(http.StatusOK, result.StatusCode) | ||||
| 		t.Equal("application/octet-stream+base64", result.Header.Get("Content-Type")) | ||||
| 		body, _ := io.ReadAll(result.Body) | ||||
| 		t.Equal([]byte{0x62, 0x57, 0x39, 0x6a, 0x61, 0x79, 0x42, 0x7a, 0x61, 0x57, 0x64, 0x75, 0x59, 0x58, 0x52, 0x31, 0x63, 0x6d, 0x55, 0x3d}, body) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("handle error during sign", func() { | ||||
| 		t.Signer.On("Sign", mock.Anything).Return(nil, errors.New("mock error")) | ||||
|  | ||||
| 		req := httptest.NewRequest("POST", "http://chrly/", strings.NewReader("mock body to sign")) | ||||
| 		w := httptest.NewRecorder() | ||||
|  | ||||
| 		t.App.Handler().ServeHTTP(w, req) | ||||
|  | ||||
| 		result := w.Result() | ||||
| 		t.Equal(http.StatusInternalServerError, result.StatusCode) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func (t *SignerApiTestSuite) TestGetPublicKey() { | ||||
| 	t.Run("in pem format", func() { | ||||
| 		publicKey := []byte("mock public key in pem format") | ||||
| 		t.Signer.On("GetPublicKey", "pem").Return(publicKey, nil) | ||||
|  | ||||
| 		req := httptest.NewRequest("GET", "http://chrly/public-key.pem", nil) | ||||
| 		w := httptest.NewRecorder() | ||||
|  | ||||
| 		t.App.Handler().ServeHTTP(w, req) | ||||
|  | ||||
| 		result := w.Result() | ||||
| 		t.Equal(http.StatusOK, result.StatusCode) | ||||
| 		t.Equal("application/x-pem-file", result.Header.Get("Content-Type")) | ||||
| 		t.Equal(`attachment; filename="yggdrasil_session_pubkey.pem"`, result.Header.Get("Content-Disposition")) | ||||
| 		body, _ := io.ReadAll(result.Body) | ||||
| 		t.Equal(publicKey, body) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("in der format", func() { | ||||
| 		publicKey := []byte("mock public key in der format") | ||||
| 		t.Signer.On("GetPublicKey", "der").Return(publicKey, nil) | ||||
|  | ||||
| 		req := httptest.NewRequest("GET", "http://chrly/public-key.der", nil) | ||||
| 		w := httptest.NewRecorder() | ||||
|  | ||||
| 		t.App.Handler().ServeHTTP(w, req) | ||||
|  | ||||
| 		result := w.Result() | ||||
| 		t.Equal(http.StatusOK, result.StatusCode) | ||||
| 		t.Equal("application/octet-stream", result.Header.Get("Content-Type")) | ||||
| 		t.Equal(`attachment; filename="yggdrasil_session_pubkey.der"`, result.Header.Get("Content-Disposition")) | ||||
| 		body, _ := io.ReadAll(result.Body) | ||||
| 		t.Equal(publicKey, body) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("handle error", func() { | ||||
| 		t.Signer.On("GetPublicKey", "pem").Return(nil, errors.New("mock error")) | ||||
|  | ||||
| 		req := httptest.NewRequest("GET", "http://chrly/public-key.pem", nil) | ||||
| 		w := httptest.NewRecorder() | ||||
|  | ||||
| 		t.App.Handler().ServeHTTP(w, req) | ||||
|  | ||||
| 		result := w.Result() | ||||
| 		t.Equal(http.StatusInternalServerError, result.StatusCode) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func TestSignerApi(t *testing.T) { | ||||
| 	suite.Run(t, new(SignerApiTestSuite)) | ||||
| } | ||||
| @@ -2,13 +2,10 @@ package http | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"encoding/base64" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/gorilla/mux" | ||||
| 	"go.opentelemetry.io/otel/metric" | ||||
| @@ -17,24 +14,14 @@ import ( | ||||
| 	"ely.by/chrly/internal/db" | ||||
| 	"ely.by/chrly/internal/mojang" | ||||
| 	"ely.by/chrly/internal/otel" | ||||
| 	"ely.by/chrly/internal/utils" | ||||
| ) | ||||
|  | ||||
| var timeNow = time.Now | ||||
|  | ||||
| type ProfilesProvider interface { | ||||
| 	FindProfileByUsername(ctx context.Context, username string, allowProxy bool) (*db.Profile, error) | ||||
| } | ||||
|  | ||||
| // SignerService uses context because in the future we may separate this logic as an external microservice | ||||
| type SignerService interface { | ||||
| 	Sign(ctx context.Context, data string) (string, error) | ||||
| 	GetPublicKey(ctx context.Context, format string) (string, error) | ||||
| } | ||||
|  | ||||
| func NewSkinsystemApi( | ||||
| 	profilesProvider ProfilesProvider, | ||||
| 	signerService SignerService, | ||||
| 	texturesExtraParamName string, | ||||
| 	texturesExtraParamValue string, | ||||
| ) (*Skinsystem, error) { | ||||
| @@ -45,7 +32,6 @@ func NewSkinsystemApi( | ||||
|  | ||||
| 	return &Skinsystem{ | ||||
| 		ProfilesProvider:        profilesProvider, | ||||
| 		SignerService:           signerService, | ||||
| 		TexturesExtraParamName:  texturesExtraParamName, | ||||
| 		TexturesExtraParamValue: texturesExtraParamValue, | ||||
| 		metrics:                 metrics, | ||||
| @@ -54,7 +40,6 @@ func NewSkinsystemApi( | ||||
|  | ||||
| type Skinsystem struct { | ||||
| 	ProfilesProvider | ||||
| 	SignerService | ||||
| 	TexturesExtraParamName  string | ||||
| 	TexturesExtraParamValue string | ||||
| 	metrics                 *skinsystemApiMetrics | ||||
| @@ -68,12 +53,9 @@ func (s *Skinsystem) Handler() *mux.Router { | ||||
| 	// TODO: alias /capes/{username}? | ||||
| 	router.HandleFunc("/textures/{username}", s.texturesHandler).Methods(http.MethodGet) | ||||
| 	router.HandleFunc("/textures/signed/{username}", s.signedTexturesHandler).Methods(http.MethodGet) | ||||
| 	router.HandleFunc("/profile/{username}", s.profileHandler).Methods(http.MethodGet) | ||||
| 	// Legacy | ||||
| 	router.HandleFunc("/skins", s.legacySkinHandler).Methods(http.MethodGet) | ||||
| 	router.HandleFunc("/cloaks", s.legacyCapeHandler).Methods(http.MethodGet) | ||||
| 	// Utils | ||||
| 	router.HandleFunc("/signature-verification-key.{format:(?:pem|der)}", s.signatureVerificationKeyHandler).Methods(http.MethodGet) | ||||
|  | ||||
| 	return router | ||||
| } | ||||
| @@ -212,83 +194,6 @@ func (s *Skinsystem) signedTexturesHandler(resp http.ResponseWriter, req *http.R | ||||
| 	_, _ = resp.Write(responseJson) | ||||
| } | ||||
|  | ||||
| func (s *Skinsystem) profileHandler(resp http.ResponseWriter, req *http.Request) { | ||||
| 	s.metrics.ProfileRequest.Add(req.Context(), 1) | ||||
|  | ||||
| 	profile, err := s.ProfilesProvider.FindProfileByUsername(req.Context(), mux.Vars(req)["username"], true) | ||||
| 	if err != nil { | ||||
| 		apiServerError(resp, req, fmt.Errorf("unable to retrieve a profile: %w", err)) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if profile == nil { | ||||
| 		resp.WriteHeader(http.StatusNotFound) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	texturesPropContent := &mojang.TexturesProp{ | ||||
| 		Timestamp:   utils.UnixMillisecond(timeNow()), | ||||
| 		ProfileID:   profile.Uuid, | ||||
| 		ProfileName: profile.Username, | ||||
| 		Textures:    texturesFromProfile(profile), | ||||
| 	} | ||||
|  | ||||
| 	texturesPropValueJson, _ := json.Marshal(texturesPropContent) | ||||
| 	texturesPropEncodedValue := base64.StdEncoding.EncodeToString(texturesPropValueJson) | ||||
|  | ||||
| 	texturesProp := &mojang.Property{ | ||||
| 		Name:  "textures", | ||||
| 		Value: texturesPropEncodedValue, | ||||
| 	} | ||||
|  | ||||
| 	if req.URL.Query().Has("unsigned") && !getToBool(req.URL.Query().Get("unsigned")) { | ||||
| 		signature, err := s.SignerService.Sign(req.Context(), texturesProp.Value) | ||||
| 		if err != nil { | ||||
| 			apiServerError(resp, req, fmt.Errorf("unable to sign textures: %w", err)) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		texturesProp.Signature = signature | ||||
| 	} | ||||
|  | ||||
| 	profileResponse := &mojang.ProfileResponse{ | ||||
| 		Id:   profile.Uuid, | ||||
| 		Name: profile.Username, | ||||
| 		Props: []*mojang.Property{ | ||||
| 			texturesProp, | ||||
| 			{ | ||||
| 				Name:  s.TexturesExtraParamName, | ||||
| 				Value: s.TexturesExtraParamValue, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	responseJson, _ := json.Marshal(profileResponse) | ||||
| 	resp.Header().Set("Content-Type", "application/json") | ||||
| 	_, _ = resp.Write(responseJson) | ||||
| } | ||||
|  | ||||
| func (s *Skinsystem) signatureVerificationKeyHandler(resp http.ResponseWriter, req *http.Request) { | ||||
| 	s.metrics.SigningKeyRequest.Add(req.Context(), 1) | ||||
|  | ||||
| 	format := mux.Vars(req)["format"] | ||||
| 	publicKey, err := s.SignerService.GetPublicKey(req.Context(), format) | ||||
| 	if err != nil { | ||||
| 		apiServerError(resp, req, fmt.Errorf("unable to retrieve public key: %w", err)) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if format == "pem" { | ||||
| 		resp.Header().Set("Content-Type", "application/x-pem-file") | ||||
| 		resp.Header().Set("Content-Disposition", `attachment; filename="yggdrasil_session_pubkey.pem"`) | ||||
| 	} else { | ||||
| 		resp.Header().Set("Content-Type", "application/octet-stream") | ||||
| 		resp.Header().Set("Content-Disposition", `attachment; filename="yggdrasil_session_pubkey.der"`) | ||||
| 	} | ||||
|  | ||||
| 	_, _ = io.WriteString(resp, publicKey) | ||||
| } | ||||
|  | ||||
| func parseUsername(username string) string { | ||||
| 	return strings.TrimSuffix(username, ".png") | ||||
| } | ||||
| @@ -345,12 +250,6 @@ func newSkinsystemMetrics(meter metric.Meter) (*skinsystemApiMetrics, error) { | ||||
| 	m.SignedTexturesRequest, err = meter.Int64Counter("chrly.app.skinsystem.signed_textures.request", metric.WithUnit("{request}")) | ||||
| 	errors = multierr.Append(errors, err) | ||||
|  | ||||
| 	m.ProfileRequest, err = meter.Int64Counter("chrly.app.skinsystem.profile.request", metric.WithUnit("{request}")) | ||||
| 	errors = multierr.Append(errors, err) | ||||
|  | ||||
| 	m.SigningKeyRequest, err = meter.Int64Counter("chrly.app.skinsystem.signing_key.request", metric.WithUnit("{request}")) | ||||
| 	errors = multierr.Append(errors, err) | ||||
|  | ||||
| 	return m, errors | ||||
| } | ||||
|  | ||||
| @@ -361,6 +260,4 @@ type skinsystemApiMetrics struct { | ||||
| 	LegacyCapeRequest     metric.Int64Counter | ||||
| 	TexturesRequest       metric.Int64Counter | ||||
| 	SignedTexturesRequest metric.Int64Counter | ||||
| 	ProfileRequest        metric.Int64Counter | ||||
| 	SigningKeyRequest     metric.Int64Counter | ||||
| } | ||||
|   | ||||
| @@ -7,7 +7,6 @@ import ( | ||||
| 	"net/http" | ||||
| 	"net/http/httptest" | ||||
| 	"testing" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/stretchr/testify/mock" | ||||
| 	testify "github.com/stretchr/testify/require" | ||||
| @@ -30,27 +29,12 @@ func (m *ProfilesProviderMock) FindProfileByUsername(ctx context.Context, userna | ||||
| 	return result, args.Error(1) | ||||
| } | ||||
|  | ||||
| type SignerServiceMock struct { | ||||
| 	mock.Mock | ||||
| } | ||||
|  | ||||
| func (m *SignerServiceMock) Sign(ctx context.Context, data string) (string, error) { | ||||
| 	args := m.Called(ctx, data) | ||||
| 	return args.String(0), args.Error(1) | ||||
| } | ||||
|  | ||||
| func (m *SignerServiceMock) GetPublicKey(ctx context.Context, format string) (string, error) { | ||||
| 	args := m.Called(ctx, format) | ||||
| 	return args.String(0), args.Error(1) | ||||
| } | ||||
|  | ||||
| type SkinsystemTestSuite struct { | ||||
| 	suite.Suite | ||||
|  | ||||
| 	App *Skinsystem | ||||
|  | ||||
| 	ProfilesProvider *ProfilesProviderMock | ||||
| 	SignerService    *SignerServiceMock | ||||
| } | ||||
|  | ||||
| /******************** | ||||
| @@ -58,17 +42,10 @@ type SkinsystemTestSuite struct { | ||||
|  ********************/ | ||||
|  | ||||
| func (t *SkinsystemTestSuite) SetupSubTest() { | ||||
| 	timeNow = func() time.Time { | ||||
| 		CET, _ := time.LoadLocation("CET") | ||||
| 		return time.Date(2021, 02, 25, 01, 50, 23, 0, CET) | ||||
| 	} | ||||
|  | ||||
| 	t.ProfilesProvider = &ProfilesProviderMock{} | ||||
| 	t.SignerService = &SignerServiceMock{} | ||||
|  | ||||
| 	t.App, _ = NewSkinsystemApi( | ||||
| 		t.ProfilesProvider, | ||||
| 		t.SignerService, | ||||
| 		"texturesParamName", | ||||
| 		"texturesParamValue", | ||||
| 	) | ||||
| @@ -76,7 +53,6 @@ func (t *SkinsystemTestSuite) SetupSubTest() { | ||||
|  | ||||
| func (t *SkinsystemTestSuite) TearDownSubTest() { | ||||
| 	t.ProfilesProvider.AssertExpectations(t.T()) | ||||
| 	t.SignerService.AssertExpectations(t.T()) | ||||
| } | ||||
|  | ||||
| func (t *SkinsystemTestSuite) TestSkinHandler() { | ||||
| @@ -415,165 +391,6 @@ func (t *SkinsystemTestSuite) TestSignedTextures() { | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func (t *SkinsystemTestSuite) TestProfile() { | ||||
| 	t.Run("exists profile with skin and cape", func() { | ||||
| 		req := httptest.NewRequest("GET", "http://chrly/profile/mock_username", nil) | ||||
| 		w := httptest.NewRecorder() | ||||
|  | ||||
| 		// TODO: see the TODO about context above | ||||
| 		t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{ | ||||
| 			Uuid:      "mock-uuid", | ||||
| 			Username:  "mock_username", | ||||
| 			SkinUrl:   "https://example.com/skin.png", | ||||
| 			SkinModel: "slim", | ||||
| 			CapeUrl:   "https://example.com/cape.png", | ||||
| 		}, nil) | ||||
|  | ||||
| 		t.App.Handler().ServeHTTP(w, req) | ||||
|  | ||||
| 		result := w.Result() | ||||
| 		t.Equal(http.StatusOK, result.StatusCode) | ||||
| 		t.Equal("application/json", result.Header.Get("Content-Type")) | ||||
| 		body, _ := io.ReadAll(result.Body) | ||||
| 		t.JSONEq(`{ | ||||
| 			"id": "mock-uuid", | ||||
| 			"name": "mock_username", | ||||
| 			"properties": [ | ||||
| 				{ | ||||
| 					"name": "textures", | ||||
| 					"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6Im1vY2stdXVpZCIsInByb2ZpbGVOYW1lIjoibW9ja191c2VybmFtZSIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9za2luLnBuZyIsIm1ldGFkYXRhIjp7Im1vZGVsIjoic2xpbSJ9fSwiQ0FQRSI6eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tL2NhcGUucG5nIn19fQ==" | ||||
| 				}, | ||||
| 				{ | ||||
| 					"name": "texturesParamName", | ||||
| 					"value": "texturesParamValue" | ||||
| 				} | ||||
| 			] | ||||
| 		}`, string(body)) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("exists signed profile with skin", func() { | ||||
| 		req := httptest.NewRequest("GET", "http://chrly/profile/mock_username?unsigned=false", nil) | ||||
| 		w := httptest.NewRecorder() | ||||
|  | ||||
| 		t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{ | ||||
| 			Uuid:      "mock-uuid", | ||||
| 			Username:  "mock_username", | ||||
| 			SkinUrl:   "https://example.com/skin.png", | ||||
| 			SkinModel: "slim", | ||||
| 		}, nil) | ||||
| 		t.SignerService.On("Sign", mock.Anything, "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6Im1vY2stdXVpZCIsInByb2ZpbGVOYW1lIjoibW9ja191c2VybmFtZSIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9za2luLnBuZyIsIm1ldGFkYXRhIjp7Im1vZGVsIjoic2xpbSJ9fX19").Return("mock signature", nil) | ||||
|  | ||||
| 		t.App.Handler().ServeHTTP(w, req) | ||||
|  | ||||
| 		result := w.Result() | ||||
| 		t.Equal(http.StatusOK, result.StatusCode) | ||||
| 		t.Equal("application/json", result.Header.Get("Content-Type")) | ||||
| 		body, _ := io.ReadAll(result.Body) | ||||
| 		t.JSONEq(`{ | ||||
| 			"id": "mock-uuid", | ||||
| 			"name": "mock_username", | ||||
| 			"properties": [ | ||||
| 				{ | ||||
| 					"name": "textures", | ||||
|                     "signature": "mock signature", | ||||
| 					"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6Im1vY2stdXVpZCIsInByb2ZpbGVOYW1lIjoibW9ja191c2VybmFtZSIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9za2luLnBuZyIsIm1ldGFkYXRhIjp7Im1vZGVsIjoic2xpbSJ9fX19" | ||||
| 				}, | ||||
| 				{ | ||||
| 					"name": "texturesParamName", | ||||
| 					"value": "texturesParamValue" | ||||
| 				} | ||||
| 			] | ||||
| 		}`, string(body)) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("not exists profile", func() { | ||||
| 		req := httptest.NewRequest("GET", "http://chrly/profile/mock_username", nil) | ||||
| 		w := httptest.NewRecorder() | ||||
|  | ||||
| 		t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(nil, nil) | ||||
|  | ||||
| 		t.App.Handler().ServeHTTP(w, req) | ||||
|  | ||||
| 		result := w.Result() | ||||
| 		t.Equal(http.StatusNotFound, result.StatusCode) | ||||
| 		body, _ := io.ReadAll(result.Body) | ||||
| 		t.Empty(body) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("err from profiles provider", func() { | ||||
| 		req := httptest.NewRequest("GET", "http://chrly/profile/mock_username", nil) | ||||
| 		w := httptest.NewRecorder() | ||||
|  | ||||
| 		t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(nil, errors.New("mock error")) | ||||
|  | ||||
| 		t.App.Handler().ServeHTTP(w, req) | ||||
|  | ||||
| 		result := w.Result() | ||||
| 		t.Equal(http.StatusInternalServerError, result.StatusCode) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("err from textures signer", func() { | ||||
| 		req := httptest.NewRequest("GET", "http://chrly/profile/mock_username?unsigned=false", nil) | ||||
| 		w := httptest.NewRecorder() | ||||
|  | ||||
| 		t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{}, nil) | ||||
| 		t.SignerService.On("Sign", mock.Anything, mock.Anything).Return("", errors.New("mock error")) | ||||
|  | ||||
| 		t.App.Handler().ServeHTTP(w, req) | ||||
|  | ||||
| 		result := w.Result() | ||||
| 		t.Equal(http.StatusInternalServerError, result.StatusCode) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func (t *SkinsystemTestSuite) TestSignatureVerificationKey() { | ||||
| 	t.Run("in pem format", func() { | ||||
| 		publicKey := "mock public key in pem format" | ||||
| 		t.SignerService.On("GetPublicKey", mock.Anything, "pem").Return(publicKey, nil) | ||||
|  | ||||
| 		req := httptest.NewRequest("GET", "http://chrly/signature-verification-key.pem", nil) | ||||
| 		w := httptest.NewRecorder() | ||||
|  | ||||
| 		t.App.Handler().ServeHTTP(w, req) | ||||
|  | ||||
| 		result := w.Result() | ||||
| 		t.Equal(http.StatusOK, result.StatusCode) | ||||
| 		t.Equal("application/x-pem-file", result.Header.Get("Content-Type")) | ||||
| 		t.Equal(`attachment; filename="yggdrasil_session_pubkey.pem"`, result.Header.Get("Content-Disposition")) | ||||
| 		body, _ := io.ReadAll(result.Body) | ||||
| 		t.Equal(publicKey, string(body)) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("in der format", func() { | ||||
| 		publicKey := "mock public key in der format" | ||||
| 		t.SignerService.On("GetPublicKey", mock.Anything, "der").Return(publicKey, nil) | ||||
|  | ||||
| 		req := httptest.NewRequest("GET", "http://chrly/signature-verification-key.der", nil) | ||||
| 		w := httptest.NewRecorder() | ||||
|  | ||||
| 		t.App.Handler().ServeHTTP(w, req) | ||||
|  | ||||
| 		result := w.Result() | ||||
| 		t.Equal(http.StatusOK, result.StatusCode) | ||||
| 		t.Equal("application/octet-stream", result.Header.Get("Content-Type")) | ||||
| 		t.Equal(`attachment; filename="yggdrasil_session_pubkey.der"`, result.Header.Get("Content-Disposition")) | ||||
| 		body, _ := io.ReadAll(result.Body) | ||||
| 		t.Equal(publicKey, string(body)) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("handle error", func() { | ||||
| 		t.SignerService.On("GetPublicKey", mock.Anything, "pem").Return("", errors.New("mock error")) | ||||
|  | ||||
| 		req := httptest.NewRequest("GET", "http://chrly/signature-verification-key.pem", nil) | ||||
| 		w := httptest.NewRecorder() | ||||
|  | ||||
| 		t.App.Handler().ServeHTTP(w, req) | ||||
|  | ||||
| 		result := w.Result() | ||||
| 		t.Equal(http.StatusInternalServerError, result.StatusCode) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func TestSkinsystem(t *testing.T) { | ||||
| 	suite.Run(t, new(SkinsystemTestSuite)) | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user