implement basic passkey login flow
This commit is contained in:
parent
926afee5c5
commit
0a1da35d84
|
@ -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');
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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'))
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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"""
|
||||||
|
|
|
@ -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'))
|
|
Loading…
Reference in a new issue