Skip to content

Commit

Permalink
Merge a703484 into 0c3c4a4
Browse files Browse the repository at this point in the history
  • Loading branch information
fantix committed Sep 27, 2019
2 parents 0c3c4a4 + a703484 commit 2aba2ff
Show file tree
Hide file tree
Showing 11 changed files with 267 additions and 9 deletions.
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ sudo: false
cache: pip

addons:
postgresql: "9.4"
postgresql: "9.6"

install:
- pip uninstall -y six || true # travis installs wrong version
Expand Down
1 change: 1 addition & 0 deletions bin/fence-create
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ def main():
arborist = ArboristClient(
arborist_base_url=args.arborist,
logger=get_logger("user_syncer.arborist_client"),
authz_provider="user-sync"
)

if args.action == "create":
Expand Down
8 changes: 8 additions & 0 deletions fence/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
MicrosoftOauth2Client as MicrosoftClient,
)
from fence.resources.openid.orcid_oauth2 import OrcidOauth2Client as ORCIDClient
from fence.resources.openid.synapse_oauth2 import SynapseOauth2Client as SynapseClient
from fence.resources.storage import StorageManager
from fence.resources.user.user_session import UserSessionInterface
from fence.error_handler import get_error_response
Expand Down Expand Up @@ -248,6 +249,7 @@ def _set_authlib_cfgs(app):

def _setup_oidc_clients(app):
enabled_idp_ids = list(config["ENABLED_IDENTITY_PROVIDERS"]["providers"].keys())
oidc = config.get("OPENID_CONNECT", {})

# Add OIDC client for Google if configured.
configured_google = (
Expand All @@ -271,6 +273,12 @@ def _setup_oidc_clients(app):
logger=logger,
)

# Add OIDC client for Synapse if configured.
if "synapse" in oidc:
app.synapse_client = SynapseClient(
oidc["synapse"], HTTP_PROXY=config.get("HTTP_PROXY"), logger=logger
)

# Add OIDC client for Microsoft if configured.
configured_microsoft = (
"OPENID_CONNECT" in config and "microsoft" in config["OPENID_CONNECT"]
Expand Down
18 changes: 17 additions & 1 deletion fence/blueprints/login/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from fence.blueprints.login.shib import ShibbolethLogin, ShibbolethCallback
from fence.blueprints.login.microsoft import MicrosoftLogin, MicrosoftCallback
from fence.blueprints.login.orcid import ORCIDLogin, ORCIDCallback
from fence.blueprints.login.synapse import SynapseLogin, SynapseCallback
from fence.errors import InternalError
from fence.restful import RestfulApi
from fence.config import config
Expand All @@ -27,6 +28,7 @@
"google": "google",
"shibboleth": "shib",
"orcid": "orcid",
"synapse": "synapse",
"microsoft": "microsoft",
}

Expand Down Expand Up @@ -75,11 +77,19 @@ def absolute_login_url(provider_id):

def provider_info(idp_id):
if not idp_id:
return {"id": None, "name": None, "url": None}
return {
"id": None,
"name": None,
"url": None,
"desc": None,
"secondary": False,
}
return {
"id": idp_id,
"name": idps[idp_id]["name"],
"url": absolute_login_url(idp_id),
"desc": idps[idp_id].get("desc", None),
"secondary": idps[idp_id].get("secondary", False),
}

try:
Expand Down Expand Up @@ -112,6 +122,12 @@ def provider_info(idp_id):
blueprint_api.add_resource(ORCIDLogin, "/orcid", strict_slashes=False)
blueprint_api.add_resource(ORCIDCallback, "/orcid/login", strict_slashes=False)

if "synapse" in idps:
blueprint_api.add_resource(SynapseLogin, "/synapse", strict_slashes=False)
blueprint_api.add_resource(
SynapseCallback, "/synapse/login", strict_slashes=False
)

if "microsoft" in idps:
blueprint_api.add_resource(MicrosoftLogin, "/microsoft", strict_slashes=False)
blueprint_api.add_resource(
Expand Down
7 changes: 6 additions & 1 deletion fence/blueprints/login/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,14 @@ def get(self):
result = self.client.get_user_id(code)
username = result.get(self.username_field)
if username:
return _login(username, self.idp_name)
resp = _login(username, self.idp_name)
self.post_login(flask.g.user, result)
return resp
raise UserError(result)

def post_login(self, user, token_result):
pass


def _login(username, idp_name):
"""
Expand Down
45 changes: 45 additions & 0 deletions fence/blueprints/login/synapse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from datetime import datetime, timezone, timedelta

import flask
from flask_sqlalchemy_session import current_session

from fence.config import config
from fence.models import IdentityProvider

from fence.blueprints.login.base import DefaultOAuth2Login, DefaultOAuth2Callback


class SynapseLogin(DefaultOAuth2Login):
def __init__(self):
super(SynapseLogin, self).__init__(
idp_name=IdentityProvider.synapse, client=flask.current_app.synapse_client
)


class SynapseCallback(DefaultOAuth2Callback):
def __init__(self):
super(SynapseCallback, self).__init__(
idp_name=IdentityProvider.synapse, client=flask.current_app.synapse_client
)

def post_login(self, user, token_result):
user.id_from_idp = token_result["sub"]
user.display_name = "{given_name} {family_name}".format(**token_result)
if user.additional_info is None:
user.additional_info = {}
user.additional_info.update(token_result)
current_session.add(user)
current_session.commit()

with flask.current_app.arborist.context(authz_provider="synapse"):
if config["DREAM_CHALLENGE_TEAM"] in token_result.get("team", []):
flask.current_app.arborist.add_user_to_group(
user.username,
config["DREAM_CHALLENGE_GROUP"],
datetime.now(timezone.utc)
+ timedelta(seconds=config["SYNAPSE_AUTHZ_TTL"]),
)
else:
flask.current_app.arborist.remove_user_from_group(
user.username, config["DREAM_CHALLENGE_GROUP"]
)
9 changes: 9 additions & 0 deletions fence/config-default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -646,3 +646,12 @@ ALLOWED_USER_SERVICE_ACCOUNT_DOMAINS:
- 'appspot.gserviceaccount.com'
# user-managed service account
- 'iam.gserviceaccount.com'

# Synapse integration and DREAM challenge mapping. Team is from Synapse, and group is
# providing the actual permission in Arborist. User will be added to the group for TTL
# seconds if the team matches.
DREAM_CHALLENGE_TEAM: 'DREAM'
DREAM_CHALLENGE_GROUP: 'DREAM'
SYNAPSE_URI: 'https://repo-prod.prod.sagebase.org/auth/v1/'
SYNAPSE_DISCOVERY_URL:
SYNAPSE_AUTHZ_TTL: 86400
44 changes: 43 additions & 1 deletion fence/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@
Text,
MetaData,
Table,
text,
)
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import relationship, backref
from sqlalchemy.sql import func
from sqlalchemy import exc as sa_exc
Expand Down Expand Up @@ -689,6 +690,47 @@ def migrate(driver):
metadata=md,
)

add_column_if_not_exist(
table_name=User.__tablename__,
column=Column("additional_info", JSONB(), server_default=text("'{}'")),
driver=driver,
metadata=md,
)

with driver.session as session:
session.execute(
"""\
CREATE OR REPLACE FUNCTION process_user_audit() RETURNS TRIGGER AS $user_audit$
BEGIN
IF (TG_OP = 'DELETE') THEN
INSERT INTO user_audit_logs (timestamp, operation, old_values)
SELECT now(), 'DELETE', row_to_json(OLD);
RETURN OLD;
ELSIF (TG_OP = 'UPDATE') THEN
INSERT INTO user_audit_logs (timestamp, operation, old_values, new_values)
SELECT now(), 'UPDATE', row_to_json(OLD), row_to_json(NEW);
RETURN NEW;
ELSIF (TG_OP = 'INSERT') THEN
INSERT INTO user_audit_logs (timestamp, operation, new_values)
SELECT now(), 'INSERT', row_to_json(NEW);
RETURN NEW;
END IF;
RETURN NULL;
END;
$user_audit$ LANGUAGE plpgsql;"""
)

exist = session.scalar(
"SELECT exists (SELECT * FROM pg_trigger WHERE tgname = 'user_audit')"
)
session.execute(
('DROP TRIGGER user_audit ON "User"; ' if exist else "")
+ """\
CREATE TRIGGER user_audit
AFTER INSERT OR UPDATE OR DELETE ON "User"
FOR EACH ROW EXECUTE PROCEDURE process_user_audit();"""
)


def add_foreign_key_column_if_not_exist(
table_name,
Expand Down
131 changes: 131 additions & 0 deletions fence/resources/openid/synapse_oauth2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import json

import jwt
from jwt.algorithms import RSAAlgorithm
from jwt.utils import to_base64url_uint

from .idp_oauth2 import Oauth2ClientBase
from ...config import config


class SynapseOauth2Client(Oauth2ClientBase):
"""
client for interacting with Synapse OAuth2,
as OpenID Connect is supported under OAuth2
"""

REQUIRED_CLAIMS = {"given_name", "family_name", "email", "email_verified"}
OPTIONAL_CLAIMS = {
"company",
"userid",
"orcid",
"is_certified",
"is_validated",
"validated_given_name",
"validated_family_name",
"validated_location",
"validated_email",
"validated_company",
"validated_orcid",
"validated_at",
}
SYSTEM_CLAIMS = {"sub", "exp"}
CUSTOM_CLAIMS = {"team"}

def __init__(self, settings, logger, HTTP_PROXY=None):
super(SynapseOauth2Client, self).__init__(
settings,
logger,
scope="openid",
# The default discovery URL on Synapse staging is not serving the correct
# info. Providing a workaround here for overwriting.
discovery_url=config["SYNAPSE_DISCOVERY_URL"]
or (config["SYNAPSE_URI"] + "/.well-known/openid-configuration"),
idp="Synapse",
HTTP_PROXY=HTTP_PROXY,
)

def get_auth_url(self):
"""
Get authorization uri from discovery doc
"""
authorization_endpoint = self.get_value_from_discovery_doc(
"authorization_endpoint", config["SYNAPSE_URI"] + "/oauth2/authorize"
)

claims = dict(
id_token=dict(
team=dict(values=[config["DREAM_CHALLENGE_TEAM"]]),
**{
claim: dict(essential=claim in self.REQUIRED_CLAIMS)
for claim in self.REQUIRED_CLAIMS | self.OPTIONAL_CLAIMS
}
)
)
uri, state = self.session.create_authorization_url(
authorization_endpoint,
prompt="login",
claims=json.dumps(claims, separators=(",", ": ")),
)

return uri

def load_key(self, jwks_endpoint):
"""A custom method to load a Synapse "RS256" key.
Synapse is not providing standard JWK keys:
* kty is RS256 not RSA
* e and n are not base64-encoded
"""
for key in self.get_jwt_keys(jwks_endpoint):
if key["kty"] == "RS256":
key["kty"] = "RSA"
for field in ["e", "n"]:
if key[field].isdigit():
key[field] = to_base64url_uint(int(key[field])).decode()
return "RS256", RSAAlgorithm.from_jwk(json.dumps(key))

return None, None

def get_user_id(self, code):
try:
token_endpoint = self.get_value_from_discovery_doc(
"token_endpoint", config["SYNAPSE_URI"] + "/oauth2/token"
)
jwks_endpoint = self.get_value_from_discovery_doc(
"jwks_uri", config["SYNAPSE_URI"] + "/oauth2/jwks"
)
token = self.get_token(token_endpoint, code)
algorithm, key = self.load_key(jwks_endpoint)
if not key:
return dict(error="Cannot load JWK keys")

claims = jwt.decode(
token["id_token"],
key,
options={"verify_aud": False, "verify_at_hash": False},
algorithms=[algorithm],
)

if not claims["email_verified"]:
return dict(error="Email is not verified")

rv = {}
none = object()
for claim in (
self.REQUIRED_CLAIMS
| self.OPTIONAL_CLAIMS
| self.SYSTEM_CLAIMS
| self.CUSTOM_CLAIMS
):
value = claims.get(claim, none)
if value is none:
if claim not in self.OPTIONAL_CLAIMS:
return dict(error="Required claim {} not found".format(claim))
else:
rv[claim] = value
return rv
except Exception as e:
self.logger.exception("Can't get user info")
return {"error": "Can't get ID token: {}".format(e)}
9 changes: 5 additions & 4 deletions fence/sync/sync_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -1077,15 +1077,17 @@ def _update_arborist(self, session, user_yaml):

policies = user_yaml.authz.get("policies", [])
for policy in policies:
policy_id = policy.pop("id")
try:
response = self.arborist_client.update_policy(
policy["id"], policy, create_if_not_exist=True
policy_id, policy, create_if_not_exist=True
)
if response:
self._created_policies.add(policy["id"])
except ArboristError as e:
self.logger.error(e)
# keep going; maybe just some conflicts from things existing already
else:
if response:
self._created_policies.add(policy_id)

groups = user_yaml.authz.get("groups", [])
for group in groups:
Expand Down Expand Up @@ -1197,7 +1199,6 @@ def _update_authz_in_arborist(self, session, user_projects, user_yaml=None):
self.arborist_client.update_policy(
policy_id,
{
"id": policy_id,
"description": "policy created by fence sync",
"role_ids": [permission],
"resource_paths": [path],
Expand Down
Loading

0 comments on commit 2aba2ff

Please sign in to comment.