mirror of
https://github.com/lukin/keywind.git
synced 2025-01-08 08:56:22 +00:00
feat: add recovery authn code and webauthn pages (#23)
Co-authored-by: Anthony Lukin <anthony@lukin.dev>
This commit is contained in:
parent
3b4e62dc78
commit
439cdabc8f
23 changed files with 774 additions and 10 deletions
|
@ -11,11 +11,17 @@ Keywind is a component-based Keycloak Login Theme built with [Tailwind CSS](http
|
|||
- Login IDP Link Confirm
|
||||
- Login OAuth Grant
|
||||
- Login OTP
|
||||
- Login Recovery Authn Code Config
|
||||
- Login Recovery Authn Code Input
|
||||
- Login Reset Password
|
||||
- Login Update Password
|
||||
- Login Update Profile
|
||||
- Logout Confirm
|
||||
- Register
|
||||
- Select Authenticator
|
||||
- WebAuthn Authenticate
|
||||
- WebAuthn Error
|
||||
- WebAuthn Register
|
||||
|
||||
### Identity Provider Icons
|
||||
|
||||
|
|
|
@ -6,7 +6,8 @@
|
|||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"alpinejs": "^3.10.5"
|
||||
"alpinejs": "^3.10.5",
|
||||
"rfc4648": "^1.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
|
|
|
@ -6,12 +6,14 @@ specifiers:
|
|||
alpinejs: ^3.10.5
|
||||
autoprefixer: ^10.4.13
|
||||
postcss: ^8.4.20
|
||||
rfc4648: ^1.5.2
|
||||
tailwindcss: ^3.2.4
|
||||
typescript: ^4.9.4
|
||||
vite: ^4.0.1
|
||||
|
||||
dependencies:
|
||||
alpinejs: 3.10.5
|
||||
rfc4648: 1.5.2
|
||||
|
||||
devDependencies:
|
||||
'@tailwindcss/forms': 0.5.3_tailwindcss@3.2.4
|
||||
|
@ -716,6 +718,10 @@ packages:
|
|||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/rfc4648/1.5.2:
|
||||
resolution: {integrity: sha512-tLOizhR6YGovrEBLatX1sdcuhoSCXddw3mqNVAcKxGJ+J0hFeJ+SjeWCv5UPA/WU3YzWPPuCVYgXBKZUPGpKtg==}
|
||||
dev: false
|
||||
|
||||
/rollup/3.7.5:
|
||||
resolution: {integrity: sha512-z0ZbqHBtS/et2EEUKMrAl2CoSdwN7ZPzL17UMiKN9RjjqHShTlv7F9J6ZJZJNREYjBh3TvBrdfjkFDIXFNeuiQ==}
|
||||
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
|
||||
|
|
59
src/data/recoveryCodes.ts
Normal file
59
src/data/recoveryCodes.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
import Alpine from 'alpinejs';
|
||||
|
||||
type DataType = {
|
||||
$refs: RefsType;
|
||||
$store: StoreType;
|
||||
};
|
||||
|
||||
type RefsType = {
|
||||
codeList: HTMLUListElement;
|
||||
};
|
||||
|
||||
type StoreType = {
|
||||
recoveryCodes: {
|
||||
downloadFileDate: string;
|
||||
downloadFileDescription: string;
|
||||
downloadFileHeader: string;
|
||||
downloadFileName: string;
|
||||
};
|
||||
};
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('recoveryCodes', function (this: DataType) {
|
||||
const { codeList } = this.$refs;
|
||||
const { downloadFileDate, downloadFileDescription, downloadFileHeader, downloadFileName } =
|
||||
this.$store.recoveryCodes;
|
||||
|
||||
const date = new Date().toLocaleString(navigator.language);
|
||||
|
||||
const codeElements = codeList.getElementsByTagName('li');
|
||||
const codes = Array.from(codeElements)
|
||||
.map((codeElement) => codeElement.innerText)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
copy: () => navigator.clipboard.writeText(codes),
|
||||
download: () => {
|
||||
const element = document.createElement('a');
|
||||
const text = `${downloadFileHeader}\n\n${codes}\n\n${downloadFileDescription}\n\n${downloadFileDate} ${date}`;
|
||||
|
||||
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
|
||||
element.setAttribute('download', `${downloadFileName}.txt`);
|
||||
element.click();
|
||||
},
|
||||
print: () => {
|
||||
const codeListHTML = codeList.innerHTML;
|
||||
const styles = 'div { font-family: monospace; list-style-type: none }';
|
||||
const content = `<html><style>${styles}</style><body><title>${downloadFileName}</title><p>${downloadFileHeader}</p><div>${codeListHTML}</div><p>${downloadFileDescription}</p><p>${downloadFileDate} ${date}</p></body></html>`;
|
||||
|
||||
const printWindow = window.open();
|
||||
|
||||
if (printWindow) {
|
||||
printWindow.document.write(content);
|
||||
printWindow.print();
|
||||
printWindow.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
145
src/data/webAuthnAuthenticate.ts
Normal file
145
src/data/webAuthnAuthenticate.ts
Normal file
|
@ -0,0 +1,145 @@
|
|||
import Alpine from 'alpinejs';
|
||||
import { base64url } from 'rfc4648';
|
||||
|
||||
type DataType = {
|
||||
$refs: RefsType;
|
||||
$store: StoreType;
|
||||
};
|
||||
|
||||
type RefsType = {
|
||||
authenticatorDataInput: HTMLInputElement;
|
||||
authnSelectForm: HTMLFormElement;
|
||||
clientDataJSONInput: HTMLInputElement;
|
||||
credentialIdInput: HTMLInputElement;
|
||||
errorInput: HTMLInputElement;
|
||||
signatureInput: HTMLInputElement;
|
||||
userHandleInput: HTMLInputElement;
|
||||
webAuthnForm: HTMLFormElement;
|
||||
};
|
||||
|
||||
type StoreType = {
|
||||
webAuthnAuthenticate: {
|
||||
challenge: string;
|
||||
createTimeout: string;
|
||||
isUserIdentified: string;
|
||||
rpId: string;
|
||||
unsupportedBrowserText: string;
|
||||
userVerification: UserVerificationRequirement | 'not specified';
|
||||
};
|
||||
};
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('webAuthnAuthenticate', function (this: DataType) {
|
||||
const {
|
||||
authenticatorDataInput,
|
||||
authnSelectForm,
|
||||
clientDataJSONInput,
|
||||
credentialIdInput,
|
||||
errorInput,
|
||||
signatureInput,
|
||||
userHandleInput,
|
||||
webAuthnForm,
|
||||
} = this.$refs;
|
||||
const {
|
||||
challenge,
|
||||
createTimeout,
|
||||
isUserIdentified,
|
||||
rpId,
|
||||
unsupportedBrowserText,
|
||||
userVerification,
|
||||
} = this.$store.webAuthnAuthenticate;
|
||||
|
||||
const doAuthenticate = (allowCredentials: PublicKeyCredentialDescriptor[]) => {
|
||||
if (!window.PublicKeyCredential) {
|
||||
errorInput.value = unsupportedBrowserText;
|
||||
webAuthnForm.submit();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const publicKey: PublicKeyCredentialRequestOptions = {
|
||||
challenge: base64url.parse(challenge, { loose: true }),
|
||||
rpId: rpId,
|
||||
};
|
||||
|
||||
if (allowCredentials.length) {
|
||||
publicKey.allowCredentials = allowCredentials;
|
||||
}
|
||||
|
||||
if (parseInt(createTimeout) !== 0) publicKey.timeout = parseInt(createTimeout) * 1000;
|
||||
|
||||
if (userVerification !== 'not specified') publicKey.userVerification = userVerification;
|
||||
|
||||
navigator.credentials
|
||||
.get({ publicKey })
|
||||
.then((result) => {
|
||||
if (
|
||||
result instanceof PublicKeyCredential &&
|
||||
result.response instanceof AuthenticatorAssertionResponse
|
||||
) {
|
||||
window.result = result;
|
||||
|
||||
authenticatorDataInput.value = base64url.stringify(
|
||||
new Uint8Array(result.response.authenticatorData),
|
||||
{ pad: false }
|
||||
);
|
||||
|
||||
clientDataJSONInput.value = base64url.stringify(
|
||||
new Uint8Array(result.response.clientDataJSON),
|
||||
{ pad: false }
|
||||
);
|
||||
|
||||
signatureInput.value = base64url.stringify(new Uint8Array(result.response.signature), {
|
||||
pad: false,
|
||||
});
|
||||
|
||||
credentialIdInput.value = result.id;
|
||||
|
||||
if (result.response.userHandle) {
|
||||
userHandleInput.value = base64url.stringify(
|
||||
new Uint8Array(result.response.userHandle),
|
||||
{ pad: false }
|
||||
);
|
||||
}
|
||||
|
||||
webAuthnForm.submit();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
errorInput.value = error;
|
||||
webAuthnForm.submit();
|
||||
});
|
||||
};
|
||||
|
||||
const checkAllowCredentials = () => {
|
||||
const allowCredentials: PublicKeyCredentialDescriptor[] = [];
|
||||
|
||||
const authnSelectFormElements = Array.from(authnSelectForm.elements);
|
||||
|
||||
if (authnSelectFormElements.length) {
|
||||
authnSelectFormElements.forEach((element) => {
|
||||
if (element instanceof HTMLInputElement) {
|
||||
allowCredentials.push({
|
||||
id: base64url.parse(element.value, { loose: true }),
|
||||
type: 'public-key',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
doAuthenticate(allowCredentials);
|
||||
};
|
||||
|
||||
return {
|
||||
webAuthnAuthenticate: () => {
|
||||
if (!isUserIdentified) {
|
||||
doAuthenticate([]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
checkAllowCredentials();
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
225
src/data/webAuthnRegister.ts
Normal file
225
src/data/webAuthnRegister.ts
Normal file
|
@ -0,0 +1,225 @@
|
|||
import Alpine from 'alpinejs';
|
||||
import { base64url } from 'rfc4648';
|
||||
|
||||
type DataType = {
|
||||
$refs: RefsType;
|
||||
$store: StoreType;
|
||||
};
|
||||
|
||||
type RefsType = {
|
||||
attestationObjectInput: HTMLInputElement;
|
||||
authenticatorLabelInput: HTMLInputElement;
|
||||
clientDataJSONInput: HTMLInputElement;
|
||||
errorInput: HTMLInputElement;
|
||||
publicKeyCredentialIdInput: HTMLInputElement;
|
||||
registerForm: HTMLFormElement;
|
||||
transportsInput: HTMLInputElement;
|
||||
};
|
||||
|
||||
type StoreType = {
|
||||
webAuthnRegister: {
|
||||
attestationConveyancePreference: AttestationConveyancePreference | 'not specified';
|
||||
authenticatorAttachment: AuthenticatorAttachment | 'not specified';
|
||||
challenge: string;
|
||||
createTimeout: string;
|
||||
excludeCredentialIds: string;
|
||||
requireResidentKey: string;
|
||||
rpEntityName: string;
|
||||
rpId: string;
|
||||
signatureAlgorithms: string;
|
||||
unsupportedBrowserText: string;
|
||||
userId: string;
|
||||
userVerificationRequirement: UserVerificationRequirement | 'not specified';
|
||||
username: string;
|
||||
};
|
||||
};
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('webAuthnRegister', function (this: DataType) {
|
||||
const {
|
||||
attestationObjectInput,
|
||||
authenticatorLabelInput,
|
||||
clientDataJSONInput,
|
||||
errorInput,
|
||||
publicKeyCredentialIdInput,
|
||||
registerForm,
|
||||
transportsInput,
|
||||
} = this.$refs;
|
||||
const {
|
||||
attestationConveyancePreference,
|
||||
authenticatorAttachment,
|
||||
challenge,
|
||||
createTimeout,
|
||||
excludeCredentialIds,
|
||||
requireResidentKey,
|
||||
rpEntityName,
|
||||
rpId,
|
||||
signatureAlgorithms,
|
||||
unsupportedBrowserText,
|
||||
userId,
|
||||
userVerificationRequirement,
|
||||
username,
|
||||
} = this.$store.webAuthnRegister;
|
||||
|
||||
const getPubKeyCredParams = (signatureAlgorithms: string) => {
|
||||
const pubKeyCredParams: PublicKeyCredentialParameters[] = [];
|
||||
|
||||
if (signatureAlgorithms === '') {
|
||||
pubKeyCredParams.push({ alg: -7, type: 'public-key' });
|
||||
|
||||
return pubKeyCredParams;
|
||||
}
|
||||
|
||||
const signatureAlgorithmsList = signatureAlgorithms.split(',');
|
||||
|
||||
signatureAlgorithmsList.forEach((value) => {
|
||||
pubKeyCredParams.push({
|
||||
alg: parseInt(value),
|
||||
type: 'public-key',
|
||||
});
|
||||
});
|
||||
|
||||
return pubKeyCredParams;
|
||||
};
|
||||
|
||||
const getExcludeCredentials = (excludeCredentialIds: string) => {
|
||||
const excludeCredentials: PublicKeyCredentialDescriptor[] = [];
|
||||
|
||||
if (excludeCredentialIds === '') return excludeCredentials;
|
||||
|
||||
const excludeCredentialIdsList = excludeCredentialIds.split(',');
|
||||
|
||||
excludeCredentialIdsList.forEach((value) => {
|
||||
excludeCredentials.push({
|
||||
id: base64url.parse(value, { loose: true }),
|
||||
type: 'public-key',
|
||||
});
|
||||
});
|
||||
|
||||
return excludeCredentials;
|
||||
};
|
||||
|
||||
const getTransportsAsString = (transportsList: string | string[]) => {
|
||||
if (transportsList === '' || transportsList.constructor !== Array) return '';
|
||||
|
||||
let transportsString = '';
|
||||
|
||||
transportsList.forEach((value) => {
|
||||
transportsString += value + ',';
|
||||
});
|
||||
|
||||
return transportsString.slice(0, -1);
|
||||
};
|
||||
|
||||
return {
|
||||
registerSecurityKey: () => {
|
||||
if (!window.PublicKeyCredential) {
|
||||
errorInput.value = unsupportedBrowserText;
|
||||
registerForm.submit();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const publicKey: PublicKeyCredentialCreationOptions = {
|
||||
challenge: base64url.parse(challenge, { loose: true }),
|
||||
pubKeyCredParams: getPubKeyCredParams(signatureAlgorithms),
|
||||
rp: {
|
||||
id: rpId,
|
||||
name: rpEntityName,
|
||||
},
|
||||
user: {
|
||||
displayName: username,
|
||||
id: base64url.parse(userId, { loose: true }),
|
||||
name: username,
|
||||
},
|
||||
};
|
||||
|
||||
if (attestationConveyancePreference !== 'not specified')
|
||||
publicKey.attestation = attestationConveyancePreference;
|
||||
|
||||
const authenticatorSelection: AuthenticatorSelectionCriteria = {};
|
||||
let isAuthenticatorSelectionSpecified = false;
|
||||
|
||||
if (authenticatorAttachment !== 'not specified') {
|
||||
authenticatorSelection.authenticatorAttachment = authenticatorAttachment;
|
||||
isAuthenticatorSelectionSpecified = true;
|
||||
}
|
||||
|
||||
if (requireResidentKey !== 'not specified') {
|
||||
if (requireResidentKey === 'Yes') authenticatorSelection.requireResidentKey = true;
|
||||
else authenticatorSelection.requireResidentKey = false;
|
||||
isAuthenticatorSelectionSpecified = true;
|
||||
}
|
||||
|
||||
if (userVerificationRequirement !== 'not specified') {
|
||||
authenticatorSelection.userVerification = userVerificationRequirement;
|
||||
isAuthenticatorSelectionSpecified = true;
|
||||
}
|
||||
|
||||
if (isAuthenticatorSelectionSpecified)
|
||||
publicKey.authenticatorSelection = authenticatorSelection;
|
||||
|
||||
const excludeCredentials = getExcludeCredentials(excludeCredentialIds);
|
||||
if (excludeCredentials.length > 0) publicKey.excludeCredentials = excludeCredentials;
|
||||
|
||||
if (parseInt(createTimeout) !== 0) publicKey.timeout = parseInt(createTimeout) * 1000;
|
||||
|
||||
navigator.credentials
|
||||
.create({ publicKey })
|
||||
.then((result) => {
|
||||
if (
|
||||
result instanceof PublicKeyCredential &&
|
||||
result.response instanceof AuthenticatorAttestationResponse
|
||||
) {
|
||||
const { getTransports } = result.response;
|
||||
|
||||
window.result = result;
|
||||
|
||||
const publicKeyCredentialId = result.rawId;
|
||||
|
||||
attestationObjectInput.value = base64url.stringify(
|
||||
new Uint8Array(result.response.attestationObject),
|
||||
{ pad: false }
|
||||
);
|
||||
|
||||
clientDataJSONInput.value = base64url.stringify(
|
||||
new Uint8Array(result.response.clientDataJSON),
|
||||
{ pad: false }
|
||||
);
|
||||
|
||||
publicKeyCredentialIdInput.value = base64url.stringify(
|
||||
new Uint8Array(publicKeyCredentialId),
|
||||
{ pad: false }
|
||||
);
|
||||
|
||||
if (typeof getTransports === 'function') {
|
||||
const transports = getTransports();
|
||||
|
||||
if (transports) {
|
||||
transportsInput.value = getTransportsAsString(transports);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
'Your browser is not able to recognize supported transport media for the authenticator.'
|
||||
);
|
||||
}
|
||||
|
||||
const initLabel = 'WebAuthn Authenticator (Default Label)';
|
||||
let labelResult = window.prompt(
|
||||
"Please input your registered authenticator's label",
|
||||
initLabel
|
||||
);
|
||||
if (labelResult === null) labelResult = initLabel;
|
||||
|
||||
authenticatorLabelInput.value = labelResult;
|
||||
registerForm.submit();
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
error.value = error;
|
||||
registerForm.submit();
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
1
src/global.d.ts
vendored
1
src/global.d.ts
vendored
|
@ -3,5 +3,6 @@ import type { Alpine } from 'alpinejs';
|
|||
declare global {
|
||||
interface Window {
|
||||
Alpine: Alpine;
|
||||
result?: PublicKeyCredential;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
<#macro kw linkHref="" linkTitle="" name="">
|
||||
<div class="flex items-center justify-center mb-4 space-x-2">
|
||||
<b>${name}</b>
|
||||
<span class="font-medium">${name}</span>
|
||||
<@link.kw
|
||||
color="primary"
|
||||
href=linkHref
|
||||
|
|
|
@ -31,7 +31,7 @@
|
|||
<#if mode?? && mode="manual">
|
||||
<li>
|
||||
<p>${msg("loginTotpManualStep2")}</p>
|
||||
<p class="font-bold text-xl">${totp.totpSecretEncoded}</p>
|
||||
<p class="font-medium text-xl">${totp.totpSecretEncoded}</p>
|
||||
</li>
|
||||
<li>
|
||||
<@link.kw color="primary" href=totp.qrUrl>
|
||||
|
|
91
theme/keywind/login/login-recovery-authn-code-config.ftl
Normal file
91
theme/keywind/login/login-recovery-authn-code-config.ftl
Normal file
|
@ -0,0 +1,91 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<#import "components/atoms/alert.ftl" as alert>
|
||||
<#import "components/atoms/button.ftl" as button>
|
||||
<#import "components/atoms/button-group.ftl" as buttonGroup>
|
||||
<#import "components/atoms/checkbox.ftl" as checkbox>
|
||||
<#import "components/atoms/form.ftl" as form>
|
||||
|
||||
<@layout.registrationLayout script="dist/recoveryCodes.js"; section>
|
||||
<#if section="header">
|
||||
${msg("recovery-code-config-header")}
|
||||
<#elseif section="form">
|
||||
<div class="space-y-6" x-data="recoveryCodes">
|
||||
<@alert.kw color="warning">
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-medium">${msg("recovery-code-config-warning-title")}</h4>
|
||||
<p>${msg("recovery-code-config-warning-message")}</p>
|
||||
</div>
|
||||
</@alert.kw>
|
||||
<ul class="columns-2 font-mono text-center" x-ref="codeList">
|
||||
<#list recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesList as code>
|
||||
<li>${code[0..3]}-${code[4..7]}-${code[8..]}</li>
|
||||
</#list>
|
||||
</ul>
|
||||
<div class="flex items-stretch space-x-4 mb-4">
|
||||
<@button.kw @click="print" color="secondary" size="small" type="button">
|
||||
${msg("recovery-codes-print")}
|
||||
</@button.kw>
|
||||
<@button.kw @click="download" color="secondary" size="small" type="button">
|
||||
${msg("recovery-codes-download")}
|
||||
</@button.kw>
|
||||
<@button.kw @click="copy" color="secondary" size="small" type="button">
|
||||
${msg("recovery-codes-copy")}
|
||||
</@button.kw>
|
||||
</div>
|
||||
<@form.kw action=url.loginAction method="post">
|
||||
<input
|
||||
name="generatedRecoveryAuthnCodes"
|
||||
type="hidden"
|
||||
value="${recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesAsString}"
|
||||
/>
|
||||
<input
|
||||
name="generatedAt"
|
||||
type="hidden"
|
||||
value="${recoveryAuthnCodesConfigBean.generatedAt?c}"
|
||||
/>
|
||||
<input
|
||||
name="userLabel"
|
||||
type="hidden"
|
||||
value="${msg('recovery-codes-label-default')}"
|
||||
/>
|
||||
<@checkbox.kw
|
||||
label=msg("recovery-codes-confirmation-message")
|
||||
name="kcRecoveryCodesConfirmationCheck"
|
||||
required="required"
|
||||
x\-ref="confirmationCheck"
|
||||
/>
|
||||
<@buttonGroup.kw>
|
||||
<#if isAppInitiatedAction??>
|
||||
<@button.kw color="primary" type="submit">
|
||||
${msg("recovery-codes-action-complete")}
|
||||
</@button.kw>
|
||||
<@button.kw
|
||||
@click="$refs.confirmationCheck.required = false"
|
||||
color="secondary"
|
||||
name="cancel-aia"
|
||||
type="submit"
|
||||
value="true"
|
||||
>
|
||||
${msg("recovery-codes-action-cancel")}
|
||||
</@button.kw>
|
||||
<#else>
|
||||
<@button.kw color="primary" type="submit">
|
||||
${msg("recovery-codes-action-complete")}
|
||||
</@button.kw>
|
||||
</#if>
|
||||
</@buttonGroup.kw>
|
||||
</@form.kw>
|
||||
</div>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
||||
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('recoveryCodes', {
|
||||
downloadFileDate: '${msg("recovery-codes-download-file-date")}',
|
||||
downloadFileDescription: '${msg("recovery-codes-download-file-description")}',
|
||||
downloadFileHeader: '${msg("recovery-codes-download-file-header")}',
|
||||
downloadFileName: 'kc-download-recovery-codes',
|
||||
})
|
||||
})
|
||||
</script>
|
26
theme/keywind/login/login-recovery-authn-code-input.ftl
Normal file
26
theme/keywind/login/login-recovery-authn-code-input.ftl
Normal file
|
@ -0,0 +1,26 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<#import "components/atoms/button.ftl" as button>
|
||||
<#import "components/atoms/button-group.ftl" as buttonGroup>
|
||||
<#import "components/atoms/form.ftl" as form>
|
||||
<#import "components/atoms/input.ftl" as input>
|
||||
|
||||
<@layout.registrationLayout; section>
|
||||
<#if section="header">
|
||||
${msg("auth-recovery-code-header")}
|
||||
<#elseif section="form">
|
||||
<@form.kw action=url.loginAction method="post">
|
||||
<@input.kw
|
||||
autocomplete="off"
|
||||
autofocus=true
|
||||
label=msg("auth-recovery-code-prompt", recoveryAuthnCodesInputBean.codeNumber?c)
|
||||
name="recoveryCodeInput"
|
||||
type="text"
|
||||
/>
|
||||
<@buttonGroup.kw>
|
||||
<@button.kw color="primary" name="login" type="submit">
|
||||
${msg("doLogIn")}
|
||||
</@button.kw>
|
||||
</@buttonGroup.kw>
|
||||
</@form.kw>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
5
theme/keywind/login/resources/dist/assets/module.esm-90313d2c.js
vendored
Normal file
5
theme/keywind/login/resources/dist/assets/module.esm-90313d2c.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
theme/keywind/login/resources/dist/assets/rfc4648-b81320ce.js
vendored
Normal file
1
theme/keywind/login/resources/dist/assets/rfc4648-b81320ce.js
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
function w(f,r,a){var l;if(a===void 0&&(a={}),!r.codes){r.codes={};for(var v=0;v<r.chars.length;++v)r.codes[r.chars[v]]=v}if(!a.loose&&f.length*r.bits&7)throw new SyntaxError("Invalid padding");for(var b=f.length;f[b-1]==="=";)if(--b,!a.loose&&!((f.length-b)*r.bits&7))throw new SyntaxError("Invalid padding");for(var i=new((l=a.out)!=null?l:Uint8Array)(b*r.bits/8|0),t=0,s=0,h=0,e=0;e<b;++e){var u=r.codes[f[e]];if(u===void 0)throw new SyntaxError("Invalid character "+f[e]);s=s<<r.bits|u,t+=r.bits,t>=8&&(t-=8,i[h++]=255&s>>t)}if(t>=r.bits||255&s<<8-t)throw new SyntaxError("Unexpected end of data");return i}function x(f,r,a){a===void 0&&(a={});for(var l=a,v=l.pad,b=v===void 0?!0:v,i=(1<<r.bits)-1,t="",s=0,h=0,e=0;e<f.length;++e)for(h=h<<8|255&f[e],s+=8;s>r.bits;)s-=r.bits,t+=r.chars[i&h>>s];if(s&&(t+=r.chars[i&h<<r.bits-s]),b)for(;t.length*r.bits&7;)t+="=";return t}var o={chars:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",bits:6},y={parse:function(r,a){return w(r,o,a)},stringify:function(r,a){return x(r,o,a)}};export{y as b};
|
2
theme/keywind/login/resources/dist/index.css
vendored
2
theme/keywind/login/resources/dist/index.css
vendored
File diff suppressed because one or more lines are too long
6
theme/keywind/login/resources/dist/index.js
vendored
6
theme/keywind/login/resources/dist/index.js
vendored
File diff suppressed because one or more lines are too long
8
theme/keywind/login/resources/dist/recoveryCodes.js
vendored
Normal file
8
theme/keywind/login/resources/dist/recoveryCodes.js
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
import{m as p}from"./assets/module.esm-90313d2c.js";document.addEventListener("alpine:init",()=>{p.data("recoveryCodes",function(){const{codeList:o}=this.$refs,{downloadFileDate:n,downloadFileDescription:i,downloadFileHeader:d,downloadFileName:s}=this.$store.recoveryCodes,a=new Date().toLocaleString(navigator.language),r=o.getElementsByTagName("li"),l=Array.from(r).map(t=>t.innerText).join(`
|
||||
`);return{copy:()=>navigator.clipboard.writeText(l),download:()=>{const t=document.createElement("a"),c=`${d}
|
||||
|
||||
${l}
|
||||
|
||||
${i}
|
||||
|
||||
${n} ${a}`;t.setAttribute("href","data:text/plain;charset=utf-8,"+encodeURIComponent(c)),t.setAttribute("download",`${s}.txt`),t.click()},print:()=>{const t=o.innerHTML,m=`<html><style>div { font-family: monospace; list-style-type: none }</style><body><title>${s}</title><p>${d}</p><div>${t}</div><p>${i}</p><p>${n} ${a}</p></body></html>`,e=window.open();e&&(e.document.write(m),e.print(),e.close())}}})});
|
1
theme/keywind/login/resources/dist/webAuthnAuthenticate.js
vendored
Normal file
1
theme/keywind/login/resources/dist/webAuthnAuthenticate.js
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
import{m as g}from"./assets/module.esm-90313d2c.js";import{b as n}from"./assets/rfc4648-b81320ce.js";document.addEventListener("alpine:init",()=>{g.data("webAuthnAuthenticate",function(){const{authenticatorDataInput:c,authnSelectForm:l,clientDataJSONInput:p,credentialIdInput:f,errorInput:r,signatureInput:d,userHandleInput:h,webAuthnForm:i}=this.$refs,{challenge:m,createTimeout:s,isUserIdentified:A,rpId:w,unsupportedBrowserText:y,userVerification:o}=this.$store.webAuthnAuthenticate,u=a=>{if(!window.PublicKeyCredential){r.value=y,i.submit();return}const t={challenge:n.parse(m,{loose:!0}),rpId:w};a.length&&(t.allowCredentials=a),parseInt(s)!==0&&(t.timeout=parseInt(s)*1e3),o!=="not specified"&&(t.userVerification=o),navigator.credentials.get({publicKey:t}).then(e=>{e instanceof PublicKeyCredential&&e.response instanceof AuthenticatorAssertionResponse&&(window.result=e,c.value=n.stringify(new Uint8Array(e.response.authenticatorData),{pad:!1}),p.value=n.stringify(new Uint8Array(e.response.clientDataJSON),{pad:!1}),d.value=n.stringify(new Uint8Array(e.response.signature),{pad:!1}),f.value=e.id,e.response.userHandle&&(h.value=n.stringify(new Uint8Array(e.response.userHandle),{pad:!1})),i.submit())}).catch(e=>{r.value=e,i.submit()})},b=()=>{const a=[],t=Array.from(l.elements);t.length&&t.forEach(e=>{e instanceof HTMLInputElement&&a.push({id:n.parse(e.value,{loose:!0}),type:"public-key"})}),u(a)};return{webAuthnAuthenticate:()=>{if(!A){u([]);return}b()}}})});
|
1
theme/keywind/login/resources/dist/webAuthnRegister.js
vendored
Normal file
1
theme/keywind/login/resources/dist/webAuthnRegister.js
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
import{m as T}from"./assets/module.esm-90313d2c.js";import{b as a}from"./assets/rfc4648-b81320ce.js";document.addEventListener("alpine:init",()=>{T.data("webAuthnRegister",function(){const{attestationObjectInput:m,authenticatorLabelInput:g,clientDataJSONInput:A,errorInput:I,publicKeyCredentialIdInput:w,registerForm:s,transportsInput:C}=this.$refs,{attestationConveyancePreference:u,authenticatorAttachment:c,challenge:K,createTimeout:l,excludeCredentialIds:v,requireResidentKey:p,rpEntityName:S,rpId:P,signatureAlgorithms:R,unsupportedBrowserText:x,userId:E,userVerificationRequirement:d,username:f}=this.$store.webAuthnRegister,L=t=>{const e=[];return t===""?(e.push({alg:-7,type:"public-key"}),e):(t.split(",").forEach(r=>{e.push({alg:parseInt(r),type:"public-key"})}),e)},q=t=>{const e=[];return t===""||t.split(",").forEach(r=>{e.push({id:a.parse(r,{loose:!0}),type:"public-key"})}),e},N=t=>{if(t===""||t.constructor!==Array)return"";let e="";return t.forEach(i=>{e+=i+","}),e.slice(0,-1)};return{registerSecurityKey:()=>{if(!window.PublicKeyCredential){I.value=x,s.submit();return}const t={challenge:a.parse(K,{loose:!0}),pubKeyCredParams:L(R),rp:{id:P,name:S},user:{displayName:f,id:a.parse(E,{loose:!0}),name:f}};u!=="not specified"&&(t.attestation=u);const e={};let i=!1;c!=="not specified"&&(e.authenticatorAttachment=c,i=!0),p!=="not specified"&&(p==="Yes"?e.requireResidentKey=!0:e.requireResidentKey=!1,i=!0),d!=="not specified"&&(e.userVerification=d,i=!0),i&&(t.authenticatorSelection=e);const r=q(v);r.length>0&&(t.excludeCredentials=r),parseInt(l)!==0&&(t.timeout=parseInt(l)*1e3),navigator.credentials.create({publicKey:t}).then(n=>{if(n instanceof PublicKeyCredential&&n.response instanceof AuthenticatorAttestationResponse){const{getTransports:h}=n.response;window.result=n;const O=n.rawId;if(m.value=a.stringify(new Uint8Array(n.response.attestationObject),{pad:!1}),A.value=a.stringify(new Uint8Array(n.response.clientDataJSON),{pad:!1}),w.value=a.stringify(new Uint8Array(O),{pad:!1}),typeof h=="function"){const b=h();b&&(C.value=N(b))}else console.log("Your browser is not able to recognize supported transport media for the authenticator.");const y="WebAuthn Authenticator (Default Label)";let o=window.prompt("Please input your registered authenticator's label",y);o===null&&(o=y),g.value=o,s.submit()}}).catch(function(n){n.value=n,s.submit()})}}})});
|
28
theme/keywind/login/select-authenticator.ftl
Normal file
28
theme/keywind/login/select-authenticator.ftl
Normal file
|
@ -0,0 +1,28 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<#import "components/atoms/form.ftl" as form>
|
||||
<#import "components/atoms/link.ftl" as link>
|
||||
|
||||
<@layout.registrationLayout displayInfo=false; section>
|
||||
<#if section="header">
|
||||
${msg("loginChooseAuthenticator")}
|
||||
<#elseif section="form">
|
||||
<div x-data>
|
||||
<@form.kw action=url.loginAction method="post" x\-ref="selectCredentialForm">
|
||||
<input name="authenticationExecution" type="hidden" x-ref="authExecInput" />
|
||||
<#list auth.authenticationSelections as authenticationSelection>
|
||||
<div>
|
||||
<@link.kw
|
||||
@click="$refs.authExecInput.value = '${authenticationSelection.authExecId}'; $refs.selectCredentialForm.submit()"
|
||||
color="primary"
|
||||
component="button"
|
||||
type="button"
|
||||
>
|
||||
${msg("${authenticationSelection.displayName}")}
|
||||
</@link.kw>
|
||||
<div class="text-sm">${msg("${authenticationSelection.helpText}")}</div>
|
||||
</div>
|
||||
</#list>
|
||||
</@form.kw>
|
||||
</div>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
71
theme/keywind/login/webauthn-authenticate.ftl
Normal file
71
theme/keywind/login/webauthn-authenticate.ftl
Normal file
|
@ -0,0 +1,71 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<#import "components/atoms/button.ftl" as button>
|
||||
<#import "components/atoms/button-group.ftl" as buttonGroup>
|
||||
|
||||
<@layout.registrationLayout script="dist/webAuthnAuthenticate.js"; section>
|
||||
<#if section="title">
|
||||
title
|
||||
<#elseif section="header">
|
||||
${kcSanitize(msg("webauthn-login-title"))?no_esc}
|
||||
<#elseif section="form">
|
||||
<div x-data="webAuthnAuthenticate">
|
||||
<form action="${url.loginAction}" method="post" x-ref="webAuthnForm">
|
||||
<input name="authenticatorData" type="hidden" x-ref="authenticatorDataInput" />
|
||||
<input name="clientDataJSON" type="hidden" x-ref="clientDataJSONInput" />
|
||||
<input name="credentialId" type="hidden" x-ref="credentialIdInput" />
|
||||
<input name="error" type="hidden" x-ref="errorInput" />
|
||||
<input name="signature" type="hidden" x-ref="signatureInput" />
|
||||
<input name="userHandle" type="hidden" x-ref="userHandleInput" />
|
||||
</form>
|
||||
<#if authenticators??>
|
||||
<form x-ref="authnSelectForm">
|
||||
<#list authenticators.authenticators as authenticator>
|
||||
<input value="${authenticator.credentialId}" type="hidden" />
|
||||
</#list>
|
||||
</form>
|
||||
<#if shouldDisplayAuthenticators?? && shouldDisplayAuthenticators>
|
||||
<#if authenticators.authenticators?size gt 1>
|
||||
<p>${kcSanitize(msg("webauthn-available-authenticators"))?no_esc}</p>
|
||||
</#if>
|
||||
<#list authenticators.authenticators as authenticator>
|
||||
<div>
|
||||
<div class="font-medium">${kcSanitize(msg("${authenticator.label}"))?no_esc}</div>
|
||||
<#if authenticator.transports?? && authenticator.transports.displayNameProperties?has_content>
|
||||
<div>
|
||||
<#list authenticator.transports.displayNameProperties as nameProperty>
|
||||
<span>${kcSanitize(msg("${nameProperty!}"))?no_esc}</span>
|
||||
<#if nameProperty?has_next>
|
||||
<span>, </span>
|
||||
</#if>
|
||||
</#list>
|
||||
</div>
|
||||
</#if>
|
||||
<div class="text-sm">
|
||||
<span>${kcSanitize(msg("webauthn-createdAt-label"))?no_esc}</span>
|
||||
<span>${kcSanitize(authenticator.createdAt)?no_esc}</span>
|
||||
</div>
|
||||
</div>
|
||||
</#list>
|
||||
</#if>
|
||||
</#if>
|
||||
<@buttonGroup.kw>
|
||||
<@button.kw @click="webAuthnAuthenticate" color="primary" type="button">
|
||||
${kcSanitize(msg("webauthn-doAuthenticate"))}
|
||||
</@button.kw>
|
||||
</@buttonGroup.kw>
|
||||
</div>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
||||
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('webAuthnAuthenticate', {
|
||||
challenge: '${challenge}',
|
||||
createTimeout: '${createTimeout}',
|
||||
isUserIdentified: '${isUserIdentified}',
|
||||
rpId: '${rpId}',
|
||||
unsupportedBrowserText: '${msg("webauthn-unsupported-browser-text")?no_esc}',
|
||||
userVerification: '${userVerification}',
|
||||
})
|
||||
})
|
||||
</script>
|
34
theme/keywind/login/webauthn-error.ftl
Normal file
34
theme/keywind/login/webauthn-error.ftl
Normal file
|
@ -0,0 +1,34 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<#import "components/atoms/button.ftl" as button>
|
||||
<#import "components/atoms/button-group.ftl" as buttonGroup>
|
||||
|
||||
<@layout.registrationLayout displayMessage=true; section>
|
||||
<#if section="header">
|
||||
${kcSanitize(msg("webauthn-error-title"))?no_esc}
|
||||
<#elseif section="form">
|
||||
<div x-data>
|
||||
<form action="${url.loginAction}" method="post" x-ref="errorCredentialForm">
|
||||
<input name="authenticationExecution" type="hidden" x-ref="executionValueInput" />
|
||||
<input name="isSetRetry" type="hidden" x-ref="isSetRetryInput" />
|
||||
</form>
|
||||
<@buttonGroup.kw>
|
||||
<@button.kw
|
||||
@click="$refs.executionValueInput.value = '${execution}'; $refs.isSetRetryInput.value = 'retry'; $refs.errorCredentialForm.submit()"
|
||||
color="primary"
|
||||
name="try-again"
|
||||
tabindex="4"
|
||||
type="button"
|
||||
>
|
||||
${kcSanitize(msg("doTryAgain"))?no_esc}
|
||||
</@button.kw>
|
||||
<#if isAppInitiatedAction??>
|
||||
<form action="${url.loginAction}" method="post">
|
||||
<@button.kw color="secondary" name="cancel-aia" type="submit" value="true">
|
||||
${msg("doCancel")}
|
||||
</@button.kw>
|
||||
</form>
|
||||
</#if>
|
||||
</@buttonGroup.kw>
|
||||
</div>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
54
theme/keywind/login/webauthn-register.ftl
Normal file
54
theme/keywind/login/webauthn-register.ftl
Normal file
|
@ -0,0 +1,54 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<#import "components/atoms/button.ftl" as button>
|
||||
<#import "components/atoms/button-group.ftl" as buttonGroup>
|
||||
|
||||
<@layout.registrationLayout script="dist/webAuthnRegister.js"; section>
|
||||
<#if section="title">
|
||||
title
|
||||
<#elseif section="header">
|
||||
${kcSanitize(msg("webauthn-registration-title"))?no_esc}
|
||||
<#elseif section="form">
|
||||
<div x-data="webAuthnRegister">
|
||||
<form action="${url.loginAction}" method="post" x-ref="registerForm">
|
||||
<input name="attestationObject" type="hidden" x-ref="attestationObjectInput" />
|
||||
<input name="authenticatorLabel" type="hidden" x-ref="authenticatorLabelInput" />
|
||||
<input name="clientDataJSON" type="hidden" x-ref="clientDataJSONInput" />
|
||||
<input name="error" type="hidden" x-ref="errorInput" />
|
||||
<input name="publicKeyCredentialId" type="hidden" x-ref="publicKeyCredentialIdInput" />
|
||||
<input name="transports" type="hidden" x-ref="transportsInput" />
|
||||
</form>
|
||||
<@buttonGroup.kw>
|
||||
<@button.kw @click="registerSecurityKey" color="primary" type="submit">
|
||||
${msg("doRegister")}
|
||||
</@button.kw>
|
||||
<#if !isSetRetry?has_content && isAppInitiatedAction?has_content>
|
||||
<form action="${url.loginAction}" method="post">
|
||||
<@button.kw color="secondary" name="cancel-aia" type="submit" value="true">
|
||||
${msg("doCancel")}
|
||||
</@button.kw>
|
||||
</form>
|
||||
</#if>
|
||||
</@buttonGroup.kw>
|
||||
</div>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
||||
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('webAuthnRegister', {
|
||||
attestationConveyancePreference: '${attestationConveyancePreference}',
|
||||
authenticatorAttachment: '${authenticatorAttachment}',
|
||||
challenge: '${challenge}',
|
||||
createTimeout: '${createTimeout}',
|
||||
excludeCredentialIds: '${excludeCredentialIds}',
|
||||
requireResidentKey: '${requireResidentKey}',
|
||||
rpEntityName: '${rpEntityName}',
|
||||
rpId: '${rpId}',
|
||||
signatureAlgorithms: '${signatureAlgorithms}',
|
||||
unsupportedBrowserText: '${msg("webauthn-unsupported-browser-text")?no_esc}',
|
||||
userId: '${userid}',
|
||||
userVerificationRequirement: '${userVerificationRequirement}',
|
||||
username: '${username}',
|
||||
})
|
||||
})
|
||||
</script>
|
|
@ -3,7 +3,12 @@ import { defineConfig } from 'vite';
|
|||
export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: 'src/index.ts',
|
||||
input: [
|
||||
'src/index.ts',
|
||||
'src/data/recoveryCodes.ts',
|
||||
'src/data/webAuthnAuthenticate.ts',
|
||||
'src/data/webAuthnRegister.ts',
|
||||
],
|
||||
output: {
|
||||
assetFileNames: '[name][extname]',
|
||||
dir: 'theme/keywind/login/resources/dist',
|
||||
|
|
Loading…
Reference in a new issue