Skip to content

Secure RESTful API with Keycloak implementation details

Filippo Ledda edited this page Feb 3, 2021 · 2 revisions

🔒Securing RESTful APIs with Keycloak (KC) 🔒

Note: this document is not a tutorial on how to secure an application on Cloud Harness. The aim is giving the CH developer an insight on how it's implemented. To see an example of a secured api, see samples application:

This wiki describes the steps required to have a web application dashboard secured with username and password and then interact with a RESTful API using JWT

👨‍💻Keycloak configuration

1️⃣ Create a new Realm

Screenshot 2019-09-04 at 10 06 41 AM

2️⃣ Create a KC client using KC UI

Screenshot 2019-09-04 at 10 03 37 AM

3️⃣ Increase tokens lifespan

Screenshot 2019-09-04 at 10 05 18 AM

4️⃣ Take note of the client secret

Screenshot 2019-09-04 at 10 09 32 AM

This secret is required to initialise authentication processes

5️⃣ Create a new user

Screenshot 2019-09-04 at 10 14 06 AM

6️⃣ Change the password and toggle tmp to off

Screenshot 2019-09-04 at 2 23 12 PM

7️⃣ Assign some roles

Screenshot 2019-09-04 at 2 24 58 PM

8️⃣ Export KC configuration

Screenshot 2019-09-10 at 11 01 22 AM

K8s deployment

KC

1️⃣ Environmental variables

// load the previously created setup
KEYCLOAK_IMPORT: "/tmp/realm.json"

// kc admin
KEYCLOAK_USER: "mnp"
KEYCLOAK_PASSWORD: "secret"
PROXY_ADDRESS_FORWARDING: "true"

// db related 
DB_VENDOR: "POSTGRES"
DB_ADDR: "<db-pod-service-name>.<namespace>.svc.cluster.local:5432"  # k8s service
DB_DATABASE: "mnp" 
DB_USER: "mnp"
DB_PASSWORD: "secret"

2️⃣ ConfigMap

In order to import the JSON file we created in the first section, we could add a ConfigMap

apiVersion: v1
kind: ConfigMap
metadata:
  name: <Insert some name here>
data:
  realm.json: |
    {
        <Insert here the content of the JSON file we exported from KC UI>
    }

And we use it like this:

spec:
  containers:
    volumeMounts:
    - name: realm-config
      mountPath: "/tmp/realm.json"
      subPath: realm.json
  volumes:
  - name: realm-config
    configMap:
      name: <Insert the configMap name here>

KC associated database

1️⃣ Environmental variables

POSTGRES_DB: <Same than above>
POSTGRES_USER: <Same than above>
POSTGRES_PASSWORD: <Same than above>
PGDATA: /var/lib/postgresql/data/pgdata

2️⃣ Persistent volume

Add a PV at /var/lib/postgresql/data leaving pgdata folder to postgres to avoid conflicts when mounting.

Secure a web application with keycloak

We will use flask as example. Following good practices, configuration values to interact with KC will be available through environmental variables in the container. Example:

Screenshot 2019-09-10 at 12 14 38 PM

Requirements

  • flask-oidc
  • requests

OIDC config file creation

Run this script when the server starts (you might want to bootstrap it from k8s yaml)

SECURITY_CONFIG_FILE_NAME = 'client_secrets.json'

with open(SECURITY_CONFIG_FILE_NAME, 'w') as f:
    REALM = os.environ.get('REALM')                     # KC realm
    DOMAIN = os.environ.get('DOMAIN')                   # This webapp public domain
    CLIENT_ID = os.environ.get('CLIENT_ID')             # KC client created in the beginning of this wiki 
    AUTH_DOMAIN = os.environ.get('AUTH_DOMAIN')         # KC public domain
    CLIENT_SECRET = os.environ.get('CLIENT_SECRET')     # KC client secret required to start auth process
    
    # The following code will generate the required configuration values
    # from the variables defined above
    SCHEMA = 'https://'
    BASE_PATH = f"//{os.path.join(AUTH_DOMAIN, 'auth/realms', REALM)}"
    EXTENDED_PATH = f"//{os.path.join(BASE_PATH, 'protocol/openid-connect')}"
    
    content = {
        "web": {
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
            "redirect_uris": [ urljoin(SCHEMA, os.path.join(DOMAIN, '*')) ],
            "issuer": urljoin(SCHEMA, BASE_PATH),
            "auth_uri": urljoin(SCHEMA, os.path.join(EXTENDED_PATH, 'auth')),
            "userinfo_uri": urljoin(SCHEMA, os.path.join(EXTENDED_PATH, 'userinfo')),
            "token_uri": urljoin(SCHEMA, os.path.join(EXTENDED_PATH, 'token')),
            "token_introspection_uri": urljoin(SCHEMA, os.path.join(EXTENDED_PATH, 'token/introspect'))
        }
    }
    json.dump(content, f)

Flask app OIDC config

With the variables defined above, update the app configuration like this

app.config.update({
    'DEBUG': True,
    'TESTING': True,
    'SECRET_KEY': 'secret',
    'OIDC_CLIENT_SECRETS': SECURITY_CONFIG_FILE_NAME,       # file we generated above
    'OIDC_ID_TOKEN_COOKIE_SECURE': False,
    'OIDC_REQUIRE_VERIFIED_EMAIL': False,
    'OIDC_USER_INFO_ENABLED': True,
    'OIDC_VALID_ISSUERS': [urljoin(SCHEMA, BASE_PATH)],
    'OIDC_OPENID_REALM': REALM,
    'OIDC_INTROSPECTION_AUTH_METHOD': 'client_secret_post',
    'OIDC_SCOPES': ['openid', 'email', 'profile'],           # this could include the scopes you want
})

Securing the dashboard

The base path would look like this

@app.route('/')
def hello_world():
    if oidc.user_loggedin:                        # Check if the user is logged in 
        return redirect(url_for('dash'))          # Send the user to the dashboard
    else:
        return redirect(url_for('main'))          # Send the user to a public path

Regarding the dashboard

@app.route('/private')
@oidc.require_login                # implement auth flow is the user is not authenticated
def dash():
    # extract here info aboout the user that is visiting this path
    info = oidc.user_getinfo(['email', 'openid_id'])
    info2 = oidc.user_getinfo(['preferred_username', 'email', 'sub'])
    username = info2.get('preferred_username')
    email = info.get('email')
    user_id = info2.get('sub')

    if user_id in oidc.credentials_store:

        try:
            # extract the token from the user 
            from oauth2client.client import OAuth2Credentials
            access_token = OAuth2Credentials.from_json(oidc.credentials_store[user_id]).access_token
            
        except:
            access_token = "sorry"
        
        # render the user dashboard 
        return render_template("token.html", token=access_token, username=username, is_valid="EMPTY")

    else:
        return redirect(url_for('logout'))

RESTful API

Using OpenAPI to generate the code, we introduce the following security

security:
  - bearerAuth: []
components:
  securitySchemes: 
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

Then we look for security_controller.py file:

def info_from_bearerAuth(token):
    SCHEMA = 'https://'
    AUTH_DOMAIN = os.environ.get('AUTH_DOMAIN')
    AUTH_REALM = os.environ.get('AUTH_REALM')
    BASE_PATH = f"//{os.path.join(AUTH_DOMAIN, 'auth/realms', AUTH_REALM)}"
    AUTH_PUBLIC_KEY_URL = urljoin(SCHEMA, BASE_PATH)

    # We extract KC public key to validate the JWT we receive 
    KEY = json.loads(requests.get(AUTH_PUBLIC_KEY_URL, verify=False).text)['public_key'] 
    
    # Create the key
    KEY = f"-----BEGIN PUBLIC KEY-----\n{KEY}\n-----END PUBLIC KEY-----"
    
    try:
        # Here we decode the JWT
        decoded = jwt.decode(token, KEY, audience='account', algorithms='RS256')
    except:
        current_app.logger.debug(f"Error validating user: {sys.exc_info()}")
        return None
    
    # Here we proceed to do all the validation we need to check if we grant access to the RESTful API 
    valid = 'offline_access' in decoded['realm_access']['roles']
    current_app.logger.debug(valid)
    return {'uid': 'user_id' }

NOTE: OpenApi might have a way to implement this functionality from within the yaml file definition. We need to explore that possibility.