From f120064fe393bb25f74e3b631f4c27948fb19c2e Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Tue, 23 Jan 2018 18:43:37 +0300 Subject: [PATCH] Implemented API endpoint to update skin information Added tests to jwt package Reworked redis backend implementation Skin repository now have methods to remove skins by user id or username --- Gopkg.lock | 9 +- Gopkg.toml | 9 + auth/jwt.go | 90 +++-- auth/jwt_test.go | 168 +++++++++ cmd/serve.go | 9 +- db/redis.go | 113 ++++-- http/api.go | 202 +++++++++++ http/api_test.go | 337 ++++++++++++++++++ http/http.go | 3 + http/http_test.go | 4 + interfaces/auth.go | 7 + interfaces/mock_interfaces/mock_interfaces.go | 24 ++ interfaces/repositories.go | 2 + script/mocks | 1 + 14 files changed, 923 insertions(+), 55 deletions(-) create mode 100644 auth/jwt_test.go create mode 100644 http/api.go create mode 100644 http/api_test.go create mode 100644 interfaces/auth.go diff --git a/Gopkg.lock b/Gopkg.lock index 5736959..ba451f4 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -181,6 +181,13 @@ revision = "69483b4bd14f5845b5a1e55bca19e954e827f1d0" version = "v1.1.4" +[[projects]] + branch = "issue-18" + name = "github.com/thedevsaddam/govalidator" + packages = ["."] + revision = "59055296916bb3c6ad9cf3b21d5f2cf7059f8e76" + source = "https://github.com/erickskrauch/govalidator.git" + [[projects]] branch = "master" name = "golang.org/x/crypto" @@ -214,6 +221,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "a12e681ec671ce8a93256cd754d4e70797476b2d2ce4379c3860df09c4b6a552" + inputs-digest = "b7c6dd9fffc543dc24b5832c7767632e4c066189be7c40868ba5612f5f45dc64" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index a1ba591..1c45e9d 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -34,6 +34,15 @@ ignored = ["elyby/minecraft-skinsystem"] name = "github.com/segmentio/go-prompt" branch = "master" +[[constraint]] + name = "github.com/thedevsaddam/govalidator" + source = "https://github.com/erickskrauch/govalidator.git" + branch = "issue-18" + +[[constraint]] + branch = "master" + name = "github.com/spf13/afero" + # Testing dependencies [[constraint]] diff --git a/auth/jwt.go b/auth/jwt.go index 8ed9f97..fcb3365 100644 --- a/auth/jwt.go +++ b/auth/jwt.go @@ -2,17 +2,21 @@ package auth import ( "encoding/base64" - "io/ioutil" "math" "math/rand" + "net/http" "os" + "strings" "time" "github.com/SermoDigital/jose/crypto" "github.com/SermoDigital/jose/jws" "github.com/mitchellh/go-homedir" + "github.com/spf13/afero" ) +var fs = afero.NewOsFs() + var hashAlg = crypto.SigningMethodHS256 const appHomeDirName = ".minecraft-skinsystem" @@ -52,39 +56,68 @@ func (t *JwtAuth) GenerateSigningKey() error { } key := generateRandomBytes(64) - if err := ioutil.WriteFile(getKeyPath(), key, 0600); err != nil { + if err := afero.WriteFile(fs, getKeyPath(), key, 0600); err != nil { return err } return nil } -func (t *JwtAuth) getSigningKey() ([]byte, error) { - if t.signingKey != nil { - return t.signingKey, nil +func (t *JwtAuth) Check(req *http.Request) error { + bearerToken := req.Header.Get("Authorization") + if bearerToken == "" { + return &Unauthorized{"Authentication header not presented"} } - path := getKeyPath() - if _, err := os.Stat(path); err != nil { - if os.IsNotExist(err) { - return nil, &SigningKeyNotAvailable{} + if !strings.EqualFold(bearerToken[0:7], "BEARER ") { + return &Unauthorized{"Cannot recognize JWT token in passed value"} + } + + tokenStr := bearerToken[7:] + token, err := jws.ParseJWT([]byte(tokenStr)) + if err != nil { + return &Unauthorized{"Cannot parse passed JWT token"} + } + + signKey, err := t.getSigningKey() + if err != nil { + return err + } + + err = token.Validate(signKey, hashAlg) + if err != nil { + return &Unauthorized{"JWT token have invalid signature. It corrupted or expired."} + } + + return nil +} + +func (t *JwtAuth) getSigningKey() ([]byte, error) { + if t.signingKey == nil { + path := getKeyPath() + if _, err := fs.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil, &SigningKeyNotAvailable{} + } + + return nil, err } - return nil, err + key, err := afero.ReadFile(fs, path) + if err != nil { + return nil, err + } + + t.signingKey = key } - key, err := ioutil.ReadFile(path) - if err != nil { - return nil, err - } - - return key, nil + return t.signingKey, nil } func createAppHomeDir() error { path := getAppHomeDirPath() - if _, err := os.Stat(path); os.IsNotExist(err) { - err := os.Mkdir(path, 0755) // rwx r-x r-x + if _, err := fs.Stat(path); os.IsNotExist(err) { + err := fs.Mkdir(path, 0755) // rwx r-x r-x if err != nil { return err } @@ -107,13 +140,28 @@ func getKeyPath() string { } func generateRandomBytes(n int) []byte { - randLen := int(math.Ceil(float64(n) / 1.37)) // base64 will increase length in 1.37 times + // base64 will increase length in 1.37 times + // +1 is needed to ensure, that after base64 we will do not have any '===' characters + randLen := int(math.Ceil(float64(n) / 1.37)) + 1 randBytes := make([]byte, randLen) rand.Read(randBytes) - resBytes := make([]byte, n) + // +5 is needed to have additional buffer for the next set of XX=== characters + resBytes := make([]byte, n + 5) base64.URLEncoding.Encode(resBytes, randBytes) - return resBytes + return resBytes[:n] +} + +type Unauthorized struct { + Reason string +} + +func (e *Unauthorized) Error() string { + if e.Reason != "" { + return e.Reason + } + + return "Unauthorized" } type SigningKeyNotAvailable struct { diff --git a/auth/jwt_test.go b/auth/jwt_test.go new file mode 100644 index 0000000..5b4e701 --- /dev/null +++ b/auth/jwt_test.go @@ -0,0 +1,168 @@ +package auth + +import ( + "net/http/httptest" + "strings" + "testing" + + "github.com/spf13/afero" + + testify "github.com/stretchr/testify/assert" +) + +const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxNTE2NjU4MTkzIiwic2NvcGVzIjoic2tpbiJ9.agbBS0qdyYMBaVfTZJAZcTTRgW1Y0kZty4H3N2JHBO8" + +func TestJwtAuth_NewToken_Success(t *testing.T) { + clearFs() + assert := testify.New(t) + + fs.Mkdir(getAppHomeDirPath(), 0755) + afero.WriteFile(fs, getKeyPath(), []byte("secret"), 0600) + + jwt := &JwtAuth{} + token, err := jwt.NewToken(SkinScope) + assert.Nil(err) + assert.NotNil(token) +} + +func TestJwtAuth_NewToken_KeyNotAvailable(t *testing.T) { + clearFs() + assert := testify.New(t) + + fs = afero.NewMemMapFs() + + jwt := &JwtAuth{} + token, err := jwt.NewToken(SkinScope) + assert.IsType(&SigningKeyNotAvailable{}, err) + assert.Nil(token) +} + +func TestJwtAuth_GenerateSigningKey_KeyNotExists(t *testing.T) { + clearFs() + assert := testify.New(t) + + jwt := &JwtAuth{} + err := jwt.GenerateSigningKey() + assert.Nil(err) + if _, err := fs.Stat(getAppHomeDirPath()); err != nil { + assert.Fail("directory not created") + } + + if _, err := fs.Stat(getKeyPath()); err != nil { + assert.Fail("signing file not created") + } + + content, _ := afero.ReadFile(fs, getKeyPath()) + assert.Len(content, 64) +} + +func TestJwtAuth_GenerateSigningKey_KeyExists(t *testing.T) { + clearFs() + assert := testify.New(t) + + fs.Mkdir(getAppHomeDirPath(), 0755) + afero.WriteFile(fs, getKeyPath(), []byte("secret"), 0600) + + jwt := &JwtAuth{} + err := jwt.GenerateSigningKey() + assert.Nil(err) + if _, err := fs.Stat(getAppHomeDirPath()); err != nil { + assert.Fail("directory not created") + } + + if _, err := fs.Stat(getKeyPath()); err != nil { + assert.Fail("signing file not created") + } + + content, _ := afero.ReadFile(fs, getKeyPath()) + assert.NotEqual([]byte("secret"), content) +} + +func TestJwtAuth_Check_EmptyRequest(t *testing.T) { + clearFs() + assert := testify.New(t) + + req := httptest.NewRequest("POST", "http://localhost", nil) + jwt := &JwtAuth{} + + err := jwt.Check(req) + assert.IsType(&Unauthorized{}, err) + assert.EqualError(err, "Authentication header not presented") +} + +func TestJwtAuth_Check_NonBearer(t *testing.T) { + clearFs() + assert := testify.New(t) + + req := httptest.NewRequest("POST", "http://localhost", nil) + req.Header.Add("Authorization", "this is not jwt") + jwt := &JwtAuth{} + + err := jwt.Check(req) + assert.IsType(&Unauthorized{}, err) + assert.EqualError(err, "Cannot recognize JWT token in passed value") +} + +func TestJwtAuth_Check_BearerButNotJwt(t *testing.T) { + clearFs() + assert := testify.New(t) + + req := httptest.NewRequest("POST", "http://localhost", nil) + req.Header.Add("Authorization", "Bearer thisIs.Not.Jwt") + jwt := &JwtAuth{} + + err := jwt.Check(req) + assert.IsType(&Unauthorized{}, err) + assert.EqualError(err, "Cannot parse passed JWT token") +} + +func TestJwtAuth_Check_SecretNotAvailable(t *testing.T) { + clearFs() + assert := testify.New(t) + + req := httptest.NewRequest("POST", "http://localhost", nil) + req.Header.Add("Authorization", "Bearer " + jwt) + jwt := &JwtAuth{} + + err := jwt.Check(req) + assert.IsType(&SigningKeyNotAvailable{}, err) +} + +func TestJwtAuth_Check_SecretInvalid(t *testing.T) { + clearFs() + assert := testify.New(t) + + req := httptest.NewRequest("POST", "http://localhost", nil) + req.Header.Add("Authorization", "Bearer " + jwt) + jwt := &JwtAuth{[]byte("this is another secret")} + + err := jwt.Check(req) + assert.IsType(&Unauthorized{}, err) + assert.EqualError(err, "JWT token have invalid signature. It corrupted or expired.") +} + +func TestJwtAuth_Check_Valid(t *testing.T) { + clearFs() + assert := testify.New(t) + + req := httptest.NewRequest("POST", "http://localhost", nil) + req.Header.Add("Authorization", "Bearer " + jwt) + jwt := &JwtAuth{[]byte("secret")} + + err := jwt.Check(req) + assert.Nil(err) +} + +func TestJwtAuth_generateRandomBytes(t *testing.T) { + assert := testify.New(t) + lengthMap := []int{12, 20, 24, 30, 32, 48, 50, 64} + for _, length := range lengthMap { + bytes := generateRandomBytes(length) + assert.Len(bytes, length) + assert.False(strings.HasSuffix(string(bytes), "="), "secret key should not ends with '=' character") + } +} + +func clearFs() { + fs = afero.NewMemMapFs() +} diff --git a/cmd/serve.go b/cmd/serve.go index d67ef97..f73769a 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -4,6 +4,8 @@ import ( "fmt" "log" + "elyby/minecraft-skinsystem/auth" + "github.com/spf13/cobra" "github.com/spf13/viper" @@ -42,9 +44,10 @@ var serveCmd = &cobra.Command{ cfg := &http.Config{ ListenSpec: fmt.Sprintf("%s:%d", viper.GetString("server.host"), viper.GetInt("server.port")), - SkinsRepo: skinsRepo, - CapesRepo: capesRepo, - Logger: logger, + SkinsRepo: skinsRepo, + CapesRepo: capesRepo, + Logger: logger, + Auth: &auth.JwtAuth{}, } if err := cfg.Run(); err != nil { diff --git a/db/redis.go b/db/redis.go index 0e963a6..e3e9d70 100644 --- a/db/redis.go +++ b/db/redis.go @@ -22,9 +22,11 @@ type RedisFactory struct { Host string Port int PoolSize int - connection util.Cmder + connection *pool.Pool } +// TODO: maybe we should manually return connection to the pool? + func (f RedisFactory) CreateSkinsRepository() (interfaces.SkinsRepository, error) { connection, err := f.getConnection() if err != nil { @@ -38,7 +40,7 @@ func (f RedisFactory) CreateCapesRepository() (interfaces.CapesRepository, error panic("capes repository not supported for this storage type") } -func (f RedisFactory) getConnection() (util.Cmder, error) { +func (f RedisFactory) getConnection() (*pool.Pool, error) { if f.connection == nil { if f.Host == "" { return nil, &ParamRequired{"host"} @@ -49,7 +51,7 @@ func (f RedisFactory) getConnection() (util.Cmder, error) { } addr := fmt.Sprintf("%s:%d", f.Host, f.Port) - conn, err := createConnection(addr, f.PoolSize) + conn, err := pool.New("tcp", addr, f.PoolSize) if err != nil { return nil, err } @@ -66,7 +68,7 @@ func (f RedisFactory) getConnection() (util.Cmder, error) { } log.Println("Redis not pinged. Try to reconnect") - conn, err := createConnection(addr, f.PoolSize) + conn, err := pool.New("tcp", addr, f.PoolSize) if err != nil { log.Printf("Cannot reconnect to redis: %v\n", err) log.Printf("Waiting %d seconds to retry\n", period) @@ -82,27 +84,44 @@ func (f RedisFactory) getConnection() (util.Cmder, error) { return f.connection, nil } -func createConnection(addr string, poolSize int) (util.Cmder, error) { - if poolSize > 1 { - return pool.New("tcp", addr, poolSize) - } else { - return redis.Dial("tcp", addr) - } -} - type redisDb struct { - conn util.Cmder + conn *pool.Pool } -const accountIdToUsernameKey string = "hash:username-to-account-id" +const accountIdToUsernameKey = "hash:username-to-account-id" func (db *redisDb) FindByUsername(username string) (*model.Skin, error) { + return findByUsername(username, db.getConn()) +} + +func (db *redisDb) FindByUserId(id int) (*model.Skin, error) { + return findByUserId(id, db.getConn()) +} + +func (db *redisDb) Save(skin *model.Skin) error { + return save(skin, db.getConn()) +} + +func (db *redisDb) RemoveByUserId(id int) error { + return removeByUserId(id, db.getConn()) +} + +func (db *redisDb) RemoveByUsername(username string) error { + return removeByUsername(username, db.getConn()) +} + +func (db *redisDb) getConn() util.Cmder { + conn, _ := db.conn.Get() + return conn +} + +func findByUsername(username string, conn util.Cmder) (*model.Skin, error) { if username == "" { return nil, &SkinNotFoundError{username} } - redisKey := buildKey(username) - response := db.conn.Cmd("GET", redisKey) + redisKey := buildUsernameKey(username) + response := conn.Cmd("GET", redisKey) if response.IsType(redis.Nil) { return nil, &SkinNotFoundError{username} } @@ -128,37 +147,72 @@ func (db *redisDb) FindByUsername(username string) (*model.Skin, error) { return skin, nil } -func (db *redisDb) FindByUserId(id int) (*model.Skin, error) { - response := db.conn.Cmd("HGET", accountIdToUsernameKey, id) +func findByUserId(id int, conn util.Cmder) (*model.Skin, error) { + response := conn.Cmd("HGET", accountIdToUsernameKey, id) if response.IsType(redis.Nil) { return nil, &SkinNotFoundError{"unknown"} } username, _ := response.Str() - return db.FindByUsername(username) + return findByUsername(username, conn) } -func (db *redisDb) Save(skin *model.Skin) error { - conn := db.conn - if poolConn, isPool := conn.(*pool.Pool); isPool { - conn, _ = poolConn.Get() +func removeByUserId(id int, conn util.Cmder) error { + record, err := findByUserId(id, conn) + if err != nil { + if _, ok := err.(*SkinNotFoundError); !ok { + return err + } } conn.Cmd("MULTI") - // Если пользователь сменил ник, то мы должны удать его ключ - if skin.OldUsername != "" && skin.OldUsername != skin.Username { - conn.Cmd("DEL", buildKey(skin.OldUsername)) + conn.Cmd("HDEL", accountIdToUsernameKey, id) + if record != nil { + conn.Cmd("DEL", buildUsernameKey(record.Username)) } - // Если это новая запись или если пользователь сменил ник, то обновляем значение в хэш-таблице + conn.Cmd("EXEC") + + return nil +} + +func removeByUsername(username string, conn util.Cmder) error { + record, err := findByUsername(username, conn) + if err != nil { + if _, ok := err.(*SkinNotFoundError); !ok { + return err + } + } + + conn.Cmd("MULTI") + + conn.Cmd("DEL", buildUsernameKey(record.Username)) + if record != nil { + conn.Cmd("HDEL", accountIdToUsernameKey, record.UserId) + } + + conn.Cmd("EXEC") + + return nil +} + +func save(skin *model.Skin, conn util.Cmder) error { + conn.Cmd("MULTI") + + // If user has changed username, then we must delete his old username record + if skin.OldUsername != "" && skin.OldUsername != skin.Username { + conn.Cmd("DEL", buildUsernameKey(skin.OldUsername)) + } + + // If this is a new record or if the user has changed username, we set the value in the hash table if skin.OldUsername != "" || skin.OldUsername != skin.Username { conn.Cmd("HSET", accountIdToUsernameKey, skin.UserId, skin.Username) } str, _ := json.Marshal(skin) - conn.Cmd("SET", buildKey(skin.Username), zlibEncode(str)) + conn.Cmd("SET", buildUsernameKey(skin.Username), zlibEncode(str)) conn.Cmd("EXEC") @@ -167,11 +221,10 @@ func (db *redisDb) Save(skin *model.Skin) error { return nil } -func buildKey(username string) string { +func buildUsernameKey(username string) string { return "username:" + strings.ToLower(username) } -//noinspection GoUnusedFunction func zlibEncode(str []byte) []byte { var buff bytes.Buffer writer := zlib.NewWriter(&buff) diff --git a/http/api.go b/http/api.go new file mode 100644 index 0000000..dbc22f0 --- /dev/null +++ b/http/api.go @@ -0,0 +1,202 @@ +package http + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "regexp" + "strconv" + + "elyby/minecraft-skinsystem/auth" + "elyby/minecraft-skinsystem/db" + "elyby/minecraft-skinsystem/interfaces" + "elyby/minecraft-skinsystem/model" + + "github.com/mono83/slf/wd" + "github.com/thedevsaddam/govalidator" +) + +func init() { + govalidator.AddCustomRule("md5", func(field string, rule string, message string, value interface{}) error { + val := []byte(value.(string)) + if ok, _ := regexp.Match(`^[a-f0-9]{32}$`, val); !ok { + if message == "" { + message = fmt.Sprintf("The %s field must be a valid md5 hash", field) + } + + return errors.New(message) + } + + return nil + }) + + govalidator.AddCustomRule("skinUploadingNotAvailable", func(field string, rule string, message string, value interface{}) error { + if message == "" { + message = "Skin uploading is temporary unavailable" + } + + return errors.New(message) + }) +} + +func (cfg *Config) PostSkin(resp http.ResponseWriter, req *http.Request) { + validationErrors := validatePostSkinRequest(req) + if validationErrors != nil { + apiBadRequest(resp, validationErrors) + return + } + + identityId, _ := strconv.Atoi(req.Form.Get("identityId")) + username := req.Form.Get("username") + + record, err := findIdentity(cfg.SkinsRepo, identityId, username) + if err != nil { + cfg.Logger.Error("Error on requesting a skin from the repository: :err", wd.ErrParam(err)) + apiServerError(resp) + return + } + + skinId, _ := strconv.Atoi(req.Form.Get("skinId")) + is18, _ := strconv.ParseBool(req.Form.Get("is1_8")) + isSlim, _ := strconv.ParseBool(req.Form.Get("isSlim")) + + record.Uuid = req.Form.Get("uuid") + record.SkinId = skinId + record.Hash = req.Form.Get("hash") + record.Is1_8 = is18 + record.IsSlim = isSlim + record.Url = req.Form.Get("url") + record.MojangTextures = req.Form.Get("mojangTextures") + record.MojangSignature = req.Form.Get("mojangSignature") + + err = cfg.SkinsRepo.Save(record) + if err != nil { + cfg.Logger.Error("Unable to save record to the repository: :err", wd.ErrParam(err)) + apiServerError(resp) + return + } + + resp.WriteHeader(http.StatusCreated) +} + +func (cfg *Config) Authenticate(handler http.Handler) http.Handler { + return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + err := cfg.Auth.Check(req) + if err != nil { + if _, ok := err.(*auth.Unauthorized); ok { + apiForbidden(resp, err.Error()) + } else { + cfg.Logger.Error("Unknown error on validating api request: :err", wd.ErrParam(err)) + apiServerError(resp) + } + + return + } + + handler.ServeHTTP(resp, req) + }) +} + +func validatePostSkinRequest(request *http.Request) map[string][]string { + const maxMultipartMemory int64 = 32 << 20 + const oneOfSkinOrUrlMessage = "One of url or skin should be provided, but not both" + + request.ParseMultipartForm(maxMultipartMemory) + + validationRules := govalidator.MapData{ + "identityId": {"required", "numeric", "min:1"}, + "username": {"required"}, + "uuid": {"required", "uuid"}, + "skinId": {"required", "numeric", "min:1"}, + "url": {"url"}, + "file:skin": {"ext:png", "size:24576", "mime:image/png"}, + "hash": {"md5"}, + "is1_8": {"bool"}, + "isSlim": {"bool"}, + } + + shouldAppendSkinRequiredError := false + url := request.Form.Get("url") + _, _, skinErr := request.FormFile("skin") + if (url != "" && skinErr == nil) || (url == "" && skinErr != nil) { + shouldAppendSkinRequiredError = true + } else if skinErr == nil { + validationRules["file:skin"] = append(validationRules["file:skin"], "skinUploadingNotAvailable") + } else if url != "" { + validationRules["hash"] = append(validationRules["hash"], "required") + validationRules["is1_8"] = append(validationRules["is1_8"], "required") + validationRules["isSlim"] = append(validationRules["isSlim"], "required") + } + + mojangTextures := request.Form.Get("mojangTextures") + if mojangTextures != "" { + validationRules["mojangSignature"] = []string{"required"} + } + + validator := govalidator.New(govalidator.Options{ + Request: request, + Rules: validationRules, + RequiredDefault: false, + FormSize: maxMultipartMemory, + }) + validationResults := validator.Validate() + if shouldAppendSkinRequiredError { + validationResults["url"] = append(validationResults["url"], oneOfSkinOrUrlMessage) + validationResults["skin"] = append(validationResults["skin"], oneOfSkinOrUrlMessage) + } + + if len(validationResults) != 0 { + return validationResults + } + + return nil +} + +func findIdentity(repo interfaces.SkinsRepository, identityId int, username string) (*model.Skin, error) { + var record *model.Skin + record, err := repo.FindByUserId(identityId) + if err != nil { + if _, isSkinNotFound := err.(*db.SkinNotFoundError); !isSkinNotFound { + return nil, err + } + + record, err = repo.FindByUsername(username) + if err == nil { + repo.RemoveByUsername(username) + record.UserId = identityId + } else { + record = &model.Skin{ + UserId: identityId, + Username: username, + } + } + } else if record.Username != username { + repo.RemoveByUserId(identityId) + record.Username = username + } + + return record, nil +} + +func apiBadRequest(resp http.ResponseWriter, errorsPerField map[string][]string) { + resp.WriteHeader(http.StatusBadRequest) + resp.Header().Set("Content-Type", "application/json") + result, _ := json.Marshal(map[string]interface{}{ + "errors": errorsPerField, + }) + resp.Write(result) +} + +func apiForbidden(resp http.ResponseWriter, reason string) { + resp.WriteHeader(http.StatusForbidden) + resp.Header().Set("Content-Type", "application/json") + result, _ := json.Marshal([]interface{}{ + reason, + }) + resp.Write(result) +} + +func apiServerError(resp http.ResponseWriter) { + resp.WriteHeader(http.StatusInternalServerError) +} diff --git a/http/api_test.go b/http/api_test.go new file mode 100644 index 0000000..6062596 --- /dev/null +++ b/http/api_test.go @@ -0,0 +1,337 @@ +package http + +import ( + "bytes" + "encoding/base64" + "io/ioutil" + "mime/multipart" + "net/http/httptest" + "net/url" + "testing" + + "elyby/minecraft-skinsystem/auth" + "elyby/minecraft-skinsystem/db" + + "github.com/golang/mock/gomock" + testify "github.com/stretchr/testify/assert" +) + +func TestConfig_PostSkin_Valid(t *testing.T) { + assert := testify.New(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + config, mocks := setupMocks(ctrl) + + resultModel := createSkinModel("mock_user", false) + resultModel.SkinId = 5 + resultModel.Hash = "94a457d92a61460cb9cb5d6f29732d2a" + resultModel.Url = "http://ely.by/minecraft/skins/default.png" + resultModel.MojangTextures = "" + resultModel.MojangSignature = "" + + mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) + mocks.Skins.EXPECT().FindByUserId(1).Return(createSkinModel("mock_user", false), nil) + mocks.Skins.EXPECT().Save(resultModel).Return(nil) + + form := url.Values{ + "identityId": {"1"}, + "username": {"mock_user"}, + "uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"}, + "skinId": {"5"}, + "hash": {"94a457d92a61460cb9cb5d6f29732d2a"}, + "is1_8": {"0"}, + "isSlim": {"0"}, + "url": {"http://ely.by/minecraft/skins/default.png"}, + } + + req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode())) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + config.CreateHandler().ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(201, resp.StatusCode) + response, _ := ioutil.ReadAll(resp.Body) + assert.Empty(string(response)) +} + +func TestConfig_PostSkin_ChangedIdentityId(t *testing.T) { + assert := testify.New(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + config, mocks := setupMocks(ctrl) + + resultModel := createSkinModel("mock_user", false) + resultModel.UserId = 2 + resultModel.SkinId = 5 + resultModel.Hash = "94a457d92a61460cb9cb5d6f29732d2a" + resultModel.Url = "http://ely.by/minecraft/skins/default.png" + resultModel.MojangTextures = "" + resultModel.MojangSignature = "" + + form := url.Values{ + "identityId": {"2"}, + "username": {"mock_user"}, + "uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"}, + "skinId": {"5"}, + "hash": {"94a457d92a61460cb9cb5d6f29732d2a"}, + "is1_8": {"0"}, + "isSlim": {"0"}, + "url": {"http://ely.by/minecraft/skins/default.png"}, + } + + req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode())) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) + mocks.Skins.EXPECT().FindByUserId(2).Return(nil, &db.SkinNotFoundError{"unknown"}) + mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil) + mocks.Skins.EXPECT().RemoveByUsername("mock_user").Return(nil) + mocks.Skins.EXPECT().Save(resultModel).Return(nil) + + config.CreateHandler().ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(201, resp.StatusCode) + response, _ := ioutil.ReadAll(resp.Body) + assert.Empty(string(response)) +} + +func TestConfig_PostSkin_ChangedUsername(t *testing.T) { + assert := testify.New(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + config, mocks := setupMocks(ctrl) + + resultModel := createSkinModel("changed_username", false) + resultModel.SkinId = 5 + resultModel.Hash = "94a457d92a61460cb9cb5d6f29732d2a" + resultModel.Url = "http://ely.by/minecraft/skins/default.png" + resultModel.MojangTextures = "" + resultModel.MojangSignature = "" + + form := url.Values{ + "identityId": {"1"}, + "username": {"changed_username"}, + "uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"}, + "skinId": {"5"}, + "hash": {"94a457d92a61460cb9cb5d6f29732d2a"}, + "is1_8": {"0"}, + "isSlim": {"0"}, + "url": {"http://ely.by/minecraft/skins/default.png"}, + } + + req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode())) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) + mocks.Skins.EXPECT().FindByUserId(1).Return(createSkinModel("mock_user", false), nil) + mocks.Skins.EXPECT().RemoveByUserId(1).Return(nil) + mocks.Skins.EXPECT().Save(resultModel).Return(nil) + + config.CreateHandler().ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(201, resp.StatusCode) + response, _ := ioutil.ReadAll(resp.Body) + assert.Empty(string(response)) +} + +func TestConfig_PostSkin_CompletelyNewIdentity(t *testing.T) { + assert := testify.New(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + config, mocks := setupMocks(ctrl) + + resultModel := createSkinModel("mock_user", false) + resultModel.SkinId = 5 + resultModel.Hash = "94a457d92a61460cb9cb5d6f29732d2a" + resultModel.Url = "http://ely.by/minecraft/skins/default.png" + resultModel.MojangTextures = "" + resultModel.MojangSignature = "" + + form := url.Values{ + "identityId": {"1"}, + "username": {"mock_user"}, + "uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"}, + "skinId": {"5"}, + "hash": {"94a457d92a61460cb9cb5d6f29732d2a"}, + "is1_8": {"0"}, + "isSlim": {"0"}, + "url": {"http://ely.by/minecraft/skins/default.png"}, + } + + req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode())) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) + mocks.Skins.EXPECT().FindByUserId(1).Return(nil, &db.SkinNotFoundError{"unknown"}) + mocks.Skins.EXPECT().FindByUsername("mock_user").Return(nil, &db.SkinNotFoundError{"mock_user"}) + mocks.Skins.EXPECT().Save(resultModel).Return(nil) + + config.CreateHandler().ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(201, resp.StatusCode) + response, _ := ioutil.ReadAll(resp.Body) + assert.Empty(string(response)) +} + +func TestConfig_PostSkin_UploadSkin(t *testing.T) { + assert := testify.New(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + config, mocks := setupMocks(ctrl) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + part, _ := writer.CreateFormFile("skin", "char.png") + part.Write(loadSkinFile()) + + _ = writer.WriteField("identityId", "1") + _ = writer.WriteField("username", "mock_user") + _ = writer.WriteField("uuid", "0f657aa8-bfbe-415d-b700-5750090d3af3") + _ = writer.WriteField("skinId", "5") + + err := writer.Close() + if err != nil { + panic(err) + } + + req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", body) + req.Header.Add("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + + mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) + + config.CreateHandler().ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(400, resp.StatusCode) + response, _ := ioutil.ReadAll(resp.Body) + assert.JSONEq(`{ + "errors": { + "skin": [ + "Skin uploading is temporary unavailable" + ] + } + }`, string(response)) +} + +func TestConfig_PostSkin_RequiredFields(t *testing.T) { + assert := testify.New(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + config, mocks := setupMocks(ctrl) + + form := url.Values{ + "hash": {"this is not md5"}, + "mojangTextures": {"someBase64EncodedString"}, + } + + req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode())) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) + + config.CreateHandler().ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(400, resp.StatusCode) + response, _ := ioutil.ReadAll(resp.Body) + assert.JSONEq(`{ + "errors": { + "identityId": [ + "The identityId field is required", + "The identityId field must be numeric", + "The identityId field must be minimum 1 char" + ], + "skinId": [ + "The skinId field is required", + "The skinId field must be numeric", + "The skinId field must be minimum 1 char" + ], + "username": [ + "The username field is required" + ], + "uuid": [ + "The uuid field is required", + "The uuid field must contain valid UUID" + ], + "hash": [ + "The hash field must be a valid md5 hash" + ], + "url": [ + "One of url or skin should be provided, but not both" + ], + "skin": [ + "One of url or skin should be provided, but not both" + ], + "mojangSignature": [ + "The mojangSignature field is required" + ] + } + }`, string(response)) +} + +func TestConfig_PostSkin_Unauthorized(t *testing.T) { + assert := testify.New(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + config, mocks := setupMocks(ctrl) + + req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", nil) + req.Header.Add("Authorization", "Bearer invalid.jwt.token") + w := httptest.NewRecorder() + + mocks.Auth.EXPECT().Check(gomock.Any()).Return(&auth.Unauthorized{"Cannot parse passed JWT token"}) + + config.CreateHandler().ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(403, resp.StatusCode) + response, _ := ioutil.ReadAll(resp.Body) + assert.JSONEq(`[ + "Cannot parse passed JWT token" + ]`, string(response)) +} + +// base64 https://github.com/mathiasbynens/small/blob/0ca3c51/png-transparent.png +var OnePxPng = []byte("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==") + +func loadSkinFile() []byte { + result := make([]byte, 92) + _, err := base64.StdEncoding.Decode(result, OnePxPng) + if err != nil { + panic(err) + } + + return result +} diff --git a/http/http.go b/http/http.go index b07fdd0..e68aedf 100644 --- a/http/http.go +++ b/http/http.go @@ -22,6 +22,7 @@ type Config struct { SkinsRepo interfaces.SkinsRepository CapesRepo interfaces.CapesRepository Logger wd.Watchdog + Auth interfaces.AuthChecker } func (cfg *Config) Run() error { @@ -59,6 +60,8 @@ func (cfg *Config) CreateHandler() http.Handler { // Legacy router.HandleFunc("/skins", cfg.SkinGET).Methods("GET") router.HandleFunc("/cloaks", cfg.CapeGET).Methods("GET") + // API + router.Handle("/api/skins", cfg.Authenticate(http.HandlerFunc(cfg.PostSkin))).Methods("POST") // 404 router.NotFoundHandler = http.HandlerFunc(cfg.NotFound) diff --git a/http/http_test.go b/http/http_test.go index 3db459d..3c58763 100644 --- a/http/http_test.go +++ b/http/http_test.go @@ -25,6 +25,7 @@ func TestBuildElyUrl(t *testing.T) { type mocks struct { Skins *mock_interfaces.MockSkinsRepository Capes *mock_interfaces.MockCapesRepository + Auth *mock_interfaces.MockAuthChecker Log *mock_wd.MockWatchdog } @@ -34,15 +35,18 @@ func setupMocks(ctrl *gomock.Controller) ( ) { skinsRepo := mock_interfaces.NewMockSkinsRepository(ctrl) capesRepo := mock_interfaces.NewMockCapesRepository(ctrl) + authChecker := mock_interfaces.NewMockAuthChecker(ctrl) wd := mock_wd.NewMockWatchdog(ctrl) return &Config{ SkinsRepo: skinsRepo, CapesRepo: capesRepo, + Auth: authChecker, Logger: wd, }, &mocks{ Skins: skinsRepo, Capes: capesRepo, + Auth: authChecker, Log: wd, } } diff --git a/interfaces/auth.go b/interfaces/auth.go new file mode 100644 index 0000000..3f645f7 --- /dev/null +++ b/interfaces/auth.go @@ -0,0 +1,7 @@ +package interfaces + +import "net/http" + +type AuthChecker interface { + Check(req *http.Request) error +} diff --git a/interfaces/mock_interfaces/mock_interfaces.go b/interfaces/mock_interfaces/mock_interfaces.go index 72b1a52..78744c4 100644 --- a/interfaces/mock_interfaces/mock_interfaces.go +++ b/interfaces/mock_interfaces/mock_interfaces.go @@ -70,6 +70,30 @@ func (_mr *MockSkinsRepositoryMockRecorder) Save(arg0 interface{}) *gomock.Call return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Save", reflect.TypeOf((*MockSkinsRepository)(nil).Save), arg0) } +// RemoveByUserId mocks base method +func (_m *MockSkinsRepository) RemoveByUserId(id int) error { + ret := _m.ctrl.Call(_m, "RemoveByUserId", id) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemoveByUserId indicates an expected call of RemoveByUserId +func (_mr *MockSkinsRepositoryMockRecorder) RemoveByUserId(arg0 interface{}) *gomock.Call { + return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "RemoveByUserId", reflect.TypeOf((*MockSkinsRepository)(nil).RemoveByUserId), arg0) +} + +// RemoveByUsername mocks base method +func (_m *MockSkinsRepository) RemoveByUsername(username string) error { + ret := _m.ctrl.Call(_m, "RemoveByUsername", username) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemoveByUsername indicates an expected call of RemoveByUsername +func (_mr *MockSkinsRepositoryMockRecorder) RemoveByUsername(arg0 interface{}) *gomock.Call { + return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "RemoveByUsername", reflect.TypeOf((*MockSkinsRepository)(nil).RemoveByUsername), arg0) +} + // MockCapesRepository is a mock of CapesRepository interface type MockCapesRepository struct { ctrl *gomock.Controller diff --git a/interfaces/repositories.go b/interfaces/repositories.go index 94164e9..5fdca61 100644 --- a/interfaces/repositories.go +++ b/interfaces/repositories.go @@ -8,6 +8,8 @@ type SkinsRepository interface { FindByUsername(username string) (*model.Skin, error) FindByUserId(id int) (*model.Skin, error) Save(skin *model.Skin) error + RemoveByUserId(id int) error + RemoveByUsername(username string) error } type CapesRepository interface { diff --git a/script/mocks b/script/mocks index d73d1ef..4bab1ab 100755 --- a/script/mocks +++ b/script/mocks @@ -2,3 +2,4 @@ mockgen -source=interfaces/repositories.go -destination=interfaces/mock_interfaces/mock_interfaces.go mockgen -source=interfaces/api.go -destination=interfaces/mock_interfaces/mock_api.go +mockgen -source=interfaces/auth.go -destination=interfaces/mock_interfaces/mock_auth.go