add basic passkey management

master
tuxcoder 2023-12-25 17:28:09 +01:00
parent 5759cb1e4f
commit f858a1a78c
10 changed files with 258 additions and 273 deletions

View File

@ -0,0 +1,40 @@
"""passkey
Revision ID: b5448df204eb
Revises: a74320a5d7a1
Create Date: 2023-12-25 00:13:01.703575
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b5448df204eb'
down_revision = 'a74320a5d7a1'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('passkey_credential',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('credential_id', sa.BINARY(), nullable=False),
sa.Column('credential_public_key', sa.BINARY(), nullable=False),
sa.Column('name', sa.String(length=250), nullable=False),
sa.Column('last_used', sa.DateTime(), nullable=True),
sa.Column('sign_count', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('modified_at', sa.DateTime(), nullable=False),
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('passkey_credential')
# ### end Alembic commands ###

View File

@ -164,14 +164,11 @@ class User(BaseModel, ModelUpdatedMixin):
app_tokens: Mapped[List['AppToken']] = relationship('AppToken', back_populates='user')
# totps: Mapped[List['Totp']] = relationship('Totp', back_populates='user', default_factory=list)
# webauthn_credentials: Mapped[List['WebauthnCredential']] = relationship('WebauthnCredential', back_populates='user', cascade='delete,delete-orphan', passive_deletes=True, default_factory=list)
passkey_credentials: Mapped[List['PasskeyCredential']] = relationship('PasskeyCredential', back_populates='user', cascade='delete,delete-orphan', passive_deletes=True)
@property
def totps(self) -> List['Totp']:
return []
@property
def webauthn_credentials(self) -> List['WebauthnCredential']:
return []
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
@ -245,17 +242,18 @@ class Totp(BaseModel, ModelUpdatedMixin):
return totp.verify(token)
class WebauthnCredential(BaseModel, ModelUpdatedMixin): # pylint: disable=too-few-public-methods
"""Webauthn credential model"""
class PasskeyCredential(BaseModel, ModelUpdatedMixin): # pylint: disable=too-few-public-methods
"""Passkey credential model"""
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[uuid.UUID] = mapped_column(db.Uuid, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
user_handle: Mapped[str] = mapped_column(db.String(64), nullable=False)
credential_data: Mapped[bytes] = mapped_column(db.LargeBinary, nullable=False)
credential_id: Mapped[bytes] = mapped_column(db.BINARY, nullable=False)
credential_public_key: Mapped[bytes] = mapped_column(db.BINARY, nullable=False)
name: Mapped[str] = mapped_column(db.String(250), nullable=False)
registered: Mapped[datetime] = mapped_column(db.DateTime, default=datetime.utcnow, nullable=False)
last_used: Mapped[Optional[datetime]] = mapped_column(db.DateTime, nullable=True, default=None)
sign_count: Mapped[int] = mapped_column(db.Integer, nullable=False, default=0)
# user = db.relationship('User', back_populates='webauthn_credentials')
user = db.relationship('User', back_populates='passkey_credentials')
class Group(BaseModel, ModelUpdatedMixin):

View File

@ -213,9 +213,9 @@
action_text - text of submit button
class_ - sets a class for form
#}
{% macro render_form(form, action_url='', action_text='Submit', class_='', btn_class='btn btn-primary', method='post', onsubmit='') -%}
{% macro render_form(form, action_url='', id='', action_text='Submit', class_='', btn_class='btn btn-primary', method='post', onsubmit='') -%}
<form method="{{ method }}" {% if action_url %}action="{{ action_url }}" {% endif %}{% if onsubmit %}onsubmit="{{ onsubmit }}" {% endif %}role="form" class="{{ class_ }}">
<form method="{{ method }}" {% if id %}id="{{ id }}" {% endif %}{% if action_url %}action="{{ action_url }}" {% endif %}{% if onsubmit %}onsubmit="{{ onsubmit }}" {% endif %}role="form" class="{{ class_ }}">
<input name="form" type="hidden" value="{{ form.__class__.__name__ }}">
{{ _render_form(form) }}
{% if not form.submit %}

View File

@ -17,7 +17,8 @@
<div class="sidebar-sticky active">
{#<a href="/"><img alt="logo" class="container-fluid" src="/static/images/dog_main_small.png"></a>#}
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.index') }}">{{ gettext('Account') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.client_cert') }}">{{ gettext('Client Cert') }}</a></li>
{#<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.client_cert') }}">{{ gettext('Client Cert') }}</a></li>#}
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.passkey') }}">{{ gettext('Passkey') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.app_token') }}">{{ gettext('App Tokens') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.totp') }}">{{ gettext('2FA - TOTP') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.oauth2_tokens') }}">{{ gettext('Oauth2 Tokens') }}</a></li>

View File

@ -0,0 +1,30 @@
{% extends 'frontend/base.html.j2' %}
{% block content %}
<div class="users">
<h1>Passkey Credentials list</h1>
<table class="table">
<thead>
<tr>
<th>name</th>
<th>id</th>
<th>last used</th>
<th>created at</th>
<th>actions</th>
</tr>
</thead>
<tbody>
{% for credential in credentials %}
<tr>
<td>{{ credential.name }}</td>
<td>{{ credential.credential_id[0:8].hex() }}...</td>
<td>{{ credential.last_used }}</td>
<td>{{ credential.created_at }}...</td>
<td>{{ render_form(button_form, action_url=url_for('.passkey_delete', id=credential.id), action_text='delete', btn_class='btn btn-danger') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -0,0 +1,69 @@
{#- This file is part of sner4 project governed by MIT license, see the LICENSE.txt file. -#}
{% extends 'frontend/base.html.j2' %}
{% block script %}
<script>
let options_req = {{ options }};
let token = "{{ token }}";
// https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential/parseRequestOptionsFromJSON_static
// PublicKeyCredential.parseRequestOptionsFromJSON(
async function get_passkey_credentials(options) {
let option_obj = PublicKeyCredential.parseCreationOptionsFromJSON(options);
console.log(option_obj);
let credential = await navigator.credentials.create({
'publicKey': option_obj,
});
let credential_json = credential.toJSON()
console.log(credential_json);
return credential_json;
}
let form = document.getElementById('webauthn_register_form');
async function register() {
//let credential = await get_passkey_credentials(options_req);
let credential = await auth_passkey.sign_up(options_req);
let name = form.querySelector('#name').value;
let response = await fetch("{{ url_for('.passkey_new_process') }}", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
token,
credential,
name
}),
})
}
form.onsubmit = ev => {
ev.preventDefault()
register().then( result => {
document.location = "{{ url_for('.passkey') }}";
})
};
</script>
{% endblock %}
{% block content %}
<div class="profile">
<h1>Register new Passkey credential</h1>
<div>
To register new credential:
<ol>
<li>Insert/connect authenticator and verify user presence.</li>
<li>Set name for the new credential.</li>
<li>Submit the registration.</li>
</ol>
</div>
{{ render_form(form, id="webauthn_register_form") }}
</div>
{% endblock %}

View File

@ -1,30 +0,0 @@
{% extends 'frontend/base.html.j2' %}
{% block content %}
<div class="users">
<h1>WebauthnCredentials list</h1>
<table class="table">
<thead>
<tr>
<th>user.username</th>
<th>user_handle</th>
<th>credential_data</th>
<th>name</th>
<th>_actions</th>
</tr>
</thead>
<tbody>
{% for cred in creds %}
<tr>
<td>{{ cred.user.username }}</td>
<td>{{ cred.user_handle }}</td>
<td>{{ cred.credential_data[0:40] }}...</td>
<td>{{ cred.name }}</td>
<td>{{ render_form(button_form, action_url=url_for('app.webauthn_delete_route', webauthn_id=cred.id)) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -1,140 +0,0 @@
{#- This file is part of sner4 project governed by MIT license, see the LICENSE.txt file. -#}
{% extends 'frontend/base.html.j2' %}
{% block script %}
<script>
/**
* decode base64 data to ArrayBuffer
*
* @param {string} data data to decode
* @return {ArrayBuffer} decoded data
*/
function base64_to_array_buffer(data) {
return Uint8Array.from(atob(data), c => c.charCodeAt(0)).buffer;
}
/**
* request publicKeyCredentialCreationOptions for webauthn from server
*
* @return {Promise<Object>} A promise that resolves with publicKeyCredentialCreationOptions for navigator.credentials.create()
*/
function get_pkcco() {
return fetch("{{ url_for('frontend.webauthn_pkcco_route')}}", {method:'post', headers: {'Content-Type': 'application/json'}})
.then(function(resp) {
return resp.text();
})
.then(function(data){
var pkcco = CBOR.decode(base64_to_array_buffer(data));
console.debug('credentials.create options:', pkcco);
var publicKey = {
// The challenge is produced by the server; see the Security Considerations
challenge: new Uint8Array([21,31,105 /* 29 more random bytes generated by the server */]),
// Relying Party:
rp: {
name: "Lenticular Cloud - domain TODO"
},
// User:
user: {
id: Uint8Array.from(window.atob("MIIBkzCCATigAwIBAjCCAZMwggE4oAMCAQIwggGTMII="), c=>c.charCodeAt(0)),
name: "{user.domain}",
displayName: "{user.name}",
},
// This Relying Party will accept either an ES256 or RS256 credential, but
// prefers an ES256 credential.
pubKeyCredParams: [
{
type: "public-key",
alg: -7 // "ES256" as registered in the IANA COSE Algorithms registry
},
{
type: "public-key",
alg: -257 // Value registered by this specification for "RS256"
}
],
authenticatorSelection: {
// Try to use UV if possible. This is also the default.
userVerification: "preferred"
},
timeout: 360000, // 6 minutes
excludeCredentials: [
// Dont re-register any authenticator that has one of these credentials
//{"id": Uint8Array.from(window.atob("E/e1dhZc++mIsz4f9hb6NifAzJpF1V4mEtRlIPBiWdY="), c=>c.charCodeAt(0)), "type": "public-key"}
],
// Make excludeCredentials check backwards compatible with credentials registered with U2F
extensions: {"appidExclude": "https://acme.example.com"}
};
return { "publicKey": publicKey };
})
.catch(function(error) { console.log('cant get pkcco ',error)});
}
/**
* pack attestation
*
* @param {object} attestation attestation response for the credential to register
*/
function pack_attestation(attestation) {
console.debug('new credential attestation:', attestation);
var attestation_data = {
'clientDataJSON': new Uint8Array(attestation.response.clientDataJSON),
'attestationObject': new Uint8Array(attestation.response.attestationObject)
};
//var form = $('#webauthn_register_form')[0];
var form = document.querySelector('form')
var base64 = btoa(new Uint8Array(CBOR.encode(attestation_data)).reduce((data, byte) => data + String.fromCharCode(byte), ''));
form.attestation.value = base64;
form.submit.disabled = false;
//form.querySelecotr('p[name="attestation_data_status"]').innerHTML = '<span style="color: green;">Prepared</span>';
}
console.log(window.PublicKeyCredential ? 'WebAuthn supported' : 'WebAuthn NOT supported');
get_pkcco()
.then(pkcco => navigator.credentials.create(pkcco))
.then(attestation_response => pack_attestation(attestation_response))
.catch(function(error) {
//toastr.error('Registration data preparation failed.');
console.log(error.message);
});
</script>
{% endblock %}
{% block content %}
<div class="profile">
<h1>Register new Webauthn credential</h1>
<div>
To register new credential:
<ol>
<li>Insert/connect authenticator and verify user presence.</li>
<li>Optionaly set comment for the new credential.</li>
<li>Submit the registration.</li>
</ol>
</div>
{{ render_form(form) }}
{#
<form id="webauthn_register_form" class="form-horizontal" method="post">
{{ form.csrf_token }}
<div class="form-group">
<label class="col-sm-2 control-label">Registration data</label>
<div class="col-sm-10"><p class="form-control-static" name="attestation_data_status"><span style="color: orange;">To be prepared</span></p></div>
</div>
{{ b_wtf.bootstrap_field(form.attestation, horizontal=True) }}
{{ b_wtf.bootstrap_field(form.name, horizontal=True) }}
{{ b_wtf.bootstrap_field(form.submit, horizontal=True) }}
</form>
#}
</div>
{% endblock %}

View File

@ -1,29 +1,24 @@
from authlib.integrations.base_client.errors import MissingTokenError, InvalidTokenError
from base64 import b64encode, b64decode
from fido2 import cbor
from fido2.webauthn import CollectedClientData, AttestationObject, AttestedCredentialData, AuthenticatorData, PublicKeyCredentialUserEntity
from flask import Blueprint, Response, redirect, request
from flask import Blueprint, redirect, request
from flask import current_app
from flask import jsonify, session, flash
from flask import jsonify, session
from flask import render_template, url_for
from flask_login import login_user, logout_user, current_user
from flask_login import logout_user, current_user
from http import HTTPStatus
from werkzeug.utils import redirect
import logging
from datetime import timedelta
from base64 import b64decode
from flask.typing import ResponseReturnValue
from oauthlib.oauth2.rfc6749.errors import TokenExpiredError
from ory_hydra_client.api.o_auth_2 import list_o_auth_2_consent_sessions, revoke_o_auth_2_consent_sessions
from ory_hydra_client.models import GenericError
from urllib.parse import urlencode, parse_qs
from random import SystemRandom
import string
from collections.abc import Iterable
from typing import Optional, Mapping, Iterator, List, Any
from urllib.parse import urlparse
from typing import Optional, Any
import jwt
from datetime import datetime, timedelta
from ..model import db, User, SecurityUser, Totp, AppToken, WebauthnCredential
from ..model import db, User, Totp, AppToken, PasskeyCredential
from ..form.frontend import ClientCertForm, TOTPForm, \
TOTPDeleteForm, PasswordChangeForm, WebauthnRegisterForm, \
AppTokenForm, AppTokenDeleteForm
@ -223,96 +218,118 @@ def totp_delete(totp_name) -> ResponseReturnValue:
'status': 'ok'})
@frontend_views.route('/webauthn/list', methods=['GET'])
def webauthn_list_route() -> ResponseReturnValue:
## Passkey
import webauthn
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
UserVerificationRequirement,
)
@frontend_views.route('/passkey/list', methods=['GET'])
def passkey() -> ResponseReturnValue:
"""list registered credentials for current user"""
creds = WebauthnCredential.query.all() # type: Iterable[WebauthnCredential]
return render_template('frontend/webauthn_list.html', creds=creds, button_form=ButtonForm())
credentials = PasskeyCredential.query.all()
return render_template('frontend/passkey_list.html.j2', credentials=credentials, button_form=ButtonForm())
@frontend_views.route('/webauthn/delete/<webauthn_id>', methods=['POST'])
def webauthn_delete_route(webauthn_id: str) -> ResponseReturnValue:
@frontend_views.route('/passkey/new', methods=['GET'])
def passkey_new() -> ResponseReturnValue:
"""register credential for current user"""
public_url = urlparse(current_app.config['PUBLIC_URL'])
user = get_current_user() # type: User
form = WebauthnRegisterForm()
options = webauthn.generate_registration_options(
rp_name="Lenticluar Cloud",
rp_id=public_url.hostname,
user_id=str(user.id),
user_name=user.username,
authenticator_selection=AuthenticatorSelectionCriteria(
user_verification=UserVerificationRequirement.REQUIRED,
resident_key=ResidentKeyRequirement.REQUIRED,
),
exclude_credentials = list(map(lambda x: PublicKeyCredentialDescriptor(id=x.credential_id), user.passkey_credentials))
)
secret_key = current_app.config['SECRET_KEY']
token = jwt.encode({
'challenge': b64encode(options.challenge).decode(),
'iat': datetime.utcnow() - timedelta(minutes=1),
'nbf': datetime.utcnow(),
'exp': datetime.utcnow() + timedelta(minutes=15),
}, secret_key, algorithm="HS256"
)
return render_template(
'frontend/passkey_new.html.j2',
form=form,
options=webauthn.options_to_json(options),
token=token,
)
@frontend_views.route('/passkey/new', methods=['POST'])
def passkey_new_process() -> ResponseReturnValue:
secret_key = current_app.config['SECRET_KEY']
public_url = urlparse(current_app.config['PUBLIC_URL'])
user = get_current_user()
data = request.get_json()
try:
token = jwt.decode(
data['token'], secret_key, algorithms=['HS256'],
options = {
'require': ["challenge", "exp", "iat", "nbf"],
})
except jwt.exceptions.MissingRequiredClaimError:
return jsonify({'message': "invalid token"}), 400
challenge = b64decode(token['challenge'])
credential = data['credential']
name = data['name']
result = webauthn.verify_registration_response(
credential = credential,
expected_rp_id = public_url.hostname,
expected_challenge = challenge,
expected_origin = [ public_url.geturl() ],
)
if not result.user_verified:
return jsonify({ "message": "invalid auth" }), 403
db.session.add(PasskeyCredential(
id=None,
user_id=user.id,
credential_id=result.credential_id,
credential_public_key=result.credential_public_key,
name=name,
))
db.session.commit()
logger.info(f"add new passkey for user {user.username}")
return jsonify({})
@frontend_views.route('/passkey/delete/<id>', methods=['POST'])
def passkey_delete(id: str) -> ResponseReturnValue:
"""delete registered credential"""
form = ButtonForm()
if form.validate_on_submit():
cred = WebauthnCredential.query.filter(WebauthnCredential.id == webauthn_id).one() # type: WebauthnCredential
cred = PasskeyCredential.query.filter(PasskeyCredential.id == id).first_or_404()
db.session.delete(cred)
db.session.commit()
return redirect(url_for('app.webauthn_list_route'))
return redirect(url_for('.passkey'))
return '', HTTPStatus.BAD_REQUEST
def webauthn_credentials(user: User) -> list[AttestedCredentialData]:
"""get and decode all credentials for given user"""
def decode(creds: List[WebauthnCredential]) -> Iterator[AttestedCredentialData]:
for cred in creds:
data = cbor.decode(cred.credential_data)
if isinstance(data, Mapping):
yield AttestedCredentialData.create(**data)
return list(decode(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"""
user = User.query.get(get_current_user().id) #type: Optional[User]
if user is None:
return 'internal error', 500
user_handle = random_string()
exclude_credentials = webauthn_credentials(user)
pkcco, state = webauthn.register_begin(
user=PublicKeyCredentialUserEntity(id=user_handle.encode('utf-8'), name=user.username, display_name=user.username),
credentials=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')
@frontend_views.route('/webauthn/register', methods=['GET', 'POST'])
def webauthn_register_route() -> ResponseReturnValue:
"""register credential for current user"""
user = get_current_user() # type: User
form = WebauthnRegisterForm()
if form.validate_on_submit():
try:
attestation = cbor.decode(b64decode(form.attestation.data))
if not isinstance(attestation, Mapping) or 'clientDataJSON' not in attestation or 'attestationObject' not in attestation:
return 'invalid attestion data', 400
auth_data = webauthn.register_complete(
session.pop('webauthn_register_state'),
CollectedClientData(attestation['clientDataJSON']),
AttestationObject(attestation['attestationObject']))
db.session.add(WebauthnCredential(
user=user,
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:
logger.exception(e)
flash('Error during registration.', 'error')
return render_template('frontend/webauthn_register.html', form=form)
@frontend_views.route('/password_change')
def password_change() -> ResponseReturnValue:
form = PasswordChangeForm()

View File

@ -86,12 +86,12 @@ in {
cryptography
blinker
authlib # as oauth client lib
fido2 # for webauthn
flask_migrate # db migrations
flask-dance
ory-hydra-client
toml
webauthn
webauthn pyopenssl
pyjwt
pkgs.nodejs
#node-env