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 blinker
ory-hydra-client ory-hydra-client
authlib # as oauth client lib authlib # as oauth client lib
fido2 # for webauthn
flask_migrate # db migrations flask_migrate # db migrations
gunicorn gunicorn
@ -142,5 +143,6 @@ in
nose nose
mypy 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) #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) 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.ldap_conn = app.ldap_conn
model.base_dn = app.config['LDAP_BASE_DN'] model.base_dn = app.config['LDAP_BASE_DN']

View file

@ -2,6 +2,7 @@ import argparse
from .model import db, User, UserSignUp from .model import db, User, UserSignUp
from .app import create_app from .app import create_app
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from flask_migrate import upgrade
import logging import logging
import os import os
@ -23,6 +24,9 @@ def entry_point():
parser_run = subparsers.add_parser('run') parser_run = subparsers.add_parser('run')
parser_run.set_defaults(func=cli_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 = subparsers.add_parser('upcoming')
parser_upcoming.set_defaults(func=cli_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.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1)
app.run(debug=True, host='127.0.0.1', port=5000) app.run(debug=True, host='127.0.0.1', port=5000)
def cli_db_upgrade(args):
upgrade()
if __name__ == "__main__": if __name__ == "__main__":
entry_point() entry_point()

View file

@ -3,10 +3,11 @@ from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, TextField, \ from wtforms import StringField, SubmitField, TextField, \
TextAreaField, PasswordField, IntegerField, FloatField, \ TextAreaField, PasswordField, IntegerField, FloatField, \
DateTimeField, DateField, FormField, BooleanField, \ DateTimeField, DateField, FormField, BooleanField, \
SelectField, Form as NoCsrfForm, SelectMultipleField SelectField, Form as NoCsrfForm, SelectMultipleField, \
HiddenField
from wtforms.fields.html5 import EmailField from wtforms.fields.html5 import EmailField
from wtforms.widgets.html5 import NumberInput, DateInput 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 from datetime import datetime
@ -25,6 +26,11 @@ class TotpForm(FlaskForm):
submit = SubmitField(gettext('Authorize')) submit = SubmitField(gettext('Authorize'))
class WebauthnLoginForm(FlaskForm):
"""webauthn login form"""
assertion = HiddenField('Assertion', [InputRequired()])
class Fido2Form(FlaskForm): class Fido2Form(FlaskForm):
fido2 = TextField(gettext('Fido2'), default="Javascript Required") fido2 = TextField(gettext('Fido2'), default="Javascript Required")
submit = SubmitField(gettext('Authorize')) 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 import SelectField, FieldList as WTFFieldList, Form
from wtforms.fields import Field from wtforms.fields import Field
from ..model import db from ..model import db
class ButtonForm(FlaskForm):
"""only button form"""
class FieldList(WTFFieldList): class FieldList(WTFFieldList):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.modify = kwargs.pop("modify", True) self.modify = kwargs.pop("modify", True)

View file

@ -6,7 +6,7 @@ from wtforms import StringField, SubmitField, TextField, \
SelectField, Form as NoCsrfForm, HiddenField SelectField, Form as NoCsrfForm, HiddenField
from wtforms.widgets.html5 import NumberInput, DateInput from wtforms.widgets.html5 import NumberInput, DateInput
from wtforms.validators import DataRequired, NumberRange, \ from wtforms.validators import DataRequired, NumberRange, \
Optional, NoneOf, Length, EqualTo Optional, NoneOf, Length, EqualTo, InputRequired
class ClientCertForm(FlaskForm): class ClientCertForm(FlaskForm):
@ -34,6 +34,13 @@ class TOTPDeleteForm(FlaskForm):
submit = SubmitField(gettext('Delete')) 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): class PasswordChangeForm(FlaskForm):
password_old = PasswordField(gettext('Old Password'), validators=[DataRequired()]) password_old = PasswordField(gettext('Old Password'), validators=[DataRequired()])
password_new = PasswordField(gettext('New 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) last_login = db.Column(db.DateTime, nullable=True)
totps = db.relationship('Totp', back_populates='user') 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}" dn = "uid={uid},{base_dn}"
base_dn = "ou=users,{_base_dn}" base_dn = "ou=users,{_base_dn}"
@ -297,7 +298,7 @@ class User(EntryBase):
def by_username(self, username) -> Optional['User']: def by_username(self, username) -> Optional['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 and isinstance(result[0], User):
return result[0] return result[0]
else: else:
return None return None
@ -334,6 +335,20 @@ class Totp(db.Model):
totp = pyotp.TOTP(self.secret) totp = pyotp.TOTP(self.secret)
return totp.verify(token) 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): class Group(EntryBase):
__abstract__ = True # for sqlalchemy, disable for now __abstract__ = True # for sqlalchemy, disable for now
dn = "cn={cn},{base_dn}" dn = "cn={cn},{base_dn}"

View file

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

View file

@ -1,5 +1,4 @@
from authlib.integrations.flask_client import OAuth
from urllib.parse import urlencode, parse_qs from urllib.parse import urlencode, parse_qs
import flask import flask
@ -26,12 +25,13 @@ from ..model import db, User, SecurityUser, UserSignUp
from ..form.auth import ConsentForm, LoginForm, RegistrationForm from ..form.auth import ConsentForm, LoginForm, RegistrationForm
from ..auth_providers import AUTH_PROVIDER_LIST from ..auth_providers import AUTH_PROVIDER_LIST
from ..hydra import hydra_service from ..hydra import hydra_service
from ..wrapped_fido2_server import WrappedFido2Server
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
auth_views = Blueprint('auth', __name__, url_prefix='/auth') auth_views = Blueprint('auth', __name__, url_prefix='/auth')
webauthn = WrappedFido2Server()
@auth_views.route('/consent', methods=['GET', 'POST']) @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) 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") @auth_views.route("/logout")
async def logout() -> ResponseReturnValue: async def logout() -> ResponseReturnValue:
logout_challenge = request.args.get('logout_challenge') logout_challenge = request.args.get('logout_challenge')

View file

@ -1,11 +1,15 @@
from authlib.integrations.base_client.errors import MissingTokenError, InvalidTokenError from authlib.integrations.base_client.errors import MissingTokenError, InvalidTokenError
from urllib.parse import urlencode, parse_qs from base64 import b64encode, b64decode
from flask import Blueprint, redirect, request 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 current_app
from flask import jsonify, session from flask import jsonify, session, flash
from flask import render_template, url_for from flask import render_template, url_for
from flask_login import login_user, logout_user, current_user from flask_login import login_user, logout_user, current_user
from http import HTTPStatus
from werkzeug.utils import redirect from werkzeug.utils import redirect
import logging import logging
from datetime import timedelta from datetime import timedelta
@ -14,12 +18,17 @@ from flask.typing import ResponseReturnValue
from oauthlib.oauth2.rfc6749.errors import TokenExpiredError 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.api.admin import list_subject_consent_sessions, revoke_consent_sessions
from ory_hydra_client.models import GenericError 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 typing import Optional
from ..model import db, User, SecurityUser, Totp from ..model import db, User, SecurityUser, Totp, WebauthnCredential
from ..form.frontend import ClientCertForm, TOTPForm, \ from ..form.frontend import ClientCertForm, TOTPForm, \
TOTPDeleteForm, PasswordChangeForm TOTPDeleteForm, PasswordChangeForm, WebauthnRegisterForm
from ..form.base import ButtonForm
from ..auth_providers import LdapAuthProvider from ..auth_providers import LdapAuthProvider
from .auth import webauthn
from .oauth2 import redirect_login, oauth2 from .oauth2 import redirect_login, oauth2
from ..hydra import hydra_service from ..hydra import hydra_service
@ -161,6 +170,87 @@ def totp_delete(totp_name) -> ResponseReturnValue:
'status': 'ok'}) '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') @frontend_views.route('/password_change')
def password_change() -> ResponseReturnValue: def password_change() -> ResponseReturnValue:
form = PasswordChangeForm() form = PasswordChangeForm()
@ -186,10 +276,10 @@ def password_change_post() -> ResponseReturnValue:
@frontend_views.route('/oauth2_token') @frontend_views.route('/oauth2_token')
def oauth2_tokens() -> ResponseReturnValue: async def oauth2_tokens() -> ResponseReturnValue:
subject = oauth2.custom.get('/userinfo').json()['sub'] 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): if consent_sessions is None or isinstance( consent_sessions, GenericError):
return 'internal error, could not fetch sessions', 500 return 'internal error, could not fetch sessions', 500
return render_template( return render_template(
@ -198,9 +288,9 @@ def oauth2_tokens() -> ResponseReturnValue:
@frontend_views.route('/oauth2_token/<client_id>', methods=['DELETE']) @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'] 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, subject=subject,
client=client_id) 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 from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import engine_from_config
from sqlalchemy.engine import reflection
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
@ -18,6 +21,18 @@ depends_on = None
def upgrade(): def upgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
# 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', op.create_table('user',
sa.Column('id', sa.String(length=36), nullable=False), sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('username', sa.String(), nullable=False), sa.Column('username', sa.String(), nullable=False),
@ -28,6 +43,7 @@ def upgrade():
sa.PrimaryKeyConstraint('id'), sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('username') sa.UniqueConstraint('username')
) )
if 'user_sign_up' not in tables:
op.create_table('user_sign_up', op.create_table('user_sign_up',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(), nullable=False), sa.Column('username', sa.String(), nullable=False),
@ -36,6 +52,7 @@ def upgrade():
sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
if 'totp' not in tables:
op.create_table('totp', op.create_table('totp',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('secret', sa.String(), nullable=False), sa.Column('secret', sa.String(), nullable=False),
@ -49,8 +66,9 @@ def upgrade():
def downgrade(): def downgrade():
pass
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_table('totp') #op.drop_table('totp')
op.drop_table('user_sign_up') #op.drop_table('user_sign_up')
op.drop_table('user') #op.drop_table('user')
# ### end Alembic commands ### # ### end Alembic commands ###

View file

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