diff --git a/etc/run-flash.sh b/etc/run-flash.sh new file mode 100644 index 0000000000..9be3b99f6c --- /dev/null +++ b/etc/run-flash.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -eou pipefail + +export SIREPO_FEATURE_CONFIG_PROPRIETARY_OAUTH_SIM_TYPES=flash +export SIREPO_SIM_OAUTH_FLASH_INFO_VALID_USER=G +if [[ ! ${SIREPO_SIM_OAUTH_FLASH_KEY:-} || ! ${SIREPO_SIM_OAUTH_FLASH_SECRET:-} ]]; then + echo 'You must set $SIREPO_SIM_OAUTH_FLASH_KEY and $SIREPO_SIM_OAUTH_FLASH_SECRET' 1>&2 + exit 1 +fi +sirepo service http diff --git a/etc/run-jupyterhub.sh b/etc/run-jupyterhub.sh index 646f2ca34f..d71f32e3be 100644 --- a/etc/run-jupyterhub.sh +++ b/etc/run-jupyterhub.sh @@ -1,4 +1,9 @@ #!/bin/bash +# +# Start Sirepo Jupyterhub with email login +# If $SIREPO_AUTH_GITHUB_KEY and $SIREPO_AUTH_GITHUB_SECRET, then +# add github authentication to test SIREPO_SIM_API_JUPYTERHUBLOGIN_RS_JUPYTER_MIGRATE. +# set -eou pipefail if [[ ! -d ~/mail ]]; then @@ -33,15 +38,10 @@ export SIREPO_FROM_NAME='RadiaSoft Support' export SIREPO_SMTP_SERVER='localhost' export SIREPO_SMTP_SEND_DIRECTLY=1 -if [[ ! ${SIREPO_AUTH_METHODS:-} ]]; then - export SIREPO_AUTH_METHODS=email -elif [[ ! $SIREPO_AUTH_METHODS =~ 'email' ]]; then - export SIREPO_AUTH_METHODS=$SIREPO_AUTH_METHODS:email -fi - -if [[ ${SIREPO_AUTH_GITHUB_KEY:-} || ${SIREPO_AUTH_GITHUB_SECRET:-} ]]; then +export SIREPO_AUTH_METHODS=${SIREPO_AUTH_METHODS:+$SIREPO_AUTH_METHODS:}email +if [[ ${SIREPO_AUTH_GITHUB_KEY:-} && ${SIREPO_AUTH_GITHUB_SECRET:-} ]]; then + export SIREPO_AUTH_METHODS=$SIREPO_AUTH_METHODS:github export SIREPO_AUTH_GITHUB_METHOD_VISIBLE=0 - export SIREPO_AUTH_METHODS="$SIREPO_AUTH_METHODS:github" export SIREPO_SIM_API_JUPYTERHUBLOGIN_RS_JUPYTER_MIGRATE=1 fi diff --git a/setup.py b/setup.py index 03ea0a62ab..b7c9315a2f 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ description='accelerator code gui', install_requires=[ 'Flask==2.0.3', - 'SQLAlchemy', + 'SQLAlchemy>=1.4', 'aenum', 'asyncssh', 'cryptography>=2.8', diff --git a/sirepo/api_auth.py b/sirepo/api_auth.py index 29b273e4c1..b448ca2f33 100644 --- a/sirepo/api_auth.py +++ b/sirepo/api_auth.py @@ -59,6 +59,5 @@ def check_api_call(func): raise AssertionError('unhandled api_perm={}'.format(expect)) -def is_sim_type_required_for_api(func): - e = getattr(func, api_perm.ATTR) - return e not in api_perm.SIM_TYPELESS_PERMS +def maybe_sim_type_required_for_api(func): + return getattr(func, api_perm.ATTR) not in api_perm.SIM_TYPELESS_PERMS diff --git a/sirepo/auth/__init__.py b/sirepo/auth/__init__.py index 92e76aec3e..e07cd23e7e 100644 --- a/sirepo/auth/__init__.py +++ b/sirepo/auth/__init__.py @@ -90,8 +90,8 @@ def api_authCompleteRegistration(self): _parse_display_name(self.parse_json().get('displayName')), ) return self.reply_ok() - - + + @api_perm.allow_visitor def api_authState(self): return self.reply_static_jinja( @@ -99,12 +99,12 @@ def api_authState(self): 'js', PKDict(auth_state=_auth_state()), ) - - + + @api_perm.allow_visitor def api_authLogout(self, simulation_type=None): """Set the current user as logged out. - + Redirects to root simulation page. """ req = None @@ -120,6 +120,26 @@ def api_authLogout(self, simulation_type=None): return self.reply_redirect_for_app_root(req and req.type) +def check_sim_type_role(sim_type): + from sirepo import oauth + from sirepo import auth_role_moderation + + t = sirepo.template.assert_sim_type(sim_type) + if t not in sirepo.feature_config.auth_controlled_sim_types(): + return + if not uri_router.maybe_sim_type_required_for_api(): + return + u = logged_in_user() + r = sirepo.auth_role.for_sim_type(t) + if auth_db.UserRole.has_role(u, r) and not auth_db.UserRole.is_expired(u, r): + return + elif r in sirepo.auth_role.for_proprietary_oauth_sim_types(): + oauth.raise_authorize_redirect(sirepo.auth_role.sim_type(r)) + if r in sirepo.auth_role.for_moderated_sim_types(): + auth_role_moderation.raise_control_for_user(u, r) + sirepo.util.raise_forbidden(f'uid={u} does not have access to sim_type={t}') + + def complete_registration(name=None): """Update the database with the user's display_name and sets state to logged-in. Guests will have no name. @@ -333,47 +353,11 @@ def require_auth_basic(): login(m, uid=uid) -def require_sim_type(sim_type): - def _assert_login(): - try: - return logged_in_user() - except util.SRException as e: - if ( - getattr(e, 'sr_args', PKDict()).get('routeName') == LOGIN_ROUTE_NAME - and not uri_router.is_sim_type_required_for_api() - ): - return None - raise - - def _moderate(uid, role): - s = sirepo.auth_db.UserRoleInvite.get_status(uid, role) - if s in ('clarify', 'pending'): - raise sirepo.util.SRException('moderationPending', None) - if s == 'denied': - sirepo.util.raise_forbidden(f'uid={uid} role={role} already denied') - assert s is None, \ - f'Unexpected status={s} for uid={uid} and role={role}' - require_email_user() - raise sirepo.util.SRException('moderationRequest', None) - - if sim_type not in sirepo.feature_config.auth_controlled_sim_types(): - return - u = _assert_login() - if u is None: - return - r = sirepo.auth_role.for_sim_type(sim_type) - if auth_db.UserRole.has_role(u, r): - return - if r not in sirepo.auth_role.for_moderated_sim_types(): - sirepo.util.raise_forbidden(f'uid={u} does not have access to sim_type={sim_type}') - _moderate(u, r) - - def require_email_user(): - uid = require_user() - u = user_name(uid) + i = require_user() + u = user_name(i) if not pyisemail.is_email(u): - util.raise_forbidden(f'uid={uid} username={u} is not an email') + util.raise_forbidden(f'uid={i} username={u} is not an email') def require_user(): diff --git a/sirepo/auth/github.py b/sirepo/auth/github.py index 6dff819ccc..42154fc664 100644 --- a/sirepo/auth/github.py +++ b/sirepo/auth/github.py @@ -13,16 +13,10 @@ from sirepo import api_perm from sirepo import auth from sirepo import auth_db -from sirepo import cookie -from sirepo import feature_config -from sirepo import http_reply -from sirepo import uri_router from sirepo import util -import authlib.integrations.requests_client -import authlib.oauth2.rfc6749.errors -import flask import sirepo.api import sirepo.events +import sirepo.oauth import sqlalchemy @@ -33,34 +27,14 @@ #: Well known alias for auth UserModel = None - -#: module handle -this_module = pkinspect.this_module() - -#: cookie keys for github (prefix is "srag") -_COOKIE_NONCE = 'sragn' -_COOKIE_SIM_TYPE = 'srags' - - class API(sirepo.api.Base): @api_perm.allow_cookieless_set_user def api_authGithubAuthorized(self): """Handle a callback from a successful OAUTH request. - + Tracks oauth users in a database. """ - # clear temporary cookie values first - s = cookie.unchecked_remove(_COOKIE_NONCE) - t = cookie.unchecked_remove(_COOKIE_SIM_TYPE) - oc = _client() - try: - oc.fetch_token( - authorization_response=flask.request.url, - state=s, - ) - except authlib.oauth2.rfc6749.errors.MismatchingStateException: - auth.login_fail_redirect(t, this_module, 'oauth-state', reload_js=True) - raise AssertionError('auth.login_fail_redirect returned unexpectedly') + oc, t = sirepo.oauth.check_authorized_callback(github_auth=True) d = oc.get('https://api.github.com/user').json() sirepo.events.emit('github_authorized', PKDict(user_name=d['login'])) with util.THREAD_LOCK: @@ -71,29 +45,23 @@ def api_authGithubAuthorized(self): else: u = AuthGithubUser(oauth_id=d['id'], user_name=d['login']) u.save() - auth.login(this_module, model=u, sim_type=t, sapi=self, want_redirect=True) + auth.login( + pkinspect.this_module(), + model=u, + sim_type=t, + want_redirect=True, + ) raise AssertionError('auth.login returned unexpectedly') - - + + @api_perm.require_cookie_sentinel def api_authGithubLogin(self, simulation_type): """Redirects to Github""" - req = self.parse_params(type=simulation_type) - s = util.random_base62() - cookie.set_value(_COOKIE_NONCE, s) - cookie.set_value(_COOKIE_SIM_TYPE, req.type) - if not cfg.callback_uri: - # must be executed in an app and request context so can't - # initialize earlier. - cfg.callback_uri = uri_router.uri_for_api('authGithubAuthorized') - u, _ = _client().create_authorization_url( - 'https://github.com/login/oauth/authorize', - redirect_uri=cfg.callback_uri, - state=s, + sirepo.oauth.raise_authorize_redirect( + self.parse_params(type=simulation_type).type, + github_auth=True, ) - return self.reply_redirect(u) - - + @api_perm.allow_cookieless_set_user def api_oauthAuthorized(self, oauth_type): """Deprecated use `api_authGithubAuthorized`""" @@ -107,19 +75,6 @@ def avatar_uri(model, size): ) -def _client(token=None): - """Makes it easier to mock, see github_srunit.py""" - # OAuth2Session doesn't inherit from OAuth2Mixin for some reason. - # So, supplying api_base_url has no effect. - return authlib.integrations.requests_client.OAuth2Session( - cfg.key, - cfg.secret, - scope='user:email', - token=token, - token_endpoint='https://github.com/login/oauth/access_token', - ) - - def _init(): def _init_model(base): """Creates User class bound to dynamic `db` variable""" @@ -135,6 +90,7 @@ class AuthGithubUser(base): global cfg, AUTH_METHOD_VISIBLE cfg = pkconfig.init( + authorize_url=('https://github.com/login/oauth/authorize', str, 'url to redirect to for authorization'), callback_uri=(None, str, 'Github callback URI (defaults to api_authGithubAuthorized)'), key=pkconfig.Required(str, 'Github key'), method_visible=( @@ -142,8 +98,12 @@ class AuthGithubUser(base): bool, 'github auth method is visible to users when it is an enabled method', ), + scope=('user:email', str, 'scope of data to request about user'), secret=pkconfig.Required(str, 'Github secret'), + token_endpoint=('https://github.com/login/oauth/access_token', str, 'url for obtaining access token') ) + cfg.callback_api = 'authGithubAuthorized' + AUTH_METHOD_VISIBLE = cfg.method_visible auth_db.init_model(_init_model) diff --git a/sirepo/auth_db.py b/sirepo/auth_db.py index d099be06c9..eccc413743 100644 --- a/sirepo/auth_db.py +++ b/sirepo/auth_db.py @@ -4,8 +4,6 @@ :copyright: Copyright (c) 2018-2019 RadiaSoft LLC. All Rights Reserved. :license: http://www.apache.org/licenses/LICENSE-2.0.html """ -import sqlite3 - from pykern.pkcollections import PKDict from pykern.pkdebug import pkdc, pkdexc, pkdlog, pkdp import contextlib @@ -16,6 +14,7 @@ import sirepo.auth_role import sirepo.srcontext import sirepo.srdb +import sirepo.srtime import sirepo.util @@ -88,7 +87,7 @@ def _add(proprietary_code_dir, sim_type, sim_data_class): if force or f not in e: t.join(f).rename(l.join(f)) - s = sirepo.feature_config.cfg().proprietary_sim_types + s = sirepo.feature_config.proprietary_sim_types() if sim_types: assert sim_types.issubset(s), \ f'sim_types={sim_types} not a subset of proprietary_sim_types={s}' @@ -96,7 +95,7 @@ def _add(proprietary_code_dir, sim_type, sim_data_class): for t in s: c = sirepo.sim_data.get_class(t) if not c.proprietary_code_tarball(): - return + continue d = sirepo.srdb.proprietary_code_dir(t) assert d.exists(), \ f'{d} proprietary_code_dir must exist' \ @@ -159,6 +158,23 @@ def __init__(self, **kwargs): for k, v in kwargs.items(): setattr(self, k, v) + @classmethod + def add_column_if_not_exists(cls, table, column, column_type): + column_type = column_type.upper() + t = table.__table__.name + r = cls._execute_raw_sql(f'PRAGMA table_info({t})') + for c in r.all(): + if not c[1] == column: + continue + assert c[2] == column_type, \ + ( + f'unexpected column={c} when adding column={column} of', + f' type={column_type} to table={table}', + ) + return + r = cls._execute_raw_sql(f'ALTER TABLE {t} ADD {column} {column_type}') + cls._session().commit() + @classmethod def all(cls): with sirepo.util.THREAD_LOCK: @@ -191,7 +207,7 @@ def delete_user(cls, uid): @classmethod def execute(cls, statement): - cls._session().execute( + return cls._session().execute( statement.execution_options(synchronize_session='fetch') ) @@ -232,6 +248,10 @@ def delete_all_for_column_by_values(cls, column, values): )) cls._session().commit() + @classmethod + def _execute_raw_sql(cls, text): + return cls.execute(sqlalchemy.text(text + ';')) + @classmethod def _session(cls): return sirepo.srcontext.get(_SRCONTEXT_SESSION_KEY) @@ -258,6 +278,7 @@ class UserRole(UserDbBase): __tablename__ = 'user_role_t' uid = sqlalchemy.Column(UserDbBase.STRING_ID, primary_key=True) role = sqlalchemy.Column(UserDbBase.STRING_NAME, primary_key=True) + expiration = sqlalchemy.Column(sqlalchemy.DateTime()) @classmethod def all_roles(cls): @@ -267,15 +288,27 @@ def all_roles(cls): ] @classmethod - def add_roles(cls, uid, roles): + def add_roles(cls, uid, role_or_roles, expiration=None): + if isinstance(role_or_roles, str): + role_or_roles = [role_or_roles] with sirepo.util.THREAD_LOCK: - for r in roles: + for r in role_or_roles: try: - UserRole(uid=uid, role=r).save() + UserRole(uid=uid, role=r, expiration=expiration).save() except sqlalchemy.exc.IntegrityError: pass audit_proprietary_lib_files(uid) + @classmethod + def add_role_or_update_expiration(cls, uid, role, expiration): + with sirepo.util.THREAD_LOCK: + if not cls.has_role(uid, role): + cls.add_roles(uid, role, expiration=expiration) + return + r = cls.search_by(uid=uid, role=role) + r.expiration = expiration + r.save() + @classmethod def delete_roles(cls, uid, roles): with sirepo.util.THREAD_LOCK: @@ -300,6 +333,18 @@ def has_role(cls, uid, role): with sirepo.util.THREAD_LOCK: return bool(cls.search_by(uid=uid, role=role)) + + @classmethod + def is_expired(cls, uid, role): + with sirepo.util.THREAD_LOCK: + assert cls.has_role(uid, role), \ + f'No role for uid={uid} and role={role}' + r = cls.search_by(uid=uid, role=role) + if not r.expiration: + # Roles with no expiration can't expire + return False + return r.expiration < sirepo.srtime.utc_now() + @classmethod def uids_of_paid_users(cls): return [ @@ -333,15 +378,13 @@ def get_status(cls, uid, role): s = cls.search_by(uid=uid, role=role) if not s: return None - return s.status + return sirepo.auth_role.ModerationStatus.check(s.status) @classmethod def set_status(cls, uid, role, status, moderator_uid=None): - assert status in ('approve', 'clarify', 'deny'), \ - f'status={status} not in expected values' with sirepo.util.THREAD_LOCK: s = cls.search_by(uid=uid, role=role) - s.status = status + s.status = sirepo.auth_role.ModerationStatus.check(status) if moderator_uid: s.moderator_uid = moderator_uid s.save() diff --git a/sirepo/auth_role.py b/sirepo/auth_role.py index 6b55c2e56b..dcb7fa6a97 100644 --- a/sirepo/auth_role.py +++ b/sirepo/auth_role.py @@ -4,8 +4,9 @@ :copyright: Copyright (c) 2021 RadiaSoft LLC. All Rights Reserved. :license: http://www.apache.org/licenses/LICENSE-2.0.html """ -from __future__ import absolute_import, division, print_function from pykern import pkconfig +from pykern.pkdebug import pkdp +import aenum import sirepo.feature_config ROLE_ADM = 'adm' @@ -15,6 +16,21 @@ _SIM_TYPE_ROLE_PREFIX = 'sim_type_' +class ModerationStatus(aenum.NamedConstant): + """States used by auth_role_moderation and UserRoleInvite""" + APPROVE = 'approve' + CLARIFY = 'clarify' + DENY = 'deny' + PENDING = 'pending' + VALID_SET = frozenset([APPROVE, CLARIFY, DENY, PENDING]) + + @classmethod + def check(cls, value): + assert value in cls.VALID_SET, \ + f'status={value} is not in valied_set={cls.VALID_SET}' + return value + + def for_moderated_sim_types(): return [for_sim_type(s) for s in sirepo.feature_config.cfg().moderated_sim_types] @@ -25,6 +41,10 @@ def for_new_user(is_guest): return [] +def for_proprietary_oauth_sim_types(): + return [for_sim_type(s) for s in sirepo.feature_config.cfg().proprietary_oauth_sim_types] + + def for_sim_type(sim_type): return _SIM_TYPE_ROLE_PREFIX + sim_type diff --git a/sirepo/auth_role_moderation.py b/sirepo/auth_role_moderation.py index a0ca5deccd..250065b5c9 100644 --- a/sirepo/auth_role_moderation.py +++ b/sirepo/auth_role_moderation.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- u"""Moderate user roles -:copyright: Copyright (c) 2018-2019 RadiaSoft LLC. All Rights Reserved. +:copyright: Copyright (c) 2022 RadiaSoft LLC. All Rights Reserved. :license: http://www.apache.org/licenses/LICENSE-2.0.html """ from pykern import pkconfig @@ -14,7 +14,6 @@ import sirepo.auth_db import sirepo.auth_role import sirepo.feature_config -import sirepo.http_reply import sirepo.http_request import sirepo.simulation_db import sirepo.smtp @@ -22,14 +21,14 @@ import sirepo.uri_router import sqlalchemy -_STATUS_TO_SUBJECT = PKDict( - approve='{} Access Request Approved', - clarify='Sirepo {}: Additional Info?', - deny='{} Access Request Denied', -) +_STATUS_TO_SUBJECT = None _cfg = None +_ACTIVE = frozenset([ + sirepo.auth_role.ModerationStatus.CLARIFY, + sirepo.auth_role.ModerationStatus.PENDING, +]) class API(sirepo.api.Base): @sirepo.api_perm.require_adm @@ -118,7 +117,7 @@ def _send_request_email(info): sirepo.auth_db.UserRoleInvite( uid=u, role=r, - status='pending', + status=sirepo.auth_role.ModerationStatus.PENDING, token=sirepo.util.random_base62(32), ).save() except sqlalchemy.exc.IntegrityError as e: @@ -142,8 +141,32 @@ def _send_request_email(info): return self.reply_ok() +def raise_control_for_user(uid, role): + s = sirepo.auth_db.UserRoleInvite.get_status(uid, role) + if s in _ACTIVE: + raise sirepo.util.SRException('moderationPending', None) + if s == sirepo.auth_role.ModerationStatus.DENY: + sirepo.util.raise_forbidden(f'uid={uid} role={role} already denied') + assert s is None, \ + f'Unexpected status={s} for uid={uid} and role={role}' + sirepo.auth.require_email_user() + raise sirepo.util.SRException('moderationRequest', None) + + def init_apis(): - global _cfg + global _cfg, _STATUS_TO_SUBJECT + _cfg = pkconfig.init( moderator_email=pkconfig.Required(str, 'The email address to send moderation emails to'), ) + _STATUS_TO_SUBJECT = PKDict( + approve='{} Access Request Approved', +#TODO(robnagler) should we send an email when moderation pending? + # For completeness + pending=None, + clarify='Sirepo {}: Additional Info?', + deny='{} Access Request Denied', + ) + x = frozenset(_STATUS_TO_SUBJECT.keys()) + if x != sirepo.auth_role.ModerationStatus.VALID_SET: + raise AssertionError(f'{x} not same as {sirepo.auth_role.ModerationStatus.VALID_SET}') diff --git a/sirepo/db_upgrade.py b/sirepo/db_upgrade.py index 7ed645530d..d48207a040 100644 --- a/sirepo/db_upgrade.py +++ b/sirepo/db_upgrade.py @@ -171,7 +171,15 @@ def _20210301_migrate_role_jupyterhub(): r in sirepo.auth_db.UserRole.all_roles(): return for u in sirepo.auth_db.all_uids(): - sirepo.auth_db.UserRole.add_roles(u, [r]) + sirepo.auth_db.UserRole.add_roles(u, r) + + +def _20220609_add_expiration_column_to_user_role_t(): + sirepo.auth_db.UserDbBase.add_column_if_not_exists( + sirepo.auth_db.UserRole, + 'expiration', + 'datetime', + ) @contextlib.contextmanager diff --git a/sirepo/events.py b/sirepo/events.py index 6f9c38e0eb..d2a1de8a16 100644 --- a/sirepo/events.py +++ b/sirepo/events.py @@ -25,7 +25,7 @@ """ # Limit imports from pykern.pkcollections import PKDict -import aenum +from pykern.pkdebug import pkdp #: Map of events to handlers. Note: this is the list of all possible events. _MAP = PKDict( diff --git a/sirepo/feature_config.py b/sirepo/feature_config.py index 092cd48dfe..cc707b7b69 100644 --- a/sirepo/feature_config.py +++ b/sirepo/feature_config.py @@ -56,7 +56,10 @@ def auth_controlled_sim_types(): frozenset: enabled sim types that require role """ return frozenset( - cfg().proprietary_sim_types.union(cfg().moderated_sim_types), + cfg().moderated_sim_types.union( + cfg().proprietary_sim_types, + cfg().proprietary_oauth_sim_types, + ), ) @@ -87,6 +90,19 @@ def for_sim_type(sim_type): ).pkupdate(c.schema_common) +def proprietary_sim_types(): + """All sim types that have proprietary information and require granted access to use + + Granted access can be through oauth or manual management of the role + + Returns: + frozenset: enabled sim types that require role + """ + return frozenset( + cfg().proprietary_sim_types.union(cfg().proprietary_oauth_sim_types), + ) + + def _data_dir(value): import sirepo.srdb return sirepo.srdb.root().join(value) @@ -110,7 +126,7 @@ def b(msg, dev=False): schema_common=dict( hide_guest_warning=b('Hide the guest warning in the UI', dev=True), ), - moderated_sim_types=(set(), set, 'codes where all users must be authorized via moderation'), + moderated_sim_types=(frozenset(), set, 'codes where all users must be authorized via moderation'), jspec=dict( derbenevskrinsky_force_formula=b('Include Derbenev-Skrinsky force formula'), ), @@ -119,7 +135,16 @@ def b(msg, dev=False): tuple, 'Names of root packages that should be checked for codes and resources. Order is important, the first package with a matching code/resource will be used. sirepo added automatically.', ), - proprietary_sim_types=(set(), set, 'codes that require authorization'), + proprietary_sim_types=( + frozenset(), + set, + 'codes that contain proprietary information and authorization to use is granted manually', + ), + proprietary_oauth_sim_types=( + frozenset(), + set, + 'codes that contain proprietary information and authorization to use is granted through oauth', + ), raydata=dict( data_dir=(None, _data_dir, 'abspath of dir to store raydata analysis output'), ), @@ -142,7 +167,11 @@ def b(msg, dev=False): PROD_FOSS_CODES if pkconfig.channel_in('prod') else FOSS_CODES ) ) - s.update(_cfg.proprietary_sim_types, _cfg.moderated_sim_types) + s.update( + _cfg.moderated_sim_types, + _cfg.proprietary_sim_types, + _cfg.proprietary_oauth_sim_types, + ) for v in _DEPENDENT_CODES: if v[0] in s: s.add(v[1]) diff --git a/sirepo/github_srunit.py b/sirepo/github_srunit.py index edbfbd1b7a..beda2fcd0c 100644 --- a/sirepo/github_srunit.py +++ b/sirepo/github_srunit.py @@ -12,7 +12,7 @@ class MockOAuthClient(object): def __init__(self, monkeypatch, user_name='joeblow'): from pykern import pkcollections - from sirepo.auth import github + import sirepo.oauth self.values = PKDict({ 'access_token': 'xyzzy', @@ -22,7 +22,7 @@ def __init__(self, monkeypatch, user_name='joeblow'): login=user_name, ), }) - monkeypatch.setattr(github, '_client', self) + monkeypatch.setattr(sirepo.oauth, '_client', self) def __call__(self, *args, **kwargs): return self @@ -35,9 +35,10 @@ def create_authorization_url(self, *args, **kwargs): from sirepo.auth import github import sirepo.http_reply - self.values.redirect_uri = kwargs['redirect_uri'] - self.values.state = kwargs['state'] - return f'https://github.com/login/oauth/oauthorize?response_type=code&client_id={github.cfg.key}&redirect_uri={github.cfg.callback_uri}&state={self.values.state}', self.values.state + r = github.cfg.callback_uri + self.values.redirect_uri = r + self.values.state = 'xxyyzz' + return f'https://github.com/login/oauth/oauthorize?response_type=code&client_id={github.cfg.key}&redirect_uri={r}&state={self.values.state}', self.values.state def fetch_token(self, *args, **kwargs): return 'a_mock_token' diff --git a/sirepo/http_request.py b/sirepo/http_request.py index b29b3c07c1..55b214a25e 100644 --- a/sirepo/http_request.py +++ b/sirepo/http_request.py @@ -11,7 +11,6 @@ import sirepo.srcontext import sirepo.srschema import sirepo.template -import sirepo.uri_router import sirepo.util import user_agents @@ -21,9 +20,7 @@ def init(**imports): - global sirepo sirepo.util.setattr_imports(imports) - import sirepo.auth def is_spider(): @@ -93,20 +90,20 @@ def parse_post(**kwargs): res.pkupdate(req_data=r) kwargs.pksetdefault(type=True) - def t(v): + def _type(v): + from sirepo import auth + assert not isinstance(v, bool), \ 'missing type in params/post={}'.format(kwargs) - v = sirepo.template.assert_sim_type(v) + auth.check_sim_type_role(v) # Do this in order to maintain explicit coupling of _SIM_TYPE_ATTR set_sim_type(v) res.sim_data = sirepo.sim_data.get_class(v) - if sirepo.uri_router.is_sim_type_required_for_api(): - sirepo.auth.require_sim_type(v) return v for x in ( # must be first - ('type', ('simulationType',), t), + ('type', ('simulationType',), _type), ('file_type', ('file_type', 'fileType'), sirepo.util.secure_filename), ('filename', ('filename', 'fileName'), sirepo.util.secure_filename), ('folder', ('folder',), sirepo.srschema.parse_folder), @@ -171,13 +168,17 @@ def sim_type(value=None): def user_agent_headers(): + def _dns_reverse_lookup(ip): import dns.resolver import dns.reversename try: - return str(dns.resolver.query(dns.reversename.from_address(ip), 'PTR')[0]) + if ip: + return str(dns.resolver.query(dns.reversename.from_address(ip), 'PTR')) except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN): - return 'No Reverse DNS Lookup' + pass + return 'No Reverse DNS Lookup' + return PKDict( ip_addr=flask.request.remote_addr, domain_name=_dns_reverse_lookup(flask.request.remote_addr), diff --git a/sirepo/jupyterhub.py b/sirepo/jupyterhub.py index 81992b0f47..7cc4102ee2 100644 --- a/sirepo/jupyterhub.py +++ b/sirepo/jupyterhub.py @@ -4,15 +4,18 @@ :copyright: Copyright (c) 2020 RadiaSoft LLC. All Rights Reserved. :license: http://www.apache.org/licenses/LICENSE-2.0.html """ +from pykern import pkcompat from pykern.pkcollections import PKDict from pykern.pkdebug import pkdp, pkdexc import jupyterhub.auth import pykern.pkresource +import re import requests import sirepo.server import tornado.web import traitlets +_SIM_TYPE = 'jupyterhublogin' def template_dirs(): return pykern.pkresource.filename('jupyterhub_templates') @@ -32,33 +35,61 @@ def __init__(self, *args, **kwargs): sirepo.server.init() async def authenticate(self, handler, data): - d = self._check_permissions(handler) - if 'username' in d: - return d.username - elif 'uri' in d: - handler.redirect(d.uri) - raise tornado.web.Finish() # returning None means the user is forbidden (403) # https://jupyterhub.readthedocs.io/en/stable/api/auth.html#jupyterhub.auth.Authenticator.authenticate - return None + return self._check_permissions(handler).get('username') async def refresh_user(self, user, handler=None): - try: - return bool(self._check_permissions(handler).get('username')) - except Exception: - # Returning False is what the jupyterhub API expects and jupyterhub - # will handle re-authenticating the user. - # https://jupyterhub.readthedocs.io/en/stable/api/auth.html#jupyterhub.auth.Authenticator.refresh_user - return False - raise AssertionError('should not get here') + # Reading jupyterhub code the handler is never None + # We need the handler for cookies and redirects + assert handler, \ + 'handler should never be none' + # Returning True/False is what the jupyterhub API expects and jupyterhub + # will handle re-authenticating the user if needed. + # https://jupyterhub.readthedocs.io/en/stable/api/auth.html#jupyterhub.auth.Authenticator.refresh_user + return bool(self._check_permissions(handler).get('username')) def _check_permissions(self, handler): + def _cookies(response): + for k, v in response.cookies.get_dict().items(): + handler.set_cookie(k, v) + + + def _maybe_html_redirect(response): + m = re.search(r'window.location = "(.*)"', pkcompat.from_bytes(response.content)) + m and self._redirect(handler, m.group(1)) + + def _maybe_srexception_redirect(response): + if 'srException' not in response: + return + from sirepo import uri + e = PKDict(response.srException) + self._redirect( + handler, + uri.local_route( + _SIM_TYPE, + route_name=e.routeName, + params=e.params, + external=False, + ) + ) + r = requests.post( # POSIT: no params on checkAuthJupyterhub self.sirepo_uri + sirepo.simulation_db.SCHEMA_COMMON.route.checkAuthJupyterhub, cookies={k: handler.get_cookie(k) for k in handler.cookies.keys()}, ) + _cookies(r) + if r.status_code == requests.codes.forbidden: + return PKDict() r.raise_for_status() - for k, v in r.cookies.get_dict().items(): - handler.set_cookie(k, v) - return PKDict(r.json()) + _maybe_html_redirect(r) + res = PKDict(r.json()) + _maybe_srexception_redirect(res) + assert 'username' in res, \ + f'unexpected response={res}' + return res + + def _redirect(self, handler, uri): + handler.redirect(uri) + raise tornado.web.Finish() diff --git a/sirepo/oauth.py b/sirepo/oauth.py new file mode 100644 index 0000000000..cccc9c1919 --- /dev/null +++ b/sirepo/oauth.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +u"""oauth for authentication and role moderation + +:copyright: Copyright (c) 2022 RadiaSoft LLC. All Rights Reserved. +:license: http://www.apache.org/licenses/LICENSE-2.0.html +""" +from pykern import pkinspect +from pykern.pkcollections import PKDict +from pykern.pkdebug import pkdc, pkdexc, pkdlog, pkdp +from sirepo import auth +from sirepo import cookie +import authlib.integrations.base_client +import authlib.integrations.requests_client +import flask +import sirepo.events +import sirepo.feature_config +import sirepo.sim_oauth +import sirepo.uri_router +import sirepo.util + + +#: cookie keys for oauth (prefix is "sroa") +_COOKIE_NONCE = 'sroan' +_COOKIE_SIM_TYPE = 'sroas' + + +def check_authorized_callback(github_auth=False): + # clear temporary cookie values first + s = cookie.unchecked_remove(_COOKIE_NONCE) + t = cookie.unchecked_remove(_COOKIE_SIM_TYPE) + assert t + c = _client(t, github_auth) + try: + c.fetch_token( + authorization_response=flask.request.url, + state=s, + # SECURITY: This *must* be the grant_type otherwise authlib defaults to + # client_credentials which just returns details about the oauth client. That response + # can easily be confused for a valid authorization_code response. + grant_type='authorization_code', + ) + + return c, t + except Exception as e: + pkdlog('url={} exception={} stack={}', flask.request.url, e, pkdexc()) + sirepo.util.raise_forbidden(f'user denied access from sim_type={t}') + + +def raise_authorize_redirect(sim_type, github_auth=False): + cookie.set_value(_COOKIE_SIM_TYPE, sim_type) + c = _cfg(sim_type, github_auth) + u, s = _client(sim_type, github_auth).create_authorization_url(c.authorize_url) + cookie.set_value(_COOKIE_NONCE, s) + raise sirepo.util.Redirect(u) + + +def _cfg(sim_type, github_auth): + if github_auth: + # We are authenticating to sirepo using github oauth + # or doing jupyter migration. + from sirepo.auth import github + return github.cfg + # We are doing oauth for a sim type + return sirepo.sim_oauth.import_module(sim_type).cfg + + +def _client(sim_type, github_auth): + """Makes it easier to mock, see github_srunit.py""" + c = _cfg(sim_type, github_auth) + return authlib.integrations.requests_client.OAuth2Session( + c.key, + c.secret, + redirect_uri=c.callback_uri or sirepo.uri_router.uri_for_api(c.callback_api), + **c + ) diff --git a/sirepo/package_data/static/json/schema-common.json b/sirepo/package_data/static/json/schema-common.json index 1a93003ed0..d8c3fec4e6 100644 --- a/sirepo/package_data/static/json/schema-common.json +++ b/sirepo/package_data/static/json/schema-common.json @@ -165,6 +165,7 @@ "saveSimulationData": "/save-simulation", "sbatchLogin": "/sbatch-login", "serverStatus": "/server-status", + "simOauthFlashAuthorized": "/sim-oauth-flash-authorized", "simulationData": "/simulation////?
", "simulationFrame": "/simulation-frame/", "simulationSchema": "/simulation-schema", diff --git a/sirepo/pkcli/jupyterhublogin.py b/sirepo/pkcli/jupyterhublogin.py index 6f3f3c03fa..6271428d27 100644 --- a/sirepo/pkcli/jupyterhublogin.py +++ b/sirepo/pkcli/jupyterhublogin.py @@ -74,5 +74,5 @@ def maybe_create_sirepo_user(module, email, display_name): ) with sirepo.auth.set_user_outside_of_http_request(u): n = sirepo.sim_api.jupyterhublogin.create_user(check_dir=True) - sirepo.auth_db.UserRole.add_roles(u, [sirepo.auth_role.for_sim_type('jupyterhublogin')]) + sirepo.auth_db.UserRole.add_roles(u, sirepo.auth_role.for_sim_type('jupyterhublogin')) return PKDict(email=email, jupyterhub_user_name=n) diff --git a/sirepo/pkcli/setup_dev.py b/sirepo/pkcli/setup_dev.py index 4cc206c7e6..5b06b0217d 100644 --- a/sirepo/pkcli/setup_dev.py +++ b/sirepo/pkcli/setup_dev.py @@ -39,10 +39,10 @@ def _proprietary_codes(): import urllib.error import urllib.request - for s in sirepo.feature_config.cfg().proprietary_sim_types: + for s in sirepo.feature_config.proprietary_sim_types(): f = sirepo.sim_data.get_class(s).proprietary_code_tarball() if not f: - return + continue r = pkio.mkdir_parent( sirepo.srdb.proprietary_code_dir(s), ).join(f) diff --git a/sirepo/sim_api/jupyterhublogin.py b/sirepo/sim_api/jupyterhublogin.py index 914a365c6e..9cde9f65c0 100644 --- a/sirepo/sim_api/jupyterhublogin.py +++ b/sirepo/sim_api/jupyterhublogin.py @@ -15,6 +15,7 @@ import sirepo.events import sirepo.http_reply import sirepo.http_request +import sirepo.oauth import sirepo.srdb import sirepo.uri_router import sirepo.uri @@ -31,33 +32,16 @@ _JUPYTERHUB_LOGOUT_USER_NAME_ATTR = 'jupyterhub_logout_user_name' +_SIM_TYPE = 'jupyterhublogin' + class API(sirepo.api.Base): - @sirepo.api_perm.allow_visitor + @sirepo.api_perm.require_user def api_checkAuthJupyterhub(self): - def _res_for_uri(uri): - return self.reply_ok(PKDict(uri=uri)) - - u = None - try: - sirepo.auth.require_user() - sirepo.auth.require_sim_type('jupyterhublogin') - u = _unchecked_jupyterhub_user_name( - have_simulation_db=False, - ) - except werkzeug.exceptions.Forbidden: - return self.reply_ok() - except sirepo.util.Redirect as e: - return _res_for_uri(sirepo.uri_router.uri_for_api( - 'root', - params=PKDict(path_info=e.sr_args.uri), - )) - except sirepo.util.SRException as e: - return _res_for_uri(sirepo.uri.local_route( - 'jupyterhublogin', - route_name=e.sr_args.routeName, - external=True, - )) + self.parse_params(type=_SIM_TYPE) + u = _unchecked_jupyterhub_user_name( + have_simulation_db=False, + ) if not u: u = create_user() return self.reply_ok(PKDict( @@ -66,21 +50,18 @@ def _res_for_uri(uri): @sirepo.api_perm.require_user def api_migrateJupyterhub(self): - sirepo.auth.require_sim_type('jupyterhublogin') + self.parse_params(type=_SIM_TYPE) if not cfg.rs_jupyter_migrate: sirepo.util.raise_forbidden('migrate not enabled') d = self.parse_json() if not d.doMigration: create_user() return self.reply_redirect('jupyterHub') - return self.call_api( - 'authGithubLogin', - kwargs=PKDict(simulation_type='jupyterhublogin'), - ) + sirepo.oauth.raise_authorize_redirect(_SIM_TYPE, github_auth=True) @sirepo.api_perm.require_user def api_redirectJupyterHub(self): - sirepo.auth.require_sim_type('jupyterhublogin') + self.parse_params(type=_SIM_TYPE) u = _unchecked_jupyterhub_user_name() if u: return self.reply_redirect('jupyterHub') @@ -141,7 +122,7 @@ def __user_name(): not _user_dir(user_name=github_handle).exists(): raise sirepo.util.SRException( 'jupyterNameConflict', - PKDict(sim_type='jupyterhublogin'), + PKDict(sim_type=_SIM_TYPE), ) return github_handle n = __handle_or_name_sanitized() @@ -187,14 +168,14 @@ def init_apis(*args, **kwargs): ) pkio.mkdir_parent(cfg.user_db_root_d) sirepo.auth_db.init_model(_init_model) - sirepo.events.register(PKDict( - auth_logout=_event_auth_logout, - end_api_call=_event_end_api_call, - )) if cfg.rs_jupyter_migrate: sirepo.events.register(PKDict( github_authorized=_event_github_authorized, )) + sirepo.events.register(PKDict( + auth_logout=_event_auth_logout, + end_api_call=_event_end_api_call, + )) def _event_auth_logout(kwargs): diff --git a/sirepo/sim_oauth/__init__.py b/sirepo/sim_oauth/__init__.py new file mode 100644 index 0000000000..c6e4228b13 --- /dev/null +++ b/sirepo/sim_oauth/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +u"""Type agnostic OAuth operations + +:copyright: Copyright (c) 2016 RadiaSoft LLC. All Rights Reserved. +:license: http://www.apache.org/licenses/LICENSE-2.0.html +""" +import sirepo.util + +def import_module(type_or_data): + + """Load the simulation_type module + + Args: + type_or_data (str or dict): simulation type or description + Returns: + module: simulation type module instance + """ + return sirepo.util.import_submodule('sim_oauth', type_or_data) diff --git a/sirepo/sim_oauth/flash.py b/sirepo/sim_oauth/flash.py new file mode 100644 index 0000000000..8624a83aba --- /dev/null +++ b/sirepo/sim_oauth/flash.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +u"""Oauth API's for flash sim + +:copyright: Copyright (c) 2020 RadiaSoft LLC. All Rights Reserved. +:license: http://www.apache.org/licenses/LICENSE-2.0.html +""" +from pykern import pkconfig +from pykern.pkcollections import PKDict +from pykern.pkdebug import pkdp +from sirepo import api_perm +import datetime +import sirepo.api +import sirepo.auth +import sirepo.auth_db +import sirepo.auth_role +import sirepo.oauth +import sirepo.srtime +import sirepo.util + +cfg = None + +_SIM_TYPE = 'flash' + +class API(sirepo.api.Base): + + @api_perm.require_user + def api_simOauthFlashAuthorized(self): + o, _ = sirepo.oauth.check_authorized_callback() + i = PKDict(o.get(cfg.info_url).json()) +#TODO(robnagler) should this not raise forbidden? + assert i.status == cfg.info_valid_user, \ + f'unexpected status in info={i} expect={cfg.info_valid_user}' + sirepo.auth_db.UserRole.add_role_or_update_expiration( + sirepo.auth.logged_in_user(), + sirepo.auth_role.for_sim_type(_SIM_TYPE), + expiration=datetime.datetime.fromtimestamp(PKDict(o.token).expires_at), + ) + raise sirepo.util.Redirect(_SIM_TYPE) + + +def init_apis(): + global cfg + cfg = pkconfig.init( + authorize_url=('https://flash.rochester.edu/id/oauth2/auth', str, 'url to redirect to for authorization'), + callback_uri=(None, str, 'Flash callback URI (defaults to api_simOauthFlashAuthorized)'), + info_valid_user=pkconfig.Required(str, 'valid user status code'), + info_url=('https://flash.rochester.edu/id/userinfo', str, 'to request user data'), + key=pkconfig.Required(str, 'OAuth key'), + scope=('openid', str, 'scope of data to request about user'), + secret=pkconfig.Required(str, 'OAuth secret'), + token_endpoint=('https://flash.rochester.edu/id/oauth2/token', str, 'url for obtaining access token') + ) + cfg.callback_api = 'simOauthFlashAuthorized' diff --git a/sirepo/uri_router.py b/sirepo/uri_router.py index 9aaf8d6ac0..f249f45b36 100644 --- a/sirepo/uri_router.py +++ b/sirepo/uri_router.py @@ -142,6 +142,7 @@ def _api_modules(): for n in _api_modules(): register_api_module(importlib.import_module('sirepo.' + n)) _register_sim_api_modules() + _register_sim_oauth_modules(feature_config.cfg().proprietary_oauth_sim_types) _init_uris(app, simulation_db, feature_config.cfg().sim_types) sirepo.http_request.init( @@ -163,13 +164,11 @@ def _api_modules(): ) -def is_sim_type_required_for_api(): +def maybe_sim_type_required_for_api(): a = sirepo.srcontext.get(_API_ATTR) if not a: return True - return sirepo.api_auth.is_sim_type_required_for_api( - a.func if isinstance(a, _Route) else _api_to_route[a].func - ) + return sirepo.api_auth.maybe_sim_type_required_for_api(a.func) def register_api_module(module=None): @@ -221,7 +220,8 @@ def uri_for_api(api_name, params=None, external=True): if params is None: params = PKDict() r = _api_to_route[api_name] - res = (flask.url_for('_dispatch_empty', _external=external) + r.base_uri).rstrip('/') + s = flask.url_for('_dispatch_empty', _external=external) if external else '/' + res = (s + r.base_uri).rstrip('/') for p in r.params: if p.name in params: v = params[p.name] @@ -352,15 +352,23 @@ def _init_uris(app, simulation_db, sim_types): def _register_sim_api_modules(): + _register_sim_modules_from_package('sim_api') + + +def _register_sim_modules_from_package(package, valid_sim_types=None): for _, n, ispkg in pkgutil.iter_modules( - [os.path.dirname(sirepo.sim_api.__file__)], + [os.path.dirname(importlib.import_module(f'sirepo.{package}').__file__)], ): if ispkg: continue - if not sirepo.template.is_sim_type(n): + if not sirepo.template.is_sim_type(n) or \ + (valid_sim_types is not None and n not in valid_sim_types): pkdc(f'not adding apis for unknown sim_type={n}') continue - register_api_module(importlib.import_module(f'sirepo.sim_api.{n}')) + register_api_module(importlib.import_module(f'sirepo.{package}.{n}')) + +def _register_sim_oauth_modules(oauth_sim_types): + _register_sim_modules_from_package('sim_oauth', oauth_sim_types) @contextlib.contextmanager diff --git a/tests/auth/email2_data/db/auth.db b/tests/auth/email2_data/db/auth.db index 9f2aa7d324..a50b46c93b 100644 Binary files a/tests/auth/email2_data/db/auth.db and b/tests/auth/email2_data/db/auth.db differ diff --git a/tests/role_moderation_test.py b/tests/auth_role_moderation_test.py similarity index 99% rename from tests/role_moderation_test.py rename to tests/auth_role_moderation_test.py index 9939d79e2b..4ba10773fa 100644 --- a/tests/role_moderation_test.py +++ b/tests/auth_role_moderation_test.py @@ -10,6 +10,7 @@ import os import pytest + def setup_module(module): os.environ.update( SIREPO_FEATURE_CONFIG_MODERATED_SIM_TYPES='myapp', diff --git a/tests/pkcli/roles_test.py b/tests/pkcli/roles_test.py index b1f2cfb9de..88201c17a5 100644 --- a/tests/pkcli/roles_test.py +++ b/tests/pkcli/roles_test.py @@ -41,7 +41,6 @@ def _check_file(exists=True): _proprietary_file = 'flash.tar.gz' fc = auth_fc fc.sr_email_register('a@b.c', sim_type='flash') - r = fc.sr_post('listSimulations', {'simulationType': fc.sr_sim_type}, raw_response=True) pkunit.pkeq(403, r.status_code)