Răsfoiți Sursa

add support for asymmetric crypto based jwt signatures (#145)

* add support for asymmetric crypto based jwt signatures

* wip: e2e test example

* wip: rsa key parse error...

* fix: test works

---------

Co-authored-by: James Read <contact@jread.com>
Bernard Crnković 2 ani în urmă
părinte
comite
6e2e585175

+ 2 - 1
internal/config/config.go

@@ -74,9 +74,10 @@ type Config struct {
 	ShowNavigation                  bool
 	ShowNewVersions                 bool
 	AuthJwtCookieName               string
-	AuthJwtSecret                   string
+	AuthJwtSecret                   string // mutually exclusive with pub key config fields
 	AuthJwtClaimUsername            string
 	AuthJwtClaimUserGroup           string
+	AuthJwtPubKeyPath               string // will read pub key from file on disk
 	AuthHttpHeaderUsername          string
 	AuthHttpHeaderUserGroup         string
 	DefaultPermissions              PermissionsList

+ 4 - 0
internal/httpservers/restapi.go

@@ -59,6 +59,10 @@ func parseRequestMetadata(ctx context.Context, req *http.Request) metadata.MD {
 	return md
 }
 
+func SetGlobalRestConfig(config *config.Config) {
+	cfg = config
+}
+
 func startRestAPIServer(globalConfig *config.Config) error {
 	cfg = globalConfig
 

+ 31 - 0
internal/httpservers/restapi_auth_jwt.go

@@ -1,14 +1,45 @@
 package httpservers
 
 import (
+	"crypto/rsa"
 	"errors"
 	"fmt"
 	"github.com/golang-jwt/jwt/v4"
 	log "github.com/sirupsen/logrus"
 	"net/http"
+	"os"
+)
+
+var (
+	pubKeyBytes []byte = nil
+	pubKey      *rsa.PublicKey
 )
 
 func parseJwtToken(cookieValue string) (*jwt.Token, error) {
+	if cfg.AuthJwtPubKeyPath != "" { // activate this path only if pub key is specified
+		if pubKeyBytes == nil { // keep in memory after first load
+			var err error
+			pubKeyBytes, err = os.ReadFile(cfg.AuthJwtPubKeyPath)
+			if err != nil {
+				return nil, fmt.Errorf("couldn't read public key from file %s", cfg.AuthJwtPubKeyPath)
+			}
+			// Since the token is RSA (which we validated at the start of this function), the return type of this function actually has to be rsa.PublicKey!
+			pubKey, err = jwt.ParseRSAPublicKeyFromPEM(pubKeyBytes)
+			if err != nil {
+				return nil, fmt.Errorf("error parsing public key object (from %s)", cfg.AuthJwtPubKeyPath)
+			}
+		}
+		return jwt.Parse(cookieValue, func(token *jwt.Token) (interface{}, error) {
+			if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
+				return nil, fmt.Errorf(
+					"expected token algorithm '%v' but got '%v'",
+					jwt.SigningMethodRS256.Name,
+					token.Header)
+			}
+			return pubKey, nil
+		})
+	}
+
 	return jwt.Parse(cookieValue, func(token *jwt.Token) (interface{}, error) {
 		// Don't forget to validate the alg is what you expect:
 		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {

+ 113 - 0
internal/httpservers/restapi_test.go

@@ -0,0 +1,113 @@
+package httpservers
+
+import (
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/x509"
+	"encoding/pem"
+	"fmt"
+	config2 "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 testBase(t *testing.T, expire int64, expectCode int) {
+	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)
+	}
+
+	// default config + overrides
+	config := config2.DefaultConfig()
+	config.AuthJwtPubKeyPath = tmpFile.Name()
+	config.AuthJwtClaimUsername = "sub"
+	config.AuthJwtClaimUserGroup = "olivetinGroup"
+	config.AuthJwtCookieName = "authorization_token"
+	SetGlobalRestConfig(config) // 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
+	srv := &http.Server{Handler: cors.AllowCors(mux)}
+	lis, _ := net.Listen("tcp", ":1337")
+
+	go func() {
+		if err := srv.Serve(lis); err != nil {
+			t.Errorf("couldn't start server: %v", err)
+		}
+	}()
+
+	// 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, _ := client.Do(req)
+	defer res.Body.Close()
+	assert.Equal(t, expectCode, res.StatusCode)
+	body, _ := io.ReadAll(res.Body)
+	fmt.Println(string(body))
+}
+
+func TestJWTSignatureVerificationSucceeds(t *testing.T) {
+	testBase(t, 1000, 200)
+}
+
+func TestJWTSignatureVerificationFails(t *testing.T) {
+	testBase(t, -500, 403)
+}