-
Notifications
You must be signed in to change notification settings - Fork 5
Secure RESTful API with Keycloak implementation details
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:
- https://github.com/MetaCell/cloud-harness/blob/develop/applications/samples/server/api_samples/controllers/auth_controller.py
- https://github.com/MetaCell/cloud-harness/blob/d38fd902fbe489ee717507857810e436e4a6873f/applications/samples/api/samples.yaml#L16
- https://github.com/MetaCell/cloud-harness/blob/d38fd902fbe489ee717507857810e436e4a6873f/applications/samples/api/samples.yaml#L122
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
This secret is required to initialise authentication processes
// 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"
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>
POSTGRES_DB: <Same than above>
POSTGRES_USER: <Same than above>
POSTGRES_PASSWORD: <Same than above>
PGDATA: /var/lib/postgresql/data/pgdata
Add a PV at /var/lib/postgresql/data
leaving pgdata folder to postgres to avoid conflicts when mounting.
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:
- flask-oidc
- requests
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)
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
})
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'))
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.