From 4308df0663b45eb9d95b3babdf519a06ee76c15a Mon Sep 17 00:00:00 2001 From: ychacon Date: Thu, 23 Feb 2023 11:13:14 +0100 Subject: [PATCH] Generating token using keycloak Issue-ID: NONRTRIC-836 Signed-off-by: ychacon Change-Id: If337576ccac816292709c6d7ab30d239a2d8a77b --- capifcore/configs/keycloak.yaml | 23 ++++++ capifcore/internal/config/config.go | 55 +++++++++++++ capifcore/internal/keycloak/keycloak.go | 90 ++++++++++++++++++++++ .../internal/keycloak/mocks/AccessManagement.go | 52 +++++++++++++ capifcore/internal/securityapi/typevalidation.go | 2 +- capifcore/internal/securityservice/security.go | 32 +++----- .../internal/securityservice/security_test.go | 67 ++++++++++++++-- capifcore/main.go | 11 ++- 8 files changed, 299 insertions(+), 33 deletions(-) create mode 100644 capifcore/configs/keycloak.yaml create mode 100644 capifcore/internal/config/config.go create mode 100644 capifcore/internal/keycloak/keycloak.go create mode 100644 capifcore/internal/keycloak/mocks/AccessManagement.go diff --git a/capifcore/configs/keycloak.yaml b/capifcore/configs/keycloak.yaml new file mode 100644 index 0000000..1ca2ba9 --- /dev/null +++ b/capifcore/configs/keycloak.yaml @@ -0,0 +1,23 @@ +# ============LICENSE_START=============================================== +# Copyright (C) 2023 Nordix Foundation. All rights reserved. +# ======================================================================== +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============LICENSE_END================================================= +# + +# Keycloak configurations +authorizationServer: + host: "localhost" + port: "8080" + realms: + invokerrealm: "invokerrealm" diff --git a/capifcore/internal/config/config.go b/capifcore/internal/config/config.go new file mode 100644 index 0000000..4a53022 --- /dev/null +++ b/capifcore/internal/config/config.go @@ -0,0 +1,55 @@ +// - +// ========================LICENSE_START================================= +// O-RAN-SC +// %% +// Copyright (C) 2023: Nordix Foundation +// %% +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========================LICENSE_END=================================== +// + +package config + +import ( + "os" + "path/filepath" + + "gopkg.in/yaml.v2" +) + +type AuthorizationServer struct { + Port string `yaml:"port"` + Host string `yaml:"host"` + Realms map[string]string `yaml:"realms"` +} + +type Config struct { + AuthorizationServer AuthorizationServer `yaml:"authorizationServer"` +} + +func ReadKeycloakConfigFile(configFolder string) (*Config, error) { + + f, err := os.Open(filepath.Join(configFolder, "keycloak.yaml")) + if err != nil { + return nil, err + } + defer f.Close() + + var cfg *Config + decoder := yaml.NewDecoder(f) + err = decoder.Decode(&cfg) + if err != nil { + return nil, err + } + return cfg, nil +} diff --git a/capifcore/internal/keycloak/keycloak.go b/capifcore/internal/keycloak/keycloak.go new file mode 100644 index 0000000..16f65c8 --- /dev/null +++ b/capifcore/internal/keycloak/keycloak.go @@ -0,0 +1,90 @@ +// - +// ========================LICENSE_START================================= +// O-RAN-SC +// %% +// Copyright (C) 2023: Nordix Foundation +// %% +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========================LICENSE_END=================================== +// + +package keycloak + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + + "oransc.org/nonrtric/capifcore/internal/config" +) + +//go:generate mockery --name AccessManagement +type AccessManagement interface { + // Get JWT token for a client. + // Returns JWT token if client exits and credentials are correct otherwise returns error. + GetToken(clientId, clientPassword, scope string, realm string) (Jwttoken, error) +} + +type KeycloakManager struct { + keycloakServerUrl string + realms map[string]string +} + +func NewKeycloakManager(cfg *config.Config) *KeycloakManager { + + keycloakUrl := "http://" + cfg.AuthorizationServer.Host + ":" + cfg.AuthorizationServer.Port + + return &KeycloakManager{ + keycloakServerUrl: keycloakUrl, + realms: cfg.AuthorizationServer.Realms, + } +} + +type Jwttoken struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + ExpiresIn int `json:"expires_in"` + RefreshExpiresIn int `json:"refresh_expires_in"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + NotBeforePolicy int `json:"not-before-policy"` + SessionState string `json:"session_state"` + Scope string `json:"scope"` +} + +func (km *KeycloakManager) GetToken(clientId, clientPassword, scope string, realm string) (Jwttoken, error) { + var jwt Jwttoken + getTokenUrl := km.keycloakServerUrl + "/realms/" + realm + "/protocol/openid-connect/token" + + resp, err := http.PostForm(getTokenUrl, + url.Values{"grant_type": {"client_credentials"}, "client_id": {clientId}, "client_secret": {clientPassword}}) + + if err != nil { + return jwt, err + } + + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + + if err != nil { + return jwt, err + } + if resp.StatusCode != http.StatusOK { + return jwt, errors.New(string(body)) + } + + json.Unmarshal([]byte(body), &jwt) + return jwt, nil +} diff --git a/capifcore/internal/keycloak/mocks/AccessManagement.go b/capifcore/internal/keycloak/mocks/AccessManagement.go new file mode 100644 index 0000000..9ac2179 --- /dev/null +++ b/capifcore/internal/keycloak/mocks/AccessManagement.go @@ -0,0 +1,52 @@ +// Code generated by mockery v2.20.0. DO NOT EDIT. + +package mocks + +import ( + mock "github.com/stretchr/testify/mock" + keycloak "oransc.org/nonrtric/capifcore/internal/keycloak" +) + +// AccessManagement is an autogenerated mock type for the AccessManagement type +type AccessManagement struct { + mock.Mock +} + +// GetToken provides a mock function with given fields: clientId, clientPassword, scope, realm +func (_m *AccessManagement) GetToken(clientId string, clientPassword string, scope string, realm string) (keycloak.Jwttoken, error) { + ret := _m.Called(clientId, clientPassword, scope, realm) + + var r0 keycloak.Jwttoken + var r1 error + if rf, ok := ret.Get(0).(func(string, string, string, string) (keycloak.Jwttoken, error)); ok { + return rf(clientId, clientPassword, scope, realm) + } + if rf, ok := ret.Get(0).(func(string, string, string, string) keycloak.Jwttoken); ok { + r0 = rf(clientId, clientPassword, scope, realm) + } else { + r0 = ret.Get(0).(keycloak.Jwttoken) + } + + if rf, ok := ret.Get(1).(func(string, string, string, string) error); ok { + r1 = rf(clientId, clientPassword, scope, realm) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type mockConstructorTestingTNewAccessManagement interface { + mock.TestingT + Cleanup(func()) +} + +// NewAccessManagement creates a new instance of AccessManagement. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewAccessManagement(t mockConstructorTestingTNewAccessManagement) *AccessManagement { + mock := &AccessManagement{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/capifcore/internal/securityapi/typevalidation.go b/capifcore/internal/securityapi/typevalidation.go index 90dbda3..1241f96 100644 --- a/capifcore/internal/securityapi/typevalidation.go +++ b/capifcore/internal/securityapi/typevalidation.go @@ -35,7 +35,7 @@ func (tokenReq AccessTokenReq) Validate() (bool, AccessTokenErr) { } //3gpp#aefId1:apiName1,apiName2,…apiNameX;aefId2:apiName1,apiName2,…apiNameY;…aefIdN:apiName1,apiName2,…apiNameZ - if tokenReq.Scope != nil { + if tokenReq.Scope != nil && *tokenReq.Scope != "" { scope := strings.Split(*tokenReq.Scope, "#") if len(scope) < 2 { return false, createAccessTokenError(AccessTokenErrErrorInvalidScope, "Malformed scope") diff --git a/capifcore/internal/securityservice/security.go b/capifcore/internal/securityservice/security.go index d71ff0d..dcf1dbb 100644 --- a/capifcore/internal/securityservice/security.go +++ b/capifcore/internal/securityservice/security.go @@ -23,32 +23,31 @@ package security import ( "net/http" "strings" - "time" - "github.com/golang-jwt/jwt" "github.com/labstack/echo/v4" "oransc.org/nonrtric/capifcore/internal/common29122" securityapi "oransc.org/nonrtric/capifcore/internal/securityapi" "oransc.org/nonrtric/capifcore/internal/invokermanagement" + "oransc.org/nonrtric/capifcore/internal/keycloak" "oransc.org/nonrtric/capifcore/internal/providermanagement" "oransc.org/nonrtric/capifcore/internal/publishservice" ) -var jwtKey = "my-secret-key" - type Security struct { serviceRegister providermanagement.ServiceRegister publishRegister publishservice.PublishRegister invokerRegister invokermanagement.InvokerRegister + keycloak keycloak.AccessManagement } -func NewSecurity(serviceRegister providermanagement.ServiceRegister, publishRegister publishservice.PublishRegister, invokerRegister invokermanagement.InvokerRegister) *Security { +func NewSecurity(serviceRegister providermanagement.ServiceRegister, publishRegister publishservice.PublishRegister, invokerRegister invokermanagement.InvokerRegister, km keycloak.AccessManagement) *Security { return &Security{ serviceRegister: serviceRegister, publishRegister: publishRegister, invokerRegister: invokerRegister, + keycloak: km, } } @@ -68,7 +67,7 @@ func (s *Security) PostSecuritiesSecurityIdToken(ctx echo.Context, securityId st return sendAccessTokenError(ctx, http.StatusBadRequest, securityapi.AccessTokenErrErrorUnauthorizedClient, "Invoker secret not valid") } - if accessTokenReq.Scope != nil { + if accessTokenReq.Scope != nil && *accessTokenReq.Scope != "" { scope := strings.Split(*accessTokenReq.Scope, "#") aefList := strings.Split(scope[1], ";") for _, aef := range aefList { @@ -83,27 +82,14 @@ func (s *Security) PostSecuritiesSecurityIdToken(ctx echo.Context, securityId st } } } - - expirationTime := time.Now().Add(time.Hour).Unix() - - claims := &jwt.MapClaims{ - "iss": accessTokenReq.ClientId, - "exp": expirationTime, - "data": map[string]interface{}{ - "scope": accessTokenReq.Scope, - }, - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - tokenString, err := token.SignedString([]byte(jwtKey)) + jwtToken, err := s.keycloak.GetToken(accessTokenReq.ClientId, *accessTokenReq.ClientSecret, *accessTokenReq.Scope, "invokerrealm") if err != nil { - // If there is an error in creating the JWT return an internal server error - return err + return sendAccessTokenError(ctx, http.StatusBadRequest, securityapi.AccessTokenErrErrorUnauthorizedClient, err.Error()) } accessTokenResp := securityapi.AccessTokenRsp{ - AccessToken: tokenString, - ExpiresIn: common29122.DurationSec(expirationTime), + AccessToken: jwtToken.AccessToken, + ExpiresIn: common29122.DurationSec(jwtToken.ExpiresIn), Scope: accessTokenReq.Scope, TokenType: "Bearer", } diff --git a/capifcore/internal/securityservice/security_test.go b/capifcore/internal/securityservice/security_test.go index 6c62161..13af737 100644 --- a/capifcore/internal/securityservice/security_test.go +++ b/capifcore/internal/securityservice/security_test.go @@ -21,12 +21,14 @@ package security import ( + "errors" "fmt" "net/http" "net/url" "os" "testing" + "oransc.org/nonrtric/capifcore/internal/keycloak" "oransc.org/nonrtric/capifcore/internal/securityapi" "oransc.org/nonrtric/capifcore/internal/invokermanagement" @@ -36,6 +38,7 @@ import ( "github.com/labstack/echo/v4" invokermocks "oransc.org/nonrtric/capifcore/internal/invokermanagement/mocks" + keycloackmocks "oransc.org/nonrtric/capifcore/internal/keycloak/mocks" servicemocks "oransc.org/nonrtric/capifcore/internal/providermanagement/mocks" publishmocks "oransc.org/nonrtric/capifcore/internal/publishservice/mocks" @@ -55,7 +58,15 @@ func TestPostSecurityIdTokenInvokerRegistered(t *testing.T) { publishRegisterMock := publishmocks.PublishRegister{} publishRegisterMock.On("IsAPIPublished", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(true) - requestHandler := getEcho(&serviceRegisterMock, &publishRegisterMock, &invokerRegisterMock) + jwt := keycloak.Jwttoken{ + AccessToken: "eyJhbGNIn0.e3YTQ0xLjEifQ.FcqCwCy7iJiOmw", + ExpiresIn: 300, + Scope: "3gpp#aefIdpath", + } + accessMgmMock := keycloackmocks.AccessManagement{} + accessMgmMock.On("GetToken", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(jwt, nil) + + requestHandler := getEcho(&serviceRegisterMock, &publishRegisterMock, &invokerRegisterMock, &accessMgmMock) data := url.Values{} clientId := "id" @@ -76,19 +87,19 @@ func TestPostSecurityIdTokenInvokerRegistered(t *testing.T) { err := result.UnmarshalBodyToObject(&resultResponse) assert.NoError(t, err, "error unmarshaling response") assert.NotEmpty(t, resultResponse.AccessToken) - assert.Equal(t, "3gpp#"+aefId+":"+path, *resultResponse.Scope) assert.Equal(t, securityapi.AccessTokenRspTokenTypeBearer, resultResponse.TokenType) invokerRegisterMock.AssertCalled(t, "IsInvokerRegistered", clientId) invokerRegisterMock.AssertCalled(t, "VerifyInvokerSecret", clientId, clientSecret) serviceRegisterMock.AssertCalled(t, "IsFunctionRegistered", aefId) publishRegisterMock.AssertCalled(t, "IsAPIPublished", aefId, path) + accessMgmMock.AssertCalled(t, "GetToken", clientId, clientSecret, "3gpp#"+aefId+":"+path, "invokerrealm") } func TestPostSecurityIdTokenInvokerNotRegistered(t *testing.T) { invokerRegisterMock := invokermocks.InvokerRegister{} invokerRegisterMock.On("IsInvokerRegistered", mock.AnythingOfType("string")).Return(false) - requestHandler := getEcho(nil, nil, &invokerRegisterMock) + requestHandler := getEcho(nil, nil, &invokerRegisterMock, nil) data := url.Values{} data.Set("client_id", "id") @@ -113,7 +124,7 @@ func TestPostSecurityIdTokenInvokerSecretNotValid(t *testing.T) { invokerRegisterMock.On("IsInvokerRegistered", mock.AnythingOfType("string")).Return(true) invokerRegisterMock.On("VerifyInvokerSecret", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(false) - requestHandler := getEcho(nil, nil, &invokerRegisterMock) + requestHandler := getEcho(nil, nil, &invokerRegisterMock, nil) data := url.Values{} data.Set("client_id", "id") @@ -140,7 +151,7 @@ func TestPostSecurityIdTokenFunctionNotRegistered(t *testing.T) { serviceRegisterMock := servicemocks.ServiceRegister{} serviceRegisterMock.On("IsFunctionRegistered", mock.AnythingOfType("string")).Return(false) - requestHandler := getEcho(&serviceRegisterMock, nil, &invokerRegisterMock) + requestHandler := getEcho(&serviceRegisterMock, nil, &invokerRegisterMock, nil) data := url.Values{} data.Set("client_id", "id") @@ -169,7 +180,7 @@ func TestPostSecurityIdTokenAPINotPublished(t *testing.T) { publishRegisterMock := publishmocks.PublishRegister{} publishRegisterMock.On("IsAPIPublished", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(false) - requestHandler := getEcho(&serviceRegisterMock, &publishRegisterMock, &invokerRegisterMock) + requestHandler := getEcho(&serviceRegisterMock, &publishRegisterMock, &invokerRegisterMock, nil) data := url.Values{} data.Set("client_id", "id") @@ -189,7 +200,47 @@ func TestPostSecurityIdTokenAPINotPublished(t *testing.T) { assert.Equal(t, &errMsg, errDetails.ErrorDescription) } -func getEcho(serviceRegister providermanagement.ServiceRegister, publishRegister publishservice.PublishRegister, invokerRegister invokermanagement.InvokerRegister) *echo.Echo { +func TestPostSecurityIdTokenInvokerInvalidCredentials(t *testing.T) { + invokerRegisterMock := invokermocks.InvokerRegister{} + invokerRegisterMock.On("IsInvokerRegistered", mock.AnythingOfType("string")).Return(true) + invokerRegisterMock.On("VerifyInvokerSecret", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(true) + serviceRegisterMock := servicemocks.ServiceRegister{} + serviceRegisterMock.On("IsFunctionRegistered", mock.AnythingOfType("string")).Return(true) + publishRegisterMock := publishmocks.PublishRegister{} + publishRegisterMock.On("IsAPIPublished", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(true) + + jwt := keycloak.Jwttoken{} + accessMgmMock := keycloackmocks.AccessManagement{} + accessMgmMock.On("GetToken", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(jwt, errors.New("invalid_credentials")) + + requestHandler := getEcho(&serviceRegisterMock, &publishRegisterMock, &invokerRegisterMock, &accessMgmMock) + + data := url.Values{} + clientId := "id" + clientSecret := "secret" + aefId := "aefId" + path := "path" + data.Set("client_id", clientId) + data.Set("client_secret", clientSecret) + data.Set("grant_type", "client_credentials") + data.Set("scope", "3gpp#"+aefId+":"+path) + + encodedData := data.Encode() + + result := testutil.NewRequest().Post("/securities/invokerId/token").WithContentType("application/x-www-form-urlencoded").WithBody([]byte(encodedData)).Go(t, requestHandler) + + assert.Equal(t, http.StatusBadRequest, result.Code()) + var resultResponse securityapi.AccessTokenErr + err := result.UnmarshalBodyToObject(&resultResponse) + assert.NoError(t, err, "error unmarshaling response") + invokerRegisterMock.AssertCalled(t, "IsInvokerRegistered", clientId) + invokerRegisterMock.AssertCalled(t, "VerifyInvokerSecret", clientId, clientSecret) + serviceRegisterMock.AssertCalled(t, "IsFunctionRegistered", aefId) + publishRegisterMock.AssertCalled(t, "IsAPIPublished", aefId, path) + accessMgmMock.AssertCalled(t, "GetToken", clientId, clientSecret, "3gpp#"+aefId+":"+path, "invokerrealm") +} + +func getEcho(serviceRegister providermanagement.ServiceRegister, publishRegister publishservice.PublishRegister, invokerRegister invokermanagement.InvokerRegister, keycloakMgm keycloak.AccessManagement) *echo.Echo { swagger, err := securityapi.GetSwagger() if err != nil { fmt.Fprintf(os.Stderr, "Error loading swagger spec\n: %s", err) @@ -198,7 +249,7 @@ func getEcho(serviceRegister providermanagement.ServiceRegister, publishRegister swagger.Servers = nil - s := NewSecurity(serviceRegister, publishRegister, invokerRegister) + s := NewSecurity(serviceRegister, publishRegister, invokerRegister, keycloakMgm) e := echo.New() e.Use(echomiddleware.Logger()) diff --git a/capifcore/main.go b/capifcore/main.go index 3ee4192..f8b9daf 100644 --- a/capifcore/main.go +++ b/capifcore/main.go @@ -32,12 +32,14 @@ import ( "oransc.org/nonrtric/capifcore/internal/discoverserviceapi" "oransc.org/nonrtric/capifcore/internal/eventsapi" "oransc.org/nonrtric/capifcore/internal/invokermanagementapi" + "oransc.org/nonrtric/capifcore/internal/keycloak" "oransc.org/nonrtric/capifcore/internal/providermanagementapi" "oransc.org/nonrtric/capifcore/internal/securityapi" "github.com/deepmap/oapi-codegen/pkg/middleware" echomiddleware "github.com/labstack/echo/v4/middleware" log "github.com/sirupsen/logrus" + config "oransc.org/nonrtric/capifcore/internal/config" "oransc.org/nonrtric/capifcore/internal/discoverservice" "oransc.org/nonrtric/capifcore/internal/eventservice" "oransc.org/nonrtric/capifcore/internal/helmmanagement" @@ -87,6 +89,13 @@ func getEcho() *echo.Echo { // Log all requests e.Use(echomiddleware.Logger()) + // Read configuration file + cfg, err := config.ReadKeycloakConfigFile("configs") + if err != nil { + log.Fatalf("Error loading configuration file\n: %s", err) + } + km := keycloak.NewKeycloakManager(cfg) + var group *echo.Group // Register ProviderManagement providerManagerSwagger, err := providermanagementapi.GetSwagger() @@ -150,7 +159,7 @@ func getEcho() *echo.Echo { log.Fatalf("Error loading Security swagger spec\n: %s", err) } securitySwagger.Servers = nil - securityService := security.NewSecurity(providerManager, publishService, invokerManager) + securityService := security.NewSecurity(providerManager, publishService, invokerManager, km) group = e.Group("/capif-security/v1") group.Use(middleware.OapiRequestValidator(securitySwagger)) securityapi.RegisterHandlersWithBaseURL(e, securityService, "/capif-security/v1") -- 2.16.6