* feat(api): remove stage name from email subject

* change(api): refactored code & SAML2 SSO SLO SLS

* change(api): SAML2 extracted & custom configuration

* change(api): SAML2 migrate user after signup

* feat(api): return project_key with session's details

* change(api): SAML2

* feat(db): tenants & users table changes for SAML2
This commit is contained in:
Kraiem Taha Yassine 2021-07-12 22:09:09 +02:00 committed by GitHub
parent 376b110cdd
commit 9a5fc4bac7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 533 additions and 70 deletions

View file

@ -10,7 +10,7 @@ from chalicelib.utils.helper import environ
def __get_subject(subject):
return subject if helper.is_production() else f"{helper.get_stage_name()}: {subject}"
return subject
def __get_html_from_file(source, formatting_variables):

View file

@ -55,7 +55,13 @@
"S3_HOST": "",
"S3_KEY": "",
"S3_SECRET": "",
"version_number": "1.0.0"
"version_number": "1.0.0",
"LICENSE_KEY": "",
"SAML2_MD_URL": "",
"idp_entityId": "",
"idp_sso_url": "",
"idp_x509cert": "",
"idp_sls_url": ""
},
"lambda_timeout": 150,
"lambda_memory_size": 400,

58
ee/api/.gitignore vendored
View file

@ -175,4 +175,60 @@ SUBNETS.json
chalicelib/.config
chalicelib/saas
README/*
Pipfile
Pipfile
/chalicelib/core/alerts.py
/chalicelib/core/announcements.py
/chalicelib/blueprints/bp_app_api.py
/chalicelib/blueprints/bp_core.py
/chalicelib/blueprints/bp_core_crons.py
/chalicelib/core/collaboration_slack.py
/chalicelib/core/errors_favorite_viewed.py
/chalicelib/core/events.py
/chalicelib/core/events_ios.py
/chalicelib/core/integration_base.py
/chalicelib/core/integration_base_issue.py
/chalicelib/core/integration_github.py
/chalicelib/core/integration_github_issue.py
/chalicelib/core/integration_jira_cloud.py
/chalicelib/core/integration_jira_cloud_issue.py
/chalicelib/core/integrations_manager.py
/chalicelib/core/issues.py
/chalicelib/core/jobs.py
/chalicelib/core/log_tool_bugsnag.py
/chalicelib/core/log_tool_cloudwatch.py
/chalicelib/core/log_tool_datadog.py
/chalicelib/core/log_tool_elasticsearch.py
/chalicelib/core/log_tool_newrelic.py
/chalicelib/core/log_tool_rollbar.py
/chalicelib/core/log_tool_sentry.py
/chalicelib/core/log_tool_stackdriver.py
/chalicelib/core/log_tool_sumologic.py
/chalicelib/core/sessions_assignments.py
/chalicelib/core/sessions_favorite_viewed.py
/chalicelib/core/sessions_metas.py
/chalicelib/core/sessions_mobs.py
/chalicelib/core/significance.py
/chalicelib/core/slack.py
/chalicelib/core/socket_ios.py
/chalicelib/core/sourcemaps.py
/chalicelib/core/sourcemaps_parser.py
/chalicelib/core/weekly_report.py
/chalicelib/saml
/chalicelib/utils/html/
/chalicelib/utils/__init__.py
/chalicelib/utils/args_transformer.py
/chalicelib/utils/captcha.py
/chalicelib/utils/dev.py
/chalicelib/utils/email_handler.py
/chalicelib/utils/email_helper.py
/chalicelib/utils/event_filter_definition.py
/chalicelib/utils/github_client_v3.py
/chalicelib/utils/helper.py
/chalicelib/utils/jira_client.py
/chalicelib/utils/metrics_helper.py
/chalicelib/utils/pg_client.py
/chalicelib/utils/s3.py
/chalicelib/utils/smtp.py
/chalicelib/utils/strings.py
/chalicelib/utils/TimeUTC.py

View file

@ -11,7 +11,7 @@ from chalicelib.utils import helper
from chalicelib.utils import pg_client
from chalicelib.utils.helper import environ
from chalicelib.blueprints import bp_ee, bp_ee_crons
from chalicelib.blueprints import bp_ee, bp_ee_crons, bp_saml
app = Chalice(app_name='parrot')
app.debug = not helper.is_production() or helper.is_local()
@ -59,17 +59,11 @@ _overrides.chalice_app(app)
@app.middleware('http')
def or_middleware(event, get_response):
from chalicelib.ee import unlock
from chalicelib.core import unlock
if not unlock.is_valid():
return Response(body={"errors": ["expired license"]}, status_code=403)
if "{projectid}" in event.path.lower():
from chalicelib.ee import projects
print("==================================")
print(event.context["authorizer"].get("authorizer_identity"))
print(event.uri_params["projectId"])
print(projects.get_internal_project_id(event.uri_params["projectId"]))
print(event.context["authorizer"]["tenantId"])
print("==================================")
from chalicelib.core import projects
if event.context["authorizer"].get("authorizer_identity") == "api_key" \
and not projects.is_authorized(
project_id=projects.get_internal_project_id(event.uri_params["projectId"]),
@ -126,3 +120,4 @@ app.register_blueprint(bp_dashboard.app)
# Enterprise
app.register_blueprint(bp_ee.app)
app.register_blueprint(bp_ee_crons.app)
app.register_blueprint(bp_saml.app)

View file

@ -2,7 +2,7 @@ from chalice import Blueprint, AuthResponse
from chalicelib.utils import helper
from chalicelib.core import authorizers
from chalicelib.ee import users
from chalicelib.core import users
app = Blueprint(__name__)

View file

@ -3,19 +3,19 @@ from chalice import Blueprint, Response
from chalicelib import _overrides
from chalicelib.core import metadata, errors_favorite_viewed, slack, alerts, sessions, integration_github, \
integrations_manager
from chalicelib.utils import captcha
from chalicelib.utils import captcha, SAML2_helper
from chalicelib.utils import helper
from chalicelib.utils.helper import environ
from chalicelib.ee import tenants
from chalicelib.ee import signup
from chalicelib.ee import users
from chalicelib.ee import projects
from chalicelib.ee import errors
from chalicelib.ee import notifications
from chalicelib.ee import boarding
from chalicelib.ee import webhook
from chalicelib.ee import license
from chalicelib.core import tenants
from chalicelib.core import signup
from chalicelib.core import users
from chalicelib.core import projects
from chalicelib.core import errors
from chalicelib.core import notifications
from chalicelib.core import boarding
from chalicelib.core import webhook
from chalicelib.core import license
from chalicelib.core.collaboration_slack import Slack
app = Blueprint(__name__)
@ -41,6 +41,8 @@ def login():
return {
'errors': ['Youve entered invalid Email or Password.']
}
elif "errors" in r:
return r
tenant_id = r.pop("tenantId")
# change this in open-source
@ -74,7 +76,8 @@ def get_account(context):
"metadata": metadata.get_remaining_metadata_with_count(context['tenantId'])
},
**license.get_status(context["tenantId"]),
"smtp": environ["EMAIL_HOST"] is not None and len(environ["EMAIL_HOST"]) > 0
"smtp": environ["EMAIL_HOST"] is not None and len(environ["EMAIL_HOST"]) > 0,
"saml2": SAML2_helper.is_saml2_available()
}
}
@ -350,6 +353,8 @@ def get_members(context):
@app.route('/client/members', methods=['PUT', 'POST'])
def add_member(context):
if SAML2_helper.is_saml2_available():
return {"errors": ["please use your SSO server to add teammates"]}
data = app.current_request.json_body
return users.create_member(tenant_id=context['tenantId'], user_id=context['userId'], data=data)

View file

@ -4,8 +4,8 @@ from chalicelib.utils import helper
app = Blueprint(__name__)
_overrides.chalice_app(app)
from chalicelib.ee import telemetry
from chalicelib.ee import unlock
from chalicelib.core import telemetry
from chalicelib.core import unlock
# Run every day.

View file

@ -1,7 +1,7 @@
from chalice import Blueprint
from chalicelib import _overrides
from chalicelib.ee import unlock
from chalicelib.core import unlock
app = Blueprint(__name__)
_overrides.chalice_app(app)

View file

@ -0,0 +1,188 @@
from chalice import Blueprint
from chalicelib import _overrides
from chalicelib.utils.SAML2_helper import prepare_request, init_saml_auth
app = Blueprint(__name__)
_overrides.chalice_app(app)
from chalicelib.utils.helper import environ
from onelogin.saml2.auth import OneLogin_Saml2_Logout_Request
from onelogin.saml2.utils import OneLogin_Saml2_Utils
from chalice import Response
from chalicelib.core import users, tenants
@app.route("/saml2", methods=['GET'], authorizer=None)
def start_sso():
app.current_request.path = ''
req = prepare_request(request=app.current_request)
auth = init_saml_auth(req)
sso_built_url = auth.login()
return Response(
# status_code=301,
status_code=307,
body='',
headers={'Location': sso_built_url, 'Content-Type': 'text/plain'})
@app.route('/saml2/acs', methods=['POST'], content_types=['application/x-www-form-urlencoded'], authorizer=None)
def process_sso_assertion():
req = prepare_request(request=app.current_request)
session = req["cookie"]["session"]
request = req['request']
auth = init_saml_auth(req)
request_id = None
if 'AuthNRequestID' in session:
request_id = session['AuthNRequestID']
auth.process_response(request_id=request_id)
errors = auth.get_errors()
user_data = {}
if len(errors) == 0:
if 'AuthNRequestID' in session:
del session['AuthNRequestID']
user_data = auth.get_attributes()
# session['samlUserdata'] = user_data
# session['samlNameId'] = auth.get_nameid()
# session['samlNameIdFormat'] = auth.get_nameid_format()
# session['samlNameIdNameQualifier'] = auth.get_nameid_nq()
# session['samlNameIdSPNameQualifier'] = auth.get_nameid_spnq()
# session['samlSessionIndex'] = auth.get_session_index()
# session['samlSessionExpiration'] = auth.get_session_expiration()
# print('>>>>')
# print(session)
self_url = OneLogin_Saml2_Utils.get_self_url(req)
if 'RelayState' in request.form and self_url != request.form['RelayState']:
print("====>redirect")
return Response(
status_code=307,
body='',
headers={'Location': auth.redirect_to(request.form['RelayState']), 'Content-Type': 'text/plain'})
elif auth.get_settings().is_debug_active():
error_reason = auth.get_last_error_reason()
return {"errors": [error_reason]}
email = auth.get_nameid()
existing = users.get_by_email_only(auth.get_nameid())
internal_id = next(iter(user_data.get("internalId", [])), None)
if len(existing) == 0 or existing[0].get("origin") != 'saml':
tenant_key = user_data.get("tenantKey", [])
if len(tenant_key) == 0:
print("tenantKey not present in assertion")
return Response(
status_code=307,
body={"errors": ["tenantKey not present in assertion"]},
headers={'Location': auth.redirect_to(request.form['RelayState']), 'Content-Type': 'text/plain'})
else:
t = tenants.get_by_tenant_key(tenant_key[0])
if t is None:
return Response(
status_code=307,
body={"errors": ["Unknown tenantKey"]},
headers={'Location': auth.redirect_to(request.form['RelayState']), 'Content-Type': 'text/plain'})
if len(existing) == 0:
print("== new user ==")
users.create_sso_user(tenant_id=t['tenantId'], email=email, admin=True, origin='saml',
name=" ".join(user_data.get("firstName", []) + user_data.get("lastName", [])),
internal_id=internal_id)
else:
existing = existing[0]
if existing.get("origin") != 'saml':
print("== migrating user to SAML ==")
users.update(tenant_id=t['tenantId'], user_id=existing["id"],
changes={"origin": 'saml', "internal_id": internal_id})
return users.authenticate_sso(email=email, internal_id=internal_id, exp=auth.get_session_expiration())
@app.route('/saml2/slo', methods=['GET'])
def process_slo_request(context):
req = prepare_request(request=app.current_request)
session = req["cookie"]["session"]
request = req['request']
auth = init_saml_auth(req)
name_id = session_index = name_id_format = name_id_nq = name_id_spnq = None
if 'samlNameId' in session:
name_id = session['samlNameId']
if 'samlSessionIndex' in session:
session_index = session['samlSessionIndex']
if 'samlNameIdFormat' in session:
name_id_format = session['samlNameIdFormat']
if 'samlNameIdNameQualifier' in session:
name_id_nq = session['samlNameIdNameQualifier']
if 'samlNameIdSPNameQualifier' in session:
name_id_spnq = session['samlNameIdSPNameQualifier']
users.change_jwt_iat(context["userId"])
return Response(
status_code=307,
body='',
headers={'Location': auth.logout(name_id=name_id, session_index=session_index, nq=name_id_nq,
name_id_format=name_id_format,
spnq=name_id_spnq), 'Content-Type': 'text/plain'})
@app.route('/saml2/sls', methods=['GET'], authorizer=None)
def process_sls_assertion():
req = prepare_request(request=app.current_request)
session = req["cookie"]["session"]
request = req['request']
auth = init_saml_auth(req)
request_id = None
if 'LogoutRequestID' in session:
request_id = session['LogoutRequestID']
def dscb():
session.clear()
url = auth.process_slo(request_id=request_id, delete_session_cb=dscb)
errors = auth.get_errors()
if len(errors) == 0:
if 'SAMLRequest' in req['get_data']:
logout_request = OneLogin_Saml2_Logout_Request(auth.get_settings(), req['get_data']['SAMLRequest'])
user_email = logout_request.get_nameid(auth.get_last_request_xml())
to_logout = users.get_by_email_only(user_email)
if len(to_logout) > 0:
to_logout = to_logout[0]['id']
users.change_jwt_iat(to_logout)
else:
print("Unknown user SLS-Request By IdP")
else:
print("Preprocessed SLS-Request by SP")
if url is not None:
return Response(
status_code=307,
body='',
headers={'Location': url, 'Content-Type': 'text/plain'})
return Response(
status_code=307,
body='',
headers={'Location': environ["SITE_URL"], 'Content-Type': 'text/plain'})
@app.route('/saml2/metadata', methods=['GET'], authorizer=None)
def saml2_metadata():
req = prepare_request(request=app.current_request)
auth = init_saml_auth(req)
settings = auth.get_settings()
metadata = settings.get_sp_metadata()
errors = settings.validate_metadata(metadata)
if len(errors) == 0:
return Response(
status_code=200,
body=metadata,
headers={'Content-Type': 'text/xml'})
else:
return Response(
status_code=500,
body=', '.join(errors))

View file

@ -2,7 +2,7 @@ from chalice import Blueprint
from chalicelib.utils import helper
from chalicelib import _overrides
from chalicelib.ee import dashboard
from chalicelib.core import dashboard
from chalicelib.core import metadata

View file

@ -3,8 +3,8 @@ import jwt
from chalicelib.utils import helper
from chalicelib.utils.TimeUTC import TimeUTC
from chalicelib.ee import tenants
from chalicelib.ee import users
from chalicelib.core import tenants
from chalicelib.core import users
def jwt_authorizer(token):
@ -38,12 +38,13 @@ def jwt_context(context):
}
def generate_jwt(id, tenant_id, iat, aud):
def generate_jwt(id, tenant_id, iat, aud, exp=None):
token = jwt.encode(
payload={
"userId": id,
"tenantId": tenant_id,
"exp": iat // 1000 + int(environ["jwt_exp_delta_seconds"]) + TimeUTC.get_utc_offset() // 1000,
"exp": iat // 1000 + int(environ["jwt_exp_delta_seconds"]) + TimeUTC.get_utc_offset() // 1000 \
if exp is None else exp,
"iss": environ["jwt_issuer"],
"iat": iat // 1000,
"aud": aud
@ -58,4 +59,4 @@ def api_key_authorizer(token):
t = tenants.get_by_api_key(token)
if t is not None:
t["createdAt"] = TimeUTC.datetime_to_timestamp(t["createdAt"])
return t
return t

View file

@ -1,8 +1,8 @@
from chalicelib.utils import pg_client
from chalicelib.core import log_tool_datadog, log_tool_stackdriver, log_tool_sentry
from chalicelib.ee import users
from chalicelib.ee import projects
from chalicelib.core import users
from chalicelib.core import projects
def get_state(tenant_id):

View file

@ -5,7 +5,7 @@ from chalicelib.utils import pg_client
from chalicelib.utils import args_transformer
from chalicelib.utils import helper
from chalicelib.utils.TimeUTC import TimeUTC
from chalicelib.ee.utils import ch_client
from chalicelib.utils import ch_client
from math import isnan
from chalicelib.utils.metrics_helper import __get_step_size

View file

@ -1,9 +1,9 @@
import json
from chalicelib.utils import pg_client, helper
from chalicelib.ee.utils import ch_client
from chalicelib.utils import ch_client
from chalicelib.core import sourcemaps, sessions
from chalicelib.ee import dashboard
from chalicelib.core import dashboard
from chalicelib.utils.TimeUTC import TimeUTC

View file

@ -1,5 +1,5 @@
from chalicelib.utils import pg_client
from chalicelib.ee import unlock
from chalicelib.core import unlock
def get_status(tenant_id):

View file

@ -1,6 +1,6 @@
from chalicelib.utils import pg_client, helper, dev
from chalicelib.ee import projects
from chalicelib.core import projects
import re

View file

@ -1,6 +1,6 @@
import json
from chalicelib.ee import users
from chalicelib.core import users
from chalicelib.utils import pg_client, helper
from chalicelib.utils.TimeUTC import TimeUTC

View file

@ -3,7 +3,7 @@ from chalicelib.utils import email_helper, captcha, helper
import secrets
from chalicelib.utils import pg_client
from chalicelib.ee import users
from chalicelib.core import users
def step1(data):

View file

@ -1,5 +1,5 @@
from chalicelib.utils import helper
from chalicelib.ee.utils import ch_client
from chalicelib.utils import ch_client
from chalicelib.utils.TimeUTC import TimeUTC

View file

@ -2,9 +2,9 @@ from chalicelib.utils import pg_client, helper
from chalicelib.utils import dev
from chalicelib.core import events, sessions_metas, socket_ios, metadata, events_ios, sessions_mobs, issues
from chalicelib.ee import projects, errors
from chalicelib.core import projects, errors
from chalicelib.ee import resources
from chalicelib.core import resources
SESSION_PROJECTION_COLS = """s.project_id,
s.session_id::text AS session_id,

View file

@ -1,6 +1,6 @@
from chalicelib.utils import helper
from chalicelib.utils import pg_client
from chalicelib.ee import users, telemetry
from chalicelib.core import users, telemetry
from chalicelib.utils import captcha
import json
from chalicelib.utils.TimeUTC import TimeUTC
@ -57,7 +57,8 @@ def create_step1(data):
signed_ups = get_signed_ups()
if len(signed_ups) == 0 and data.get("tenantId") is not None \
or len(signed_ups) > 0 and data.get("tenantId") not in [t['tenantId'] for t in signed_ups]:
or len(signed_ups) > 0 and data.get("tenantId") is not None\
and data.get("tenantId") not in [t['tenantId'] for t in signed_ups]:
errors.append("Tenant does not exist")
if len(errors) > 0:
print("==> error")
@ -156,4 +157,4 @@ def create_step1(data):
"user": r,
"client": c,
}
}
}

View file

@ -65,4 +65,4 @@ def new_client(tenant_id):
FROM public.tenants
WHERE tenant_id=%(tenant_id)s;""", {"tenant_id": tenant_id}))
data = cur.fetchone()
requests.post('https://parrot.asayer.io/os/signup', json=process_data(data, edition='ee'))
requests.post('https://parrot.asayer.io/os/signup', json=process_data(data, edition='ee'))

View file

@ -1,6 +1,26 @@
from chalicelib.utils import pg_client
from chalicelib.utils import helper
from chalicelib.ee import users
from chalicelib.core import users
def get_by_tenant_key(tenant_key):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(
f"""SELECT
t.tenant_id,
t.name,
t.api_key,
t.created_at,
t.edition,
t.version_number,
t.opt_out
FROM public.tenants AS t
WHERE t.user_id = %(user_id)s AND t.deleted_at ISNULL
LIMIT 1;""",
{"user_id": tenant_key})
)
return helper.dict_to_camel_case(cur.fetchone())
def get_by_tenant_id(tenant_id):
@ -14,7 +34,8 @@ def get_by_tenant_id(tenant_id):
t.created_at,
t.edition,
t.version_number,
t.opt_out
t.opt_out,
t.user_id AS tenant_key
FROM public.tenants AS t
WHERE t.tenant_id = %(tenantId)s AND t.deleted_at ISNULL
LIMIT 1;""",

View file

@ -1,15 +1,12 @@
import json
import time
from chalicelib.core import authorizers
from chalicelib.core import tenants
from chalicelib.utils import helper
from chalicelib.utils import pg_client
from chalicelib.utils.TimeUTC import TimeUTC
from chalicelib.utils.helper import environ
from chalicelib.ee import tenants
def create_new_member(tenant_id, email, password, admin, name, owner=False):
with pg_client.PostgresClient() as cur:
@ -203,7 +200,8 @@ def get(user_id, tenant_id):
(CASE WHEN role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
(CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member,
appearance,
api_key
api_key,
origin
FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id
WHERE
users.user_id = %(userId)s
@ -274,7 +272,8 @@ def get_by_email_only(email):
basic_authentication.generated_password,
(CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
(CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
(CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member
(CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member,
origin
FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id
WHERE
users.email = %(email)s
@ -363,6 +362,8 @@ def change_password(tenant_id, user_id, email, old_password, new_password):
item = get(tenant_id=tenant_id, user_id=user_id)
if item is None:
return {"errors": ["access denied"]}
if item["origin"] is not None:
return {"errors": ["cannot change your password because you are logged-in form an SSO service"]}
if old_password == new_password:
return {"errors": ["old and new password are the same"]}
auth = authenticate(email, old_password, for_change_password=True)
@ -437,9 +438,19 @@ def auth_exists(user_id, tenant_id, jwt_iat, jwt_aud):
)
def change_jwt_iat(user_id):
with pg_client.PostgresClient() as cur:
query = cur.mogrify(
f"""UPDATE public.users
SET jwt_iat = timezone('utc'::text, now())
WHERE user_id = %(user_id)s
RETURNING jwt_iat;""",
{"user_id": user_id})
cur.execute(query)
return cur.fetchone().get("jwt_iat")
def authenticate(email, password, for_change_password=False, for_plugin=False):
if helper.TRACK_TIME:
now = int(time.time() * 1000)
with pg_client.PostgresClient() as cur:
query = cur.mogrify(
f"""SELECT
@ -451,7 +462,8 @@ def authenticate(email, password, for_change_password=False, for_plugin=False):
(CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
(CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
(CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member,
users.appearance
users.appearance,
users.origin
FROM public.users AS users INNER JOIN public.basic_authentication USING(user_id)
WHERE users.email = %(email)s
AND basic_authentication.password = crypt(%(password)s, basic_authentication.password)
@ -461,13 +473,45 @@ def authenticate(email, password, for_change_password=False, for_plugin=False):
cur.execute(query)
r = cur.fetchone()
if helper.TRACK_TIME:
now2 = int(time.time() * 1000)
print(f"=====> authentication query&fetch in: {now2 - now} ms")
now = now2
if r is not None:
if r["origin"] is not None:
return {"errors": ["must sign-in with SSO"]}
if for_change_password:
return True
r = helper.dict_to_camel_case(r, ignore_keys=["appearance"])
jwt_iat = change_jwt_iat(r['id'])
return {
"jwt": authorizers.generate_jwt(r['id'], r['tenantId'],
TimeUTC.datetime_to_timestamp(jwt_iat),
aud=f"plugin:{helper.get_stage_name()}" if for_plugin else f"front:{helper.get_stage_name()}"),
"email": email,
**r
}
return None
def authenticate_sso(email, internal_id, exp=None):
with pg_client.PostgresClient() as cur:
query = cur.mogrify(
f"""SELECT
users.user_id AS id,
users.tenant_id,
users.role,
users.name,
False AS change_password,
(CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
(CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
(CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member,
users.appearance,
origin
FROM public.users AS users
WHERE users.email = %(email)s AND internal_id = %(internal_id)s;""",
{"email": email, "internal_id": internal_id})
cur.execute(query)
r = cur.fetchone()
if r is not None:
if for_change_password:
return True
r = helper.dict_to_camel_case(r, ignore_keys=["appearance"])
query = cur.mogrify(
f"""UPDATE public.users
@ -479,8 +523,37 @@ def authenticate(email, password, for_change_password=False, for_plugin=False):
return {
"jwt": authorizers.generate_jwt(r['id'], r['tenantId'],
TimeUTC.datetime_to_timestamp(cur.fetchone()["jwt_iat"]),
aud=f"plugin:{helper.get_stage_name()}" if for_plugin else f"front:{helper.get_stage_name()}"),
aud=f"front:{helper.get_stage_name()}",
exp=exp),
"email": email,
**r
}
return None
def create_sso_user(tenant_id, email, admin, name, origin, internal_id=None):
with pg_client.PostgresClient() as cur:
query = cur.mogrify(f"""\
WITH u AS (
INSERT INTO public.users (tenant_id, email, role, name, data, origin, internal_id)
VALUES (%(tenantId)s, %(email)s, %(role)s, %(name)s, %(data)s, %(origin)s, %(internal_id)s)
RETURNING *
)
SELECT u.user_id AS id,
u.email,
u.role,
u.name,
TRUE AS change_password,
(CASE WHEN u.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
(CASE WHEN u.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
(CASE WHEN u.role = 'member' THEN TRUE ELSE FALSE END) AS member,
u.appearance,
origin
FROM u;""",
{"tenantId": tenant_id, "email": email, "internal_id": internal_id,
"role": "admin" if admin else "member", "name": name, "origin": origin,
"data": json.dumps({"lastAnnouncementView": TimeUTC.now()})})
cur.execute(
query
)
return helper.dict_to_camel_case(cur.fetchone())

View file

@ -0,0 +1,104 @@
from http import cookies
from urllib.parse import urlparse, parse_qsl
from onelogin.saml2.auth import OneLogin_Saml2_Auth
from chalicelib.utils.helper import environ
SAML2 = {
"strict": True,
"debug": True,
"sp": {
"entityId": environ["SITE_URL"] + "/api/saml2/metadata/",
"assertionConsumerService": {
"url": environ["SITE_URL"] + "/api/saml2/acs",
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
},
"singleLogoutService": {
"url": environ["SITE_URL"] + "/api/saml2/sls",
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
},
"NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
"x509cert": "",
"privateKey": ""
},
"idp": None
}
idp = None
# SAML2 config handler
if len(environ.get("SAML2_MD_URL")) > 0:
print("SAML2_MD_URL provided, getting IdP metadata config")
from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser
idp_data = OneLogin_Saml2_IdPMetadataParser.parse_remote(environ.get("SAML2_MD_URL"))
idp = idp_data.get("idp")
if SAML2["idp"] is None:
if len(environ.get("idp_entityId", "")) > 0 \
and len(environ.get("idp_sso_url", "")) > 0 \
and len(environ.get("idp_x509cert", "")) > 0:
idp = {
"entityId": environ["idp_entityId"],
"singleSignOnService": {
"url": environ["idp_sso_url"],
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
},
"x509cert": environ["idp_x509cert"]
}
if len(environ.get("idp_sls_url", "")) > 0:
idp["singleLogoutService"] = {
"url": environ["idp_sls_url"],
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
}
if idp is None:
print("No SAML2 IdP configuration found")
else:
SAML2["idp"] = idp
def init_saml_auth(req):
# auth = OneLogin_Saml2_Auth(req, custom_base_path=environ['SAML_PATH'])
if idp is None:
raise Exception("No SAML2 config provided")
auth = OneLogin_Saml2_Auth(req, old_settings=SAML2)
return auth
def prepare_request(request):
request.args = dict(request.query_params).copy() if request.query_params else {}
request.form = dict(request.json_body).copy() if request.json_body else dict(
parse_qsl(request.raw_body.decode())) if request.raw_body else {}
cookie_str = request.headers.get("cookie", "")
if "session" in cookie_str:
cookie = cookies.SimpleCookie()
cookie.load(cookie_str)
# Even though SimpleCookie is dictionary-like, it internally uses a Morsel object
# which is incompatible with requests. Manually construct a dictionary instead.
extracted_cookies = {}
for key, morsel in cookie.items():
extracted_cookies[key] = morsel.value
session = extracted_cookies["session"]
else:
session = {}
# If server is behind proxys or balancers use the HTTP_X_FORWARDED fields
headers = request.headers
url_data = urlparse('%s://%s' % (headers.get('x-forwarded-proto', 'http'), headers['host']))
return {
'https': 'on' if request.headers.get('x-forwarded-proto', 'http') == 'https' else 'off',
'http_host': request.headers['host'],
'server_port': url_data.port,
'script_name': request.path,
'get_data': request.args.copy(),
# Uncomment if using ADFS as IdP, https://github.com/onelogin/python-saml/pull/144
# 'lowercase_urlencoding': True,
'post_data': request.form.copy(),
'cookie': {"session": session},
'request': request
}
def is_saml2_available():
return idp is not None

View file

@ -9,4 +9,5 @@ elasticsearch==7.9.1
jira==2.0.0
schedule==1.1.0
croniter==1.0.12
clickhouse-driver==0.1.5
clickhouse-driver==0.1.5
python3-saml==1.10.1

View file

@ -46,6 +46,7 @@ CREATE TABLE tenants
);
CREATE TYPE user_role AS ENUM ('owner', 'admin', 'member');
CREATE TYPE user_origin AS ENUM ('saml');
CREATE TABLE users
(
user_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY,
@ -119,7 +120,9 @@ CREATE TABLE users
api_key text UNIQUE default generate_api_key(20) not null,
jwt_iat timestamp without time zone NULL DEFAULT NULL,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
weekly_report boolean NOT NULL DEFAULT TRUE
weekly_report boolean NOT NULL DEFAULT TRUE,
origin user_origin NULL DEFAULT NULL,
);

View file

@ -66,4 +66,9 @@ env:
# Enable logging for python app
# Ref: https://stackoverflow.com/questions/43969743/logs-in-kubernetes-pod-not-showing-up
PYTHONUNBUFFERED: '0'
version_number: '1.0.0'
version_number: '1.1.0'
SAML2_MD_URL: ''
idp_entityId: ''
idp_sso_url: ''
idp_x509cert: ''
idp_sls_url: ''

View file

@ -31,4 +31,8 @@ CREATE INDEX resources_session_id_timestamp_duration_durationgt0NN_img_idx ON ev
CREATE INDEX resources_timestamp_session_id_idx ON events.resources (timestamp, session_id);
CREATE INDEX errors_project_id_error_id_integration_idx ON public.errors (project_id, error_id) WHERE source != 'js_exception';
CREATE INDEX errors_error_id_timestamp_session_id_idx ON events.errors (error_id, timestamp, session_id);
CREATE TYPE user_origin AS ENUM ('saml');
ALTER TABLE public.users
ADD COLUMN origin user_origin NULL DEFAULT NULL,
ADD COLUMN internal_id text NULL DEFAULT NULL;
COMMIT;