Compare commits

...

5 commits

13 changed files with 203 additions and 131 deletions

View file

@ -67,3 +67,9 @@ If you want to add new pages, there are some things to be aware of.
If the browser language is supported, it will be loaded automatically. Otherwise, English is loaded by default. If the browser language is supported, it will be loaded automatically. Otherwise, English is loaded by default.
To add new languages, the corresponding file must be added under `static/login/resources/locales`. Then the language must be added to `SUPPORT_LOCALES` in `functions/i18n.ts`. To add new languages, the corresponding file must be added under `static/login/resources/locales`. Then the language must be added to `SUPPORT_LOCALES` in `functions/i18n.ts`.
### Credits
Special thanks to the following projects, without which the development of keycloak-modern-login would not be possible:
- [Keycloakify](https://github.com/keycloakify/keycloakify), from which the [`ftl_object_to_js_code_declaring_an_object`](src/static/login/baselayout.ftl) function and [Keycloak context type definitions](src/types/context.ts) are derived.

View file

@ -45,7 +45,6 @@
"webpack-cli": "^4.9.1" "webpack-cli": "^4.9.1"
}, },
"dependencies": { "dependencies": {
"moment": "^2.29.4",
"rfc4648": "^1.5.2", "rfc4648": "^1.5.2",
"vue": "^3.2.26", "vue": "^3.2.26",
"vue-i18n": "9" "vue-i18n": "9"

View file

@ -1,39 +1,39 @@
import { nextTick } from "vue" import { nextTick } from "vue";
import { createI18n } from "vue-i18n" import { createI18n } from "vue-i18n";
import { KcContextBase } from "~/types/context"; import { KcContextBase } from "~/types/context";
export const SUPPORT_LOCALES = ["en", "de"] export const SUPPORT_LOCALES = ["en", "de"];
export function setupI18n(options = {}) { export function setupI18n(options = {}) {
let browserLocale = navigator.language.split("-")[0]; const browserLocale = navigator.language.split("-")[0];
let defaultLocale = "en"; let defaultLocale = "en";
if (SUPPORT_LOCALES.includes(browserLocale)) { if (SUPPORT_LOCALES.includes(browserLocale)) {
defaultLocale = browserLocale; defaultLocale = browserLocale;
} }
const i18n = createI18n(options) const i18n = createI18n(options);
setI18nLanguage(i18n, defaultLocale) setI18nLanguage(i18n, defaultLocale);
return i18n return i18n;
} }
export function setI18nLanguage(i18n, locale) { export function setI18nLanguage(i18n, locale) {
loadLocaleMessages(i18n, locale); loadLocaleMessages(i18n, locale);
if (i18n.mode === 'legacy') { if (i18n.mode === "legacy") {
i18n.global.locale = locale i18n.global.locale = locale;
} else { } else {
i18n.global.locale.value = locale i18n.global.locale.value = locale;
}
} }
}
export async function loadLocaleMessages(i18n, locale) { export async function loadLocaleMessages(i18n, locale) {
// load locale messages with dynamic import // load locale messages with dynamic import
let context = (window as any).kcContext as KcContextBase.Common; const context = (window as any).kcContext as KcContextBase.Common;
const messages = await import( const messages = await import(
/* webpackIgnore: true */`${context.url.resourcesPath}/locales/${locale}.js` /* webpackIgnore: true */ `${context.url.resourcesPath}/locales/${locale}.js`
) );
// set locale and locale message // set locale and locale message
i18n.global.setLocaleMessage(locale, messages.content) i18n.global.setLocaleMessage(locale, messages.content);
return nextTick() return nextTick();
} }

View file

@ -1,3 +1,26 @@
<#--
MIT License
Copyright (c) 2020 GitHub user u/garronej
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-->
<#function are_same_path path searchedPath> <#function are_same_path path searchedPath>
<#if path?size != searchedPath?size> <#if path?size != searchedPath?size>
<#return false> <#return false>
@ -72,6 +95,9 @@
) || ( ) || (
["masterAdminClient", "delegateForUpdate", "defaultRole"]?seq_contains(key) && ["masterAdminClient", "delegateForUpdate", "defaultRole"]?seq_contains(key) &&
are_same_path(path, ["realm"]) are_same_path(path, ["realm"])
) || (
["password"]?seq_contains(key) &&
are_same_path(path, ["login"])
) )
> >
<#continue> <#continue>

View file

@ -1,37 +1,40 @@
export const content = { export const content = {
"login": { login: {
"title": "Mit Ihrem Konto anmelden", title: "Mit Ihrem Konto anmelden",
"noAccount": "Sie haben noch kein Konto?", noAccount: "Sie haben noch kein Konto?",
"signupLink": "Jetzt registrieren.", signupLink: "Jetzt registrieren.",
"username": "Benutzername", username: "Benutzername",
"usernameOrEmail": "Benutzername oder E-Mail-Adresse", usernameOrEmail: "Benutzername oder E-Mail-Adresse",
"password": "Passwort", password: "Passwort",
"forgotPassword": "Passwort vergessen?", forgotPassword: "Passwort vergessen?",
"rememberMe": "Angemeldet bleiben", rememberMe: "Angemeldet bleiben",
"login": "Anmelden", login: "Anmelden",
"rety": "Erneut versuchen", rety: "Erneut versuchen",
"welcome": "Willkommen!" welcome: "Willkommen!",
}, },
"2fa": { "2fa": {
"selectFactor": "Bitte wählen Sie einen zweiten Faktor aus:", selectFactor: "Bitte wählen Sie einen zweiten Faktor aus:",
"hwSecKey": "Hardware Sicherheits-Schlüssel", hwSecKey: "Hardware Sicherheits-Schlüssel",
"hwSecKeyDesc": "Verwenden Sie ein WebAuthn kompatibles Gerät", hwSecKeyDesc: "Verwenden Sie ein WebAuthn kompatibles Gerät",
"otp": "Authenticator App", otp: "Authenticator App",
"otpDesc": "Verwenden Sie einen Einmalcode aus ihrem Authenticator", otpDesc: "Verwenden Sie einen Einmalcode aus ihrem Authenticator",
"recoveryCode": "Wiederherstellungsschlüssel", recoveryCode: "Wiederherstellungsschlüssel",
"recoveryCodeDesc": "Verwenden Sie einen ihrer Wiederherstellungsschlüssel", recoveryCodeDesc: "Verwenden Sie einen ihrer Wiederherstellungsschlüssel",
"tryAnotherWay": "Eine andere Option nutzen." tryAnotherWay: "Eine andere Option nutzen.",
}, },
"webauthn": { webauthn: {
"title": "Bitte verwenden Sie eines ihrer Geräte, um fortzufahren:", title: "Bitte verwenden Sie eines ihrer Geräte, um fortzufahren:",
"registered": "Registriert: {date}", registered: "Registriert: {date}",
"authenticate": "Fortfahren", authenticate: "Fortfahren",
"noDevice": "Kein Gerät zur Hand?", noDevice: "Kein Gerät zur Hand?",
"noSupport": "Es scheint, als würde ihr Browser WebAuthn nicht unterstützen. Bitte nutzen Sie einen anderen Browser für die Anmeldung.", noSupport:
"noSupportOtherMethod": "Es scheint, als würde ihr Browser WebAuthn nicht unterstützen. Bitte nutzen Sie einen anderen Browser für die Anmeldung, oder wählen Sie eine andere Anmeldeoption.", "Es scheint, als würde ihr Browser WebAuthn nicht unterstützen. Bitte nutzen Sie einen anderen Browser für die Anmeldung.",
"error": "Bei der Anmeldung mit ihrem Gerät ist etwas schief gelaufen. Bitte versuchen Sie es erneut, oder wählen Sie eine andere Anmeldeoption." noSupportOtherMethod:
"Es scheint, als würde ihr Browser WebAuthn nicht unterstützen. Bitte nutzen Sie einen anderen Browser für die Anmeldung, oder wählen Sie eine andere Anmeldeoption.",
error:
"Bei der Anmeldung mit ihrem Gerät ist etwas schief gelaufen. Bitte versuchen Sie es erneut, oder wählen Sie eine andere Anmeldeoption.",
}, },
"redirect": { redirect: {
"message": "Sie werden weitergeleitet..." message: "Sie werden weitergeleitet...",
} },
} };

View file

@ -1,37 +1,40 @@
export const content = { export const content = {
"login": { login: {
"title": "Login to your account", title: "Login to your account",
"noAccount": "Don't have an account yet?", noAccount: "Don't have an account yet?",
"signupLink": "Sign up.", signupLink: "Sign up.",
"username": "Username", username: "Username",
"usernameOrEmail": "Email or Username", usernameOrEmail: "Email or Username",
"password": "Password", password: "Password",
"forgotPassword": "Forgot Password?", forgotPassword: "Forgot Password?",
"rememberMe": "Remember me", rememberMe: "Remember me",
"login": "Login", login: "Login",
"rety": "Retry", rety: "Retry",
"welcome": "Welcome!" welcome: "Welcome!",
}, },
"2fa": { "2fa": {
"selectFactor": "Please select a second factor you would like to use:", selectFactor: "Please select a second factor you would like to use:",
"hwSecKey": "Hardware Security Key", hwSecKey: "Hardware Security Key",
"hwSecKeyDesc": "Authenticate using a WebAuthn capable device", hwSecKeyDesc: "Authenticate using a WebAuthn capable device",
"otp": "Authenticator App", otp: "Authenticator App",
"otpDesc": "Authenticate using a one time code from your authenticator app", otpDesc: "Authenticate using a one time code from your authenticator app",
"recoveryCode": "Recovery Code", recoveryCode: "Recovery Code",
"recoveryCodeDesc": "Authenticate using one of your recovery codes", recoveryCodeDesc: "Authenticate using one of your recovery codes",
"tryAnotherWay": "Try another method." tryAnotherWay: "Try another method.",
}, },
"webauthn": { webauthn: {
"title": "Please use one of your registered devices to continue:", title: "Please use one of your registered devices to continue:",
"registered": "Registered: {date}", registered: "Registered: {date}",
"authenticate": "Continue", authenticate: "Continue",
"noDevice": "Don't have your device at hand?", noDevice: "Don't have your device at hand?",
"noSupport": "It seems that your browser doesn't support WebAuthn. Please try logging in with a different browser.", noSupport:
"noSupportOtherMethod": "It seems that your browser doesn't support WebAuthn. Please try logging in with a different browser, or try a different login method using the button below.", "It seems that your browser doesn't support WebAuthn. Please try logging in with a different browser.",
"error": "Something went wrong during authentication using your device. Please try again, or use a different login method." noSupportOtherMethod:
"It seems that your browser doesn't support WebAuthn. Please try logging in with a different browser, or try a different login method using the button below.",
error:
"Something went wrong during authentication using your device. Please try again, or use a different login method.",
}, },
"redirect": { redirect: {
"message": "Redirecting..." message: "Redirecting...",
} },
} };

View file

@ -1,4 +1,29 @@
/* eslint-disable */ /* eslint-disable */
/**
* MIT License
*
* Copyright (c) 2020 GitHub user u/garronej
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
/** Take theses type definition with a grain of salt. /** Take theses type definition with a grain of salt.
* Some values might be undefined on some pages. * Some values might be undefined on some pages.
* (ex: url.loginAction is undefined on error.ftl) * (ex: url.loginAction is undefined on error.ftl)

View file

@ -4,6 +4,6 @@ import { setupI18n } from "~/functions/i18n";
if ((window as any).kcContext) { if ((window as any).kcContext) {
const app = createApp(index); const app = createApp(index);
app.use(setupI18n()) app.use(setupI18n());
app.mount("#app"); app.mount("#app");
} }

View file

@ -22,11 +22,14 @@
method="post"> method="post">
<div> <div>
<label for="username" class="block text-sm text-gray-800">{{ <label for="username" class="block text-sm text-gray-800">{{
context.realm.loginWithEmailAllowed ? $t("login.usernameOrEmail") : $t("login.username") context.realm.loginWithEmailAllowed
? $t("login.usernameOrEmail")
: $t("login.username")
}}</label> }}</label>
<input <input
name="username" name="username"
type="text" type="text"
ref="focus"
:placeholder=" :placeholder="
context.realm.loginWithEmailAllowed context.realm.loginWithEmailAllowed
? 'jane.doe@example.com' ? 'jane.doe@example.com'
@ -37,9 +40,9 @@
</div> </div>
<div class="mt-4"> <div class="mt-4">
<div> <div>
<label for="password" class="block text-sm text-gray-800" <label for="password" class="block text-sm text-gray-800">{{
>{{ $t("login.password") }}</label $t("login.password")
> }}</label>
<input <input
type="password" type="password"
name="password" name="password"
@ -60,9 +63,9 @@
type="checkbox" type="checkbox"
name="rememberMe" name="rememberMe"
class="w-4 h-4 text-blue-500 bg-gray-100 border-gray-400 rounded dark:ring-offset-gray-800 dark:bg-gray-700 dark:border-gray-600" /> class="w-4 h-4 text-blue-500 bg-gray-100 border-gray-400 rounded dark:ring-offset-gray-800 dark:bg-gray-700 dark:border-gray-600" />
<label for="rememberMe" class="block ml-3 text-sm text-gray-800" <label for="rememberMe" class="block ml-3 text-sm text-gray-800">{{
>{{ $t("login.rememberMe") }}</label $t("login.rememberMe")
> }}</label>
</div> </div>
<input <input
type="hidden" type="hidden"
@ -78,7 +81,7 @@
</layout> </layout>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue"; import { defineComponent, ref, onMounted, nextTick } from "vue";
import Layout from "~/components/Layout.vue"; 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";
@ -94,6 +97,19 @@ export default defineComponent({
context: (window as any).kcContext as KcContextBase.Login, context: (window as any).kcContext as KcContextBase.Login,
}; };
}, },
setup() {
const focus = ref(null);
onMounted(() => {
nextTick(() => {
focus.value.focus();
});
});
return {
focus,
};
},
}); });
</script> </script>
<style> <style>

View file

@ -4,6 +4,6 @@ import { setupI18n } from "~/functions/i18n";
if ((window as any).kcContext) { if ((window as any).kcContext) {
const app = createApp(index); const app = createApp(index);
app.use(setupI18n()) app.use(setupI18n());
app.mount("#app"); app.mount("#app");
} }

View file

@ -12,7 +12,9 @@
stroke-dasharray="164 56" /> stroke-dasharray="164 56" />
</svg> </svg>
</div> </div>
<h1 class="mt-4 text-xl text-center text-gray-700">{{ $t("redirect.message") }}</h1> <h1 class="mt-4 text-xl text-center text-gray-700">
{{ $t("redirect.message") }}
</h1>
</layout> </layout>
</template> </template>
<script lang="ts"> <script lang="ts">

View file

@ -4,6 +4,6 @@ import { setupI18n } from "~/functions/i18n";
if ((window as any).kcContext) { if ((window as any).kcContext) {
const app = createApp(index); const app = createApp(index);
app.use(setupI18n()) app.use(setupI18n());
app.mount("#app"); app.mount("#app");
} }

View file

@ -24,7 +24,7 @@
<div class="w-full ml-5 flex flex-col"> <div class="w-full ml-5 flex flex-col">
<p>{{ authenticator.label }}</p> <p>{{ authenticator.label }}</p>
<p class="text-sm"> <p class="text-sm">
{{ $t("webauthn.registered", {date: moment(authenticator.createdAt)}) }} {{ $t("webauthn.registered", { date: authenticator.createdAt }) }}
</p> </p>
</div> </div>
</div> </div>
@ -59,9 +59,7 @@
</button> </button>
</div> </div>
<div v-if="error" class="mt-5 flex flex-col"> <div v-if="error" class="mt-5 flex flex-col">
<ErrorBox <ErrorBox>{{ $t("webauthn.error") }}</ErrorBox>
>{{ $t("webauthn.error") }}</ErrorBox
>
<button <button
:onclick="retryAuth" :onclick="retryAuth"
class="w-full mt-5 px-4 py-2 tracking-wide text-white transition-colors duration-200 transform bg-blue-500 rounded-md hover:bg-blue-400 focus:outline-none focus:bg-blue-400"> class="w-full mt-5 px-4 py-2 tracking-wide text-white transition-colors duration-200 transform bg-blue-500 rounded-md hover:bg-blue-400 focus:outline-none focus:bg-blue-400">
@ -77,7 +75,6 @@ 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"; import { formPost } from "~/functions/utils";
import moment from "moment";
export default defineComponent({ export default defineComponent({
name: "WebAuthnAuthenticate", name: "WebAuthnAuthenticate",
@ -98,9 +95,6 @@ export default defineComponent({
} }
}, },
methods: { methods: {
moment(date: string) : string {
return moment(date).toDate().toLocaleString();
},
tryAnotherWay(e: Event) { tryAnotherWay(e: Event) {
e.preventDefault(); e.preventDefault();
formPost(this.context.url.loginAction, { formPost(this.context.url.loginAction, {
@ -109,7 +103,7 @@ export default defineComponent({
}, },
prepareAuthenticate() { prepareAuthenticate() {
if (this.context.isUserIdentified) { if (this.context.isUserIdentified) {
const allowedCredentials = new Array<PublicKeyCredentialDescriptor>(); const allowedCredentials: PublicKeyCredentialDescriptor[] = [];
this.context.authenticators.authenticators.forEach(authenticator => { this.context.authenticators.authenticators.forEach(authenticator => {
allowedCredentials.push({ allowedCredentials.push({
id: base64url.parse(authenticator.credentialId, { loose: true }), id: base64url.parse(authenticator.credentialId, { loose: true }),
@ -121,9 +115,7 @@ export default defineComponent({
this.authenticate([]); this.authenticate([]);
} }
}, },
async authenticate( async authenticate(allowedAuthenticators: PublicKeyCredentialDescriptor[]) {
allowedAuthenticators: Array<PublicKeyCredentialDescriptor>
) {
const publicKey: PublicKeyCredentialRequestOptions = { const publicKey: PublicKeyCredentialRequestOptions = {
rpId: this.context.rpId, rpId: this.context.rpId,
challenge: base64url.parse(this.context.challenge, { loose: true }), challenge: base64url.parse(this.context.challenge, { loose: true }),