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