diff --git a/default.nix b/default.nix index 51e0d2a..7beafb2 100644 --- a/default.nix +++ b/default.nix @@ -121,6 +121,7 @@ in blinker ory-hydra-client authlib # as oauth client lib + fido2 # for webauthn flask_migrate # db migrations gunicorn @@ -142,5 +143,6 @@ in nose mypy + ]; } diff --git a/lenticular_cloud/app.py b/lenticular_cloud/app.py index ddecb93..2955da3 100644 --- a/lenticular_cloud/app.py +++ b/lenticular_cloud/app.py @@ -32,7 +32,7 @@ def create_app() -> Flask: #app.ldap_orm = Connection(app.config['LDAP_URL'], app.config['LDAP_BIND_DN'], app.config['LDAP_BIND_PW'], auto_bind=True) server = Server(app.config['LDAP_URL'], get_info=ALL) - app.ldap_conn = Connection(server, app.config['LDAP_BIND_DN'], app.config['LDAP_BIND_PW'], auto_bind=True) + app.ldap_conn = Connection(server, app.config['LDAP_BIND_DN'], app.config['LDAP_BIND_PW'], auto_bind="DEFAULT") model.ldap_conn = app.ldap_conn model.base_dn = app.config['LDAP_BASE_DN'] diff --git a/lenticular_cloud/cli.py b/lenticular_cloud/cli.py index 9b4da41..7c205c9 100644 --- a/lenticular_cloud/cli.py +++ b/lenticular_cloud/cli.py @@ -2,6 +2,7 @@ import argparse from .model import db, User, UserSignUp from .app import create_app from werkzeug.middleware.proxy_fix import ProxyFix +from flask_migrate import upgrade import logging import os @@ -23,6 +24,9 @@ def entry_point(): parser_run = subparsers.add_parser('run') parser_run.set_defaults(func=cli_run) + parser_db_upgrade = subparsers.add_parser('db_upgrade') + parser_db_upgrade.set_defaults(func=cli_db_upgrade) + ''' parser_upcoming = subparsers.add_parser('upcoming') parser_upcoming.set_defaults(func=cli_upcoming) @@ -73,6 +77,10 @@ def cli_run(app, args): app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1) app.run(debug=True, host='127.0.0.1', port=5000) +def cli_db_upgrade(args): + upgrade() + + if __name__ == "__main__": entry_point() diff --git a/lenticular_cloud/form/auth.py b/lenticular_cloud/form/auth.py index 133b8e1..1c36117 100644 --- a/lenticular_cloud/form/auth.py +++ b/lenticular_cloud/form/auth.py @@ -3,10 +3,11 @@ from flask_wtf import FlaskForm from wtforms import StringField, SubmitField, TextField, \ TextAreaField, PasswordField, IntegerField, FloatField, \ DateTimeField, DateField, FormField, BooleanField, \ - SelectField, Form as NoCsrfForm, SelectMultipleField + SelectField, Form as NoCsrfForm, SelectMultipleField, \ + HiddenField from wtforms.fields.html5 import EmailField from wtforms.widgets.html5 import NumberInput, DateInput -from wtforms.validators import DataRequired, NumberRange, Optional, NoneOf, Length, Regexp +from wtforms.validators import DataRequired, NumberRange, Optional, NoneOf, Length, Regexp, InputRequired from datetime import datetime @@ -25,6 +26,11 @@ class TotpForm(FlaskForm): submit = SubmitField(gettext('Authorize')) +class WebauthnLoginForm(FlaskForm): + """webauthn login form""" + + assertion = HiddenField('Assertion', [InputRequired()]) + class Fido2Form(FlaskForm): fido2 = TextField(gettext('Fido2'), default="Javascript Required") submit = SubmitField(gettext('Authorize')) diff --git a/lenticular_cloud/form/base.py b/lenticular_cloud/form/base.py index 7d30d7c..7375818 100644 --- a/lenticular_cloud/form/base.py +++ b/lenticular_cloud/form/base.py @@ -1,8 +1,14 @@ +from flask_wtf import FlaskForm from wtforms import SelectField, FieldList as WTFFieldList, Form from wtforms.fields import Field from ..model import db + +class ButtonForm(FlaskForm): + """only button form""" + + class FieldList(WTFFieldList): def __init__(self, *args, **kwargs): self.modify = kwargs.pop("modify", True) diff --git a/lenticular_cloud/form/frontend.py b/lenticular_cloud/form/frontend.py index c5c6997..32ebb51 100644 --- a/lenticular_cloud/form/frontend.py +++ b/lenticular_cloud/form/frontend.py @@ -6,7 +6,7 @@ from wtforms import StringField, SubmitField, TextField, \ SelectField, Form as NoCsrfForm, HiddenField from wtforms.widgets.html5 import NumberInput, DateInput from wtforms.validators import DataRequired, NumberRange, \ - Optional, NoneOf, Length, EqualTo + Optional, NoneOf, Length, EqualTo, InputRequired class ClientCertForm(FlaskForm): @@ -34,6 +34,13 @@ class TOTPDeleteForm(FlaskForm): submit = SubmitField(gettext('Delete')) +class WebauthnRegisterForm(FlaskForm): + """webauthn register token form""" + + attestation = HiddenField('Attestation', [InputRequired()]) + name = StringField('Name', [Length(max=250)]) + submit = SubmitField('Register', render_kw={'disabled': True}) + class PasswordChangeForm(FlaskForm): password_old = PasswordField(gettext('Old Password'), validators=[DataRequired()]) password_new = PasswordField(gettext('New Password'), validators=[DataRequired()]) diff --git a/lenticular_cloud/model.py b/lenticular_cloud/model.py index 2fc9b48..b5356a9 100644 --- a/lenticular_cloud/model.py +++ b/lenticular_cloud/model.py @@ -239,6 +239,7 @@ class User(EntryBase): last_login = db.Column(db.DateTime, nullable=True) totps = db.relationship('Totp', back_populates='user') + webauthn_credentials = db.relationship('WebauthnCredential', back_populates='user', cascade='delete,delete-orphan', passive_deletes=True) dn = "uid={uid},{base_dn}" base_dn = "ou=users,{_base_dn}" @@ -297,7 +298,7 @@ class User(EntryBase): def by_username(self, username) -> Optional['User']: result = self._query('(uid={username:s})'.format(username=escape_filter_chars(username))) - if len(result) > 0: + if len(result) > 0 and isinstance(result[0], User): return result[0] else: return None @@ -334,6 +335,20 @@ class Totp(db.Model): totp = pyotp.TOTP(self.secret) return totp.verify(token) + +class WebauthnCredential(db.Model): # pylint: disable=too-few-public-methods + """Webauthn credential model""" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) + user_handle = db.Column(db.String(64), nullable=False) + credential_data = db.Column(db.LargeBinary, nullable=False) + name = db.Column(db.String(250)) + registered = db.Column(db.DateTime, default=datetime.utcnow) + + user = db.relationship('User', back_populates='webauthn_credentials') + + class Group(EntryBase): __abstract__ = True # for sqlalchemy, disable for now dn = "cn={cn},{base_dn}" diff --git a/lenticular_cloud/template/admin/clients.html.j2 b/lenticular_cloud/template/admin/clients.html.j2 index aa5e5fe..3ddd486 100644 --- a/lenticular_cloud/template/admin/clients.html.j2 +++ b/lenticular_cloud/template/admin/clients.html.j2 @@ -5,6 +5,10 @@ {% block content %} +

+Add Client +

+ diff --git a/lenticular_cloud/views/auth.py b/lenticular_cloud/views/auth.py index d94fb1c..d41047c 100644 --- a/lenticular_cloud/views/auth.py +++ b/lenticular_cloud/views/auth.py @@ -1,5 +1,4 @@ -from authlib.integrations.flask_client import OAuth from urllib.parse import urlencode, parse_qs import flask @@ -26,12 +25,13 @@ 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 +from ..wrapped_fido2_server import WrappedFido2Server logger = logging.getLogger(__name__) auth_views = Blueprint('auth', __name__, url_prefix='/auth') - +webauthn = WrappedFido2Server() @auth_views.route('/consent', methods=['GET', 'POST']) @@ -171,6 +171,21 @@ async def login_auth() -> ResponseReturnValue: return render_template('auth/login_auth.html.j2', forms=auth_forms) + +@auth_views.route('/webauthn/pkcro', methods=['POST']) +def webauthn_pkcro_route(): + """login webauthn pkcro route""" + + user = User.query.filter(User.id == session.get('webauthn_login_user_id')).one_or_none() + form = ButtonForm() + if user and form.validate_on_submit(): + pkcro, state = webauthn.authenticate_begin(webauthn_credentials(user)) + session['webauthn_login_state'] = state + return Response(b64encode(cbor.encode(pkcro)).decode('utf-8'), mimetype='text/plain') + + return '', HTTPStatus.BAD_REQUEST + + @auth_views.route("/logout") async def logout() -> ResponseReturnValue: logout_challenge = request.args.get('logout_challenge') diff --git a/lenticular_cloud/views/frontend.py b/lenticular_cloud/views/frontend.py index 6c85ad8..3d745fe 100644 --- a/lenticular_cloud/views/frontend.py +++ b/lenticular_cloud/views/frontend.py @@ -1,11 +1,15 @@ from authlib.integrations.base_client.errors import MissingTokenError, InvalidTokenError -from urllib.parse import urlencode, parse_qs -from flask import Blueprint, redirect, request +from base64 import b64encode, b64decode +from fido2 import cbor +from fido2.client import ClientData +from fido2.ctap2 import AttestationObject, AttestedCredentialData, AuthenticatorData +from flask import Blueprint, Response, redirect, request from flask import current_app -from flask import jsonify, session +from flask import jsonify, session, flash from flask import render_template, url_for from flask_login import login_user, logout_user, current_user +from http import HTTPStatus from werkzeug.utils import redirect import logging from datetime import timedelta @@ -14,12 +18,17 @@ 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 urllib.parse import urlencode, parse_qs +from random import SystemRandom +import string from typing import Optional -from ..model import db, User, SecurityUser, Totp +from ..model import db, User, SecurityUser, Totp, WebauthnCredential from ..form.frontend import ClientCertForm, TOTPForm, \ - TOTPDeleteForm, PasswordChangeForm + TOTPDeleteForm, PasswordChangeForm, WebauthnRegisterForm +from ..form.base import ButtonForm from ..auth_providers import LdapAuthProvider +from .auth import webauthn from .oauth2 import redirect_login, oauth2 from ..hydra import hydra_service @@ -161,6 +170,87 @@ def totp_delete(totp_name) -> ResponseReturnValue: 'status': 'ok'}) +@frontend_views.route('/webauthn/list', methods=['GET']) +def webauthn_list_route() -> ResponseReturnValue: + """list registered credentials for current user""" + + creds = WebauthnCredential.query.all() + return render_template('webauthn_list.html', creds=creds, button_form=ButtonForm()) + + +@frontend_views.route('/webauthn/delete/', methods=['POST']) +def webauthn_delete_route(webauthn_id: str) -> ResponseReturnValue: + """delete registered credential""" + + form = ButtonForm() + if form.validate_on_submit(): + cred = WebauthnCredential.query.filter(WebauthnCredential.id == webauthn_id).one() + db.session.delete(cred) + db.session.commit() + return redirect(url_for('app.webauthn_list_route')) + + return '', HTTPStatus.BAD_REQUEST + + + +def webauthn_credentials(user: User) -> list[AttestedCredentialData]: + """get and decode all credentials for given user""" + return [AttestedCredentialData.create(**cbor.decode(cred.credential_data)) for cred in user.webauthn_credentials] + + +def random_string(length=32) -> str: + """generates random string""" + return ''.join([SystemRandom().choice(string.ascii_letters + string.digits) for i in range(length)]) + + +@frontend_views.route('/webauthn/pkcco', methods=['POST']) +def webauthn_pkcco_route() -> ResponseReturnValue: + """get publicKeyCredentialCreationOptions""" + + form = ButtonForm() + if form.validate_on_submit(): + user = User.query.get(current_user.id) + user_handle = random_string() + exclude_credentials = webauthn_credentials(user) + pkcco, state = webauthn.register_begin( + {'id': user_handle.encode('utf-8'), 'name': user.username, 'displayName': user.username}, + exclude_credentials) + session['webauthn_register_user_handle'] = user_handle + session['webauthn_register_state'] = state + return Response(b64encode(cbor.encode(pkcco)).decode('utf-8'), mimetype='text/plain') + + return '', HTTPStatus.BAD_REQUEST + + +@frontend_views.route('/webauthn/register', methods=['GET', 'POST']) +def webauthn_register_route() -> ResponseReturnValue: + """register credential for current user""" + + user = User.query.get(current_user.id) + form = WebauthnRegisterForm() + if form.validate_on_submit(): + try: + attestation = cbor.decode(b64decode(form.attestation.data)) + auth_data = webauthn.register_complete( + session.pop('webauthn_register_state'), + ClientData(attestation['clientDataJSON']), + AttestationObject(attestation['attestationObject'])) + + db.session.add(WebauthnCredential( + user_id=user.id, + user_handle=session.pop('webauthn_register_user_handle'), + credential_data=cbor.encode(auth_data.credential_data.__dict__), + name=form.name.data)) + db.session.commit() + + return redirect(url_for('app.webauthn_list_route')) + except (KeyError, ValueError) as e: + current_app.logger.exception(e) + flash('Error during registration.', 'error') + + return render_template('webauthn_register.html', form=form) + + @frontend_views.route('/password_change') def password_change() -> ResponseReturnValue: form = PasswordChangeForm() @@ -186,10 +276,10 @@ def password_change_post() -> ResponseReturnValue: @frontend_views.route('/oauth2_token') -def oauth2_tokens() -> ResponseReturnValue: +async def oauth2_tokens() -> ResponseReturnValue: subject = oauth2.custom.get('/userinfo').json()['sub'] - consent_sessions = list_subject_consent_sessions.sync(subject=subject, _client=hydra_service.hydra_client) + consent_sessions = await list_subject_consent_sessions.asyncio(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( @@ -198,9 +288,9 @@ def oauth2_tokens() -> ResponseReturnValue: @frontend_views.route('/oauth2_token/', methods=['DELETE']) -def oauth2_token_revoke(client_id: str) -> ResponseReturnValue: +async def oauth2_token_revoke(client_id: str) -> ResponseReturnValue: subject = oauth2.session.get('/userinfo').json()['sub'] - revoke_consent_sessions.sync( _client=hydra_service.hydra_client, + await revoke_consent_sessions.asyncio( _client=hydra_service.hydra_client, subject=subject, client=client_id) diff --git a/lenticular_cloud/wrapped_fido2_server.py b/lenticular_cloud/wrapped_fido2_server.py new file mode 100644 index 0000000..585e34e --- /dev/null +++ b/lenticular_cloud/wrapped_fido2_server.py @@ -0,0 +1,21 @@ +# This file is part of sner4 project governed by MIT license, see the LICENSE.txt file. +# source: https://github.com/bodik/flask-webauthn-example/blob/master/fwe/wrapped_fido2_server.py +""" +yubico fido2 server wrapped for flask factory pattern delayed configuration +""" + +from socket import getfqdn + +from fido2.server import Fido2Server, PublicKeyCredentialRpEntity + + +class WrappedFido2Server(Fido2Server): + """yubico fido2 server wrapped for flask factory pattern delayed configuration""" + + def __init__(self): + """initialize with default rp name""" + super().__init__(PublicKeyCredentialRpEntity(getfqdn(), 'name')) + + def init_app(self, app) -> None: + """reinitialize on factory pattern config request""" + super().__init__(PublicKeyCredentialRpEntity(app.config['SERVER_NAME'] or getfqdn(), 'name')) diff --git a/migrations/versions/52a21983d2a8_add_webauthn.py b/migrations/versions/52a21983d2a8_add_webauthn.py new file mode 100644 index 0000000..1d503d5 --- /dev/null +++ b/migrations/versions/52a21983d2a8_add_webauthn.py @@ -0,0 +1,37 @@ +"""add webauthn + +Revision ID: 52a21983d2a8 +Revises: ff2f2e871dfc +Create Date: 2022-02-20 17:00:04.531393 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '52a21983d2a8' +down_revision = 'ff2f2e871dfc' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('webauthn_credential', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('user_handle', sa.String(length=64), nullable=False), + sa.Column('credential_data', sa.LargeBinary(), nullable=False), + sa.Column('name', sa.String(length=250), nullable=True), + sa.Column('registered', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('webauthn_credential') + # ### end Alembic commands ### diff --git a/migrations/versions/ff2f2e871dfc_init.py b/migrations/versions/ff2f2e871dfc_init.py index 201b18d..71dad0c 100644 --- a/migrations/versions/ff2f2e871dfc_init.py +++ b/migrations/versions/ff2f2e871dfc_init.py @@ -7,6 +7,9 @@ Create Date: 2022-02-20 16:56:13.258209 """ from alembic import op import sqlalchemy as sa +from sqlalchemy import engine_from_config +from sqlalchemy.engine import reflection + # revision identifiers, used by Alembic. @@ -18,39 +21,54 @@ depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.create_table('user', - sa.Column('id', sa.String(length=36), nullable=False), - sa.Column('username', sa.String(), nullable=False), - sa.Column('alternative_email', sa.String(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('modified_at', sa.DateTime(), nullable=False), - sa.Column('last_login', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('username') - ) - op.create_table('user_sign_up', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('username', sa.String(), nullable=False), - sa.Column('password', sa.String(), nullable=False), - sa.Column('alternative_email', sa.String(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('totp', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('secret', sa.String(), nullable=False), - sa.Column('name', sa.String(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('user_id', sa.String(length=36), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), - sa.PrimaryKeyConstraint('id') + + # init sate, migrate from non versioned db schema + # by checking if tables exist + + config = op.get_context().config + engine = engine_from_config( + config.get_section(config.config_ini_section), prefix="sqlalchemy." ) + inspector = reflection.Inspector.from_engine(engine) + tables = inspector.get_table_names() + + if 'user' not in tables: + op.create_table('user', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('username', sa.String(), nullable=False), + sa.Column('alternative_email', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('modified_at', sa.DateTime(), nullable=False), + sa.Column('last_login', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + if 'user_sign_up' not in tables: + op.create_table('user_sign_up', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(), nullable=False), + sa.Column('password', sa.String(), nullable=False), + sa.Column('alternative_email', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + if 'totp' not in tables: + op.create_table('totp', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('secret', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) # ### end Alembic commands ### def downgrade(): + pass # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('totp') - op.drop_table('user_sign_up') - op.drop_table('user') + #op.drop_table('totp') + #op.drop_table('user_sign_up') + #op.drop_table('user') # ### end Alembic commands ### diff --git a/requirements-dev.txt b/requirements-dev.txt index a3e77d4..d81d4ae 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,2 +1,3 @@ flask-debug types-python-dateutil +types-ldap3