fix lots of stuff, migrate to new client api

This commit is contained in:
TuxCoder 2022-02-19 23:16:13 +01:00
parent 4e9fd55093
commit 710460cc88
19 changed files with 384 additions and 224 deletions

View file

@ -4,8 +4,6 @@
...}: ...}:
let let
nixpkgs_unstable = import <nixpkgs_unstable> {};
urlobject = with python.pkgs; buildPythonPackage rec { urlobject = with python.pkgs; buildPythonPackage rec {
pname = "URLObject"; pname = "URLObject";
version = "2.4.3"; version = "2.4.3";
@ -16,9 +14,20 @@ let
doCheck = true; doCheck = true;
propagatedBuildInputs = [ 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 { flask-dance = with python.pkgs; buildPythonPackage rec {
pname = "Flask-Dance"; pname = "Flask-Dance";
@ -37,6 +46,7 @@ let
]; ];
checkInputs = [ checkInputs = [
pytest pytest
nose
pytest-mock pytest-mock
responses responses
freezegun freezegun
@ -89,7 +99,7 @@ let
propagatedBuildInputs = [ propagatedBuildInputs = [
urllib3 urllib3
python-dateutil python-dateutil
nixpkgs_unstable.python39Packages.attrs python_attrs
httpx httpx
]; ];
}; };
@ -110,6 +120,7 @@ in
cryptography cryptography
blinker blinker
ory-hydra-client ory-hydra-client
authlib
gunicorn gunicorn
@ -127,6 +138,8 @@ in
pytest-mypy pytest-mypy
flask_testing flask_testing
tox tox
nose
mypy mypy
]; ];
} }

View file

@ -1,18 +1,18 @@
from flask.app import Flask from flask.app import Flask
from flask import g, redirect, request from flask import g, redirect, request
from flask.helpers import url_for from flask.helpers import url_for
from flask_babel import Babel
from flask_login import LoginManager from flask_login import LoginManager
import time import time
import subprocess import subprocess
import ory_hydra_client as hydra from ory_hydra_client import Client
import ory_hydra_client.api.admin_api as hydra_admin_api
import os import os
from ldap3 import Connection, Server, ALL from ldap3 import Connection, Server, ALL
from . import model from . import model
from .pki import Pki from .pki import Pki
from .hydra import hydra_service
from .translations import init_babel
def get_git_hash(): def get_git_hash():
@ -22,12 +22,7 @@ def get_git_hash():
return '' return ''
def init_oauth2(app): def create_app() -> Flask:
pass
def create_app():
name = "lenticular_cloud" name = "lenticular_cloud"
app = Flask(name, template_folder='template') app = Flask(name, template_folder='template')
app.config.from_pyfile('application.cfg') app.config.from_pyfile('application.cfg')
@ -47,35 +42,31 @@ def create_app():
with app.app_context(): with app.app_context():
db.create_all() db.create_all()
app.babel = Babel(app) init_babel(app)
init_oauth2(app)
app.login_manager = LoginManager(app) app.login_manager = LoginManager(app)
#init hydra admin api #init hydra admin api
hydra_config = hydra.Configuration( # hydra_config = hydra.Configuration(
host=app.config['HYDRA_ADMIN_URL'], # host=app.config['HYDRA_ADMIN_URL'],
username=app.config['HYDRA_ADMIN_USER'], # username=app.config['HYDRA_ADMIN_USER'],
password=app.config['HYDRA_ADMIN_PASSWORD']) # password=app.config['HYDRA_ADMIN_PASSWORD'])
hydra_client = hydra.ApiClient(hydra_config) hydra_service.set_hydra_client(Client(base_url=app.config['HYDRA_ADMIN_URL']))
app.hydra_api = hydra_admin_api.AdminApi(hydra_client)
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) init_login_manager(app)
#oauth2.init_app(app)
app.register_blueprint(auth_views) app.register_blueprint(auth_views)
app.register_blueprint(frontend_views) app.register_blueprint(frontend_views)
app.register_blueprint(api_views) app.register_blueprint(api_views)
app.register_blueprint(pki_views) app.register_blueprint(pki_views)
app.register_blueprint(admin_views) app.register_blueprint(admin_views)
app.register_blueprint(oauth2_views)
@app.before_request @app.before_request
def befor_request(): def befor_request():
request_start_time = time.time() request_start_time = time.time()
g.request_time = lambda: "%.5fs" % (time.time() - request_start_time) g.request_time = lambda: "%.5fs" % (time.time() - request_start_time)
from .translations import init_babel
init_babel(app)
app.lenticular_services = {} app.lenticular_services = {}
for service_name, service_config in app.config['LENTICULAR_CLOUD_SERVICES'].items(): 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) app.lenticular_services[service_name] = model.Service.from_config(service_name, service_config)

View file

@ -44,6 +44,7 @@ def entry_point():
app = create_app() app = create_app()
if args.func == cli_run: if args.func == cli_run:
cli_run(app,args) cli_run(app,args)
return
with app.app_context(): with app.app_context():
args.func(args) args.func(args)

View file

@ -6,29 +6,53 @@ from wtforms import StringField, SubmitField, TextField, \
SelectField, Form as NoCsrfForm, SelectMultipleField SelectField, Form as NoCsrfForm, SelectMultipleField
from wtforms.fields.html5 import URLField from wtforms.fields.html5 import URLField
from wtforms.fields import FormField from wtforms.fields import FormField, SelectMultipleField
from .base import FieldList 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): class OAuth2ClientForm(FlaskForm):
client_id = StringField(gettext('client_id') ) client_id = StringField(gettext('client_id') )
client_name = StringField(gettext('client_name')) client_name = StringField(gettext('client_name'))
client_uri = URLField(gettext('client_uri')) client_uri = URLField(gettext('client_uri'))
client_secret = PasswordField(gettext('client_secret')) client_secret = PasswordField(gettext('client_secret'))
logo_uri = URLField(gettext('logo_uri')) logo_uri = URLField(gettext('logo_uri'))
redirect_uris = FieldList(FormField(URLField(gettext('logo_uri')))) redirect_uris = FieldList(URLField(gettext('redirect_uri')), min_entries=1)
#contacts = List[str] contacts = FieldList(StringField('contacts'))
#grant_types = List[str] grant_types = SelectMultipleField('grant_types',choices=[(x, x) for x in ['authorization_code', 'refresh_token', 'implicit']])
#response_types = List[str] response_types = SelectMultipleField('repsonse_type',choices=[(x, x) for x in ['code token', 'code', 'id_token']])
scope = StringField(gettext('scope')) scope = StringField(gettext('scope'))
subject_type = StringField(gettext('subject_type')) 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')) userinfo_signed_response_alg = StringField(gettext('userinfo_signed_response_alg'))
client_secret_expires_at = IntegerField('client_secret_expires_at') client_secret_expires_at = IntegerField('client_secret_expires_at')
#allowed_cross_origins = Array #allowed_cross_origins = Array
contacts = FieldList(StringField('contacts'))
#audience = List[str] #audience = List[str]

View file

@ -5,6 +5,7 @@ from ..model import db
class FieldList(WTFFieldList): class FieldList(WTFFieldList):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.modify = kwargs.pop("modify", True)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def get_template(self) -> Field: def get_template(self) -> Field:
@ -16,7 +17,6 @@ class FieldList(WTFFieldList):
class ModelFieldList(FieldList): class ModelFieldList(FieldList):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.model = kwargs.pop("model", None) self.model = kwargs.pop("model", None)
self.modify = kwargs.pop("modify", True)
super(ModelFieldList, self).__init__(*args, **kwargs) super(ModelFieldList, self).__init__(*args, **kwargs)
if not self.model: if not self.model:
raise ValueError("ModelFieldList requires model to be set") raise ValueError("ModelFieldList requires model to be set")

31
lenticular_cloud/hydra.py Normal file
View file

@ -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()

View file

@ -145,7 +145,7 @@
{% if field.modify %} {% if field.modify %}
<template id='{{field.name}}-template' data-fieldlist-name="{{ field.name }}" data-fieldlist-next-id="{{ field | length }}"> <template id='{{field.name}}-template' data-fieldlist-name="{{ field.name }}" data-fieldlist-next-id="{{ field | length }}">
<li class="list-group-item"> <li class="list-group-item">
{{ _render_form(field.get_template(), horizontal=true) }} {{ _render_field(field.get_template(), horizontal=true) }}
<a class="btn btn-danger" onclick="fieldlist.remove(this)">{{ ('Remove') }}</a> <a class="btn btn-danger" onclick="fieldlist.remove(this)">{{ ('Remove') }}</a>
</li> </li>
</template> </template>
@ -174,6 +174,8 @@
{{ render_password_field(f, **kwargs) }} {{ render_password_field(f, **kwargs) }}
{% elif f.type == 'DecimalRangeField' %} {% elif f.type == 'DecimalRangeField' %}
{{ render_range_field(f, **kwargs) }} {{ render_range_field(f, **kwargs) }}
{% elif f.type == 'URLField' %}
{{ render_field(f, **kwargs) }}
{% elif f.type == 'SubmitField' %} {% elif f.type == 'SubmitField' %}
{{ render_submit_field(f, **kwargs) }} {{ render_submit_field(f, **kwargs) }}
{% elif f.type == 'FormField' %} {% elif f.type == 'FormField' %}

View file

@ -34,6 +34,26 @@
<script type="application/javascript" src="/static/main.js?v={{ GIT_HASH }}" ></script> <script type="application/javascript" src="/static/main.js?v={{ GIT_HASH }}" ></script>
<script type="application/javascript" > <script type="application/javascript" >
{% block script_js %}{% endblock %} {% block script_js %}{% endblock %}
window.fieldlist = {
add: function(linkTag) {
var templateTag = linkTag.parentNode.querySelector('template');
var name = templateTag.dataset['fieldlistName'];
var template = templateTag.content.cloneNode(true);
var id = templateTag.dataset['fieldlistNextId']++;
for(let tag of template.querySelectorAll('[name], [id]')){
tag.name = tag.name.replace(/^custom-/, name+'-'+id+'-')
tag.id = tag.id.replace(/^custom-/, name+'-'+id+'-')
};
templateTag.parentNode.querySelector('ul').appendChild(template);
return false;
},
remove: function(base) {
base.parentElement.remove()
return false;
}
};
</script> </script>
</body> </body>
</html> </html>

View file

@ -1,4 +1,5 @@
from flask import g, request, Flask from flask import g, request, Flask
from flask_babel import Babel
from flask_login import current_user from flask_login import current_user
from typing import Optional from typing import Optional
from lenticular_cloud.model import db, User from lenticular_cloud.model import db, User
@ -8,9 +9,8 @@ LANGUAGES = {
'de': 'Deutsch' 'de': 'Deutsch'
} }
babel = Babel()
def init_babel(app: Flask) -> None:
babel = app.babel
@babel.localeselector @babel.localeselector
def get_locale() -> str: def get_locale() -> str:
@ -41,9 +41,14 @@ def init_babel(app: Flask) -> None:
# return user.timezone # return user.timezone
return None return None
def init_babel(app: Flask) -> None:
babel.init_app(app)
@app.context_processor @app.context_processor
def get_locale_jinja() -> dict: def get_locale_jinja() -> dict:
def get_locale_() -> str: def get_locale_() -> str:
return get_locale() return get_locale()
return dict(get_locale=get_locale_) return dict(get_locale=get_locale_)
return None

View file

@ -1,7 +1,8 @@
# pylint: disable=unused-import # pylint: disable=unused-import
from .auth import auth_views from .auth import auth_views
from .frontend import frontend_views, init_login_manager from .oauth2 import init_login_manager, oauth2_views
from .frontend import frontend_views
from .admin import admin_views from .admin import admin_views
from .api import api_views from .api import api_views
from .pki import pki_views from .pki import pki_views

View file

@ -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 Blueprint, redirect, request, url_for, render_template
from flask import current_app, session from flask import current_app, session
from flask import jsonify from flask import jsonify
from flask.typing import ResponseReturnValue
from flask_login import current_user, logout_user from flask_login import current_user, logout_user
from oauthlib.oauth2.rfc6749.errors import TokenExpiredError from oauthlib.oauth2.rfc6749.errors import TokenExpiredError
import ory_hydra_client 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.model.o_auth2_client import OAuth2Client from ory_hydra_client.models import OAuth2Client, GenericError
from typing import Optional
import logging import logging
from ..model import db, User, UserSignUp from ..model import db, User, UserSignUp
from .frontend import redirect_login from .oauth2 import redirect_login, oauth2
from ..form.admin import OAuth2ClientForm from ..form.admin import OAuth2ClientForm
from ..hydra import hydra_service
admin_views = Blueprint('admin', __name__, url_prefix='/admin') admin_views = Blueprint('admin', __name__, url_prefix='/admin')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def before_request(): def before_request() -> Optional[ResponseReturnValue]:
try: try:
resp = current_app.oauth.session.get('/userinfo') resp = oauth2.custom.get('/userinfo')
data = resp.json() data = resp.json()
if not current_user.is_authenticated or resp.status_code != 200: if not current_user.is_authenticated or resp.status_code != 200:
return redirect_login() return redirect_login()
if 'groups' not in data or 'admin' not in data['groups']: if 'groups' not in data or 'admin' not in data['groups']:
return 'Not an admin', 403 return 'Not an admin', 403
except TokenExpiredError: except MissingTokenError:
return redirect_login() return redirect_login()
return None
admin_views.before_request(before_request) admin_views.before_request(before_request)
@admin_views.route('/', methods=['GET', 'POST']) @admin_views.route('/', methods=['GET', 'POST'])
def index(): def index() -> ResponseReturnValue:
return render_template('admin/index.html.j2') return render_template('admin/index.html.j2')
@ -44,13 +48,13 @@ def users():
@admin_views.route('/registrations', methods=['GET']) @admin_views.route('/registrations', methods=['GET'])
def registrations(): def registrations() -> ResponseReturnValue:
users = UserSignUp.query.all() users = UserSignUp.query.all()
return render_template('admin/registrations.html.j2', users=users) return render_template('admin/registrations.html.j2', users=users)
@admin_views.route('/registration/<registration_id>', methods=['DELETE']) @admin_views.route('/registration/<registration_id>', methods=['DELETE'])
def registration_delete(registration_id): def registration_delete(registration_id) -> ResponseReturnValue:
user_data = UserSignUp.query.get(registration_id) user_data = UserSignUp.query.get(registration_id)
if user_data is None: if user_data is None:
return jsonify({}), 404 return jsonify({}), 404
@ -60,7 +64,7 @@ def registration_delete(registration_id):
@admin_views.route('/registration/<registration_id>', methods=['PUT']) @admin_views.route('/registration/<registration_id>', methods=['PUT'])
def registration_accept(registration_id): def registration_accept(registration_id) -> ResponseReturnValue:
user_data = UserSignUp.query.get(registration_id) user_data = UserSignUp.query.get(registration_id)
#create user #create user
user = User.new(user_data) user = User.new(user_data)
@ -72,16 +76,15 @@ def registration_accept(registration_id):
@admin_views.route('/clients') @admin_views.route('/clients')
def clients(): def clients() -> ResponseReturnValue:
clients = current_app.hydra_api.list_o_auth2_clients() clients = list_o_auth_2_clients.sync(_client=hydra_service.hydra_client)
return render_template('admin/clients.html.j2', clients=clients) return render_template('admin/clients.html.j2', clients=clients)
@admin_views.route('/client/<client_id>', methods=['GET', 'POST']) @admin_views.route('/client/<client_id>', methods=['GET', 'POST'])
def client(client_id: str): def client(client_id: str) -> ResponseReturnValue:
try: client = get_o_auth_2_client.sync(client_id, _client=hydra_service.hydra_client)
client = current_app.hydra_api.get_o_auth2_client(client_id) if client is None or isinstance( client, GenericError):
except ory_hydra_client.ApiException as e:
logger.error(f"oauth2 client not found with id: '{client_id}'") logger.error(f"oauth2 client not found with id: '{client_id}'")
return 'client not found', 404 return 'client not found', 404
@ -89,9 +92,8 @@ def client(client_id: str):
if form.validate_on_submit(): if form.validate_on_submit():
form.populate_obj(client) form.populate_obj(client)
try: client = update_o_auth_2_client.sync(id=client_id ,json_body=client, _client=hydra_service.hydra_client)
client = current_app.hydra_api.update_o_auth2_client(client_id, client) if client is None or isinstance(client, GenericError):
except ory_hydra_client.ApiException as e:
logger.error(f"oauth2 client update failed: '{client_id}'") logger.error(f"oauth2 client update failed: '{client_id}'")
return 'client update failed', 500 return 'client update failed', 500
@ -101,7 +103,7 @@ def client(client_id: str):
@admin_views.route('/client_new', methods=['GET','POST']) @admin_views.route('/client_new', methods=['GET','POST'])
def client_new(): def client_new() -> ResponseReturnValue:
client = OAuth2Client() client = OAuth2Client()
@ -109,9 +111,8 @@ def client_new():
if form.validate_on_submit(): if form.validate_on_submit():
form.populate_obj(client) form.populate_obj(client)
try: resp_client = create_o_auth_2_client.sync(json_body=client, _client=hydra_service.hydra_client)
client = current_app.hydra_api.create_o_auth2_client(client) if resp_client is None:
except ory_hydra_client.ApiException as e:
logger.error(f"oauth2 client update failed: '{client.client_id}'") logger.error(f"oauth2 client update failed: '{client.client_id}'")
return 'internal error', 500 return 'internal error', 500
return redirect(url_for('.client', client_id=client.client_id)) return redirect(url_for('.client', client_id=client.client_id))

View file

@ -4,26 +4,32 @@ from flask import current_app, session
from flask import jsonify from flask import jsonify
from flask.helpers import make_response from flask.helpers import make_response
from flask.templating import render_template from flask.templating import render_template
from flask.typing import ResponseReturnValue
from flask import Blueprint, render_template, request, url_for from flask import Blueprint, render_template, request, url_for
import logging import logging
import requests
from ..model import User from ..model import User
from ..auth_providers import LdapAuthProvider 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 = Blueprint('api', __name__, url_prefix='/api')
@api_views.route('/users', methods=['GET']) @api_views.route('/users', methods=['GET'])
def user_list(): def user_list() -> ResponseReturnValue:
if 'authorization' not in request.headers: if 'authorization' not in request.headers:
return '', 403 return '', 403
token = request.headers['authorization'].replace('Bearer ', '') 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 '', 403
return jsonify([ return jsonify([

View file

@ -1,11 +1,12 @@
from authlib.integrations.flask_client import OAuth
from urllib.parse import urlencode, parse_qs from urllib.parse import urlencode, parse_qs
import flask import flask
from flask import Blueprint, redirect from flask import Blueprint, redirect, flash, current_app, session
from flask import current_app, session
from flask.templating import render_template from flask.templating import render_template
from flask_babel import gettext from flask_babel import gettext
from flask.typing import ResponseReturnValue
from flask import request, url_for, jsonify from flask import request, url_for, jsonify
from flask_login import login_required, login_user, logout_user, current_user 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 from base64 import b64decode, b64encode
import http import http
import crypt import crypt
import ory_hydra_client
from datetime import datetime from datetime import datetime
import logging import logging
import json 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 ..model import db, User, SecurityUser, UserSignUp
from ..form.auth import ConsentForm, LoginForm, RegistrationForm from ..form.auth import ConsentForm, LoginForm, RegistrationForm
from ..auth_providers import AUTH_PROVIDER_LIST from ..auth_providers import AUTH_PROVIDER_LIST
from ..hydra import hydra_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -29,26 +33,31 @@ logger = logging.getLogger(__name__)
auth_views = Blueprint('auth', __name__, url_prefix='/auth') auth_views = Blueprint('auth', __name__, url_prefix='/auth')
@auth_views.route('/consent', methods=['GET', 'POST']) @auth_views.route('/consent', methods=['GET', 'POST'])
def consent(): def consent() -> ResponseReturnValue:
"""Always grant consent.""" """Always grant consent."""
# DUMMPY ONLY # DUMMPY ONLY
form = ConsentForm() form = ConsentForm()
remember_for = 60*60*24*30 # remember for 30 days remember_for = 60*60*24*30 # remember for 30 days
try: #try:
consent_request = current_app.hydra_api.get_consent_request( consent_request = get_consent_request.sync(consent_challenge=request.args['consent_challenge'],_client=hydra_service.hydra_client)
request.args['consent_challenge'])
except ory_hydra_client.exceptions.ApiValueError: if consent_request is None or isinstance( consent_request, GenericError):
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')) return redirect(url_for('frontend.index'))
requested_scope = consent_request.requested_scope["value"]
requested_audiences = consent_request.requested_access_token_audience["value"] # 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: if form.validate_on_submit() or consent_request.skip:
user = User.query.get(consent_request.subject) user = User.query.get(consent_request.subject)
@ -60,19 +69,25 @@ def consent():
'groups': [group.name for group in user.groups] 'groups': [group.name for group in user.groups]
} }
id_token_data = {} id_token_data = {}
if 'openid' in requested_scope: if isinstance(requested_scope, list) and 'openid' in requested_scope:
id_token_data = token_data id_token_data = token_data
resp = current_app.hydra_api.accept_consent_request( access_token=ConsentRequestSessionAccessToken.from_dict(token_data)
consent_request.challenge, body={ id_token=ConsentRequestSessionIdToken.from_dict(id_token_data)
'grant_scope': requested_scope, body = AcceptConsentRequest(
'grant_access_token_audience': requested_audiences, grant_scope= requested_scope,
'remember': form.data['remember'], grant_access_token_audience= requested_audiences,
'remember_for': remember_for, remember= form.data['remember'],
'session': { remember_for= remember_for,
'access_token': token_data, session= ConsentRequestSession(
'id_token': id_token_data 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 redirect(resp.redirect_to)
return render_template( return render_template(
'auth/consent.html.j2', 'auth/consent.html.j2',
@ -83,18 +98,22 @@ def consent():
@auth_views.route('/login', methods=['GET', 'POST']) @auth_views.route('/login', methods=['GET', 'POST'])
def login(): def login() -> ResponseReturnValue:
login_challenge = request.args.get('login_challenge') login_challenge = request.args.get('login_challenge')
try: if login_challenge is None:
login_request = current_app.hydra_api.get_login_request(login_challenge) return 'login_challenge missing', 400
except ory_hydra_client.exceptions.ApiException as e: 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") logger.exception("could not fetch login request")
return redirect(url_for('frontend.index')) return redirect(url_for('frontend.index'))
if login_request.skip: if login_request.skip:
resp = current_app.hydra_api.accept_login_request( resp = accept_login_request.sync(_client=hydra_service.hydra_client,
login_challenge, login_challenge=login_challenge,
body={'subject': login_request.subject}) 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) return redirect(resp.redirect_to)
form = LoginForm() form = LoginForm()
if form.validate_on_submit(): if form.validate_on_submit():
@ -110,11 +129,12 @@ def login():
@auth_views.route('/login/auth', methods=['GET', 'POST']) @auth_views.route('/login/auth', methods=['GET', 'POST'])
def login_auth(): def login_auth() -> ResponseReturnValue:
login_challenge = request.args.get('login_challenge') login_challenge = request.args.get('login_challenge')
try: if login_challenge is None:
login_request = current_app.hydra_api.get_login_request(login_challenge) return 'missing login_challenge, bad request', 400
except ory_hydra_client.exceptions.ApiValueError: 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')) return redirect(url_for('frontend.index'))
if 'username' not in session: if 'username' not in session:
@ -140,25 +160,31 @@ def login_auth():
subject = user.id subject = user.id
user.last_login = datetime.now() user.last_login = datetime.now()
db.session.commit() db.session.commit()
resp = current_app.hydra_api.accept_login_request( resp = accept_login_request.sync(_client=hydra_service.hydra_client,
login_challenge, body={ login_challenge=login_challenge, json_body=AcceptLoginRequest(
'subject': subject, subject=subject,
'remember': remember_me, 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 redirect(resp.redirect_to)
return render_template('auth/login_auth.html.j2', forms=auth_forms) return render_template('auth/login_auth.html.j2', forms=auth_forms)
@auth_views.route("/logout") @auth_views.route("/logout")
def logout(): def logout() -> ResponseReturnValue:
logout_challenge = request.args.get('logout_challenge') logout_challenge = request.args.get('logout_challenge')
if logout_challenge is None:
return 'invalid request, logout_challenge not set', 400
# TODO confirm # 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) return redirect(resp.redirect_to)
@auth_views.route("/error", methods=["GET"]) @auth_views.route("/error", methods=["GET"])
def auth_error(): def auth_error() -> ResponseReturnValue:
error = request.args.get('error') error = request.args.get('error')
error_description = request.args.get('error_description') error_description = request.args.get('error_description')

View file

@ -1,109 +1,58 @@
from authlib.integrations.base_client.errors import MissingTokenError, InvalidTokenError
from urllib.parse import urlencode, parse_qs from urllib.parse import urlencode, parse_qs
from flask import Blueprint, redirect, request from flask import Blueprint, redirect, request
from flask import current_app from flask import current_app
from flask import jsonify, session 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 flask_login import login_user, logout_user, current_user
from werkzeug.utils import redirect from werkzeug.utils import redirect
import logging import logging
from datetime import timedelta from datetime import timedelta
from base64 import b64decode from base64 import b64decode
from flask_dance.consumer import oauth_authorized from flask.typing import ResponseReturnValue
from flask_dance.consumer.base import oauth_before_login
from flask_dance.consumer import OAuth2ConsumerBlueprint
from oauthlib.oauth2.rfc6749.errors import TokenExpiredError 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 ..model import db, User, SecurityUser, Totp
from ..form.frontend import ClientCertForm, TOTPForm, \ from ..form.frontend import ClientCertForm, TOTPForm, \
TOTPDeleteForm, PasswordChangeForm TOTPDeleteForm, PasswordChangeForm
from ..auth_providers import LdapAuthProvider from ..auth_providers import LdapAuthProvider
from .oauth2 import redirect_login, oauth2
from ..hydra import hydra_service
frontend_views = Blueprint('frontend', __name__, url_prefix='') frontend_views = Blueprint('frontend', __name__, url_prefix='')
logger = logging.getLogger(__name__) 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: try:
resp = current_app.oauth.session.get('/userinfo') resp = oauth2.custom.get('/userinfo')
if not current_user.is_authenticated or resp.status_code != 200: if not current_user.is_authenticated or resp.status_code != 200:
logger.info('user not logged in redirect') logger.info('user not logged in redirect')
return redirect_login() return redirect_login()
except TokenExpiredError: except MissingTokenError:
return redirect_login() return redirect_login()
except InvalidTokenError:
return redirect_login()
return None
frontend_views.before_request(before_request) 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') @frontend_views.route('/logout')
def logout(): def logout() -> ResponseReturnValue:
logout_user() logout_user()
return redirect( return redirect(
f'{current_app.config["HYDRA_PUBLIC_URL"]}/oauth2/sessions/logout') f'{current_app.config["HYDRA_PUBLIC_URL"]}/oauth2/sessions/logout')
@frontend_views.route('/', methods=['GET']) @frontend_views.route('/', methods=['GET'])
def index(): def index() -> ResponseReturnValue:
if 'next_url' in session: if 'next_url' in session:
next_url = session['next_url'] next_url = session['next_url']
del session['next_url'] del session['next_url']
@ -112,7 +61,7 @@ def index():
@frontend_views.route('/client_cert') @frontend_views.route('/client_cert')
def client_cert(): def client_cert() -> ResponseReturnValue:
client_certs = {} client_certs = {}
for service in current_app.lenticular_services.values(): for service in current_app.lenticular_services.values():
client_certs[str(service.name)] = \ client_certs[str(service.name)] = \
@ -125,7 +74,7 @@ def client_cert():
@frontend_views.route('/client_cert/<service_name>/<serial_number>') @frontend_views.route('/client_cert/<service_name>/<serial_number>')
def get_client_cert(service_name, serial_number): def get_client_cert(service_name, serial_number) -> ResponseReturnValue:
service = current_app.lenticular_services[service_name] service = current_app.lenticular_services[service_name]
cert = current_app.pki.get_client_cert( cert = current_app.pki.get_client_cert(
current_user, service, serial_number) current_user, service, serial_number)
@ -137,7 +86,7 @@ def get_client_cert(service_name, serial_number):
@frontend_views.route( @frontend_views.route(
'/client_cert/<service_name>/<serial_number>', methods=['DELETE']) '/client_cert/<service_name>/<serial_number>', 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] service = current_app.lenticular_services[service_name]
cert = current_app.pki.get_client_cert( cert = current_app.pki.get_client_cert(
current_user, service, serial_number) current_user, service, serial_number)
@ -148,7 +97,7 @@ def revoke_client_cert(service_name, serial_number):
@frontend_views.route( @frontend_views.route(
'/client_cert/<service_name>/new', '/client_cert/<service_name>/new',
methods=['GET', 'POST']) methods=['GET', 'POST'])
def client_cert_new(service_name): def client_cert_new(service_name) -> ResponseReturnValue:
service = current_app.lenticular_services[service_name] service = current_app.lenticular_services[service_name]
form = ClientCertForm() form = ClientCertForm()
if form.validate_on_submit(): if form.validate_on_submit():
@ -177,13 +126,13 @@ def client_cert_new(service_name):
@frontend_views.route('/totp') @frontend_views.route('/totp')
def totp(): def totp() -> ResponseReturnValue:
delete_form = TOTPDeleteForm() delete_form = TOTPDeleteForm()
return render_template('frontend/totp.html.j2', delete_form=delete_form) return render_template('frontend/totp.html.j2', delete_form=delete_form)
@frontend_views.route('/totp/new', methods=['GET', 'POST']) @frontend_views.route('/totp/new', methods=['GET', 'POST'])
def totp_new(): def totp_new() -> ResponseReturnValue:
form = TOTPForm() form = TOTPForm()
if form.validate_on_submit(): if form.validate_on_submit():
@ -203,7 +152,7 @@ def totp_new():
@frontend_views.route('/totp/<totp_name>/delete', methods=['GET', 'POST']) @frontend_views.route('/totp/<totp_name>/delete', methods=['GET', 'POST'])
def totp_delete(totp_name): def totp_delete(totp_name) -> ResponseReturnValue:
totp = Totp.query.filter(Totp.name == totp_name).first() totp = Totp.query.filter(Totp.name == totp_name).first()
db.session.delete(totp) db.session.delete(totp)
db.session.commit() db.session.commit()
@ -213,13 +162,13 @@ def totp_delete(totp_name):
@frontend_views.route('/password_change') @frontend_views.route('/password_change')
def password_change(): def password_change() -> ResponseReturnValue:
form = PasswordChangeForm() form = PasswordChangeForm()
return render_template('frontend/password_change.html.j2', form=form) return render_template('frontend/password_change.html.j2', form=form)
@frontend_views.route('/password_change', methods=['POST']) @frontend_views.route('/password_change', methods=['POST'])
def password_change_post(): def password_change_post() -> ResponseReturnValue:
form = PasswordChangeForm() form = PasswordChangeForm()
if form.validate(): if form.validate():
password_old = str(form.data['password_old']) password_old = str(form.data['password_old'])
@ -230,7 +179,6 @@ def password_change_post():
{'errors': {'password_old': 'Old Password is invalid'}}) {'errors': {'password_old': 'Old Password is invalid'}})
resp = current_user.change_password(password_new) resp = current_user.change_password(password_new)
if resp: if resp:
print(current_user)
return jsonify({}) return jsonify({})
else: else:
return jsonify({'errors': {'internal': 'internal server errror'}}) return jsonify({'errors': {'internal': 'internal server errror'}})
@ -238,23 +186,22 @@ def password_change_post():
@frontend_views.route('/oauth2_token') @frontend_views.route('/oauth2_token')
def oauth2_tokens(): def oauth2_tokens() -> ResponseReturnValue:
subject = current_app.oauth.session.get('/userinfo').json()['sub'] subject = oauth2.custom.get('/userinfo').json()['sub']
consent_sessions = current_app.hydra_api.list_subject_consent_sessions( consent_sessions = list_subject_consent_sessions.sync(subject=subject, _client=hydra_service.hydra_client)
subject) if consent_sessions is None or isinstance( consent_sessions, GenericError):
return 'internal error, could not fetch sessions', 500
print(consent_sessions)
return render_template( return render_template(
'frontend/oauth2_tokens.html.j2', 'frontend/oauth2_tokens.html.j2',
consent_sessions=consent_sessions) consent_sessions=consent_sessions)
@frontend_views.route('/oauth2_token/<client_id>', methods=['DELETE']) @frontend_views.route('/oauth2_token/<client_id>', methods=['DELETE'])
def oauth2_token_revoke(client_id: str): def oauth2_token_revoke(client_id: str) -> ResponseReturnValue:
subject = current_app.oauth.session.get('/userinfo').json()['sub'] subject = oauth2.session.get('/userinfo').json()['sub']
current_app.hydra_api.revoke_consent_sessions( revoke_consent_sessions.sync( _client=hydra_service.hydra_client,
subject, subject=subject,
client=client_id) client=client_id)
return jsonify({}) return jsonify({})

View file

@ -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)

View file

@ -1,4 +1,6 @@
## regenerate lib ## 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/
```

View file

@ -4,3 +4,5 @@
warn_return_any = True warn_return_any = True
warn_unused_configs = True warn_unused_configs = True
ignore_missing_imports = True ignore_missing_imports = True
follow_imports_for_stubs= True
files=lenticular_cloud/**/*.py

View file

@ -1 +1,2 @@
flask-debug flask-debug
types-python-dateutil

View file

@ -15,6 +15,6 @@ cryptography
requests requests
requests_oauthlib requests_oauthlib
blinker blinker
ory-hydra-client
./libs/ory-hydra-client