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
This commit is contained in:
ErickSkrauch 2018-01-23 18:43:37 +03:00
parent aaff88d32f
commit f120064fe3
No known key found for this signature in database
GPG Key ID: 669339FCBB30EE0E
14 changed files with 923 additions and 55 deletions

9
Gopkg.lock generated
View File

@ -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

View File

@ -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]]

View File

@ -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 {

168
auth/jwt_test.go Normal file
View File

@ -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()
}

View File

@ -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 {

View File

@ -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)

202
http/api.go Normal file
View File

@ -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)
}

337
http/api_test.go Normal file
View File

@ -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
}

View File

@ -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)

View File

@ -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,
}
}

7
interfaces/auth.go Normal file
View File

@ -0,0 +1,7 @@
package interfaces
import "net/http"
type AuthChecker interface {
Check(req *http.Request) error
}

View File

@ -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

View File

@ -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 {

View File

@ -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