github.com/verrazzano/verrazzano@v1.7.1/tests/e2e/pkg/keycloak.go (about)

     1  // Copyright (c) 2021, 2023, Oracle and/or its affiliates.
     2  // Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl.
     3  
     4  package pkg
     5  
     6  import (
     7  	"context"
     8  	"encoding/json"
     9  	"fmt"
    10  	"io"
    11  	"net/http"
    12  	"net/url"
    13  	"strings"
    14  	"time"
    15  
    16  	"go.uber.org/zap"
    17  
    18  	"github.com/hashicorp/go-retryablehttp"
    19  	"github.com/onsi/gomega"
    20  	"github.com/verrazzano/verrazzano/pkg/k8sutil"
    21  	k8smeta "k8s.io/apimachinery/pkg/apis/meta/v1"
    22  )
    23  
    24  type KeycloakRESTClient struct {
    25  	kubeConfigPath      string
    26  	keycloakIngressHost string
    27  	adminAccessToken    string
    28  	httpClient          *retryablehttp.Client
    29  }
    30  
    31  const (
    32  	keycloakNamespace               = "keycloak"
    33  	keycloadIngressName             = "keycloak"
    34  	keycloakAdminUserPasswordSecret = "keycloak-http" //nolint:gosec //#gosec G101
    35  	keycloakAdminUserRealm          = "master"
    36  	keycloakAdminUserName           = "keycloakadmin"
    37  
    38  	verrazzanoPgClientID  = "verrazzano-pg"
    39  	keycloakAdminClientID = "admin-cli"
    40  
    41  	TestKeycloakMasterUserIDKey     = "TEST_KEYCLOAK_MASTER_USERID"
    42  	TestKeycloakVerrazzanoUserIDKey = "TEST_KEYCLOAK_VZ_USERID"
    43  
    44  	TestKeycloakNamespace = "keycloak-test-ns"
    45  	TestKeycloakConfigMap = "keycloak-test-cm"
    46  )
    47  
    48  // NewKeycloakRESTClient creates a new Keycloak REST client.
    49  func NewKeycloakAdminRESTClient() (*KeycloakRESTClient, error) {
    50  	kubeconfigPath, err := k8sutil.GetKubeConfigLocation()
    51  	if err != nil {
    52  		return nil, err
    53  	}
    54  
    55  	clientset, err := GetKubernetesClientsetForCluster(kubeconfigPath)
    56  	if err != nil {
    57  		return nil, err
    58  	}
    59  	ingress, err := clientset.NetworkingV1().Ingresses(keycloakNamespace).Get(context.TODO(), keycloadIngressName, k8smeta.GetOptions{})
    60  	if err != nil {
    61  		return nil, err
    62  	}
    63  	httpClient, err := GetVerrazzanoHTTPClient(kubeconfigPath)
    64  	if err != nil {
    65  		return nil, err
    66  	}
    67  
    68  	secret, err := GetSecret(keycloakNamespace, keycloakAdminUserPasswordSecret)
    69  	if err != nil {
    70  		return nil, err
    71  	}
    72  	keycloakAdminPassword := strings.TrimSpace(string(secret.Data["password"]))
    73  
    74  	ingressHost := ingress.Spec.Rules[0].Host
    75  	keycloakLoginURL := fmt.Sprintf("https://%s/auth/realms/%s/protocol/openid-connect/token", ingressHost, keycloakAdminUserRealm)
    76  	body := fmt.Sprintf("username=%s&password=%s&grant_type=password&client_id=%s", keycloakAdminUserName, keycloakAdminPassword, keycloakAdminClientID)
    77  	resp, err := PostWithHostHeader(keycloakLoginURL, "application/x-www-form-urlencoded", ingressHost, strings.NewReader(body))
    78  	if err != nil {
    79  		return nil, err
    80  	}
    81  	if resp.StatusCode != http.StatusOK {
    82  		return nil, fmt.Errorf("failed to login as admin user")
    83  	}
    84  	token := JTq(string(resp.Body), "access_token").(string)
    85  	if token == "" {
    86  		return nil, fmt.Errorf("failed to obtain valid access token")
    87  	}
    88  
    89  	client := KeycloakRESTClient{
    90  		kubeConfigPath:      kubeconfigPath,
    91  		keycloakIngressHost: ingress.Spec.Rules[0].Host,
    92  		adminAccessToken:    token,
    93  		httpClient:          httpClient}
    94  	return &client, nil
    95  }
    96  
    97  // GetRealm gets realm data from Keycloak.
    98  func (c *KeycloakRESTClient) GetRealm(realm string) (map[string]interface{}, error) {
    99  	requestURL := fmt.Sprintf("https://%s/auth/admin/realms/%s", c.keycloakIngressHost, realm)
   100  	request, err := retryablehttp.NewRequest("GET", requestURL, nil)
   101  	request.Host = c.keycloakIngressHost
   102  	request.Header.Add("Authorization", fmt.Sprintf("Bearer %v", c.adminAccessToken))
   103  	request.Header.Add("Accept", "application/json")
   104  	if err != nil {
   105  		return nil, err
   106  	}
   107  	response, err := c.httpClient.Do(request)
   108  	if err != nil {
   109  		return nil, err
   110  	}
   111  	if response == nil {
   112  		return nil, fmt.Errorf("invalid response")
   113  	}
   114  	defer response.Body.Close()
   115  	if response.StatusCode != 200 {
   116  		return nil, fmt.Errorf("invalid response status: %d", response.StatusCode)
   117  	}
   118  	responseBody, err := io.ReadAll(response.Body)
   119  	if err != nil {
   120  		return nil, err
   121  	}
   122  	jsonMap := make(map[string]interface{})
   123  	err = json.Unmarshal(responseBody, &jsonMap)
   124  	if err != nil {
   125  		return nil, err
   126  	}
   127  	return jsonMap, nil
   128  }
   129  
   130  // GetRealm gets a bearer token from a realm.
   131  func (c *KeycloakRESTClient) GetToken(realm string, username string, password string, clientid string, log *zap.SugaredLogger) (string, error) {
   132  	form := url.Values{}
   133  	form.Add("username", username)
   134  	form.Add("password", password)
   135  	form.Add("grant_type", "password")
   136  	form.Add("client_id", clientid)
   137  
   138  	requestURL := fmt.Sprintf("https://%s/auth/realms/%s/protocol/openid-connect/token", c.keycloakIngressHost, realm)
   139  	response, err := PostWithHostHeader(requestURL, "application/x-www-form-urlencoded", c.keycloakIngressHost, strings.NewReader(form.Encode()))
   140  	log.Debugf("response: %s", response.Body)
   141  	if response.StatusCode != 200 {
   142  		return "", fmt.Errorf("invalid response status: %d", response.StatusCode)
   143  	}
   144  	if err != nil {
   145  		return "", err
   146  	}
   147  	if response.StatusCode != http.StatusOK {
   148  		return "", fmt.Errorf("failed to access token endpoint")
   149  	}
   150  	token := JTq(string(response.Body), "access_token").(string)
   151  	if token == "" {
   152  		return "", fmt.Errorf("failed to obtain valid access token")
   153  	}
   154  
   155  	return token, nil
   156  }
   157  
   158  // CreateUser creates a user in Keycloak
   159  // curl -v http://localhost:8080/auth/admin/realms/apiv2/users -H "Content-Type: application/json" -H "Authorization: bearer $TOKEN"   --data '{"username":"someuser", "firstName":"xyz", "lastName":"xyz", "email":"demo2@gmail.com", "enabled":"true"}'
   160  func (c *KeycloakRESTClient) CreateUser(userRealm string, userName string, firstName string, lastName string, password string) (string, error) {
   161  	requestData := map[string]interface{}{
   162  		"username":  userName,
   163  		"firstName": firstName,
   164  		"lastName":  lastName,
   165  		"credentials": [...]map[string]interface{}{{
   166  			"type":      "password",
   167  			"value":     password,
   168  			"temporary": false},
   169  		},
   170  	}
   171  	requestBody, err := json.Marshal(requestData)
   172  	if err != nil {
   173  		fmt.Printf("marshal request failed: %v\n", err)
   174  		return "", err
   175  	}
   176  
   177  	requestURL := fmt.Sprintf("https://%s/auth/admin/realms/%s/users", c.keycloakIngressHost, userRealm)
   178  	request, err := retryablehttp.NewRequest("POST", requestURL, requestBody)
   179  	if err != nil {
   180  		return "", err
   181  	}
   182  	request.Host = c.keycloakIngressHost
   183  	request.Header.Add("Authorization", fmt.Sprintf("Bearer %v", c.adminAccessToken))
   184  	request.Header.Add("Content-Type", "application/json")
   185  	response, err := c.httpClient.Do(request)
   186  	if err != nil {
   187  		return "", err
   188  	}
   189  	if response == nil {
   190  		return "", fmt.Errorf("invalid response")
   191  	}
   192  	defer response.Body.Close()
   193  	location := response.Header.Get("Location")
   194  	if response.StatusCode != 201 {
   195  		return location, fmt.Errorf("invalid response status code: %d", response.StatusCode)
   196  	}
   197  	if location == "" {
   198  		return location, fmt.Errorf("invalid response location")
   199  	}
   200  	return location, nil
   201  }
   202  
   203  // DeleteUser deletes a user from Keycloak
   204  // DELETE /auth/admin/realms/<realm>/users/<userID>
   205  func (c *KeycloakRESTClient) DeleteUser(userRealm string, userID string) error {
   206  	requestURL := fmt.Sprintf("https://%s/auth/admin/realms/%s/users/%s", c.keycloakIngressHost, userRealm, userID)
   207  	request, err := retryablehttp.NewRequest("DELETE", requestURL, nil)
   208  	if err != nil {
   209  		return err
   210  	}
   211  	request.Host = c.keycloakIngressHost
   212  	request.Header.Add("Authorization", fmt.Sprintf("Bearer %v", c.adminAccessToken))
   213  	response, err := c.httpClient.Do(request)
   214  	if err != nil {
   215  		return err
   216  	}
   217  	if response == nil {
   218  		return fmt.Errorf("invalid response")
   219  	}
   220  	defer response.Body.Close()
   221  	// all responses in the 200 range are acceptable
   222  	// in practice, this call returns 204 (No Content) for success
   223  	if response.StatusCode < 200 || response.StatusCode >= 300 {
   224  		return fmt.Errorf("invalid response status: %d", response.StatusCode)
   225  	}
   226  	return nil
   227  }
   228  
   229  // VerifyUserExists verifies the user exists in Keycloak
   230  // GET /auth/admin/realms/<realm>/users/<userID>
   231  func (c *KeycloakRESTClient) VerifyUserExists(userRealm string, userID string) (bool, error) {
   232  	requestURL := fmt.Sprintf("https://%s/auth/admin/realms/%s/users/%s", c.keycloakIngressHost, userRealm, userID)
   233  	request, err := retryablehttp.NewRequest("GET", requestURL, nil)
   234  	if err != nil {
   235  		return false, err
   236  	}
   237  	request.Host = c.keycloakIngressHost
   238  	request.Header.Add("Authorization", fmt.Sprintf("Bearer %v", c.adminAccessToken))
   239  	response, err := c.httpClient.Do(request)
   240  	if err != nil {
   241  		return false, err
   242  	}
   243  	if response == nil {
   244  		return false, fmt.Errorf("invalid response")
   245  	}
   246  	defer response.Body.Close()
   247  	if response.StatusCode != 200 {
   248  		return false, fmt.Errorf("invalid response status: %d", response.StatusCode)
   249  	}
   250  	return true, nil
   251  }
   252  
   253  // SetPassword sets a user's password in Keycloak
   254  // PUT /auth/admin/realms/{realm}/users/{id}/reset-password
   255  // { "type": "password", "temporary": false, "value": "..." }
   256  func (c *KeycloakRESTClient) SetPassword(userRealm string, userID string, password string) error {
   257  	requestData := map[string]interface{}{
   258  		"type":      "password",
   259  		"value":     password,
   260  		"temporary": false}
   261  	requestBody, err := json.Marshal(requestData)
   262  	if err != nil {
   263  		return err
   264  	}
   265  	requestURL := fmt.Sprintf("https://%s/auth/admin/realms/%s/users/%s/reset-password", c.keycloakIngressHost, userRealm, userID)
   266  	request, err := retryablehttp.NewRequest("PUT", requestURL, requestBody)
   267  	if err != nil {
   268  		fmt.Printf("create reset-password request failed=%v\n", err)
   269  		return err
   270  	}
   271  	request.Host = c.keycloakIngressHost
   272  	request.Header.Add("Authorization", fmt.Sprintf("Bearer %v", c.adminAccessToken))
   273  	request.Header.Add("Content-Type", "application/json")
   274  	response, err := c.httpClient.Do(request)
   275  	if err != nil {
   276  		return err
   277  	}
   278  	if response == nil {
   279  		return fmt.Errorf("invalid response")
   280  	}
   281  	defer response.Body.Close()
   282  	if response.StatusCode != 204 {
   283  		return fmt.Errorf("invalid response status: %d", response.StatusCode)
   284  	}
   285  	return nil
   286  }
   287  
   288  // VerifyKeycloakAccess verifies access to Keycloak
   289  func VerifyKeycloakAccess(log *zap.SugaredLogger) error {
   290  	waitTimeout := 5 * time.Minute
   291  	pollingInterval := 5 * time.Second
   292  	if !IsManagedClusterProfile() {
   293  		var keycloakURL string
   294  		kubeconfigPath, err := k8sutil.GetKubeConfigLocation()
   295  		if err != nil {
   296  			log.Error(fmt.Sprintf("Error getting kubeconfig: %v", err))
   297  			return err
   298  		}
   299  
   300  		gomega.Eventually(func() error {
   301  			api := EventuallyGetAPIEndpoint(kubeconfigPath)
   302  			ingress, err := api.GetIngress("keycloak", "keycloak")
   303  			if err != nil {
   304  				return err
   305  			}
   306  			keycloakURL = fmt.Sprintf("https://%s", ingress.Spec.Rules[0].Host)
   307  			log.Infof("Found ingress URL: %s", keycloakURL)
   308  			return nil
   309  		}, waitTimeout, pollingInterval).ShouldNot(gomega.HaveOccurred())
   310  
   311  		gomega.Expect(keycloakURL).NotTo(gomega.BeEmpty())
   312  		var httpResponse *HTTPResponse
   313  
   314  		gomega.Eventually(func() (*HTTPResponse, error) {
   315  			var err error
   316  			httpResponse, err = GetWebPage(keycloakURL, "")
   317  			return httpResponse, err
   318  		}, waitTimeout, pollingInterval).Should(HasStatus(http.StatusOK))
   319  		gomega.Expect(CheckNoServerHeader(httpResponse)).To(gomega.BeTrue(), "Found unexpected server header in response")
   320  	}
   321  	return nil
   322  }