diff --git a/internal/httpservers/restapi.go b/internal/httpservers/restapi.go index f1d1fca7..f3a8d5f3 100644 --- a/internal/httpservers/restapi.go +++ b/internal/httpservers/restapi.go @@ -78,31 +78,34 @@ func startRestAPIServer(globalConfig *config.Config) error { "address": cfg.ListenAddressGrpcActions, }).Info("Starting REST API") - ctx := context.Background() - ctx, cancel := context.WithCancel(ctx) - defer cancel() + mux := newMux() - // The JSONPb.EmitDefaults is necssary, so "empty" fields are returned in JSON. + return http.ListenAndServe(cfg.ListenAddressRestActions, cors.AllowCors(mux)) +} + +func newMux() *runtime.ServeMux { + // The MarshalOptions set some important compatibility settings for the webui. See below. mux := runtime.NewServeMux( runtime.WithMetadata(parseRequestMetadata), runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.HTTPBodyMarshaler{ Marshaler: &runtime.JSONPb{ MarshalOptions: protojson.MarshalOptions{ UseProtoNames: false, // eg: canExec for js instead of can_exec from protobuf - EmitUnpopulated: true, + EmitUnpopulated: true, // Emit empty fields so that javascript does not get "undefined" when accessing fields with empty values. }, }, }), ) + + ctx := context.Background() + opts := []grpc.DialOption{grpc.WithInsecure()} err := gw.RegisterOliveTinApiServiceHandlerFromEndpoint(ctx, mux, cfg.ListenAddressGrpcActions, opts) if err != nil { - log.Errorf("Could not register REST API Handler %v", err) - - return err + log.Panicf("Could not register REST API Handler %v", err) } - return http.ListenAndServe(cfg.ListenAddressRestActions, cors.AllowCors(mux)) + return mux } diff --git a/internal/httpservers/restapi_auth_jwt_test.go b/internal/httpservers/restapi_auth_jwt_test.go new file mode 100644 index 00000000..38d8bdd1 --- /dev/null +++ b/internal/httpservers/restapi_auth_jwt_test.go @@ -0,0 +1,105 @@ +package httpservers + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + config "github.com/OliveTin/OliveTin/internal/config" + "github.com/golang-jwt/jwt/v4" + "github.com/stretchr/testify/assert" + "io" + "net/http" + "os" + "testing" + "time" +) + +func createKeys(t *testing.T) (*rsa.PrivateKey, string) { + tmpFile, _ := os.CreateTemp(os.TempDir(), "olivetin-jwt-") + + fmt.Println("Created File: " + tmpFile.Name()) + + privateKey, _ := rsa.GenerateKey(rand.Reader, 2048) + pubKey := &privateKey.PublicKey + // https://stackoverflow.com/questions/13555085/save-and-load-crypto-rsa-privatekey-to-and-from-the-disk + pkixPubKey, _ := x509.MarshalPKIXPublicKey(pubKey) + pubPem := pem.EncodeToMemory( + &pem.Block{ + Type: "RSA PUBLIC KEY", + Bytes: pkixPubKey, + }, + ) + + if err := os.WriteFile(tmpFile.Name(), pubPem, 0755); err != nil { + t.Fatalf("error when dumping pubKey: %s \n", err) + } + + return privateKey, tmpFile.Name() +} + +func testJwkValidation(t *testing.T, expire int64, expectCode int) { + privateKey, publicKeyPath := createKeys(t) + + defer os.Remove(publicKeyPath) + + cfg := config.DefaultConfig() + cfg.AuthJwtPubKeyPath = publicKeyPath + cfg.AuthJwtClaimUsername = "sub" + cfg.AuthJwtClaimUserGroup = "olivetinGroup" + cfg.AuthJwtCookieName = "authorization_token" + SetGlobalRestConfig(cfg) // ugly, setting global var, we should pass configs as params to modules... :/ + + token := jwt.New(jwt.SigningMethodRS256) + + claims := token.Claims.(jwt.MapClaims) + claims["nbf"] = time.Now().Unix() - 1000 + claims["exp"] = time.Now().Unix() + expire + claims["sub"] = "test" + claims["olivetinGroup"] = "test" + + tokenStr, _ := token.SignedString(privateKey) + + mux := newMux() + mux.HandlePath("GET", "/", func(w http.ResponseWriter, r *http.Request, pathParams map[string]string) { + username, usergroup := parseJwtCookie(r) + + if username == "" { + w.WriteHeader(403) + } + + w.Write([]byte(fmt.Sprintf("username=%v, usergroup=%v", username, usergroup))) + }) + + srv := setupTestingServer(mux, t) + + req, client := newReq("") + req.AddCookie(&http.Cookie{ + Name: "authorization_token", + Value: tokenStr, + MaxAge: 300, + }) + + res, err := client.Do(req) + + if err != nil { + t.Fatalf("Client err: %+v", err) + } else { + defer res.Body.Close() + assert.Equal(t, expectCode, res.StatusCode) + body, _ := io.ReadAll(res.Body) + fmt.Println(string(body)) + } + + srv.Shutdown(context.TODO()) +} + +func TestJWTSignatureVerificationSucceeds(t *testing.T) { + testJwkValidation(t, 1000, 200) +} + +func TestJWTSignatureVerificationFails(t *testing.T) { + testJwkValidation(t, -500, 403) +} diff --git a/internal/httpservers/restapi_test.go b/internal/httpservers/restapi_test.go index 348f8146..7c710634 100644 --- a/internal/httpservers/restapi_test.go +++ b/internal/httpservers/restapi_test.go @@ -1,125 +1,33 @@ package httpservers +/* +The REST API actually has very few tests, as the "real" API behind OliveTin +is is implemented as a gRPC in /internal/grpc. The REST API therefore only +handles HTTP specific stuff like authentication cookies and JWT parsing. +*/ + import ( - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/pem" "fmt" - config "github.com/OliveTin/OliveTin/internal/config" "github.com/OliveTin/OliveTin/internal/cors" - "github.com/golang-jwt/jwt/v4" "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" - "github.com/stretchr/testify/assert" - "google.golang.org/protobuf/encoding/protojson" - "io" "net" "net/http" - "os" "testing" - "time" ) -func createKeys() (*rsa.PrivateKey, string) { - tmpFile, _ := os.CreateTemp(os.TempDir(), "olivetin-jwt-") - defer os.Remove(tmpFile.Name()) - - fmt.Println("Created File: " + tmpFile.Name()) - - privateKey, _ := rsa.GenerateKey(rand.Reader, 2048) - pubKey := &privateKey.PublicKey - // https://stackoverflow.com/questions/13555085/save-and-load-crypto-rsa-privatekey-to-and-from-the-disk - pkixPubKey, _ := x509.MarshalPKIXPublicKey(pubKey) - pubPem := pem.EncodeToMemory( - &pem.Block{ - Type: "RSA PUBLIC KEY", - Bytes: pkixPubKey, - }, - ) - - if err := os.WriteFile(tmpFile.Name(), pubPem, 0755); err != nil { - fmt.Printf("error when dumping pubKey: %s \n", err) - } - - return privateKey, tmpFile.Name() -} - -func testBase(t *testing.T, expire int64, expectCode int) { - privateKey, publicKeyPath := createKeys() - - // default config + overrides - cfg := config.DefaultConfig() - cfg.AuthJwtPubKeyPath = publicKeyPath - cfg.AuthJwtClaimUsername = "sub" - cfg.AuthJwtClaimUserGroup = "olivetinGroup" - cfg.AuthJwtCookieName = "authorization_token" - SetGlobalRestConfig(cfg) // ugly, setting global var, we should pass configs as params to modules... :/ - - token := jwt.New(jwt.SigningMethodRS256) - - claims := token.Claims.(jwt.MapClaims) - claims["nbf"] = time.Now().Unix() - 1000 - claims["exp"] = time.Now().Unix() + expire - claims["sub"] = "test" - claims["olivetinGroup"] = "test" - - tokenStr, _ := token.SignedString(privateKey) - - // init mux endpoint like in restapi.go (but using dummy response handler) - mux := runtime.NewServeMux( - runtime.WithMetadata(parseRequestMetadata), // i am guessing this is critical middleware for authorizing request cookie - runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.HTTPBodyMarshaler{ - Marshaler: &runtime.JSONPb{ - MarshalOptions: protojson.MarshalOptions{ - UseProtoNames: true, - EmitUnpopulated: true, - }, - }, - }), - ) - mux.HandlePath("GET", "/", func(w http.ResponseWriter, r *http.Request, pathParams map[string]string) { - username, usergroup := parseJwtCookie(r) - if username == "" { - w.WriteHeader(403) - } - w.Write([]byte(fmt.Sprintf("username=%v, usergroup=%v", username, usergroup))) - }) - - // make server and attach handler - setupTestingServer(mux, t) - - // make http client and send request to myself - client := &http.Client{} - req, _ := http.NewRequest("GET", "http://localhost:1337/", nil) - cookie := &http.Cookie{ - Name: "authorization_token", - Value: tokenStr, - MaxAge: 300, - } - req.AddCookie(cookie) - res, err := client.Do(req) - - if err != nil { - assert.Equal(t, expectCode, -1) - } else { - defer res.Body.Close() - assert.Equal(t, expectCode, res.StatusCode) - body, _ := io.ReadAll(res.Body) - fmt.Println(string(body)) - } -} - -func setupTestingServer(mux *runtime.ServeMux, t *testing.T) { +func setupTestingServer(mux *runtime.ServeMux, t *testing.T) *http.Server { lis, err := net.Listen("tcp", ":1337") if err != nil || lis == nil { t.Errorf("Could not listen %v %v", err, lis) - return + return nil } srv := &http.Server{Handler: cors.AllowCors(mux)} go startTestingServer(lis, srv, t) + + return srv } func startTestingServer(lis net.Listener, srv *http.Server, t *testing.T) { @@ -130,15 +38,14 @@ func startTestingServer(lis net.Listener, srv *http.Server, t *testing.T) { go func() { if err := srv.Serve(lis); err != nil { - t.Errorf("couldn't start server: %v", err) + fmt.Printf("couldn't start server: %+v", err) } }() } -func TestJWTSignatureVerificationSucceeds(t *testing.T) { - // testBase(t, 1000, 200) -} +func newReq(path string) (*http.Request, *http.Client) { + client := &http.Client{} + req, _ := http.NewRequest("GET", fmt.Sprintf("http://localhost:1337/%v", path), nil) -func TestJWTSignatureVerificationFails(t *testing.T) { - testBase(t, -500, 403) + return req, client }