mirror of
https://github.com/elyby/chrly.git
synced 2025-01-03 10:41:47 +05:30
Remove profiles endpoint and textures signing mechanism
This commit is contained in:
parent
62b6ac8083
commit
716ec8bd37
@ -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))
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user