change to hydra as oauth backend

This commit is contained in:
TuxCoder 2020-05-21 13:20:27 +02:00
parent 157bf65635
commit 38932aef44
12 changed files with 266 additions and 147 deletions

View file

@ -6,6 +6,7 @@ PREFERRED_URL_SCHEME = 'https'
DATA_FOLDER = "./data" DATA_FOLDER = "./data"
SQLALCHEMY_DATABASE_URI = f'sqlite:///{DATA_FOLDER}/db.sqlite' SQLALCHEMY_DATABASE_URI = f'sqlite:///{DATA_FOLDER}/db.sqlite'
SQLALCHEMY_TRACK_MODIFICATIONS=False
LDAP_URL = 'ldaps://ldap.example.org' LDAP_URL = 'ldaps://ldap.example.org'
LDAP_BASE_DN = 'dc=example,dc=com' LDAP_BASE_DN = 'dc=example,dc=com'
@ -17,6 +18,15 @@ PKI_PATH = f'{DATA_FOLDER}/pki'
DOMAIN = 'example.com' DOMAIN = 'example.com'
SERVER_NAME = f'account.{ DOMAIN }:9090' 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 = { LENTICULAR_CLOUD_SERVICES = {
'jabber': { 'jabber': {

View file

@ -1,16 +1,13 @@
from flask.app import Flask from flask.app import Flask
from flask import g, redirect from flask import g, redirect, request
from flask.helpers import url_for from flask.helpers import url_for
from jwkest.jwk import RSAKey, rsa_load from jwkest.jwk import RSAKey, rsa_load
from flask_babel import Babel 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 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 ldap3 import Connection, Server, ALL
from . import model from . import model
@ -23,112 +20,13 @@ def get_git_hash():
except Exception: except Exception:
return '' 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 = { def init_oauth2(app):
'issuer': issuer, pass
'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
}
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): def init_app(name=None):
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):
name = name or __name__ name = name or __name__
app = Flask(name) app = Flask(name)
app.config.from_pyfile('application.cfg') app.config.from_pyfile('application.cfg')
@ -142,23 +40,31 @@ def oidc_provider_init_app(name=None):
model.ldap_conn = app.ldap_conn model.ldap_conn = app.ldap_conn
model.base_dn = app.config['LDAP_BASE_DN'] 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) app.babel = Babel(app)
init_oauth2(app)
app.login_manager = LoginManager(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) init_login_manager(app)
app.register_blueprint(oidc_provider_views)
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.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)
# Initialize the oidc_provider after views to be able to set correct urls
app.provider = init_oidc_provider(app)
from .translations import init_babel from .translations import init_babel
init_babel(app) init_babel(app)

View file

@ -32,8 +32,12 @@ class LdapAuthProvider(AuthProvider):
@staticmethod @staticmethod
def check_auth(user, form): 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']) 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: try:
return ldap_conn.bind() return ldap_conn.bind()
except LDAPException: except LDAPException:

View file

@ -80,6 +80,9 @@ class EntryBase(object):
def __init__(self, clazz): def __init__(self, clazz):
self._class = clazz self._class = clazz
def _mapping(self, ldap_object):
return ldap_object
def _query(self, ldap_filter: str): def _query(self, ldap_filter: str):
reader = Reader(ldap_conn, self._class.get_object_def(), self._class.get_base(), ldap_filter) reader = Reader(ldap_conn, self._class.get_object_def(), self._class.get_base(), ldap_filter)
try: try:
@ -87,7 +90,7 @@ class EntryBase(object):
except LDAPSessionTerminatedByServerError: except LDAPSessionTerminatedByServerError:
ldap_conn.bind() ldap_conn.bind()
reader.search() reader.search()
return list(reader) return [self._mapping(entry) for entry in reader]
def all(self): def all(self):
return self._query(None) return self._query(None)
@ -277,7 +280,7 @@ class User(EntryBase):
return self._ldap_object.surname return self._ldap_object.surname
@property @property
def mail(self): def email(self):
return self._ldap_object.mail return self._ldap_object.mail
@property @property
@ -297,10 +300,14 @@ class User(EntryBase):
return self._totp_list return self._totp_list
class _query(EntryBase._query): class _query(EntryBase._query):
def _mapping(self, ldap_object):
return User(ldap_object=ldap_object)
def by_username(self, username) -> 'User': def by_username(self, username) -> 'User':
result = self._query('(uid={username:s})'.format(username=escape_filter_chars(username))) result = self._query('(uid={username:s})'.format(username=escape_filter_chars(username)))
if len(result) > 0: if len(result) > 0:
return User(result[0]) return result[0]
else: else:
return None return None

View file

@ -1,8 +1,24 @@
from flask_sqlalchemy import SQLAlchemy, orm from flask_sqlalchemy import SQLAlchemy, orm
import uuid
db = SQLAlchemy() # type: SQLAlchemy 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): class Client(db.Model):
key = db.Column(db.Text, primary_key=True) key = db.Column(db.Text, primary_key=True)
value = db.Column(db.Text) value = db.Column(db.Text)

View file

@ -1,5 +1,5 @@
# pylint: disable=unused-import # pylint: disable=unused-import
from .oidc import oidc_provider_views from .auth import auth_views
from .auth import auth_views, init_login_manager from .frontend import frontend_views, init_login_manager
from .frontend import frontend_views from .api import api_views

View file

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

View file

@ -20,31 +20,44 @@ from werkzeug.utils import redirect
import logging import logging
from urllib.parse import urlparse from urllib.parse import urlparse
from base64 import b64decode, b64encode from base64 import b64decode, b64encode
import http
from ..model import User, SecurityUser from ..model import User, SecurityUser
from ..model_db import db, User as DbUser
from ..form.login import LoginForm from ..form.login import LoginForm
from ..auth_providers import AUTH_PROVIDER_LIST 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): remember_me = True
@app.login_manager.user_loader remember_for = 60*60*24*7 # remember for 7 days
def user_loader(username):
return User.query().by_username(username)
@app.login_manager.request_loader consent_request = current_app.hydra_api.get_consent_request(request.args['consent_challenge'])
def request_loader(request): requested_scope = consent_request.requested_scope
pass resp = current_app.hydra_api.accept_consent_request(consent_request.challenge, body={
'grant_scope': requested_scope,
@app.login_manager.unauthorized_handler 'remember': remember_me,
def unauthorized(): 'remember_for': remember_for,
return redirect(url_for('auth.login', next=b64encode(request.url.encode()))) })
return redirect(resp.redirect_to)
@auth_views.route('/login', methods=['GET', 'POST']) @auth_views.route('/login', methods=['GET', 'POST'])
def login(): 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() form = LoginForm()
if form.validate_on_submit(): if form.validate_on_submit():
user = User.query().by_username(form.data['name']) user = User.query().by_username(form.data['name'])
@ -53,13 +66,14 @@ def login():
else: else:
session['user'] = None session['user'] = None
session['auth_providers'] = [] 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) return render_template('frontend/login.html.j2', form=form)
@auth_views.route('/login/auth', methods=['GET', 'POST']) @auth_views.route('/login/auth', methods=['GET', 'POST'])
def login_auth(): 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: if 'username' not in session:
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
auth_forms = {} auth_forms = {}
@ -74,6 +88,21 @@ def login_auth():
auth_forms[auth_provider.get_name()]=form auth_forms[auth_provider.get_name()]=form
if len(session['auth_providers']) >= 2: 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'])) login_user(SecurityUser(session['username']))
# TODO use this var # TODO use this var
_next = None _next = None
@ -89,9 +118,10 @@ def login_auth():
@auth_views.route("/logout") @auth_views.route("/logout")
@login_required
def logout(): def logout():
logout_user() logout_challenge = request.args.get('logout_challenge')
do_logout() logout_request = current_app.hydra_api.get_logout_request(logout_challenge)
return redirect(url_for('.login')) resp = current_app.hydra_api.accept_logout_request(logout_challenge)
return redirect(resp.redirect_to)

View file

@ -14,15 +14,19 @@ from pyop.exceptions import InvalidAuthenticationRequest, InvalidAccessToken, In
InvalidSubjectIdentifier, InvalidClientRegistrationRequest InvalidSubjectIdentifier, InvalidClientRegistrationRequest
from pyop.util import should_fragment_encode 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 flask_login import login_required, 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
import pyotp 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 import User, SecurityUser, Totp
from ..model_db import OAuth, db, User as DbUser
from ..form.login import LoginForm from ..form.login import LoginForm
from ..form.frontend import ClientCertForm, TOTPForm, TOTPDeleteForm from ..form.frontend import ClientCertForm, TOTPForm, TOTPDeleteForm
from ..auth_providers import AUTH_PROVIDER_LIST 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='') 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']) @frontend_views.route('/', methods=['GET'])
@login_required @login_required
def index(): def index():

View file

@ -1,4 +1,3 @@
pyop
Flask Flask
gunicorn gunicorn
flask_babel flask_babel
@ -12,6 +11,10 @@ python-u2flib-server
pyotp pyotp
cryptography cryptography
requests
requests_oauthlib
blinker
ory-hydra-client
flask-debug flask-debug

View file

@ -57,7 +57,7 @@
<div class="sidebar-sticky active"> <div class="sidebar-sticky active">
{#<a href="/"><img alt="logo" class="container-fluid" src="/static/images/dog_main_small.png"></a>#} {#<a href="/"><img alt="logo" class="container-fluid" src="/static/images/dog_main_small.png"></a>#}
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.index') }}">{{ gettext('Account') }}</a></li> <li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.index') }}">{{ gettext('Account') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('auth.logout') }}">{{ gettext('Logout') }}</a></li> <li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.logout') }}">{{ gettext('Logout') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.client_cert') }}">{{ gettext('Client Cert') }}</a></li> <li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.client_cert') }}">{{ gettext('Client Cert') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.totp') }}">{{ gettext('2FA - TOTP') }}</a></li> <li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.totp') }}">{{ gettext('2FA - TOTP') }}</a></li>
</div> </div>

View file

@ -1,13 +1,12 @@
import logging import logging
from lenticular_cloud.app import oidc_provider_init_app from lenticular_cloud.app import init_app
name = 'oidc_provider' name = 'oidc_provider'
app = oidc_provider_init_app(name) app = init_app(name)
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
from flask_debug import Debug
Debug(app)
if __name__ == "__main__": 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')