blob: 3ba2a17da4f6b064a8e0a1f1edd916a8d6c3de12 [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""OAuth+OIDC Portal for the Apache Software Foundation"""
import quart
import re
from ..lib import middleware, config, ldap, database
from oic import rndstr
from oic.oic import Client
import oic.utils.authn.client
import oic.oic.message
import oic.utils.keyio
import urllib.parse
import time
STATE_EXPIRY = 600 # All sessions expire 10 minutes after being created
def make_client():
"""Construct a useful OIDC client from our config yaml"""
client = Client(
client_id=config.oidc.client_id,
client_authn_method=oic.utils.authn.client.CLIENT_AUTHN_METHOD,
)
client_reg = oic.oic.message.RegistrationResponse(
client_id=config.oidc.client_id,
client_secret=config.oidc.client_secret,
redirect_uris=[config.oidc.redirect_uri],
)
provider = oic.oic.message.ProviderConfigurationResponse(
version="1.0",
issuer=config.oidc.issuer,
authorization_endpoint=config.oidc.endpoint + "authorize/",
token_endpoint=config.oidc.endpoint + "token/",
jwks_uri=config.oidc.issuer + "jwks/",
userinfo_endpoint=config.oidc.endpoint + "userinfo/",
)
client.handle_provider_config(provider, provider["issuer"])
client.store_registration_info(client_reg)
client.redirect_uris = [
config.oidc.redirect_uri,
]
return client
def make_redirect_url(url, **kwargs):
"""Appends any number of query string args to an existing URL"""
# Parse original URL, so we can munge it with our token
origin_url = urllib.parse.unquote(url)
url_parsed = urllib.parse.urlparse(origin_url)
args = dict(urllib.parse.parse_qsl(url_parsed.query))
# Add the token
args.update(**kwargs)
# Encode args and reconstruct URL
args_encoded = urllib.parse.urlencode(args, doseq=True)
new_url = urllib.parse.ParseResult(
url_parsed.scheme, url_parsed.netloc, url_parsed.path, url_parsed.params, args_encoded, url_parsed.fragment
).geturl()
return new_url
states = {} # Track current oauth states
client = make_client() # OIDC client
async def init_oidc(form_data):
"""Initial OAuth gateway. Verify parameters, log state, punt auth to OIDC"""
origin_state = form_data.get("state")
redirect_uri = form_data.get("redirect_uri")
# Validate state and callback
if not origin_state or len(origin_state) < 10 or len(origin_state) > 64:
return quart.Response(
status=400, response="Origin OAuth state ID MUST be between 10 and 64 characters", mimetype="text/plain"
)
if not re.match(r"^[-a-z0-9]+$", origin_state):
return quart.Response(status=400, response="Origin state ID MUST be hex or alphanumerical (dashes are allowed)")
if not redirect_uri or not re.match(r"https://([-0-9a-zA-Z.]+)", redirect_uri):
return quart.Response(
status=400,
response="Invalid redirect URI specified. MUST be of format https://foo.bar/baz.html and MUST be https",
)
# Ensure the redirect URI belongs to a registered, approved client app. We check every
# approved client's redirect_uris list, matching each entry as an fnmatch glob pattern.
# If no client app allows this redirect URI, we refuse the login. This enforcement can
# be turned off via the server.enforce_redirect_uris config option, in which case any
# (otherwise valid) redirect URI is allowed.
if config.server.enforce_redirect_uris and not database.find_client_for_redirect(redirect_uri):
return quart.Response(
status=400,
response="The redirect URI is not allowed. It must match a registered and approved client application.",
)
session = {
"state": rndstr(),
"nonce": rndstr(),
"original_state": origin_state,
"redirect_uri": redirect_uri,
}
args = {
"client_id": config.oidc.client_id,
"response_type": "code",
"scope": ["openid", "profile", "email"],
"state": session["state"],
"nonce": session["nonce"],
"redirect_uri": config.oidc.redirect_uri,
}
auth_req = client.construct_AuthorizationRequest(request_args=args)
login_url = auth_req.request(client.authorization_endpoint)
states[session["state"]] = session
return quart.Response(
status=302,
response="Redirecting...",
headers={
"Location": login_url,
},
)
async def callback_oidc(form_data):
"""OIDC callback. Ensure OIDC response is valid, obtain a token, verify username"""
aresp = client.parse_response(oic.oic.message.AuthorizationResponse, info=form_data, sformat="dict")
oidc_state = aresp.get("state")
oidc_code = aresp.get("code")
if oidc_state not in states:
return quart.Response(status=400, response="Unknown session, perhaps it expired? Please retry your login.")
resp = client.do_access_token_request(
scope=["openid", "profile", "email"],
state=oidc_state,
request_args={"code": oidc_code},
)
if isinstance(resp, oic.oic.message.AccessTokenResponse): # Could be ErrorResponse, we don't want that...
userinfo = client.do_user_info_request(state=oidc_state)
if userinfo:
username = userinfo["preferred_username"]
committer = ldap.Committer(username)
details = await committer.verify()
if details:
details["provider"] = "oidc" # Distinguish between old oauth and OIDC-backed (2FA etc)
# Some services require 2FA. Our OIDC workflows will always use 2FA if set up, though they may skip
# it if not set up by the user. There is no way for the oauth backend to know if 2FA is enabled
# in the workflow, but since these apps need to know, we have to make an educated guess.
# Currently, no in-production app make use of the 2FA requirement, but we do have services in
# development that do.
details["mfa"] = True # TODO: Discuss (100%) enforcement of 2FA on keycloak
states[oidc_state]["credentials"] = details
states[oidc_state]["timestamp"] = time.time() # Set so we can check expiry of state
redirect_uri = states[oidc_state]["redirect_uri"]
# Record the successful login in the audit log. The client app ID, if any, is
# resolved from the redirect URI against the registered (approved) clients.
url = make_redirect_url(redirect_uri, code=oidc_state)
database.log_login(
ip=quart.request.headers.get("X-Forwarded-For", quart.request.remote_addr),
user_id=username,
user_agent=quart.request.headers.get("User-Agent"),
redirect_uri=url, # We keep the state ID here for forensics
)
return quart.Response(
status=302,
response="Redirecting...",
headers={
"Location": url,
},
)
return quart.Response(status=400, response="The OIDC provider did not respond well....booo")
async def token_oidc(form_data):
"""Token response. Given a valid oauth token, presents the backend client with committer details"""
ip = quart.request.headers.get("X-Forwarded-For", quart.request.remote_addr)
user_agent = quart.request.headers.get("User-Agent")
code = form_data.get("code")
if code and code in states:
credentials = states[code]["credentials"]
credentials["origin_uri"] = states[code]["redirect_uri"]
redirect_uri = states[code]["redirect_uri"]
user_id = credentials.get("uid")
expiry = states[code]["timestamp"] + STATE_EXPIRY
del states[code]
# Record the token request in the audit log, whether or not it is still valid.
success = expiry >= time.time() # Only return creds if within expiry window
database.log_token(
ip=ip, user_id=user_id, user_agent=user_agent, redirect_uri=redirect_uri, success=success
)
if success:
return credentials
else:
# Unknown/missing code: no session to tie this to, but still worth auditing.
database.log_token(ip=ip, user_id=None, user_agent=user_agent, redirect_uri=None, success=False)
return quart.Response(status=404, response="Could not find the login session that was requested.")
def _client_ip():
"""Best-effort source IP for the current request (honours a proxy header)."""
return quart.request.headers.get("X-Forwarded-For", quart.request.remote_addr)
def _request_token(form_data):
"""Extract the caller's bearer token from the Authorization header or a 'token' field."""
auth = quart.request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
return auth[len("Bearer ") :].strip()
return form_data.get("token")
async def _authorize_registration(form_data):
"""Authorize a client-app registration request.
Any team with a valid token in the config 'authorization' section may register
(or update) a client app. Returns the authenticated team name, or None if the
token is missing/unknown.
"""
return config.authorization.team_for_token(_request_token(form_data))
async def _authorize_review(form_data):
"""Authorize a client-app approve/deny request.
Only teams permitted to approve (the infrastructure team) may review client
apps. Returns the authenticated team name, or None if the token is missing,
unknown, or belongs to a team that is not allowed to approve.
"""
team = config.authorization.team_for_token(_request_token(form_data))
if team and config.authorization.can_approve(team):
return team
return None
async def register_client(form_data):
"""Register a new client application, or update an existing one.
If a 'client_id' is supplied, the matching client app is updated in place:
only the fields that are provided are changed, and the change is recorded in
the client audit log. Otherwise a brand new client app is registered, which
starts life in 'pending' status.
Expected fields:
- client_id: UUID of an existing client app to update (optional; omit to create)
- description: human-friendly name of the application
- contact_email: contact address for the application
- redirect_uris: one or more accepted redirect URIs (fnmatch globs allowed).
May be supplied as a JSON list, or as a single string.
When creating, description, contact_email and redirect_uris are all required.
When updating, any subset of those may be supplied; omitted fields are left as-is.
"""
actor = await _authorize_registration(form_data)
if not actor:
return quart.Response(
status=403, response="A valid team authorization token is required to register a client app."
)
client_id = form_data.get("client_id")
description = form_data.get("description")
contact_email = form_data.get("contact_email")
redirect_uris = form_data.get("redirect_uris")
# Validate any fields that were supplied. On create these are all required (checked below);
# on update they are optional, so we only validate the ones that are actually present.
if contact_email is not None and not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", contact_email):
return quart.Response(status=400, response="A valid 'contact_email' is required.")
# redirect_uris may arrive as a JSON list (JSON body) or a single string (form post).
if isinstance(redirect_uris, str):
redirect_uris = [redirect_uris]
if redirect_uris is not None:
if not isinstance(redirect_uris, list) or not redirect_uris:
return quart.Response(
status=400, response="'redirect_uris' must be a non-empty list of URL patterns."
)
if not all(isinstance(uri, str) and uri for uri in redirect_uris):
return quart.Response(status=400, response="Every entry in 'redirect_uris' must be a non-empty string.")
# Update path: a client_id was supplied, so amend the existing entry.
if client_id:
updated = database.update_client(
client_id,
actor=actor,
actor_ip=_client_ip(),
description=description,
contact_email=contact_email,
redirect_uris=redirect_uris,
)
if not updated:
return quart.Response(status=404, response="No client application with that ID was found.")
entry = database.get_client(client_id)
return quart.jsonify({"client_id": client_id, "status": entry["status"]})
# Create path: no client_id, so register a brand new client app. All fields are required.
if not description:
return quart.Response(status=400, response="A client app 'description' (name) is required.")
if not contact_email:
return quart.Response(status=400, response="A valid 'contact_email' is required.")
if not redirect_uris:
return quart.Response(
status=400, response="'redirect_uris' is required and must be a non-empty list of URL patterns."
)
client_id = database.register_client(
description=description,
redirect_uris=redirect_uris,
contact_email=contact_email,
registered_by=actor,
registered_ip=_client_ip(),
)
return quart.jsonify({"client_id": client_id, "status": database.STATUS_PENDING})
async def review_client(form_data):
"""Approve or deny a pending client application registration.
Expected fields:
- client_id: the UUID of the client application to review (required)
- action: either 'approve' or 'deny' (required)
"""
actor = await _authorize_review(form_data)
if not actor:
return quart.Response(
status=403, response="Only the infrastructure team may approve or deny client app registrations."
)
client_id = form_data.get("client_id")
action = (form_data.get("action") or "").lower()
if not client_id:
return quart.Response(status=400, response="A 'client_id' is required.")
if action not in ("approve", "deny"):
return quart.Response(status=400, response="'action' must be either 'approve' or 'deny'.")
status = database.STATUS_APPROVED if action == "approve" else database.STATUS_DENIED
updated = database.set_client_status(client_id, status, actor=actor, actor_ip=_client_ip())
if not updated:
return quart.Response(status=404, response="No client application with that ID was found.")
return quart.jsonify({"client_id": client_id, "status": status})
# Endpoint for OAuth init
quart.current_app.add_url_rule(
"/oauth-oidc",
methods=[
"GET",
],
view_func=middleware.glued(init_oidc),
)
# Endpoint for callback from OIDC Provider
quart.current_app.add_url_rule(
"/callback-oidc",
methods=[
"GET",
],
view_func=middleware.glued(callback_oidc),
)
# Endpoint for backend requesting OAuth data
quart.current_app.add_url_rule(
"/token-oidc",
methods=["GET", "POST"],
view_func=middleware.glued(token_oidc),
)
# Endpoint for registering a new client application (auth TBD)
quart.current_app.add_url_rule(
"/clients/register",
methods=[
"POST",
],
view_func=middleware.glued(register_client),
)
# Endpoint for approving or denying a client application registration (auth TBD)
quart.current_app.add_url_rule(
"/clients/review",
methods=[
"GET",
],
view_func=middleware.glued(review_client),
)