diff --git a/default.nix b/default.nix index eb251ff..ddbca9b 100644 --- a/default.nix +++ b/default.nix @@ -4,8 +4,6 @@ ...}: let - nixpkgs_unstable = import {}; - urlobject = with python.pkgs; buildPythonPackage rec { pname = "URLObject"; version = "2.4.3"; @@ -16,9 +14,20 @@ let doCheck = true; propagatedBuildInputs = [ ]; - }; + python_attrs = with python.pkgs; buildPythonPackage rec { + pname = "attrs"; + version = "21.4.0"; + src = fetchPypi { + inherit pname version; + sha256 = "626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"; + }; + #doCheck = true; + doCheck = false; + propagatedBuildInputs = [ + ]; + }; flask-dance = with python.pkgs; buildPythonPackage rec { pname = "Flask-Dance"; @@ -37,6 +46,7 @@ let ]; checkInputs = [ pytest + nose pytest-mock responses freezegun @@ -89,7 +99,7 @@ let propagatedBuildInputs = [ urllib3 python-dateutil - nixpkgs_unstable.python39Packages.attrs + python_attrs httpx ]; }; @@ -110,6 +120,7 @@ in cryptography blinker ory-hydra-client + authlib gunicorn @@ -127,6 +138,8 @@ in pytest-mypy flask_testing tox + + nose mypy ]; } diff --git a/lenticular_cloud/app.py b/lenticular_cloud/app.py index 7f3dacb..7c2afad 100644 --- a/lenticular_cloud/app.py +++ b/lenticular_cloud/app.py @@ -1,18 +1,18 @@ from flask.app import Flask from flask import g, redirect, request from flask.helpers import url_for -from flask_babel import Babel from flask_login import LoginManager import time import subprocess -import ory_hydra_client as hydra -import ory_hydra_client.api.admin_api as hydra_admin_api +from ory_hydra_client import Client import os from ldap3 import Connection, Server, ALL from . import model from .pki import Pki +from .hydra import hydra_service +from .translations import init_babel def get_git_hash(): @@ -22,12 +22,7 @@ def get_git_hash(): return '' -def init_oauth2(app): - pass - - - -def create_app(): +def create_app() -> Flask: name = "lenticular_cloud" app = Flask(name, template_folder='template') app.config.from_pyfile('application.cfg') @@ -47,35 +42,31 @@ def create_app(): with app.app_context(): db.create_all() - app.babel = Babel(app) - init_oauth2(app) + init_babel(app) app.login_manager = LoginManager(app) #init hydra admin api - hydra_config = hydra.Configuration( - host=app.config['HYDRA_ADMIN_URL'], - username=app.config['HYDRA_ADMIN_USER'], - password=app.config['HYDRA_ADMIN_PASSWORD']) - hydra_client = hydra.ApiClient(hydra_config) - app.hydra_api = hydra_admin_api.AdminApi(hydra_client) +# hydra_config = hydra.Configuration( +# host=app.config['HYDRA_ADMIN_URL'], +# username=app.config['HYDRA_ADMIN_USER'], +# password=app.config['HYDRA_ADMIN_PASSWORD']) + hydra_service.set_hydra_client(Client(base_url=app.config['HYDRA_ADMIN_URL'])) - from .views import auth_views, frontend_views, init_login_manager, api_views, pki_views, admin_views + from .views import auth_views, frontend_views, init_login_manager, api_views, pki_views, admin_views, oauth2_views init_login_manager(app) + #oauth2.init_app(app) app.register_blueprint(auth_views) app.register_blueprint(frontend_views) app.register_blueprint(api_views) app.register_blueprint(pki_views) app.register_blueprint(admin_views) + app.register_blueprint(oauth2_views) @app.before_request def befor_request(): request_start_time = time.time() g.request_time = lambda: "%.5fs" % (time.time() - request_start_time) - from .translations import init_babel - - init_babel(app) - app.lenticular_services = {} for service_name, service_config in app.config['LENTICULAR_CLOUD_SERVICES'].items(): app.lenticular_services[service_name] = model.Service.from_config(service_name, service_config) diff --git a/lenticular_cloud/cli.py b/lenticular_cloud/cli.py index 8b26644..9b4da41 100644 --- a/lenticular_cloud/cli.py +++ b/lenticular_cloud/cli.py @@ -44,6 +44,7 @@ def entry_point(): app = create_app() if args.func == cli_run: cli_run(app,args) + return with app.app_context(): args.func(args) diff --git a/lenticular_cloud/form/admin.py b/lenticular_cloud/form/admin.py index 13936be..70b4952 100644 --- a/lenticular_cloud/form/admin.py +++ b/lenticular_cloud/form/admin.py @@ -6,29 +6,53 @@ from wtforms import StringField, SubmitField, TextField, \ SelectField, Form as NoCsrfForm, SelectMultipleField from wtforms.fields.html5 import URLField -from wtforms.fields import FormField +from wtforms.fields import FormField, SelectMultipleField from .base import FieldList +class SerilizedSelectField(SelectMultipleField): + + def process_data(self, value): + try: + self.data = ','.join(list(self.coerce(v) for v in value)) + except (ValueError, TypeError): + self.data = None + + def process_formdata(self, valuelist): + try: + self.data = ','.join(list(self.coerce(x) for x in valuelist)) + except ValueError as exc: + raise ValueError( + self.gettext( + "Invalid choice(s): one or more data inputs could not be coerced." + ) + ) from exc + + def populate_obj(self, obj, name) -> None: + setattr(obj, name, ','.join(self.data)) + + + class OAuth2ClientForm(FlaskForm): client_id = StringField(gettext('client_id') ) client_name = StringField(gettext('client_name')) client_uri = URLField(gettext('client_uri')) client_secret = PasswordField(gettext('client_secret')) logo_uri = URLField(gettext('logo_uri')) - redirect_uris = FieldList(FormField(URLField(gettext('logo_uri')))) - #contacts = List[str] - #grant_types = List[str] - #response_types = List[str] + redirect_uris = FieldList(URLField(gettext('redirect_uri')), min_entries=1) + contacts = FieldList(StringField('contacts')) + grant_types = SelectMultipleField('grant_types',choices=[(x, x) for x in ['authorization_code', 'refresh_token', 'implicit']]) + response_types = SelectMultipleField('repsonse_type',choices=[(x, x) for x in ['code token', 'code', 'id_token']]) scope = StringField(gettext('scope')) subject_type = StringField(gettext('subject_type')) - token_endpoint_auth_method = StringField(gettext('token_endpoint_auth_method')) + token_endpoint_auth_method = SelectField('token_endpoint_auth_method', choices=[(x, x) for x in ['client_secret_basic', 'client_secret_post']]) userinfo_signed_response_alg = StringField(gettext('userinfo_signed_response_alg')) client_secret_expires_at = IntegerField('client_secret_expires_at') #allowed_cross_origins = Array + contacts = FieldList(StringField('contacts')) #audience = List[str] diff --git a/lenticular_cloud/form/base.py b/lenticular_cloud/form/base.py index 9660cee..7d30d7c 100644 --- a/lenticular_cloud/form/base.py +++ b/lenticular_cloud/form/base.py @@ -5,6 +5,7 @@ from ..model import db class FieldList(WTFFieldList): def __init__(self, *args, **kwargs): + self.modify = kwargs.pop("modify", True) super().__init__(*args, **kwargs) def get_template(self) -> Field: @@ -16,7 +17,6 @@ class FieldList(WTFFieldList): class ModelFieldList(FieldList): def __init__(self, *args, **kwargs): self.model = kwargs.pop("model", None) - self.modify = kwargs.pop("modify", True) super(ModelFieldList, self).__init__(*args, **kwargs) if not self.model: raise ValueError("ModelFieldList requires model to be set") diff --git a/lenticular_cloud/hydra.py b/lenticular_cloud/hydra.py new file mode 100644 index 0000000..6c8be47 --- /dev/null +++ b/lenticular_cloud/hydra.py @@ -0,0 +1,31 @@ +from ory_hydra_client import Client +from typing import Optional + + + +class HydraService: + + def __init__(self): + self._hydra_client = None # type: Optional[Client] + self._oauth_client = None # type: Optional[Client] + + @property + def hydra_client(self) -> Client: + if self._hydra_client is None: + raise RuntimeError('need to init client first') + return self._hydra_client + + def set_hydra_client(self, client: Client) -> None: + self._hydra_client = client + + @property + def oauth_client(self) -> Client: + if self._oauth_client is None: + raise RuntimeError('need to init client first') + return self._oauth_client + + def set_oauth_client(self, client: Client) -> None: + self._hydra_client = client + + +hydra_service = HydraService() diff --git a/lenticular_cloud/template/base.html.j2 b/lenticular_cloud/template/base.html.j2 index 82be352..b2e97c4 100644 --- a/lenticular_cloud/template/base.html.j2 +++ b/lenticular_cloud/template/base.html.j2 @@ -145,7 +145,7 @@ {% if field.modify %} @@ -174,6 +174,8 @@ {{ render_password_field(f, **kwargs) }} {% elif f.type == 'DecimalRangeField' %} {{ render_range_field(f, **kwargs) }} + {% elif f.type == 'URLField' %} + {{ render_field(f, **kwargs) }} {% elif f.type == 'SubmitField' %} {{ render_submit_field(f, **kwargs) }} {% elif f.type == 'FormField' %} diff --git a/lenticular_cloud/template/skelet.html.j2 b/lenticular_cloud/template/skelet.html.j2 index eed1b75..f80e61e 100644 --- a/lenticular_cloud/template/skelet.html.j2 +++ b/lenticular_cloud/template/skelet.html.j2 @@ -34,6 +34,26 @@ diff --git a/lenticular_cloud/translations/__init__.py b/lenticular_cloud/translations/__init__.py index 28ba73c..145b208 100644 --- a/lenticular_cloud/translations/__init__.py +++ b/lenticular_cloud/translations/__init__.py @@ -1,4 +1,5 @@ from flask import g, request, Flask +from flask_babel import Babel from flask_login import current_user from typing import Optional from lenticular_cloud.model import db, User @@ -8,38 +9,41 @@ LANGUAGES = { 'de': 'Deutsch' } +babel = Babel() -def init_babel(app: Flask) -> None: - babel = app.babel - @babel.localeselector - def get_locale() -> str: - # if a user is logged in, use the locale from the user settings - user = current_user # type: Optional[User] - return 'de' +@babel.localeselector +def get_locale() -> str: + # if a user is logged in, use the locale from the user settings + user = current_user # type: Optional[User] + return 'de' - # prefer lang argument - if 'lang' in request.args: - lang = request.args['lang'] # type: str - if lang in LANGUAGES: - if not isinstance(user, User): - return lang - user.locale = lang - db.session.commit() + # prefer lang argument + if 'lang' in request.args: + lang = request.args['lang'] # type: str + if lang in LANGUAGES: + if not isinstance(user, User): + return lang + user.locale = lang + db.session.commit() - if isinstance(user, User): - return user.locale - # otherwise try to guess the language from the user accept - # header the browser transmits. We support de/fr/en in this - # example. The best match wins. - return request.accept_languages.best_match(['de']) + if isinstance(user, User): + return user.locale + # otherwise try to guess the language from the user accept + # header the browser transmits. We support de/fr/en in this + # example. The best match wins. + return request.accept_languages.best_match(['de']) - @babel.timezoneselector - def get_timezone() -> Optional[str]: +@babel.timezoneselector +def get_timezone() -> Optional[str]: # user = getattr(g, 'user', None) # if user is not None: # return user.timezone - return None + return None + +def init_babel(app: Flask) -> None: + + babel.init_app(app) @app.context_processor def get_locale_jinja() -> dict: @@ -47,3 +51,4 @@ def init_babel(app: Flask) -> None: return get_locale() return dict(get_locale=get_locale_) + return None diff --git a/lenticular_cloud/views/__init__.py b/lenticular_cloud/views/__init__.py index b2bff10..4196cb4 100644 --- a/lenticular_cloud/views/__init__.py +++ b/lenticular_cloud/views/__init__.py @@ -1,7 +1,8 @@ # pylint: disable=unused-import -from .auth import auth_views -from .frontend import frontend_views, init_login_manager +from .auth import auth_views +from .oauth2 import init_login_manager, oauth2_views +from .frontend import frontend_views from .admin import admin_views from .api import api_views from .pki import pki_views diff --git a/lenticular_cloud/views/admin.py b/lenticular_cloud/views/admin.py index df45368..7c1f3d0 100644 --- a/lenticular_cloud/views/admin.py +++ b/lenticular_cloud/views/admin.py @@ -1,39 +1,43 @@ -import flask +from authlib.integrations.base_client.errors import MissingTokenError from flask import Blueprint, redirect, request, url_for, render_template from flask import current_app, session from flask import jsonify +from flask.typing import ResponseReturnValue from flask_login import current_user, logout_user from oauthlib.oauth2.rfc6749.errors import TokenExpiredError -import ory_hydra_client -from ory_hydra_client.model.o_auth2_client import OAuth2Client +from ory_hydra_client.api.admin import list_o_auth_2_clients, get_o_auth_2_client, update_o_auth_2_client, create_o_auth_2_client +from ory_hydra_client.models import OAuth2Client, GenericError +from typing import Optional import logging from ..model import db, User, UserSignUp -from .frontend import redirect_login +from .oauth2 import redirect_login, oauth2 from ..form.admin import OAuth2ClientForm +from ..hydra import hydra_service admin_views = Blueprint('admin', __name__, url_prefix='/admin') logger = logging.getLogger(__name__) -def before_request(): +def before_request() -> Optional[ResponseReturnValue]: try: - resp = current_app.oauth.session.get('/userinfo') + resp = oauth2.custom.get('/userinfo') data = resp.json() if not current_user.is_authenticated or resp.status_code != 200: return redirect_login() if 'groups' not in data or 'admin' not in data['groups']: return 'Not an admin', 403 - except TokenExpiredError: + except MissingTokenError: return redirect_login() + return None admin_views.before_request(before_request) @admin_views.route('/', methods=['GET', 'POST']) -def index(): +def index() -> ResponseReturnValue: return render_template('admin/index.html.j2') @@ -44,13 +48,13 @@ def users(): @admin_views.route('/registrations', methods=['GET']) -def registrations(): +def registrations() -> ResponseReturnValue: users = UserSignUp.query.all() return render_template('admin/registrations.html.j2', users=users) @admin_views.route('/registration/', methods=['DELETE']) -def registration_delete(registration_id): +def registration_delete(registration_id) -> ResponseReturnValue: user_data = UserSignUp.query.get(registration_id) if user_data is None: return jsonify({}), 404 @@ -60,7 +64,7 @@ def registration_delete(registration_id): @admin_views.route('/registration/', methods=['PUT']) -def registration_accept(registration_id): +def registration_accept(registration_id) -> ResponseReturnValue: user_data = UserSignUp.query.get(registration_id) #create user user = User.new(user_data) @@ -72,16 +76,15 @@ def registration_accept(registration_id): @admin_views.route('/clients') -def clients(): - clients = current_app.hydra_api.list_o_auth2_clients() +def clients() -> ResponseReturnValue: + clients = list_o_auth_2_clients.sync(_client=hydra_service.hydra_client) return render_template('admin/clients.html.j2', clients=clients) @admin_views.route('/client/', methods=['GET', 'POST']) -def client(client_id: str): +def client(client_id: str) -> ResponseReturnValue: - try: - client = current_app.hydra_api.get_o_auth2_client(client_id) - except ory_hydra_client.ApiException as e: + client = get_o_auth_2_client.sync(client_id, _client=hydra_service.hydra_client) + if client is None or isinstance( client, GenericError): logger.error(f"oauth2 client not found with id: '{client_id}'") return 'client not found', 404 @@ -89,9 +92,8 @@ def client(client_id: str): if form.validate_on_submit(): form.populate_obj(client) - try: - client = current_app.hydra_api.update_o_auth2_client(client_id, client) - except ory_hydra_client.ApiException as e: + client = update_o_auth_2_client.sync(id=client_id ,json_body=client, _client=hydra_service.hydra_client) + if client is None or isinstance(client, GenericError): logger.error(f"oauth2 client update failed: '{client_id}'") return 'client update failed', 500 @@ -101,7 +103,7 @@ def client(client_id: str): @admin_views.route('/client_new', methods=['GET','POST']) -def client_new(): +def client_new() -> ResponseReturnValue: client = OAuth2Client() @@ -109,9 +111,8 @@ def client_new(): if form.validate_on_submit(): form.populate_obj(client) - try: - client = current_app.hydra_api.create_o_auth2_client(client) - except ory_hydra_client.ApiException as e: + resp_client = create_o_auth_2_client.sync(json_body=client, _client=hydra_service.hydra_client) + if resp_client is None: logger.error(f"oauth2 client update failed: '{client.client_id}'") return 'internal error', 500 return redirect(url_for('.client', client_id=client.client_id)) diff --git a/lenticular_cloud/views/api.py b/lenticular_cloud/views/api.py index 11cf849..a5e5176 100644 --- a/lenticular_cloud/views/api.py +++ b/lenticular_cloud/views/api.py @@ -4,26 +4,32 @@ from flask import current_app, session from flask import jsonify from flask.helpers import make_response from flask.templating import render_template +from flask.typing import ResponseReturnValue from flask import Blueprint, render_template, request, url_for import logging -import requests from ..model import User from ..auth_providers import LdapAuthProvider +from ..hydra import hydra_service +from ory_hydra_client.api.admin import introspect_o_auth_2_token +from ory_hydra_client.models import GenericError api_views = Blueprint('api', __name__, url_prefix='/api') @api_views.route('/users', methods=['GET']) -def user_list(): +def user_list() -> ResponseReturnValue: if 'authorization' not in request.headers: return '', 403 token = request.headers['authorization'].replace('Bearer ', '') - token_info = current_app.hydra_api.introspect_o_auth2_token(token=token) + token_info = introspect_o_auth_2_token.sync(_client=hydra_service.hydra_client) - if 'lc_i_userlist' not in token_info.scope.split(' '): + if token_info is None or isinstance(token_info, GenericError): + return 'internal errror', 500 + + if not isinstance(token_info.scope, str) or 'lc_i_userlist' not in token_info.scope.split(' '): return '', 403 return jsonify([ diff --git a/lenticular_cloud/views/auth.py b/lenticular_cloud/views/auth.py index c35725d..e59fd8c 100644 --- a/lenticular_cloud/views/auth.py +++ b/lenticular_cloud/views/auth.py @@ -1,11 +1,12 @@ +from authlib.integrations.flask_client import OAuth from urllib.parse import urlencode, parse_qs import flask -from flask import Blueprint, redirect -from flask import current_app, session +from flask import Blueprint, redirect, flash, current_app, session from flask.templating import render_template from flask_babel import gettext +from flask.typing import ResponseReturnValue from flask import request, url_for, jsonify from flask_login import login_required, login_user, logout_user, current_user @@ -14,14 +15,17 @@ from urllib.parse import urlparse from base64 import b64decode, b64encode import http import crypt -import ory_hydra_client from datetime import datetime import logging import json +from ory_hydra_client.api.admin import get_consent_request, accept_consent_request, accept_login_request, get_login_request, accept_login_request, accept_logout_request, get_login_request +from ory_hydra_client.models import AcceptLoginRequest, AcceptConsentRequest, ConsentRequestSession, GenericError, ConsentRequestSessionAccessToken, ConsentRequestSessionIdToken +from typing import Optional from ..model import db, User, SecurityUser, UserSignUp from ..form.auth import ConsentForm, LoginForm, RegistrationForm from ..auth_providers import AUTH_PROVIDER_LIST +from ..hydra import hydra_service logger = logging.getLogger(__name__) @@ -29,26 +33,31 @@ logger = logging.getLogger(__name__) auth_views = Blueprint('auth', __name__, url_prefix='/auth') + @auth_views.route('/consent', methods=['GET', 'POST']) -def consent(): +def consent() -> ResponseReturnValue: """Always grant consent.""" # DUMMPY ONLY form = ConsentForm() remember_for = 60*60*24*30 # remember for 30 days - try: - consent_request = current_app.hydra_api.get_consent_request( - request.args['consent_challenge']) - except ory_hydra_client.exceptions.ApiValueError: - logger.info('ory exception - could not fetch user data ApiValueError') - return redirect(url_for('frontend.index')) - except ory_hydra_client.exceptions.ApiException: - logger.exception('ory exception - could not fetch user data') - return redirect(url_for('frontend.index')) + #try: + consent_request = get_consent_request.sync(consent_challenge=request.args['consent_challenge'],_client=hydra_service.hydra_client) - requested_scope = consent_request.requested_scope["value"] - requested_audiences = consent_request.requested_access_token_audience["value"] + if consent_request is None or isinstance( consent_request, GenericError): + return redirect(url_for('frontend.index')) + + +# except ory_hydra_client.exceptions.ApiValueError: +# logger.info('ory exception - could not fetch user data ApiValueError') +# return redirect(url_for('frontend.index')) +# except ory_hydra_client.exceptions.ApiException: +# logger.exception('ory exception - could not fetch user data') +# return redirect(url_for('frontend.index')) + + requested_scope = consent_request.requested_scope + requested_audiences = consent_request.requested_access_token_audience if form.validate_on_submit() or consent_request.skip: user = User.query.get(consent_request.subject) @@ -60,19 +69,25 @@ def consent(): 'groups': [group.name for group in user.groups] } id_token_data = {} - if 'openid' in requested_scope: + if isinstance(requested_scope, list) and 'openid' in requested_scope: id_token_data = token_data - resp = current_app.hydra_api.accept_consent_request( - consent_request.challenge, body={ - 'grant_scope': requested_scope, - 'grant_access_token_audience': requested_audiences, - 'remember': form.data['remember'], - 'remember_for': remember_for, - 'session': { - 'access_token': token_data, - 'id_token': id_token_data - } - }) + access_token=ConsentRequestSessionAccessToken.from_dict(token_data) + id_token=ConsentRequestSessionIdToken.from_dict(id_token_data) + body = AcceptConsentRequest( + grant_scope= requested_scope, + grant_access_token_audience= requested_audiences, + remember= form.data['remember'], + remember_for= remember_for, + session= ConsentRequestSession( + access_token= access_token, + id_token= id_token + ) + ) + resp = accept_consent_request.sync(_client=hydra_service.hydra_client, + json_body=body, + consent_challenge=consent_request.challenge) + if resp is None or isinstance( resp, GenericError): + return 'internal error, could not forward request', 503 return redirect(resp.redirect_to) return render_template( 'auth/consent.html.j2', @@ -83,18 +98,22 @@ def consent(): @auth_views.route('/login', methods=['GET', 'POST']) -def login(): +def login() -> ResponseReturnValue: login_challenge = request.args.get('login_challenge') - try: - login_request = current_app.hydra_api.get_login_request(login_challenge) - except ory_hydra_client.exceptions.ApiException as e: + if login_challenge is None: + return 'login_challenge missing', 400 + login_request = get_login_request.sync(_client=hydra_service.hydra_client, login_challenge=login_challenge) + if login_request is None or isinstance( login_request, GenericError): logger.exception("could not fetch login request") return redirect(url_for('frontend.index')) if login_request.skip: - resp = current_app.hydra_api.accept_login_request( - login_challenge, - body={'subject': login_request.subject}) + resp = accept_login_request.sync(_client=hydra_service.hydra_client, + login_challenge=login_challenge, + json_body=AcceptLoginRequest(subject=login_request.subject)) + if resp is None or isinstance( resp, GenericError): + return 'internal error, could not forward request', 503 + return redirect(resp.redirect_to) form = LoginForm() if form.validate_on_submit(): @@ -110,11 +129,12 @@ def login(): @auth_views.route('/login/auth', methods=['GET', 'POST']) -def login_auth(): +def login_auth() -> ResponseReturnValue: login_challenge = request.args.get('login_challenge') - try: - login_request = current_app.hydra_api.get_login_request(login_challenge) - except ory_hydra_client.exceptions.ApiValueError: + if login_challenge is None: + return 'missing login_challenge, bad request', 400 + login_request = get_login_request.sync(_client=hydra_service.hydra_client, login_challenge=login_challenge) + if login_request is None: return redirect(url_for('frontend.index')) if 'username' not in session: @@ -140,25 +160,31 @@ def login_auth(): subject = user.id user.last_login = datetime.now() db.session.commit() - resp = current_app.hydra_api.accept_login_request( - login_challenge, body={ - 'subject': subject, - 'remember': remember_me, - }) + resp = accept_login_request.sync(_client=hydra_service.hydra_client, + login_challenge=login_challenge, json_body=AcceptLoginRequest( + subject=subject, + remember=remember_me, + )) + if resp is None or isinstance( resp, GenericError): + return 'internal error, could not forward request', 503 return redirect(resp.redirect_to) return render_template('auth/login_auth.html.j2', forms=auth_forms) @auth_views.route("/logout") -def logout(): +def logout() -> ResponseReturnValue: logout_challenge = request.args.get('logout_challenge') + if logout_challenge is None: + return 'invalid request, logout_challenge not set', 400 # TODO confirm - resp = current_app.hydra_api.accept_logout_request(logout_challenge) + resp = accept_logout_request.sync(_client=hydra_service.hydra_client, logout_challenge=logout_challenge) + if resp is None or isinstance( resp, GenericError): + return 'internal error, could not forward request', 503 return redirect(resp.redirect_to) @auth_views.route("/error", methods=["GET"]) -def auth_error(): +def auth_error() -> ResponseReturnValue: error = request.args.get('error') error_description = request.args.get('error_description') diff --git a/lenticular_cloud/views/frontend.py b/lenticular_cloud/views/frontend.py index bade548..6c85ad8 100644 --- a/lenticular_cloud/views/frontend.py +++ b/lenticular_cloud/views/frontend.py @@ -1,109 +1,58 @@ +from authlib.integrations.base_client.errors import MissingTokenError, InvalidTokenError from urllib.parse import urlencode, parse_qs - from flask import Blueprint, redirect, request from flask import current_app from flask import jsonify, session -from flask import render_template, url_for, flash +from flask import render_template, url_for from flask_login import login_user, logout_user, current_user from werkzeug.utils import redirect import logging from datetime import timedelta from base64 import b64decode -from flask_dance.consumer import oauth_authorized -from flask_dance.consumer.base import oauth_before_login -from flask_dance.consumer import OAuth2ConsumerBlueprint +from flask.typing import ResponseReturnValue from oauthlib.oauth2.rfc6749.errors import TokenExpiredError +from ory_hydra_client.api.admin import list_subject_consent_sessions, revoke_consent_sessions +from ory_hydra_client.models import GenericError +from typing import Optional from ..model import db, User, SecurityUser, Totp from ..form.frontend import ClientCertForm, TOTPForm, \ TOTPDeleteForm, PasswordChangeForm from ..auth_providers import LdapAuthProvider +from .oauth2 import redirect_login, oauth2 +from ..hydra import hydra_service frontend_views = Blueprint('frontend', __name__, url_prefix='') logger = logging.getLogger(__name__) -def redirect_login(): - logout_user() - session['next_url'] = request.path - return redirect(url_for('oauth.login', next_url=request.path)) -def before_request(): +def before_request() -> Optional[ResponseReturnValue]: try: - resp = current_app.oauth.session.get('/userinfo') + resp = oauth2.custom.get('/userinfo') if not current_user.is_authenticated or resp.status_code != 200: logger.info('user not logged in redirect') return redirect_login() - except TokenExpiredError: + except MissingTokenError: return redirect_login() + except InvalidTokenError: + return redirect_login() + + return None frontend_views.before_request(before_request) -def init_login_manager(app): - @app.login_manager.user_loader - def user_loader(username): - return User.query_().by_username(username) - - @app.login_manager.request_loader - def request_loader(_request): - pass - - @app.login_manager.unauthorized_handler - def unauthorized(): - redirect_login() - - base_url = app.config['HYDRA_PUBLIC_URL'] - example_blueprint = OAuth2ConsumerBlueprint( - "oauth", __name__, - client_id=app.config['OAUTH_ID'], - client_secret=app.config['OAUTH_SECRET'], - base_url=base_url, - token_url=f"{base_url}/oauth2/token", - authorization_url=f"{base_url}/oauth2/auth", - scope=['openid', 'profile', 'manage'] - ) - app.register_blueprint(example_blueprint, url_prefix="/") - app.oauth = example_blueprint - - @oauth_authorized.connect_via(app.oauth) - def oauth2_logged_in(blueprint, token): - if not token: - flash("Failed to log in.", category="error") - return False - #print(f'debug ---------------{token}') - - resp = blueprint.session.get("/userinfo") - if not resp.ok: - msg = "Failed to fetch user info from hydra." - flash(msg, category="error") - return False - - oauth_info = resp.json() - - db_user = User.query.get(str(oauth_info["sub"])) - - login_user(SecurityUser(db_user.username)) - #flash("Successfully signed in with GitHub.") - - # Since we're manually creating the OAuth model in the database, - # we should return False so that Flask-Dance knows that - # it doesn't have to do it. If we don't return False, the OAuth token - # could be saved twice, or Flask-Dance could throw an error when - # trying to incorrectly save it for us. - return True - - @frontend_views.route('/logout') -def logout(): +def logout() -> ResponseReturnValue: logout_user() return redirect( f'{current_app.config["HYDRA_PUBLIC_URL"]}/oauth2/sessions/logout') @frontend_views.route('/', methods=['GET']) -def index(): +def index() -> ResponseReturnValue: if 'next_url' in session: next_url = session['next_url'] del session['next_url'] @@ -112,7 +61,7 @@ def index(): @frontend_views.route('/client_cert') -def client_cert(): +def client_cert() -> ResponseReturnValue: client_certs = {} for service in current_app.lenticular_services.values(): client_certs[str(service.name)] = \ @@ -125,7 +74,7 @@ def client_cert(): @frontend_views.route('/client_cert//') -def get_client_cert(service_name, serial_number): +def get_client_cert(service_name, serial_number) -> ResponseReturnValue: service = current_app.lenticular_services[service_name] cert = current_app.pki.get_client_cert( current_user, service, serial_number) @@ -137,7 +86,7 @@ def get_client_cert(service_name, serial_number): @frontend_views.route( '/client_cert//', methods=['DELETE']) -def revoke_client_cert(service_name, serial_number): +def revoke_client_cert(service_name, serial_number) -> ResponseReturnValue: service = current_app.lenticular_services[service_name] cert = current_app.pki.get_client_cert( current_user, service, serial_number) @@ -148,7 +97,7 @@ def revoke_client_cert(service_name, serial_number): @frontend_views.route( '/client_cert//new', methods=['GET', 'POST']) -def client_cert_new(service_name): +def client_cert_new(service_name) -> ResponseReturnValue: service = current_app.lenticular_services[service_name] form = ClientCertForm() if form.validate_on_submit(): @@ -177,13 +126,13 @@ def client_cert_new(service_name): @frontend_views.route('/totp') -def totp(): +def totp() -> ResponseReturnValue: delete_form = TOTPDeleteForm() return render_template('frontend/totp.html.j2', delete_form=delete_form) @frontend_views.route('/totp/new', methods=['GET', 'POST']) -def totp_new(): +def totp_new() -> ResponseReturnValue: form = TOTPForm() if form.validate_on_submit(): @@ -203,7 +152,7 @@ def totp_new(): @frontend_views.route('/totp//delete', methods=['GET', 'POST']) -def totp_delete(totp_name): +def totp_delete(totp_name) -> ResponseReturnValue: totp = Totp.query.filter(Totp.name == totp_name).first() db.session.delete(totp) db.session.commit() @@ -213,13 +162,13 @@ def totp_delete(totp_name): @frontend_views.route('/password_change') -def password_change(): +def password_change() -> ResponseReturnValue: form = PasswordChangeForm() return render_template('frontend/password_change.html.j2', form=form) @frontend_views.route('/password_change', methods=['POST']) -def password_change_post(): +def password_change_post() -> ResponseReturnValue: form = PasswordChangeForm() if form.validate(): password_old = str(form.data['password_old']) @@ -230,7 +179,6 @@ def password_change_post(): {'errors': {'password_old': 'Old Password is invalid'}}) resp = current_user.change_password(password_new) if resp: - print(current_user) return jsonify({}) else: return jsonify({'errors': {'internal': 'internal server errror'}}) @@ -238,23 +186,22 @@ def password_change_post(): @frontend_views.route('/oauth2_token') -def oauth2_tokens(): +def oauth2_tokens() -> ResponseReturnValue: - subject = current_app.oauth.session.get('/userinfo').json()['sub'] - consent_sessions = current_app.hydra_api.list_subject_consent_sessions( - subject) - - print(consent_sessions) + subject = oauth2.custom.get('/userinfo').json()['sub'] + consent_sessions = list_subject_consent_sessions.sync(subject=subject, _client=hydra_service.hydra_client) + if consent_sessions is None or isinstance( consent_sessions, GenericError): + return 'internal error, could not fetch sessions', 500 return render_template( 'frontend/oauth2_tokens.html.j2', consent_sessions=consent_sessions) @frontend_views.route('/oauth2_token/', methods=['DELETE']) -def oauth2_token_revoke(client_id: str): - subject = current_app.oauth.session.get('/userinfo').json()['sub'] - current_app.hydra_api.revoke_consent_sessions( - subject, +def oauth2_token_revoke(client_id: str) -> ResponseReturnValue: + subject = oauth2.session.get('/userinfo').json()['sub'] + revoke_consent_sessions.sync( _client=hydra_service.hydra_client, + subject=subject, client=client_id) return jsonify({}) diff --git a/lenticular_cloud/views/oauth2.py b/lenticular_cloud/views/oauth2.py new file mode 100644 index 0000000..dc62593 --- /dev/null +++ b/lenticular_cloud/views/oauth2.py @@ -0,0 +1,87 @@ +from authlib.integrations.flask_client import OAuth +from authlib.integrations.base_client.errors import MismatchingStateError +from flask import Flask, Blueprint, session, request, redirect, url_for +from flask_login import login_user, logout_user, current_user +from flask.typing import ResponseReturnValue +from flask_login import LoginManager +from typing import Optional + +from ..model import User, SecurityUser + +def fetch_token(name: str) -> Optional[dict]: + token = session['token'] + if isinstance(token, dict): + return token + return None + +oauth2 = OAuth(fetch_token=fetch_token) + +oauth2_views = Blueprint('oauth2', __name__, url_prefix='/oauth') + +login_manager = LoginManager() + +def redirect_login() -> ResponseReturnValue: + logout_user() + session['next_url'] = request.path + redirect_uri = url_for('oauth2.authorized', _external=True) + return oauth2.custom.authorize_redirect(redirect_uri) + + +@oauth2_views.route('/authorized') +def authorized() -> ResponseReturnValue: + try: + token = oauth2.custom.authorize_access_token() + except MismatchingStateError: + return redirect(url_for('oauth2.login')) + if token is None: + return 'bad request', 400 + session['token'] = token + userinfo = oauth2.custom.get('/userinfo').json() + db_user = User.query.get(str(userinfo["sub"])) + login_user(SecurityUser(db_user.username)) + + + next_url = request.args.get('next_url') + if next_url is None: + next_url = '/' + return redirect(next_url) + +@oauth2_views.route('login') +def login() -> ResponseReturnValue: + redirect_uri = url_for('.authorized', _external=True) + return oauth2.custom.authorize_redirect(redirect_uri) + + +@login_manager.user_loader +def user_loader(username) -> Optional[User]: + user = User.query_().by_username(username) + if isinstance(user, User): + return user + else: + return None + +@login_manager.request_loader +def request_loader(_request): + pass + +@login_manager.unauthorized_handler +def unauthorized(): + redirect_login() + +def init_login_manager(app: Flask): + + base_url = app.config['HYDRA_PUBLIC_URL'] + oauth2.register( + name="custom", + client_id=app.config['OAUTH_ID'], + client_secret=app.config['OAUTH_SECRET'], + access_token_url=f"{base_url}/oauth2/token", + authorize_url=f"{base_url}/oauth2/auth", + api_base_url=base_url, + + client_kwargs={'scope': ' '.join(['openid', 'profile', 'manage'])} + ) + oauth2.init_app(app) + login_manager.init_app(app) + + diff --git a/libs/README.md b/libs/README.md index 87a4705..4dc62cd 100644 --- a/libs/README.md +++ b/libs/README.md @@ -1,4 +1,6 @@ ## regenerate lib -openapi-python-client generate --path ../specs/hydra.yaml --meta setup --custom-template-path ../specs/api-template +```sh +openapi-python-client generate --path ../specs/hydra.yaml --meta setup --custom-template-path ../specs/api_template/ +``` diff --git a/mypy.ini b/mypy.ini index e717bea..c061cfa 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4,3 +4,5 @@ warn_return_any = True warn_unused_configs = True ignore_missing_imports = True +follow_imports_for_stubs= True +files=lenticular_cloud/**/*.py diff --git a/requirements-dev.txt b/requirements-dev.txt index c47de40..a3e77d4 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1 +1,2 @@ flask-debug +types-python-dateutil diff --git a/requirements.txt b/requirements.txt index 3cf88a7..f6d407f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,6 +15,6 @@ cryptography requests requests_oauthlib blinker -ory-hydra-client +./libs/ory-hydra-client