implement basic passkey login flow

This commit is contained in:
tuxcoder 2023-12-25 18:55:20 +01:00
parent 926afee5c5
commit 0a1da35d84
8 changed files with 150 additions and 96 deletions

View file

@ -12,6 +12,7 @@ var asn1 = require('node-forge/lib/asn1');
var pkcs12 = require('node-forge/lib/pkcs12'); var pkcs12 = require('node-forge/lib/pkcs12');
var util = require('node-forge/lib/util'); var util = require('node-forge/lib/util');
import SimpleFormSubmit from "simple-form-submit"; import SimpleFormSubmit from "simple-form-submit";
import {startRegistration, startAuthentication} from '@simplewebauthn/browser';
const $ = document.querySelector.bind(document); const $ = document.querySelector.bind(document);
const $$ = document.querySelectorAll.bind(document); const $$ = document.querySelectorAll.bind(document);
@ -100,6 +101,17 @@ window.auth = {
} }
}; };
window.auth_passkey = {
sign_up: async function(options) {
const resp = await startRegistration(options);
return resp;
},
sign_in: async function(options) {
const resp = await startAuthentication(options);
return resp;
},
}
window.totp = { window.totp = {
init_list: function(){ init_list: function(){
}, },
@ -133,11 +145,6 @@ window.totp = {
} }
} }
window.fido2 = {
init: function() {
}
}
window.password_change= { window.password_change= {
init: function(){ init: function(){
var form = $('form'); var form = $('form');

View file

@ -1,6 +1,6 @@
from flask import current_app from flask import current_app
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from .form.auth import PasswordForm, TotpForm, Fido2Form from .form.auth import PasswordForm
from hmac import compare_digest as compare_hash from hmac import compare_digest as compare_hash
import crypt import crypt
from .model import User from .model import User

View file

@ -26,15 +26,6 @@ class TotpForm(FlaskForm):
submit = SubmitField(gettext('Authorize')) submit = SubmitField(gettext('Authorize'))
class WebauthnLoginForm(FlaskForm):
"""webauthn login form"""
assertion = HiddenField('Assertion', [InputRequired()])
class Fido2Form(FlaskForm):
fido2 = StringField(gettext('Fido2'), default="Javascript Required")
submit = SubmitField(gettext('Authorize'))
class ConsentForm(FlaskForm): class ConsentForm(FlaskForm):
# scopes = SelectMultipleField(gettext('scopes')) # scopes = SelectMultipleField(gettext('scopes'))

View file

@ -2,12 +2,53 @@
{% block title %}{{ gettext('Login') }}{% endblock %} {% block title %}{{ gettext('Login') }}{% endblock %}
{% block content %}
{% block script %}
<script>
const options_req = {{ options }};
const token = "{{ token }}";
const login_challenge = "{{ login_challenge }}";
{{ render_form(form) }} async function login() {
const credential = await auth_passkey.sign_in(options_req);
const response = await fetch("{{ url_for('.passkey_verify') }}", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
token,
credential,
login_challenge,
}),
})
return await response.json()
}
<a href="{{ url_for('.sign_up') }}" class="btn btn-primary">Sign Up</a> let form = document.getElementById('webauthn_register_form');
form.onsubmit = ev => {
ev.preventDefault()
login().then( response => {
document.location = response.redirect;
})
};
</script>
{% endblock %}
{% block content %}
<div class="row">
{{ render_form(form) }}
</div>
<div class="row">
<form id="webauthn_register_form">
<button class="btn btn-primary">Login wiht Passkey</button>
</form>
</div>
<div class="row">
<a href="{{ url_for('.sign_up') }}" class="btn btn-secondary">Sign Up</a>
</div>
{% endblock %} {% endblock %}

View file

@ -6,24 +6,9 @@
let options_req = {{ options }}; let options_req = {{ options }};
let token = "{{ token }}"; 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'); let form = document.getElementById('webauthn_register_form');
async function register() { async function register() {
//let credential = await get_passkey_credentials(options_req);
let credential = await auth_passkey.sign_up(options_req); let credential = await auth_passkey.sign_up(options_req);
let name = form.querySelector('#name').value; let name = form.querySelector('#name').value;

View file

@ -1,39 +1,35 @@
from urllib.parse import urlencode, parse_qs from base64 import b64encode, b64decode, urlsafe_b64decode
import flask
from flask import Blueprint, redirect, flash, current_app, session
from flask.templating import render_template
from flask_babel import gettext
from flask.typing import ResponseReturnValue
from flask import request, url_for, jsonify
from flask_login import login_required, login_user, logout_user, current_user
import logging
from urllib.parse import urlparse
from base64 import b64decode, b64encode
import http
import crypt import crypt
from datetime import datetime from datetime import datetime, timedelta
import jwt
from flask import request, url_for, jsonify, Blueprint, redirect, current_app, session
from flask.templating import render_template
from flask.typing import ResponseReturnValue
import logging import logging
import json
from ory_hydra_client.api.o_auth_2 import get_o_auth_2_consent_request, accept_o_auth_2_consent_request, accept_o_auth_2_login_request, get_o_auth_2_login_request, accept_o_auth_2_login_request, accept_o_auth_2_logout_request, get_o_auth_2_login_request
from ory_hydra_client import models as ory_hydra_m from ory_hydra_client import models as ory_hydra_m
from ory_hydra_client.models import TheRequestPayloadUsedToAcceptALoginOrConsentRequest, TheRequestPayloadUsedToAcceptAConsentRequest, GenericError from ory_hydra_client.api.o_auth_2 import get_o_auth_2_consent_request, accept_o_auth_2_consent_request, accept_o_auth_2_login_request, get_o_auth_2_login_request, accept_o_auth_2_login_request, accept_o_auth_2_logout_request, get_o_auth_2_login_request
from ory_hydra_client.models import TheRequestPayloadUsedToAcceptAConsentRequest, GenericError
from typing import Optional from typing import Optional
from urllib.parse import urlparse
from uuid import uuid4, UUID from uuid import uuid4, UUID
import webauthn
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
UserVerificationRequirement,
)
from ..model import db, User, SecurityUser from ..model import db, User, PasskeyCredential
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'])
@ -98,6 +94,9 @@ async def consent() -> ResponseReturnValue:
@auth_views.route('/login', methods=['GET', 'POST']) @auth_views.route('/login', methods=['GET', 'POST'])
async def login() -> ResponseReturnValue: async def login() -> ResponseReturnValue:
secret_key = current_app.config['SECRET_KEY']
public_url = urlparse(current_app.config['PUBLIC_URL'])
login_challenge = request.args.get('login_challenge') login_challenge = request.args.get('login_challenge')
if login_challenge is None: if login_challenge is None:
return 'login_challenge missing', 400 return 'login_challenge missing', 400
@ -106,6 +105,21 @@ async def login() -> ResponseReturnValue:
logger.exception("could not fetch login request") logger.exception("could not fetch login request")
return redirect(url_for('frontend.index')) return redirect(url_for('frontend.index'))
## passkey
options = webauthn.generate_authentication_options(
rp_id = public_url.hostname,
user_verification = UserVerificationRequirement.REQUIRED,
challenge=webauthn.helpers.generate_challenge(32)
)
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"
)
##
if login_request.skip: if login_request.skip:
resp = await accept_o_auth_2_login_request.asyncio(_client=hydra_service.hydra_client, resp = await accept_o_auth_2_login_request.asyncio(_client=hydra_service.hydra_client,
login_challenge=login_challenge, login_challenge=login_challenge,
@ -124,7 +138,13 @@ async def login() -> ResponseReturnValue:
session['auth_providers'] = [] session['auth_providers'] = []
return redirect( return redirect(
url_for('auth.login_auth', login_challenge=login_challenge)) url_for('auth.login_auth', login_challenge=login_challenge))
return render_template('auth/login.html.j2', form=form) return render_template(
'auth/login.html.j2',
form=form,
options=webauthn.options_to_json(options),
token=token,
login_challenge=login_challenge,
)
@auth_views.route('/login/auth', methods=['GET', 'POST']) @auth_views.route('/login/auth', methods=['GET', 'POST'])
@ -171,21 +191,54 @@ 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('/passkey/verify', methods=['POST'])
async def passkey_verify() -> ResponseReturnValue:
secret_key = current_app.config['SECRET_KEY']
public_url = current_app.config['PUBLIC_URL']
@auth_views.route('/webauthn/pkcro', methods=['POST'])
def webauthn_pkcro_route() -> ResponseReturnValue:
"""login webauthn pkcro route"""
return '', 404
user = User.query.filter(User.id == session.get('webauthn_login_user_id')).one() #type: User data = request.get_json()
form = ButtonForm() token = jwt.decode(data['token'], secret_key, algorithms=['HS256'])
if user and form.validate_on_submit(): challenge = urlsafe_b64decode(token['challenge'])
pkcro, state = webauthn.authenticate_begin(webauthn_credentials(user)) credential = data['credential']
session['webauthn_login_state'] = state credential_id = urlsafe_b64decode(credential['id'])
return Response(b64encode(cbor.encode(pkcro)).decode('utf-8'), mimetype='text/plain')
return '', HTTPStatus.BAD_REQUEST login_challenge = data['login_challenge']
if login_challenge is None:
return 'missing login_challenge, bad request', 400
login_request = await get_o_auth_2_login_request.asyncio(_client=hydra_service.hydra_client, login_challenge=login_challenge)
if login_request is None:
return redirect(url_for('frontend.index'))
passkey = PasskeyCredential.query.filter(PasskeyCredential.credential_id == credential_id).first_or_404()
result = webauthn.verify_authentication_response(
credential = credential,
expected_rp_id = "localhost",
expected_challenge = challenge,
expected_origin = [ public_url ],
credential_public_key = passkey.credential_public_key,
credential_current_sign_count = passkey.sign_count,
)
logger.error(f"DEBUG: {passkey}")
logger.error(f"DEBUG: {result}")
passkey.sign_count = result.new_sign_count
passkey.last_used = datetime.utcnow()
user = passkey.user
user.last_login = datetime.now()
subject = str(user.id)
resp = await accept_o_auth_2_login_request.asyncio(_client=hydra_service.hydra_client,
login_challenge=login_challenge, json_body=ory_hydra_m.HandledLoginRequestIsTheRequestPayloadUsedToAcceptALoginRequest(
subject=subject,
remember=True,
))
if resp is None or isinstance( resp, GenericError):
return 'internal error, could not forward request', 503
db.session.commit()
return jsonify({'redirect': resp.redirect_to})
@auth_views.route("/logout") @auth_views.route("/logout")
async def logout() -> ResponseReturnValue: async def logout() -> ResponseReturnValue:

View file

@ -17,6 +17,13 @@ from urllib.parse import urlparse
from typing import Optional, Any from typing import Optional, Any
import jwt import jwt
from datetime import datetime, timedelta from datetime import datetime, timedelta
import webauthn
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
UserVerificationRequirement,
)
from ..model import db, User, Totp, AppToken, PasskeyCredential from ..model import db, User, Totp, AppToken, PasskeyCredential
from ..form.frontend import ClientCertForm, TOTPForm, \ from ..form.frontend import ClientCertForm, TOTPForm, \
@ -24,7 +31,6 @@ from ..form.frontend import ClientCertForm, TOTPForm, \
AppTokenForm, AppTokenDeleteForm AppTokenForm, AppTokenDeleteForm
from ..form.base import ButtonForm from ..form.base import ButtonForm
from ..auth_providers import PasswordAuthProvider from ..auth_providers import PasswordAuthProvider
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
from ..pki import pki from ..pki import pki
@ -221,14 +227,6 @@ def totp_delete(totp_name) -> ResponseReturnValue:
## Passkey ## Passkey
import webauthn
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
UserVerificationRequirement,
)
@frontend_views.route('/passkey/list', methods=['GET']) @frontend_views.route('/passkey/list', methods=['GET'])
def passkey() -> ResponseReturnValue: def passkey() -> ResponseReturnValue:
"""list registered credentials for current user""" """list registered credentials for current user"""

View file

@ -1,21 +0,0 @@
# 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'))