diff --git a/internal/config/config.go b/internal/config/config.go index f69a653..c70e0ad 100644 --- a/internal/config/config.go +++ b/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 diff --git a/internal/httpservers/restapi.go b/internal/httpservers/restapi.go index 07d9327..cf200bb 100644 --- a/internal/httpservers/restapi.go +++ b/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 diff --git a/internal/httpservers/restapi_auth_jwt.go b/internal/httpservers/restapi_auth_jwt.go index 4441310..23b639b 100644 --- a/internal/httpservers/restapi_auth_jwt.go +++ b/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 { diff --git a/internal/httpservers/restapi_test.go b/internal/httpservers/restapi_test.go new file mode 100644 index 0000000..478277c --- /dev/null +++ b/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) +}