-
f7deaf2d
# Basic system imports
import pprint
import logging
import os
import click
import urllib.parse
# Flask
from flask import abort, Flask, redirect, request, render_template
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask.cli import AppGroup
# Import modules for external APIs
# Hydra, OIDC Identity Provider
import hydra_client
# Kratos, Identity manager
import ory_kratos_client
from ory_kratos_client.api import metadata_api
from ory_kratos_client.api import v0alpha2_api as kratos_api
from ory_kratos_client.model.generic_error import GenericError
from ory_kratos_client.model.inline_response200 import InlineResponse200
from ory_kratos_client.model.inline_response2001 import InlineResponse2001
from ory_kratos_client.model.inline_response503 import InlineResponse503
from ory_kratos_client.model.admin_create_identity_body import AdminCreateIdentityBody
from ory_kratos_client.model.admin_create_self_service_recovery_link_body import AdminCreateSelfServiceRecoveryLinkBody
from ory_kratos_client.model.identity_state import IdentityState
# Initaliaze the FLASK app
app = Flask(__name__,
static_url_path='/static')
# Load config
app.config.from_object(os.environ['APP_SETTINGS'])
# Set right logging level based on config DEBUG flag
if app.config['DEBUG']:
app.logger.setLevel(logging.INFO)
else:
app.logger.setLevel(logging.ERROR)
# Create HYDRA & KRATOS API interfaces
HYDRA = hydra_client.HydraAdmin(app.config["HYDRA_ADMIN_URL"])
# Kratos has an admin and public end-point. We create an API for them
# both. The kratos implementation has bugs, which forces us to set
# the discard_unknown_keys to True.
tmp = ory_kratos_client.Configuration(host=app.config["KRATOS_ADMIN_URL"],
discard_unknown_keys= True)
KRATOS_ADMIN = kratos_api.V0alpha2Api(ory_kratos_client.ApiClient(tmp))
tmp = ory_kratos_client.Configuration(host=app.config["KRATOS_PUBLIC_URL"],
discard_unknown_keys = True)
KRATOS_PUBLIC = kratos_api.V0alpha2Api(ory_kratos_client.ApiClient(tmp))
# Kratos API Example:
# A minimal use of the KRATOS is for example getting all identities as an admin
#
# ids = KRATOS_ADMIN.admin_list_identities()
# print (ids)
#
# Kratos API documentation for python can be found here:
# https://github.com/ory/sdk/blob/master/clients/kratos/python/README.md
#
# Create DB & migrate interface
db = SQLAlchemy(app)
migrate = Migrate(app, db)
# Import models
from models import User, App, AppRole
#
# WARNING:
#
# Below are very minimalistic calls to interfaces for development and testing
# purposed. Eventually this need to be moved to seperate files and more
# sophisticated calls with try{} catch{} claused etc.
#
##############################################################################
# CLI INTERFACE #
##############################################################################
# Define Flask CLI command groups and commands
user_cli = AppGroup('user')
app_cli = AppGroup('app')
## CLI APP COMMANDS
@app_cli.command('create')
@click.argument('slug')
@click.argument('name')
def create_app(slug, name):
app.logger.info(f"Creating app definition: {name} ({slug})")
obj = App()
obj.name = name
obj.slug = slug
db.session.add(obj)
db.session.commit()
@app_cli.command('list')
def list_app():
app.logger.info("Listing configured apps")
apps = App.query.all()
for obj in apps:
print("App name: %s \t Slug: %s" %(obj.name, obj.slug))
@app_cli.command('delete',)
@click.argument('slug')
def delete_app(slug):
app.logger.info("Trying to delete app: {0}".format(slug))
obj = App.query.filter_by(slug=slug).first()
if not obj:
app.logger.info("Not found")
return
# TODO: Deleting will (propably) fail is there are still roles attached
# these probobly need to be cleaned up first.
db.session.delete(obj)
db.session.commit()
app.logger.info("Success")
return
app.cli.add_command(app_cli)
## CLI USER COMMANDS
@user_cli.command('create')
@click.argument('email')
def create_user(email):
app.logger.info("Creating user with email: ({0})".format(email))
obj = User()
obj.email = email
# TODO:
# - Should check if database entry already exists. If so, double check if
# kratos user exists. If not, create that one.
#
# - If no DB user, should check with kratos of user already exists. If so,
# throw warning, but still create DB entrie
#
# - After creating kratos user, check if success, otherwise throw warning.
body = AdminCreateIdentityBody(
schema_id="default",
traits={'email':email},
) # AdminCreateIdentityBody | (optional)
#state=IdentityState("active"),
kratos_obj = KRATOS_ADMIN.admin_create_identity(admin_create_identity_body=body)
obj.kratos_id = kratos_obj.id
db.session.add(obj)
db.session.commit()
@user_cli.command('list')
def list_user():
app.logger.info("Listing users")
users = User.query.all()
for obj in users:
print("Email: %s (admin: %s)" %(obj.email, obj.admin))
@user_cli.command('delete',)
@click.argument('email')
def delete_user(email):
app.logger.info("Trying to delete user: {0}".format(email))
obj = User.query.filter_by(email=email).first()
if not obj:
app.logger.info("Not found")
return
db.session.delete(obj)
# TODO:
# - if delete succesfull, also delete kratos user, if exists
# - probably user roles need to be deleted before user can be deleted
db.session.commit()
app.logger.info("Success")
return
@user_cli.command('setadmin')
@click.argument('email')
def setadmin_user(email):
app.logger.info("Trying to make user into admin: {0}".format(email))
obj = User.query.filter_by(email=email).first()
if not obj:
app.logger.info("Not found")
return
obj.admin = True
db.session.commit()
app.logger.info("Success")
return
@user_cli.command('unsetadmin')
@click.argument('email')
def unsetadmin_user(email):
app.logger.info("Trying to make user into normal user: {0}".format(email))
obj = User.query.filter_by(email=email).first()
if not obj:
app.logger.info("Not found")
return
obj.admin = False
db.session.commit()
app.logger.info("Success")
return
@user_cli.command('recover')
@click.argument('email')
def recover_user(email):
app.logger.info("Trying to send recover email for user: {0}".format(email))
obj = User.query.filter_by(email=email).first()
if not obj:
app.logger.info("Not found")
return
if not obj.kratos_id:
app.logger.info("User found, but no kratos ID")
return
body = AdminCreateSelfServiceRecoveryLinkBody(
expires_in="1h",
identity_id=obj.kratos_id,
)
# example passing only required values which don't have defaults set
# and optional values
try:
# Create a Recovery Link
api_response = KRATOS_ADMIN.admin_create_self_service_recovery_link(
admin_create_self_service_recovery_link_body=body)
pprint.pprint(api_response)
except ory_kratos_client.ApiException as error:
app.logger.error("Exception when calling" +
"V0alpha2Api->admin_create_self_service_recovery_link: %s\n" % error)
return
app.cli.add_command(user_cli)
##############################################################################
# WEB ROUTES #
##############################################################################
@app.route('/recovery', methods=['GET', 'POST'])
def recovery():
"""Start recovery flow
If no active flow, redirect to kratos to create a flow, otherwise render the
recovery template.
:param flow: flow as given by Kratos
:return: redirect or recovery page
"""
flow = request.args.get("flow")
if not flow:
return redirect(app.config["KRATOS_PUBLIC_URL"] + "self-service/recovery/browser")
return render_template(
'recover.html',
api_url = app.config["KRATOS_PUBLIC_URL"]
)
@app.route('/settings', methods=['GET', 'POST'])
def settings():
"""Start settings flow
If no active flow, redirect to kratos to create a flow, otherwise render the
settings template.
:param flow: flow as given by Kratos
:return: redirect or settings page
"""
flow = request.args.get("flow")
if not flow:
return redirect(app.config["KRATOS_PUBLIC_URL"] + "self-service/settings/browser")
return render_template(
'settings.html',
api_url = app.config["KRATOS_PUBLIC_URL"]
)
@app.route('/login', methods=['GET', 'POST'])
def login():
"""Start login flow
If already logged in, shows the loggedin template. Otherwise creates a login
flow, if no active flow will redirect to kratos to create a flow.
:param flow: flow as given by Kratos
:return: redirect or login page
"""
# Check if we are logged in:
profile = getAuth()
if profile:
return render_template(
'loggedin.html',
api_url = app.config["KRATOS_PUBLIC_URL"],
id = id)
flow = request.args.get("flow")
# If we do not have a flow, get one.
if not flow:
return redirect(app.config["KRATOS_PUBLIC_URL"] + "self-service/login/browser")
return render_template(
'login.html',
api_url = app.config["KRATOS_PUBLIC_URL"]
)
@app.route('/auth', methods=['GET', 'POST'])
def auth():
"""Authorize an user for an application
If an application authenticated against the IdP (Idenitity Provider), if
there are no active session, the user is forwarded to the login page.
This is the entry point for those authorization requests. The challenge
as provided, is verified. If an active user is logged in, the request
is accepted and the user is returned to the application. If the user is not
logged in yet, it redirects to the login page
:param challenge: challenge as given by Hydra
:return: redirect to login or application/idp
"""
challenge = None
# Retrieve the challenge id from the request. Depending on the method it is
# saved in the form (POST) or in a GET variable. If this variable is not set
# we can not continue.
if request.method == 'GET':
challenge = request.args.get("login_challenge")
if request.method == 'POST':
challenge = request.args.post("login_challenge")
if not challenge:
# TODO: Use local error page?
app.logger.error("No challange given. Error in request")
abort(404)
# Check if we are logged in:
profile = getAuth()
# If the user is not logged in yet, we redirect to the login page
# but before we do that, we set the "flow_state" cookie to auth.
# so the UI knows it has to redirect after a successful login.
# The redirect URL is back to this page (auth) with the same challenge
# so we can pickup the flow where we left off.
if not profile:
url = app.config["PUBLIC_URL"] + "/auth?login_challenge=" + challenge;
url = urllib.parse.quote_plus(url)
app.logger.info("Redirecting to login. Setting flow_state cookies")
app.logger.info("auth_url: " + url)
response = redirect("login")
response.set_cookie('flow_state', 'auth')
response.set_cookie('auth_url', url)
return response
app.logger.info("User is logged in. We can authorize the user")
try:
login_request = HYDRA.login_request(challenge)
except hydra_client.exceptions.NotFound:
app.logger.error("Not Found. Login request not found. challenge={0}".format(challenge))
# TODO: Different error page?
abort(404)
except hydra_client.exceptions.HTTPError:
app.logger.error("Conflict. Login request has been used already. challenge={0}".format(challenge))
# TODO: Different error page?
abort(503)
# Authorize the user
redirect_to = login_request.accept(
profile['email'],
remember=True,
# Remember session for 7d
remember_for=60*60*24*7)
return redirect(redirect_to)
@app.route('/consent', methods=['GET', 'POST'])
def consent():
"""Get consent
For now, it just allows every user. Eventually this function should check
the roles and settings of a user and provide that information to the
application.
:param consent_challenge: challenge as given by Hydra
:return: redirect to login or render error
"""
challenge = request.args.get("consent_challenge")
if not challenge:
# TODO: Better error handling/display?
abort(403)
try:
consent_request = HYDRA.consent_request(challenge)
except hydra_client.exceptions.NotFound:
app.logger.error("Not Found. Consent request not found. challenge={0}".format(challenge))
# TODO: Better error handling/display?
abort(404)
except hydra_client.exceptions.HTTPError:
app.logger.error("Conflict. Consent request has been used already. challenge={0}".format(challenge))
# TODO: Better error handling/display?
abort(503)
# Get information about this consent request:
app_name = consent_request.client.client_name
username = consent_request.subject
# Get the related database object to get roles/access rights
user = User.query.filter_by(email=username).first()
if not user:
app.logger.error("User not found in database: {0}".format(username))
# TODO: Better error handling?
abort(401)
# Get claims for this user, provided the current app
claims = user.getClaims(app_name)
# TODO: Check access / claims?
app.logger.info("Providing consent to %s for %s" % (app_name, username))
app.logger.info("{0} was granted access to {1}".format(username, app_name))
return redirect(consent_request.accept(
grant_scope=consent_request.requested_scope,
grant_access_token_audience=consent_request.requested_access_token_audience,
session=claims,
))
@app.route('/status', methods=['GET', 'POST'])
def status():
"""Get status of current session
Show if there is an user is logged in. If not shows: not-auth
"""
auth = getAuth()
if auth:
return auth['email']
else:
return "not-auth"
def getAuth():
"""Checks if user is logged in
Queries the cookies. If an authentication cookie is found, it
checks with Kratos if the cookie is still valid. If so,
the profile is returned. Otherwise False is returned.
:return: Profile or False if not logged in
"""
try:
cookie = request.cookies.get('ory_kratos_session');
cookie = "ory_kratos_session=" + cookie
except TypeError:
app.logger.info("User not logged in or cookie corrupted")
return False
# Given a cookie, check if it is valid and get the profile
try:
api_response = KRATOS_PUBLIC.to_session(
cookie = cookie)
# Get all traits from ID
profile = api_response.identity.traits
return profile
except ory_kratos_client.ApiException as error:
app.logger.error("Exception when calling" +
"V0alpha2Api->to_session(): %s\n" % error)
return False
if __name__ == '__main__':
app.run()