Newer
Older
"""Flask application which provides the interface of a login panel. The
application interacts with different backend, like the Kratos backend for users,
Hydra for OIDC sessions and MariaDB for application and role specifications.
The application provides also several command line options to interact with
the user entries in the database(s)"""
import urllib.parse
import urllib.request
import ast
import ory_hydra_client
# hydra v2
# from ory_hydra_client.api import o_auth2_api
from ory_hydra_client.api import admin_api
from ory_hydra_client.models import AcceptConsentRequest, AcceptLoginRequest, ConsentRequestSession
import ory_hydra_client.exceptions as hydra_exceptions
from ory_kratos_client.api import frontend_api, identity_api
from flask import abort, current_app, jsonify, redirect, render_template, request
from database import db
from helpers import KratosUser
from areas.roles.models import Role
from areas.users.user_service import UserService
# This is a circular import and should be solved differently
# APIs
# Create HYDRA & KRATOS API interfaces
hydra_admin_api_configuration = \
ory_hydra_client.Configuration(host=HYDRA_ADMIN_URL, discard_unknown_keys=True)
hydra_client = ory_hydra_client.ApiClient(hydra_admin_api_configuration)
# hydra v2
# oauth2_api = o_auth2_api.OAuth2Api(hydra_client)
hydra_admin_api = admin_api.AdminApi(hydra_client)
# 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.
kratos_admin_api_configuration = \
ory_kratos_client.Configuration(host=KRATOS_ADMIN_URL, discard_unknown_keys=True)
kratos_admin_client = ory_kratos_client.ApiClient(kratos_admin_api_configuration)
admin_identity_api = identity_api.IdentityApi(kratos_admin_client)
admin_frontend_api = frontend_api.FrontendApi(kratos_admin_client)
kratos_public_api_configuration = \
ory_kratos_client.Configuration(host=KRATOS_PUBLIC_URL, discard_unknown_keys=True)
kratos_public_client = ory_kratos_client.ApiClient(kratos_public_api_configuration)
kratos_public_frontend_api = frontend_api.FrontendApi(kratos_public_client)
##############################################################################
# WEB ROUTES #
##############################################################################
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(KRATOS_PUBLIC_URL + "self-service/recovery/browser")
return render_template("recover.html", api_url=KRATOS_PUBLIC_URL, dashboard_url=DASHBOARD_URL)
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(KRATOS_PUBLIC_URL + "self-service/settings/browser")
return render_template("settings.html", api_url=KRATOS_PUBLIC_URL, dashboard_url=DASHBOARD_URL)
@web.route("/error", methods=["GET"])
def error():
"""Show error messages from Kratos
Implements user-facing errors as described in
https://www.ory.sh/docs/kratos/self-service/flows/user-facing-errors
:param id: error ID as given by Kratos
:return: redirect or settings page
"""
error_id = request.args.get("id")
try:
# Get Self-Service Errors
except ory_kratos_client.ApiException as ex:
current_app.logger.error(
return render_template("error.html", dashboard_url=DASHBOARD_URL, error_message=api_response)
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:
identity = get_auth()
# List to contain messages pushed to the frontend
messages = list()
refresh = False
flow = request.args.get("flow")
if flow:
cookies = request.headers['cookie']
flow = kratos_public_frontend_api.get_login_flow(flow, cookie=cookies)
if refresh:
message = {
"id": "S00001",
"message": "Please confirm your credentials to complete this action.",
"type": "info"
}
try:
for msg in flow.ui.messages.value:
current_app.logger.info("Kratos message:" + msg.text)
message = {
"id": msg.id,
"message": "The provided login e-mail address or password is incorrect. Please try again.",
"type": "error"
}
message = {
"id": msg.id,
"message": msg.text,
}
message['type'] = 'info'
messages.append(message)
except Exception as ex:
current_app.logger.error(ex)
if identity and not refresh:
# We are already logged in, and don't need to refresh.

Maarten de Waard
committed
if 'name' in identity['traits']:
# Add a space in front of the "name" so the template can put it
# between "Welcome" and the comma
name = " " + identity['traits']['name']
else:
name = ""
return render_template("loggedin.html",
api_url=KRATOS_PUBLIC_URL,
dashboard_url=DASHBOARD_URL,
name=name)
# If we do not have a flow, get one.
if not flow:
return redirect(KRATOS_PUBLIC_URL + "self-service/login/browser")
# If we end up here, then either:
# `identity and refresh`
# User is already logged in, but "refresh" is specified, meaning that
# we should ask the user to authenticate again. This is necessary when
# you want to change protected fields (password, TOTP) in the
# self-service settings, and your session is too old.
# or `not identity`
# User is not logged in yet.
# In either case, we present the login screen now.
return render_template("login.html", api_url=KRATOS_PUBLIC_URL,
dashboard_url=DASHBOARD_URL, messages=messages,
demo=DEMO_INSTANCE)
"""Authorize a user for an application
If an application authenticated against the IdP (Identity Provider), if
there are no active sessions, 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
: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.
challenge = request.args.get("login_challenge")
challenge = request.args.post("login_challenge")
if not challenge:
current_app.logger.error("No challenge given. Error in request")
abort(400, description="Challenge required when requesting authorization")
# Check if we are logged in:
identity = get_auth()
# 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 identity:
url = LOGIN_PANEL_URL + "/auth?login_challenge=" + challenge
url = urllib.parse.quote_plus(url)
current_app.logger.info("Redirecting to login. Setting flow_state cookies")
current_app.logger.info("auth_url: " + url)
response = redirect(LOGIN_PANEL_URL + "/login")
response.set_cookie("flow_state", "auth")
response.set_cookie("auth_url", url)
current_app.logger.info("User is logged in. We can authorize the user")
# hydra v2
# login_request = oauth2_api.get_o_auth2_login_request(challenge)
login_request = hydra_admin_api.get_login_request(challenge)
except hydra_exceptions.NotFoundException:
current_app.logger.error(
f"Not Found. Login request not found. challenge={challenge}"
)
abort(404, description="Login request not found. Please try again.")
current_app.logger.error(
f"Conflict. Login request has been used already. challenge={challenge}"
)
abort(503, description="Login request already used. Please try again.")
# Authorize the user
# False positive: pylint: disable=no-member
try:
redirect_to = hydra_admin_api.accept_login_request(
challenge,
accept_login_request=AcceptLoginRequest(
identity.id,
remember=True,
# Remember session for 7d
remember_for=60 * 60 * 24 * 7,
)
).redirect_to
except Exception as e:
current_app.logger.error("Failure during accepting login request. Redirecting to logout, hopefully to wipe cookies")
current_app.logger.error(e)
return redirect("logout")
return redirect(redirect_to)
def consent():
"""Get consent
Rather than get consent from the end-user, this checks whether the user
should have access to the given app based on stackspin roles.
:param consent_challenge: challenge as given by Hydra
:return: redirect to login or render error
"""
challenge = request.args.get("consent_challenge")
if not challenge:
abort(
403, description="Consent request required. Do not call this page directly"
)
# hydra v2
# consent_request = oauth2_api.get_o_auth2_consent_request(challenge)
consent_request = hydra_admin_api.get_consent_request(challenge)
except hydra_exceptions.NotFoundException:
current_app.logger.error(f"Not Found. Consent request {challenge} not found")
abort(404, description="Consent request does not exist. Please try again")
current_app.logger.error(f"Conflict. Consent request {challenge} already used")
abort(503, description="Consent request already used. Please try again")
# Get information about this consent request:
# False positive: pylint: disable=no-member
consent_client = consent_request.client
# Some versions of Hydra module return a string object and need to be decoded
if isinstance(consent_client, str):
consent_client = ast.literal_eval(consent_client)
# False positive: pylint: disable=no-member
kratos_id = consent_request.subject
current_app.logger.info(f"Info: Found kratos_id {kratos_id}")
current_app.logger.info(f"Info: Found client_id {client_id}")
except Exception as ex:
"Error: Unable to extract information from consent request"
current_app.logger.error(f"Error: {ex}")
current_app.logger.error(f"Client: {consent_request.client}")
current_app.logger.error(f"Subject: {consent_request.subject}")
# Get the related user object
current_app.logger.info(f"Info: Getting user from admin {kratos_id}")
current_app.logger.error(f"User not found in database: {kratos_id}")
abort(401, description="User not found. Please try again.")
# Get role on dashboard
dashboard_app = db.session.query(App).filter(
App.slug == 'dashboard').first()
if dashboard_app:
role_object = (
db.session.query(AppRole)
.filter(AppRole.app_id == dashboard_app.id)
.filter(AppRole.user_id == user.uuid)
.first()
)
# If the user is dashboard admin admin is for all
if role_object is not None and role_object.role_id == ADMIN_ROLE_ID:
current_app.logger.info(f"Info: User has admin dashboard role")
current_app.logger.info(f"Providing consent to {client_id} for {kratos_id}")
current_app.logger.info(f"{kratos_id} was granted admin access to {client_id}")
# Get claims for this user, provided the current app
hydra_admin_api.accept_consent_request(
challenge,
accept_consent_request=AcceptConsentRequest(
grant_scope=consent_request.requested_scope,
grant_access_token_audience=consent_request.requested_access_token_audience,
session=ConsentRequestSession(**claims),
)
).redirect_to
try:
app_obj = db.session.query(OAuthClientApp).filter(OAuthClientApp.oauthclient_id == client_id).first().app
except AttributeError:
current_app.logger.error(f"Could not find app for client {client_id}")
return redirect(
hydra_admin_api.reject_consent_request(
challenge,
# In previous versions of the hydra API client library, we
# could set these parameters, but that's no longer possible,
# not sure why.
# error="No access",
# error_description="The user has no access for app",
# error_hint="Contact your administrator",
# status_code=401,
# Default access level
roles = []
role_object = (
db.session.query(AppRole)
.filter(AppRole.app_id == app_obj.id)
.filter(AppRole.user_id == user.uuid)
.first()
)
# Role ID 3 is always "No access" due to migration b514cca2d47b
if role_object is None or role_object.role_id is None or role_object.role_id == NO_ACCESS_ROLE_ID:
# If there is no role in app_roles or the role_id for an app is null user has no permissions
current_app.logger.error(f"User has no access for: {app_obj.name}")
return redirect(
hydra_admin_api.reject_consent_request(
challenge,
# In previous versions of the hydra API client library, we
# could set these parameters, but that's no longer possible,
# not sure why.
# error="No access",
# error_description="The user has no access for app",
# error_hint="Contact your administrator",
# status_code=401,
current_app.logger.info(f"Using '{roles}' when applying consent for {kratos_id}")
# Get claims for this user, provided the current app
# pylint: disable=fixme
# TODO: Need to implement checking claims here, once the backend for that is
# developed
current_app.logger.info(f"Providing consent to {client_id} for {kratos_id}")
current_app.logger.info(f"{kratos_id} was granted access to {client_id}")
# False positive: pylint: disable=no-member
try:
redirectUrl = hydra_admin_api.accept_consent_request(
challenge,
accept_consent_request=AcceptConsentRequest(
grant_scope=consent_request.requested_scope,
grant_access_token_audience=consent_request.requested_access_token_audience,
session=ConsentRequestSession(**claims),
)
).redirect_to
except:
# If an unexpected error occurs, logout, hopefully that wipes the
# relevant cookies
current_app.logger.error('Fatal processing consent, redirect to logout:' + str(e))
return redirect("logout")
current_app.logger.info(f"Redirect to: {redirectUrl}")
return redirect(redirectUrl)
def status():
"""Get status of current session
Show if there is an user is logged in. If not shows: not-auth
"""
auth_status = get_auth()
if auth_status:
return auth_status.id
return "not-auth"
def get_auth():
"""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
"""
cookie = get_kratos_cookie()
if not cookie:
return False
# Given a cookie, check if it is valid and get the profile
try:
api_response = kratos_public_frontend_api.to_session(cookie=cookie)
# Get all traits from ID
return api_response.identity
except ory_kratos_client.ApiException as ex:
def get_kratos_cookie():
"""Retrieves the Kratos cookie from the session.
Returns False if the cookie does not exist or is corrupted.
"""
try:
cookie = request.cookies.get("ory_kratos_session")
cookie = "ory_kratos_session=" + cookie
except TypeError:
current_app.logger.info("User not logged in or cookie corrupted")
cookie = False
return cookie
@web.route("/prelogout", methods=["GET"])
def prelogout():
"""Handles the Hydra OpenID Connect Logout flow
1. Hydra's /oauth2/sessions/logout endpoint is called by an application
2. Hydra calls this endpoint with a `logout_challenge` get parameter
3. We retrieve the logout request using the challenge
4. We accept the Hydra logout request
5. We redirect to Hydra to clean-up cookies.
6. Hyrda calls back to us with a post logout handle (/logout)
Args:
logout_challenge (string): Reference to a Hydra logout challenge object
Returns:
Redirect to the url that is provided by the LogoutRequest object.
"""
challenge = request.args.get("logout_challenge")
current_app.logger.info("Logout request: challenge=%s", challenge)
if not challenge:
abort(403)
try:
# hydra v2
# logout_request = oauth2_api.get_o_auth2_logout_request(challenge)
logout_request = hydra_admin_api.get_logout_request(challenge)
except hydra_exceptions.NotFoundException:
current_app.logger.error("Logout request with challenge '%s' not found", challenge)
abort(404, "Hydra session invalid or not found")
current_app.logger.error(
"Conflict. Logout request with challenge '%s' has been used already.",
challenge)
abort(503)
current_app.logger.info("Logout request hydra, subject %s", logout_request.subject)
# Accept logout request and direct to hydra to remove cookies
try:
hydra_return = hydra_admin_api.accept_logout_request(challenge)
return redirect(hydra_return.redirect_to)
except Exception as ex:
current_app.logger.info("Error logging out hydra: %s", str(ex))
current_app.logger.info("Hydra logout not completed. Redirecting to kratos logout, maybe user removed cookies manually")
return redirect("logout")
@web.route("/logout", methods=["GET"])
def logout():
"""Handles the Kratos Logout flow
Steps:
1. We got here from hyrda
2. We retrieve the Kratos cookie from the browser
3. We generate a Kratos logout URL
4. We redirect to the Kratos logout URIL
"""
kratos_cookie = get_kratos_cookie()
if not kratos_cookie:
# No kratos cookie, already logged out
current_app.logger.info("Expected kratos cookie but not found. Redirecting to login");
return redirect("login")
try:
# Create a Logout URL for Browsers
kratos_api_response = \
cookie=kratos_cookie)
current_app.logger.info(kratos_api_response)
except ory_kratos_client.ApiException as ex:
current_app.logger.error("Exception when calling"
ex)
return redirect(kratos_api_response.logout_url)
if DEMO_INSTANCE:
@web.route("/demo-user", methods=["POST"])
def demo_user():
data = request.get_json()
defaults = {
"name": "",
"app_roles": [{"name": "dashboard", "role_id": Role.ADMIN_ROLE_ID}],
}
UserService.post_user({**defaults, **data})
return jsonify("User created successfully. You should receive an email to confirm your address and set a password.")