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" revision = "69483b4bd14f5845b5a1e55bca19e954e827f1d0"
version = "v1.1.4" version = "v1.1.4"
[[projects]]
branch = "issue-18"
name = "github.com/thedevsaddam/govalidator"
packages = ["."]
revision = "59055296916bb3c6ad9cf3b21d5f2cf7059f8e76"
source = "https://github.com/erickskrauch/govalidator.git"
[[projects]] [[projects]]
branch = "master" branch = "master"
name = "golang.org/x/crypto" name = "golang.org/x/crypto"
@ -214,6 +221,6 @@
[solve-meta] [solve-meta]
analyzer-name = "dep" analyzer-name = "dep"
analyzer-version = 1 analyzer-version = 1
inputs-digest = "a12e681ec671ce8a93256cd754d4e70797476b2d2ce4379c3860df09c4b6a552" inputs-digest = "b7c6dd9fffc543dc24b5832c7767632e4c066189be7c40868ba5612f5f45dc64"
solver-name = "gps-cdcl" solver-name = "gps-cdcl"
solver-version = 1 solver-version = 1

View File

@ -34,6 +34,15 @@ ignored = ["elyby/minecraft-skinsystem"]
name = "github.com/segmentio/go-prompt" name = "github.com/segmentio/go-prompt"
branch = "master" 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 # Testing dependencies
[[constraint]] [[constraint]]

View File

@ -2,17 +2,21 @@ package auth
import ( import (
"encoding/base64" "encoding/base64"
"io/ioutil"
"math" "math"
"math/rand" "math/rand"
"net/http"
"os" "os"
"strings"
"time" "time"
"github.com/SermoDigital/jose/crypto" "github.com/SermoDigital/jose/crypto"
"github.com/SermoDigital/jose/jws" "github.com/SermoDigital/jose/jws"
"github.com/mitchellh/go-homedir" "github.com/mitchellh/go-homedir"
"github.com/spf13/afero"
) )
var fs = afero.NewOsFs()
var hashAlg = crypto.SigningMethodHS256 var hashAlg = crypto.SigningMethodHS256
const appHomeDirName = ".minecraft-skinsystem" const appHomeDirName = ".minecraft-skinsystem"
@ -52,39 +56,68 @@ func (t *JwtAuth) GenerateSigningKey() error {
} }
key := generateRandomBytes(64) 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 err
} }
return nil return nil
} }
func (t *JwtAuth) getSigningKey() ([]byte, error) { func (t *JwtAuth) Check(req *http.Request) error {
if t.signingKey != nil { bearerToken := req.Header.Get("Authorization")
return t.signingKey, nil if bearerToken == "" {
return &Unauthorized{"Authentication header not presented"}
} }
path := getKeyPath() if !strings.EqualFold(bearerToken[0:7], "BEARER ") {
if _, err := os.Stat(path); err != nil { return &Unauthorized{"Cannot recognize JWT token in passed value"}
if os.IsNotExist(err) { }
return nil, &SigningKeyNotAvailable{}
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) return t.signingKey, nil
if err != nil {
return nil, err
}
return key, nil
} }
func createAppHomeDir() error { func createAppHomeDir() error {
path := getAppHomeDirPath() path := getAppHomeDirPath()
if _, err := os.Stat(path); os.IsNotExist(err) { if _, err := fs.Stat(path); os.IsNotExist(err) {
err := os.Mkdir(path, 0755) // rwx r-x r-x err := fs.Mkdir(path, 0755) // rwx r-x r-x
if err != nil { if err != nil {
return err return err
} }
@ -107,13 +140,28 @@ func getKeyPath() string {
} }
func generateRandomBytes(n int) []byte { 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) randBytes := make([]byte, randLen)
rand.Read(randBytes) 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) 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 { 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" "fmt"
"log" "log"
"elyby/minecraft-skinsystem/auth"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
@ -42,9 +44,10 @@ var serveCmd = &cobra.Command{
cfg := &http.Config{ cfg := &http.Config{
ListenSpec: fmt.Sprintf("%s:%d", viper.GetString("server.host"), viper.GetInt("server.port")), ListenSpec: fmt.Sprintf("%s:%d", viper.GetString("server.host"), viper.GetInt("server.port")),
SkinsRepo: skinsRepo, SkinsRepo: skinsRepo,
CapesRepo: capesRepo, CapesRepo: capesRepo,
Logger: logger, Logger: logger,
Auth: &auth.JwtAuth{},
} }
if err := cfg.Run(); err != nil { if err := cfg.Run(); err != nil {

View File

@ -22,9 +22,11 @@ type RedisFactory struct {
Host string Host string
Port int Port int
PoolSize 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) { func (f RedisFactory) CreateSkinsRepository() (interfaces.SkinsRepository, error) {
connection, err := f.getConnection() connection, err := f.getConnection()
if err != nil { if err != nil {
@ -38,7 +40,7 @@ func (f RedisFactory) CreateCapesRepository() (interfaces.CapesRepository, error
panic("capes repository not supported for this storage type") 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.connection == nil {
if f.Host == "" { if f.Host == "" {
return nil, &ParamRequired{"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) 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 { if err != nil {
return nil, err return nil, err
} }
@ -66,7 +68,7 @@ func (f RedisFactory) getConnection() (util.Cmder, error) {
} }
log.Println("Redis not pinged. Try to reconnect") 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 { if err != nil {
log.Printf("Cannot reconnect to redis: %v\n", err) log.Printf("Cannot reconnect to redis: %v\n", err)
log.Printf("Waiting %d seconds to retry\n", period) log.Printf("Waiting %d seconds to retry\n", period)
@ -82,27 +84,44 @@ func (f RedisFactory) getConnection() (util.Cmder, error) {
return f.connection, nil 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 { 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) { 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 == "" { if username == "" {
return nil, &SkinNotFoundError{username} return nil, &SkinNotFoundError{username}
} }
redisKey := buildKey(username) redisKey := buildUsernameKey(username)
response := db.conn.Cmd("GET", redisKey) response := conn.Cmd("GET", redisKey)
if response.IsType(redis.Nil) { if response.IsType(redis.Nil) {
return nil, &SkinNotFoundError{username} return nil, &SkinNotFoundError{username}
} }
@ -128,37 +147,72 @@ func (db *redisDb) FindByUsername(username string) (*model.Skin, error) {
return skin, nil return skin, nil
} }
func (db *redisDb) FindByUserId(id int) (*model.Skin, error) { func findByUserId(id int, conn util.Cmder) (*model.Skin, error) {
response := db.conn.Cmd("HGET", accountIdToUsernameKey, id) response := conn.Cmd("HGET", accountIdToUsernameKey, id)
if response.IsType(redis.Nil) { if response.IsType(redis.Nil) {
return nil, &SkinNotFoundError{"unknown"} return nil, &SkinNotFoundError{"unknown"}
} }
username, _ := response.Str() username, _ := response.Str()
return db.FindByUsername(username) return findByUsername(username, conn)
} }
func (db *redisDb) Save(skin *model.Skin) error { func removeByUserId(id int, conn util.Cmder) error {
conn := db.conn record, err := findByUserId(id, conn)
if poolConn, isPool := conn.(*pool.Pool); isPool { if err != nil {
conn, _ = poolConn.Get() if _, ok := err.(*SkinNotFoundError); !ok {
return err
}
} }
conn.Cmd("MULTI") conn.Cmd("MULTI")
// Если пользователь сменил ник, то мы должны удать его ключ conn.Cmd("HDEL", accountIdToUsernameKey, id)
if skin.OldUsername != "" && skin.OldUsername != skin.Username { if record != nil {
conn.Cmd("DEL", buildKey(skin.OldUsername)) 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 { if skin.OldUsername != "" || skin.OldUsername != skin.Username {
conn.Cmd("HSET", accountIdToUsernameKey, skin.UserId, skin.Username) conn.Cmd("HSET", accountIdToUsernameKey, skin.UserId, skin.Username)
} }
str, _ := json.Marshal(skin) str, _ := json.Marshal(skin)
conn.Cmd("SET", buildKey(skin.Username), zlibEncode(str)) conn.Cmd("SET", buildUsernameKey(skin.Username), zlibEncode(str))
conn.Cmd("EXEC") conn.Cmd("EXEC")
@ -167,11 +221,10 @@ func (db *redisDb) Save(skin *model.Skin) error {
return nil return nil
} }
func buildKey(username string) string { func buildUsernameKey(username string) string {
return "username:" + strings.ToLower(username) return "username:" + strings.ToLower(username)
} }
//noinspection GoUnusedFunction
func zlibEncode(str []byte) []byte { func zlibEncode(str []byte) []byte {
var buff bytes.Buffer var buff bytes.Buffer
writer := zlib.NewWriter(&buff) 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 SkinsRepo interfaces.SkinsRepository
CapesRepo interfaces.CapesRepository CapesRepo interfaces.CapesRepository
Logger wd.Watchdog Logger wd.Watchdog
Auth interfaces.AuthChecker
} }
func (cfg *Config) Run() error { func (cfg *Config) Run() error {
@ -59,6 +60,8 @@ func (cfg *Config) CreateHandler() http.Handler {
// Legacy // Legacy
router.HandleFunc("/skins", cfg.SkinGET).Methods("GET") router.HandleFunc("/skins", cfg.SkinGET).Methods("GET")
router.HandleFunc("/cloaks", cfg.CapeGET).Methods("GET") router.HandleFunc("/cloaks", cfg.CapeGET).Methods("GET")
// API
router.Handle("/api/skins", cfg.Authenticate(http.HandlerFunc(cfg.PostSkin))).Methods("POST")
// 404 // 404
router.NotFoundHandler = http.HandlerFunc(cfg.NotFound) router.NotFoundHandler = http.HandlerFunc(cfg.NotFound)

View File

@ -25,6 +25,7 @@ func TestBuildElyUrl(t *testing.T) {
type mocks struct { type mocks struct {
Skins *mock_interfaces.MockSkinsRepository Skins *mock_interfaces.MockSkinsRepository
Capes *mock_interfaces.MockCapesRepository Capes *mock_interfaces.MockCapesRepository
Auth *mock_interfaces.MockAuthChecker
Log *mock_wd.MockWatchdog Log *mock_wd.MockWatchdog
} }
@ -34,15 +35,18 @@ func setupMocks(ctrl *gomock.Controller) (
) { ) {
skinsRepo := mock_interfaces.NewMockSkinsRepository(ctrl) skinsRepo := mock_interfaces.NewMockSkinsRepository(ctrl)
capesRepo := mock_interfaces.NewMockCapesRepository(ctrl) capesRepo := mock_interfaces.NewMockCapesRepository(ctrl)
authChecker := mock_interfaces.NewMockAuthChecker(ctrl)
wd := mock_wd.NewMockWatchdog(ctrl) wd := mock_wd.NewMockWatchdog(ctrl)
return &Config{ return &Config{
SkinsRepo: skinsRepo, SkinsRepo: skinsRepo,
CapesRepo: capesRepo, CapesRepo: capesRepo,
Auth: authChecker,
Logger: wd, Logger: wd,
}, &mocks{ }, &mocks{
Skins: skinsRepo, Skins: skinsRepo,
Capes: capesRepo, Capes: capesRepo,
Auth: authChecker,
Log: wd, 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) 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 // MockCapesRepository is a mock of CapesRepository interface
type MockCapesRepository struct { type MockCapesRepository struct {
ctrl *gomock.Controller ctrl *gomock.Controller

View File

@ -8,6 +8,8 @@ type SkinsRepository interface {
FindByUsername(username string) (*model.Skin, error) FindByUsername(username string) (*model.Skin, error)
FindByUserId(id int) (*model.Skin, error) FindByUserId(id int) (*model.Skin, error)
Save(skin *model.Skin) error Save(skin *model.Skin) error
RemoveByUserId(id int) error
RemoveByUsername(username string) error
} }
type CapesRepository interface { type CapesRepository interface {

View File

@ -2,3 +2,4 @@
mockgen -source=interfaces/repositories.go -destination=interfaces/mock_interfaces/mock_interfaces.go 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/api.go -destination=interfaces/mock_interfaces/mock_api.go
mockgen -source=interfaces/auth.go -destination=interfaces/mock_interfaces/mock_auth.go