Merge branch 'sign_textures'

This commit is contained in:
ErickSkrauch 2021-02-27 02:40:13 +01:00
commit d1d2c7ee6e
16 changed files with 1074 additions and 252 deletions

View File

@ -5,11 +5,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] - xxxx-xx-xx ## [Unreleased] - xxxx-xx-xx
### Added
- `/profile/{username}` endpoint, which returns a profile and its textures, equivalent of the Mojang's
[UUID -> Profile + Skin/Cape endpoint](https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape).
- `/signature-verification-key` endpoint, which returns the public key in `DER` format for signature verification.
### Fixed ### Fixed
- [#29](https://github.com/elyby/chrly/issues/29) If a previously cached UUID no longer exists, - [#29](https://github.com/elyby/chrly/issues/29) If a previously cached UUID no longer exists,
it will be invalidated and re-requested. it will be invalidated and re-requested.
- Use correct status code for error about empty response from Mojang's API. - Use correct status code for error about empty response from Mojang's API.
### Changed
- **BREAKING**: `/cloaks/{username}` and `/textures/{username}` endpoints will no longer return a cape if there are no
textures for the requested username.
- All endpoints are now returns `500` status code when an error occurred during request processing.
## [4.5.0] - 2020-05-01 ## [4.5.0] - 2020-05-01
### Added ### Added
- [#24](https://github.com/elyby/chrly/issues/24): Implemented a new strategy for the queue in the batch provider of - [#24](https://github.com/elyby/chrly/issues/24): Implemented a new strategy for the queue in the batch provider of

View File

@ -15,8 +15,9 @@ production ready.
## Installation ## Installation
You can easily install Chrly using [docker-compose](https://docs.docker.com/compose/). The configuration below (save You can easily install Chrly using [docker-compose](https://docs.docker.com/compose/). The configuration below (save
it as `docker-compose.yml`) can be used to start a Chrly server. It relies on `CHRLY_SECRET` environment variable it as `docker-compose.yml`) can be used to start a Chrly server. It relies on `CHRLY_SECRET` and `CHRLY_SIGNING_KEY`
that you must set before running `docker-compose up -d`. Other possible variables are described below. environment variables that you must set before running `docker-compose up -d`. Other possible variables are described
below.
```yml ```yml
version: '2' version: '2'
@ -33,6 +34,7 @@ services:
- "80:80" - "80:80"
environment: environment:
CHRLY_SECRET: replace_this_value_in_production CHRLY_SECRET: replace_this_value_in_production
CHRLY_SIGNING_KEY: base64:LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT3dJQkFBSkJBTmJVcFZDWmtNS3BmdllaMDhXM2x1bWRBYVl4TEJubVVEbHpIQlFIM0RwWWVmNVdDTzMyClREVTZmZUlKNThBMGxBeXdndFo0d3dpMmRHSE96LzFoQXZjQ0F3RUFBUUpBSXRheFNIVGU2UEtieUVVLzlweGoKT05kaFlSWXdWTExvNTZnbk1ZaGt5b0VxYWFNc2ZvdjhoaG9lcGtZWkJNdlpGQjJiRE9zUTJTYUorRTJlaUJPNApBUUloQVBzc1MwK0JSOXcwYk9kbWpHcW1kRTlOck41VUpRY09XMTNzMjkrNlF6VUJBaUVBMnZXT2VwQTVBcGl1CnBFQTNwd29HZGtWQ3JOU25uS2pEUXpEWEJucGQzL2NDSUVGTmQ5c1k0cVVHNEZXZFhONlJubVhMN1NqMHVaZkgKRE13enU4ckVNNXNCQWlFQWh2ZG9ETnFMbWJNZHEzYytGc1BTT2VMMWQyMVpwL0pLOGtiUHRGbUhOZjhDSVFEVgo2RlNaRHd2V2Z1eGFNN0JzeWNRT05rakRCVFBOdStscWN0SkJHbkJ2M0E9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=
redis: redis:
image: redis:4.0-32bit image: redis:4.0-32bit
@ -41,6 +43,11 @@ services:
- ./data/redis:/data - ./data/redis:/data
``` ```
**Tip**: to generate a value for the `CHRLY_SIGNING_KEY` use the command below and then join it with a `base64:` prefix.
```sh
openssl genrsa 4096 | base64 -w0
```
Chrly uses some volumes to persist storage for capes and Redis database. The configuration above mounts them to Chrly uses some volumes to persist storage for capes and Redis database. The configuration above mounts them to
the host machine to do not lose data on container recreations. the host machine to do not lose data on container recreations.
@ -48,11 +55,10 @@ the host machine to do not lose data on container recreations.
Application's configuration is based on the environment variables. You can adjust config by modifying `environment` key Application's configuration is based on the environment variables. You can adjust config by modifying `environment` key
inside your `docker-compose.yml` file. After value will have been changed, container should be stopped and recreated. inside your `docker-compose.yml` file. After value will have been changed, container should be stopped and recreated.
If environment variables have been changed, Docker will automatically recreate the container, so you only need to `stop` If environment variables have been changed, Docker will automatically recreate the container, so you only need to `up`
and `up` it: it again:
```sh ```sh
docker-compose stop app
docker-compose up -d app docker-compose up -d app
``` ```
@ -182,7 +188,7 @@ If something goes wrong, you can always access logs by executing `docker-compose
## Endpoints ## Endpoints
Each endpoint that accepts `username` as a part of an url takes it case insensitive. `.png` part can be omitted too. Each endpoint that accepts `username` as a part of an url takes it case-insensitive. The `.png` postfix can be omitted.
#### `GET /skins/{username}.png` #### `GET /skins/{username}.png`
@ -220,11 +226,71 @@ That request is handy in case when your server implements authentication for a g
operation) and you have to respond with hasJoined request with an actual user textures. You have to simply send request operation) and you have to respond with hasJoined request with an actual user textures. You have to simply send request
to the Chrly server and put the result in your hasJoined response. to the Chrly server and put the result in your hasJoined response.
#### `GET /profile/{username}`
This endpoint behaves exactly like the
[Mojang's UUID -> Profile + Skin/Cape endpoint](https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape), but using
a username instead of the UUID. Just like in the Mojang's API, you can append `?unsigned=false` part to URL to sign
the `textures` property. If the textures for the requested username aren't found, it'll request them through the
Mojang's API, but the Mojang's signature will be discarded and the textures will be re-signed using the signature key
for your Chrly instance.
Response example:
```json
{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "username",
"properties": [
{
"name": "textures",
"signature": "signature value",
"value": "base64 encoded value"
},
{
"name": "chrly",
"value": "how do you tame a horse in Minecraft?"
}
]
}
```
The base64 `value` string for the `textures` property decoded:
```json
{
"timestamp": 1614387238630,
"profileId": "0f657aa8bfbe415db7005750090d3af3",
"profileName": "username",
"textures": {
"SKIN": {
"url": "http://example.com/skin.png"
},
"CAPE": {
"url": "http://example.com/cape.png"
}
}
}
```
If username can't be found locally and can't be obtained from the Mojang's API, empty response with `204` status code
will be sent.
Note that this endpoint will try to use the UUID for the stored profile in the database. This is an edge case, related
to the situation where the user is available in the database but has no textures, which caused them to be retrieved
from the Mojang's API.
#### `GET /signature-verification-key`
This endpoint returns a public key that can be used to verify textures signatures. The key is provided in `DER` format,
so it can be used directly in the Authlib, without modifying the signature checking algorithm.
#### `GET /textures/signed/{username}` #### `GET /textures/signed/{username}`
Actually, it's [Ely.by](http://ely.by) feature called [Server Skins System](http://ely.by/server-skins-system), but if Actually, this is the [Ely.by](https://ely.by)'s feature called
you have your own source of Mojang's signatures, then you can pass it with textures and it'll be displayed in response [Server Skins System](https://ely.by/server-skins-system), but if you have your own source of Mojang's signatures,
of this endpoint. Received response should be directly sent to the client without any modification via game server API. then you can pass it with textures and it'll be displayed in response of this endpoint. Received response should be
directly sent to the client without any modification via game server API.
Response example: Response example:

View File

@ -11,6 +11,7 @@ func New() (*di.Container, error) {
mojangTextures, mojangTextures,
handlers, handlers,
server, server,
signer,
) )
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -104,6 +104,7 @@ func newSkinsystemHandler(
skinsRepository SkinsRepository, skinsRepository SkinsRepository,
capesRepository CapesRepository, capesRepository CapesRepository,
mojangTexturesProvider MojangTexturesProvider, mojangTexturesProvider MojangTexturesProvider,
texturesSigner TexturesSigner,
) *mux.Router { ) *mux.Router {
config.SetDefault("textures.extra_param_name", "chrly") config.SetDefault("textures.extra_param_name", "chrly")
config.SetDefault("textures.extra_param_value", "how do you tame a horse in Minecraft?") config.SetDefault("textures.extra_param_value", "how do you tame a horse in Minecraft?")
@ -113,14 +114,14 @@ func newSkinsystemHandler(
SkinsRepo: skinsRepository, SkinsRepo: skinsRepository,
CapesRepo: capesRepository, CapesRepo: capesRepository,
MojangTexturesProvider: mojangTexturesProvider, MojangTexturesProvider: mojangTexturesProvider,
TexturesSigner: texturesSigner,
TexturesExtraParamName: config.GetString("textures.extra_param_name"), TexturesExtraParamName: config.GetString("textures.extra_param_name"),
TexturesExtraParamValue: config.GetString("textures.extra_param_value"), TexturesExtraParamValue: config.GetString("textures.extra_param_value"),
}).Handler() }).Handler()
} }
func newApiHandler(emitter Emitter, skinsRepository SkinsRepository) *mux.Router { func newApiHandler(skinsRepository SkinsRepository) *mux.Router {
return (&Api{ return (&Api{
Emitter: emitter,
SkinsRepo: skinsRepository, SkinsRepo: skinsRepository,
}).Handler() }).Handler()
} }

View File

@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"runtime/debug"
"time" "time"
"github.com/getsentry/raven-go" "github.com/getsentry/raven-go"
@ -42,13 +43,26 @@ func newServer(params serverParams) *http.Server {
params.Config.SetDefault("server.host", "") params.Config.SetDefault("server.host", "")
params.Config.SetDefault("server.port", 80) params.Config.SetDefault("server.port", 80)
handler := params.Handler var handler http.Handler
if params.Sentry != nil { if params.Sentry != nil {
// raven.Recoverer uses DefaultClient and nothing can be done about it // raven.Recoverer uses DefaultClient and nothing can be done about it
// To avoid code duplication, if the Sentry service is successfully initiated, // To avoid code duplication, if the Sentry service is successfully initiated,
// it will also replace DefaultClient, so raven.Recoverer will work with the instance // it will also replace DefaultClient, so raven.Recoverer will work with the instance
// created in the application constructor // created in the application constructor
handler = raven.Recoverer(handler) handler = raven.Recoverer(params.Handler)
} else {
// Raven's Recoverer is prints the stacktrace and sets the corresponding status itself.
// But there is no magic and if you don't define a panic handler, Mux will just reset the connection
handler = http.HandlerFunc(func(request http.ResponseWriter, response *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
debug.PrintStack() // TODO: colorize output
request.WriteHeader(http.StatusInternalServerError)
}
}()
params.Handler.ServeHTTP(request, response)
})
} }
address := fmt.Sprintf("%s:%d", params.Config.GetString("server.host"), params.Config.GetInt("server.port")) address := fmt.Sprintf("%s:%d", params.Config.GetString("server.host"), params.Config.GetInt("server.port"))

48
di/signer.go Normal file
View File

@ -0,0 +1,48 @@
package di
import (
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"github.com/elyby/chrly/http"
. "github.com/elyby/chrly/signer"
"strings"
"github.com/goava/di"
"github.com/spf13/viper"
)
var signer = di.Options(
di.Provide(newTexturesSigner,
di.As(new(http.TexturesSigner)),
),
)
func newTexturesSigner(config *viper.Viper) (*Signer, error) {
keyStr := config.GetString("chrly.signing.key")
if keyStr == "" {
return nil, errors.New("chrly.signing.key must be set in order to sign textures")
}
var keyBytes []byte
if strings.HasPrefix(keyStr, "base64:") {
base64Value := keyStr[7:]
decodedKey, err := base64.URLEncoding.DecodeString(base64Value)
if err != nil {
return nil, err
}
keyBytes = decodedKey
} else {
keyBytes = []byte(keyStr)
}
rawPem, _ := pem.Decode(keyBytes)
key, err := x509.ParsePKCS1PrivateKey(rawPem.Bytes)
if err != nil {
return nil, err
}
return &Signer{Key: key}, nil
}

View File

@ -43,7 +43,6 @@ func init() {
} }
type Api struct { type Api struct {
Emitter
SkinsRepo SkinsRepository SkinsRepo SkinsRepository
} }
@ -68,9 +67,7 @@ func (ctx *Api) postSkinHandler(resp http.ResponseWriter, req *http.Request) {
record, err := ctx.findIdentityOrCleanup(identityId, username) record, err := ctx.findIdentityOrCleanup(identityId, username)
if err != nil { if err != nil {
ctx.Emit("skinsystem:error", fmt.Errorf("error on requesting a skin from the repository: %w", err)) panic(err)
apiServerError(resp)
return
} }
if record == nil { if record == nil {
@ -94,9 +91,7 @@ func (ctx *Api) postSkinHandler(resp http.ResponseWriter, req *http.Request) {
err = ctx.SkinsRepo.SaveSkin(record) err = ctx.SkinsRepo.SaveSkin(record)
if err != nil { if err != nil {
ctx.Emit("skinsystem:error", fmt.Errorf("unable to save record to the repository: %w", err)) panic(err)
apiServerError(resp)
return
} }
resp.WriteHeader(http.StatusCreated) resp.WriteHeader(http.StatusCreated)
@ -116,9 +111,7 @@ func (ctx *Api) deleteSkinByUsernameHandler(resp http.ResponseWriter, req *http.
func (ctx *Api) deleteSkin(skin *model.Skin, err error, resp http.ResponseWriter) { func (ctx *Api) deleteSkin(skin *model.Skin, err error, resp http.ResponseWriter) {
if err != nil { if err != nil {
ctx.Emit("skinsystem:error", fmt.Errorf("unable to find skin info from the repository: %w", err)) panic(err)
apiServerError(resp)
return
} }
if skin == nil { if skin == nil {
@ -128,9 +121,7 @@ func (ctx *Api) deleteSkin(skin *model.Skin, err error, resp http.ResponseWriter
err = ctx.SkinsRepo.RemoveSkinByUserId(skin.UserId) err = ctx.SkinsRepo.RemoveSkinByUserId(skin.UserId)
if err != nil { if err != nil {
ctx.Emit("skinsystem:error", fmt.Errorf("cannot delete skin by error: %w", err)) panic(err)
apiServerError(resp)
return
} }
resp.WriteHeader(http.StatusNoContent) resp.WriteHeader(http.StatusNoContent)

View File

@ -28,7 +28,6 @@ type apiTestSuite struct {
App *Api App *Api
SkinsRepository *skinsRepositoryMock SkinsRepository *skinsRepositoryMock
Emitter *emitterMock
} }
/******************** /********************
@ -37,17 +36,14 @@ type apiTestSuite struct {
func (suite *apiTestSuite) SetupTest() { func (suite *apiTestSuite) SetupTest() {
suite.SkinsRepository = &skinsRepositoryMock{} suite.SkinsRepository = &skinsRepositoryMock{}
suite.Emitter = &emitterMock{}
suite.App = &Api{ suite.App = &Api{
SkinsRepo: suite.SkinsRepository, SkinsRepo: suite.SkinsRepository,
Emitter: suite.Emitter,
} }
} }
func (suite *apiTestSuite) TearDownTest() { func (suite *apiTestSuite) TearDownTest() {
suite.SkinsRepository.AssertExpectations(suite.T()) suite.SkinsRepository.AssertExpectations(suite.T())
suite.Emitter.AssertExpectations(suite.T())
} }
func (suite *apiTestSuite) RunSubTest(name string, subTest func()) { func (suite *apiTestSuite) RunSubTest(name string, subTest func()) {
@ -72,6 +68,7 @@ type postSkinTestCase struct {
Name string Name string
Form io.Reader Form io.Reader
BeforeTest func(suite *apiTestSuite) BeforeTest func(suite *apiTestSuite)
PanicErr string
AfterTest func(suite *apiTestSuite, response *http.Response) AfterTest func(suite *apiTestSuite, response *http.Response)
} }
@ -198,6 +195,22 @@ var postSkinTestsCases = []*postSkinTestCase{
}, },
{ {
Name: "Handle an error when loading the data from the repository", Name: "Handle an error when loading the data from the repository",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"changed_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(nil, errors.New("can't find skin by user id"))
},
PanicErr: "can't find skin by user id",
},
{
Name: "Handle an error when saving the data into the repository",
Form: bytes.NewBufferString(url.Values{ Form: bytes.NewBufferString(url.Values{
"identityId": {"1"}, "identityId": {"1"},
"username": {"mock_username"}, "username": {"mock_username"},
@ -209,43 +222,9 @@ var postSkinTestsCases = []*postSkinTestCase{
}.Encode()), }.Encode()),
BeforeTest: func(suite *apiTestSuite) { BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil) suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil)
err := errors.New("mock error") suite.SkinsRepository.On("SaveSkin", mock.Anything).Return(errors.New("can't save textures"))
suite.SkinsRepository.On("SaveSkin", mock.Anything).Return(err)
suite.Emitter.On("Emit", "skinsystem:error", mock.MatchedBy(func(cErr error) bool {
return cErr.Error() == "unable to save record to the repository: mock error" &&
errors.Is(cErr, err)
})).Once()
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(500, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Handle an error when saving the data into the repository",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"changed_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
err := errors.New("mock error")
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(nil, err)
suite.Emitter.On("Emit", "skinsystem:error", mock.MatchedBy(func(cErr error) bool {
return cErr.Error() == "error on requesting a skin from the repository: mock error" &&
errors.Is(cErr, err)
})).Once()
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(500, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
}, },
PanicErr: "can't save textures",
}, },
} }
@ -258,9 +237,14 @@ func (suite *apiTestSuite) TestPostSkin() {
req.Header.Add("Content-Type", "application/x-www-form-urlencoded") req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder() w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req) if testCase.PanicErr != "" {
suite.PanicsWithError(testCase.PanicErr, func() {
testCase.AfterTest(suite, w.Result()) suite.App.Handler().ServeHTTP(w, req)
})
} else {
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
}
}) })
} }

View File

@ -36,7 +36,7 @@ func StartServer(server *http.Server, logger slf.Logger) {
go func() { go func() {
s := waitForExitSignal() s := waitForExitSignal()
logger.Info("Got signal: :signal, starting graceful shutdown", wd.StringParam("signal", s.String())) logger.Info("Got signal: :signal, starting graceful shutdown", wd.StringParam("signal", s.String()))
server.Shutdown(context.Background()) _ = server.Shutdown(context.Background())
logger.Info("Graceful shutdown succeed, exiting", wd.StringParam("signal", s.String())) logger.Info("Graceful shutdown succeed, exiting", wd.StringParam("signal", s.String()))
close(done) close(done)
}() }()
@ -135,7 +135,3 @@ func apiNotFound(resp http.ResponseWriter, reason string) {
}) })
_, _ = resp.Write(result) _, _ = resp.Write(result)
} }
func apiServerError(resp http.ResponseWriter) {
resp.WriteHeader(http.StatusInternalServerError)
}

View File

@ -1,10 +1,15 @@
package http package http
import ( import (
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json" "encoding/json"
"github.com/elyby/chrly/utils"
"io" "io"
"net/http" "net/http"
"strings" "strings"
"time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
@ -12,6 +17,8 @@ import (
"github.com/elyby/chrly/model" "github.com/elyby/chrly/model"
) )
var timeNow = time.Now
type SkinsRepository interface { type SkinsRepository interface {
FindSkinByUsername(username string) (*model.Skin, error) FindSkinByUsername(username string) (*model.Skin, error)
FindSkinByUserId(id int) (*model.Skin, error) FindSkinByUserId(id int) (*model.Skin, error)
@ -28,15 +35,30 @@ type MojangTexturesProvider interface {
GetForUsername(username string) (*mojang.SignedTexturesResponse, error) GetForUsername(username string) (*mojang.SignedTexturesResponse, error)
} }
type TexturesSigner interface {
SignTextures(textures string) (string, error)
GetPublicKey() (*rsa.PublicKey, error)
}
type Skinsystem struct { type Skinsystem struct {
Emitter Emitter
SkinsRepo SkinsRepository SkinsRepo SkinsRepository
CapesRepo CapesRepository CapesRepo CapesRepository
MojangTexturesProvider MojangTexturesProvider MojangTexturesProvider MojangTexturesProvider
TexturesSigner TexturesSigner
TexturesExtraParamName string TexturesExtraParamName string
TexturesExtraParamValue string TexturesExtraParamValue string
} }
type profile struct {
Id string
Username string
Textures *mojang.TexturesResponse
CapeFile io.Reader
MojangTextures string
MojangSignature string
}
func (ctx *Skinsystem) Handler() *mux.Router { func (ctx *Skinsystem) Handler() *mux.Router {
router := mux.NewRouter().StrictSlash(true) router := mux.NewRouter().StrictSlash(true)
@ -44,40 +66,28 @@ func (ctx *Skinsystem) Handler() *mux.Router {
router.HandleFunc("/cloaks/{username}", ctx.capeHandler).Methods(http.MethodGet).Name("cloaks") router.HandleFunc("/cloaks/{username}", ctx.capeHandler).Methods(http.MethodGet).Name("cloaks")
router.HandleFunc("/textures/{username}", ctx.texturesHandler).Methods(http.MethodGet) router.HandleFunc("/textures/{username}", ctx.texturesHandler).Methods(http.MethodGet)
router.HandleFunc("/textures/signed/{username}", ctx.signedTexturesHandler).Methods(http.MethodGet) router.HandleFunc("/textures/signed/{username}", ctx.signedTexturesHandler).Methods(http.MethodGet)
router.HandleFunc("/profile/{username}", ctx.profileHandler).Methods(http.MethodGet)
// Legacy // Legacy
router.HandleFunc("/skins", ctx.skinGetHandler).Methods(http.MethodGet) router.HandleFunc("/skins", ctx.skinGetHandler).Methods(http.MethodGet)
router.HandleFunc("/cloaks", ctx.capeGetHandler).Methods(http.MethodGet) router.HandleFunc("/cloaks", ctx.capeGetHandler).Methods(http.MethodGet)
// Utils
router.HandleFunc("/signature-verification-key", ctx.signatureVerificationKeyHandler).Methods(http.MethodGet)
return router return router
} }
func (ctx *Skinsystem) skinHandler(response http.ResponseWriter, request *http.Request) { func (ctx *Skinsystem) skinHandler(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"]) profile, err := ctx.getProfile(request, true)
rec, err := ctx.SkinsRepo.FindSkinByUsername(username) if err != nil {
if err == nil && rec != nil && rec.SkinId != 0 { panic(err)
http.Redirect(response, request, rec.Url, 301)
return
} }
mojangTextures, err := ctx.MojangTexturesProvider.GetForUsername(username) if profile == nil || profile.Textures == nil || profile.Textures.Skin == nil {
if err != nil || mojangTextures == nil {
response.WriteHeader(http.StatusNotFound) response.WriteHeader(http.StatusNotFound)
return return
} }
texturesProp, _ := mojangTextures.DecodeTextures() http.Redirect(response, request, profile.Textures.Skin.Url, 301)
if texturesProp == nil {
response.WriteHeader(http.StatusNotFound)
return
}
skin := texturesProp.Textures.Skin
if skin == nil {
response.WriteHeader(http.StatusNotFound)
return
}
http.Redirect(response, request, skin.Url, 301)
} }
func (ctx *Skinsystem) skinGetHandler(response http.ResponseWriter, request *http.Request) { func (ctx *Skinsystem) skinGetHandler(response http.ResponseWriter, request *http.Request) {
@ -88,39 +98,27 @@ func (ctx *Skinsystem) skinGetHandler(response http.ResponseWriter, request *htt
} }
mux.Vars(request)["username"] = username mux.Vars(request)["username"] = username
mux.Vars(request)["converted"] = "1"
ctx.skinHandler(response, request) ctx.skinHandler(response, request)
} }
func (ctx *Skinsystem) capeHandler(response http.ResponseWriter, request *http.Request) { func (ctx *Skinsystem) capeHandler(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"]) profile, err := ctx.getProfile(request, true)
rec, err := ctx.CapesRepo.FindCapeByUsername(username) if err != nil {
if err == nil && rec != nil { panic(err)
}
if profile == nil || profile.Textures == nil || (profile.CapeFile == nil && profile.Textures.Cape == nil) {
response.WriteHeader(http.StatusNotFound)
return
}
if profile.CapeFile == nil {
http.Redirect(response, request, profile.Textures.Cape.Url, 301)
} else {
request.Header.Set("Content-Type", "image/png") request.Header.Set("Content-Type", "image/png")
_, _ = io.Copy(response, rec.File) _, _ = io.Copy(response, profile.CapeFile)
return
} }
mojangTextures, err := ctx.MojangTexturesProvider.GetForUsername(username)
if err != nil || mojangTextures == nil {
response.WriteHeader(http.StatusNotFound)
return
}
texturesProp, _ := mojangTextures.DecodeTextures()
if texturesProp == nil {
response.WriteHeader(http.StatusNotFound)
return
}
cape := texturesProp.Textures.Cape
if cape == nil {
response.WriteHeader(http.StatusNotFound)
return
}
http.Redirect(response, request, cape.Url, 301)
} }
func (ctx *Skinsystem) capeGetHandler(response http.ResponseWriter, request *http.Request) { func (ctx *Skinsystem) capeGetHandler(response http.ResponseWriter, request *http.Request) {
@ -131,104 +129,219 @@ func (ctx *Skinsystem) capeGetHandler(response http.ResponseWriter, request *htt
} }
mux.Vars(request)["username"] = username mux.Vars(request)["username"] = username
mux.Vars(request)["converted"] = "1"
ctx.capeHandler(response, request) ctx.capeHandler(response, request)
} }
func (ctx *Skinsystem) texturesHandler(response http.ResponseWriter, request *http.Request) { func (ctx *Skinsystem) texturesHandler(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"]) profile, err := ctx.getProfile(request, true)
if err != nil {
var textures *mojang.TexturesResponse panic(err)
skin, skinErr := ctx.SkinsRepo.FindSkinByUsername(username)
cape, capeErr := ctx.CapesRepo.FindCapeByUsername(username)
if (skinErr == nil && skin != nil && skin.SkinId != 0) || (capeErr == nil && cape != nil) {
textures = &mojang.TexturesResponse{}
if skinErr == nil && skin != nil && skin.SkinId != 0 {
skinTextures := &mojang.SkinTexturesResponse{
Url: skin.Url,
}
if skin.IsSlim {
skinTextures.Metadata = &mojang.SkinTexturesMetadata{
Model: "slim",
}
}
textures.Skin = skinTextures
}
if capeErr == nil && cape != nil {
textures.Cape = &mojang.CapeTexturesResponse{
// Use statically http since the application doesn't support TLS
Url: "http://" + request.Host + "/cloaks/" + username,
}
}
} else {
mojangTextures, err := ctx.MojangTexturesProvider.GetForUsername(username)
if err != nil || mojangTextures == nil {
response.WriteHeader(http.StatusNoContent)
return
}
texturesProp, _ := mojangTextures.DecodeTextures()
if texturesProp == nil {
response.WriteHeader(http.StatusNoContent)
return
}
textures = texturesProp.Textures
if textures.Skin == nil && textures.Cape == nil {
response.WriteHeader(http.StatusNoContent)
return
}
} }
responseData, _ := json.Marshal(textures) if profile == nil || profile.Textures == nil || (profile.Textures.Skin == nil && profile.Textures.Cape == nil) {
response.WriteHeader(http.StatusNoContent)
return
}
responseData, _ := json.Marshal(profile.Textures)
response.Header().Set("Content-Type", "application/json") response.Header().Set("Content-Type", "application/json")
_, _ = response.Write(responseData) _, _ = response.Write(responseData)
} }
func (ctx *Skinsystem) signedTexturesHandler(response http.ResponseWriter, request *http.Request) { func (ctx *Skinsystem) signedTexturesHandler(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"]) profile, err := ctx.getProfile(request, request.URL.Query().Get("proxy") != "")
if err != nil {
var responseData *mojang.SignedTexturesResponse panic(err)
rec, err := ctx.SkinsRepo.FindSkinByUsername(username)
if err == nil && rec != nil && rec.SkinId != 0 && rec.MojangTextures != "" {
responseData = &mojang.SignedTexturesResponse{
Id: strings.Replace(rec.Uuid, "-", "", -1),
Name: rec.Username,
Props: []*mojang.Property{
{
Name: "textures",
Signature: rec.MojangSignature,
Value: rec.MojangTextures,
},
},
}
} else if request.URL.Query().Get("proxy") != "" {
mojangTextures, err := ctx.MojangTexturesProvider.GetForUsername(username)
if err == nil && mojangTextures != nil {
responseData = mojangTextures
}
} }
if responseData == nil { if profile == nil || profile.MojangTextures == "" {
response.WriteHeader(http.StatusNoContent) response.WriteHeader(http.StatusNoContent)
return return
} }
responseData.Props = append(responseData.Props, &mojang.Property{ profileResponse := &mojang.SignedTexturesResponse{
Name: ctx.TexturesExtraParamName, Id: profile.Id,
Value: ctx.TexturesExtraParamValue, Name: profile.Username,
}) Props: []*mojang.Property{
{
Name: "textures",
Signature: profile.MojangSignature,
Value: profile.MojangTextures,
},
{
Name: ctx.TexturesExtraParamName,
Value: ctx.TexturesExtraParamValue,
},
},
}
responseJson, _ := json.Marshal(responseData) responseJson, _ := json.Marshal(profileResponse)
response.Header().Set("Content-Type", "application/json") response.Header().Set("Content-Type", "application/json")
_, _ = response.Write(responseJson) _, _ = response.Write(responseJson)
} }
func (ctx *Skinsystem) profileHandler(response http.ResponseWriter, request *http.Request) {
profile, err := ctx.getProfile(request, true)
if err != nil {
panic(err)
}
if profile == nil {
response.WriteHeader(http.StatusNoContent)
return
}
texturesPropContent := &mojang.TexturesProp{
Timestamp: utils.UnixMillisecond(timeNow()),
ProfileID: profile.Id,
ProfileName: profile.Username,
Textures: profile.Textures,
}
texturesPropValueJson, _ := json.Marshal(texturesPropContent)
texturesPropEncodedValue := base64.StdEncoding.EncodeToString(texturesPropValueJson)
texturesProp := &mojang.Property{
Name: "textures",
Value: texturesPropEncodedValue,
}
if request.URL.Query().Get("unsigned") == "false" {
signature, err := ctx.TexturesSigner.SignTextures(texturesProp.Value)
if err != nil {
panic(err)
}
texturesProp.Signature = signature
}
profileResponse := &mojang.SignedTexturesResponse{
Id: profile.Id,
Name: profile.Username,
Props: []*mojang.Property{
texturesProp,
{
Name: ctx.TexturesExtraParamName,
Value: ctx.TexturesExtraParamValue,
},
},
}
responseJson, _ := json.Marshal(profileResponse)
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write(responseJson)
}
func (ctx *Skinsystem) signatureVerificationKeyHandler(response http.ResponseWriter, request *http.Request) {
publicKey, err := ctx.TexturesSigner.GetPublicKey()
if err != nil {
panic(err)
}
asn1Bytes, err := x509.MarshalPKIXPublicKey(publicKey)
if err != nil {
panic(err)
}
_, _ = response.Write(asn1Bytes)
response.Header().Set("Content-Type", "application/octet-stream")
response.Header().Set("Content-Disposition", "attachment; filename=\"yggdrasil_session_pubkey.der\"")
}
// TODO: in v5 should be extracted into some ProfileProvider interface,
// which will encapsulate all logics, declared in this method
func (ctx *Skinsystem) getProfile(request *http.Request, proxy bool) (*profile, error) {
username := parseUsername(mux.Vars(request)["username"])
skin, err := ctx.SkinsRepo.FindSkinByUsername(username)
if err != nil {
return nil, err
}
profile := &profile{
Id: "",
Username: "",
Textures: &mojang.TexturesResponse{}, // Field must be initialized to avoid "null" after json encoding
CapeFile: nil,
MojangTextures: "",
MojangSignature: "",
}
if skin != nil {
profile.Id = strings.Replace(skin.Uuid, "-", "", -1)
profile.Username = skin.Username
}
if skin != nil && skin.SkinId != 0 {
profile.Textures.Skin = &mojang.SkinTexturesResponse{
Url: skin.Url,
}
if skin.IsSlim {
profile.Textures.Skin.Metadata = &mojang.SkinTexturesMetadata{
Model: "slim",
}
}
cape, _ := ctx.CapesRepo.FindCapeByUsername(username)
if cape != nil {
profile.CapeFile = cape.File
profile.Textures.Cape = &mojang.CapeTexturesResponse{
// Use statically http since the application doesn't support TLS
Url: "http://" + request.Host + "/cloaks/" + username,
}
}
profile.MojangTextures = skin.MojangTextures
profile.MojangSignature = skin.MojangSignature
} else if proxy {
mojangProfile, err := ctx.MojangTexturesProvider.GetForUsername(username)
// If we at least know something about a user,
// than we can ignore an error and return profile without textures
if err != nil && profile.Id != "" {
return profile, nil
}
if err != nil || mojangProfile == nil {
return nil, err
}
decodedTextures, err := mojangProfile.DecodeTextures()
if err != nil {
return nil, err
}
// There might be no textures property
if decodedTextures != nil {
profile.Textures = decodedTextures.Textures
}
var texturesProp *mojang.Property
for _, prop := range mojangProfile.Props {
if prop.Name == "textures" {
texturesProp = prop
break
}
}
if texturesProp != nil {
profile.MojangTextures = texturesProp.Value
profile.MojangSignature = texturesProp.Signature
}
// If user id is unknown at this point, then use values from Mojang profile
if profile.Id == "" {
profile.Id = mojangProfile.Id
profile.Username = mojangProfile.Name
}
} else {
return nil, nil
}
return profile, nil
}
func parseUsername(username string) string { func parseUsername(username string) string {
return strings.TrimSuffix(username, ".png") return strings.TrimSuffix(username, ".png")
} }

View File

@ -2,6 +2,10 @@ package http
import ( import (
"bytes" "bytes"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"image" "image"
"image/png" "image/png"
"io/ioutil" "io/ioutil"
@ -89,6 +93,25 @@ func (m *mojangTexturesProviderMock) GetForUsername(username string) (*mojang.Si
return result, args.Error(1) return result, args.Error(1)
} }
type texturesSignerMock struct {
mock.Mock
}
func (m *texturesSignerMock) SignTextures(textures string) (string, error) {
args := m.Called(textures)
return args.String(0), args.Error(1)
}
func (m *texturesSignerMock) GetPublicKey() (*rsa.PublicKey, error) {
args := m.Called()
var publicKey *rsa.PublicKey
if casted, ok := args.Get(0).(*rsa.PublicKey); ok {
publicKey = casted
}
return publicKey, args.Error(1)
}
type skinsystemTestSuite struct { type skinsystemTestSuite struct {
suite.Suite suite.Suite
@ -97,6 +120,7 @@ type skinsystemTestSuite struct {
SkinsRepository *skinsRepositoryMock SkinsRepository *skinsRepositoryMock
CapesRepository *capesRepositoryMock CapesRepository *capesRepositoryMock
MojangTexturesProvider *mojangTexturesProviderMock MojangTexturesProvider *mojangTexturesProviderMock
TexturesSigner *texturesSignerMock
Emitter *emitterMock Emitter *emitterMock
} }
@ -105,15 +129,22 @@ type skinsystemTestSuite struct {
********************/ ********************/
func (suite *skinsystemTestSuite) SetupTest() { func (suite *skinsystemTestSuite) SetupTest() {
timeNow = func() time.Time {
CET, _ := time.LoadLocation("CET")
return time.Date(2021, 02, 25, 01, 50, 23, 0, CET)
}
suite.SkinsRepository = &skinsRepositoryMock{} suite.SkinsRepository = &skinsRepositoryMock{}
suite.CapesRepository = &capesRepositoryMock{} suite.CapesRepository = &capesRepositoryMock{}
suite.MojangTexturesProvider = &mojangTexturesProviderMock{} suite.MojangTexturesProvider = &mojangTexturesProviderMock{}
suite.TexturesSigner = &texturesSignerMock{}
suite.Emitter = &emitterMock{} suite.Emitter = &emitterMock{}
suite.App = &Skinsystem{ suite.App = &Skinsystem{
SkinsRepo: suite.SkinsRepository, SkinsRepo: suite.SkinsRepository,
CapesRepo: suite.CapesRepository, CapesRepo: suite.CapesRepository,
MojangTexturesProvider: suite.MojangTexturesProvider, MojangTexturesProvider: suite.MojangTexturesProvider,
TexturesSigner: suite.TexturesSigner,
Emitter: suite.Emitter, Emitter: suite.Emitter,
TexturesExtraParamName: "texturesParamName", TexturesExtraParamName: "texturesParamName",
TexturesExtraParamValue: "texturesParamValue", TexturesExtraParamValue: "texturesParamValue",
@ -124,6 +155,7 @@ func (suite *skinsystemTestSuite) TearDownTest() {
suite.SkinsRepository.AssertExpectations(suite.T()) suite.SkinsRepository.AssertExpectations(suite.T())
suite.CapesRepository.AssertExpectations(suite.T()) suite.CapesRepository.AssertExpectations(suite.T())
suite.MojangTexturesProvider.AssertExpectations(suite.T()) suite.MojangTexturesProvider.AssertExpectations(suite.T())
suite.TexturesSigner.AssertExpectations(suite.T())
suite.Emitter.AssertExpectations(suite.T()) suite.Emitter.AssertExpectations(suite.T())
} }
@ -144,6 +176,7 @@ func TestSkinsystem(t *testing.T) {
type skinsystemTestCase struct { type skinsystemTestCase struct {
Name string Name string
BeforeTest func(suite *skinsystemTestSuite) BeforeTest func(suite *skinsystemTestSuite)
PanicErr string
AfterTest func(suite *skinsystemTestSuite, response *http.Response) AfterTest func(suite *skinsystemTestSuite, response *http.Response)
} }
@ -156,6 +189,7 @@ var skinsTestsCases = []*skinsystemTestCase{
Name: "Username exists in the local storage", Name: "Username exists in the local storage",
BeforeTest: func(suite *skinsystemTestSuite) { BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil) suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
}, },
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(301, response.StatusCode) suite.Equal(301, response.StatusCode)
@ -203,6 +237,13 @@ var skinsTestsCases = []*skinsystemTestCase{
suite.Equal(404, response.StatusCode) suite.Equal(404, response.StatusCode)
}, },
}, },
{
Name: "Receive an error from the SkinsRepository",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, errors.New("skins repository error"))
},
PanicErr: "skins repository error",
},
} }
func (suite *skinsystemTestSuite) TestSkin() { func (suite *skinsystemTestSuite) TestSkin() {
@ -213,14 +254,20 @@ func (suite *skinsystemTestSuite) TestSkin() {
req := httptest.NewRequest("GET", "http://chrly/skins/mock_username", nil) req := httptest.NewRequest("GET", "http://chrly/skins/mock_username", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req) if testCase.PanicErr != "" {
suite.PanicsWithError(testCase.PanicErr, func() {
testCase.AfterTest(suite, w.Result()) suite.App.Handler().ServeHTTP(w, req)
})
} else {
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
}
}) })
} }
suite.RunSubTest("Pass username with png extension", func() { suite.RunSubTest("Pass username with png extension", func() {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil) suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
req := httptest.NewRequest("GET", "http://chrly/skins/mock_username.png", nil) req := httptest.NewRequest("GET", "http://chrly/skins/mock_username.png", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@ -241,14 +288,18 @@ func (suite *skinsystemTestSuite) TestSkinGET() {
req := httptest.NewRequest("GET", "http://chrly/skins?name=mock_username", nil) req := httptest.NewRequest("GET", "http://chrly/skins?name=mock_username", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req) if testCase.PanicErr != "" {
suite.PanicsWithError(testCase.PanicErr, func() {
testCase.AfterTest(suite, w.Result()) suite.App.Handler().ServeHTTP(w, req)
})
} else {
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
}
}) })
} }
suite.RunSubTest("Do not pass name param", func() { suite.RunSubTest("Do not pass name param", func() {
req := httptest.NewRequest("GET", "http://chrly/skins", nil) req := httptest.NewRequest("GET", "http://chrly/skins", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@ -267,6 +318,7 @@ var capesTestsCases = []*skinsystemTestCase{
{ {
Name: "Username exists in the local storage", Name: "Username exists in the local storage",
BeforeTest: func(suite *skinsystemTestSuite) { BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil) suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
}, },
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@ -279,7 +331,7 @@ var capesTestsCases = []*skinsystemTestCase{
{ {
Name: "Username doesn't exists on the local storage, but exists on Mojang and has textures", Name: "Username doesn't exists on the local storage, but exists on Mojang and has textures",
BeforeTest: func(suite *skinsystemTestSuite) { BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil) suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponseWithTextures(true, true), nil) suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponseWithTextures(true, true), nil)
}, },
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@ -290,7 +342,7 @@ var capesTestsCases = []*skinsystemTestCase{
{ {
Name: "Username doesn't exists on the local storage, but exists on Mojang and has no cape texture", Name: "Username doesn't exists on the local storage, but exists on Mojang and has no cape texture",
BeforeTest: func(suite *skinsystemTestSuite) { BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil) suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponseWithTextures(false, false), nil) suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponseWithTextures(false, false), nil)
}, },
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@ -300,7 +352,7 @@ var capesTestsCases = []*skinsystemTestCase{
{ {
Name: "Username doesn't exists on the local storage, but exists on Mojang and has an empty properties", Name: "Username doesn't exists on the local storage, but exists on Mojang and has an empty properties",
BeforeTest: func(suite *skinsystemTestSuite) { BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil) suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createEmptyMojangResponse(), nil) suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createEmptyMojangResponse(), nil)
}, },
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@ -310,13 +362,20 @@ var capesTestsCases = []*skinsystemTestCase{
{ {
Name: "Username doesn't exists on the local storage and doesn't exists on Mojang", Name: "Username doesn't exists on the local storage and doesn't exists on Mojang",
BeforeTest: func(suite *skinsystemTestSuite) { BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil) suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(nil, nil) suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(nil, nil)
}, },
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(404, response.StatusCode) suite.Equal(404, response.StatusCode)
}, },
}, },
{
Name: "Receive an error from the SkinsRepository",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, errors.New("skins repository error"))
},
PanicErr: "skins repository error",
},
} }
func (suite *skinsystemTestSuite) TestCape() { func (suite *skinsystemTestSuite) TestCape() {
@ -327,13 +386,19 @@ func (suite *skinsystemTestSuite) TestCape() {
req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username", nil) req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req) if testCase.PanicErr != "" {
suite.PanicsWithError(testCase.PanicErr, func() {
testCase.AfterTest(suite, w.Result()) suite.App.Handler().ServeHTTP(w, req)
})
} else {
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
}
}) })
} }
suite.RunSubTest("Pass username with png extension", func() { suite.RunSubTest("Pass username with png extension", func() {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil) suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username.png", nil) req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username.png", nil)
@ -357,14 +422,18 @@ func (suite *skinsystemTestSuite) TestCapeGET() {
req := httptest.NewRequest("GET", "http://chrly/cloaks?name=mock_username", nil) req := httptest.NewRequest("GET", "http://chrly/cloaks?name=mock_username", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req) if testCase.PanicErr != "" {
suite.PanicsWithError(testCase.PanicErr, func() {
testCase.AfterTest(suite, w.Result()) suite.App.Handler().ServeHTTP(w, req)
})
} else {
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
}
}) })
} }
suite.RunSubTest("Do not pass name param", func() { suite.RunSubTest("Do not pass name param", func() {
req := httptest.NewRequest("GET", "http://chrly/cloaks", nil) req := httptest.NewRequest("GET", "http://chrly/cloaks", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@ -417,23 +486,9 @@ var texturesTestsCases = []*skinsystemTestCase{
}`, string(body)) }`, string(body))
}, },
}, },
{ // There is no case when the user has cape, but has no skin.
Name: "Username exists and has cape, no skin", // In v5 we will rework textures repositories to be more generic about source of textures,
BeforeTest: func(suite *skinsystemTestSuite) { // but right now it's not possible to return profile entity with a cape only.
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"CAPE": {
"url": "http://chrly/cloaks/mock_username"
}
}`, string(body))
},
},
{ {
Name: "Username exists and has both skin and cape", Name: "Username exists and has both skin and cape",
BeforeTest: func(suite *skinsystemTestSuite) { BeforeTest: func(suite *skinsystemTestSuite) {
@ -458,7 +513,6 @@ var texturesTestsCases = []*skinsystemTestCase{
Name: "Username not exists, but Mojang profile available", Name: "Username not exists, but Mojang profile available",
BeforeTest: func(suite *skinsystemTestSuite) { BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(true, true), nil) suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(true, true), nil)
}, },
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@ -479,7 +533,6 @@ var texturesTestsCases = []*skinsystemTestCase{
Name: "Username not exists, but Mojang profile available, but there is an empty skin and cape textures", Name: "Username not exists, but Mojang profile available, but there is an empty skin and cape textures",
BeforeTest: func(suite *skinsystemTestSuite) { BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(false, false), nil) suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(false, false), nil)
}, },
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@ -490,7 +543,6 @@ var texturesTestsCases = []*skinsystemTestCase{
Name: "Username not exists, but Mojang profile available, but there is an empty properties", Name: "Username not exists, but Mojang profile available, but there is an empty properties",
BeforeTest: func(suite *skinsystemTestSuite) { BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createEmptyMojangResponse(), nil) suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createEmptyMojangResponse(), nil)
}, },
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@ -501,7 +553,6 @@ var texturesTestsCases = []*skinsystemTestCase{
Name: "Username not exists and Mojang profile unavailable", Name: "Username not exists and Mojang profile unavailable",
BeforeTest: func(suite *skinsystemTestSuite) { BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(nil, nil) suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(nil, nil)
}, },
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@ -510,6 +561,13 @@ var texturesTestsCases = []*skinsystemTestCase{
suite.Equal("", string(body)) suite.Equal("", string(body))
}, },
}, },
{
Name: "Receive an error from the SkinsRepository",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, errors.New("skins repository error"))
},
PanicErr: "skins repository error",
},
} }
func (suite *skinsystemTestSuite) TestTextures() { func (suite *skinsystemTestSuite) TestTextures() {
@ -520,9 +578,14 @@ func (suite *skinsystemTestSuite) TestTextures() {
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil) req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req) if testCase.PanicErr != "" {
suite.PanicsWithError(testCase.PanicErr, func() {
testCase.AfterTest(suite, w.Result()) suite.App.Handler().ServeHTTP(w, req)
})
} else {
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
}
}) })
} }
} }
@ -535,6 +598,7 @@ type signedTexturesTestCase struct {
Name string Name string
AllowProxy bool AllowProxy bool
BeforeTest func(suite *skinsystemTestSuite) BeforeTest func(suite *skinsystemTestSuite)
PanicErr string
AfterTest func(suite *skinsystemTestSuite, response *http.Response) AfterTest func(suite *skinsystemTestSuite, response *http.Response)
} }
@ -544,6 +608,7 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{
AllowProxy: false, AllowProxy: false,
BeforeTest: func(suite *skinsystemTestSuite) { BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", true), nil) suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", true), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
}, },
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode) suite.Equal(200, response.StatusCode)
@ -586,6 +651,7 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{
skinModel.MojangTextures = "" skinModel.MojangTextures = ""
skinModel.MojangSignature = "" skinModel.MojangSignature = ""
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(skinModel, nil) suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(skinModel, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
}, },
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode) suite.Equal(204, response.StatusCode)
@ -605,12 +671,13 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{
suite.Equal("application/json", response.Header.Get("Content-Type")) suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body) body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{ suite.JSONEq(`{
"id": "00000000000000000000000000000000", "id": "292a1db7353d476ca99cab8f57mojang",
"name": "mock_username", "name": "mock_username",
"properties": [ "properties": [
{ {
"name": "textures", "name": "textures",
"value": "eyJ0aW1lc3RhbXAiOjE1NTYzOTg1NzIsInByb2ZpbGVJZCI6IjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vbW9qYW5nL3NraW4ucG5nIn19fQ==" "value": "eyJ0aW1lc3RhbXAiOjE1NTYzOTg1NzIwMDAsInByb2ZpbGVJZCI6IjI5MmExZGI3MzUzZDQ3NmNhOTljYWI4ZjU3bW9qYW5nIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vbW9qYW5nL3NraW4ucG5nIn19fQ==",
"signature": "mojang signature"
}, },
{ {
"name": "texturesParamName", "name": "texturesParamName",
@ -633,6 +700,13 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{
suite.Equal("", string(body)) suite.Equal("", string(body))
}, },
}, },
{
Name: "Receive an error from the SkinsRepository",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, errors.New("skins repository error"))
},
PanicErr: "skins repository error",
},
} }
func (suite *skinsystemTestSuite) TestSignedTextures() { func (suite *skinsystemTestSuite) TestSignedTextures() {
@ -650,9 +724,406 @@ func (suite *skinsystemTestSuite) TestSignedTextures() {
req := httptest.NewRequest("GET", target, nil) req := httptest.NewRequest("GET", target, nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req) if testCase.PanicErr != "" {
suite.PanicsWithError(testCase.PanicErr, func() {
suite.App.Handler().ServeHTTP(w, req)
})
} else {
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
}
})
}
}
testCase.AfterTest(suite, w.Result()) /***************************
* Get profile tests cases *
***************************/
type profileTestCase struct {
Name string
Signed bool
BeforeTest func(suite *skinsystemTestSuite)
PanicErr string
AfterTest func(suite *skinsystemTestSuite, response *http.Response)
}
var profileTestsCases = []*profileTestCase{
{
Name: "Username exists and has both skin and cape, don't sign",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_username",
"properties": [
{
"name": "textures",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vY2hybHkvc2tpbi5wbmcifSwiQ0FQRSI6eyJ1cmwiOiJodHRwOi8vY2hybHkvY2xvYWtzL21vY2tfdXNlcm5hbWUifX19"
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username exists and has both skin and cape",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
suite.TexturesSigner.On("SignTextures", "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vY2hybHkvc2tpbi5wbmcifSwiQ0FQRSI6eyJ1cmwiOiJodHRwOi8vY2hybHkvY2xvYWtzL21vY2tfdXNlcm5hbWUifX19").Return("textures signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "textures signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vY2hybHkvc2tpbi5wbmcifSwiQ0FQRSI6eyJ1cmwiOiJodHRwOi8vY2hybHkvY2xvYWtzL21vY2tfdXNlcm5hbWUifX19"
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username exists and has skin, no cape",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("textures signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "textures signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vY2hybHkvc2tpbi5wbmcifX19"
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username exists and has slim skin, no cape",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", true), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("textures signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "textures signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vY2hybHkvc2tpbi5wbmciLCJtZXRhZGF0YSI6eyJtb2RlbCI6InNsaW0ifX19fQ=="
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username exists, but has no skin and Mojang profile with textures available",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
skin := createSkinModel("mock_username", false)
skin.SkinId = 0
skin.Url = ""
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(skin, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(true, true), nil)
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("chrly signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "chrly signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vbW9qYW5nL3NraW4ucG5nIn0sIkNBUEUiOnsidXJsIjoiaHR0cDovL21vamFuZy9jYXBlLnBuZyJ9fX0="
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username exists, but has no skin and Mojang textures proxy returned an error",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
skin := createSkinModel("mock_username", false)
skin.SkinId = 0
skin.Url = ""
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(skin, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(nil, errors.New("shit happened"))
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("chrly signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "chrly signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnt9fQ=="
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username not exists, but Mojang profile with textures available",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(true, true), nil)
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("chrly signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "292a1db7353d476ca99cab8f57mojang",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "chrly signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjI5MmExZGI3MzUzZDQ3NmNhOTljYWI4ZjU3bW9qYW5nIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vbW9qYW5nL3NraW4ucG5nIn0sIkNBUEUiOnsidXJsIjoiaHR0cDovL21vamFuZy9jYXBlLnBuZyJ9fX0="
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username not exists, but Mojang profile available, but there is an empty skin and cape textures",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(false, false), nil)
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("chrly signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "292a1db7353d476ca99cab8f57mojang",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "chrly signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjI5MmExZGI3MzUzZDQ3NmNhOTljYWI4ZjU3bW9qYW5nIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnt9fQ=="
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username not exists, but Mojang profile available, but there is an empty properties",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createEmptyMojangResponse(), nil)
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("chrly signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "292a1db7353d476ca99cab8f57mojang",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "chrly signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjI5MmExZGI3MzUzZDQ3NmNhOTljYWI4ZjU3bW9qYW5nIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnt9fQ=="
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username not exists and Mojang profile unavailable",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Equal("", string(body))
},
},
{
Name: "Username not exists and Mojang textures proxy returned an error",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(nil, errors.New("mojang textures provider error"))
},
PanicErr: "mojang textures provider error",
},
{
Name: "Receive an error from the SkinsRepository",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, errors.New("skins repository error"))
},
PanicErr: "skins repository error",
},
{
Name: "Receive an error from the TexturesSigner",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("", errors.New("textures signer error"))
},
PanicErr: "textures signer error",
},
}
func (suite *skinsystemTestSuite) TestProfile() {
for _, testCase := range profileTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
url := "http://chrly/profile/mock_username"
if testCase.Signed {
url += "?unsigned=false"
}
req := httptest.NewRequest("GET", url, nil)
w := httptest.NewRecorder()
if testCase.PanicErr != "" {
suite.PanicsWithError(testCase.PanicErr, func() {
suite.App.Handler().ServeHTTP(w, req)
})
} else {
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
}
})
}
}
/***************************
* Get profile tests cases *
***************************/
var signingKeyTestsCases = []*skinsystemTestCase{
{
Name: "Get public key",
BeforeTest: func(suite *skinsystemTestSuite) {
pubPem, _ := pem.Decode([]byte("-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANbUpVCZkMKpfvYZ08W3lumdAaYxLBnm\nUDlzHBQH3DpYef5WCO32TDU6feIJ58A0lAywgtZ4wwi2dGHOz/1hAvcCAwEAAQ==\n-----END PUBLIC KEY-----"))
publicKey, _ := x509.ParsePKIXPublicKey(pubPem.Bytes)
suite.TexturesSigner.On("GetPublicKey").Return(publicKey, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/octet-stream", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.Equal([]byte{48, 92, 48, 13, 6, 9, 42, 134, 72, 134, 247, 13, 1, 1, 1, 5, 0, 3, 75, 0, 48, 72, 2, 65, 0, 214, 212, 165, 80, 153, 144, 194, 169, 126, 246, 25, 211, 197, 183, 150, 233, 157, 1, 166, 49, 44, 25, 230, 80, 57, 115, 28, 20, 7, 220, 58, 88, 121, 254, 86, 8, 237, 246, 76, 53, 58, 125, 226, 9, 231, 192, 52, 148, 12, 176, 130, 214, 120, 195, 8, 182, 116, 97, 206, 207, 253, 97, 2, 247, 2, 3, 1, 0, 1}, body)
},
},
{
Name: "Error while obtaining public key",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.TexturesSigner.On("GetPublicKey").Return(nil, errors.New("textures signer error"))
},
PanicErr: "textures signer error",
},
}
func (suite *skinsystemTestSuite) TestSignatureVerificationKey() {
for _, testCase := range signingKeyTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/signature-verification-key", nil)
w := httptest.NewRecorder()
if testCase.PanicErr != "" {
suite.PanicsWithError(testCase.PanicErr, func() {
suite.App.Handler().ServeHTTP(w, req)
})
} else {
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
}
}) })
} }
} }
@ -699,7 +1170,7 @@ func createCapeModel() *model.Cape {
func createEmptyMojangResponse() *mojang.SignedTexturesResponse { func createEmptyMojangResponse() *mojang.SignedTexturesResponse {
return &mojang.SignedTexturesResponse{ return &mojang.SignedTexturesResponse{
Id: "00000000000000000000000000000000", Id: "292a1db7353d476ca99cab8f57mojang",
Name: "mock_username", Name: "mock_username",
Props: []*mojang.Property{}, Props: []*mojang.Property{},
} }
@ -708,8 +1179,8 @@ func createEmptyMojangResponse() *mojang.SignedTexturesResponse {
func createMojangResponseWithTextures(includeSkin bool, includeCape bool) *mojang.SignedTexturesResponse { func createMojangResponseWithTextures(includeSkin bool, includeCape bool) *mojang.SignedTexturesResponse {
timeZone, _ := time.LoadLocation("Europe/Minsk") timeZone, _ := time.LoadLocation("Europe/Minsk")
textures := &mojang.TexturesProp{ textures := &mojang.TexturesProp{
Timestamp: time.Date(2019, 4, 27, 23, 56, 12, 0, timeZone).Unix(), Timestamp: time.Date(2019, 4, 27, 23, 56, 12, 0, timeZone).UnixNano() / int64(time.Millisecond),
ProfileID: "00000000000000000000000000000000", ProfileID: "292a1db7353d476ca99cab8f57mojang",
ProfileName: "mock_username", ProfileName: "mock_username",
Textures: &mojang.TexturesResponse{}, Textures: &mojang.TexturesResponse{},
} }
@ -728,8 +1199,9 @@ func createMojangResponseWithTextures(includeSkin bool, includeCape bool) *mojan
response := createEmptyMojangResponse() response := createEmptyMojangResponse()
response.Props = append(response.Props, &mojang.Property{ response.Props = append(response.Props, &mojang.Property{
Name: "textures", Name: "textures",
Value: mojang.EncodeTextures(textures), Value: mojang.EncodeTextures(textures),
Signature: "mojang signature",
}) })
return response return response

View File

@ -5,6 +5,7 @@ import (
"time" "time"
"github.com/elyby/chrly/api/mojang" "github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/utils"
) )
type inMemoryItem struct { type inMemoryItem struct {
@ -53,7 +54,7 @@ func (s *InMemoryTexturesStorage) StoreTextures(uuid string, textures *mojang.Si
s.data[uuid] = &inMemoryItem{ s.data[uuid] = &inMemoryItem{
textures: textures, textures: textures,
timestamp: unixNanoToUnixMicro(time.Now().UnixNano()), timestamp: utils.UnixMillisecond(time.Now()),
} }
} }
@ -89,9 +90,5 @@ func (s *InMemoryTexturesStorage) gc() {
} }
func (s *InMemoryTexturesStorage) getMinimalNotExpiredTimestamp() int64 { func (s *InMemoryTexturesStorage) getMinimalNotExpiredTimestamp() int64 {
return unixNanoToUnixMicro(time.Now().Add(s.Duration * time.Duration(-1)).UnixNano()) return utils.UnixMillisecond(time.Now().Add(s.Duration * time.Duration(-1)))
}
func unixNanoToUnixMicro(unixNano int64) int64 {
return unixNano / 10e5
} }

42
signer/signer.go Normal file
View File

@ -0,0 +1,42 @@
package signer
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"encoding/base64"
"errors"
)
var randomReader = rand.Reader
type Signer struct {
Key *rsa.PrivateKey
}
func (s *Signer) SignTextures(textures string) (string, error) {
if s.Key == nil {
return "", errors.New("Key is empty")
}
message := []byte(textures)
messageHash := sha1.New()
_, _ = messageHash.Write(message)
messageHashSum := messageHash.Sum(nil)
signature, err := rsa.SignPKCS1v15(randomReader, s.Key, crypto.SHA1, messageHashSum)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(signature), nil
}
func (s *Signer) GetPublicKey() (*rsa.PublicKey, error) {
if s.Key == nil {
return nil, errors.New("Key is empty")
}
return &s.Key.PublicKey, nil
}

64
signer/signer_test.go Normal file
View File

@ -0,0 +1,64 @@
package signer
import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"testing"
assert "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{}
t.Run("sign textures", func(t *testing.T) {
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 := &Signer{key}
signature, err := signer.SignTextures("eyJ0aW1lc3RhbXAiOjE2MTQzMDcxMzQsInByb2ZpbGVJZCI6ImZmYzhmZGM5NTgyNDUwOWU4YTU3Yzk5Yjk0MGZiOTk2IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly9lbHkuYnkvc3RvcmFnZS9za2lucy82OWM2NzQwZDI5OTNlNWQ2ZjZhN2ZjOTI0MjBlZmMyOS5wbmcifX0sImVseSI6dHJ1ZX0")
assert.NoError(t, err)
assert.Equal(t, "IyHCxTP5ITquEXTHcwCtLd08jWWy16JwlQeWg8naxhoAVQecHGRdzHRscuxtdq/446kmeox7h4EfRN2A2ZLL+A==", signature)
})
t.Run("empty key", func(t *testing.T) {
signer := &Signer{}
signature, err := signer.SignTextures("hello world")
assert.Error(t, err, "Key is empty")
assert.Empty(t, signature)
})
}
func TestSigner_GetPublicKey(t *testing.T) {
randomReader = &ConstantReader{}
t.Run("get public key", func(t *testing.T) {
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 := &Signer{key}
publicKey, err := signer.GetPublicKey()
assert.NoError(t, err)
assert.IsType(t, &rsa.PublicKey{}, publicKey)
})
t.Run("empty key", func(t *testing.T) {
signer := &Signer{}
publicKey, err := signer.GetPublicKey()
assert.Error(t, err, "Key is empty")
assert.Nil(t, publicKey)
})
}

7
utils/time.go Normal file
View File

@ -0,0 +1,7 @@
package utils
import "time"
func UnixMillisecond(t time.Time) int64 {
return t.UnixNano() / int64(time.Millisecond)
}

16
utils/time_test.go Normal file
View File

@ -0,0 +1,16 @@
package utils
import (
"time"
"testing"
assert "github.com/stretchr/testify/require"
)
func TestUnixMillisecond(t *testing.T) {
loc, _ := time.LoadLocation("CET")
d := time.Date(2021, 02, 26, 00, 43, 57, 987654321, loc)
assert.Equal(t, int64(1614296637987), UnixMillisecond(d))
}