import groovy.json.JsonBuilder import groovy.json.JsonSlurper import java.util.UUID if (inargs.containsKey('cancel_fido2')) { response.setResult('cancel') LOG.debug("Fido2Auth: authentication cancelled by user") return } def base64url(uuid) { def msb = uuid.getMostSignificantBits() def lsb = uuid.getLeastSignificantBits() return new byte[] { (byte) msb, (byte) (msb >> 8), (byte) (msb >> 16), (byte) (msb >> 24), (byte) (msb >> 32), (byte) (msb >> 40), (byte) (msb >> 48), (byte) (msb >> 56), (byte) lsb, (byte) (lsb >> 8), (byte) (lsb >> 16), (byte) (lsb >> 24), (byte) (lsb >> 32), (byte) (lsb >> 40), (byte) (lsb >> 48), (byte) (lsb >> 56) }.encodeBase64Url().toString() } def showGui() { response.setGuiName('fido2_auth') // name is the trigger for including the JS response.setGuiLabel('title.login.fido2') response.addInfoGuiField('info', 'info.login.fido2', null) response.addHiddenGuiField('authRequestId', 'not used', session['ch.nevis.auth.saml.request.id']) response.addTextGuiField('email', 'email', session['ch.nevis.idm.User.email']) if (notes.containsKey('lasterrorinfo') || notes.containsKey('lasterror')) { response.addErrorGuiField('lasterror', notes['lasterrorinfo'], notes['lasterror']) } if (parameters.containsKey('cancel')) { response.addButtonGuiField('cancel_fido2', 'cancel.login.fido2.button.label', 'true') } } def getPath() { if (inargs.containsKey('path')) { // form POST return inargs['path'] } if (inargs.containsKey('o.path.v')) { // AJAX POST return inargs['o.path.v'] } return null } def post(connection, json) { connection.setRequestMethod("POST") connection.setRequestProperty("Content-Type", "application/json") connection.setDoOutput(true) // required to write body String body = json.toString() LOG.debug("Fido2Auth: ==> Request: '${body}'") connection.getOutputStream().write(body.getBytes()) } String userExtId = session['ch.adnovum.nevisidm.user.extId'] ?: session['ch.nevis.idm.User.extId'] ?: request.getUserId() ?: notes['userid'] if (userExtId == null) { LOG.error("Fido2Auth: missing extId of nevisIDM user. check your authentication flow.") } // without the user extId this script won't work and we can fail with a System Error Objects.requireNonNull(userExtId) def path = getPath() if (path == null) { showGui() // POST from JavaScript not received return } def connection = null try { def fullPath = "https://${parameters.get('fido')}${path}" LOG.debug("Fido2Auth: opening connection to '${fullPath}'") connection = new URL(fullPath).openConnection() } catch (Exception e) { LOG.error("Fido2Auth: opening connection failed", e) notes.setProperty('lasterrorinfo', 'FIDO2 authentication failed') response.setResult('error') return } def json = new JsonBuilder() if (path == '/nevisfido/fido2/attestation/options') { json { "username" userExtId "userVerification" "required" } post(connection, json) def responseCode = connection.responseCode // non existing account, or account without FIDO2 key case if (responseCode == 404 || responseCode == 400) { LOG.debug("Fido2Auth: <== Response: ${responseCode}") // Accounting def requester = session['ch.nevis.auth.saml.request.scoping.requesterId'] ?: 'unknown' def requestId = session['ch.nevis.auth.saml.request.id'] ?: 'unknown' def requestedAq = session['agov.requestedRoleLevel'] ?: 'unknown' def user = session['ch.adnovum.nevisidm.user.extId'] ?: 'unknown' def credentialType = session['authenticatedWith'] ?: 'unknown' def sourceIp = request.getLoginContext()['connection.HttpHeader.X-Real-IP'] ?: 'unknown' def userAgent = request.getLoginContext()['connection.HttpHeader.user-agent'] ?: request.getLoginContext()['connection.HttpHeader.User-Agent'] ?: 'unknown' def tAuth = System.currentTimeMillis() - (request.getSession(true).getCreationTime().getEpochSecond() * 1000) LOG.info("Event='NOACCOUNT', Requester='${requester}', RequestId='${requestId}', RequestedAq=${requestedAq}, User=${session['ch.nevis.idm.User.email']}, CredentialType='${credentialType}', tAuth=${tAuth}ms, SourceIp=${sourceIp}, UserAgent='${userAgent}'") // returning a fake options structure, which shouldn't leak whether the user account exists or not // keyId is unique per environment and email, fido2SessionId and challenge are renewed each time def keyId = UUID.nameUUIDFromBytes("${parameters['rpId']}.${session['ch.nevis.idm.User.email']}".getBytes()) def responseText = """{"status": "ok", "errorMessage": "", "fido2SessionId": "${UUID.randomUUID()}", "challenge": "${base64url(UUID.randomUUID())}", "timeout": 300000, "rpId": "${parameters['rpId']}", "allowCredentials": [ { "type": "public-key", "id": "${base64url(keyId)}", "transports": [] } ], "userVerification": "required"}""" response.setContent(responseText) // return response from nevisFIDO "as-is" response.setContentType('application/json') response.setHttpStatusCode(200) response.setIsDirectResponse(true) return } def responseText = connection.inputStream.text LOG.debug("Fido2Auth: <== Response: ${responseCode} : ${responseText}") response.setContent(responseText) // return response from nevisFIDO "as-is" response.setContentType('application/json') response.setHttpStatusCode(200) response.setIsDirectResponse(true) return } if (path == '/nevisfido/fido2/assertion/result') { if (inargs.containsKey('authRequestId') && (inargs['authRequestId'] != session['ch.nevis.auth.saml.request.id'])) { // wrong request, "force" a timeout LOG.debug('Fido2Auth: authentication timeout enforced, due to concurrent requests') response.setIsDirectResponse(true) response.setContentType('text/html; charset=UTF-8') response.setContent('Timeout') response.setHttpStatusCode(205) response.setHeader('IDP-AUTH', 'Timeout') // CONTINUE to keep the other request beeing processed response.setStatus(AuthResponse.AUTH_CONTINUE) return } def userHandleValue = userExtId.getBytes().encodeBase64Url().toString() LOG.debug("Fido2Auth: encoded userHandle: ${userHandleValue}") json { "id" inargs['id'] "type" inargs['type'] response { "clientDataJSON" inargs['response.clientDataJSON'] "authenticatorData" inargs['response.authenticatorData'] "signature" inargs['response.signature'] "userHandle" userHandleValue } } post(connection, json) def responseCode = connection.responseCode // test if credentials exist if (responseCode != 400) { def responseText = connection.inputStream.text LOG.debug("Fido2Auth: <== Response: ${responseCode} : ${responseText}") if (responseCode == 200 && new JsonSlurper().parseText(responseText).status == 'ok') { response.setResult('ok') return } } //response.setHttpStatusCode(400) //response.setIsDirectResponse(true) // DEFINE how to handel error notes.setProperty('lasterror', '1') notes.setProperty('lasterrorinfo', 'FIDO2 authentication failed') response.setResult('error') return } response.setError(1, "FIDO2 authentication failed") showGui()