implement webauthn and recovery codes from keycloak base

This commit is contained in:
Constantin Wildförster 2022-11-08 14:57:53 +01:00
parent 79b24d2b86
commit 0b1682031c
No known key found for this signature in database
GPG key ID: 5DDDBFF28CC85AFB
6 changed files with 558 additions and 1 deletions

View file

@ -0,0 +1,176 @@
<#import "template.ftl" as layout>
<#import "components/button/primary.ftl" as buttonPrimary>
<#import "components/button/secondary.ftl" as buttonSecondary>
<#import "components/checkbox/primary.ftl" as checkboxPrimary>
<@layout.registrationLayout; section>
<#if section = "header">
${kcSanitize(msg("recovery-code-config-header"))}
<#elseif section = "form">
<!-- warning -->
<div class="bg-orange-100 text-orange-600 p-4 rounded-lg text-sm" role="alert">
<p class="font-bold my-2">${kcSanitize(msg("recovery-code-config-warning-title"))}</p>
<span>${kcSanitize(msg("recovery-code-config-warning-message"))}</span>
</div>
<ol class="font-mono kc-recovery-codes-list" id="kc-recovery-codes-list">
<#list recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesList as code>
<li><span class="font-bold">${code?counter}:</span> ${code[0..3]}-${code[4..7]}-${code[8..]}</li>
</#list>
</ol>
<!-- actions -->
<div class="flex items-stretch space-x-4 mb-4">
<button id="printRecoveryCodes" type="button" class="bg-secondary-100 flex justify-center px-2 py-1 relative rounded text-xs text-secondary-600 w-full focus:outline-none focus:ring-2 focus:ring-secondary-600 focus:ring-offset-2 hover:bg-secondary-200 hover:text-secondary-900">
${kcSanitize(msg("recovery-codes-print"))}
</button>
<button id="downloadRecoveryCodes" type="button" class="bg-secondary-100 flex justify-center px-2 py-1 relative rounded text-xs text-secondary-600 w-full focus:outline-none focus:ring-2 focus:ring-secondary-600 focus:ring-offset-2 hover:bg-secondary-200 hover:text-secondary-900">
${kcSanitize(msg("recovery-codes-download"))}
</button>
<button id="copyRecoveryCodes" type="button" class="bg-secondary-100 flex justify-center px-2 py-1 relative rounded text-xs text-secondary-600 w-full focus:outline-none focus:ring-2 focus:ring-secondary-600 focus:ring-offset-2 hover:bg-secondary-200 hover:text-secondary-900">
${kcSanitize(msg("recovery-codes-copy"))}
</button>
</div>
<form action="${url.loginAction}" class="m-0 space-y-4m" method="post">
<input type="hidden" name="generatedRecoveryAuthnCodes" value="${recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesAsString}" />
<input type="hidden" name="generatedAt" value="${recoveryAuthnCodesConfigBean.generatedAt?c}" />
<input type="hidden" id="userLabel" name="userLabel" value="${msg("recovery-codes-label-default")}" />
<!-- confirmation checkbox -->
<@checkboxPrimary.kw id="kcRecoveryCodesConfirmationCheck" name="kcRecoveryCodesConfirmationCheck" required="required">
${kcSanitize(msg("recovery-codes-confirmation-message"))}
</@checkboxPrimary.kw>
<div class="flex flex-col space-y-2 mt-4">
<#if isAppInitiatedAction??>
<@buttonPrimary.kw type="submit" id="saveRecoveryAuthnCodesBtn">
${kcSanitize(msg("recovery-codes-action-complete"))}
</@buttonPrimary.kw>
<@buttonSecondary.kw type="submit" id="cancelRecoveryAuthnCodesBtn" name="cancel-aia" value="true" onclick="document.getElementById('kcRecoveryCodesConfirmationCheck').required=false;return true;">
${kcSanitize(msg("recovery-codes-action-cancel"))}
</@buttonSecondary.kw>
<#else>
<@buttonPrimary.kw type="submit" id="saveRecoveryAuthnCodesBtn">
${kcSanitize(msg("recovery-codes-action-complete"))}
</@buttonPrimary.kw>
</#if>
</div>
</form>
<script>
/* copy recovery codes */
function copyRecoveryCodes() {
var tmpTextarea = document.createElement("textarea");
var codes = document.getElementById("kc-recovery-codes-list").getElementsByTagName("li");
for (i = 0; i < codes.length; i++) {
tmpTextarea.value = tmpTextarea.value + codes[i].innerText + "\n";
}
document.body.appendChild(tmpTextarea);
tmpTextarea.select();
document.execCommand("copy");
document.body.removeChild(tmpTextarea);
}
var copyButton = document.getElementById("copyRecoveryCodes");
copyButton && copyButton.addEventListener("click", function () {
copyRecoveryCodes();
});
/* download recovery codes */
function formatCurrentDateTime() {
var dt = new Date();
var options = {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
timeZoneName: 'short'
};
return dt.toLocaleString('en-US', options);
}
function parseRecoveryCodeList() {
var recoveryCodes = document.querySelectorAll(".kc-recovery-codes-list li");
var recoveryCodeList = "";
for (var i = 0; i < recoveryCodes.length; i++) {
var recoveryCodeLiElement = recoveryCodes[i].innerText;
recoveryCodeList += recoveryCodeLiElement + "\r\n";
}
return recoveryCodeList;
}
function buildDownloadContent() {
var recoveryCodeList = parseRecoveryCodeList();
var dt = new Date();
var options = {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
timeZoneName: 'short'
};
return fileBodyContent =
"${msg("recovery-codes-download-file-header")}\n\n" +
recoveryCodeList + "\n" +
"${msg("recovery-codes-download-file-description")}\n\n" +
"${msg("recovery-codes-download-file-date")} " + formatCurrentDateTime();
}
function setUpDownloadLinkAndDownload(filename, text) {
var el = document.createElement('a');
el.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
el.setAttribute('download', filename);
el.style.display = 'none';
document.body.appendChild(el);
el.click();
document.body.removeChild(el);
}
function downloadRecoveryCodes() {
setUpDownloadLinkAndDownload('kc-download-recovery-codes.txt', buildDownloadContent());
}
var downloadButton = document.getElementById("downloadRecoveryCodes");
downloadButton && downloadButton.addEventListener("click", downloadRecoveryCodes);
/* print recovery codes */
function buildPrintContent() {
var recoveryCodeListHTML = document.getElementById('kc-recovery-codes-list').innerHTML;
var styles =
`@page { size: auto; margin-top: 0; }
body { width: 480px; }
div { list-style-type: none; font-family: monospace }
p:first-of-type { margin-top: 48px }`
return printFileContent =
"<html><style>" + styles + "</style><body>" +
"<title>kc-download-recovery-codes</title>" +
"<p>${msg("recovery-codes-download-file-header")}</p>" +
"<div>" + recoveryCodeListHTML + "</div>" +
"<p>${msg("recovery-codes-download-file-description")}</p>" +
"<p>${msg("recovery-codes-download-file-date")} " + formatCurrentDateTime() + "</p>" +
"</body></html>";
}
function printRecoveryCodes() {
var w = window.open();
w.document.write(buildPrintContent());
w.print();
//w.close();
}
var printButton = document.getElementById("printRecoveryCodes");
printButton && printButton.addEventListener("click", printRecoveryCodes);
</script>
</#if>
</@layout.registrationLayout>

View file

@ -0,0 +1,26 @@
<#import "template.ftl" as layout>
<#import "components/button/primary.ftl" as buttonPrimary>
<#import "components/input/primary.ftl" as inputPrimary>
<@layout.registrationLayout; section>
<#if section="header">
${kcSanitize(msg("auth-recovery-code-header"))}
<#elseif section = "form">
<form class="m-0 space-y-4" action="${url.loginAction}" method="post">
<@inputPrimary.kw
autocomplete="off"
autofocus=true
invalid=["firstName"]
name="recoveryCodeInput"
type="text"
value=(register.formData.firstName)!''
>
${msg("auth-recovery-code-prompt", recoveryAuthnCodesInputBean.codeNumber?c)}
</@inputPrimary.kw>
<@buttonPrimary.kw type="submit" name="login">
${kcSanitize(msg("doLogIn"))}
</@buttonPrimary.kw>
</form>
</#if>
</@layout.registrationLayout>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,130 @@
<#import "template.ftl" as layout>
<#import "components/button/primary.ftl" as buttonPrimary>
<#import "components/button/secondary.ftl" as buttonSecondary>
<@layout.registrationLayout; section>
<#if section="header">
${msg("webauthn-login-title")}
<#elseif section = "form">
<div class="m-0">
<form id="webauth" action="${url.loginAction}" method="post">
<input type="hidden" id="clientDataJSON" name="clientDataJSON"/>
<input type="hidden" id="authenticatorData" name="authenticatorData"/>
<input type="hidden" id="signature" name="signature"/>
<input type="hidden" id="credentialId" name="credentialId"/>
<input type="hidden" id="userHandle" name="userHandle"/>
<input type="hidden" id="error" name="error"/>
</form>
<#if authenticators??>
<form id="authn_select" class="m-0">
<#list authenticators.authenticators as authenticator>
<input type="hidden" name="authn_use_chk" value="${authenticator.credentialId}"/>
</#list>
</form>
<#if shouldDisplayAuthenticators?? && shouldDisplayAuthenticators>
<#if authenticators.authenticators?size gt 1>
<p class="font-bold py-2 text-xl">${kcSanitize(msg("webauthn-available-authenticators"))?no_esc}</p>
</#if>
<#list authenticators.authenticators as authenticator>
<div class="my-5">
<p><span class="font-bold">${kcSanitize(msg('${authenticator.label}'))?no_esc}</span>
<#--<#if authenticator.transports?? && authenticator.transports.displayNameProperties?has_content>
(<#list authenticator.transports.displayNameProperties as nameProperty>
${kcSanitize(msg('${nameProperty!}'))?no_esc}
<#if nameProperty?has_next>, </#if>
</#list>)
</#if>-->
</p>
<p>${kcSanitize(msg('webauthn-createdAt-label'))?no_esc}: <span class="font-bold">${kcSanitize(authenticator.createdAt)?no_esc}</span></p>
</div>
</#list>
</#if>
</#if>
<@buttonPrimary.kw type="button" onclick="webAuthnAuthenticate()" autofocus="autofocus">
${msg("webauthn-doAuthenticate")}
</@buttonPrimary.kw>
</div>
<script type="text/javascript" src="${url.resourcesCommonPath}/node_modules/jquery/dist/jquery.min.js"></script>
<script type="text/javascript" src="${url.resourcesPath}/js/base64url.js"></script>
<script type="text/javascript">
function webAuthnAuthenticate() {
let isUserIdentified = ${isUserIdentified};
if (!isUserIdentified) {
doAuthenticate([]);
return;
}
checkAllowCredentials();
}
function checkAllowCredentials() {
let allowCredentials = [];
let authn_use = document.forms['authn_select'].authn_use_chk;
if (authn_use !== undefined) {
if (authn_use.length === undefined) {
allowCredentials.push({
id: base64url.decode(authn_use.value, {loose: true}),
type: 'public-key',
});
} else {
for (let i = 0; i < authn_use.length; i++) {
allowCredentials.push({
id: base64url.decode(authn_use[i].value, {loose: true}),
type: 'public-key',
});
}
}
}
doAuthenticate(allowCredentials);
}
function doAuthenticate(allowCredentials) {
// Check if WebAuthn is supported by this browser
if (!window.PublicKeyCredential) {
jQuery("#error").val("${msg("webauthn-unsupported-browser-text")?no_esc}");
jQuery("#webauth").submit();
return;
}
let challenge = "${challenge}";
let userVerification = "${userVerification}";
let rpId = "${rpId}";
let publicKey = {
rpId : rpId,
challenge: base64url.decode(challenge, { loose: true })
};
let createTimeout = ${createTimeout};
if (createTimeout !== 0) publicKey.timeout = createTimeout * 1000;
if (allowCredentials.length) {
publicKey.allowCredentials = allowCredentials;
}
if (userVerification !== 'not specified') publicKey.userVerification = userVerification;
navigator.credentials.get({publicKey})
.then((result) => {
window.result = result;
let clientDataJSON = result.response.clientDataJSON;
let authenticatorData = result.response.authenticatorData;
let signature = result.response.signature;
jQuery("#clientDataJSON").val(base64url.encode(new Uint8Array(clientDataJSON), { pad: false }));
jQuery("#authenticatorData").val(base64url.encode(new Uint8Array(authenticatorData), { pad: false }));
jQuery("#signature").val(base64url.encode(new Uint8Array(signature), { pad: false }));
jQuery("#credentialId").val(result.id);
if(result.response.userHandle) {
jQuery("#userHandle").val(base64url.encode(new Uint8Array(result.response.userHandle), { pad: false }));
}
jQuery("#webauth").submit();
})
.catch((err) => {
jQuery("#error").val(err);
jQuery("#webauth").submit();
})
;
}
</script>
<#elseif section = "info">
</#if>
</@layout.registrationLayout>

View file

@ -0,0 +1,36 @@
<#import "template.ftl" as layout>
<#import "components/button/primary.ftl" as buttonPrimary>
<#import "components/button/secondary.ftl" as buttonSecondary>
<@layout.registrationLayout displayMessage=true; section>
<#if section = "header">
${kcSanitize(msg("webauthn-error-title"))}
<#elseif section = "form">
<script type="text/javascript">
refreshPage = () => {
document.getElementById('isSetRetry').value = 'retry';
document.getElementById('executionValue').value = '${execution}';
document.getElementById('kc-error-credential-form').submit();
}
</script>
<form action="${url.loginAction}" method="post" class="m-0">
<input type="hidden" id="executionValue" name="authenticationExecution"/>
<input type="hidden" id="isSetRetry" name="isSetRetry"/>
</form>
<@buttonPrimary.kw type="button" tabindex="4" onclick="refreshPage()">
${kcSanitize(msg("doTryAgain"))}
</@buttonPrimary.kw>
<#if isAppInitiatedAction??>
<form action="${url.loginAction}" method="post" class="m-0">
<@buttonSecondary.kw name="cancel-aia" type="submit" value="true">
${kcSanitize(msg("doCancel"))}
</@buttonSecondary.kw>
</form>
</#if>
</#if>
</@layout.registrationLayout>

View file

@ -0,0 +1,189 @@
<#import "template.ftl" as layout>
<#import "components/button/primary.ftl" as buttonPrimary>
<#import "components/button/secondary.ftl" as buttonSecondary>
<@layout.registrationLayout; section>
<#if section="header">
${kcSanitize(msg("webauthn-registration-title"))}
<#elseif section = "form">
<form id="register" action="${url.loginAction}" class="m-0 space-y-4" method="post">
<div class="${properties.kcFormGroupClass!}">
<input type="hidden" id="clientDataJSON" name="clientDataJSON"/>
<input type="hidden" id="attestationObject" name="attestationObject"/>
<input type="hidden" id="publicKeyCredentialId" name="publicKeyCredentialId"/>
<input type="hidden" id="authenticatorLabel" name="authenticatorLabel"/>
<input type="hidden" id="transports" name="transports"/>
<input type="hidden" id="error" name="error"/>
</div>
</form>
<script type="text/javascript" src="${url.resourcesCommonPath}/node_modules/jquery/dist/jquery.min.js"></script>
<script type="text/javascript" src="${url.resourcesPath}/js/base64url.js"></script>
<script type="text/javascript">
function registerSecurityKey() {
// Check if WebAuthn is supported by this browser
if (!window.PublicKeyCredential) {
jQuery("#error").val("${msg("webauthn-unsupported-browser-text")?no_esc}");
jQuery("#register").submit();
return;
}
// mandatory parameters
let challenge = "${challenge}";
let userid = "${userid}";
let username = "${username}";
let signatureAlgorithms = "${signatureAlgorithms}";
let pubKeyCredParams = getPubKeyCredParams(signatureAlgorithms);
let rpEntityName = "${rpEntityName}";
let rp = {name: rpEntityName};
let publicKey = {
challenge: base64url.decode(challenge, {loose: true}),
rp: rp,
user: {
id: base64url.decode(userid, {loose: true}),
name: username,
displayName: username
},
pubKeyCredParams: pubKeyCredParams,
};
// optional parameters
let rpId = "${rpId}";
publicKey.rp.id = rpId;
let attestationConveyancePreference = "${attestationConveyancePreference}";
if (attestationConveyancePreference !== 'not specified') publicKey.attestation = attestationConveyancePreference;
let authenticatorSelection = {};
let isAuthenticatorSelectionSpecified = false;
let authenticatorAttachment = "${authenticatorAttachment}";
if (authenticatorAttachment !== 'not specified') {
authenticatorSelection.authenticatorAttachment = authenticatorAttachment;
isAuthenticatorSelectionSpecified = true;
}
let requireResidentKey = "${requireResidentKey}";
if (requireResidentKey !== 'not specified') {
if (requireResidentKey === 'Yes')
authenticatorSelection.requireResidentKey = true;
else
authenticatorSelection.requireResidentKey = false;
isAuthenticatorSelectionSpecified = true;
}
let userVerificationRequirement = "${userVerificationRequirement}";
if (userVerificationRequirement !== 'not specified') {
authenticatorSelection.userVerification = userVerificationRequirement;
isAuthenticatorSelectionSpecified = true;
}
if (isAuthenticatorSelectionSpecified) publicKey.authenticatorSelection = authenticatorSelection;
let createTimeout = ${createTimeout};
if (createTimeout !== 0) publicKey.timeout = createTimeout * 1000;
let excludeCredentialIds = "${excludeCredentialIds}";
let excludeCredentials = getExcludeCredentials(excludeCredentialIds);
if (excludeCredentials.length > 0) publicKey.excludeCredentials = excludeCredentials;
navigator.credentials.create({publicKey})
.then(function (result) {
window.result = result;
let clientDataJSON = result.response.clientDataJSON;
let attestationObject = result.response.attestationObject;
let publicKeyCredentialId = result.rawId;
jQuery("#clientDataJSON").val(base64url.encode(new Uint8Array(clientDataJSON), {pad: false}));
jQuery("#attestationObject").val(base64url.encode(new Uint8Array(attestationObject), {pad: false}));
jQuery("#publicKeyCredentialId").val(base64url.encode(new Uint8Array(publicKeyCredentialId), {pad: false}));
if (typeof result.response.getTransports === "function") {
let transports = result.response.getTransports();
if (transports) {
jQuery("#transports").val(getTransportsAsString(transports));
}
} else {
console.log("Your browser is not able to recognize supported transport media for the authenticator.");
}
let initLabel = "WebAuthn Authenticator (Default Label)";
let labelResult = window.prompt("Please input your registered authenticator's label", initLabel);
if (labelResult === null) labelResult = initLabel;
jQuery("#authenticatorLabel").val(labelResult);
jQuery("#register").submit();
})
.catch(function (err) {
jQuery("#error").val(err);
jQuery("#register").submit();
});
}
function getPubKeyCredParams(signatureAlgorithms) {
let pubKeyCredParams = [];
if (signatureAlgorithms === "") {
pubKeyCredParams.push({type: "public-key", alg: -7});
return pubKeyCredParams;
}
let signatureAlgorithmsList = signatureAlgorithms.split(',');
for (let i = 0; i < signatureAlgorithmsList.length; i++) {
pubKeyCredParams.push({
type: "public-key",
alg: signatureAlgorithmsList[i]
});
}
return pubKeyCredParams;
}
function getExcludeCredentials(excludeCredentialIds) {
let excludeCredentials = [];
if (excludeCredentialIds === "") return excludeCredentials;
let excludeCredentialIdsList = excludeCredentialIds.split(',');
for (let i = 0; i < excludeCredentialIdsList.length; i++) {
excludeCredentials.push({
type: "public-key",
id: base64url.decode(excludeCredentialIdsList[i],
{loose: true})
});
}
return excludeCredentials;
}
function getTransportsAsString(transportsList) {
if (transportsList === '' || transportsList.constructor !== Array) return "";
let transportsString = "";
for (let i = 0; i < transportsList.length; i++) {
transportsString += transportsList[i] + ",";
}
return transportsString.slice(0, -1);
}
</script>
<@buttonPrimary.kw type="submit" onclick="registerSecurityKey()">
${kcSanitize(msg("doRegister"))}
</@buttonPrimary.kw>
<#if !isSetRetry?has_content && isAppInitiatedAction?has_content>
<form action="${url.loginAction}" method="post" class="m-0">
<@buttonSecondary.kw name="cancel-aia" type="submit" value="true">
${kcSanitize(msg("doCancel"))}
</@buttonSecondary.kw>
</form>
</#if>
</#if>
</@layout.registrationLayout>