From 6f148a87913a88f861eb7d902b3050af1960100c Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Fri, 26 Feb 2021 02:45:45 +0100 Subject: [PATCH] Implemented /profile/{username} endpoint to get complete profile with signed by the current server textures. Implemented /signing-key endpoint to get public key in der format, used to sign the textures. Improved logging of errors from http package. Changed behavior of the /cloaks endpoint --- CHANGELOG.md | 7 + di/di.go | 1 + di/handlers.go | 5 +- di/server.go | 18 +- di/signer.go | 50 ++ http/api.go | 17 +- http/api_test.go | 70 +-- http/http.go | 6 +- http/skinsystem.go | 358 ++++++++---- http/skinsystem_test.go | 574 +++++++++++++++++-- mojangtextures/in_memory_textures_storage.go | 9 +- signer/signer.go | 42 ++ signer/signer_test.go | 64 +++ utils/time.go | 7 + utils/time_test.go | 16 + 15 files changed, 1001 insertions(+), 243 deletions(-) create mode 100644 di/signer.go create mode 100644 signer/signer.go create mode 100644 signer/signer_test.go create mode 100644 utils/time.go create mode 100644 utils/time_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c5d662f..ca55fde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,18 @@ 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. +- `/signing-key` endpoint. + ### 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 +- All skinsystem's 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/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..419c437 --- /dev/null +++ b/di/signer.go @@ -0,0 +1,50 @@ +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) { + // TODO: add CHANGELOG and README entries about this variable + // TODO: rename param variable + keyStr := config.GetString("textures.signer.pem") + if keyStr == "" { + return nil, errors.New("texturesSigner.pem 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..e572acc 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("/signing-key", ctx.signingKeyHandler).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,28 @@ func (ctx *Skinsystem) skinGetHandler(response http.ResponseWriter, request *htt } mux.Vars(request)["username"] = username - mux.Vars(request)["converted"] = "1" ctx.skinHandler(response, request) } +// TODO: write CHANGELOG about breaking change in this method 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 +130,221 @@ 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) } +// TODO: add README entry about this method +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) +} + +// TODO: add README entry about this method +func (ctx *Skinsystem) signingKeyHandler(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..8207538 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) TestSigningKey() { + for _, testCase := range signingKeyTestsCases { + suite.RunSubTest(testCase.Name, func() { + testCase.BeforeTest(suite) + + req := httptest.NewRequest("GET", "http://chrly/signing-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)) +}