add partial fido2/WebAuthn

This commit is contained in:
TuxCoder 2022-04-08 21:28:22 +02:00
parent 6c8bb99c61
commit 5401e2594d
14 changed files with 275 additions and 45 deletions

View file

@ -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
];
}

View file

@ -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']

View file

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

View file

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

View file

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

View file

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

View file

@ -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}"

View file

@ -5,6 +5,10 @@
{% block content %}
<p>
<a href="{{url_for('.client_new')}}">Add Client</a>
</p>
<table class="table">
<thead>
<tr>

View file

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

View file

@ -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/<webauthn_id>', 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/<client_id>', 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)

View file

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

View file

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

View file

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

View file

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