Compare commits
2 commits
f377ed313e
...
47b0436b14
Author | SHA1 | Date | |
---|---|---|---|
Oliver Traber | 47b0436b14 | ||
Oliver Traber | 2e612fc2ad |
|
@ -7,6 +7,8 @@ keycloak-modern-login is a Keycloak login theme that's build using Tailwind CSS,
|
||||||
|
|
||||||
![](docs/login-form.jpg)
|
![](docs/login-form.jpg)
|
||||||
|
|
||||||
|
![](docs/select-authenticator.jpg)
|
||||||
|
|
||||||
![](docs/login-webauthn.jpg)
|
![](docs/login-webauthn.jpg)
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
version: "3.3"
|
version: "3.3"
|
||||||
services:
|
services:
|
||||||
keycloak:
|
keycloak:
|
||||||
image: quay.io/keycloak/keycloak:20.0.3
|
image: quay.io/keycloak/keycloak:latest
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
|
@ -12,4 +12,5 @@ services:
|
||||||
- KEYCLOAK_ADMIN_PASSWORD=admin
|
- KEYCLOAK_ADMIN_PASSWORD=admin
|
||||||
command:
|
command:
|
||||||
- start-dev
|
- start-dev
|
||||||
|
- --features=recovery-codes
|
||||||
- --import-realm
|
- --import-realm
|
||||||
|
|
BIN
docs/select-authenticator.jpg
Normal file
BIN
docs/select-authenticator.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 224 KiB |
|
@ -7,7 +7,7 @@
|
||||||
"prod": "webpack --mode=production",
|
"prod": "webpack --mode=production",
|
||||||
"zipjar": "cd dist; zip -r ../dist/theme.jar META-INF theme; cd -",
|
"zipjar": "cd dist; zip -r ../dist/theme.jar META-INF theme; cd -",
|
||||||
"build": "yarn prod && yarn zipjar",
|
"build": "yarn prod && yarn zipjar",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write src/",
|
||||||
"lint": "eslint --cache --report-unused-disable-directives --format codeframe --ext js,ts,vue ."
|
"lint": "eslint --cache --report-unused-disable-directives --format codeframe --ext js,ts,vue ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
22
src/functions/utils.ts
Normal file
22
src/functions/utils.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
export function formPost(url: string, data: object) {
|
||||||
|
const form = document.createElement("form");
|
||||||
|
form.method = "post";
|
||||||
|
form.action = url;
|
||||||
|
|
||||||
|
for (const key in data) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
||||||
|
if (data[key] === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hiddenField = document.createElement("input");
|
||||||
|
hiddenField.type = "hidden";
|
||||||
|
hiddenField.name = key;
|
||||||
|
hiddenField.value = data[key];
|
||||||
|
|
||||||
|
form.appendChild(hiddenField);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
|
@ -31,6 +31,19 @@ export type WebauthnAuthenticator = {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AuthenticationSelection = {
|
||||||
|
authExecId: string;
|
||||||
|
authenticationExecution: {
|
||||||
|
alternative: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
authenticator:
|
||||||
|
| "auth-otp-form"
|
||||||
|
| "webauthn-authenticator"
|
||||||
|
| "auth-recovery-authn-code-form";
|
||||||
|
priority: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export declare namespace KcContextBase {
|
export declare namespace KcContextBase {
|
||||||
export type Common = {
|
export type Common = {
|
||||||
url: {
|
url: {
|
||||||
|
@ -327,13 +340,19 @@ export declare namespace KcContextBase {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SAMLPostForm = Common & {
|
export type SAMLPostForm = Common & {
|
||||||
pageId: "saml-post-form.ftl"
|
pageId: "saml-post-form.ftl";
|
||||||
samlPost: {
|
samlPost: {
|
||||||
url: string;
|
url: string;
|
||||||
SAMLRequest?: string;
|
SAMLRequest?: string;
|
||||||
SAMLResponse?: string;
|
SAMLResponse?: string;
|
||||||
relayState?: string;
|
relayState?: string;
|
||||||
}
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SelectAuthenticator = Common & {
|
||||||
|
auth: {
|
||||||
|
authenticationSelections: Array<AuthenticationSelection>;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
export type SAMLPostData = {
|
export type SAMLPostData = {
|
||||||
SAMLRequest?: string,
|
SAMLRequest?: string;
|
||||||
SAMLResponse?: string,
|
SAMLResponse?: string;
|
||||||
RelayState?: string,
|
RelayState?: string;
|
||||||
}
|
};
|
||||||
|
|
|
@ -2,7 +2,14 @@
|
||||||
<layout>
|
<layout>
|
||||||
<div class="flex justify-center justify-items-center">
|
<div class="flex justify-center justify-items-center">
|
||||||
<svg class="h-15 w-15 animate-spin" preserveAspectRatio="xMidYMid">
|
<svg class="h-15 w-15 animate-spin" preserveAspectRatio="xMidYMid">
|
||||||
<circle cx="50%" cy="50%" r="35" fill="none" stroke="#374151" stroke-width="10" stroke-dasharray="164 56"/>
|
<circle
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
r="35"
|
||||||
|
fill="none"
|
||||||
|
stroke="#374151"
|
||||||
|
stroke-width="10"
|
||||||
|
stroke-dasharray="164 56" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="mt-4 text-xl text-center text-gray-700">Redirecting...</h1>
|
<h1 class="mt-4 text-xl text-center text-gray-700">Redirecting...</h1>
|
||||||
|
@ -12,7 +19,8 @@
|
||||||
import { defineComponent } from "vue";
|
import { defineComponent } from "vue";
|
||||||
import Layout from "~/components/Layout.vue";
|
import Layout from "~/components/Layout.vue";
|
||||||
import type { KcContextBase } from "~/types/context";
|
import type { KcContextBase } from "~/types/context";
|
||||||
import type { SAMLPostData } from "~/types/samlPostData"
|
import type { SAMLPostData } from "~/types/samlPostData";
|
||||||
|
import { formPost } from "~/functions/utils";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: "SamlPostForm",
|
name: "SamlPostForm",
|
||||||
|
@ -30,31 +38,7 @@ export default defineComponent({
|
||||||
SAMLResponse: this.context.samlPost.SAMLResponse,
|
SAMLResponse: this.context.samlPost.SAMLResponse,
|
||||||
RelayState: this.context.samlPost.relayState,
|
RelayState: this.context.samlPost.relayState,
|
||||||
};
|
};
|
||||||
this.formPost(this.context.samlPost.url, postData);
|
formPost(this.context.samlPost.url, postData);
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
formPost(url: string, data: object) {
|
|
||||||
const form = document.createElement("form");
|
|
||||||
form.method = "post";
|
|
||||||
form.action = url;
|
|
||||||
|
|
||||||
for (const key in data) {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
|
||||||
if (data[key] === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hiddenField = document.createElement("input");
|
|
||||||
hiddenField.type = "hidden";
|
|
||||||
hiddenField.name = key;
|
|
||||||
hiddenField.value = data[key];
|
|
||||||
|
|
||||||
form.appendChild(hiddenField);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.body.appendChild(form);
|
|
||||||
form.submit();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
6
src/views/select-authenticator/index.ftl
Normal file
6
src/views/select-authenticator/index.ftl
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<#import "baselayout.ftl" as layout>
|
||||||
|
<@layout.baseLayout pageId="select-authenticator.ftl" ; section>
|
||||||
|
<#if section = "scripts">
|
||||||
|
<script typo="module" src="${url.resourcesPath}/js/select-authenticator.js"></script>
|
||||||
|
</#if>
|
||||||
|
</@layout.baseLayout>
|
7
src/views/select-authenticator/index.ts
Normal file
7
src/views/select-authenticator/index.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { createApp } from "vue";
|
||||||
|
import index from "./index.vue";
|
||||||
|
|
||||||
|
if ((window as any).kcContext) {
|
||||||
|
const app = createApp(index);
|
||||||
|
app.mount("#app");
|
||||||
|
}
|
143
src/views/select-authenticator/index.vue
Normal file
143
src/views/select-authenticator/index.vue
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
<template>
|
||||||
|
<layout>
|
||||||
|
<h1
|
||||||
|
v-if="context.auth.attemptedUsername && context.auth.showUsername"
|
||||||
|
class="text-3xl font-semibold text-center text-gray-700">
|
||||||
|
Welcome {{ context.auth?.attemptedUsername }}!
|
||||||
|
</h1>
|
||||||
|
<h1 v-else class="text-3xl font-semibold text-center text-gray-700">
|
||||||
|
Welcome!
|
||||||
|
</h1>
|
||||||
|
<p class="text-center mt-3">
|
||||||
|
Please select a second factor you would like to use:
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col items-center mt-10 text-gray-700">
|
||||||
|
<div
|
||||||
|
v-if="$data.selection.webauthn.show"
|
||||||
|
@click="useMethod($data.selection.webauthn.execId)"
|
||||||
|
class="flex flex-row items-center w-full p-5 bg-white shadow-lg transition ease-in-out duration-150 hover:scale-105 hover:cursor-pointer rounded-md">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="w-8 h-8"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 16 16">
|
||||||
|
<path
|
||||||
|
d="M6 .5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 .5.5v4H6v-4ZM7 1v1h1V1H7Zm2 0v1h1V1H9ZM5.5 5a.5.5 0 0 0-.5.5V15a1 1 0 0 0 1 1h5a1 1 0 0 0 1-1V5.5a.5.5 0 0 0-.5-.5h-6Z" />
|
||||||
|
</svg>
|
||||||
|
<div class="flex flex-col ml-5 leading-none">
|
||||||
|
<h2 class="mb-1.5 font-semibold">Hardware Security Key</h2>
|
||||||
|
<p class="text-sm">Authenticate using a WebAuthn capable device</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="$data.selection.otp.show"
|
||||||
|
@click="useMethod($data.selection.otp.execId)"
|
||||||
|
class="flex flex-row items-center w-full p-5 mt-4 bg-white shadow-lg transition ease-in-out duration-150 hover:scale-105 hover:cursor-pointer rounded-md">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="w-8 h-8"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 16 16">
|
||||||
|
<path
|
||||||
|
d="M8.515 1.019A7 7 0 0 0 8 1V0a8 8 0 0 1 .589.022l-.074.997zm2.004.45a7.003 7.003 0 0 0-.985-.299l.219-.976c.383.086.76.2 1.126.342l-.36.933zm1.37.71a7.01 7.01 0 0 0-.439-.27l.493-.87a8.025 8.025 0 0 1 .979.654l-.615.789a6.996 6.996 0 0 0-.418-.302zm1.834 1.79a6.99 6.99 0 0 0-.653-.796l.724-.69c.27.285.52.59.747.91l-.818.576zm.744 1.352a7.08 7.08 0 0 0-.214-.468l.893-.45a7.976 7.976 0 0 1 .45 1.088l-.95.313a7.023 7.023 0 0 0-.179-.483zm.53 2.507a6.991 6.991 0 0 0-.1-1.025l.985-.17c.067.386.106.778.116 1.17l-1 .025zm-.131 1.538c.033-.17.06-.339.081-.51l.993.123a7.957 7.957 0 0 1-.23 1.155l-.964-.267c.046-.165.086-.332.12-.501zm-.952 2.379c.184-.29.346-.594.486-.908l.914.405c-.16.36-.345.706-.555 1.038l-.845-.535zm-.964 1.205c.122-.122.239-.248.35-.378l.758.653a8.073 8.073 0 0 1-.401.432l-.707-.707z" />
|
||||||
|
<path
|
||||||
|
d="M8 1a7 7 0 1 0 4.95 11.95l.707.707A8.001 8.001 0 1 1 8 0v1z" />
|
||||||
|
<path
|
||||||
|
d="M7.5 3a.5.5 0 0 1 .5.5v5.21l3.248 1.856a.5.5 0 0 1-.496.868l-3.5-2A.5.5 0 0 1 7 9V3.5a.5.5 0 0 1 .5-.5z" />
|
||||||
|
</svg>
|
||||||
|
<div class="flex flex-col ml-5 leading-none">
|
||||||
|
<h2 class="mb-1.5 font-semibold">Authenticator App</h2>
|
||||||
|
<p class="text-sm">
|
||||||
|
Authenticate using a one time code from your authenticator app
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="$data.selection.recovery.show"
|
||||||
|
@click="useMethod($data.selection.recovery.execId)"
|
||||||
|
class="flex flex-row items-center w-full p-5 mt-4 bg-white shadow-lg transition ease-in-out duration-150 hover:scale-105 hover:cursor-pointer rounded-md">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="w-8 h-8"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 16 16">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M8 3a5 5 0 1 1-4.546 2.914.5.5 0 0 0-.908-.417A6 6 0 1 0 8 2v1z" />
|
||||||
|
<path
|
||||||
|
d="M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466z" />
|
||||||
|
</svg>
|
||||||
|
<div class="flex flex-col ml-5 leading-none">
|
||||||
|
<h2 class="mb-1.5 font-semibold">Recovery Code</h2>
|
||||||
|
<p class="text-sm">Authenticate using one of your recovery codes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</layout>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
import Layout from "~/components/Layout.vue";
|
||||||
|
import type { KcContextBase } from "~/types/context";
|
||||||
|
import { formPost } from "~/functions/utils";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "SelectAuthenticator",
|
||||||
|
components: {
|
||||||
|
Layout,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
context: (window as any).kcContext as KcContextBase.SelectAuthenticator,
|
||||||
|
selection: {
|
||||||
|
webauthn: {
|
||||||
|
show: false,
|
||||||
|
execId: "",
|
||||||
|
},
|
||||||
|
otp: {
|
||||||
|
show: false,
|
||||||
|
execId: "",
|
||||||
|
},
|
||||||
|
recovery: {
|
||||||
|
show: false,
|
||||||
|
execId: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted: function () {
|
||||||
|
this.context.auth.authenticationSelections.forEach(selection => {
|
||||||
|
if (
|
||||||
|
selection.authenticationExecution.authenticator ===
|
||||||
|
"webauthn-authenticator"
|
||||||
|
) {
|
||||||
|
this.selection.webauthn.execId = selection.authExecId;
|
||||||
|
this.selection.webauthn.show = true;
|
||||||
|
} else if (
|
||||||
|
selection.authenticationExecution.authenticator === "auth-otp-form"
|
||||||
|
) {
|
||||||
|
this.selection.otp.execId = selection.authExecId;
|
||||||
|
this.selection.otp.show = true;
|
||||||
|
} else if (
|
||||||
|
selection.authenticationExecution.authenticator ===
|
||||||
|
"auth-recovery-authn-code-form"
|
||||||
|
) {
|
||||||
|
this.selection.recovery.execId = selection.authExecId;
|
||||||
|
this.selection.recovery.show = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
useMethod(execId: string) {
|
||||||
|
formPost(this.context.url.loginAction, {
|
||||||
|
authenticationExecution: execId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
</style>
|
|
@ -84,6 +84,7 @@ import Layout from "~/components/Layout.vue";
|
||||||
import ErrorBox from "~/components/ErrorBox.vue";
|
import ErrorBox from "~/components/ErrorBox.vue";
|
||||||
import type { KcContextBase } from "~/types/context";
|
import type { KcContextBase } from "~/types/context";
|
||||||
import { base64url } from "rfc4648";
|
import { base64url } from "rfc4648";
|
||||||
|
import { formPost } from "~/functions/utils";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: "WebAuthnAuthenticate",
|
name: "WebAuthnAuthenticate",
|
||||||
|
@ -106,7 +107,7 @@ export default defineComponent({
|
||||||
methods: {
|
methods: {
|
||||||
tryAnotherWay(e: Event) {
|
tryAnotherWay(e: Event) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.formPost(this.context.url.loginAction, {
|
formPost(this.context.url.loginAction, {
|
||||||
tryAnotherWay: "on",
|
tryAnotherWay: "on",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -166,7 +167,7 @@ export default defineComponent({
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.formPost(this.context.url.loginAction, postData);
|
formPost(this.context.url.loginAction, postData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.error = true;
|
this.error = true;
|
||||||
}
|
}
|
||||||
|
@ -174,24 +175,6 @@ export default defineComponent({
|
||||||
retryAuth() {
|
retryAuth() {
|
||||||
this.error = false;
|
this.error = false;
|
||||||
},
|
},
|
||||||
formPost(url: string, data: object) {
|
|
||||||
const form = document.createElement("form");
|
|
||||||
form.method = "post";
|
|
||||||
form.action = url;
|
|
||||||
|
|
||||||
for (const key in data) {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
|
||||||
const hiddenField = document.createElement("input");
|
|
||||||
hiddenField.type = "hidden";
|
|
||||||
hiddenField.name = key;
|
|
||||||
hiddenField.value = data[key];
|
|
||||||
|
|
||||||
form.appendChild(hiddenField);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.body.appendChild(form);
|
|
||||||
form.submit();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -5,7 +5,12 @@ const { CleanWebpackPlugin } = require("clean-webpack-plugin");
|
||||||
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
||||||
|
|
||||||
const THEME_NAME = "modern-login";
|
const THEME_NAME = "modern-login";
|
||||||
const customPages = ["login", "webauthn-authenticate", "saml-post-form"];
|
const customPages = [
|
||||||
|
"login",
|
||||||
|
"webauthn-authenticate",
|
||||||
|
"saml-post-form",
|
||||||
|
"select-authenticator"
|
||||||
|
];
|
||||||
|
|
||||||
module.exports = function (env, argv) {
|
module.exports = function (env, argv) {
|
||||||
const isDevelopment = argv.mode !== "production";
|
const isDevelopment = argv.mode !== "production";
|
||||||
|
|
Loading…
Reference in a new issue