Merge branch 'v5' into otel

# Conflicts:
#	go.sum
#	internal/cmd/serve.go
#	internal/http/http.go
This commit is contained in:
ErickSkrauch
2024-03-05 14:19:04 +01:00
19 changed files with 761 additions and 278 deletions

View File

@@ -3,7 +3,6 @@ package http
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"time"
@@ -11,12 +10,10 @@ import (
"github.com/mono83/slf"
"github.com/mono83/slf/wd"
"ely.by/chrly/internal/version"
"ely.by/chrly/internal/security"
)
func StartServer(ctx context.Context, server *http.Server, logger slf.Logger) {
slog.Debug("Chrly :v (:c)", slog.String("v", version.Version()), slog.String("c", version.Commit()))
srvErr := make(chan error, 1)
go func() {
logger.Info("Starting the server, HTTP on: :addr", wd.StringParam("addr", server.Addr))
@@ -40,15 +37,13 @@ func StartServer(ctx context.Context, server *http.Server, logger slf.Logger) {
}
type Authenticator interface {
Authenticate(req *http.Request) error
Authenticate(req *http.Request, scope security.Scope) error
}
// The current middleware implementation doesn't check the scope assigned to the token.
// For now there is only one scope and at this moment I don't want to spend time on it
func CreateAuthenticationMiddleware(checker Authenticator) mux.MiddlewareFunc {
func NewAuthenticationMiddleware(authenticator Authenticator, scope security.Scope) mux.MiddlewareFunc {
return func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
err := checker.Authenticate(req)
err := authenticator.Authenticate(req, scope)
if err != nil {
apiForbidden(resp, err.Error())
return
@@ -59,6 +54,18 @@ func CreateAuthenticationMiddleware(checker Authenticator) mux.MiddlewareFunc {
}
}
func NewConditionalMiddleware(cond func(req *http.Request) bool, m mux.MiddlewareFunc) mux.MiddlewareFunc {
return func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
if cond(req) {
handler = m.Middleware(handler)
}
handler.ServeHTTP(resp, req)
})
}
}
func NotFoundHandler(response http.ResponseWriter, _ *http.Request) {
data, _ := json.Marshal(map[string]string{
"status": "404",
@@ -73,7 +80,7 @@ func NotFoundHandler(response http.ResponseWriter, _ *http.Request) {
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{}{
result, _ := json.Marshal(map[string]any{
"errors": errorsPerField,
})
_, _ = resp.Write(result)
@@ -90,7 +97,7 @@ func apiServerError(resp http.ResponseWriter, err error) {
func apiForbidden(resp http.ResponseWriter, reason string) {
resp.WriteHeader(http.StatusForbidden)
resp.Header().Set("Content-Type", "application/json")
result, _ := json.Marshal(map[string]interface{}{
result, _ := json.Marshal(map[string]any{
"error": reason,
})
_, _ = resp.Write(result)

View File

@@ -2,34 +2,35 @@ package http
import (
"errors"
"io/ioutil"
"io"
"net/http"
"net/http/httptest"
"testing"
testify "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
testify "github.com/stretchr/testify/require"
"ely.by/chrly/internal/security"
)
type authCheckerMock struct {
mock.Mock
}
func (m *authCheckerMock) Authenticate(req *http.Request) error {
args := m.Called(req)
return args.Error(0)
func (m *authCheckerMock) Authenticate(req *http.Request, scope security.Scope) error {
return m.Called(req, scope).Error(0)
}
func TestCreateAuthenticationMiddleware(t *testing.T) {
func TestAuthenticationMiddleware(t *testing.T) {
t.Run("pass", func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com", nil)
req := httptest.NewRequest("GET", "https://example.com", nil)
resp := httptest.NewRecorder()
auth := &authCheckerMock{}
auth.On("Authenticate", req).Once().Return(nil)
auth.On("Authenticate", req, security.Scope("mock")).Once().Return(nil)
isHandlerCalled := false
middlewareFunc := CreateAuthenticationMiddleware(auth)
middlewareFunc := NewAuthenticationMiddleware(auth, "mock")
middlewareFunc.Middleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
isHandlerCalled = true
})).ServeHTTP(resp, req)
@@ -40,21 +41,21 @@ func TestCreateAuthenticationMiddleware(t *testing.T) {
})
t.Run("fail", func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com", nil)
req := httptest.NewRequest("GET", "https://example.com", nil)
resp := httptest.NewRecorder()
auth := &authCheckerMock{}
auth.On("Authenticate", req).Once().Return(errors.New("error reason"))
auth.On("Authenticate", req, security.Scope("mock")).Once().Return(errors.New("error reason"))
isHandlerCalled := false
middlewareFunc := CreateAuthenticationMiddleware(auth)
middlewareFunc := NewAuthenticationMiddleware(auth, "mock")
middlewareFunc.Middleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
isHandlerCalled = true
})).ServeHTTP(resp, req)
testify.False(t, isHandlerCalled, "Handler shouldn't be called")
testify.Equal(t, 403, resp.Code)
body, _ := ioutil.ReadAll(resp.Body)
body, _ := io.ReadAll(resp.Body)
testify.JSONEq(t, `{
"error": "error reason"
}`, string(body))
@@ -63,10 +64,56 @@ func TestCreateAuthenticationMiddleware(t *testing.T) {
})
}
func TestConditionalMiddleware(t *testing.T) {
t.Run("true", func(t *testing.T) {
req := httptest.NewRequest("GET", "https://example.com", nil)
resp := httptest.NewRecorder()
isNestedMiddlewareCalled := false
isHandlerCalled := false
NewConditionalMiddleware(
func(req *http.Request) bool {
return true
},
func(handler http.Handler) http.Handler {
isNestedMiddlewareCalled = true
return handler
},
).Middleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
isHandlerCalled = true
})).ServeHTTP(resp, req)
testify.True(t, isNestedMiddlewareCalled, "Nested middleware wasn't called")
testify.True(t, isHandlerCalled, "Handler wasn't called from the middleware")
})
t.Run("false", func(t *testing.T) {
req := httptest.NewRequest("GET", "https://example.com", nil)
resp := httptest.NewRecorder()
isNestedMiddlewareCalled := false
isHandlerCalled := false
NewConditionalMiddleware(
func(req *http.Request) bool {
return false
},
func(handler http.Handler) http.Handler {
isNestedMiddlewareCalled = true
return handler
},
).Middleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
isHandlerCalled = true
})).ServeHTTP(resp, req)
testify.False(t, isNestedMiddlewareCalled, "Nested middleware shouldn't be called")
testify.True(t, isHandlerCalled, "Handler wasn't called from the middleware")
})
}
func TestNotFoundHandler(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("GET", "http://example.com", nil)
req := httptest.NewRequest("GET", "https://example.com", nil)
w := httptest.NewRecorder()
NotFoundHandler(w, req)
@@ -74,7 +121,7 @@ func TestNotFoundHandler(t *testing.T) {
resp := w.Result()
assert.Equal(404, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
response, _ := io.ReadAll(resp.Body)
assert.JSONEq(`{
"status": "404",
"message": "Not Found"

View File

@@ -17,19 +17,19 @@ type ProfilesManager interface {
RemoveProfileByUuid(ctx context.Context, uuid string) error
}
type Api struct {
type ProfilesApi struct {
ProfilesManager
}
func (ctx *Api) Handler() *mux.Router {
func (ctx *ProfilesApi) Handler() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
router.HandleFunc("/profiles", ctx.postProfileHandler).Methods(http.MethodPost)
router.HandleFunc("/profiles/{uuid}", ctx.deleteProfileByUuidHandler).Methods(http.MethodDelete)
router.HandleFunc("/", ctx.postProfileHandler).Methods(http.MethodPost)
router.HandleFunc("/{uuid}", ctx.deleteProfileByUuidHandler).Methods(http.MethodDelete)
return router
}
func (ctx *Api) postProfileHandler(resp http.ResponseWriter, req *http.Request) {
func (ctx *ProfilesApi) postProfileHandler(resp http.ResponseWriter, req *http.Request) {
err := req.ParseForm()
if err != nil {
apiBadRequest(resp, map[string][]string{
@@ -63,7 +63,7 @@ func (ctx *Api) postProfileHandler(resp http.ResponseWriter, req *http.Request)
resp.WriteHeader(http.StatusCreated)
}
func (ctx *Api) deleteProfileByUuidHandler(resp http.ResponseWriter, req *http.Request) {
func (ctx *ProfilesApi) deleteProfileByUuidHandler(resp http.ResponseWriter, req *http.Request) {
uuid := mux.Vars(req)["uuid"]
err := ctx.ProfilesManager.RemoveProfileByUuid(req.Context(), uuid)
if err != nil {

View File

@@ -30,26 +30,26 @@ func (m *ProfilesManagerMock) RemoveProfileByUuid(ctx context.Context, uuid stri
return m.Called(ctx, uuid).Error(0)
}
type ApiTestSuite struct {
type ProfilesTestSuite struct {
suite.Suite
App *Api
App *ProfilesApi
ProfilesManager *ProfilesManagerMock
}
func (t *ApiTestSuite) SetupSubTest() {
func (t *ProfilesTestSuite) SetupSubTest() {
t.ProfilesManager = &ProfilesManagerMock{}
t.App = &Api{
t.App = &ProfilesApi{
ProfilesManager: t.ProfilesManager,
}
}
func (t *ApiTestSuite) TearDownSubTest() {
func (t *ProfilesTestSuite) TearDownSubTest() {
t.ProfilesManager.AssertExpectations(t.T())
}
func (t *ApiTestSuite) TestPostProfile() {
func (t *ProfilesTestSuite) TestPostProfile() {
t.Run("successfully post profile", func() {
t.ProfilesManager.On("PersistProfile", mock.Anything, &db.Profile{
Uuid: "0f657aa8-bfbe-415d-b700-5750090d3af3",
@@ -61,7 +61,7 @@ func (t *ApiTestSuite) TestPostProfile() {
MojangSignature: "bW9jawo=",
}).Once().Return(nil)
req := httptest.NewRequest("POST", "http://chrly/profiles", bytes.NewBufferString(url.Values{
req := httptest.NewRequest("POST", "http://chrly/", bytes.NewBufferString(url.Values{
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"username": {"mock_username"},
"skinUrl": {"https://example.com/skin.png"},
@@ -82,7 +82,7 @@ func (t *ApiTestSuite) TestPostProfile() {
})
t.Run("handle malformed body", func() {
req := httptest.NewRequest("POST", "http://chrly/profiles", strings.NewReader("invalid;=url?encoded_string"))
req := httptest.NewRequest("POST", "http://chrly/", strings.NewReader("invalid;=url?encoded_string"))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
@@ -107,7 +107,7 @@ func (t *ApiTestSuite) TestPostProfile() {
},
})
req := httptest.NewRequest("POST", "http://chrly/profiles", strings.NewReader(""))
req := httptest.NewRequest("POST", "http://chrly/", strings.NewReader(""))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
@@ -129,7 +129,7 @@ func (t *ApiTestSuite) TestPostProfile() {
t.Run("receive other error", func() {
t.ProfilesManager.On("PersistProfile", mock.Anything, mock.Anything).Once().Return(errors.New("mock error"))
req := httptest.NewRequest("POST", "http://chrly/profiles", strings.NewReader(""))
req := httptest.NewRequest("POST", "http://chrly/", strings.NewReader(""))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
@@ -140,11 +140,11 @@ func (t *ApiTestSuite) TestPostProfile() {
})
}
func (t *ApiTestSuite) TestDeleteProfileByUuid() {
func (t *ProfilesTestSuite) TestDeleteProfileByUuid() {
t.Run("successfully delete", func() {
t.ProfilesManager.On("RemoveProfileByUuid", mock.Anything, "0f657aa8-bfbe-415d-b700-5750090d3af3").Once().Return(nil)
req := httptest.NewRequest("DELETE", "http://chrly/profiles/0f657aa8-bfbe-415d-b700-5750090d3af3", nil)
req := httptest.NewRequest("DELETE", "http://chrly/0f657aa8-bfbe-415d-b700-5750090d3af3", nil)
w := httptest.NewRecorder()
t.App.Handler().ServeHTTP(w, req)
@@ -158,7 +158,7 @@ func (t *ApiTestSuite) TestDeleteProfileByUuid() {
t.Run("error from manager", func() {
t.ProfilesManager.On("RemoveProfileByUuid", mock.Anything, mock.Anything).Return(errors.New("mock error"))
req := httptest.NewRequest("DELETE", "http://chrly/profiles/0f657aa8-bfbe-415d-b700-5750090d3af3", nil)
req := httptest.NewRequest("DELETE", "http://chrly/0f657aa8-bfbe-415d-b700-5750090d3af3", nil)
w := httptest.NewRecorder()
t.App.Handler().ServeHTTP(w, req)
@@ -168,6 +168,6 @@ func (t *ApiTestSuite) TestDeleteProfileByUuid() {
})
}
func TestApi(t *testing.T) {
suite.Run(t, new(ApiTestSuite))
func TestProfilesApi(t *testing.T) {
suite.Run(t, new(ProfilesTestSuite))
}

60
internal/http/signer.go Normal file
View File

@@ -0,0 +1,60 @@
package http
import (
"encoding/base64"
"fmt"
"io"
"net/http"
"github.com/gorilla/mux"
)
type Signer interface {
Sign(data io.Reader) ([]byte, error)
GetPublicKey(format string) ([]byte, error)
}
type SignerApi struct {
Signer
}
func (s *SignerApi) Handler() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
router.HandleFunc("/", s.signHandler).Methods(http.MethodPost)
router.HandleFunc("/public-key.{format:(?:pem|der)}", s.getPublicKeyHandler).Methods(http.MethodGet)
return router
}
func (s *SignerApi) signHandler(resp http.ResponseWriter, req *http.Request) {
signature, err := s.Signer.Sign(req.Body)
if err != nil {
apiServerError(resp, fmt.Errorf("unable to sign the value: %w", err))
return
}
resp.Header().Set("Content-Type", "application/octet-stream+base64")
buf := make([]byte, base64.StdEncoding.EncodedLen(len(signature)))
base64.StdEncoding.Encode(buf, signature)
_, _ = resp.Write(buf)
}
func (s *SignerApi) getPublicKeyHandler(resp http.ResponseWriter, req *http.Request) {
format := mux.Vars(req)["format"]
publicKey, err := s.Signer.GetPublicKey(format)
if err != nil {
apiServerError(resp, fmt.Errorf("unable to retrieve public key: %w", err))
return
}
if format == "pem" {
resp.Header().Set("Content-Type", "application/x-pem-file")
resp.Header().Set("Content-Disposition", `attachment; filename="yggdrasil_session_pubkey.pem"`)
} else {
resp.Header().Set("Content-Type", "application/octet-stream")
resp.Header().Set("Content-Disposition", `attachment; filename="yggdrasil_session_pubkey.der"`)
}
_, _ = resp.Write(publicKey)
}

View File

@@ -0,0 +1,146 @@
package http
import (
"bytes"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
)
type SignerMock struct {
mock.Mock
}
func (m *SignerMock) Sign(data io.Reader) ([]byte, error) {
args := m.Called(data)
var result []byte
if casted, ok := args.Get(0).([]byte); ok {
result = casted
}
return result, args.Error(1)
}
func (m *SignerMock) GetPublicKey(format string) ([]byte, error) {
args := m.Called(format)
var result []byte
if casted, ok := args.Get(0).([]byte); ok {
result = casted
}
return result, args.Error(1)
}
type SignerApiTestSuite struct {
suite.Suite
App *SignerApi
Signer *SignerMock
}
func (t *SignerApiTestSuite) SetupSubTest() {
t.Signer = &SignerMock{}
t.App = &SignerApi{
Signer: t.Signer,
}
}
func (t *SignerApiTestSuite) TearDownSubTest() {
t.Signer.AssertExpectations(t.T())
}
func (t *SignerApiTestSuite) TestSign() {
t.Run("successfully sign", func() {
signature := []byte("mock signature")
t.Signer.On("Sign", mock.Anything).Return(signature, nil).Run(func(args mock.Arguments) {
buf := &bytes.Buffer{}
_, _ = io.Copy(buf, args.Get(0).(io.Reader))
r, _ := io.ReadAll(buf)
t.Equal([]byte("mock body to sign"), r)
})
req := httptest.NewRequest("POST", "http://chrly/", strings.NewReader("mock body to sign"))
w := httptest.NewRecorder()
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusOK, result.StatusCode)
t.Equal("application/octet-stream+base64", result.Header.Get("Content-Type"))
body, _ := io.ReadAll(result.Body)
t.Equal([]byte{0x62, 0x57, 0x39, 0x6a, 0x61, 0x79, 0x42, 0x7a, 0x61, 0x57, 0x64, 0x75, 0x59, 0x58, 0x52, 0x31, 0x63, 0x6d, 0x55, 0x3d}, body)
})
t.Run("handle error during sign", func() {
t.Signer.On("Sign", mock.Anything).Return(nil, errors.New("mock error"))
req := httptest.NewRequest("POST", "http://chrly/", strings.NewReader("mock body to sign"))
w := httptest.NewRecorder()
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusInternalServerError, result.StatusCode)
})
}
func (t *SignerApiTestSuite) TestGetPublicKey() {
t.Run("in pem format", func() {
publicKey := []byte("mock public key in pem format")
t.Signer.On("GetPublicKey", "pem").Return(publicKey, nil)
req := httptest.NewRequest("GET", "http://chrly/public-key.pem", nil)
w := httptest.NewRecorder()
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusOK, result.StatusCode)
t.Equal("application/x-pem-file", result.Header.Get("Content-Type"))
t.Equal(`attachment; filename="yggdrasil_session_pubkey.pem"`, result.Header.Get("Content-Disposition"))
body, _ := io.ReadAll(result.Body)
t.Equal(publicKey, body)
})
t.Run("in der format", func() {
publicKey := []byte("mock public key in der format")
t.Signer.On("GetPublicKey", "der").Return(publicKey, nil)
req := httptest.NewRequest("GET", "http://chrly/public-key.der", nil)
w := httptest.NewRecorder()
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusOK, result.StatusCode)
t.Equal("application/octet-stream", result.Header.Get("Content-Type"))
t.Equal(`attachment; filename="yggdrasil_session_pubkey.der"`, result.Header.Get("Content-Disposition"))
body, _ := io.ReadAll(result.Body)
t.Equal(publicKey, body)
})
t.Run("handle error", func() {
t.Signer.On("GetPublicKey", "pem").Return(nil, errors.New("mock error"))
req := httptest.NewRequest("GET", "http://chrly/public-key.pem", nil)
w := httptest.NewRecorder()
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusInternalServerError, result.StatusCode)
})
}
func TestSignerApi(t *testing.T) {
suite.Run(t, new(SignerApiTestSuite))
}

View File

@@ -2,12 +2,10 @@ package http
import (
"context"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"strings"
"time"
@@ -25,40 +23,39 @@ type ProfilesProvider interface {
FindProfileByUsername(ctx context.Context, username string, allowProxy bool) (*db.Profile, error)
}
// TexturesSigner uses context because in the future we may separate this logic into a separate microservice
type TexturesSigner interface {
SignTextures(ctx context.Context, textures string) (string, error)
GetPublicKey(ctx context.Context) (*rsa.PublicKey, error)
// SignerService uses context because in the future we may separate this logic as an external microservice
type SignerService interface {
Sign(ctx context.Context, data string) (string, error)
GetPublicKey(ctx context.Context, format string) (string, error)
}
type Skinsystem struct {
ProfilesProvider
TexturesSigner
SignerService
TexturesExtraParamName string
TexturesExtraParamValue string
}
func (ctx *Skinsystem) Handler() *mux.Router {
func (s *Skinsystem) Handler() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
router.HandleFunc("/skins/{username}", ctx.skinHandler).Methods(http.MethodGet)
router.HandleFunc("/cloaks/{username}", ctx.capeHandler).Methods(http.MethodGet)
router.HandleFunc("/skins/{username}", s.skinHandler).Methods(http.MethodGet)
router.HandleFunc("/cloaks/{username}", s.capeHandler).Methods(http.MethodGet)
// TODO: alias /capes/{username}?
router.HandleFunc("/textures/{username}", ctx.texturesHandler).Methods(http.MethodGet)
router.HandleFunc("/textures/signed/{username}", ctx.signedTexturesHandler).Methods(http.MethodGet)
router.HandleFunc("/profile/{username}", ctx.profileHandler).Methods(http.MethodGet)
router.HandleFunc("/textures/{username}", s.texturesHandler).Methods(http.MethodGet)
router.HandleFunc("/textures/signed/{username}", s.signedTexturesHandler).Methods(http.MethodGet)
router.HandleFunc("/profile/{username}", s.profileHandler).Methods(http.MethodGet)
// Legacy
router.HandleFunc("/skins", ctx.skinGetHandler).Methods(http.MethodGet)
router.HandleFunc("/cloaks", ctx.capeGetHandler).Methods(http.MethodGet)
router.HandleFunc("/skins", s.skinGetHandler).Methods(http.MethodGet)
router.HandleFunc("/cloaks", s.capeGetHandler).Methods(http.MethodGet)
// Utils
router.HandleFunc("/signature-verification-key.der", ctx.signatureVerificationKeyHandler).Methods(http.MethodGet)
router.HandleFunc("/signature-verification-key.pem", ctx.signatureVerificationKeyHandler).Methods(http.MethodGet)
router.HandleFunc("/signature-verification-key.{format:(?:pem|der)}", s.signatureVerificationKeyHandler).Methods(http.MethodGet)
return router
}
func (ctx *Skinsystem) skinHandler(response http.ResponseWriter, request *http.Request) {
profile, err := ctx.ProfilesProvider.FindProfileByUsername(request.Context(), parseUsername(mux.Vars(request)["username"]), true)
func (s *Skinsystem) skinHandler(response http.ResponseWriter, request *http.Request) {
profile, err := s.ProfilesProvider.FindProfileByUsername(request.Context(), parseUsername(mux.Vars(request)["username"]), true)
if err != nil {
apiServerError(response, fmt.Errorf("unable to retrieve a profile: %w", err))
return
@@ -71,7 +68,7 @@ func (ctx *Skinsystem) skinHandler(response http.ResponseWriter, request *http.R
http.Redirect(response, request, profile.SkinUrl, http.StatusMovedPermanently)
}
func (ctx *Skinsystem) skinGetHandler(response http.ResponseWriter, request *http.Request) {
func (s *Skinsystem) skinGetHandler(response http.ResponseWriter, request *http.Request) {
username := request.URL.Query().Get("name")
if username == "" {
response.WriteHeader(http.StatusBadRequest)
@@ -80,11 +77,11 @@ func (ctx *Skinsystem) skinGetHandler(response http.ResponseWriter, request *htt
mux.Vars(request)["username"] = username
ctx.skinHandler(response, request)
s.skinHandler(response, request)
}
func (ctx *Skinsystem) capeHandler(response http.ResponseWriter, request *http.Request) {
profile, err := ctx.ProfilesProvider.FindProfileByUsername(request.Context(), parseUsername(mux.Vars(request)["username"]), true)
func (s *Skinsystem) capeHandler(response http.ResponseWriter, request *http.Request) {
profile, err := s.ProfilesProvider.FindProfileByUsername(request.Context(), parseUsername(mux.Vars(request)["username"]), true)
if err != nil {
apiServerError(response, fmt.Errorf("unable to retrieve a profile: %w", err))
return
@@ -97,7 +94,7 @@ func (ctx *Skinsystem) capeHandler(response http.ResponseWriter, request *http.R
http.Redirect(response, request, profile.CapeUrl, http.StatusMovedPermanently)
}
func (ctx *Skinsystem) capeGetHandler(response http.ResponseWriter, request *http.Request) {
func (s *Skinsystem) capeGetHandler(response http.ResponseWriter, request *http.Request) {
username := request.URL.Query().Get("name")
if username == "" {
response.WriteHeader(http.StatusBadRequest)
@@ -106,11 +103,11 @@ func (ctx *Skinsystem) capeGetHandler(response http.ResponseWriter, request *htt
mux.Vars(request)["username"] = username
ctx.capeHandler(response, request)
s.capeHandler(response, request)
}
func (ctx *Skinsystem) texturesHandler(response http.ResponseWriter, request *http.Request) {
profile, err := ctx.ProfilesProvider.FindProfileByUsername(request.Context(), mux.Vars(request)["username"], true)
func (s *Skinsystem) texturesHandler(response http.ResponseWriter, request *http.Request) {
profile, err := s.ProfilesProvider.FindProfileByUsername(request.Context(), mux.Vars(request)["username"], true)
if err != nil {
apiServerError(response, fmt.Errorf("unable to retrieve a profile: %w", err))
return
@@ -133,8 +130,8 @@ func (ctx *Skinsystem) texturesHandler(response http.ResponseWriter, request *ht
_, _ = response.Write(responseData)
}
func (ctx *Skinsystem) signedTexturesHandler(response http.ResponseWriter, request *http.Request) {
profile, err := ctx.ProfilesProvider.FindProfileByUsername(
func (s *Skinsystem) signedTexturesHandler(response http.ResponseWriter, request *http.Request) {
profile, err := s.ProfilesProvider.FindProfileByUsername(
request.Context(),
mux.Vars(request)["username"],
getToBool(request.URL.Query().Get("proxy")),
@@ -164,8 +161,8 @@ func (ctx *Skinsystem) signedTexturesHandler(response http.ResponseWriter, reque
Value: profile.MojangTextures,
},
{
Name: ctx.TexturesExtraParamName,
Value: ctx.TexturesExtraParamValue,
Name: s.TexturesExtraParamName,
Value: s.TexturesExtraParamValue,
},
},
}
@@ -175,8 +172,8 @@ func (ctx *Skinsystem) signedTexturesHandler(response http.ResponseWriter, reque
_, _ = response.Write(responseJson)
}
func (ctx *Skinsystem) profileHandler(response http.ResponseWriter, request *http.Request) {
profile, err := ctx.ProfilesProvider.FindProfileByUsername(request.Context(), mux.Vars(request)["username"], true)
func (s *Skinsystem) profileHandler(response http.ResponseWriter, request *http.Request) {
profile, err := s.ProfilesProvider.FindProfileByUsername(request.Context(), mux.Vars(request)["username"], true)
if err != nil {
apiServerError(response, fmt.Errorf("unable to retrieve a profile: %w", err))
return
@@ -203,7 +200,7 @@ func (ctx *Skinsystem) profileHandler(response http.ResponseWriter, request *htt
}
if request.URL.Query().Has("unsigned") && !getToBool(request.URL.Query().Get("unsigned")) {
signature, err := ctx.TexturesSigner.SignTextures(request.Context(), texturesProp.Value)
signature, err := s.SignerService.Sign(request.Context(), texturesProp.Value)
if err != nil {
apiServerError(response, fmt.Errorf("unable to sign textures: %w", err))
return
@@ -218,8 +215,8 @@ func (ctx *Skinsystem) profileHandler(response http.ResponseWriter, request *htt
Props: []*mojang.Property{
texturesProp,
{
Name: ctx.TexturesExtraParamName,
Value: ctx.TexturesExtraParamValue,
Name: s.TexturesExtraParamName,
Value: s.TexturesExtraParamValue,
},
},
}
@@ -229,32 +226,23 @@ func (ctx *Skinsystem) profileHandler(response http.ResponseWriter, request *htt
_, _ = response.Write(responseJson)
}
func (ctx *Skinsystem) signatureVerificationKeyHandler(response http.ResponseWriter, request *http.Request) {
publicKey, err := ctx.TexturesSigner.GetPublicKey(request.Context())
func (s *Skinsystem) signatureVerificationKeyHandler(response http.ResponseWriter, request *http.Request) {
format := mux.Vars(request)["format"]
publicKey, err := s.SignerService.GetPublicKey(request.Context(), format)
if err != nil {
panic(err)
apiServerError(response, fmt.Errorf("unable to retrieve public key: %w", err))
return
}
asn1Bytes, err := x509.MarshalPKIXPublicKey(publicKey)
if err != nil {
panic(err)
}
if strings.HasSuffix(request.URL.Path, ".pem") {
publicKeyBlock := pem.Block{
Type: "PUBLIC KEY",
Bytes: asn1Bytes,
}
publicKeyPemBytes := pem.EncodeToMemory(&publicKeyBlock)
response.Header().Set("Content-Disposition", "attachment; filename=\"yggdrasil_session_pubkey.pem\"")
_, _ = response.Write(publicKeyPemBytes)
if format == "pem" {
response.Header().Set("Content-Type", "application/x-pem-file")
response.Header().Set("Content-Disposition", `attachment; filename="yggdrasil_session_pubkey.pem"`)
} else {
response.Header().Set("Content-Type", "application/octet-stream")
response.Header().Set("Content-Disposition", "attachment; filename=\"yggdrasil_session_pubkey.der\"")
_, _ = response.Write(asn1Bytes)
response.Header().Set("Content-Disposition", `attachment; filename="yggdrasil_session_pubkey.der"`)
}
_, _ = io.WriteString(response, publicKey)
}
func parseUsername(username string) string {

View File

@@ -2,14 +2,10 @@ package http
import (
"context"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -34,23 +30,18 @@ func (m *ProfilesProviderMock) FindProfileByUsername(ctx context.Context, userna
return result, args.Error(1)
}
type TexturesSignerMock struct {
type SignerServiceMock struct {
mock.Mock
}
func (m *TexturesSignerMock) SignTextures(ctx context.Context, textures string) (string, error) {
args := m.Called(ctx, textures)
func (m *SignerServiceMock) Sign(ctx context.Context, data string) (string, error) {
args := m.Called(ctx, data)
return args.String(0), args.Error(1)
}
func (m *TexturesSignerMock) GetPublicKey(ctx context.Context) (*rsa.PublicKey, error) {
args := m.Called(ctx)
var publicKey *rsa.PublicKey
if casted, ok := args.Get(0).(*rsa.PublicKey); ok {
publicKey = casted
}
return publicKey, args.Error(1)
func (m *SignerServiceMock) GetPublicKey(ctx context.Context, format string) (string, error) {
args := m.Called(ctx, format)
return args.String(0), args.Error(1)
}
type SkinsystemTestSuite struct {
@@ -59,7 +50,7 @@ type SkinsystemTestSuite struct {
App *Skinsystem
ProfilesProvider *ProfilesProviderMock
TexturesSigner *TexturesSignerMock
SignerService *SignerServiceMock
}
/********************
@@ -73,11 +64,11 @@ func (t *SkinsystemTestSuite) SetupSubTest() {
}
t.ProfilesProvider = &ProfilesProviderMock{}
t.TexturesSigner = &TexturesSignerMock{}
t.SignerService = &SignerServiceMock{}
t.App = &Skinsystem{
ProfilesProvider: t.ProfilesProvider,
TexturesSigner: t.TexturesSigner,
SignerService: t.SignerService,
TexturesExtraParamName: "texturesParamName",
TexturesExtraParamValue: "texturesParamValue",
}
@@ -85,7 +76,7 @@ func (t *SkinsystemTestSuite) SetupSubTest() {
func (t *SkinsystemTestSuite) TearDownSubTest() {
t.ProfilesProvider.AssertExpectations(t.T())
t.TexturesSigner.AssertExpectations(t.T())
t.SignerService.AssertExpectations(t.T())
}
func (t *SkinsystemTestSuite) TestSkinHandler() {
@@ -470,7 +461,7 @@ func (t *SkinsystemTestSuite) TestProfile() {
SkinUrl: "https://example.com/skin.png",
SkinModel: "slim",
}, nil)
t.TexturesSigner.On("SignTextures", mock.Anything, "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6Im1vY2stdXVpZCIsInByb2ZpbGVOYW1lIjoibW9ja191c2VybmFtZSIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9za2luLnBuZyIsIm1ldGFkYXRhIjp7Im1vZGVsIjoic2xpbSJ9fX19").Return("mock signature", nil)
t.SignerService.On("Sign", mock.Anything, "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6Im1vY2stdXVpZCIsInByb2ZpbGVOYW1lIjoibW9ja191c2VybmFtZSIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9za2luLnBuZyIsIm1ldGFkYXRhIjp7Im1vZGVsIjoic2xpbSJ9fX19").Return("mock signature", nil)
t.App.Handler().ServeHTTP(w, req)
@@ -526,7 +517,7 @@ func (t *SkinsystemTestSuite) TestProfile() {
w := httptest.NewRecorder()
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{}, nil)
t.TexturesSigner.On("SignTextures", mock.Anything, mock.Anything).Return("", errors.New("mock error"))
t.SignerService.On("Sign", mock.Anything, mock.Anything).Return("", errors.New("mock error"))
t.App.Handler().ServeHTTP(w, req)
@@ -535,77 +526,52 @@ func (t *SkinsystemTestSuite) TestProfile() {
})
}
type signingKeyTestCase struct {
Name string
KeyFormat string
BeforeTest func(suite *SkinsystemTestSuite)
PanicErr string
AfterTest func(suite *SkinsystemTestSuite, response *http.Response)
}
var signingKeyTestsCases = []*signingKeyTestCase{
{
Name: "Get public key in DER format",
KeyFormat: "DER",
BeforeTest: func(suite *SkinsystemTestSuite) {
pubPem, _ := pem.Decode([]byte("-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANbUpVCZkMKpfvYZ08W3lumdAaYxLBnm\nUDlzHBQH3DpYef5WCO32TDU6feIJ58A0lAywgtZ4wwi2dGHOz/1hAvcCAwEAAQ==\n-----END PUBLIC KEY-----"))
publicKey, _ := x509.ParsePKIXPublicKey(pubPem.Bytes)
suite.TexturesSigner.On("GetPublicKey", mock.Anything).Return(publicKey, nil)
},
AfterTest: func(suite *SkinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/octet-stream", response.Header.Get("Content-Type"))
suite.Equal("attachment; filename=\"yggdrasil_session_pubkey.der\"", response.Header.Get("Content-Disposition"))
body, _ := io.ReadAll(response.Body)
suite.Equal([]byte{48, 92, 48, 13, 6, 9, 42, 134, 72, 134, 247, 13, 1, 1, 1, 5, 0, 3, 75, 0, 48, 72, 2, 65, 0, 214, 212, 165, 80, 153, 144, 194, 169, 126, 246, 25, 211, 197, 183, 150, 233, 157, 1, 166, 49, 44, 25, 230, 80, 57, 115, 28, 20, 7, 220, 58, 88, 121, 254, 86, 8, 237, 246, 76, 53, 58, 125, 226, 9, 231, 192, 52, 148, 12, 176, 130, 214, 120, 195, 8, 182, 116, 97, 206, 207, 253, 97, 2, 247, 2, 3, 1, 0, 1}, body)
},
},
{
Name: "Get public key in PEM format",
KeyFormat: "PEM",
BeforeTest: func(suite *SkinsystemTestSuite) {
pubPem, _ := pem.Decode([]byte("-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANbUpVCZkMKpfvYZ08W3lumdAaYxLBnm\nUDlzHBQH3DpYef5WCO32TDU6feIJ58A0lAywgtZ4wwi2dGHOz/1hAvcCAwEAAQ==\n-----END PUBLIC KEY-----"))
publicKey, _ := x509.ParsePKIXPublicKey(pubPem.Bytes)
suite.TexturesSigner.On("GetPublicKey", mock.Anything).Return(publicKey, nil)
},
AfterTest: func(suite *SkinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("text/plain; charset=utf-8", response.Header.Get("Content-Type"))
suite.Equal("attachment; filename=\"yggdrasil_session_pubkey.pem\"", response.Header.Get("Content-Disposition"))
body, _ := io.ReadAll(response.Body)
suite.Equal("-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANbUpVCZkMKpfvYZ08W3lumdAaYxLBnm\nUDlzHBQH3DpYef5WCO32TDU6feIJ58A0lAywgtZ4wwi2dGHOz/1hAvcCAwEAAQ==\n-----END PUBLIC KEY-----\n", string(body))
},
},
{
Name: "Error while obtaining public key",
KeyFormat: "DER",
BeforeTest: func(suite *SkinsystemTestSuite) {
suite.TexturesSigner.On("GetPublicKey", mock.Anything).Return(nil, errors.New("textures signer error"))
},
PanicErr: "textures signer error",
},
}
func (t *SkinsystemTestSuite) TestSignatureVerificationKey() {
for _, testCase := range signingKeyTestsCases {
t.Run(testCase.Name, func() {
testCase.BeforeTest(t)
t.Run("in pem format", func() {
publicKey := "mock public key in pem format"
t.SignerService.On("GetPublicKey", mock.Anything, "pem").Return(publicKey, nil)
req := httptest.NewRequest("GET", "http://chrly/signature-verification-key."+strings.ToLower(testCase.KeyFormat), nil)
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "http://chrly/signature-verification-key.pem", nil)
w := httptest.NewRecorder()
if testCase.PanicErr != "" {
t.PanicsWithError(testCase.PanicErr, func() {
t.App.Handler().ServeHTTP(w, req)
})
} else {
t.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(t, w.Result())
}
})
}
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusOK, result.StatusCode)
t.Equal("application/x-pem-file", result.Header.Get("Content-Type"))
t.Equal(`attachment; filename="yggdrasil_session_pubkey.pem"`, result.Header.Get("Content-Disposition"))
body, _ := io.ReadAll(result.Body)
t.Equal(publicKey, string(body))
})
t.Run("in der format", func() {
publicKey := "mock public key in der format"
t.SignerService.On("GetPublicKey", mock.Anything, "der").Return(publicKey, nil)
req := httptest.NewRequest("GET", "http://chrly/signature-verification-key.der", nil)
w := httptest.NewRecorder()
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusOK, result.StatusCode)
t.Equal("application/octet-stream", result.Header.Get("Content-Type"))
t.Equal(`attachment; filename="yggdrasil_session_pubkey.der"`, result.Header.Get("Content-Disposition"))
body, _ := io.ReadAll(result.Body)
t.Equal(publicKey, string(body))
})
t.Run("handle error", func() {
t.SignerService.On("GetPublicKey", mock.Anything, "pem").Return("", errors.New("mock error"))
req := httptest.NewRequest("GET", "http://chrly/signature-verification-key.pem", nil)
w := httptest.NewRecorder()
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusInternalServerError, result.StatusCode)
})
}
func TestSkinsystem(t *testing.T) {