add partial fido2/WebAuthn
This commit is contained in:
parent
6c8bb99c61
commit
5401e2594d
|
@ -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
|
||||
|
||||
];
|
||||
}
|
||||
|
|
|
@ -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']
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()])
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -5,6 +5,10 @@
|
|||
{% block content %}
|
||||
|
||||
|
||||
<p>
|
||||
<a href="{{url_for('.client_new')}}">Add Client</a>
|
||||
</p>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
21
lenticular_cloud/wrapped_fido2_server.py
Normal file
21
lenticular_cloud/wrapped_fido2_server.py
Normal 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'))
|
37
migrations/versions/52a21983d2a8_add_webauthn.py
Normal file
37
migrations/versions/52a21983d2a8_add_webauthn.py
Normal 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 ###
|
|
@ -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,6 +21,18 @@ depends_on = None
|
|||
|
||||
def upgrade():
|
||||
# ### 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',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('username', sa.String(), nullable=False),
|
||||
|
@ -28,6 +43,7 @@ def upgrade():
|
|||
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),
|
||||
|
@ -36,6 +52,7 @@ def upgrade():
|
|||
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),
|
||||
|
@ -49,8 +66,9 @@ def upgrade():
|
|||
|
||||
|
||||
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 ###
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
flask-debug
|
||||
types-python-dateutil
|
||||
types-ldap3
|
||||
|
|
Loading…
Reference in a new issue