diff --git a/CHANGELOG.md b/CHANGELOG.md index c5d662f..aaebaaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). ## [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 - [#29](https://github.com/elyby/chrly/issues/29) If a previously cached UUID no longer exists, it will be invalidated and re-requested. - 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 ### Added - [#24](https://github.com/elyby/chrly/issues/24): Implemented a new strategy for the queue in the batch provider of diff --git a/README.md b/README.md index 39db1d9..66d94b1 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,9 @@ production ready. ## Installation 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 -that you must set before running `docker-compose up -d`. Other possible variables are described below. +it as `docker-compose.yml`) can be used to start a Chrly server. It relies on `CHRLY_SECRET` and `CHRLY_SIGNING_KEY` +environment variables that you must set before running `docker-compose up -d`. Other possible variables are described +below. ```yml version: '2' @@ -33,6 +34,7 @@ services: - "80:80" environment: CHRLY_SECRET: replace_this_value_in_production + CHRLY_SIGNING_KEY: base64:LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT3dJQkFBSkJBTmJVcFZDWmtNS3BmdllaMDhXM2x1bWRBYVl4TEJubVVEbHpIQlFIM0RwWWVmNVdDTzMyClREVTZmZUlKNThBMGxBeXdndFo0d3dpMmRHSE96LzFoQXZjQ0F3RUFBUUpBSXRheFNIVGU2UEtieUVVLzlweGoKT05kaFlSWXdWTExvNTZnbk1ZaGt5b0VxYWFNc2ZvdjhoaG9lcGtZWkJNdlpGQjJiRE9zUTJTYUorRTJlaUJPNApBUUloQVBzc1MwK0JSOXcwYk9kbWpHcW1kRTlOck41VUpRY09XMTNzMjkrNlF6VUJBaUVBMnZXT2VwQTVBcGl1CnBFQTNwd29HZGtWQ3JOU25uS2pEUXpEWEJucGQzL2NDSUVGTmQ5c1k0cVVHNEZXZFhONlJubVhMN1NqMHVaZkgKRE13enU4ckVNNXNCQWlFQWh2ZG9ETnFMbWJNZHEzYytGc1BTT2VMMWQyMVpwL0pLOGtiUHRGbUhOZjhDSVFEVgo2RlNaRHd2V2Z1eGFNN0JzeWNRT05rakRCVFBOdStscWN0SkJHbkJ2M0E9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo= redis: image: redis:4.0-32bit @@ -41,6 +43,11 @@ services: - ./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 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 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` -and `up` it: +If environment variables have been changed, Docker will automatically recreate the container, so you only need to `up` +it again: ```sh -docker-compose stop app docker-compose up -d app ``` @@ -182,7 +188,7 @@ If something goes wrong, you can always access logs by executing `docker-compose ## 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` @@ -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 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}` -Actually, it's [Ely.by](http://ely.by) feature called [Server Skins System](http://ely.by/server-skins-system), but if -you have your own source of Mojang's signatures, 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. +Actually, this is the [Ely.by](https://ely.by)'s feature called +[Server Skins System](https://ely.by/server-skins-system), but if you have your own source of Mojang's signatures, +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: diff --git a/di/di.go b/di/di.go index 0b63b33..df8eb37 100644 --- a/di/di.go +++ b/di/di.go @@ -11,6 +11,7 @@ func New() (*di.Container, error) { mojangTextures, handlers, server, + signer, ) if err != nil { return nil, err diff --git a/di/handlers.go b/di/handlers.go index ea2f9dd..c5f4975 100644 --- a/di/handlers.go +++ b/di/handlers.go @@ -104,6 +104,7 @@ func newSkinsystemHandler( skinsRepository SkinsRepository, capesRepository CapesRepository, mojangTexturesProvider MojangTexturesProvider, + texturesSigner TexturesSigner, ) *mux.Router { config.SetDefault("textures.extra_param_name", "chrly") config.SetDefault("textures.extra_param_value", "how do you tame a horse in Minecraft?") @@ -113,14 +114,14 @@ func newSkinsystemHandler( SkinsRepo: skinsRepository, CapesRepo: capesRepository, MojangTexturesProvider: mojangTexturesProvider, + TexturesSigner: texturesSigner, TexturesExtraParamName: config.GetString("textures.extra_param_name"), TexturesExtraParamValue: config.GetString("textures.extra_param_value"), }).Handler() } -func newApiHandler(emitter Emitter, skinsRepository SkinsRepository) *mux.Router { +func newApiHandler(skinsRepository SkinsRepository) *mux.Router { return (&Api{ - Emitter: emitter, SkinsRepo: skinsRepository, }).Handler() } diff --git a/di/server.go b/di/server.go index 5b7a75a..c5a6b0b 100644 --- a/di/server.go +++ b/di/server.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "runtime/debug" "time" "github.com/getsentry/raven-go" @@ -42,13 +43,26 @@ func newServer(params serverParams) *http.Server { params.Config.SetDefault("server.host", "") params.Config.SetDefault("server.port", 80) - handler := params.Handler + var handler http.Handler if params.Sentry != nil { // raven.Recoverer uses DefaultClient and nothing can be done about it // To avoid code duplication, if the Sentry service is successfully initiated, // it will also replace DefaultClient, so raven.Recoverer will work with the instance // 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")) diff --git a/di/signer.go b/di/signer.go new file mode 100644 index 0000000..9be71dc --- /dev/null +++ b/di/signer.go @@ -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 +} diff --git a/http/api.go b/http/api.go index a93c26b..dec6cc6 100644 --- a/http/api.go +++ b/http/api.go @@ -43,7 +43,6 @@ func init() { } type Api struct { - Emitter SkinsRepo SkinsRepository } @@ -68,9 +67,7 @@ func (ctx *Api) postSkinHandler(resp http.ResponseWriter, req *http.Request) { record, err := ctx.findIdentityOrCleanup(identityId, username) if err != nil { - ctx.Emit("skinsystem:error", fmt.Errorf("error on requesting a skin from the repository: %w", err)) - apiServerError(resp) - return + panic(err) } if record == nil { @@ -94,9 +91,7 @@ func (ctx *Api) postSkinHandler(resp http.ResponseWriter, req *http.Request) { err = ctx.SkinsRepo.SaveSkin(record) if err != nil { - ctx.Emit("skinsystem:error", fmt.Errorf("unable to save record to the repository: %w", err)) - apiServerError(resp) - return + panic(err) } 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) { if err != nil { - ctx.Emit("skinsystem:error", fmt.Errorf("unable to find skin info from the repository: %w", err)) - apiServerError(resp) - return + panic(err) } 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) if err != nil { - ctx.Emit("skinsystem:error", fmt.Errorf("cannot delete skin by error: %w", err)) - apiServerError(resp) - return + panic(err) } resp.WriteHeader(http.StatusNoContent) diff --git a/http/api_test.go b/http/api_test.go index 3863f60..52484c1 100644 --- a/http/api_test.go +++ b/http/api_test.go @@ -28,7 +28,6 @@ type apiTestSuite struct { App *Api SkinsRepository *skinsRepositoryMock - Emitter *emitterMock } /******************** @@ -37,17 +36,14 @@ type apiTestSuite struct { func (suite *apiTestSuite) SetupTest() { suite.SkinsRepository = &skinsRepositoryMock{} - suite.Emitter = &emitterMock{} suite.App = &Api{ SkinsRepo: suite.SkinsRepository, - Emitter: suite.Emitter, } } func (suite *apiTestSuite) TearDownTest() { suite.SkinsRepository.AssertExpectations(suite.T()) - suite.Emitter.AssertExpectations(suite.T()) } func (suite *apiTestSuite) RunSubTest(name string, subTest func()) { @@ -72,6 +68,7 @@ type postSkinTestCase struct { Name string Form io.Reader BeforeTest func(suite *apiTestSuite) + PanicErr string 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", + 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{ "identityId": {"1"}, "username": {"mock_username"}, @@ -209,43 +222,9 @@ var postSkinTestsCases = []*postSkinTestCase{ }.Encode()), BeforeTest: func(suite *apiTestSuite) { suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil) - err := errors.New("mock error") - 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) + suite.SkinsRepository.On("SaveSkin", mock.Anything).Return(errors.New("can't save textures")) }, + PanicErr: "can't save textures", }, } @@ -258,9 +237,14 @@ func (suite *apiTestSuite) TestPostSkin() { req.Header.Add("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() - suite.App.Handler().ServeHTTP(w, req) - - testCase.AfterTest(suite, w.Result()) + 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()) + } }) } diff --git a/http/http.go b/http/http.go index 38620cd..d4402a6 100644 --- a/http/http.go +++ b/http/http.go @@ -36,7 +36,7 @@ func StartServer(server *http.Server, logger slf.Logger) { go func() { s := waitForExitSignal() 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())) close(done) }() @@ -135,7 +135,3 @@ func apiNotFound(resp http.ResponseWriter, reason string) { }) _, _ = resp.Write(result) } - -func apiServerError(resp http.ResponseWriter) { - resp.WriteHeader(http.StatusInternalServerError) -} diff --git a/http/skinsystem.go b/http/skinsystem.go index ab77070..15a593d 100644 --- a/http/skinsystem.go +++ b/http/skinsystem.go @@ -1,10 +1,15 @@ package http import ( + "crypto/rsa" + "crypto/x509" + "encoding/base64" "encoding/json" + "github.com/elyby/chrly/utils" "io" "net/http" "strings" + "time" "github.com/gorilla/mux" @@ -12,6 +17,8 @@ import ( "github.com/elyby/chrly/model" ) +var timeNow = time.Now + type SkinsRepository interface { FindSkinByUsername(username string) (*model.Skin, error) FindSkinByUserId(id int) (*model.Skin, error) @@ -28,15 +35,30 @@ type MojangTexturesProvider interface { GetForUsername(username string) (*mojang.SignedTexturesResponse, error) } +type TexturesSigner interface { + SignTextures(textures string) (string, error) + GetPublicKey() (*rsa.PublicKey, error) +} + type Skinsystem struct { Emitter SkinsRepo SkinsRepository CapesRepo CapesRepository MojangTexturesProvider MojangTexturesProvider + TexturesSigner TexturesSigner TexturesExtraParamName 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 { 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("/textures/{username}", ctx.texturesHandler).Methods(http.MethodGet) router.HandleFunc("/textures/signed/{username}", ctx.signedTexturesHandler).Methods(http.MethodGet) + router.HandleFunc("/profile/{username}", ctx.profileHandler).Methods(http.MethodGet) // Legacy router.HandleFunc("/skins", ctx.skinGetHandler).Methods(http.MethodGet) router.HandleFunc("/cloaks", ctx.capeGetHandler).Methods(http.MethodGet) + // Utils + router.HandleFunc("/signature-verification-key", ctx.signatureVerificationKeyHandler).Methods(http.MethodGet) return router } func (ctx *Skinsystem) skinHandler(response http.ResponseWriter, request *http.Request) { - username := parseUsername(mux.Vars(request)["username"]) - rec, err := ctx.SkinsRepo.FindSkinByUsername(username) - if err == nil && rec != nil && rec.SkinId != 0 { - http.Redirect(response, request, rec.Url, 301) - return + profile, err := ctx.getProfile(request, true) + if err != nil { + panic(err) } - mojangTextures, err := ctx.MojangTexturesProvider.GetForUsername(username) - if err != nil || mojangTextures == nil { + if profile == nil || profile.Textures == nil || profile.Textures.Skin == nil { response.WriteHeader(http.StatusNotFound) return } - texturesProp, _ := mojangTextures.DecodeTextures() - 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) + http.Redirect(response, request, profile.Textures.Skin.Url, 301) } 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)["converted"] = "1" ctx.skinHandler(response, request) } func (ctx *Skinsystem) capeHandler(response http.ResponseWriter, request *http.Request) { - username := parseUsername(mux.Vars(request)["username"]) - rec, err := ctx.CapesRepo.FindCapeByUsername(username) - if err == nil && rec != nil { + profile, err := ctx.getProfile(request, true) + if err != 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") - _, _ = io.Copy(response, rec.File) - return + _, _ = io.Copy(response, profile.CapeFile) } - - 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) { @@ -131,104 +129,219 @@ func (ctx *Skinsystem) capeGetHandler(response http.ResponseWriter, request *htt } mux.Vars(request)["username"] = username - mux.Vars(request)["converted"] = "1" ctx.capeHandler(response, request) } func (ctx *Skinsystem) texturesHandler(response http.ResponseWriter, request *http.Request) { - username := parseUsername(mux.Vars(request)["username"]) - - var textures *mojang.TexturesResponse - 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 - } + profile, err := ctx.getProfile(request, true) + if err != nil { + panic(err) } - 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.Write(responseData) } func (ctx *Skinsystem) signedTexturesHandler(response http.ResponseWriter, request *http.Request) { - username := parseUsername(mux.Vars(request)["username"]) - - var responseData *mojang.SignedTexturesResponse - - 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 - } + profile, err := ctx.getProfile(request, request.URL.Query().Get("proxy") != "") + if err != nil { + panic(err) } - if responseData == nil { + if profile == nil || profile.MojangTextures == "" { response.WriteHeader(http.StatusNoContent) return } - responseData.Props = append(responseData.Props, &mojang.Property{ - Name: ctx.TexturesExtraParamName, - Value: ctx.TexturesExtraParamValue, - }) + profileResponse := &mojang.SignedTexturesResponse{ + Id: profile.Id, + 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.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 { return strings.TrimSuffix(username, ".png") } diff --git a/http/skinsystem_test.go b/http/skinsystem_test.go index cd04de4..615516e 100644 --- a/http/skinsystem_test.go +++ b/http/skinsystem_test.go @@ -2,6 +2,10 @@ package http import ( "bytes" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" "image" "image/png" "io/ioutil" @@ -89,6 +93,25 @@ func (m *mojangTexturesProviderMock) GetForUsername(username string) (*mojang.Si 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 { suite.Suite @@ -97,6 +120,7 @@ type skinsystemTestSuite struct { SkinsRepository *skinsRepositoryMock CapesRepository *capesRepositoryMock MojangTexturesProvider *mojangTexturesProviderMock + TexturesSigner *texturesSignerMock Emitter *emitterMock } @@ -105,15 +129,22 @@ type skinsystemTestSuite struct { ********************/ 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.CapesRepository = &capesRepositoryMock{} suite.MojangTexturesProvider = &mojangTexturesProviderMock{} + suite.TexturesSigner = &texturesSignerMock{} suite.Emitter = &emitterMock{} suite.App = &Skinsystem{ SkinsRepo: suite.SkinsRepository, CapesRepo: suite.CapesRepository, MojangTexturesProvider: suite.MojangTexturesProvider, + TexturesSigner: suite.TexturesSigner, Emitter: suite.Emitter, TexturesExtraParamName: "texturesParamName", TexturesExtraParamValue: "texturesParamValue", @@ -124,6 +155,7 @@ func (suite *skinsystemTestSuite) TearDownTest() { suite.SkinsRepository.AssertExpectations(suite.T()) suite.CapesRepository.AssertExpectations(suite.T()) suite.MojangTexturesProvider.AssertExpectations(suite.T()) + suite.TexturesSigner.AssertExpectations(suite.T()) suite.Emitter.AssertExpectations(suite.T()) } @@ -144,6 +176,7 @@ func TestSkinsystem(t *testing.T) { type skinsystemTestCase struct { Name string BeforeTest func(suite *skinsystemTestSuite) + PanicErr string AfterTest func(suite *skinsystemTestSuite, response *http.Response) } @@ -156,6 +189,7 @@ var skinsTestsCases = []*skinsystemTestCase{ Name: "Username exists in the local storage", 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) }, AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { suite.Equal(301, response.StatusCode) @@ -203,6 +237,13 @@ var skinsTestsCases = []*skinsystemTestCase{ 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() { @@ -213,14 +254,20 @@ func (suite *skinsystemTestSuite) TestSkin() { req := httptest.NewRequest("GET", "http://chrly/skins/mock_username", nil) w := httptest.NewRecorder() - suite.App.Handler().ServeHTTP(w, req) - - testCase.AfterTest(suite, w.Result()) + 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()) + } }) } 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(nil, nil) req := httptest.NewRequest("GET", "http://chrly/skins/mock_username.png", nil) w := httptest.NewRecorder() @@ -241,14 +288,18 @@ func (suite *skinsystemTestSuite) TestSkinGET() { req := httptest.NewRequest("GET", "http://chrly/skins?name=mock_username", nil) w := httptest.NewRecorder() - suite.App.Handler().ServeHTTP(w, req) - - testCase.AfterTest(suite, w.Result()) + 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()) + } }) } suite.RunSubTest("Do not pass name param", func() { - req := httptest.NewRequest("GET", "http://chrly/skins", nil) w := httptest.NewRecorder() @@ -267,6 +318,7 @@ var capesTestsCases = []*skinsystemTestCase{ { Name: "Username exists in the local storage", 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) { @@ -279,7 +331,7 @@ var capesTestsCases = []*skinsystemTestCase{ { Name: "Username doesn't exists on the local storage, but exists on Mojang and has textures", 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) }, 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", 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) }, 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", 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) }, 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", 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) }, AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { 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() { @@ -327,13 +386,19 @@ func (suite *skinsystemTestSuite) TestCape() { req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username", nil) w := httptest.NewRecorder() - suite.App.Handler().ServeHTTP(w, req) - - testCase.AfterTest(suite, w.Result()) + 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()) + } }) } 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) 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) w := httptest.NewRecorder() - suite.App.Handler().ServeHTTP(w, req) - - testCase.AfterTest(suite, w.Result()) + 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()) + } }) } suite.RunSubTest("Do not pass name param", func() { - req := httptest.NewRequest("GET", "http://chrly/cloaks", nil) w := httptest.NewRecorder() @@ -417,23 +486,9 @@ var texturesTestsCases = []*skinsystemTestCase{ }`, string(body)) }, }, - { - Name: "Username exists and has cape, no skin", - BeforeTest: func(suite *skinsystemTestSuite) { - 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)) - }, - }, + // There is no case when the user has cape, but has no skin. + // In v5 we will rework textures repositories to be more generic about source of textures, + // but right now it's not possible to return profile entity with a cape only. { Name: "Username exists and has both skin and cape", BeforeTest: func(suite *skinsystemTestSuite) { @@ -458,7 +513,6 @@ var texturesTestsCases = []*skinsystemTestCase{ Name: "Username not exists, but Mojang profile available", BeforeTest: func(suite *skinsystemTestSuite) { 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) }, 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", BeforeTest: func(suite *skinsystemTestSuite) { 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) }, 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", BeforeTest: func(suite *skinsystemTestSuite) { 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) }, AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { @@ -501,7 +553,6 @@ var texturesTestsCases = []*skinsystemTestCase{ Name: "Username not exists and Mojang profile unavailable", BeforeTest: func(suite *skinsystemTestSuite) { 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) }, AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { @@ -510,6 +561,13 @@ var texturesTestsCases = []*skinsystemTestCase{ 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() { @@ -520,9 +578,14 @@ func (suite *skinsystemTestSuite) TestTextures() { req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil) w := httptest.NewRecorder() - suite.App.Handler().ServeHTTP(w, req) - - testCase.AfterTest(suite, w.Result()) + 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()) + } }) } } @@ -535,6 +598,7 @@ type signedTexturesTestCase struct { Name string AllowProxy bool BeforeTest func(suite *skinsystemTestSuite) + PanicErr string AfterTest func(suite *skinsystemTestSuite, response *http.Response) } @@ -544,6 +608,7 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{ AllowProxy: false, 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) }, AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { suite.Equal(200, response.StatusCode) @@ -586,6 +651,7 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{ skinModel.MojangTextures = "" skinModel.MojangSignature = "" 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) { suite.Equal(204, response.StatusCode) @@ -605,12 +671,13 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{ suite.Equal("application/json", response.Header.Get("Content-Type")) body, _ := ioutil.ReadAll(response.Body) suite.JSONEq(`{ - "id": "00000000000000000000000000000000", + "id": "292a1db7353d476ca99cab8f57mojang", "name": "mock_username", "properties": [ { "name": "textures", - "value": "eyJ0aW1lc3RhbXAiOjE1NTYzOTg1NzIsInByb2ZpbGVJZCI6IjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vbW9qYW5nL3NraW4ucG5nIn19fQ==" + "value": "eyJ0aW1lc3RhbXAiOjE1NTYzOTg1NzIwMDAsInByb2ZpbGVJZCI6IjI5MmExZGI3MzUzZDQ3NmNhOTljYWI4ZjU3bW9qYW5nIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vbW9qYW5nL3NraW4ucG5nIn19fQ==", + "signature": "mojang signature" }, { "name": "texturesParamName", @@ -633,6 +700,13 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{ 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() { @@ -650,9 +724,406 @@ func (suite *skinsystemTestSuite) TestSignedTextures() { req := httptest.NewRequest("GET", target, nil) 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 { return &mojang.SignedTexturesResponse{ - Id: "00000000000000000000000000000000", + Id: "292a1db7353d476ca99cab8f57mojang", Name: "mock_username", Props: []*mojang.Property{}, } @@ -708,8 +1179,8 @@ func createEmptyMojangResponse() *mojang.SignedTexturesResponse { func createMojangResponseWithTextures(includeSkin bool, includeCape bool) *mojang.SignedTexturesResponse { timeZone, _ := time.LoadLocation("Europe/Minsk") textures := &mojang.TexturesProp{ - Timestamp: time.Date(2019, 4, 27, 23, 56, 12, 0, timeZone).Unix(), - ProfileID: "00000000000000000000000000000000", + Timestamp: time.Date(2019, 4, 27, 23, 56, 12, 0, timeZone).UnixNano() / int64(time.Millisecond), + ProfileID: "292a1db7353d476ca99cab8f57mojang", ProfileName: "mock_username", Textures: &mojang.TexturesResponse{}, } @@ -728,8 +1199,9 @@ func createMojangResponseWithTextures(includeSkin bool, includeCape bool) *mojan response := createEmptyMojangResponse() response.Props = append(response.Props, &mojang.Property{ - Name: "textures", - Value: mojang.EncodeTextures(textures), + Name: "textures", + Value: mojang.EncodeTextures(textures), + Signature: "mojang signature", }) return response diff --git a/mojangtextures/in_memory_textures_storage.go b/mojangtextures/in_memory_textures_storage.go index dc51735..7faa924 100644 --- a/mojangtextures/in_memory_textures_storage.go +++ b/mojangtextures/in_memory_textures_storage.go @@ -5,6 +5,7 @@ import ( "time" "github.com/elyby/chrly/api/mojang" + "github.com/elyby/chrly/utils" ) type inMemoryItem struct { @@ -53,7 +54,7 @@ func (s *InMemoryTexturesStorage) StoreTextures(uuid string, textures *mojang.Si s.data[uuid] = &inMemoryItem{ 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 { - return unixNanoToUnixMicro(time.Now().Add(s.Duration * time.Duration(-1)).UnixNano()) -} - -func unixNanoToUnixMicro(unixNano int64) int64 { - return unixNano / 10e5 + return utils.UnixMillisecond(time.Now().Add(s.Duration * time.Duration(-1))) } diff --git a/signer/signer.go b/signer/signer.go new file mode 100644 index 0000000..963a241 --- /dev/null +++ b/signer/signer.go @@ -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 +} diff --git a/signer/signer_test.go b/signer/signer_test.go new file mode 100644 index 0000000..6fc8fea --- /dev/null +++ b/signer/signer_test.go @@ -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) + }) +} diff --git a/utils/time.go b/utils/time.go new file mode 100644 index 0000000..f3b1ec0 --- /dev/null +++ b/utils/time.go @@ -0,0 +1,7 @@ +package utils + +import "time" + +func UnixMillisecond(t time.Time) int64 { + return t.UnixNano() / int64(time.Millisecond) +} diff --git a/utils/time_test.go b/utils/time_test.go new file mode 100644 index 0000000..5b1fc91 --- /dev/null +++ b/utils/time_test.go @@ -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)) +}