diff --git a/application.cfg b/application.cfg index 31be6c7..3aec090 100644 --- a/application.cfg +++ b/application.cfg @@ -6,6 +6,7 @@ PREFERRED_URL_SCHEME = 'https' DATA_FOLDER = "./data" SQLALCHEMY_DATABASE_URI = f'sqlite:///{DATA_FOLDER}/db.sqlite' +SQLALCHEMY_TRACK_MODIFICATIONS=False LDAP_URL = 'ldaps://ldap.example.org' LDAP_BASE_DN = 'dc=example,dc=com' @@ -17,6 +18,15 @@ PKI_PATH = f'{DATA_FOLDER}/pki' DOMAIN = 'example.com' SERVER_NAME = f'account.{ DOMAIN }:9090' +HYDRA_REQUEST_TIMEOUT_SECONDS = 3 +HYDRA_ADMIN_URL = 'http://127.0.0.1:4445' +HYDRA_PUBLIC_URL = 'http://127.0.0.1:4444' +SUBJECT_PREFIX = 'something random' + +OAUTH_ID = 'identiy_provider' +OAUTH_SECRET = 'ThisIsNotSafe' + + LENTICULAR_CLOUD_SERVICES = { 'jabber': { diff --git a/lenticular_cloud/app.py b/lenticular_cloud/app.py index 7028463..9b2fad6 100644 --- a/lenticular_cloud/app.py +++ b/lenticular_cloud/app.py @@ -1,16 +1,13 @@ from flask.app import Flask -from flask import g, redirect +from flask import g, redirect, request from flask.helpers import url_for from jwkest.jwk import RSAKey, rsa_load from flask_babel import Babel from flask_login import LoginManager import time import subprocess +import ory_hydra_client as hydra -from pyop.authz_state import AuthorizationState -from pyop.provider import Provider -from pyop.subject_identifier import HashBasedSubjectIdentifierFactory -from pyop.userinfo import Userinfo as _Userinfo from ldap3 import Connection, Server, ALL from . import model @@ -23,112 +20,13 @@ def get_git_hash(): except Exception: return '' -def init_oidc_provider(app): - with app.app_context(): - issuer = url_for('frontend.index')[:-1] - authentication_endpoint = url_for('oidc_provider.authentication_endpoint') - jwks_uri = url_for('oidc_provider.jwks_uri') - token_endpoint = url_for('oidc_provider.token_endpoint') - userinfo_endpoint = url_for('oidc_provider.userinfo_endpoint') - registration_endpoint = url_for('oidc_provider.registration_endpoint') - end_session_endpoint = url_for('auth.logout') - configuration_information = { - 'issuer': issuer, - 'authorization_endpoint': authentication_endpoint, - 'jwks_uri': jwks_uri, - 'token_endpoint': token_endpoint, - 'userinfo_endpoint': userinfo_endpoint, - 'registration_endpoint': registration_endpoint, - 'end_session_endpoint': end_session_endpoint, - 'scopes_supported': ['openid', 'profile'], - 'response_types_supported': ['code', 'code id_token', 'code token', 'code id_token token'], # code and hybrid - 'response_modes_supported': ['query', 'fragment'], - 'grant_types_supported': ['authorization_code', 'implicit'], - 'subject_types_supported': ['pairwise'], - 'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post'], - 'claims_parameter_supported': True - } +def init_oauth2(app): + pass - from .model_db import db, Client, AuthzCode, AccessToken, RefreshToken, SubjectIdentifier - from .model import User - import json - db.init_app(app) - with app.app_context(): - db.create_all() - class SqlAlchemyWrapper(object): - def __init__(self, cls): - self._cls = cls - pass - def __getitem__(self, item): - o = self._cls.query.get(item) - if o is not None: - return json.loads(o.value) - else: - raise KeyError() - - def __setitem__(self, item, value): - o = self._cls.query.get(item) - if o is None: - o = self._cls(key=item) - db.session.add(o) - o.value = json.dumps(value) - db.session.commit() - - def items(self): - aa = self._cls.query.all() - return [(a.key, json.loads(a.value)) for a in aa] - - def __contains__(self, item): - return self._cls.query.get(item) is not None - - class Userinfo(_Userinfo): - def __init__(self): - pass - - def __getitem__(self, item): - return User.query().by_username(item) - - def __contains__(self, item): - return User.query().by_username(item) is not None - - def get_claims_for(self, user_id, requested_claims): - user = self[user_id] - print(f'user {user.username} {requested_claims}') - claims = {} - for claim in requested_claims: - if claim == 'name': - claims[claim] = str(user.username) - elif claim == 'email': - claims[claim] = str(user.mail) - elif claim == 'email_verified': - claims[claim] = True - else: - print(f'claim not found {claim}') - return claims - - client_db = SqlAlchemyWrapper(Client) - - userinfo_db = Userinfo() - signing_key = RSAKey(key=rsa_load('signing_key.pem'), alg='RS256') - provider = Provider( - signing_key, - configuration_information, - AuthorizationState( - HashBasedSubjectIdentifierFactory(app.config['SUBJECT_ID_HASH_SALT']), - SqlAlchemyWrapper(AuthzCode), - SqlAlchemyWrapper(AccessToken), - SqlAlchemyWrapper(RefreshToken), - SqlAlchemyWrapper(SubjectIdentifier) - ), - client_db, - userinfo_db) - - return provider - -def oidc_provider_init_app(name=None): +def init_app(name=None): name = name or __name__ app = Flask(name) app.config.from_pyfile('application.cfg') @@ -142,23 +40,31 @@ def oidc_provider_init_app(name=None): model.ldap_conn = app.ldap_conn model.base_dn = app.config['LDAP_BASE_DN'] + from .model_db import db + db.init_app(app) + with app.app_context(): + db.create_all() + app.babel = Babel(app) + init_oauth2(app) app.login_manager = LoginManager(app) - from .views import oidc_provider_views, auth_views, frontend_views, init_login_manager + #init hydra admin api + hydra_config = hydra.Configuration(app.config['HYDRA_ADMIN_URL']) + hydra_client = hydra.ApiClient(hydra_config) + app.hydra_api = hydra.AdminApi(hydra_client) + + from .views import auth_views, frontend_views, init_login_manager, api_views init_login_manager(app) - app.register_blueprint(oidc_provider_views) app.register_blueprint(auth_views) app.register_blueprint(frontend_views) + app.register_blueprint(api_views) @app.before_request def befor_request(): request_start_time = time.time() g.request_time = lambda: "%.5fs" % (time.time() - request_start_time) - # Initialize the oidc_provider after views to be able to set correct urls - app.provider = init_oidc_provider(app) - from .translations import init_babel init_babel(app) diff --git a/lenticular_cloud/auth_providers.py b/lenticular_cloud/auth_providers.py index cf7d735..b5daad7 100644 --- a/lenticular_cloud/auth_providers.py +++ b/lenticular_cloud/auth_providers.py @@ -32,8 +32,12 @@ class LdapAuthProvider(AuthProvider): @staticmethod def check_auth(user, form): + return LdapAuthProvider.check_auth_internal(user, form.data['password']) + + @staticmethod + def check_auth_internal(user, password): server = Server(current_app.config['LDAP_URL']) - ldap_conn = Connection(server, user.entry_dn, form.data['password']) + ldap_conn = Connection(server, user.entry_dn, password) try: return ldap_conn.bind() except LDAPException: diff --git a/lenticular_cloud/model.py b/lenticular_cloud/model.py index 075fcf5..d5d13fa 100644 --- a/lenticular_cloud/model.py +++ b/lenticular_cloud/model.py @@ -80,6 +80,9 @@ class EntryBase(object): def __init__(self, clazz): self._class = clazz + def _mapping(self, ldap_object): + return ldap_object + def _query(self, ldap_filter: str): reader = Reader(ldap_conn, self._class.get_object_def(), self._class.get_base(), ldap_filter) try: @@ -87,7 +90,7 @@ class EntryBase(object): except LDAPSessionTerminatedByServerError: ldap_conn.bind() reader.search() - return list(reader) + return [self._mapping(entry) for entry in reader] def all(self): return self._query(None) @@ -277,7 +280,7 @@ class User(EntryBase): return self._ldap_object.surname @property - def mail(self): + def email(self): return self._ldap_object.mail @property @@ -297,10 +300,14 @@ class User(EntryBase): return self._totp_list class _query(EntryBase._query): + + def _mapping(self, ldap_object): + return User(ldap_object=ldap_object) + def by_username(self, username) -> 'User': result = self._query('(uid={username:s})'.format(username=escape_filter_chars(username))) if len(result) > 0: - return User(result[0]) + return result[0] else: return None diff --git a/lenticular_cloud/model_db.py b/lenticular_cloud/model_db.py index 36e84d4..fe96434 100644 --- a/lenticular_cloud/model_db.py +++ b/lenticular_cloud/model_db.py @@ -1,8 +1,24 @@ from flask_sqlalchemy import SQLAlchemy, orm +import uuid db = SQLAlchemy() # type: SQLAlchemy +def generate_uuid(): + return str(uuid.uuid4()) + + +class OAuth(db.Model): + token = db.Column(db.Text, primary_key=True) + provider = db.Column(db.Text) + provider_username = db.Column(db.Text) + + +class User(db.Model): + id = db.Column(db.String(length=36), primary_key=True, default=generate_uuid) + username = db.Column(db.String, unique=True) + + class Client(db.Model): key = db.Column(db.Text, primary_key=True) value = db.Column(db.Text) diff --git a/lenticular_cloud/views/__init__.py b/lenticular_cloud/views/__init__.py index 51b33e1..060558f 100644 --- a/lenticular_cloud/views/__init__.py +++ b/lenticular_cloud/views/__init__.py @@ -1,5 +1,5 @@ # pylint: disable=unused-import -from .oidc import oidc_provider_views -from .auth import auth_views, init_login_manager -from .frontend import frontend_views +from .auth import auth_views +from .frontend import frontend_views, init_login_manager +from .api import api_views diff --git a/lenticular_cloud/views/api.py b/lenticular_cloud/views/api.py new file mode 100644 index 0000000..901c37f --- /dev/null +++ b/lenticular_cloud/views/api.py @@ -0,0 +1,65 @@ +import flask +from flask import Blueprint, redirect, request +from flask import current_app, session +from flask import jsonify +from flask.helpers import make_response +from flask.templating import render_template +from oic.oic.message import TokenErrorResponse, UserInfoErrorResponse, EndSessionRequest + +from pyop.access_token import AccessToken, BearerTokenError +from pyop.exceptions import InvalidAuthenticationRequest, InvalidAccessToken, InvalidClientAuthentication, OAuthError, \ + InvalidSubjectIdentifier, InvalidClientRegistrationRequest +from pyop.util import should_fragment_encode + +from flask import Blueprint, render_template, request, url_for +from flask_login import login_required, login_user, logout_user +from werkzeug.utils import redirect +import logging +from urllib.parse import urlparse +from base64 import b64decode, b64encode +import ory_hydra_client as hydra +from requests_oauthlib.oauth2_session import OAuth2Session +import requests + +from ..model import User, SecurityUser +from ..model_db import User as DbUser +from ..form.login import LoginForm +from ..auth_providers import LdapAuthProvider + + +api_views = Blueprint('api', __name__, url_prefix='/api') + +@api_views.route('/userinfo', methods=['GET', 'POST']) +def userinfo(): + token = request.headers['authorization'].replace('Bearer ', '') + token_info = current_app.hydra_api.introspect_o_auth2_token(token=token) + + user_db = DbUser.query.get(token_info.sub) + user = User.query().by_username(user_db.username) + + r = requests.get( + "http://127.0.0.1:4444/userinfo", + headers={ + 'authorization': request.headers['authorization']}) + userinfo = r.json() + scopes = token_info.scope.split(' ') + if 'email' in scopes: + userinfo['email'] = str(user.email) + if 'profile' in scopes: + userinfo['username'] = str(user.username) + print(userinfo) + return jsonify(userinfo) + + +@api_views.route('/users', methods=['GET']) +def user_list(): + 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) + + if 'lc_i_userlist' not in token_info.scope.split(' '): + return '', 403 + + return jsonify([{'username': str(user.username), 'email': str(user.email)} + for user in User.query().all()]) diff --git a/lenticular_cloud/views/auth.py b/lenticular_cloud/views/auth.py index 588fd5b..9111c6d 100644 --- a/lenticular_cloud/views/auth.py +++ b/lenticular_cloud/views/auth.py @@ -20,31 +20,44 @@ from werkzeug.utils import redirect import logging from urllib.parse import urlparse from base64 import b64decode, b64encode +import http from ..model import User, SecurityUser +from ..model_db import db, User as DbUser from ..form.login import LoginForm from ..auth_providers import AUTH_PROVIDER_LIST -from .oidc import do_logout -auth_views = Blueprint('auth', __name__, url_prefix='') +auth_views = Blueprint('auth', __name__, url_prefix='/auth') +@auth_views.route('/consent', methods=['GET', 'POST']) +def consent(): + """Always grant consent.""" + # DUMMPY ONLY -def init_login_manager(app): - @app.login_manager.user_loader - def user_loader(username): - return User.query().by_username(username) + remember_me = True + remember_for = 60*60*24*7 # remember for 7 days - @app.login_manager.request_loader - def request_loader(request): - pass - - @app.login_manager.unauthorized_handler - def unauthorized(): - return redirect(url_for('auth.login', next=b64encode(request.url.encode()))) + consent_request = current_app.hydra_api.get_consent_request(request.args['consent_challenge']) + requested_scope = consent_request.requested_scope + resp = current_app.hydra_api.accept_consent_request(consent_request.challenge, body={ + 'grant_scope': requested_scope, + 'remember': remember_me, + 'remember_for': remember_for, + }) + return redirect(resp.redirect_to) @auth_views.route('/login', methods=['GET', 'POST']) def login(): + login_challenge = request.args.get('login_challenge') + login_request = current_app.hydra_api.get_login_request(login_challenge) + + + if login_request.skip: + resp = current_app.hydra_api.accept_login_request( + login_challenge, + body={'subject': login_request.subject}) + return redirect(resp.redirect_to) form = LoginForm() if form.validate_on_submit(): user = User.query().by_username(form.data['name']) @@ -53,13 +66,14 @@ def login(): else: session['user'] = None session['auth_providers'] = [] - return redirect(url_for('auth.login_auth', next=flask.request.args.get('next'))) - + return redirect(url_for('auth.login_auth', login_challenge=login_challenge)) return render_template('frontend/login.html.j2', form=form) @auth_views.route('/login/auth', methods=['GET', 'POST']) def login_auth(): + login_challenge = request.args.get('login_challenge') + login_request = current_app.hydra_api.get_login_request(login_challenge) if 'username' not in session: return redirect(url_for('auth.login')) auth_forms = {} @@ -74,6 +88,21 @@ def login_auth(): auth_forms[auth_provider.get_name()]=form if len(session['auth_providers']) >= 2: + remember_me = True + db_user = DbUser.query.filter(DbUser.username == session['username']).one_or_none() + if db_user is None: + db_user = DbUser(username=session['username']) + db.session.add(db_user) + db.session.commit() + + subject = db_user.id + + resp = current_app.hydra_api.accept_login_request( + login_challenge, body={ + 'subject': subject, + 'remember': remember_me}) + return redirect(resp.redirect_to) + login_user(SecurityUser(session['username'])) # TODO use this var _next = None @@ -89,9 +118,10 @@ def login_auth(): @auth_views.route("/logout") -@login_required def logout(): - logout_user() - do_logout() - return redirect(url_for('.login')) + logout_challenge = request.args.get('logout_challenge') + logout_request = current_app.hydra_api.get_logout_request(logout_challenge) + resp = current_app.hydra_api.accept_logout_request(logout_challenge) + return redirect(resp.redirect_to) + diff --git a/lenticular_cloud/views/frontend.py b/lenticular_cloud/views/frontend.py index 06ff4af..2742335 100644 --- a/lenticular_cloud/views/frontend.py +++ b/lenticular_cloud/views/frontend.py @@ -14,15 +14,19 @@ from pyop.exceptions import InvalidAuthenticationRequest, InvalidAccessToken, In InvalidSubjectIdentifier, InvalidClientRegistrationRequest from pyop.util import should_fragment_encode -from flask import Blueprint, render_template, request, url_for +from flask import Blueprint, render_template, request, url_for, flash from flask_login import login_required, login_user, logout_user, current_user from werkzeug.utils import redirect import logging from datetime import timedelta import pyotp - +from base64 import b64decode, b64encode +from flask_dance.consumer import oauth_authorized +from sqlalchemy.orm.exc import NoResultFound +from flask_dance.consumer import OAuth2ConsumerBlueprint from ..model import User, SecurityUser, Totp +from ..model_db import OAuth, db, User as DbUser from ..form.login import LoginForm from ..form.frontend import ClientCertForm, TOTPForm, TOTPDeleteForm from ..auth_providers import AUTH_PROVIDER_LIST @@ -31,6 +35,81 @@ from ..auth_providers import AUTH_PROVIDER_LIST frontend_views = Blueprint('frontend', __name__, url_prefix='') +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(): + return redirect(url_for('oauth.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 github_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 GitHub." + flash(msg, category="error") + return False + + oauth_info = resp.json() + + db_user = DbUser.query.get(str(oauth_info["sub"])) + oauth_username = db_user.username + + # Find this OAuth token in the database, or create it + query = OAuth.query.filter_by( + provider=blueprint.name, + provider_username=oauth_username, + ) + try: + oauth = query.one() + except NoResultFound: + oauth = OAuth( + provider=blueprint.name, + provider_username=oauth_username, + token=token, + ) + + + login_user(SecurityUser(oauth.provider_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(): + logout_user() + return redirect(f'{current_app.config["HYDRA_PUBLIC_URL"]}/oauth2/sessions/logout') + + @frontend_views.route('/', methods=['GET']) @login_required def index(): diff --git a/requirements.txt b/requirements.txt index 1ca68ad..5647b9f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ -pyop Flask gunicorn flask_babel @@ -12,6 +11,10 @@ python-u2flib-server pyotp cryptography +requests +requests_oauthlib +blinker +ory-hydra-client flask-debug diff --git a/templates/frontend/base.html.j2 b/templates/frontend/base.html.j2 index fe0f9cb..4f2b89c 100644 --- a/templates/frontend/base.html.j2 +++ b/templates/frontend/base.html.j2 @@ -57,7 +57,7 @@ diff --git a/wsgi.py b/wsgi.py index e0ce2e8..1260815 100644 --- a/wsgi.py +++ b/wsgi.py @@ -1,13 +1,12 @@ import logging -from lenticular_cloud.app import oidc_provider_init_app +from lenticular_cloud.app import init_app name = 'oidc_provider' -app = oidc_provider_init_app(name) +app = init_app(name) logging.basicConfig(level=logging.DEBUG) -from flask_debug import Debug -Debug(app) if __name__ == "__main__": - app.run(ssl_context=('https.crt', 'https.key'), debug=True, host='::') + #app.run(ssl_context=('https.crt', 'https.key'), debug=True, host='127.0.0.1') + app.run(debug=True, host='127.0.0.1')