diff --git a/browser_app/index.js b/browser_app/index.js
index f125c4c..375933c 100644
--- a/browser_app/index.js
+++ b/browser_app/index.js
@@ -12,6 +12,7 @@ var asn1 = require('node-forge/lib/asn1');
var pkcs12 = require('node-forge/lib/pkcs12');
var util = require('node-forge/lib/util');
import SimpleFormSubmit from "simple-form-submit";
+import {startRegistration, startAuthentication} from '@simplewebauthn/browser';
const $ = document.querySelector.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 = {
init_list: function(){
},
@@ -133,11 +145,6 @@ window.totp = {
}
}
-window.fido2 = {
- init: function() {
-
- }
-}
window.password_change= {
init: function(){
var form = $('form');
diff --git a/lenticular_cloud/auth_providers.py b/lenticular_cloud/auth_providers.py
index 0570ed1..24af7ec 100644
--- a/lenticular_cloud/auth_providers.py
+++ b/lenticular_cloud/auth_providers.py
@@ -1,6 +1,6 @@
from flask import current_app
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
import crypt
from .model import User
diff --git a/lenticular_cloud/form/auth.py b/lenticular_cloud/form/auth.py
index bc3712d..7bc1c15 100644
--- a/lenticular_cloud/form/auth.py
+++ b/lenticular_cloud/form/auth.py
@@ -26,15 +26,6 @@ class TotpForm(FlaskForm):
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):
# scopes = SelectMultipleField(gettext('scopes'))
diff --git a/lenticular_cloud/template/auth/login.html.j2 b/lenticular_cloud/template/auth/login.html.j2
index 2b8e5d1..edc2b2c 100644
--- a/lenticular_cloud/template/auth/login.html.j2
+++ b/lenticular_cloud/template/auth/login.html.j2
@@ -2,12 +2,53 @@
{% block title %}{{ gettext('Login') }}{% endblock %}
-{% block content %}
+{% block script %}
+
+{% endblock %}
+
+{% block content %}
+
+
+ {{ render_form(form) }}
+
+
+
+
+
+
{% endblock %}
diff --git a/lenticular_cloud/template/frontend/passkey_new.html.j2 b/lenticular_cloud/template/frontend/passkey_new.html.j2
index 04a3dbb..d8abbb6 100644
--- a/lenticular_cloud/template/frontend/passkey_new.html.j2
+++ b/lenticular_cloud/template/frontend/passkey_new.html.j2
@@ -6,24 +6,9 @@
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;
diff --git a/lenticular_cloud/views/auth.py b/lenticular_cloud/views/auth.py
index 5d4f2e7..62cca3b 100644
--- a/lenticular_cloud/views/auth.py
+++ b/lenticular_cloud/views/auth.py
@@ -1,39 +1,35 @@
-from urllib.parse import urlencode, parse_qs
-
-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
+from base64 import b64encode, b64decode, urlsafe_b64decode
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 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.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 urllib.parse import urlparse
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 ..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'])
@@ -98,6 +94,9 @@ async def consent() -> ResponseReturnValue:
@auth_views.route('/login', methods=['GET', 'POST'])
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')
if login_challenge is None:
return 'login_challenge missing', 400
@@ -106,6 +105,21 @@ async def login() -> ResponseReturnValue:
logger.exception("could not fetch login request")
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:
resp = await accept_o_auth_2_login_request.asyncio(_client=hydra_service.hydra_client,
login_challenge=login_challenge,
@@ -124,7 +138,13 @@ async def login() -> ResponseReturnValue:
session['auth_providers'] = []
return redirect(
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'])
@@ -171,21 +191,54 @@ async def login_auth() -> ResponseReturnValue:
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
- 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')
+ data = request.get_json()
+ token = jwt.decode(data['token'], secret_key, algorithms=['HS256'])
+ challenge = urlsafe_b64decode(token['challenge'])
+ credential = data['credential']
+ credential_id = urlsafe_b64decode(credential['id'])
- 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")
async def logout() -> ResponseReturnValue:
diff --git a/lenticular_cloud/views/frontend.py b/lenticular_cloud/views/frontend.py
index edc7034..9337eff 100644
--- a/lenticular_cloud/views/frontend.py
+++ b/lenticular_cloud/views/frontend.py
@@ -17,6 +17,13 @@ from urllib.parse import urlparse
from typing import Optional, Any
import jwt
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 ..form.frontend import ClientCertForm, TOTPForm, \
@@ -24,7 +31,6 @@ from ..form.frontend import ClientCertForm, TOTPForm, \
AppTokenForm, AppTokenDeleteForm
from ..form.base import ButtonForm
from ..auth_providers import PasswordAuthProvider
-from .auth import webauthn
from .oauth2 import redirect_login, oauth2
from ..hydra import hydra_service
from ..pki import pki
@@ -221,14 +227,6 @@ def totp_delete(totp_name) -> 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"""
diff --git a/lenticular_cloud/wrapped_fido2_server.py b/lenticular_cloud/wrapped_fido2_server.py
deleted file mode 100644
index 585e34e..0000000
--- a/lenticular_cloud/wrapped_fido2_server.py
+++ /dev/null
@@ -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'))