diff --git a/README.md b/README.md index e6ca7598..178b007e 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,15 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d # Changelog highlights +## v9.0 + +Restore support for Keycloak 22.1 and up! Huge thanks to @xgp without whom this wouldn't have been possible. +Big thanks also to @ssilvert from the Keycloak team for being so open to discussion and merging [@xgp's PR](https://github.com/keycloak/keycloak/pull/22317). + +### Breaking changes + +Very few. Check them out [here](https://docs.keycloakify.dev/migration-guides/v8-greater-than-v9). + ## 8.0 - Much smaller .jar size. 70.2 MB -> 7.8 MB. @@ -138,58 +147,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d ### Breaking changes -There are very few breaking changes in this major version. - -- The [`--external-assets` build option has been removed](https://docs.keycloakify.dev/v/v7/build-options#external-assets-deprecated) it was a performance optimization that is no longer relevant now that - we have lazy loading. - -- `kcContext.usernameEditDisabled` is now `kcContext.usernameHidden`, the type was lying, it has been updated to reflect what's actually on the `kcContext` at runtime. - If you want to see in detail what should be updated [see issue](https://github.com/keycloakify/keycloakify/pull/399), or you can search and replace `usernameEditDisabled` -> `usernameHidden` it'll do the trick. - -- The `usePrepareTemplate` prototype has been changed, you can search and replace: - -`src/keycloak-theme/login/Template.tsx` - -```ts -url, -"stylesCommon": [ - "node_modules/patternfly/dist/css/patternfly.min.css", - "node_modules/patternfly/dist/css/patternfly-additions.min.css", - "lib/zocial/zocial.css" -], -"styles": ["css/login.css"], -``` - -by - -```ts -"styles": [ - `${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`, - `${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`, - `${url.resourcesCommonPath}/lib/zocial/zocial.css`, - `${url.resourcesPath}/css/login.css` -], -``` - -and - -`src/keycloak-theme/account/Template.css` - -```ts -url, -"stylesCommon": ["node_modules/patternfly/dist/css/patternfly.min.css", "node_modules/patternfly/dist/css/patternfly-additions.min.css"], -"styles": ["css/account.css"], -``` - -by - -```ts -"styles": [ - `${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly.min.css`, - `${url.resourcesCommonPath}/node_modules/patternfly/dist/css/patternfly-additions.min.css`, - `${url.resourcesPath}/css/account.css` -], -``` +There are very few breaking changes in this major version. [Check them out](https://docs.keycloakify.dev/migration-guides/v7-greater-than-v8). ## 7.15 @@ -211,7 +169,7 @@ by ## 7.12 - You can now pack multiple themes variant in a single `.jar` bundle. In vanilla Keycloak themes you have the ability to extend a base theme. - There is now an idiomatic way of achieving the same result. [Learn more](https://docs.keycloakify.dev/build-options#keycloakify.extrathemenames). + There is now an idiomatic way of achieving the same result. [Learn more](https://docs.keycloakify.dev/build-options#keycloakify.themeVariantNames). ## 7.9 diff --git a/keycloakify-json-schema.json b/keycloakify-json-schema.json deleted file mode 100644 index 254b2e50..00000000 --- a/keycloakify-json-schema.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "allOf": [ - { - "$ref": "https://json.schemastore.org/package.json" - }, - { - "$ref": "keycloakifyPackageJsonSchema" - } - ], - "$ref": "#/definitions/keycloakifyPackageJsonSchema", - "definitions": { - "keycloakifyPackageJsonSchema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "version": { - "type": "string" - }, - "homepage": { - "type": "string" - }, - "keycloakify": { - "type": "object", - "properties": { - "extraPages": { - "type": "array", - "items": { - "type": "string" - } - }, - "extraThemeProperties": { - "type": "array", - "items": { - "type": "string" - } - }, - "areAppAndKeycloakServerSharingSameDomain": { - "type": "boolean" - }, - "artifactId": { - "type": "string" - }, - "groupId": { - "type": "string" - }, - "bundler": { - "type": "string", - "enum": ["mvn", "keycloakify", "none"] - }, - "keycloakVersionDefaultAssets": { - "type": "string" - }, - "reactAppBuildDirPath": { - "type": "string" - }, - "keycloakifyBuildDirPath": { - "type": "string" - }, - "themeName": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "required": ["name", "version"], - "additionalProperties": false - } - }, - "$schema": "http://json-schema.org/draft-07/schema#" -} diff --git a/package.json b/package.json index 7273fd79..81cc4e24 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloakify", - "version": "8.4.1", + "version": "9.0.0-rc.0", "description": "Create Keycloak themes using React", "repository": { "type": "git", @@ -13,7 +13,7 @@ "build": "rimraf dist/ && tsc -p src/bin && tsc -p src && tsc-alias -p src/tsconfig.json && yarn grant-exec-perms && yarn copy-files dist/ && cp -r src dist/", "generate:json-schema": "ts-node scripts/generate-json-schema.ts", "grant-exec-perms": "node dist/bin/tools/grant-exec-perms.js", - "copy-files": "copyfiles -u 1 src/**/*.ftl", + "copy-files": "copyfiles -u 1 src/**/*.ftl src/**/*.java", "test": "yarn test:types && vitest run", "test:keycloakify-starter": "ts-node scripts/test-keycloakify-starter", "test:types": "tsc -p test/tsconfig.json --noEmit", diff --git a/scripts/generate-i18n-messages.ts b/scripts/generate-i18n-messages.ts index 756cef4f..a9596581 100644 --- a/scripts/generate-i18n-messages.ts +++ b/scripts/generate-i18n-messages.ts @@ -24,9 +24,11 @@ async function main() { fs.rmSync(tmpDirPath, { "recursive": true, "force": true }); await downloadBuiltinKeycloakTheme({ - "projectDirPath": getProjectRoot(), keycloakVersion, - "destDirPath": tmpDirPath + "destDirPath": tmpDirPath, + "buildOptions": { + "cacheDirPath": pathJoin(getProjectRoot(), "node_modules", ".cache", "keycloakify") + } }); type Dictionary = { [idiomId: string]: string }; diff --git a/src/account/kcContext/KcContext.ts b/src/account/kcContext/KcContext.ts index 406568b7..9f1851f7 100644 --- a/src/account/kcContext/KcContext.ts +++ b/src/account/kcContext/KcContext.ts @@ -1,6 +1,7 @@ -import type { AccountThemePageId, ThemeType } from "keycloakify/bin/keycloakify/generateFtl"; +import type { AccountThemePageId } from "keycloakify/bin/keycloakify/generateFtl"; import { assert } from "tsafe/assert"; import type { Equals } from "tsafe"; +import { type ThemeType } from "keycloakify/bin/constants"; export type KcContext = KcContext.Password | KcContext.Account; diff --git a/src/account/kcContext/createGetKcContext.ts b/src/account/kcContext/createGetKcContext.ts index c2063b2b..e1e37fbc 100644 --- a/src/account/kcContext/createGetKcContext.ts +++ b/src/account/kcContext/createGetKcContext.ts @@ -3,9 +3,8 @@ import { deepAssign } from "keycloakify/tools/deepAssign"; import type { ExtendKcContext } from "./getKcContextFromWindow"; import { getKcContextFromWindow } from "./getKcContextFromWindow"; import { pathJoin } from "keycloakify/bin/tools/pathJoin"; -import { pathBasename } from "keycloakify/tools/pathBasename"; -import { resourcesCommonDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath"; import { symToStr } from "tsafe/symToStr"; +import { resources_common } from "keycloakify/bin/constants"; import { kcContextMocks, kcContextCommonMock } from "keycloakify/account/kcContext/kcContextMocks"; export function createGetKcContext(params?: { @@ -89,11 +88,7 @@ export function createGetKcContext { - const projectDirPath = process.cwd(); + const reactAppRootDirPath = process.cwd(); const buildOptions = readBuildOptions({ - "processArgv": process.argv.slice(2), - "projectDirPath": process.cwd() + reactAppRootDirPath, + "processArgv": process.argv.slice(2) }); - const keycloakDirInPublicDir = pathJoin(process.env["PUBLIC_DIR_PATH"] || pathJoin(projectDirPath, "public"), basenameOfKeycloakDirInPublicDir); - - if (fs.existsSync(keycloakDirInPublicDir)) { - console.log(`${pathRelative(projectDirPath, keycloakDirInPublicDir)} already exists.`); - return; - } + const reservedDirPath = pathJoin(buildOptions.publicDirPath, keycloak_resources); for (const themeType of themeTypes) { await downloadKeycloakStaticResources({ - projectDirPath, - "keycloakVersion": buildOptions.keycloakVersionDefaultAssets, - "themeType": themeType, - "themeDirPath": keycloakDirInPublicDir, - "usedResources": undefined + "keycloakVersion": (() => { + switch (themeType) { + case "login": + return buildOptions.loginThemeResourcesFromKeycloakVersion; + case "account": + return lastKeycloakVersionWithAccountV1; + } + })(), + themeType, + "themeDirPath": reservedDirPath, + "usedResources": undefined, + buildOptions }); } fs.writeFileSync( - pathJoin(keycloakDirInPublicDir, "README.txt"), + pathJoin(reservedDirPath, "README.txt"), Buffer.from( // prettier-ignore [ @@ -43,7 +44,7 @@ import * as fs from "fs"; ) ); - fs.writeFileSync(pathJoin(keycloakDirInPublicDir, ".gitignore"), Buffer.from("*", "utf8")); + fs.writeFileSync(pathJoin(buildOptions.publicDirPath, "keycloak-resources", ".gitignore"), Buffer.from("*", "utf8")); - console.log(`${pathRelative(projectDirPath, keycloakDirInPublicDir)} directory created.`); + console.log(`${pathRelative(reactAppRootDirPath, reservedDirPath)} directory created.`); })(); diff --git a/src/bin/download-builtin-keycloak-theme.ts b/src/bin/download-builtin-keycloak-theme.ts index cbd4dc55..d9eb0be8 100644 --- a/src/bin/download-builtin-keycloak-theme.ts +++ b/src/bin/download-builtin-keycloak-theme.ts @@ -4,15 +4,23 @@ import { downloadAndUnzip } from "./tools/downloadAndUnzip"; import { promptKeycloakVersion } from "./promptKeycloakVersion"; import { getLogger } from "./tools/logger"; import { readBuildOptions } from "./keycloakify/BuildOptions"; +import { assert } from "tsafe/assert"; +import type { BuildOptions } from "./keycloakify/BuildOptions"; import * as child_process from "child_process"; import * as fs from "fs"; -export async function downloadBuiltinKeycloakTheme(params: { projectDirPath: string; keycloakVersion: string; destDirPath: string }) { - const { projectDirPath, keycloakVersion, destDirPath } = params; +export type BuildOptionsLike = { + cacheDirPath: string; +}; + +assert(); + +export async function downloadBuiltinKeycloakTheme(params: { keycloakVersion: string; destDirPath: string; buildOptions: BuildOptionsLike }) { + const { keycloakVersion, destDirPath, buildOptions } = params; await downloadAndUnzip({ "doUseCache": true, - projectDirPath, + "cacheDirPath": buildOptions.cacheDirPath, destDirPath, "url": `https://github.com/keycloak/keycloak/archive/refs/tags/${keycloakVersion}.zip`, "specificDirsToExtract": ["", "-community"].map(ext => `keycloak-${keycloakVersion}/themes/src/main/resources${ext}/theme`), @@ -74,7 +82,7 @@ export async function downloadBuiltinKeycloakTheme(params: { projectDirPath: str async function main() { const buildOptions = readBuildOptions({ - "projectDirPath": process.cwd(), + "reactAppRootDirPath": process.cwd(), "processArgv": process.argv.slice(2) }); @@ -86,9 +94,9 @@ async function main() { logger.log(`Downloading builtins theme of Keycloak ${keycloakVersion} here ${destDirPath}`); await downloadBuiltinKeycloakTheme({ - "projectDirPath": process.cwd(), keycloakVersion, - destDirPath + destDirPath, + buildOptions }); } diff --git a/src/bin/eject-keycloak-page.ts b/src/bin/eject-keycloak-page.ts index 1093711b..e82ff9c7 100644 --- a/src/bin/eject-keycloak-page.ts +++ b/src/bin/eject-keycloak-page.ts @@ -2,14 +2,7 @@ import { getProjectRoot } from "./tools/getProjectRoot"; import cliSelect from "cli-select"; -import { - loginThemePageIds, - accountThemePageIds, - type LoginThemePageId, - type AccountThemePageId, - themeTypes, - type ThemeType -} from "./keycloakify/generateFtl"; +import { loginThemePageIds, accountThemePageIds, type LoginThemePageId, type AccountThemePageId } from "./keycloakify/generateFtl"; import { capitalize } from "tsafe/capitalize"; import { readFile, writeFile } from "fs/promises"; import { existsSync } from "fs"; @@ -17,10 +10,13 @@ import { join as pathJoin, relative as pathRelative } from "path"; import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase"; import { assert, Equals } from "tsafe/assert"; import { getThemeSrcDirPath } from "./getSrcDirPath"; +import { themeTypes, type ThemeType } from "./constants"; (async () => { console.log("Select a theme type"); + const reactAppRootDirPath = process.cwd(); + const { value: themeType } = await cliSelect({ "values": [...themeTypes] }).catch(() => { @@ -49,7 +45,7 @@ import { getThemeSrcDirPath } from "./getSrcDirPath"; const pageBasename = capitalize(kebabCaseToCamelCase(pageId)).replace(/ftl$/, "tsx"); - const { themeSrcDirPath } = getThemeSrcDirPath({ "projectDirPath": process.cwd() }); + const { themeSrcDirPath } = getThemeSrcDirPath({ reactAppRootDirPath }); const targetFilePath = pathJoin(themeSrcDirPath, themeType, "pages", pageBasename); diff --git a/src/bin/getSrcDirPath.ts b/src/bin/getSrcDirPath.ts index 115cc89b..48622eed 100644 --- a/src/bin/getSrcDirPath.ts +++ b/src/bin/getSrcDirPath.ts @@ -2,15 +2,15 @@ import * as fs from "fs"; import { exclude } from "tsafe"; import { crawl } from "./tools/crawl"; import { join as pathJoin } from "path"; -import { themeTypes } from "./keycloakify/generateFtl"; +import { themeTypes } from "./constants"; const themeSrcDirBasenames = ["keycloak-theme", "keycloak_theme"]; /** Can't catch error, if the directory isn't found, this function will just exit the process with an error message. */ -export function getThemeSrcDirPath(params: { projectDirPath: string }) { - const { projectDirPath } = params; +export function getThemeSrcDirPath(params: { reactAppRootDirPath: string }) { + const { reactAppRootDirPath } = params; - const srcDirPath = pathJoin(projectDirPath, "src"); + const srcDirPath = pathJoin(reactAppRootDirPath, "src"); const themeSrcDirPath: string | undefined = crawl({ "dirPath": srcDirPath, "returnedPathsType": "relative to dirPath" }) .map(fileRelativePath => { diff --git a/src/bin/initialize-email-theme.ts b/src/bin/initialize-email-theme.ts index 8a81a5bb..14f44d6e 100644 --- a/src/bin/initialize-email-theme.ts +++ b/src/bin/initialize-email-theme.ts @@ -10,17 +10,17 @@ import { getLogger } from "./tools/logger"; import { getThemeSrcDirPath } from "./getSrcDirPath"; export async function main() { - const projectDirPath = process.cwd(); + const reactAppRootDirPath = process.cwd(); - const { isSilent } = readBuildOptions({ - projectDirPath, + const buildOptions = readBuildOptions({ + reactAppRootDirPath, "processArgv": process.argv.slice(2) }); - const logger = getLogger({ isSilent }); + const logger = getLogger({ "isSilent": buildOptions.isSilent }); const { themeSrcDirPath } = getThemeSrcDirPath({ - projectDirPath + reactAppRootDirPath }); const emailThemeSrcDirPath = pathJoin(themeSrcDirPath, "email"); @@ -36,9 +36,9 @@ export async function main() { const builtinKeycloakThemeTmpDirPath = pathJoin(emailThemeSrcDirPath, "..", "tmp_xIdP3_builtin_keycloak_theme"); await downloadBuiltinKeycloakTheme({ - projectDirPath, keycloakVersion, - "destDirPath": builtinKeycloakThemeTmpDirPath + "destDirPath": builtinKeycloakThemeTmpDirPath, + buildOptions }); transformCodebase({ diff --git a/src/bin/keycloakify/BuildOptions.ts b/src/bin/keycloakify/BuildOptions.ts index b48927b6..119dd557 100644 --- a/src/bin/keycloakify/BuildOptions.ts +++ b/src/bin/keycloakify/BuildOptions.ts @@ -1,34 +1,34 @@ -import { assert } from "tsafe/assert"; -import { id } from "tsafe/id"; import { parse as urlParse } from "url"; -import { typeGuard } from "tsafe/typeGuard"; -import { symToStr } from "tsafe/symToStr"; -import { bundlers, getParsedPackageJson, type Bundler } from "./parsedPackageJson"; -import { join as pathJoin, sep as pathSep } from "path"; +import { getParsedPackageJson } from "./parsedPackageJson"; +import { join as pathJoin } from "path"; import parseArgv from "minimist"; +import { getAbsoluteAndInOsFormatPath } from "../tools/getAbsoluteAndInOsFormatPath"; /** Consolidated build option gathered form CLI arguments and config in package.json */ export type BuildOptions = { isSilent: boolean; themeVersion: string; - themeName: string; - extraThemeNames: string[]; + themeNames: string[]; extraThemeProperties: string[] | undefined; groupId: string; artifactId: string; - bundler: Bundler; - keycloakVersionDefaultAssets: string; + doCreateJar: boolean; + loginThemeResourcesFromKeycloakVersion: string; + reactAppRootDirPath: string; /** Directory of your built react project. Defaults to {cwd}/build */ reactAppBuildDirPath: string; /** Directory that keycloakify outputs to. Defaults to {cwd}/build_keycloak */ keycloakifyBuildDirPath: string; + publicDirPath: string; + cacheDirPath: string; /** If your app is hosted under a subpath, it's the case in CRA if you have "homepage": "https://example.com/my-app" in your package.json * In this case the urlPathname will be "/my-app/" */ urlPathname: string | undefined; + doBuildRetrocompatAccountTheme: boolean; }; -export function readBuildOptions(params: { projectDirPath: string; processArgv: string[] }): BuildOptions { - const { projectDirPath, processArgv } = params; +export function readBuildOptions(params: { reactAppRootDirPath: string; processArgv: string[] }): BuildOptions { + const { reactAppRootDirPath, processArgv } = params; const { isSilentCliParamProvided } = (() => { const argv = parseArgv(processArgv); @@ -38,35 +38,36 @@ export function readBuildOptions(params: { projectDirPath: string; processArgv: }; })(); - const parsedPackageJson = getParsedPackageJson({ projectDirPath }); + const parsedPackageJson = getParsedPackageJson({ reactAppRootDirPath }); const { name, keycloakify = {}, version, homepage } = parsedPackageJson; - const { extraThemeProperties, groupId, artifactId, bundler, keycloakVersionDefaultAssets, extraThemeNames = [] } = keycloakify ?? {}; + const { extraThemeProperties, groupId, artifactId, doCreateJar, loginThemeResourcesFromKeycloakVersion } = keycloakify ?? {}; - const themeName = - keycloakify.themeName ?? - name - .replace(/^@(.*)/, "$1") - .split("/") - .join("-"); + const themeNames = (() => { + if (keycloakify.themeName === undefined) { + return [ + name + .replace(/^@(.*)/, "$1") + .split("/") + .join("-") + ]; + } + + if (typeof keycloakify.themeName === "string") { + return [keycloakify.themeName]; + } + + return keycloakify.themeName; + })(); return { - themeName, - extraThemeNames, - "bundler": (() => { - const { KEYCLOAKIFY_BUNDLER } = process.env; - - assert( - typeGuard(KEYCLOAKIFY_BUNDLER, [undefined, ...id(bundlers)].includes(KEYCLOAKIFY_BUNDLER)), - `${symToStr({ KEYCLOAKIFY_BUNDLER })} should be one of ${bundlers.join(", ")}` - ); - - return KEYCLOAKIFY_BUNDLER ?? bundler ?? "keycloakify"; - })(), - "artifactId": process.env.KEYCLOAKIFY_ARTIFACT_ID ?? artifactId ?? `${themeName}-keycloak-theme`, + reactAppRootDirPath, + themeNames, + "doCreateJar": doCreateJar ?? true, + "artifactId": process.env.KEYCLOAKIFY_ARTIFACT_ID ?? artifactId ?? `${themeNames[0]}-keycloak-theme`, "groupId": (() => { - const fallbackGroupId = `${themeName}.keycloak`; + const fallbackGroupId = `${themeNames[0]}.keycloak`; return ( process.env.KEYCLOAKIFY_GROUP_ID ?? @@ -83,41 +84,58 @@ export function readBuildOptions(params: { projectDirPath: string; processArgv: "themeVersion": process.env.KEYCLOAKIFY_THEME_VERSION ?? process.env.KEYCLOAKIFY_VERSION ?? version ?? "0.0.0", extraThemeProperties, "isSilent": isSilentCliParamProvided, - "keycloakVersionDefaultAssets": keycloakVersionDefaultAssets ?? "11.0.3", + "loginThemeResourcesFromKeycloakVersion": loginThemeResourcesFromKeycloakVersion ?? "11.0.3", + "publicDirPath": (() => { + let { PUBLIC_DIR_PATH } = process.env; + + if (PUBLIC_DIR_PATH !== undefined) { + return getAbsoluteAndInOsFormatPath({ + "pathIsh": PUBLIC_DIR_PATH, + "cwd": reactAppRootDirPath + }); + } + + return pathJoin(reactAppRootDirPath, "public"); + })(), "reactAppBuildDirPath": (() => { - let { reactAppBuildDirPath = undefined } = parsedPackageJson.keycloakify ?? {}; + const { reactAppBuildDirPath } = parsedPackageJson.keycloakify ?? {}; - if (reactAppBuildDirPath === undefined) { - return pathJoin(projectDirPath, "build"); + if (reactAppBuildDirPath !== undefined) { + return getAbsoluteAndInOsFormatPath({ + "pathIsh": reactAppBuildDirPath, + "cwd": reactAppRootDirPath + }); } - if (pathSep === "\\") { - reactAppBuildDirPath = reactAppBuildDirPath.replace(/\//g, pathSep); - } - - if (reactAppBuildDirPath.startsWith(`.${pathSep}`)) { - return pathJoin(projectDirPath, reactAppBuildDirPath); - } - - return reactAppBuildDirPath; + return pathJoin(reactAppRootDirPath, "build"); })(), "keycloakifyBuildDirPath": (() => { - let { keycloakifyBuildDirPath = undefined } = parsedPackageJson.keycloakify ?? {}; + const { keycloakifyBuildDirPath } = parsedPackageJson.keycloakify ?? {}; - if (keycloakifyBuildDirPath === undefined) { - return pathJoin(projectDirPath, "build_keycloak"); + if (keycloakifyBuildDirPath !== undefined) { + return getAbsoluteAndInOsFormatPath({ + "pathIsh": keycloakifyBuildDirPath, + "cwd": reactAppRootDirPath + }); } - if (pathSep === "\\") { - keycloakifyBuildDirPath = keycloakifyBuildDirPath.replace(/\//g, pathSep); - } - - if (keycloakifyBuildDirPath.startsWith(`.${pathSep}`)) { - return pathJoin(projectDirPath, keycloakifyBuildDirPath); - } - - return keycloakifyBuildDirPath; + return pathJoin(reactAppRootDirPath, "build_keycloak"); })(), + "cacheDirPath": pathJoin( + (() => { + let { XDG_CACHE_HOME } = process.env; + + if (XDG_CACHE_HOME !== undefined) { + return getAbsoluteAndInOsFormatPath({ + "pathIsh": XDG_CACHE_HOME, + "cwd": reactAppRootDirPath + }); + } + + return pathJoin(reactAppRootDirPath, "node_modules", ".cache"); + })(), + "keycloakify" + ), "urlPathname": (() => { const { homepage } = parsedPackageJson; @@ -133,6 +151,7 @@ export function readBuildOptions(params: { projectDirPath: string; processArgv: const out = url.pathname.replace(/([^/])$/, "$1/"); return out === "/" ? undefined : out; - })() + })(), + "doBuildRetrocompatAccountTheme": parsedPackageJson.keycloakify?.doBuildRetrocompatAccountTheme ?? true }; } diff --git a/src/bin/keycloakify/generateFtl/generateFtl.ts b/src/bin/keycloakify/generateFtl/generateFtl.ts index dceb9029..6953c6fd 100644 --- a/src/bin/keycloakify/generateFtl/generateFtl.ts +++ b/src/bin/keycloakify/generateFtl/generateFtl.ts @@ -8,13 +8,9 @@ import { objectKeys } from "tsafe/objectKeys"; import { ftlValuesGlobalName } from "../ftlValuesGlobalName"; import type { BuildOptions } from "../BuildOptions"; import { assert } from "tsafe/assert"; - -export const themeTypes = ["login", "account"] as const; - -export type ThemeType = (typeof themeTypes)[number]; +import type { ThemeType } from "../../constants"; export type BuildOptionsLike = { - themeName: string; themeVersion: string; urlPathname: string | undefined; }; @@ -22,6 +18,7 @@ export type BuildOptionsLike = { assert(); export function generateFtlFilesCodeFactory(params: { + themeName: string; indexHtmlCode: string; //NOTE: Expected to be an empty object if external assets mode is enabled. cssGlobalsToDefine: Record; @@ -30,7 +27,7 @@ export function generateFtlFilesCodeFactory(params: { themeType: ThemeType; fieldNames: string[]; }) { - const { cssGlobalsToDefine, indexHtmlCode, buildOptions, keycloakifyVersion, themeType, fieldNames } = params; + const { themeName, cssGlobalsToDefine, indexHtmlCode, buildOptions, keycloakifyVersion, themeType, fieldNames } = params; const $ = cheerio.load(indexHtmlCode); @@ -104,7 +101,7 @@ export function generateFtlFilesCodeFactory(params: { .replace("KEYCLOAKIFY_VERSION_xEdKd3xEdr", keycloakifyVersion) .replace("KEYCLOAKIFY_THEME_VERSION_sIgKd3xEdr3dx", buildOptions.themeVersion) .replace("KEYCLOAKIFY_THEME_TYPE_dExKd3xEdr", themeType) - .replace("KEYCLOAKIFY_THEME_NAME_cXxKd3xEer", buildOptions.themeName), + .replace("KEYCLOAKIFY_THEME_NAME_cXxKd3xEer", themeName), "": [ "<#if scripts??>", " <#list scripts as script>", diff --git a/src/bin/keycloakify/generateJavaStackFiles.ts b/src/bin/keycloakify/generateJavaStackFiles.ts deleted file mode 100644 index 5fa9266f..00000000 --- a/src/bin/keycloakify/generateJavaStackFiles.ts +++ /dev/null @@ -1,84 +0,0 @@ -import * as fs from "fs"; -import { join as pathJoin, dirname as pathDirname } from "path"; -import { assert } from "tsafe/assert"; -import type { BuildOptions } from "./BuildOptions"; -import type { ThemeType } from "./generateFtl"; - -export type BuildOptionsLike = { - themeName: string; - extraThemeNames: string[]; - groupId: string; - artifactId: string; - themeVersion: string; -}; - -assert(); - -export function generateJavaStackFiles(params: { - keycloakThemeBuildingDirPath: string; - implementedThemeTypes: Record; - buildOptions: BuildOptionsLike; -}): { - jarFilePath: string; -} { - const { - buildOptions: { groupId, themeName, extraThemeNames, themeVersion, artifactId }, - keycloakThemeBuildingDirPath, - implementedThemeTypes - } = params; - - { - const { pomFileCode } = (function generatePomFileCode(): { - pomFileCode: string; - } { - const pomFileCode = [ - ``, - ``, - ` 4.0.0`, - ` ${groupId}`, - ` ${artifactId}`, - ` ${themeVersion}`, - ` ${artifactId}`, - ` `, - `` - ].join("\n"); - - return { pomFileCode }; - })(); - - fs.writeFileSync(pathJoin(keycloakThemeBuildingDirPath, "pom.xml"), Buffer.from(pomFileCode, "utf8")); - } - - { - const themeManifestFilePath = pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "META-INF", "keycloak-themes.json"); - - try { - fs.mkdirSync(pathDirname(themeManifestFilePath)); - } catch {} - - fs.writeFileSync( - themeManifestFilePath, - Buffer.from( - JSON.stringify( - { - "themes": [themeName, ...extraThemeNames].map(themeName => ({ - "name": themeName, - "types": Object.entries(implementedThemeTypes) - .filter(([, isImplemented]) => isImplemented) - .map(([themeType]) => themeType) - })) - }, - null, - 2 - ), - "utf8" - ) - ); - } - - return { - "jarFilePath": pathJoin(keycloakThemeBuildingDirPath, "target", `${artifactId}-${themeVersion}.jar`) - }; -} diff --git a/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/AccountPages.java b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/AccountPages.java new file mode 100755 index 00000000..d55ec3f5 --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/AccountPages.java @@ -0,0 +1,33 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.forms.account; + +/** + * @author Stian Thorgersen + */ +public enum AccountPages { + ACCOUNT, + PASSWORD, + TOTP, + FEDERATED_IDENTITY, + LOG, + SESSIONS, + APPLICATIONS, + RESOURCES, + RESOURCE_DETAIL; +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/AccountProvider.java b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/AccountProvider.java new file mode 100755 index 00000000..63b48e86 --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/AccountProvider.java @@ -0,0 +1,76 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.forms.account; + +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; +import java.util.List; +import org.keycloak.events.Event; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.models.utils.FormMessage; +import org.keycloak.provider.Provider; + +/** + * @author Stian Thorgersen + */ +public interface AccountProvider extends Provider { + + AccountProvider setUriInfo(UriInfo uriInfo); + + AccountProvider setHttpHeaders(HttpHeaders httpHeaders); + + Response createResponse(AccountPages page); + + AccountProvider setError(Response.Status status, String message, Object... parameters); + + AccountProvider setErrors(Response.Status status, List messages); + + AccountProvider setSuccess(String message, Object... parameters); + + AccountProvider setWarning(String message, Object... parameters); + + AccountProvider setUser(UserModel user); + + AccountProvider setProfileFormData(MultivaluedMap formData); + + AccountProvider setRealm(RealmModel realm); + + AccountProvider setReferrer(String[] referrer); + + AccountProvider setEvents(List events); + + AccountProvider setSessions(List sessions); + + AccountProvider setPasswordSet(boolean passwordSet); + + AccountProvider setStateChecker(String stateChecker); + + AccountProvider setIdTokenHint(String idTokenHint); + + AccountProvider setFeatures( + boolean social, + boolean events, + boolean passwordUpdateSupported, + boolean authorizationSupported); + + AccountProvider setAttribute(String key, String value); +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/AccountProviderFactory.java b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/AccountProviderFactory.java new file mode 100755 index 00000000..a5eb0b5e --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/AccountProviderFactory.java @@ -0,0 +1,25 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.forms.account; + +import org.keycloak.provider.ProviderFactory; + +/** + * @author Stian Thorgersen + */ +public interface AccountProviderFactory extends ProviderFactory {} diff --git a/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/AccountSpi.java b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/AccountSpi.java new file mode 100755 index 00000000..97b77944 --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/AccountSpi.java @@ -0,0 +1,50 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.forms.account; + +import com.google.auto.service.AutoService; +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +/** + * @author Stian Thorgersen + */ +@AutoService(Spi.class) +public class AccountSpi implements Spi { + + @Override + public boolean isInternal() { + return true; + } + + @Override + public String getName() { + return "account"; + } + + @Override + public Class getProviderClass() { + return AccountProvider.class; + } + + @Override + public Class getProviderFactoryClass() { + return AccountProviderFactory.class; + } +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/FreeMarkerAccountProvider.java b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/FreeMarkerAccountProvider.java new file mode 100755 index 00000000..8817da7d --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/FreeMarkerAccountProvider.java @@ -0,0 +1,424 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.forms.account.freemarker; + +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.core.UriBuilder; +import jakarta.ws.rs.core.UriInfo; +import java.io.IOException; +import java.net.URI; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import org.jboss.logging.Logger; +import org.keycloak.events.Event; +import org.keycloak.forms.account.AccountPages; +import org.keycloak.forms.account.AccountProvider; +import org.keycloak.forms.account.freemarker.model.AccountBean; +import org.keycloak.forms.account.freemarker.model.AccountFederatedIdentityBean; +import org.keycloak.forms.account.freemarker.model.ApplicationsBean; +import org.keycloak.forms.account.freemarker.model.AuthorizationBean; +import org.keycloak.forms.account.freemarker.model.FeaturesBean; +import org.keycloak.forms.account.freemarker.model.LogBean; +import org.keycloak.forms.account.freemarker.model.PasswordBean; +import org.keycloak.forms.account.freemarker.model.RealmBean; +import org.keycloak.forms.account.freemarker.model.ReferrerBean; +import org.keycloak.forms.account.freemarker.model.SessionsBean; +import org.keycloak.forms.account.freemarker.model.TotpBean; +import org.keycloak.forms.account.freemarker.model.UrlBean; +import org.keycloak.forms.login.MessageType; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.models.utils.FormMessage; +import org.keycloak.services.util.CacheControlUtil; +import org.keycloak.theme.FreeMarkerException; +import org.keycloak.theme.Theme; +import org.keycloak.theme.beans.AdvancedMessageFormatterMethod; +import org.keycloak.theme.beans.LocaleBean; +import org.keycloak.theme.beans.MessageBean; +import org.keycloak.theme.beans.MessageFormatterMethod; +import org.keycloak.theme.beans.MessagesPerFieldBean; +import org.keycloak.theme.freemarker.FreeMarkerProvider; +import org.keycloak.utils.MediaType; +import org.keycloak.utils.StringUtil; + +/** + * @author Stian Thorgersen + */ +public class FreeMarkerAccountProvider implements AccountProvider { + + private static final Logger logger = Logger.getLogger(FreeMarkerAccountProvider.class); + + protected UserModel user; + protected MultivaluedMap profileFormData; + protected Response.Status status = Response.Status.OK; + protected RealmModel realm; + protected String[] referrer; + protected List events; + protected String stateChecker; + protected String idTokenHint; + protected List sessions; + protected boolean identityProviderEnabled; + protected boolean eventsEnabled; + protected boolean passwordUpdateSupported; + protected boolean passwordSet; + protected KeycloakSession session; + protected FreeMarkerProvider freeMarker; + protected HttpHeaders headers; + protected Map attributes; + + protected UriInfo uriInfo; + + protected List messages = null; + protected MessageType messageType = MessageType.ERROR; + private boolean authorizationSupported; + + public FreeMarkerAccountProvider(KeycloakSession session) { + this.session = session; + this.freeMarker = session.getProvider(FreeMarkerProvider.class); + } + + public AccountProvider setUriInfo(UriInfo uriInfo) { + this.uriInfo = uriInfo; + return this; + } + + @Override + public AccountProvider setHttpHeaders(HttpHeaders httpHeaders) { + this.headers = httpHeaders; + return this; + } + + @Override + public Response createResponse(AccountPages page) { + Map attributes = new HashMap<>(); + + if (this.attributes != null) { + attributes.putAll(this.attributes); + } + + Theme theme; + try { + theme = getTheme(); + } catch (IOException e) { + logger.error("Failed to create theme", e); + return Response.serverError().build(); + } + + Locale locale = session.getContext().resolveLocale(user); + Properties messagesBundle = handleThemeResources(theme, locale, attributes); + + URI baseUri = uriInfo.getBaseUri(); + UriBuilder baseUriBuilder = uriInfo.getBaseUriBuilder(); + for (Map.Entry> e : uriInfo.getQueryParameters().entrySet()) { + baseUriBuilder.queryParam(e.getKey(), e.getValue().toArray()); + } + URI baseQueryUri = baseUriBuilder.build(); + + if (stateChecker != null) { + attributes.put("stateChecker", stateChecker); + } + + handleMessages(locale, messagesBundle, attributes); + + if (referrer != null) { + attributes.put("referrer", new ReferrerBean(referrer)); + } + + if (realm != null) { + attributes.put("realm", new RealmBean(realm)); + } + + attributes.put( + "url", + new UrlBean(realm, theme, baseUri, baseQueryUri, uriInfo.getRequestUri(), idTokenHint)); + + if (realm.isInternationalizationEnabled()) { + UriBuilder b = UriBuilder.fromUri(baseQueryUri).path(uriInfo.getPath()); + attributes.put("locale", new LocaleBean(realm, locale, b, messagesBundle)); + } + + attributes.put( + "features", + new FeaturesBean( + identityProviderEnabled, + eventsEnabled, + passwordUpdateSupported, + authorizationSupported)); + attributes.put("account", new AccountBean(user, profileFormData)); + + switch (page) { + case TOTP: + attributes.put("totp", new TotpBean(session, realm, user, uriInfo.getRequestUriBuilder())); + break; + case FEDERATED_IDENTITY: + attributes.put( + "federatedIdentity", + new AccountFederatedIdentityBean( + session, realm, user, uriInfo.getBaseUri(), stateChecker)); + break; + case LOG: + attributes.put("log", new LogBean(events)); + break; + case SESSIONS: + attributes.put("sessions", new SessionsBean(realm, sessions)); + break; + case APPLICATIONS: + attributes.put("applications", new ApplicationsBean(session, realm, user)); + attributes.put("advancedMsg", new AdvancedMessageFormatterMethod(locale, messagesBundle)); + break; + case PASSWORD: + attributes.put("password", new PasswordBean(passwordSet)); + break; + case RESOURCES: + if (!realm.isUserManagedAccessAllowed()) { + return Response.status(Status.FORBIDDEN).build(); + } + attributes.put("authorization", new AuthorizationBean(session, realm, user, uriInfo)); + case RESOURCE_DETAIL: + if (!realm.isUserManagedAccessAllowed()) { + return Response.status(Status.FORBIDDEN).build(); + } + attributes.put("authorization", new AuthorizationBean(session, realm, user, uriInfo)); + } + + return processTemplate(theme, page, attributes, locale); + } + + /** + * Get Theme used for page rendering. + * + * @return theme for page rendering, never null + * @throws IOException in case of Theme loading problem + */ + protected Theme getTheme() throws IOException { + return session.theme().getTheme(Theme.Type.ACCOUNT); + } + + /** + * Load message bundle and place it into msg template attribute. Also load Theme + * properties and place them into properties template attribute. + * + * @param theme actual Theme to load bundle from + * @param locale to load bundle for + * @param attributes template attributes to add resources to + * @return message bundle for other use + */ + protected Properties handleThemeResources( + Theme theme, Locale locale, Map attributes) { + Properties messagesBundle = new Properties(); + try { + if (!StringUtil.isNotBlank(realm.getDefaultLocale())) { + messagesBundle.putAll(realm.getRealmLocalizationTextsByLocale(realm.getDefaultLocale())); + } + messagesBundle.putAll(theme.getMessages(locale)); + messagesBundle.putAll(realm.getRealmLocalizationTextsByLocale(locale.toLanguageTag())); + attributes.put("msg", new MessageFormatterMethod(locale, messagesBundle)); + } catch (IOException e) { + logger.warn("Failed to load messages", e); + messagesBundle = new Properties(); + } + try { + attributes.put("properties", theme.getProperties()); + } catch (IOException e) { + logger.warn("Failed to load properties", e); + } + return messagesBundle; + } + + /** + * Handle messages to be shown on the page - set them to template attributes + * + * @param locale to be used for message text loading + * @param messagesBundle to be used for message text loading + * @param attributes template attributes to messages related info to + * @see #messageType + * @see #messages + */ + protected void handleMessages( + Locale locale, Properties messagesBundle, Map attributes) { + MessagesPerFieldBean messagesPerField = new MessagesPerFieldBean(); + if (messages != null) { + MessageBean wholeMessage = new MessageBean(null, messageType); + for (FormMessage message : this.messages) { + String formattedMessageText = formatMessage(message, messagesBundle, locale); + if (formattedMessageText != null) { + wholeMessage.appendSummaryLine(formattedMessageText); + messagesPerField.addMessage(message.getField(), formattedMessageText, messageType); + } + } + attributes.put("message", wholeMessage); + } + attributes.put("messagesPerField", messagesPerField); + } + + /** + * Process FreeMarker template and prepare Response. Some fields are used for rendering also. + * + * @param theme to be used (provided by getTheme()) + * @param page to be rendered + * @param attributes pushed to the template + * @param locale to be used + * @return Response object to be returned to the browser, never null + */ + protected Response processTemplate( + Theme theme, AccountPages page, Map attributes, Locale locale) { + try { + String result = freeMarker.processTemplate(attributes, Templates.getTemplate(page), theme); + Response.ResponseBuilder builder = + Response.status(status) + .type(MediaType.TEXT_HTML_UTF_8_TYPE) + .language(locale) + .entity(result); + builder.cacheControl(CacheControlUtil.noCache()); + return builder.build(); + } catch (FreeMarkerException e) { + logger.error("Failed to process template", e); + return Response.serverError().build(); + } + } + + public AccountProvider setPasswordSet(boolean passwordSet) { + this.passwordSet = passwordSet; + return this; + } + + protected void setMessage(MessageType type, String message, Object... parameters) { + messageType = type; + messages = new ArrayList<>(); + messages.add(new FormMessage(null, message, parameters)); + } + + protected String formatMessage(FormMessage message, Properties messagesBundle, Locale locale) { + if (message == null) return null; + if (messagesBundle.containsKey(message.getMessage())) { + return new MessageFormat(messagesBundle.getProperty(message.getMessage()), locale) + .format(message.getParameters()); + } else { + return message.getMessage(); + } + } + + @Override + public AccountProvider setErrors(Response.Status status, List messages) { + this.status = status; + this.messageType = MessageType.ERROR; + this.messages = new ArrayList<>(messages); + return this; + } + + @Override + public AccountProvider setError(Response.Status status, String message, Object... parameters) { + this.status = status; + setMessage(MessageType.ERROR, message, parameters); + return this; + } + + @Override + public AccountProvider setSuccess(String message, Object... parameters) { + setMessage(MessageType.SUCCESS, message, parameters); + return this; + } + + @Override + public AccountProvider setWarning(String message, Object... parameters) { + setMessage(MessageType.WARNING, message, parameters); + return this; + } + + @Override + public AccountProvider setUser(UserModel user) { + this.user = user; + return this; + } + + @Override + public AccountProvider setProfileFormData(MultivaluedMap formData) { + this.profileFormData = formData; + return this; + } + + @Override + public AccountProvider setRealm(RealmModel realm) { + this.realm = realm; + return this; + } + + @Override + public AccountProvider setReferrer(String[] referrer) { + this.referrer = referrer; + return this; + } + + @Override + public AccountProvider setEvents(List events) { + this.events = events; + return this; + } + + @Override + public AccountProvider setSessions(List sessions) { + this.sessions = sessions; + return this; + } + + @Override + public AccountProvider setStateChecker(String stateChecker) { + this.stateChecker = stateChecker; + return this; + } + + @Override + public AccountProvider setIdTokenHint(String idTokenHint) { + this.idTokenHint = idTokenHint; + return this; + } + + @Override + public AccountProvider setFeatures( + boolean identityProviderEnabled, + boolean eventsEnabled, + boolean passwordUpdateSupported, + boolean authorizationSupported) { + this.identityProviderEnabled = identityProviderEnabled; + this.eventsEnabled = eventsEnabled; + this.passwordUpdateSupported = passwordUpdateSupported; + this.authorizationSupported = authorizationSupported; + return this; + } + + @Override + public AccountProvider setAttribute(String key, String value) { + if (attributes == null) { + attributes = new HashMap<>(); + } + attributes.put(key, value); + return this; + } + + @Override + public void close() {} +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/FreeMarkerAccountProviderFactory.java b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/FreeMarkerAccountProviderFactory.java new file mode 100755 index 00000000..d6e6f7b5 --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/FreeMarkerAccountProviderFactory.java @@ -0,0 +1,51 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.forms.account.freemarker; + +import com.google.auto.service.AutoService; +import org.keycloak.Config; +import org.keycloak.forms.account.AccountProvider; +import org.keycloak.forms.account.AccountProviderFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +/** + * @author Stian Thorgersen + */ +@AutoService(AccountProviderFactory.class) +public class FreeMarkerAccountProviderFactory implements AccountProviderFactory { + + @Override + public AccountProvider create(KeycloakSession session) { + return new FreeMarkerAccountProvider(session); + } + + @Override + public void init(Config.Scope config) {} + + @Override + public void postInit(KeycloakSessionFactory factory) {} + + @Override + public void close() {} + + @Override + public String getId() { + return "freemarker"; + } +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/Templates.java b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/Templates.java new file mode 100755 index 00000000..2782e752 --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/Templates.java @@ -0,0 +1,51 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.forms.account.freemarker; + +import org.keycloak.forms.account.AccountPages; + +/** + * @author Stian Thorgersen + */ +public class Templates { + + public static String getTemplate(AccountPages page) { + switch (page) { + case ACCOUNT: + return "account.ftl"; + case PASSWORD: + return "password.ftl"; + case TOTP: + return "totp.ftl"; + case FEDERATED_IDENTITY: + return "federatedIdentity.ftl"; + case LOG: + return "log.ftl"; + case SESSIONS: + return "sessions.ftl"; + case APPLICATIONS: + return "applications.ftl"; + case RESOURCES: + return "resources.ftl"; + case RESOURCE_DETAIL: + return "resource-detail.ftl"; + default: + throw new IllegalArgumentException(); + } + } +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/AccountBean.java b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/AccountBean.java new file mode 100755 index 00000000..c8458309 --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/AccountBean.java @@ -0,0 +1,91 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.forms.account.freemarker.model; + +import jakarta.ws.rs.core.MultivaluedMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jboss.logging.Logger; +import org.keycloak.models.Constants; +import org.keycloak.models.UserModel; + +/** + * @author Stian Thorgersen + */ +public class AccountBean { + + private static final Logger logger = Logger.getLogger(AccountBean.class); + + private final UserModel user; + private final MultivaluedMap profileFormData; + + // TODO: More proper multi-value attribute support + private final Map attributes = new HashMap<>(); + + public AccountBean(UserModel user, MultivaluedMap profileFormData) { + this.user = user; + this.profileFormData = profileFormData; + + for (Map.Entry> attr : user.getAttributes().entrySet()) { + List attrValue = attr.getValue(); + if (attrValue.size() > 0) { + attributes.put(attr.getKey(), attrValue.get(0)); + } + + if (attrValue.size() > 1) { + logger.warnf( + "There are more values for attribute '%s' of user '%s' . Will display just first value", + attr.getKey(), user.getUsername()); + } + } + + if (profileFormData != null) { + for (String key : profileFormData.keySet()) { + if (key.startsWith(Constants.USER_ATTRIBUTES_PREFIX)) { + String attribute = key.substring(Constants.USER_ATTRIBUTES_PREFIX.length()); + attributes.put(attribute, profileFormData.getFirst(key)); + } + } + } + } + + public String getFirstName() { + return profileFormData != null ? profileFormData.getFirst("firstName") : user.getFirstName(); + } + + public String getLastName() { + return profileFormData != null ? profileFormData.getFirst("lastName") : user.getLastName(); + } + + public String getUsername() { + if (profileFormData != null && profileFormData.containsKey("username")) { + return profileFormData.getFirst("username"); + } else { + return user.getUsername(); + } + } + + public String getEmail() { + return profileFormData != null ? profileFormData.getFirst("email") : user.getEmail(); + } + + public Map getAttributes() { + return attributes; + } +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/AccountFederatedIdentityBean.java b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/AccountFederatedIdentityBean.java new file mode 100755 index 00000000..c766e5dc --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/AccountFederatedIdentityBean.java @@ -0,0 +1,157 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.forms.account.freemarker.model; + +import java.net.URI; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.keycloak.models.FederatedIdentityModel; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OrderedModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.services.resources.account.AccountFormService; + +/** + * @author Marek Posolda + * @author Vlastimil Elias + */ +public class AccountFederatedIdentityBean { + + private static OrderedModel.OrderedModelComparator + IDP_COMPARATOR_INSTANCE = new OrderedModel.OrderedModelComparator<>(); + + private final List identities; + private final boolean removeLinkPossible; + private final KeycloakSession session; + + public AccountFederatedIdentityBean( + KeycloakSession session, RealmModel realm, UserModel user, URI baseUri, String stateChecker) { + this.session = session; + + AtomicInteger availableIdentities = new AtomicInteger(0); + this.identities = + realm + .getIdentityProvidersStream() + .filter(IdentityProviderModel::isEnabled) + .map( + provider -> { + String providerId = provider.getAlias(); + + FederatedIdentityModel identity = + getIdentity( + session.users().getFederatedIdentitiesStream(realm, user), providerId); + + if (identity != null) { + availableIdentities.getAndIncrement(); + } + + String displayName = + KeycloakModelUtils.getIdentityProviderDisplayName(session, provider); + return new FederatedIdentityEntry( + identity, + displayName, + provider.getAlias(), + provider.getAlias(), + provider.getConfig() != null ? provider.getConfig().get("guiOrder") : null); + }) + .sorted(IDP_COMPARATOR_INSTANCE) + .collect(Collectors.toList()); + + // Removing last social provider is not possible if you don't have other possibility to + // authenticate + this.removeLinkPossible = + availableIdentities.get() > 1 + || user.getFederationLink() != null + || AccountFormService.isPasswordSet(session, realm, user); + } + + private FederatedIdentityModel getIdentity( + Stream identities, String providerId) { + return identities + .filter( + federatedIdentityModel -> + Objects.equals(federatedIdentityModel.getIdentityProvider(), providerId)) + .findFirst() + .orElse(null); + } + + public List getIdentities() { + return identities; + } + + public boolean isRemoveLinkPossible() { + return removeLinkPossible; + } + + public static class FederatedIdentityEntry implements OrderedModel { + + private FederatedIdentityModel federatedIdentityModel; + private final String providerId; + private final String providerName; + private final String guiOrder; + private final String displayName; + + public FederatedIdentityEntry( + FederatedIdentityModel federatedIdentityModel, + String displayName, + String providerId, + String providerName, + String guiOrder) { + this.federatedIdentityModel = federatedIdentityModel; + this.displayName = displayName; + this.providerId = providerId; + this.providerName = providerName; + this.guiOrder = guiOrder; + } + + public String getProviderId() { + return providerId; + } + + public String getProviderName() { + return providerName; + } + + public String getUserId() { + return federatedIdentityModel != null ? federatedIdentityModel.getUserId() : null; + } + + public String getUserName() { + return federatedIdentityModel != null ? federatedIdentityModel.getUserName() : null; + } + + public boolean isConnected() { + return federatedIdentityModel != null; + } + + @Override + public String getGuiOrder() { + return guiOrder; + } + + public String getDisplayName() { + return displayName; + } + } +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/ApplicationsBean.java b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/ApplicationsBean.java new file mode 100755 index 00000000..c34e994e --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/ApplicationsBean.java @@ -0,0 +1,258 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.forms.account.freemarker.model; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientScopeModel; +import org.keycloak.models.Constants; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OrderedModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserConsentModel; +import org.keycloak.models.UserModel; +import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.services.managers.UserSessionManager; +import org.keycloak.services.resources.admin.permissions.AdminPermissions; +import org.keycloak.services.util.ResolveRelative; +import org.keycloak.storage.StorageId; + +/** + * @author Marek Posolda + */ +public class ApplicationsBean { + + private List applications = new LinkedList<>(); + + public ApplicationsBean(KeycloakSession session, RealmModel realm, UserModel user) { + Set offlineClients = + new UserSessionManager(session).findClientsWithOfflineToken(realm, user); + + this.applications = + this.getApplications(session, realm, user) + .filter( + client -> + !isAdminClient(client) + || AdminPermissions.realms(session, realm, user).isAdmin()) + .map(client -> toApplicationEntry(session, realm, user, client, offlineClients)) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + public static boolean isAdminClient(ClientModel client) { + return client.getClientId().equals(Constants.ADMIN_CLI_CLIENT_ID) + || client.getClientId().equals(Constants.ADMIN_CONSOLE_CLIENT_ID); + } + + private Stream getApplications( + KeycloakSession session, RealmModel realm, UserModel user) { + Predicate bearerOnly = ClientModel::isBearerOnly; + Stream clients = realm.getClientsStream().filter(bearerOnly.negate()); + + Predicate isLocal = client -> new StorageId(client.getId()).isLocal(); + return Stream.concat( + clients, + session + .users() + .getConsentsStream(realm, user.getId()) + .map(UserConsentModel::getClient) + .filter(isLocal.negate())) + .distinct(); + } + + private void processRoles( + Set inputRoles, + List realmRoles, + MultivaluedHashMap clientRoles) { + for (RoleModel role : inputRoles) { + if (role.getContainer() instanceof RealmModel) { + realmRoles.add(role); + } else { + ClientModel currentClient = (ClientModel) role.getContainer(); + ClientRoleEntry clientRole = + new ClientRoleEntry( + currentClient.getClientId(), + currentClient.getName(), + role.getName(), + role.getDescription()); + clientRoles.add(currentClient.getClientId(), clientRole); + } + } + } + + public List getApplications() { + return applications; + } + + public static class ApplicationEntry { + + private KeycloakSession session; + private final List realmRolesAvailable; + private final MultivaluedHashMap resourceRolesAvailable; + private final ClientModel client; + private final List clientScopesGranted; + private final List additionalGrants; + + public ApplicationEntry( + KeycloakSession session, + List realmRolesAvailable, + MultivaluedHashMap resourceRolesAvailable, + ClientModel client, + List clientScopesGranted, + List additionalGrants) { + this.session = session; + this.realmRolesAvailable = realmRolesAvailable; + this.resourceRolesAvailable = resourceRolesAvailable; + this.client = client; + this.clientScopesGranted = clientScopesGranted; + this.additionalGrants = additionalGrants; + } + + public List getRealmRolesAvailable() { + return realmRolesAvailable; + } + + public MultivaluedHashMap getResourceRolesAvailable() { + return resourceRolesAvailable; + } + + public List getClientScopesGranted() { + return clientScopesGranted; + } + + public String getEffectiveUrl() { + return ResolveRelative.resolveRelativeUri( + session, getClient().getRootUrl(), getClient().getBaseUrl()); + } + + public ClientModel getClient() { + return client; + } + + public List getAdditionalGrants() { + return additionalGrants; + } + } + + // Same class used in OAuthGrantBean as well. Maybe should be merged into common-freemarker... + public static class ClientRoleEntry { + + private final String clientId; + private final String clientName; + private final String roleName; + private final String roleDescription; + + public ClientRoleEntry( + String clientId, String clientName, String roleName, String roleDescription) { + this.clientId = clientId; + this.clientName = clientName; + this.roleName = roleName; + this.roleDescription = roleDescription; + } + + public String getClientId() { + return clientId; + } + + public String getClientName() { + return clientName; + } + + public String getRoleName() { + return roleName; + } + + public String getRoleDescription() { + return roleDescription; + } + } + + /** + * Constructs a {@link ApplicationEntry} from the specified parameters. + * + * @param session a reference to the {@code Keycloak} session. + * @param realm a reference to the realm. + * @param user a reference to the user. + * @param client a reference to the client that contains the applications. + * @param offlineClients a {@link Set} containing the offline clients. + * @return the constructed {@link ApplicationEntry} instance or {@code null} if the user can't + * access the applications in the specified client. + */ + private ApplicationEntry toApplicationEntry( + final KeycloakSession session, + final RealmModel realm, + final UserModel user, + final ClientModel client, + final Set offlineClients) { + + // Construct scope parameter with all optional scopes to see all potentially available roles + Stream allClientScopes = + Stream.concat( + client.getClientScopes(true).values().stream(), + client.getClientScopes(false).values().stream()); + allClientScopes = Stream.concat(allClientScopes, Stream.of(client)).distinct(); + + Set availableRoles = TokenManager.getAccess(user, client, allClientScopes); + + // Don't show applications, which user doesn't have access into (any available roles) + // unless this is can be changed by approving/revoking consent + if (!isAdminClient(client) && availableRoles.isEmpty() && !client.isConsentRequired()) { + return null; + } + + List realmRolesAvailable = new LinkedList<>(); + MultivaluedHashMap resourceRolesAvailable = new MultivaluedHashMap<>(); + processRoles(availableRoles, realmRolesAvailable, resourceRolesAvailable); + + List orderedScopes = new LinkedList<>(); + if (client.isConsentRequired()) { + UserConsentModel consent = + session.users().getConsentByClient(realm, user.getId(), client.getId()); + + if (consent != null) { + orderedScopes.addAll(consent.getGrantedClientScopes()); + } + } + List clientScopesGranted = + orderedScopes.stream() + .sorted(OrderedModel.OrderedModelComparator.getInstance()) + .map(ClientScopeModel::getConsentScreenText) + .collect(Collectors.toList()); + + List additionalGrants = new ArrayList<>(); + if (offlineClients.contains(client)) { + additionalGrants.add("${offlineToken}"); + } + return new ApplicationEntry( + session, + realmRolesAvailable, + resourceRolesAvailable, + client, + clientScopesGranted, + additionalGrants); + } +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/AuthorizationBean.java b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/AuthorizationBean.java new file mode 100755 index 00000000..2e6d2a97 --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/AuthorizationBean.java @@ -0,0 +1,515 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.forms.account.freemarker.model; + +import jakarta.ws.rs.core.UriInfo; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.keycloak.authorization.AuthorizationProvider; +import org.keycloak.authorization.model.PermissionTicket; +import org.keycloak.authorization.model.Policy; +import org.keycloak.authorization.model.Resource; +import org.keycloak.authorization.model.ResourceServer; +import org.keycloak.authorization.model.Scope; +import org.keycloak.authorization.store.PermissionTicketStore; +import org.keycloak.common.util.Time; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.ModelToRepresentation; +import org.keycloak.representations.idm.authorization.ScopeRepresentation; +import org.keycloak.services.util.ResolveRelative; + +/** + * @author Stian Thorgersen + */ +public class AuthorizationBean { + + private final KeycloakSession session; + private final RealmModel realm; + private final UserModel user; + private final AuthorizationProvider authorization; + private final UriInfo uriInfo; + private ResourceBean resource; + private List resources; + private Collection userSharedResources; + private Collection requestsWaitingPermission; + private Collection resourcesWaitingOthersApproval; + + public AuthorizationBean( + KeycloakSession session, RealmModel realm, UserModel user, UriInfo uriInfo) { + this.session = session; + this.realm = realm; + this.user = user; + this.uriInfo = uriInfo; + authorization = session.getProvider(AuthorizationProvider.class); + List pathParameters = uriInfo.getPathParameters().get("resource_id"); + + if (pathParameters != null && !pathParameters.isEmpty()) { + Resource resource = + authorization + .getStoreFactory() + .getResourceStore() + .findById(realm, null, pathParameters.get(0)); + + if (resource != null && !resource.getOwner().equals(user.getId())) { + throw new RuntimeException( + "User [" + user.getUsername() + "] can not access resource [" + resource.getId() + "]"); + } + } + } + + public Collection getResourcesWaitingOthersApproval() { + if (resourcesWaitingOthersApproval == null) { + Map filters = + new EnumMap<>(PermissionTicket.FilterOption.class); + + filters.put(PermissionTicket.FilterOption.REQUESTER, user.getId()); + filters.put(PermissionTicket.FilterOption.GRANTED, Boolean.FALSE.toString()); + + resourcesWaitingOthersApproval = toResourceRepresentation(findPermissions(filters)); + } + + return resourcesWaitingOthersApproval; + } + + public Collection getResourcesWaitingApproval() { + if (requestsWaitingPermission == null) { + Map filters = + new EnumMap<>(PermissionTicket.FilterOption.class); + + filters.put(PermissionTicket.FilterOption.OWNER, user.getId()); + filters.put(PermissionTicket.FilterOption.GRANTED, Boolean.FALSE.toString()); + + requestsWaitingPermission = toResourceRepresentation(findPermissions(filters)); + } + + return requestsWaitingPermission; + } + + public List getResources() { + if (resources == null) { + resources = + authorization + .getStoreFactory() + .getResourceStore() + .findByOwner(realm, null, user.getId()) + .stream() + .filter(Resource::isOwnerManagedAccess) + .map(ResourceBean::new) + .collect(Collectors.toList()); + } + return resources; + } + + public Collection getSharedResources() { + if (userSharedResources == null) { + Map filters = + new EnumMap<>(PermissionTicket.FilterOption.class); + + filters.put(PermissionTicket.FilterOption.REQUESTER, user.getId()); + filters.put(PermissionTicket.FilterOption.GRANTED, Boolean.TRUE.toString()); + + PermissionTicketStore ticketStore = + authorization.getStoreFactory().getPermissionTicketStore(); + + userSharedResources = + toResourceRepresentation(ticketStore.find(realm, null, filters, null, null)); + } + return userSharedResources; + } + + public ResourceBean getResource() { + if (resource == null) { + String resourceId = uriInfo.getPathParameters().getFirst("resource_id"); + + if (resourceId != null) { + resource = getResource(resourceId); + } + } + + return resource; + } + + private ResourceBean getResource(String id) { + return new ResourceBean( + authorization.getStoreFactory().getResourceStore().findById(realm, null, id)); + } + + public static class RequesterBean { + + private final Long createdTimestamp; + private final Long grantedTimestamp; + private UserModel requester; + private List scopes = new ArrayList<>(); + private boolean granted; + + public RequesterBean(PermissionTicket ticket, AuthorizationProvider authorization) { + this.requester = + authorization + .getKeycloakSession() + .users() + .getUserById(authorization.getRealm(), ticket.getRequester()); + granted = ticket.isGranted(); + createdTimestamp = ticket.getCreatedTimestamp(); + grantedTimestamp = ticket.getGrantedTimestamp(); + } + + public UserModel getRequester() { + return requester; + } + + public List getScopes() { + return scopes; + } + + private void addScope(PermissionTicket ticket) { + if (ticket != null) { + scopes.add(new PermissionScopeBean(ticket)); + } + } + + public boolean isGranted() { + return (granted && scopes.isEmpty()) + || scopes.stream().filter(permissionScopeBean -> permissionScopeBean.isGranted()).count() + > 0; + } + + public Date getCreatedDate() { + return Time.toDate(createdTimestamp); + } + + public Date getGrantedDate() { + if (grantedTimestamp == null) { + PermissionScopeBean permission = + scopes.stream() + .filter(permissionScopeBean -> permissionScopeBean.isGranted()) + .findFirst() + .orElse(null); + + if (permission == null) { + return null; + } + + return permission.getGrantedDate(); + } + return Time.toDate(grantedTimestamp); + } + } + + public static class PermissionScopeBean { + + private final Scope scope; + private final PermissionTicket ticket; + + public PermissionScopeBean(PermissionTicket ticket) { + this.ticket = ticket; + scope = ticket.getScope(); + } + + public String getId() { + return ticket.getId(); + } + + public Scope getScope() { + return scope; + } + + public boolean isGranted() { + return ticket.isGranted(); + } + + private Date getGrantedDate() { + if (isGranted()) { + return Time.toDate(ticket.getGrantedTimestamp()); + } + return null; + } + } + + public class ResourceBean { + + private final ResourceServerBean resourceServer; + private final String ownerName; + private final UserModel userOwner; + private ClientModel clientOwner; + private Resource resource; + private Map permissions = new HashMap<>(); + private Collection shares; + + public ResourceBean(Resource resource) { + RealmModel realm = authorization.getRealm(); + ResourceServer resourceServerModel = resource.getResourceServer(); + resourceServer = + new ResourceServerBean( + realm.getClientById(resourceServerModel.getClientId()), resourceServerModel); + this.resource = resource; + userOwner = + authorization.getKeycloakSession().users().getUserById(realm, resource.getOwner()); + if (userOwner == null) { + clientOwner = realm.getClientById(resource.getOwner()); + ownerName = clientOwner.getClientId(); + } else if (userOwner.getEmail() != null) { + ownerName = userOwner.getEmail(); + } else { + ownerName = userOwner.getUsername(); + } + } + + public String getId() { + return resource.getId(); + } + + public String getName() { + return resource.getName(); + } + + public String getDisplayName() { + return resource.getDisplayName(); + } + + public String getIconUri() { + return resource.getIconUri(); + } + + public String getOwnerName() { + return ownerName; + } + + public UserModel getUserOwner() { + return userOwner; + } + + public ClientModel getClientOwner() { + return clientOwner; + } + + public List getScopes() { + return resource.getScopes().stream() + .map(ModelToRepresentation::toRepresentation) + .collect(Collectors.toList()); + } + + public Collection getShares() { + if (shares == null) { + Map filters = + new EnumMap<>(PermissionTicket.FilterOption.class); + + filters.put(PermissionTicket.FilterOption.RESOURCE_ID, this.resource.getId()); + filters.put(PermissionTicket.FilterOption.GRANTED, Boolean.TRUE.toString()); + + shares = toPermissionRepresentation(findPermissions(filters)); + } + + return shares; + } + + public Collection getPolicies() { + ResourceServer resourceServer = getResourceServer().getResourceServerModel(); + RealmModel realm = resourceServer.getRealm(); + Map filters = new EnumMap<>(Policy.FilterOption.class); + + filters.put(Policy.FilterOption.TYPE, new String[] {"uma"}); + filters.put(Policy.FilterOption.RESOURCE_ID, new String[] {this.resource.getId()}); + if (getUserOwner() != null) { + filters.put(Policy.FilterOption.OWNER, new String[] {getUserOwner().getId()}); + } else { + filters.put(Policy.FilterOption.OWNER, new String[] {getClientOwner().getId()}); + } + + List policies = + authorization + .getStoreFactory() + .getPolicyStore() + .find(realm, resourceServer, filters, null, null); + + if (policies.isEmpty()) { + return Collections.emptyList(); + } + + return policies.stream() + .filter( + policy -> { + Map filters1 = + new EnumMap<>(PermissionTicket.FilterOption.class); + + filters1.put(PermissionTicket.FilterOption.POLICY_ID, policy.getId()); + + return authorization + .getStoreFactory() + .getPermissionTicketStore() + .find(realm, resourceServer, filters1, -1, 1) + .isEmpty(); + }) + .map(ManagedPermissionBean::new) + .collect(Collectors.toList()); + } + + public ResourceServerBean getResourceServer() { + return resourceServer; + } + + public Collection getPermissions() { + return permissions.values(); + } + + private void addPermission(PermissionTicket ticket, AuthorizationProvider authorization) { + permissions + .computeIfAbsent(ticket.getRequester(), key -> new RequesterBean(ticket, authorization)) + .addScope(ticket); + } + } + + private Collection toPermissionRepresentation( + List permissionRequests) { + Map requests = new HashMap<>(); + + for (PermissionTicket ticket : permissionRequests) { + Resource resource = ticket.getResource(); + + if (!resource.isOwnerManagedAccess()) { + continue; + } + + requests + .computeIfAbsent( + ticket.getRequester(), resourceId -> new RequesterBean(ticket, authorization)) + .addScope(ticket); + } + + return requests.values(); + } + + private Collection toResourceRepresentation(List tickets) { + Map requests = new HashMap<>(); + + for (PermissionTicket ticket : tickets) { + Resource resource = ticket.getResource(); + + if (!resource.isOwnerManagedAccess()) { + continue; + } + + requests + .computeIfAbsent(resource.getId(), resourceId -> getResource(resourceId)) + .addPermission(ticket, authorization); + } + + return requests.values(); + } + + private List findPermissions( + Map filters) { + return authorization + .getStoreFactory() + .getPermissionTicketStore() + .find(realm, null, filters, null, null); + } + + public class ResourceServerBean { + + private ClientModel clientModel; + private ResourceServer resourceServer; + + public ResourceServerBean(ClientModel clientModel, ResourceServer resourceServer) { + this.clientModel = clientModel; + this.resourceServer = resourceServer; + } + + public String getId() { + return resourceServer.getId(); + } + + public String getName() { + String name = clientModel.getName(); + + if (name != null) { + return name; + } + + return clientModel.getClientId(); + } + + public String getClientId() { + return clientModel.getClientId(); + } + + public String getRedirectUri() { + Set redirectUris = clientModel.getRedirectUris(); + + if (redirectUris.isEmpty()) { + return null; + } + + return redirectUris.iterator().next(); + } + + public String getBaseUri() { + return ResolveRelative.resolveRelativeUri( + session, clientModel.getRootUrl(), clientModel.getBaseUrl()); + } + + public ResourceServer getResourceServerModel() { + return resourceServer; + } + } + + public class ManagedPermissionBean { + + private final Policy policy; + private List policies; + + public ManagedPermissionBean(Policy policy) { + this.policy = policy; + } + + public String getId() { + return policy.getId(); + } + + public Collection getScopes() { + return policy.getScopes().stream() + .map(ModelToRepresentation::toRepresentation) + .collect(Collectors.toList()); + } + + public String getDescription() { + return this.policy.getDescription(); + } + + public Collection getPolicies() { + if (this.policies == null) { + this.policies = + policy.getAssociatedPolicies().stream() + .map(ManagedPermissionBean::new) + .collect(Collectors.toList()); + } + + return this.policies; + } + } +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/FeaturesBean.java b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/FeaturesBean.java new file mode 100755 index 00000000..9bf3513c --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/FeaturesBean.java @@ -0,0 +1,56 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.forms.account.freemarker.model; + +/** + * @author Stian Thorgersen + */ +public class FeaturesBean { + + private final boolean identityFederation; + private final boolean log; + private final boolean passwordUpdateSupported; + private boolean authorization; + + public FeaturesBean( + boolean identityFederation, + boolean log, + boolean passwordUpdateSupported, + boolean authorization) { + this.identityFederation = identityFederation; + this.log = log; + this.passwordUpdateSupported = passwordUpdateSupported; + this.authorization = authorization; + } + + public boolean isIdentityFederation() { + return identityFederation; + } + + public boolean isLog() { + return log; + } + + public boolean isPasswordUpdateSupported() { + return passwordUpdateSupported; + } + + public boolean isAuthorization() { + return authorization; + } +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/LogBean.java b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/LogBean.java new file mode 100755 index 00000000..80d19fd4 --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/LogBean.java @@ -0,0 +1,95 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.forms.account.freemarker.model; + +import java.util.Date; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import org.keycloak.events.Event; + +/** + * @author Stian Thorgersen + */ +public class LogBean { + + private List events; + + public LogBean(List events) { + this.events = new LinkedList(); + for (Event e : events) { + this.events.add(new EventBean(e)); + } + } + + public List getEvents() { + return events; + } + + public static class EventBean { + + private Event event; + + public EventBean(Event event) { + this.event = event; + } + + public Date getDate() { + return new Date(event.getTime()); + } + + public String getEvent() { + return event.getType().toString().toLowerCase().replace("_", " "); + } + + public String getClient() { + return event.getClientId(); + } + + public String getIpAddress() { + return event.getIpAddress(); + } + + public List getDetails() { + List details = new LinkedList(); + if (event.getDetails() != null) { + for (Map.Entry e : event.getDetails().entrySet()) { + details.add(new DetailBean(e)); + } + } + return details; + } + } + + public static class DetailBean { + + private Map.Entry entry; + + public DetailBean(Map.Entry entry) { + this.entry = entry; + } + + public String getKey() { + return entry.getKey(); + } + + public String getValue() { + return entry.getValue().replace("_", " "); + } + } +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/PasswordBean.java b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/PasswordBean.java new file mode 100755 index 00000000..865229da --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/PasswordBean.java @@ -0,0 +1,34 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.forms.account.freemarker.model; + +/** + * @author Stian Thorgersen + */ +public class PasswordBean { + + private boolean passwordSet; + + public PasswordBean(boolean passwordSet) { + this.passwordSet = passwordSet; + } + + public boolean isPasswordSet() { + return passwordSet; + } +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/RealmBean.java b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/RealmBean.java new file mode 100755 index 00000000..b65ca612 --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/RealmBean.java @@ -0,0 +1,75 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.forms.account.freemarker.model; + +import java.util.Set; +import java.util.stream.Collectors; +import org.keycloak.models.RealmModel; + +/** + * @author Michael Gerber + */ +public class RealmBean { + + private RealmModel realm; + + public RealmBean(RealmModel realmModel) { + realm = realmModel; + } + + public String getName() { + return realm.getName(); + } + + public String getDisplayName() { + String displayName = realm.getDisplayName(); + if (displayName != null && displayName.length() > 0) { + return displayName; + } else { + return getName(); + } + } + + public String getDisplayNameHtml() { + String displayNameHtml = realm.getDisplayNameHtml(); + if (displayNameHtml != null && displayNameHtml.length() > 0) { + return displayNameHtml; + } else { + return getDisplayName(); + } + } + + public boolean isInternationalizationEnabled() { + return realm.isInternationalizationEnabled(); + } + + public Set getSupportedLocales() { + return realm.getSupportedLocalesStream().collect(Collectors.toSet()); + } + + public boolean isEditUsernameAllowed() { + return realm.isEditUsernameAllowed(); + } + + public boolean isRegistrationEmailAsUsername() { + return realm.isRegistrationEmailAsUsername(); + } + + public boolean isUserManagedAccessAllowed() { + return realm.isUserManagedAccessAllowed(); + } +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/ReferrerBean.java b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/ReferrerBean.java new file mode 100755 index 00000000..07a884ff --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/ReferrerBean.java @@ -0,0 +1,38 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.forms.account.freemarker.model; + +/** + * @author Stian Thorgersen + */ +public class ReferrerBean { + + private String[] referrer; + + public ReferrerBean(String[] referrer) { + this.referrer = referrer; + } + + public String getName() { + return referrer[0]; + } + + public String getUrl() { + return referrer[1]; + } +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/SessionsBean.java b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/SessionsBean.java new file mode 100755 index 00000000..1fa30c6d --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/SessionsBean.java @@ -0,0 +1,93 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.forms.account.freemarker.model; + +import java.util.Date; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; +import org.keycloak.common.util.Time; +import org.keycloak.models.ClientModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserSessionModel; + +/** + * @author Stian Thorgersen + */ +public class SessionsBean { + + private List events; + private RealmModel realm; + + public SessionsBean(RealmModel realm, List sessions) { + this.events = new LinkedList<>(); + for (UserSessionModel session : sessions) { + this.events.add(new UserSessionBean(realm, session)); + } + } + + public List getSessions() { + return events; + } + + public static class UserSessionBean { + + private UserSessionModel session; + private RealmModel realm; + + public UserSessionBean(RealmModel realm, UserSessionModel session) { + this.realm = realm; + this.session = session; + } + + public String getId() { + return session.getId(); + } + + public String getIpAddress() { + return session.getIpAddress(); + } + + public Date getStarted() { + return Time.toDate(session.getStarted()); + } + + public Date getLastAccess() { + return Time.toDate(session.getLastSessionRefresh()); + } + + public Date getExpires() { + int maxLifespan = + session.isRememberMe() && realm.getSsoSessionMaxLifespanRememberMe() > 0 + ? realm.getSsoSessionMaxLifespanRememberMe() + : realm.getSsoSessionMaxLifespan(); + int max = session.getStarted() + maxLifespan; + return Time.toDate(max); + } + + public Set getClients() { + Set clients = new HashSet<>(); + for (String clientUUID : session.getAuthenticatedClientSessions().keySet()) { + ClientModel client = realm.getClientById(clientUUID); + clients.add(client.getClientId()); + } + return clients; + } + } +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/TotpBean.java b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/TotpBean.java new file mode 100644 index 00000000..b631666f --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/TotpBean.java @@ -0,0 +1,125 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.forms.account.freemarker.model; + +import static org.keycloak.utils.CredentialHelper.createUserStorageCredentialRepresentation; + +import jakarta.ws.rs.core.UriBuilder; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import org.keycloak.authentication.otp.OTPApplicationProvider; +import org.keycloak.credential.CredentialModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OTPPolicy; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.credential.OTPCredentialModel; +import org.keycloak.models.utils.HmacOTP; +import org.keycloak.models.utils.RepresentationToModel; +import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.utils.TotpUtils; + +/** + * @author Stian Thorgersen + */ +public class TotpBean { + + private final RealmModel realm; + private final String totpSecret; + private final String totpSecretEncoded; + private final String totpSecretQrCode; + private final boolean enabled; + private KeycloakSession session; + private final UriBuilder uriBuilder; + private final List otpCredentials; + private final List supportedApplications; + + public TotpBean( + KeycloakSession session, RealmModel realm, UserModel user, UriBuilder uriBuilder) { + this.session = session; + this.uriBuilder = uriBuilder; + this.enabled = user.credentialManager().isConfiguredFor(OTPCredentialModel.TYPE); + if (enabled) { + List otpCredentials = + user.credentialManager() + .getStoredCredentialsByTypeStream(OTPCredentialModel.TYPE) + .collect(Collectors.toList()); + + if (otpCredentials.isEmpty()) { + // Credential is configured on userStorage side. Create the "fake" credential similar like + // we do for the new account console + CredentialRepresentation credential = + createUserStorageCredentialRepresentation(OTPCredentialModel.TYPE); + this.otpCredentials = Collections.singletonList(RepresentationToModel.toModel(credential)); + } else { + this.otpCredentials = otpCredentials; + } + } else { + this.otpCredentials = Collections.EMPTY_LIST; + } + + this.realm = realm; + this.totpSecret = HmacOTP.generateSecret(20); + this.totpSecretEncoded = TotpUtils.encode(totpSecret); + this.totpSecretQrCode = TotpUtils.qrCode(totpSecret, realm, user); + + OTPPolicy otpPolicy = realm.getOTPPolicy(); + this.supportedApplications = + session.getAllProviders(OTPApplicationProvider.class).stream() + .filter(p -> p.supports(otpPolicy)) + .map(OTPApplicationProvider::getName) + .collect(Collectors.toList()); + } + + public boolean isEnabled() { + return enabled; + } + + public String getTotpSecret() { + return totpSecret; + } + + public String getTotpSecretEncoded() { + return totpSecretEncoded; + } + + public String getTotpSecretQrCode() { + return totpSecretQrCode; + } + + public String getManualUrl() { + return uriBuilder.replaceQueryParam("mode", "manual").build().toString(); + } + + public String getQrUrl() { + return uriBuilder.replaceQueryParam("mode", "qr").build().toString(); + } + + public OTPPolicy getPolicy() { + return realm.getOTPPolicy(); + } + + public List getSupportedApplications() { + return supportedApplications; + } + + public List getOtpCredentials() { + return otpCredentials; + } +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/UrlBean.java b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/UrlBean.java new file mode 100755 index 00000000..6e1ee263 --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/forms/account/freemarker/model/UrlBean.java @@ -0,0 +1,121 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.forms.account.freemarker.model; + +import java.io.IOException; +import java.net.URI; +import org.jboss.logging.Logger; +import org.keycloak.models.RealmModel; +import org.keycloak.services.AccountUrls; +import org.keycloak.theme.Theme; + +/** + * @author Stian Thorgersen + */ +public class UrlBean { + + private static final Logger logger = Logger.getLogger(UrlBean.class); + private String realm; + private Theme theme; + private URI baseURI; + private URI baseQueryURI; + private URI currentURI; + private String idTokenHint; + + public UrlBean( + RealmModel realm, + Theme theme, + URI baseURI, + URI baseQueryURI, + URI currentURI, + String idTokenHint) { + this.realm = realm.getName(); + this.theme = theme; + this.baseURI = baseURI; + this.baseQueryURI = baseQueryURI; + this.currentURI = currentURI; + this.idTokenHint = idTokenHint; + } + + public String getApplicationsUrl() { + return AccountUrls.accountApplicationsPage(baseQueryURI, realm).toString(); + } + + public String getAccountUrl() { + return AccountUrls.accountPage(baseQueryURI, realm).toString(); + } + + public String getPasswordUrl() { + return AccountUrls.accountPasswordPage(baseQueryURI, realm).toString(); + } + + public String getSocialUrl() { + return AccountUrls.accountFederatedIdentityPage(baseQueryURI, realm).toString(); + } + + public String getTotpUrl() { + return AccountUrls.accountTotpPage(baseQueryURI, realm).toString(); + } + + public String getLogUrl() { + return AccountUrls.accountLogPage(baseQueryURI, realm).toString(); + } + + public String getSessionsUrl() { + return AccountUrls.accountSessionsPage(baseQueryURI, realm).toString(); + } + + public String getLogoutUrl() { + return AccountUrls.accountLogout(baseQueryURI, currentURI, realm, idTokenHint).toString(); + } + + public String getResourceUrl() { + return AccountUrls.accountResourcesPage(baseQueryURI, realm).toString(); + } + + public String getResourceDetailUrl(String id) { + return AccountUrls.accountResourceDetailPage(id, baseQueryURI, realm).toString(); + } + + public String getResourceGrant(String id) { + return AccountUrls.accountResourceGrant(id, baseQueryURI, realm).toString(); + } + + public String getResourceShare(String id) { + return AccountUrls.accountResourceShare(id, baseQueryURI, realm).toString(); + } + + public String getResourcesPath() { + URI uri = AccountUrls.themeRoot(baseURI); + return uri.getPath() + "/" + theme.getType().toString().toLowerCase() + "/" + theme.getName(); + } + + public String getResourcesCommonPath() { + URI uri = AccountUrls.themeRoot(baseURI); + String commonPath = ""; + try { + commonPath = theme.getProperties().getProperty("import"); + } catch (IOException ex) { + logger.warn("Failed to load properties", ex); + } + if (commonPath == null || commonPath.isEmpty()) { + commonPath = "/common/keycloak"; + } + return uri.getPath() + "/" + commonPath; + } +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/services/AccountUrls.java b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/services/AccountUrls.java new file mode 100644 index 00000000..4f5ad1a9 --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/services/AccountUrls.java @@ -0,0 +1,115 @@ +package org.keycloak.services; + +import jakarta.ws.rs.core.UriBuilder; +import java.net.URI; +import lombok.extern.jbosslog.JBossLog; +import org.keycloak.OAuth2Constants; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.OIDCLoginProtocolService; +import org.keycloak.services.resources.LoginActionsService; +import org.keycloak.services.resources.RealmsResource; +import org.keycloak.services.resources.account.AccountFormService; + +@JBossLog +public class AccountUrls extends Urls { + + private static UriBuilder realmLogout(URI baseUri) { + return tokenBase(baseUri).path(OIDCLoginProtocolService.class, "logout"); + } + + public static UriBuilder accountBase(URI baseUri) { + return realmBase(baseUri).path(RealmsResource.class, "getAccountService"); + } + + private static UriBuilder tokenBase(URI baseUri) { + return realmBase(baseUri).path("{realm}/protocol/" + OIDCLoginProtocol.LOGIN_PROTOCOL); + } + + public static URI accountApplicationsPage(URI baseUri, String realmName) { + return accountBase(baseUri).path(AccountFormService.class, "applicationsPage").build(realmName); + } + + public static URI accountPage(URI baseUri, String realmName) { + return accountPageBuilder(baseUri).build(realmName); + } + + public static UriBuilder accountPageBuilder(URI baseUri) { + return accountBase(baseUri).path(AccountFormService.class, "accountPage"); + } + + public static URI accountPasswordPage(URI baseUri, String realmName) { + return accountBase(baseUri).path(AccountFormService.class, "passwordPage").build(realmName); + } + + public static URI accountFederatedIdentityPage(URI baseUri, String realmName) { + return accountBase(baseUri) + .path(AccountFormService.class, "federatedIdentityPage") + .build(realmName); + } + + public static URI accountFederatedIdentityUpdate(URI baseUri, String realmName) { + return accountBase(baseUri) + .path(AccountFormService.class, "processFederatedIdentityUpdate") + .build(realmName); + } + + public static URI accountTotpPage(URI baseUri, String realmName) { + return accountBase(baseUri).path(AccountFormService.class, "totpPage").build(realmName); + } + + public static URI accountLogPage(URI baseUri, String realmName) { + return accountBase(baseUri).path(AccountFormService.class, "logPage").build(realmName); + } + + public static URI accountSessionsPage(URI baseUri, String realmName) { + return accountBase(baseUri).path(AccountFormService.class, "sessionsPage").build(realmName); + } + + public static URI accountLogout( + URI baseUri, URI redirectUri, String realmName, String idTokenHint) { + return realmLogout(baseUri) + .queryParam(OAuth2Constants.POST_LOGOUT_REDIRECT_URI, redirectUri) + .queryParam(OAuth2Constants.ID_TOKEN_HINT, idTokenHint) + .build(realmName); + } + + public static URI accountResourcesPage(URI baseUri, String realmName) { + return accountBase(baseUri).path(AccountFormService.class, "resourcesPage").build(realmName); + } + + public static URI accountResourceDetailPage(String resourceId, URI baseUri, String realmName) { + return accountBase(baseUri) + .path(AccountFormService.class, "resourceDetailPage") + .build(realmName, resourceId); + } + + public static URI accountResourceGrant(String resourceId, URI baseUri, String realmName) { + return accountBase(baseUri) + .path(AccountFormService.class, "grantPermission") + .build(realmName, resourceId); + } + + public static URI accountResourceShare(String resourceId, URI baseUri, String realmName) { + return accountBase(baseUri) + .path(AccountFormService.class, "shareResource") + .build(realmName, resourceId); + } + + public static URI loginActionUpdatePassword(URI baseUri, String realmName) { + return loginActionsBase(baseUri) + .path(LoginActionsService.class, "updatePassword") + .build(realmName); + } + + public static URI loginActionUpdateTotp(URI baseUri, String realmName) { + return loginActionsBase(baseUri).path(LoginActionsService.class, "updateTotp").build(realmName); + } + + public static URI loginActionEmailVerification(URI baseUri, String realmName) { + return loginActionEmailVerificationBuilder(baseUri).build(realmName); + } + + public static String localeCookiePath(URI baseUri, String realmName) { + return realmBase(baseUri).path(realmName).build().getRawPath(); + } +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/services/resources/account/AccountFormService.java b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/services/resources/account/AccountFormService.java new file mode 100644 index 00000000..3f908bf2 --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/services/resources/account/AccountFormService.java @@ -0,0 +1,1320 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.services.resources.account; + +import com.google.common.base.Strings; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.FormParam; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.core.UriBuilder; +import jakarta.ws.rs.core.UriInfo; +import java.io.IOException; +import java.lang.reflect.Method; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import lombok.extern.jbosslog.JBossLog; +import org.jboss.logging.Logger; +import org.keycloak.authorization.AuthorizationProvider; +import org.keycloak.authorization.model.PermissionTicket; +import org.keycloak.authorization.model.Policy; +import org.keycloak.authorization.model.Resource; +import org.keycloak.authorization.model.ResourceServer; +import org.keycloak.authorization.model.Scope; +import org.keycloak.authorization.store.PermissionTicketStore; +import org.keycloak.authorization.store.PolicyStore; +import org.keycloak.authorization.store.ScopeStore; +import org.keycloak.common.Profile; +import org.keycloak.common.util.Base64Url; +import org.keycloak.common.util.Time; +import org.keycloak.common.util.UriUtils; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.Event; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventStoreProvider; +import org.keycloak.events.EventType; +import org.keycloak.forms.account.AccountPages; +import org.keycloak.forms.account.AccountProvider; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.locale.LocaleSelectorProvider; +import org.keycloak.locale.LocaleUpdaterProvider; +import org.keycloak.models.AccountRoles; +import org.keycloak.models.AuthenticatedClientSessionModel; +import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientSessionContext; +import org.keycloak.models.FederatedIdentityModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ModelException; +import org.keycloak.models.OTPPolicy; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.models.credential.OTPCredentialModel; +import org.keycloak.models.credential.PasswordCredentialModel; +import org.keycloak.models.utils.CredentialValidation; +import org.keycloak.models.utils.FormMessage; +import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.protocol.oidc.utils.RedirectUtils; +import org.keycloak.representations.IDToken; +import org.keycloak.services.AccountUrls; +import org.keycloak.services.ErrorResponse; +import org.keycloak.services.ForbiddenException; +import org.keycloak.services.ServicesLogger; +import org.keycloak.services.managers.AppAuthManager; +import org.keycloak.services.managers.Auth; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.services.managers.AuthenticationSessionManager; +import org.keycloak.services.managers.UserConsentManager; +import org.keycloak.services.messages.Messages; +import org.keycloak.services.resource.AccountResourceProvider; +import org.keycloak.services.resources.AbstractSecuredLocalService; +import org.keycloak.services.resources.RealmsResource; +import org.keycloak.services.util.DefaultClientSessionContext; +import org.keycloak.services.util.ResolveRelative; +import org.keycloak.services.validation.Validation; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.storage.ReadOnlyException; +import org.keycloak.theme.Theme; +import org.keycloak.userprofile.EventAuditingAttributeChangeListener; +import org.keycloak.userprofile.UserProfile; +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.UserProfileProvider; +import org.keycloak.userprofile.ValidationException; +import org.keycloak.util.JsonSerialization; +import org.keycloak.utils.CredentialHelper; + +/** + * @author Stian Thorgersen + */ +@JBossLog +public class AccountFormService extends AbstractSecuredLocalService + implements AccountResourceProvider { + + public static final String THEME_NAME = "account-v1"; + + @Override + public boolean useWithTheme(Theme theme) { + log.infof("Attempt to use with theme %s", theme.getName()); + return ((!Strings.isNullOrEmpty(theme.getName()) && THEME_NAME.equals(theme.getName())) + || (!Strings.isNullOrEmpty(theme.getParentName()) + && THEME_NAME.equals(theme.getParentName()))); + } + + @Override + public Object getResource() { + return this; + } + + @Override + public void close() {} + + private static final Logger logger = Logger.getLogger(AccountFormService.class); + + private static Set VALID_PATHS = new HashSet<>(); + + static { + for (Method m : AccountFormService.class.getMethods()) { + Path p = m.getAnnotation(Path.class); + if (p != null) { + VALID_PATHS.add(p.value()); + } + } + } + + // Used when some other context (ie. IdentityBrokerService) wants to forward error to account + // management and display it here + public static final String ACCOUNT_MGMT_FORWARDED_ERROR_NOTE = "ACCOUNT_MGMT_FORWARDED_ERROR"; + + private final AppAuthManager authManager; + private final EventBuilder event; + private AccountProvider account; + private EventStoreProvider eventStore; + + public AccountFormService(KeycloakSession session, ClientModel client, EventBuilder event) { + super(session, client); + this.event = event; + this.authManager = new AppAuthManager(); + init(); + } + + public void init() { + log.info("init"); + session.getContext().setClient(client); + eventStore = session.getProvider(EventStoreProvider.class); + + account = + session + .getProvider(AccountProvider.class) + .setRealm(realm) + .setUriInfo(session.getContext().getUri()) + .setHttpHeaders(headers); + + AuthenticationManager.AuthResult authResult = + authManager.authenticateIdentityCookie(session, realm); + if (authResult != null) { + stateChecker = (String) session.getAttribute("state_checker"); + auth = + new Auth( + realm, + authResult.getToken(), + authResult.getUser(), + client, + authResult.getSession(), + true); + account.setStateChecker(stateChecker); + } + + String requestOrigin = UriUtils.getOrigin(session.getContext().getUri().getBaseUri()); + + String origin = headers.getRequestHeaders().getFirst("Origin"); + if (origin != null && !origin.equals("null") && !requestOrigin.equals(origin)) { + throw new ForbiddenException(); + } + + if (!request.getHttpMethod().equals("GET")) { + String referrer = headers.getRequestHeaders().getFirst("Referer"); + if (referrer != null && !requestOrigin.equals(UriUtils.getOrigin(referrer))) { + throw new ForbiddenException(); + } + } + + if (authResult != null) { + UserSessionModel userSession = authResult.getSession(); + if (userSession != null) { + AuthenticatedClientSessionModel clientSession = + userSession.getAuthenticatedClientSessionByClient(client.getId()); + if (clientSession == null) { + clientSession = + session.sessions().createClientSession(userSession.getRealm(), client, userSession); + } + auth.setClientSession(clientSession); + } + + account.setUser(auth.getUser()); + + ClientSessionContext clientSessionCtx = + DefaultClientSessionContext.fromClientSessionScopeParameter( + auth.getClientSession(), session); + IDToken idToken = + new TokenManager() + .responseBuilder(realm, client, event, session, userSession, clientSessionCtx) + .accessToken(authResult.getToken()) + .generateIDToken() + .getIdToken(); + idToken.issuedFor(client.getClientId()); + account.setIdTokenHint(session.tokens().encodeAndEncrypt(idToken)); + } + + account.setFeatures( + realm.isIdentityFederationEnabled(), + eventStore != null && realm.isEventsEnabled(), + true, + Profile.isFeatureEnabled(Profile.Feature.AUTHORIZATION)); + } + + public static UriBuilder accountServiceBaseUrl(UriInfo uriInfo) { + UriBuilder base = + uriInfo + .getBaseUriBuilder() + .path(RealmsResource.class) + .path(RealmsResource.class, "getAccountService"); + return base; + } + + public static UriBuilder accountServiceApplicationPage(UriInfo uriInfo) { + return accountServiceBaseUrl(uriInfo).path(AccountFormService.class, "applicationsPage"); + } + + protected Set getValidPaths() { + return AccountFormService.VALID_PATHS; + } + + private Response forwardToPage(String path, AccountPages page) { + if (auth != null) { + try { + auth.require(AccountRoles.MANAGE_ACCOUNT); + } catch (ForbiddenException e) { + return session + .getProvider(LoginFormsProvider.class) + .setError(Messages.NO_ACCESS) + .createErrorPage(Response.Status.FORBIDDEN); + } + + setReferrerOnPage(); + + UserSessionModel userSession = auth.getSession(); + + String tabId = + session + .getContext() + .getUri() + .getQueryParameters() + .getFirst(org.keycloak.models.Constants.TAB_ID); + if (tabId != null) { + AuthenticationSessionModel authSession = + new AuthenticationSessionManager(session) + .getAuthenticationSessionByIdAndClient(realm, userSession.getId(), client, tabId); + if (authSession != null) { + String forwardedError = authSession.getAuthNote(ACCOUNT_MGMT_FORWARDED_ERROR_NOTE); + if (forwardedError != null) { + try { + FormMessage errorMessage = + JsonSerialization.readValue(forwardedError, FormMessage.class); + account.setError( + Response.Status.INTERNAL_SERVER_ERROR, + errorMessage.getMessage(), + errorMessage.getParameters()); + authSession.removeAuthNote(ACCOUNT_MGMT_FORWARDED_ERROR_NOTE); + } catch (IOException ioe) { + throw new RuntimeException(ioe); + } + } + } + } + + String locale = + session + .getContext() + .getUri() + .getQueryParameters() + .getFirst(LocaleSelectorProvider.KC_LOCALE_PARAM); + if (locale != null) { + LocaleUpdaterProvider updater = session.getProvider(LocaleUpdaterProvider.class); + updater.updateUsersLocale(auth.getUser(), locale); + } + + return account.createResponse(page); + } else { + return login(path); + } + } + + private void setReferrerOnPage() { + String[] referrer = getReferrer(); + if (referrer != null) { + account.setReferrer(referrer); + } + } + + /** + * Get account information. + * + * @return + */ + @Path("/") + @GET + @Produces(MediaType.TEXT_HTML) + public Response accountPage() { + log.info("accountPage"); + return forwardToPage(null, AccountPages.ACCOUNT); + } + + public static UriBuilder totpUrl(UriBuilder base) { + return RealmsResource.accountUrl(base).path(AccountFormService.class, "totpPage"); + } + + @Path("totp") + @GET + public Response totpPage() { + account.setAttribute( + "mode", session.getContext().getUri().getQueryParameters().getFirst("mode")); + return forwardToPage("totp", AccountPages.TOTP); + } + + public static UriBuilder passwordUrl(UriBuilder base) { + return RealmsResource.accountUrl(base).path(AccountFormService.class, "passwordPage"); + } + + @Path("password") + @GET + public Response passwordPage() { + if (auth != null) { + account.setPasswordSet(isPasswordSet(session, realm, auth.getUser())); + } + + return forwardToPage("password", AccountPages.PASSWORD); + } + + @Path("identity") + @GET + public Response federatedIdentityPage() { + return forwardToPage("identity", AccountPages.FEDERATED_IDENTITY); + } + + @Path("log") + @GET + public Response logPage() { + if (!realm.isEventsEnabled()) { + throw new NotFoundException(); + } + + if (auth != null) { + List events = + eventStore + .createQuery() + .type(Constants.EXPOSED_LOG_EVENTS) + .realm(auth.getRealm().getId()) + .user(auth.getUser().getId()) + .maxResults(30) + .getResultStream() + .peek( + e -> { + if (e.getDetails() != null) { + Iterator> itr = + e.getDetails().entrySet().iterator(); + while (itr.hasNext()) { + if (!Constants.EXPOSED_LOG_DETAILS.contains(itr.next().getKey())) { + itr.remove(); + } + } + } + }) + .collect(Collectors.toList()); + account.setEvents(events); + } + return forwardToPage("log", AccountPages.LOG); + } + + @Path("sessions") + @GET + public Response sessionsPage() { + if (auth != null) { + account.setSessions( + session + .sessions() + .getUserSessionsStream(realm, auth.getUser()) + .collect(Collectors.toList())); + } + return forwardToPage("sessions", AccountPages.SESSIONS); + } + + @Path("applications") + @GET + public Response applicationsPage() { + return forwardToPage("applications", AccountPages.APPLICATIONS); + } + + /** + * Update account information. + * + *

Form params: + * + *

firstName lastName email + * + * @return + */ + @Path("/") + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response processAccountUpdate() { + MultivaluedMap formData = request.getDecodedFormParameters(); + + if (auth == null) { + return login(null); + } + + auth.require(AccountRoles.MANAGE_ACCOUNT); + + String action = formData.getFirst("submitAction"); + if (action != null && action.equals("Cancel")) { + setReferrerOnPage(); + return account.createResponse(AccountPages.ACCOUNT); + } + + csrfCheck(formData); + + UserModel user = auth.getUser(); + + event + .event(EventType.UPDATE_PROFILE) + .client(auth.getClient()) + .user(auth.getUser()) + .detail(Details.CONTEXT, UserProfileContext.ACCOUNT_OLD.name()); + + UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class); + UserProfile profile = profileProvider.create(UserProfileContext.ACCOUNT_OLD, formData, user); + + try { + // backward compatibility with old account console where attributes are not removed if missing + profile.update(false, new EventAuditingAttributeChangeListener(profile, event)); + } catch (ValidationException pve) { + List errors = Validation.getFormErrorsFromValidation(pve.getErrors()); + + if (!errors.isEmpty()) { + setReferrerOnPage(); + Response.Status status = Status.OK; + + if (pve.hasError(Messages.READ_ONLY_USERNAME)) { + status = Response.Status.BAD_REQUEST; + } else if (pve.hasError(Messages.EMAIL_EXISTS, Messages.USERNAME_EXISTS)) { + status = Response.Status.CONFLICT; + } + + return account + .setErrors(status, errors) + .setProfileFormData(formData) + .createResponse(AccountPages.ACCOUNT); + } + } catch (ReadOnlyException e) { + setReferrerOnPage(); + return account + .setError(Response.Status.BAD_REQUEST, Messages.READ_ONLY_USER) + .setProfileFormData(formData) + .createResponse(AccountPages.ACCOUNT); + } + + event.success(); + setReferrerOnPage(); + return account.setSuccess(Messages.ACCOUNT_UPDATED).createResponse(AccountPages.ACCOUNT); + } + + @Path("sessions") + @POST + public Response processSessionsLogout() { + MultivaluedMap formData = request.getDecodedFormParameters(); + + if (auth == null) { + return login("sessions"); + } + + auth.require(AccountRoles.MANAGE_ACCOUNT); + csrfCheck(formData); + + UserModel user = auth.getUser(); + + // Rather decrease time a bit. To avoid situation when user is immediatelly redirected to login + // screen, then automatically authenticated (eg. with Kerberos) and then seeing issues due the + // stale token + // as time on the token will be same like notBefore + session.users().setNotBeforeForUser(realm, user, Time.currentTime() - 1); + + session + .sessions() + .getUserSessionsStream(realm, user) + .collect( + Collectors + .toList()) // collect to avoid concurrent modification as backchannelLogout removes + // the user sessions. + .forEach( + userSession -> + AuthenticationManager.backchannelLogout( + session, + realm, + userSession, + session.getContext().getUri(), + clientConnection, + headers, + true)); + + UriBuilder builder = + AccountUrls.accountBase(session.getContext().getUri().getBaseUri()) + .path(AccountFormService.class, "sessionsPage"); + String referrer = session.getContext().getUri().getQueryParameters().getFirst("referrer"); + if (referrer != null) { + builder.queryParam("referrer", referrer); + } + URI location = builder.build(realm.getName()); + return Response.seeOther(location).build(); + } + + @Path("applications") + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response processRevokeGrant() { + MultivaluedMap formData = request.getDecodedFormParameters(); + + if (auth == null) { + return login("applications"); + } + + auth.require(AccountRoles.MANAGE_ACCOUNT); + csrfCheck(formData); + + String clientId = formData.getFirst("clientId"); + if (clientId == null) { + setReferrerOnPage(); + return account + .setError(Response.Status.BAD_REQUEST, Messages.CLIENT_NOT_FOUND) + .createResponse(AccountPages.APPLICATIONS); + } + ClientModel client = realm.getClientById(clientId); + if (client == null) { + setReferrerOnPage(); + return account + .setError(Response.Status.BAD_REQUEST, Messages.CLIENT_NOT_FOUND) + .createResponse(AccountPages.APPLICATIONS); + } + + // Revoke grant in UserModel + UserModel user = auth.getUser(); + UserConsentManager.revokeConsentToClient(session, client, user); + + event + .event(EventType.REVOKE_GRANT) + .client(auth.getClient()) + .user(auth.getUser()) + .detail(Details.REVOKED_CLIENT, client.getClientId()) + .success(); + setReferrerOnPage(); + + UriBuilder builder = + AccountUrls.accountBase(session.getContext().getUri().getBaseUri()) + .path(AccountFormService.class, "applicationsPage"); + String referrer = session.getContext().getUri().getQueryParameters().getFirst("referrer"); + if (referrer != null) { + builder.queryParam("referrer", referrer); + } + URI location = builder.build(realm.getName()); + return Response.seeOther(location).build(); + } + + /** + * Update the TOTP for this account. + * + *

form parameters: + * + *

totp - otp generated by authenticator totpSecret - totp secret to register + * + * @return + */ + @Path("totp") + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response processTotpUpdate() { + MultivaluedMap formData = request.getDecodedFormParameters(); + + if (auth == null) { + return login("totp"); + } + + auth.require(AccountRoles.MANAGE_ACCOUNT); + + account.setAttribute( + "mode", session.getContext().getUri().getQueryParameters().getFirst("mode")); + + String action = formData.getFirst("submitAction"); + if (action != null && action.equals("Cancel")) { + setReferrerOnPage(); + return account.createResponse(AccountPages.TOTP); + } + + csrfCheck(formData); + + UserModel user = auth.getUser(); + + if (action != null && action.equals("Delete")) { + String credentialId = formData.getFirst("credentialId"); + if (credentialId == null) { + setReferrerOnPage(); + return account + .setError(Status.OK, Messages.UNEXPECTED_ERROR_HANDLING_REQUEST) + .createResponse(AccountPages.TOTP); + } + CredentialHelper.deleteOTPCredential(session, realm, user, credentialId); + event.event(EventType.REMOVE_TOTP).client(auth.getClient()).user(auth.getUser()).success(); + setReferrerOnPage(); + return account.setSuccess(Messages.SUCCESS_TOTP_REMOVED).createResponse(AccountPages.TOTP); + } else { + String challengeResponse = formData.getFirst("totp"); + String totpSecret = formData.getFirst("totpSecret"); + String userLabel = formData.getFirst("userLabel"); + + OTPPolicy policy = realm.getOTPPolicy(); + OTPCredentialModel credentialModel = + OTPCredentialModel.createFromPolicy(realm, totpSecret, userLabel); + if (Validation.isBlank(challengeResponse)) { + setReferrerOnPage(); + return account.setError(Status.OK, Messages.MISSING_TOTP).createResponse(AccountPages.TOTP); + } else if (!CredentialValidation.validOTP( + challengeResponse, credentialModel, policy.getLookAheadWindow())) { + setReferrerOnPage(); + return account.setError(Status.OK, Messages.INVALID_TOTP).createResponse(AccountPages.TOTP); + } + + if (!CredentialHelper.createOTPCredential( + session, realm, user, challengeResponse, credentialModel)) { + setReferrerOnPage(); + return account.setError(Status.OK, Messages.INVALID_TOTP).createResponse(AccountPages.TOTP); + } + event.event(EventType.UPDATE_TOTP).client(auth.getClient()).user(auth.getUser()).success(); + + setReferrerOnPage(); + return account.setSuccess(Messages.SUCCESS_TOTP).createResponse(AccountPages.TOTP); + } + } + + /** + * Update account password + * + *

Form params: + * + *

password - old password password-new pasword-confirm + * + * @return + */ + @Path("password") + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response processPasswordUpdate() { + MultivaluedMap formData = request.getDecodedFormParameters(); + + if (auth == null) { + return login("password"); + } + + auth.require(AccountRoles.MANAGE_ACCOUNT); + + csrfCheck(formData); + UserModel user = auth.getUser(); + + boolean requireCurrent = isPasswordSet(session, realm, user); + account.setPasswordSet(requireCurrent); + + String password = formData.getFirst("password"); + String passwordNew = formData.getFirst("password-new"); + String passwordConfirm = formData.getFirst("password-confirm"); + + EventBuilder errorEvent = + event + .clone() + .event(EventType.UPDATE_PASSWORD_ERROR) + .client(auth.getClient()) + .user(auth.getSession().getUser()); + + if (requireCurrent) { + if (Validation.isBlank(password)) { + setReferrerOnPage(); + errorEvent.error(Errors.PASSWORD_MISSING); + return account + .setError(Status.OK, Messages.MISSING_PASSWORD) + .createResponse(AccountPages.PASSWORD); + } + + UserCredentialModel cred = UserCredentialModel.password(password); + if (!user.credentialManager().isValid(cred)) { + setReferrerOnPage(); + errorEvent.error(Errors.INVALID_USER_CREDENTIALS); + return account + .setError(Status.OK, Messages.INVALID_PASSWORD_EXISTING) + .createResponse(AccountPages.PASSWORD); + } + } + + if (Validation.isBlank(passwordNew)) { + setReferrerOnPage(); + errorEvent.error(Errors.PASSWORD_MISSING); + return account + .setError(Status.OK, Messages.MISSING_PASSWORD) + .createResponse(AccountPages.PASSWORD); + } + + if (!passwordNew.equals(passwordConfirm)) { + setReferrerOnPage(); + errorEvent.error(Errors.PASSWORD_CONFIRM_ERROR); + return account + .setError(Status.OK, Messages.INVALID_PASSWORD_CONFIRM) + .createResponse(AccountPages.PASSWORD); + } + + try { + user.credentialManager().updateCredential(UserCredentialModel.password(passwordNew, false)); + } catch (ReadOnlyException mre) { + setReferrerOnPage(); + errorEvent.error(Errors.NOT_ALLOWED); + return account + .setError(Response.Status.BAD_REQUEST, Messages.READ_ONLY_PASSWORD) + .createResponse(AccountPages.PASSWORD); + } catch (ModelException me) { + ServicesLogger.LOGGER.failedToUpdatePassword(me); + setReferrerOnPage(); + errorEvent.detail(Details.REASON, me.getMessage()).error(Errors.PASSWORD_REJECTED); + return account + .setError(Response.Status.NOT_ACCEPTABLE, me.getMessage(), me.getParameters()) + .createResponse(AccountPages.PASSWORD); + } catch (Exception ape) { + ServicesLogger.LOGGER.failedToUpdatePassword(ape); + setReferrerOnPage(); + errorEvent.detail(Details.REASON, ape.getMessage()).error(Errors.PASSWORD_REJECTED); + return account + .setError(Response.Status.INTERNAL_SERVER_ERROR, ape.getMessage()) + .createResponse(AccountPages.PASSWORD); + } + + session + .sessions() + .getUserSessionsStream(realm, user) + .filter(s -> !Objects.equals(s.getId(), auth.getSession().getId())) + .collect( + Collectors + .toList()) // collect to avoid concurrent modification as backchannelLogout removes + // the user sessions. + .forEach( + s -> + AuthenticationManager.backchannelLogout( + session, + realm, + s, + session.getContext().getUri(), + clientConnection, + headers, + true)); + + event.event(EventType.UPDATE_PASSWORD).client(auth.getClient()).user(auth.getUser()).success(); + + setReferrerOnPage(); + return account + .setPasswordSet(true) + .setSuccess(Messages.ACCOUNT_PASSWORD_UPDATED) + .createResponse(AccountPages.PASSWORD); + } + + @Path("identity") + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response processFederatedIdentityUpdate() { + MultivaluedMap formData = request.getDecodedFormParameters(); + + if (auth == null) { + return login("identity"); + } + + auth.require(AccountRoles.MANAGE_ACCOUNT); + csrfCheck(formData); + UserModel user = auth.getUser(); + + String action = formData.getFirst("action"); + String providerId = formData.getFirst("providerId"); + + if (Validation.isEmpty(providerId)) { + setReferrerOnPage(); + return account + .setError(Status.OK, Messages.MISSING_IDENTITY_PROVIDER) + .createResponse(AccountPages.FEDERATED_IDENTITY); + } + AccountSocialAction accountSocialAction = AccountSocialAction.getAction(action); + if (accountSocialAction == null) { + setReferrerOnPage(); + return account + .setError(Status.OK, Messages.INVALID_FEDERATED_IDENTITY_ACTION) + .createResponse(AccountPages.FEDERATED_IDENTITY); + } + + if (!realm + .getIdentityProvidersStream() + .anyMatch(model -> Objects.equals(model.getAlias(), providerId))) { + setReferrerOnPage(); + return account + .setError(Status.OK, Messages.IDENTITY_PROVIDER_NOT_FOUND) + .createResponse(AccountPages.FEDERATED_IDENTITY); + } + + if (!user.isEnabled()) { + setReferrerOnPage(); + return account + .setError(Status.OK, Messages.ACCOUNT_DISABLED) + .createResponse(AccountPages.FEDERATED_IDENTITY); + } + + switch (accountSocialAction) { + case ADD: + String redirectUri = + UriBuilder.fromUri( + AccountUrls.accountFederatedIdentityPage( + session.getContext().getUri().getBaseUri(), realm.getName())) + .build() + .toString(); + + try { + String nonce = UUID.randomUUID().toString(); + MessageDigest md = MessageDigest.getInstance("SHA-256"); + String input = nonce + auth.getSession().getId() + client.getClientId() + providerId; + byte[] check = md.digest(input.getBytes(StandardCharsets.UTF_8)); + String hash = Base64Url.encode(check); + URI linkUrl = + AccountUrls.identityProviderLinkRequest( + this.session.getContext().getUri().getBaseUri(), providerId, realm.getName()); + linkUrl = + UriBuilder.fromUri(linkUrl) + .queryParam("nonce", nonce) + .queryParam("hash", hash) + .queryParam("client_id", client.getClientId()) + .queryParam("redirect_uri", redirectUri) + .build(); + return Response.seeOther(linkUrl).build(); + } catch (Exception spe) { + setReferrerOnPage(); + return account + .setError( + Response.Status.INTERNAL_SERVER_ERROR, Messages.IDENTITY_PROVIDER_REDIRECT_ERROR) + .createResponse(AccountPages.FEDERATED_IDENTITY); + } + case REMOVE: + FederatedIdentityModel link = session.users().getFederatedIdentity(realm, user, providerId); + if (link != null) { + + // Removing last social provider is not possible if you don't have other possibility to + // authenticate + if (session.users().getFederatedIdentitiesStream(realm, user).count() > 1 + || user.getFederationLink() != null + || isPasswordSet(session, realm, user)) { + session.users().removeFederatedIdentity(realm, user, providerId); + + logger.debugv( + "Social provider {0} removed successfully from user {1}", + providerId, user.getUsername()); + + event + .event(EventType.REMOVE_FEDERATED_IDENTITY) + .client(auth.getClient()) + .user(auth.getUser()) + .detail(Details.USERNAME, auth.getUser().getUsername()) + .detail(Details.IDENTITY_PROVIDER, link.getIdentityProvider()) + .detail(Details.IDENTITY_PROVIDER_USERNAME, link.getUserName()) + .success(); + + setReferrerOnPage(); + return account + .setSuccess(Messages.IDENTITY_PROVIDER_REMOVED) + .createResponse(AccountPages.FEDERATED_IDENTITY); + } else { + setReferrerOnPage(); + return account + .setError(Status.OK, Messages.FEDERATED_IDENTITY_REMOVING_LAST_PROVIDER) + .createResponse(AccountPages.FEDERATED_IDENTITY); + } + } else { + setReferrerOnPage(); + return account + .setError(Status.OK, Messages.FEDERATED_IDENTITY_NOT_ACTIVE) + .createResponse(AccountPages.FEDERATED_IDENTITY); + } + default: + throw new IllegalArgumentException(); + } + } + + @Path("resource") + @GET + public Response resourcesPage(@QueryParam("resource_id") String resourceId) { + return forwardToPage("resource", AccountPages.RESOURCES); + } + + @Path("resource/{resource_id}") + @GET + public Response resourceDetailPage(@PathParam("resource_id") String resourceId) { + return forwardToPage("resource", AccountPages.RESOURCE_DETAIL); + } + + @Path("resource/{resource_id}/grant") + @GET + public Response resourceDetailPageAfterGrant(@PathParam("resource_id") String resourceId) { + return resourceDetailPage(resourceId); + } + + @Path("resource/{resource_id}/grant") + @POST + public Response grantPermission( + @PathParam("resource_id") String resourceId, + @FormParam("action") String action, + @FormParam("permission_id") String[] permissionId, + @FormParam("requester") String requester) { + MultivaluedMap formData = request.getDecodedFormParameters(); + + if (auth == null) { + return login("resource"); + } + + auth.require(AccountRoles.MANAGE_ACCOUNT); + + csrfCheck(formData); + + AuthorizationProvider authorization = session.getProvider(AuthorizationProvider.class); + PermissionTicketStore ticketStore = authorization.getStoreFactory().getPermissionTicketStore(); + Resource resource = + authorization.getStoreFactory().getResourceStore().findById(realm, null, resourceId); + + if (resource == null) { + throw ErrorResponse.error("Invalid resource", Response.Status.BAD_REQUEST); + } + + if (action == null) { + throw ErrorResponse.error("Invalid action", Response.Status.BAD_REQUEST); + } + + boolean isGrant = "grant".equals(action); + boolean isDeny = "deny".equals(action); + boolean isRevoke = "revoke".equals(action); + boolean isRevokePolicy = "revokePolicy".equals(action); + boolean isRevokePolicyAll = "revokePolicyAll".equals(action); + + if (isRevokePolicy || isRevokePolicyAll) { + List ids = new ArrayList<>(Arrays.asList(permissionId)); + Iterator iterator = ids.iterator(); + PolicyStore policyStore = authorization.getStoreFactory().getPolicyStore(); + ResourceServer resourceServer = + authorization.getStoreFactory().getResourceServerStore().findByClient(client); + Policy policy = null; + + while (iterator.hasNext()) { + String id = iterator.next(); + + if (!id.contains(":")) { + policy = policyStore.findById(realm, resourceServer, id); + iterator.remove(); + break; + } + } + + Set scopesToKeep = new HashSet<>(); + + if (isRevokePolicyAll) { + for (Scope scope : policy.getScopes()) { + policy.removeScope(scope); + } + } else { + for (String id : ids) { + scopesToKeep.add( + authorization + .getStoreFactory() + .getScopeStore() + .findById(realm, resourceServer, id.split(":")[1])); + } + + for (Scope scope : policy.getScopes()) { + if (!scopesToKeep.contains(scope)) { + policy.removeScope(scope); + } + } + } + + if (policy.getScopes().isEmpty()) { + for (Policy associated : policy.getAssociatedPolicies()) { + policyStore.delete(realm, associated.getId()); + } + + policyStore.delete(realm, policy.getId()); + } + } else { + Map filters = + new EnumMap<>(PermissionTicket.FilterOption.class); + + filters.put(PermissionTicket.FilterOption.RESOURCE_ID, resource.getId()); + filters.put( + PermissionTicket.FilterOption.REQUESTER, + session.users().getUserByUsername(realm, requester).getId()); + + if (isRevoke) { + filters.put(PermissionTicket.FilterOption.GRANTED, Boolean.TRUE.toString()); + } else { + filters.put(PermissionTicket.FilterOption.GRANTED, Boolean.FALSE.toString()); + } + + List tickets = + ticketStore.find(realm, resource.getResourceServer(), filters, null, null); + Iterator iterator = tickets.iterator(); + + while (iterator.hasNext()) { + PermissionTicket ticket = iterator.next(); + + if (isGrant) { + if (permissionId != null + && permissionId.length > 0 + && !Arrays.asList(permissionId).contains(ticket.getId())) { + continue; + } + } + + if (isGrant && !ticket.isGranted()) { + ticket.setGrantedTimestamp(System.currentTimeMillis()); + iterator.remove(); + } else if (isDeny || isRevoke) { + if (permissionId != null + && permissionId.length > 0 + && Arrays.asList(permissionId).contains(ticket.getId())) { + iterator.remove(); + } + } + } + + for (PermissionTicket ticket : tickets) { + ticketStore.delete(client.getRealm(), ticket.getId()); + } + } + + if (isRevoke || isRevokePolicy || isRevokePolicyAll) { + return forwardToPage("resource", AccountPages.RESOURCE_DETAIL); + } + + return forwardToPage("resource", AccountPages.RESOURCES); + } + + @Path("resource/{resource_id}/share") + @GET + public Response resourceDetailPageAfterShare(@PathParam("resource_id") String resourceId) { + return resourceDetailPage(resourceId); + } + + @Path("resource/{resource_id}/share") + @POST + public Response shareResource( + @PathParam("resource_id") String resourceId, + @FormParam("user_id") String[] userIds, + @FormParam("scope_id") String[] scopes) { + MultivaluedMap formData = request.getDecodedFormParameters(); + + if (auth == null) { + return login("resource"); + } + + auth.require(AccountRoles.MANAGE_ACCOUNT); + + csrfCheck(formData); + + AuthorizationProvider authorization = session.getProvider(AuthorizationProvider.class); + PermissionTicketStore ticketStore = authorization.getStoreFactory().getPermissionTicketStore(); + ScopeStore scopeStore = authorization.getStoreFactory().getScopeStore(); + Resource resource = + authorization.getStoreFactory().getResourceStore().findById(realm, null, resourceId); + ResourceServer resourceServer = resource.getResourceServer(); + + if (resource == null) { + throw ErrorResponse.error("Invalid resource", Response.Status.BAD_REQUEST); + } + + if (userIds == null || userIds.length == 0) { + setReferrerOnPage(); + return account + .setError(Status.BAD_REQUEST, Messages.MISSING_PASSWORD) + .createResponse(AccountPages.PASSWORD); + } + + for (String id : userIds) { + UserModel user = session.users().getUserById(realm, id); + + if (user == null) { + user = session.users().getUserByUsername(realm, id); + } + + if (user == null) { + user = session.users().getUserByEmail(realm, id); + } + + if (user == null) { + setReferrerOnPage(); + return account + .setError(Status.BAD_REQUEST, Messages.INVALID_USER) + .createResponse(AccountPages.RESOURCE_DETAIL); + } + + Map filters = + new EnumMap<>(PermissionTicket.FilterOption.class); + + filters.put(PermissionTicket.FilterOption.RESOURCE_ID, resource.getId()); + filters.put(PermissionTicket.FilterOption.OWNER, auth.getUser().getId()); + filters.put(PermissionTicket.FilterOption.REQUESTER, user.getId()); + + List tickets = ticketStore.find(realm, resourceServer, filters, null, null); + final String userId = user.getId(); + + if (tickets.isEmpty()) { + if (scopes != null && scopes.length > 0) { + for (String scopeId : scopes) { + Scope scope = scopeStore.findById(realm, resourceServer, scopeId); + PermissionTicket ticket = ticketStore.create(resourceServer, resource, scope, userId); + ticket.setGrantedTimestamp(System.currentTimeMillis()); + } + } else { + if (resource.getScopes().isEmpty()) { + PermissionTicket ticket = ticketStore.create(resourceServer, resource, null, userId); + ticket.setGrantedTimestamp(System.currentTimeMillis()); + } else { + for (Scope scope : resource.getScopes()) { + PermissionTicket ticket = ticketStore.create(resourceServer, resource, scope, userId); + ticket.setGrantedTimestamp(System.currentTimeMillis()); + } + } + } + } else if (scopes != null && scopes.length > 0) { + List grantScopes = new ArrayList<>(Arrays.asList(scopes)); + Set alreadyGrantedScopes = + tickets.stream() + .map(PermissionTicket::getScope) + .map(Scope::getId) + .collect(Collectors.toSet()); + + grantScopes.removeIf(alreadyGrantedScopes::contains); + + for (String scopeId : grantScopes) { + Scope scope = scopeStore.findById(realm, resourceServer, scopeId); + PermissionTicket ticket = ticketStore.create(resourceServer, resource, scope, userId); + ticket.setGrantedTimestamp(System.currentTimeMillis()); + } + } + } + + return forwardToPage("resource", AccountPages.RESOURCE_DETAIL); + } + + @Path("resource") + @POST + public Response processResourceActions( + @FormParam("resource_id") String[] resourceIds, @FormParam("action") String action) { + MultivaluedMap formData = request.getDecodedFormParameters(); + + if (auth == null) { + return login("resource"); + } + + auth.require(AccountRoles.MANAGE_ACCOUNT); + csrfCheck(formData); + + AuthorizationProvider authorization = session.getProvider(AuthorizationProvider.class); + PermissionTicketStore ticketStore = authorization.getStoreFactory().getPermissionTicketStore(); + + if (action == null) { + throw ErrorResponse.error("Invalid action", Response.Status.BAD_REQUEST); + } + + for (String resourceId : resourceIds) { + Resource resource = + authorization.getStoreFactory().getResourceStore().findById(realm, null, resourceId); + + if (resource == null) { + throw ErrorResponse.error("Invalid resource", Response.Status.BAD_REQUEST); + } + + Map filters = + new EnumMap<>(PermissionTicket.FilterOption.class); + + filters.put(PermissionTicket.FilterOption.REQUESTER, auth.getUser().getId()); + filters.put(PermissionTicket.FilterOption.RESOURCE_ID, resource.getId()); + + if ("cancel".equals(action)) { + filters.put(PermissionTicket.FilterOption.GRANTED, Boolean.TRUE.toString()); + } else if ("cancelRequest".equals(action)) { + filters.put(PermissionTicket.FilterOption.GRANTED, Boolean.FALSE.toString()); + } + + RealmModel realm = resource.getResourceServer().getRealm(); + for (PermissionTicket ticket : + ticketStore.find(realm, resource.getResourceServer(), filters, null, null)) { + ticketStore.delete(realm, ticket.getId()); + } + } + + return forwardToPage("authorization", AccountPages.RESOURCES); + } + + public static UriBuilder loginRedirectUrl(UriBuilder base) { + return RealmsResource.accountUrl(base).path(AccountFormService.class, "loginRedirect"); + } + + @Override + protected URI getBaseRedirectUri() { + return AccountUrls.accountBase(session.getContext().getUri().getBaseUri()) + .path("/") + .build(realm.getName()); + } + + public static boolean isPasswordSet(KeycloakSession session, RealmModel realm, UserModel user) { + return user.credentialManager().isConfiguredFor(PasswordCredentialModel.TYPE); + } + + private String[] getReferrer() { + String referrer = session.getContext().getUri().getQueryParameters().getFirst("referrer"); + if (referrer == null) { + return null; + } + + String referrerUri = + session.getContext().getUri().getQueryParameters().getFirst("referrer_uri"); + + ClientModel referrerClient = realm.getClientByClientId(referrer); + if (referrerClient != null) { + if (referrerUri != null) { + referrerUri = RedirectUtils.verifyRedirectUri(session, referrerUri, referrerClient); + } else { + referrerUri = + ResolveRelative.resolveRelativeUri( + session, referrerClient.getRootUrl(), referrerClient.getBaseUrl()); + } + + if (referrerUri != null) { + String referrerName = referrerClient.getName(); + if (Validation.isBlank(referrerName)) { + referrerName = referrer; + } + return new String[] {referrerName, referrerUri}; + } + } else if (referrerUri != null) { + if (client != null) { + referrerUri = RedirectUtils.verifyRedirectUri(session, referrerUri, client); + + if (referrerUri != null) { + return new String[] {referrer, referrerUri}; + } + } + } + + return null; + } + + private enum AccountSocialAction { + ADD, + REMOVE; + + public static AccountSocialAction getAction(String action) { + if ("add".equalsIgnoreCase(action)) { + return ADD; + } else if ("remove".equalsIgnoreCase(action)) { + return REMOVE; + } else { + return null; + } + } + } + + private void csrfCheck(final MultivaluedMap formData) { + String formStateChecker = formData.getFirst("stateChecker"); + if (formStateChecker == null || !formStateChecker.equals(this.stateChecker)) { + throw new ForbiddenException(); + } + } +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/services/resources/account/AccountFormServiceFactory.java b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/services/resources/account/AccountFormServiceFactory.java new file mode 100644 index 00000000..098909b1 --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/account-v1-java/services/resources/account/AccountFormServiceFactory.java @@ -0,0 +1,64 @@ +package org.keycloak.services.resources.account; + +import com.google.auto.service.AutoService; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import java.util.Map; +import lombok.extern.jbosslog.JBossLog; +import org.keycloak.Config.Scope; +import org.keycloak.events.EventBuilder; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.ProtocolMapperModel; +import org.keycloak.models.RealmModel; +import org.keycloak.provider.ProviderEvent; +import org.keycloak.services.resource.AccountResourceProvider; +import org.keycloak.services.resource.AccountResourceProviderFactory; +import jakarta.ws.rs.NotFoundException; +import org.keycloak.models.Constants; + +@JBossLog +@AutoService(AccountResourceProviderFactory.class) +public class AccountFormServiceFactory implements AccountResourceProviderFactory { + + public static final String ID = "account-v1"; + + @Override + public String getId() { + return ID; + } + + private ClientModel getAccountManagementClient(RealmModel realm) { + ClientModel client = realm.getClientByClientId(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID); + if (client == null || !client.isEnabled()) { + log.debug("account management not enabled"); + throw new NotFoundException("account management not enabled"); + } + return client; + } + + @Override + public AccountResourceProvider create(KeycloakSession session) { + log.info("create"); + RealmModel realm = session.getContext().getRealm(); + ClientModel client = getAccountManagementClient(realm); + EventBuilder event = new EventBuilder​(realm, session, session.getContext().getConnection()); + return new AccountFormService(session, client, event); + } + + @Override + public void init(Scope config) { + log.info("init"); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + log.info("postInit"); + } + + @Override + public void close() { + log.info("close"); + } +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/bringInAccountV1.ts b/src/bin/keycloakify/generateJavaStackFiles/bringInAccountV1.ts new file mode 100644 index 00000000..25a1b5c0 --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/bringInAccountV1.ts @@ -0,0 +1,92 @@ +import * as fs from "fs"; +import { join as pathJoin, dirname as pathDirname } from "path"; +import { assert } from "tsafe/assert"; +import { Reflect } from "tsafe/Reflect"; +import type { BuildOptions } from "../BuildOptions"; +import { resources_common, lastKeycloakVersionWithAccountV1, accountV1 } from "../../constants"; +import { downloadBuiltinKeycloakTheme } from "../../download-builtin-keycloak-theme"; +import { transformCodebase } from "../../tools/transformCodebase"; + +export type BuildOptionsLike = { + keycloakifyBuildDirPath: string; + cacheDirPath: string; +}; + +{ + const buildOptions = Reflect(); + + assert(); +} + +export async function bringInAccountV1(params: { buildOptions: BuildOptionsLike }) { + const { buildOptions } = params; + + const builtinKeycloakThemeTmpDirPath = pathJoin(buildOptions.keycloakifyBuildDirPath, "..", "tmp_yxdE2_builtin_keycloak_theme"); + + await downloadBuiltinKeycloakTheme({ + "destDirPath": builtinKeycloakThemeTmpDirPath, + "keycloakVersion": lastKeycloakVersionWithAccountV1, + buildOptions + }); + + const accountV1DirPath = pathJoin(buildOptions.keycloakifyBuildDirPath, "src", "main", "resources", "theme", accountV1, "account"); + + transformCodebase({ + "srcDirPath": pathJoin(builtinKeycloakThemeTmpDirPath, "base", "account"), + "destDirPath": accountV1DirPath + }); + + const commonResourceFilePaths = [ + "node_modules/patternfly/dist/css/patternfly.min.css", + "node_modules/patternfly/dist/css/patternfly-additions.min.css" + ]; + + for (const relativeFilePath of commonResourceFilePaths.map(path => pathJoin(...path.split("/")))) { + const destFilePath = pathJoin(accountV1DirPath, "resources", resources_common, relativeFilePath); + + fs.mkdirSync(pathDirname(destFilePath), { "recursive": true }); + + fs.cpSync(pathJoin(builtinKeycloakThemeTmpDirPath, "keycloak", "common", "resources", relativeFilePath), destFilePath); + } + + const resourceFilePaths = ["css/account.css"]; + + for (const relativeFilePath of resourceFilePaths.map(path => pathJoin(...path.split("/")))) { + const destFilePath = pathJoin(accountV1DirPath, "resources", relativeFilePath); + + fs.mkdirSync(pathDirname(destFilePath), { "recursive": true }); + + fs.cpSync(pathJoin(builtinKeycloakThemeTmpDirPath, "keycloak", "account", "resources", relativeFilePath), destFilePath); + } + + fs.rmSync(builtinKeycloakThemeTmpDirPath, { "recursive": true }); + + fs.writeFileSync( + pathJoin(accountV1DirPath, "theme.properties"), + Buffer.from( + [ + "accountResourceProvider=org.keycloak.services.resources.account.AccountFormService", + "", + "locales=ar,ca,cs,da,de,en,es,fr,fi,hu,it,ja,lt,nl,no,pl,pt-BR,ru,sk,sv,tr,zh-CN", + "", + "styles=" + [...resourceFilePaths, ...commonResourceFilePaths.map(path => `resources_common/${path}`)].join(" "), + "", + "##### css classes for form buttons", + "# main class used for all buttons", + "kcButtonClass=btn", + "# classes defining priority of the button - primary or default (there is typically only one priority button for the form)", + "kcButtonPrimaryClass=btn-primary", + "kcButtonDefaultClass=btn-default", + "# classes defining size of the button", + "kcButtonLargeClass=btn-lg", + "" + ].join("\n"), + "utf8" + ) + ); + + transformCodebase({ + "srcDirPath": pathJoin(__dirname, "account-v1-java"), + "destDirPath": pathJoin(buildOptions.keycloakifyBuildDirPath, "src", "main", "java", "org", "keycloak") + }); +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/generateJavaStackFiles.ts b/src/bin/keycloakify/generateJavaStackFiles/generateJavaStackFiles.ts new file mode 100644 index 00000000..960deafd --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/generateJavaStackFiles.ts @@ -0,0 +1,211 @@ +import * as fs from "fs"; +import { join as pathJoin, dirname as pathDirname } from "path"; +import { assert } from "tsafe/assert"; +import { Reflect } from "tsafe/Reflect"; +import type { BuildOptions } from "../BuildOptions"; +import { type ThemeType, retrocompatPostfix, accountV1 } from "../../constants"; +import { bringInAccountV1 } from "./bringInAccountV1"; + +export type BuildOptionsLike = { + groupId: string; + artifactId: string; + themeVersion: string; + cacheDirPath: string; + keycloakifyBuildDirPath: string; + themeNames: string[]; +}; + +{ + const buildOptions = Reflect(); + + assert(); +} + +export async function generateJavaStackFiles(params: { + implementedThemeTypes: Record; + buildOptions: BuildOptionsLike; +}): Promise<{ + jarFilePath: string; +}> { + const { implementedThemeTypes, buildOptions } = params; + + { + const { pomFileCode } = (function generatePomFileCode(): { + pomFileCode: string; + } { + const pomFileCode = [ + ``, + ``, + ` 4.0.0`, + ` ${buildOptions.groupId}`, + ` ${buildOptions.artifactId}`, + ` ${buildOptions.themeVersion}`, + ` ${buildOptions.artifactId}`, + ` `, + ` jar`, + ` `, + ` 17`, + ` UTF-8`, + ` 999.0.0-SNAPSHOT`, + ` 32.0.0-jre`, + ` 1.18.28`, + ` 1.1.1`, + ` `, + ` `, + ` `, + ` `, + ` maven-compiler-plugin`, + ` 3.11.0`, + ` `, + ` \${java.version}`, + ` \${java.version}`, + ` -Xlint:unchecked`, + ` -Xlint:deprecation`, + ` false`, + ` `, + ` `, + ` com.google.auto.service`, + ` auto-service`, + ` \${auto-service.version}`, + ` `, + ` `, + ` org.projectlombok`, + ` lombok`, + ` \${lombok.version}`, + ` `, + ` `, + ` `, + ` `, + ` `, + ` org.apache.maven.plugins`, + ` maven-jar-plugin`, + ` 3.2.0`, + ` `, + ` `, + ` `, + ` `, + ` `, + ` `, + ` `, + ` `, + ` `, + ` `, + ` `, + ` com.spotify.fmt`, + ` fmt-maven-plugin`, + ` 2.20`, + ` `, + ` `, + ` `, + ` `, + ` `, + ` org.projectlombok`, + ` lombok`, + ` \${lombok.version}`, + ` provided`, + ` `, + ` `, + ` com.google.auto.service`, + ` auto-service`, + ` \${auto-service.version}`, + ` provided`, + ` `, + ` `, + ` org.keycloak`, + ` keycloak-server-spi`, + ` \${keycloak.version}`, + ` provided`, + ` `, + ` `, + ` org.keycloak`, + ` keycloak-server-spi-private`, + ` \${keycloak.version}`, + ` provided`, + ` `, + ` `, + ` org.keycloak`, + ` keycloak-services`, + ` \${keycloak.version}`, + ` provided`, + ` `, + ` `, + ` jakarta.ws.rs`, + ` jakarta.ws.rs-api`, + ` 3.1.0`, + ` provided`, + ` `, + ` `, + ` com.google.guava`, + ` guava`, + ` \${guava.version}`, + ` provided`, + ` `, + ` `, + `` + ].join("\n"); + + return { pomFileCode }; + })(); + + fs.writeFileSync(pathJoin(buildOptions.keycloakifyBuildDirPath, "pom.xml"), Buffer.from(pomFileCode, "utf8")); + } + + if (implementedThemeTypes.account) { + await bringInAccountV1({ buildOptions }); + } + + { + const themeManifestFilePath = pathJoin(buildOptions.keycloakifyBuildDirPath, "src", "main", "resources", "META-INF", "keycloak-themes.json"); + + try { + fs.mkdirSync(pathDirname(themeManifestFilePath)); + } catch {} + + fs.writeFileSync( + themeManifestFilePath, + Buffer.from( + JSON.stringify( + { + "themes": [ + ...(!implementedThemeTypes.account + ? [] + : [ + { + "name": accountV1, + "types": ["account"] + } + ]), + ...buildOptions.themeNames + .map(themeName => [ + { + "name": themeName, + "types": Object.entries(implementedThemeTypes) + .filter(([, isImplemented]) => isImplemented) + .map(([themeType]) => themeType) + }, + ...(!implementedThemeTypes.account + ? [] + : [ + { + "name": `${themeName}${retrocompatPostfix}`, + "types": ["account"] + } + ]) + ]) + .flat() + ] + }, + null, + 2 + ), + "utf8" + ) + ); + } + + return { + "jarFilePath": pathJoin(buildOptions.keycloakifyBuildDirPath, "target", `${buildOptions.artifactId}-${buildOptions.themeVersion}.jar`) + }; +} diff --git a/src/bin/keycloakify/generateJavaStackFiles/index.ts b/src/bin/keycloakify/generateJavaStackFiles/index.ts new file mode 100644 index 00000000..ea372c91 --- /dev/null +++ b/src/bin/keycloakify/generateJavaStackFiles/index.ts @@ -0,0 +1 @@ +export * from "./generateJavaStackFiles"; diff --git a/src/bin/keycloakify/generateStartKeycloakTestingContainer.ts b/src/bin/keycloakify/generateStartKeycloakTestingContainer.ts index a5efffef..8e0c7df2 100644 --- a/src/bin/keycloakify/generateStartKeycloakTestingContainer.ts +++ b/src/bin/keycloakify/generateStartKeycloakTestingContainer.ts @@ -1,53 +1,52 @@ import * as fs from "fs"; import { join as pathJoin } from "path"; import { assert } from "tsafe/assert"; +import { Reflect } from "tsafe/Reflect"; import type { BuildOptions } from "./BuildOptions"; export type BuildOptionsLike = { - themeName: string; - extraThemeNames: string[]; + keycloakifyBuildDirPath: string; }; -assert(); +{ + const buildOptions = Reflect(); + + assert(); +} generateStartKeycloakTestingContainer.basename = "start_keycloak_testing_container.sh"; const containerName = "keycloak-testing-container"; /** Files for being able to run a hot reload keycloak container */ -export function generateStartKeycloakTestingContainer(params: { - keycloakVersion: string; - keycloakThemeBuildingDirPath: string; - buildOptions: BuildOptionsLike; -}) { - const { - keycloakThemeBuildingDirPath, - keycloakVersion, - buildOptions: { themeName, extraThemeNames } - } = params; +export function generateStartKeycloakTestingContainer(params: { keycloakVersion: string; buildOptions: BuildOptionsLike }) { + const { keycloakVersion, buildOptions } = params; + + const themeRelativeDirPath = pathJoin("src", "main", "resources", "theme"); + const themeDirPath = pathJoin(buildOptions.keycloakifyBuildDirPath, themeRelativeDirPath); fs.writeFileSync( - pathJoin(keycloakThemeBuildingDirPath, generateStartKeycloakTestingContainer.basename), + pathJoin(buildOptions.keycloakifyBuildDirPath, generateStartKeycloakTestingContainer.basename), Buffer.from( [ "#!/usr/bin/env bash", "", `docker rm ${containerName} || true`, "", - `cd "${keycloakThemeBuildingDirPath.replace(/\\/g, "/")}"`, + `cd "${buildOptions.keycloakifyBuildDirPath}"`, "", "docker run \\", " -p 8080:8080 \\", ` --name ${containerName} \\`, " -e KEYCLOAK_ADMIN=admin \\", " -e KEYCLOAK_ADMIN_PASSWORD=admin \\", - ...[themeName, ...extraThemeNames].map( - themeName => - ` -v "${pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", themeName).replace( - /\\/g, - "/" - )}":"/opt/keycloak/themes/${themeName}":rw \\` - ), + ...fs + .readdirSync(themeDirPath) + .filter(name => fs.lstatSync(pathJoin(themeDirPath, name)).isDirectory()) + .map( + themeName => + ` -v "${pathJoin(".", themeRelativeDirPath, themeName).replace(/\\/g, "/")}":"/opt/keycloak/themes/${themeName}":rw \\` + ), ` -it quay.io/keycloak/keycloak:${keycloakVersion} \\`, ` start-dev --features=declarative-user-profile`, "" diff --git a/src/bin/keycloakify/generateTheme/downloadKeycloakStaticResources.ts b/src/bin/keycloakify/generateTheme/downloadKeycloakStaticResources.ts index c276a15c..f08404f2 100644 --- a/src/bin/keycloakify/generateTheme/downloadKeycloakStaticResources.ts +++ b/src/bin/keycloakify/generateTheme/downloadKeycloakStaticResources.ts @@ -1,80 +1,69 @@ import { transformCodebase } from "../../tools/transformCodebase"; import * as fs from "fs"; -import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path"; -import type { ThemeType } from "../generateFtl"; +import { join as pathJoin } from "path"; import { downloadBuiltinKeycloakTheme } from "../../download-builtin-keycloak-theme"; -import { - resourcesCommonDirPathRelativeToPublicDir, - resourcesDirPathRelativeToPublicDir, - basenameOfKeycloakDirInPublicDir -} from "../../mockTestingResourcesPath"; -import * as crypto from "crypto"; +import { resources_common, type ThemeType } from "../../constants"; +import { BuildOptions } from "../BuildOptions"; import { assert } from "tsafe/assert"; +import * as crypto from "crypto"; + +export type BuildOptionsLike = { + cacheDirPath: string; +}; + +assert(); export async function downloadKeycloakStaticResources( // prettier-ignore params: { - projectDirPath: string; themeType: ThemeType; themeDirPath: string; keycloakVersion: string; usedResources: { resourcesCommonFilePaths: string[]; - } | undefined + resourcesFilePaths: string[]; + } | undefined; + buildOptions: BuildOptionsLike; } ) { - const { projectDirPath, themeType, themeDirPath, keycloakVersion } = params; - - // NOTE: Hack for 427 - const usedResources = (() => { - const { usedResources } = params; - - if (usedResources === undefined) { - return undefined; - } - - assert(usedResources !== undefined); - - return { - "resourcesCommonDirPaths": usedResources.resourcesCommonFilePaths.map(filePath => { - { - const splitArg = "/dist/"; - - if (filePath.includes(splitArg)) { - return filePath.split(splitArg)[0] + splitArg; - } - } - - return pathDirname(filePath); - }) - }; - })(); + const { themeType, themeDirPath, keycloakVersion, usedResources, buildOptions } = params; const tmpDirPath = pathJoin( themeDirPath, - "..", `tmp_suLeKsxId_${crypto.createHash("sha256").update(`${themeType}-${keycloakVersion}`).digest("hex").slice(0, 8)}` ); await downloadBuiltinKeycloakTheme({ - projectDirPath, keycloakVersion, - "destDirPath": tmpDirPath + "destDirPath": tmpDirPath, + buildOptions }); + const resourcesPath = pathJoin(themeDirPath, themeType, "resources"); + transformCodebase({ "srcDirPath": pathJoin(tmpDirPath, "keycloak", themeType, "resources"), - "destDirPath": pathJoin(themeDirPath, pathRelative(basenameOfKeycloakDirInPublicDir, resourcesDirPathRelativeToPublicDir)) - }); - - transformCodebase({ - "srcDirPath": pathJoin(tmpDirPath, "keycloak", "common", "resources"), - "destDirPath": pathJoin(themeDirPath, pathRelative(basenameOfKeycloakDirInPublicDir, resourcesCommonDirPathRelativeToPublicDir)), + "destDirPath": resourcesPath, "transformSourceCode": usedResources === undefined ? undefined : ({ fileRelativePath, sourceCode }) => { - if (usedResources.resourcesCommonDirPaths.find(dirPath => fileRelativePath.startsWith(dirPath)) === undefined) { + if (!usedResources.resourcesFilePaths.includes(fileRelativePath)) { + return undefined; + } + + return { "modifiedSourceCode": sourceCode }; + } + }); + + transformCodebase({ + "srcDirPath": pathJoin(tmpDirPath, "keycloak", "common", "resources"), + "destDirPath": pathJoin(resourcesPath, resources_common), + "transformSourceCode": + usedResources === undefined + ? undefined + : ({ fileRelativePath, sourceCode }) => { + if (!usedResources.resourcesCommonFilePaths.includes(fileRelativePath)) { return undefined; } @@ -83,4 +72,4 @@ export async function downloadKeycloakStaticResources( }); fs.rmSync(tmpDirPath, { "recursive": true, "force": true }); -} +} \ No newline at end of file diff --git a/src/bin/keycloakify/generateTheme/generateMessageProperties.ts b/src/bin/keycloakify/generateTheme/generateMessageProperties.ts index f632261e..6cd38d5d 100644 --- a/src/bin/keycloakify/generateTheme/generateMessageProperties.ts +++ b/src/bin/keycloakify/generateTheme/generateMessageProperties.ts @@ -1,4 +1,4 @@ -import type { ThemeType } from "../generateFtl"; +import type { ThemeType } from "../../constants"; import { crawl } from "../../tools/crawl"; import { join as pathJoin } from "path"; import { readFileSync } from "fs"; diff --git a/src/bin/keycloakify/generateTheme/generateTheme.ts b/src/bin/keycloakify/generateTheme/generateTheme.ts index c47bc22b..b0e9ef84 100644 --- a/src/bin/keycloakify/generateTheme/generateTheme.ts +++ b/src/bin/keycloakify/generateTheme/generateTheme.ts @@ -1,13 +1,13 @@ import { transformCodebase } from "../../tools/transformCodebase"; import * as fs from "fs"; -import { join as pathJoin } from "path"; +import { join as pathJoin, basename as pathBasename, resolve as pathResolve } from "path"; import { replaceImportsFromStaticInJsCode } from "../replacers/replaceImportsFromStaticInJsCode"; import { replaceImportsInCssCode } from "../replacers/replaceImportsInCssCode"; -import { generateFtlFilesCodeFactory, loginThemePageIds, accountThemePageIds, themeTypes, type ThemeType } from "../generateFtl"; -import { basenameOfKeycloakDirInPublicDir } from "../../mockTestingResourcesPath"; +import { generateFtlFilesCodeFactory, loginThemePageIds, accountThemePageIds } from "../generateFtl"; +import { themeTypes, type ThemeType, lastKeycloakVersionWithAccountV1, keycloak_resources, retrocompatPostfix, accountV1 } from "../../constants"; import { isInside } from "../../tools/isInside"; import type { BuildOptions } from "../BuildOptions"; -import { assert } from "tsafe/assert"; +import { assert, type Equals } from "tsafe/assert"; import { downloadKeycloakStaticResources } from "./downloadKeycloakStaticResources"; import { readFieldNameUsage } from "./readFieldNameUsage"; import { readExtraPagesNames } from "./readExtraPageNames"; @@ -15,36 +15,39 @@ import { generateMessageProperties } from "./generateMessageProperties"; import { readStaticResourcesUsage } from "./readStaticResourcesUsage"; export type BuildOptionsLike = { - themeName: string; extraThemeProperties: string[] | undefined; themeVersion: string; - keycloakVersionDefaultAssets: string; + loginThemeResourcesFromKeycloakVersion: string; urlPathname: string | undefined; + keycloakifyBuildDirPath: string; + reactAppBuildDirPath: string; + cacheDirPath: string; + doBuildRetrocompatAccountTheme: boolean; }; assert(); export async function generateTheme(params: { - projectDirPath: string; - reactAppBuildDirPath: string; - keycloakThemeBuildingDirPath: string; + themeName: string; themeSrcDirPath: string; keycloakifySrcDirPath: string; buildOptions: BuildOptionsLike; keycloakifyVersion: string; }): Promise { - const { - projectDirPath, - reactAppBuildDirPath, - keycloakThemeBuildingDirPath, - themeSrcDirPath, - keycloakifySrcDirPath, - buildOptions, - keycloakifyVersion - } = params; + const { themeName, themeSrcDirPath, keycloakifySrcDirPath, buildOptions, keycloakifyVersion } = params; - const getThemeDirPath = (themeType: ThemeType | "email") => - pathJoin(keycloakThemeBuildingDirPath, "src", "main", "resources", "theme", buildOptions.themeName, themeType); + const getThemeTypeDirPath = (params: { themeType: ThemeType | "email"; isRetrocompat?: true }) => { + const { themeType, isRetrocompat = false } = params; + return pathJoin( + buildOptions.keycloakifyBuildDirPath, + "src", + "main", + "resources", + "theme", + `${themeName}${isRetrocompat ? retrocompatPostfix : ""}`, + themeType + ); + }; let allCssGlobalsToDefine: Record = {}; @@ -55,7 +58,7 @@ export async function generateTheme(params: { continue; } - const themeDirPath = getThemeDirPath(themeType); + const themeTypeDirPath = getThemeTypeDirPath({ themeType }); copy_app_resources_to_theme_path: { const isFirstPass = themeType.indexOf(themeType) === 0; @@ -65,13 +68,13 @@ export async function generateTheme(params: { } transformCodebase({ - "destDirPath": pathJoin(themeDirPath, "resources", "build"), - "srcDirPath": reactAppBuildDirPath, + "destDirPath": pathJoin(themeTypeDirPath, "resources", "build"), + "srcDirPath": buildOptions.reactAppBuildDirPath, "transformSourceCode": ({ filePath, sourceCode }) => { //NOTE: Prevent cycles, excludes the folder we generated for debug in public/ if ( isInside({ - "dirPath": pathJoin(reactAppBuildDirPath, basenameOfKeycloakDirInPublicDir), + "dirPath": pathJoin(buildOptions.reactAppBuildDirPath, keycloak_resources), filePath }) ) { @@ -114,7 +117,8 @@ export async function generateTheme(params: { generateFtlFilesCode_glob !== undefined ? generateFtlFilesCode_glob : generateFtlFilesCodeFactory({ - "indexHtmlCode": fs.readFileSync(pathJoin(reactAppBuildDirPath, "index.html")).toString("utf8"), + themeName, + "indexHtmlCode": fs.readFileSync(pathJoin(buildOptions.reactAppBuildDirPath, "index.html")).toString("utf8"), "cssGlobalsToDefine": allCssGlobalsToDefine, buildOptions, keycloakifyVersion, @@ -142,75 +146,77 @@ export async function generateTheme(params: { ].forEach(pageId => { const { ftlCode } = generateFtlFilesCode({ pageId }); - fs.mkdirSync(themeDirPath, { "recursive": true }); + fs.mkdirSync(themeTypeDirPath, { "recursive": true }); - fs.writeFileSync(pathJoin(themeDirPath, pageId), Buffer.from(ftlCode, "utf8")); + fs.writeFileSync(pathJoin(themeTypeDirPath, pageId), Buffer.from(ftlCode, "utf8")); }); generateMessageProperties({ themeSrcDirPath, themeType }).forEach(({ languageTag, propertiesFileSource }) => { - const messagesDirPath = pathJoin(themeDirPath, "messages"); + const messagesDirPath = pathJoin(themeTypeDirPath, "messages"); - fs.mkdirSync(pathJoin(themeDirPath, "messages"), { "recursive": true }); + fs.mkdirSync(pathJoin(themeTypeDirPath, "messages"), { "recursive": true }); const propertiesFilePath = pathJoin(messagesDirPath, `messages_${languageTag}.properties`); fs.writeFileSync(propertiesFilePath, Buffer.from(propertiesFileSource, "utf8")); }); - //TODO: Remove this block we left it for now only for backward compatibility - // we now have a separate script for this - copy_keycloak_resources_to_public: { - const keycloakDirInPublicDir = pathJoin(reactAppBuildDirPath, "..", "public", basenameOfKeycloakDirInPublicDir); - - if (fs.existsSync(keycloakDirInPublicDir)) { - break copy_keycloak_resources_to_public; - } - - await downloadKeycloakStaticResources({ - projectDirPath, - "keycloakVersion": buildOptions.keycloakVersionDefaultAssets, - "themeDirPath": keycloakDirInPublicDir, - themeType, - "usedResources": undefined - }); - - if (themeType !== themeTypes[0]) { - break copy_keycloak_resources_to_public; - } - - fs.writeFileSync( - pathJoin(keycloakDirInPublicDir, "README.txt"), - Buffer.from( - // prettier-ignore - [ - "This is just a test folder that helps develop", - "the login and register page without having to run a Keycloak container" - ].join(" ") - ) - ); - - fs.writeFileSync(pathJoin(keycloakDirInPublicDir, ".gitignore"), Buffer.from("*", "utf8")); - } - await downloadKeycloakStaticResources({ - projectDirPath, - "keycloakVersion": buildOptions.keycloakVersionDefaultAssets, - themeDirPath, + "keycloakVersion": (() => { + switch (themeType) { + case "account": + return lastKeycloakVersionWithAccountV1; + case "login": + return buildOptions.loginThemeResourcesFromKeycloakVersion; + } + })(), + "themeDirPath": pathResolve(pathJoin(themeTypeDirPath, "..")), themeType, "usedResources": readStaticResourcesUsage({ keycloakifySrcDirPath, themeSrcDirPath, themeType - }) + }), + buildOptions }); fs.writeFileSync( - pathJoin(themeDirPath, "theme.properties"), - Buffer.from([`parent=keycloak`, ...(buildOptions.extraThemeProperties ?? [])].join("\n\n"), "utf8") + pathJoin(themeTypeDirPath, "theme.properties"), + Buffer.from( + [ + `parent=${(() => { + switch (themeType) { + case "account": + return accountV1; + case "login": + return "keycloak"; + } + assert>(false); + })()}`, + ...(buildOptions.extraThemeProperties ?? []) + ].join("\n\n"), + "utf8" + ) ); + + if (themeType === "account" && buildOptions.doBuildRetrocompatAccountTheme) { + transformCodebase({ + "srcDirPath": themeTypeDirPath, + "destDirPath": getThemeTypeDirPath({ themeType, "isRetrocompat": true }), + "transformSourceCode": ({ filePath, sourceCode }) => { + if (pathBasename(filePath) === "theme.properties") { + return { + "modifiedSourceCode": Buffer.from(sourceCode.toString("utf8").replace(`parent=${accountV1}`, "parent=keycloak"), "utf8") + }; + } + + return { "modifiedSourceCode": sourceCode }; + } + }); + } } email: { @@ -222,7 +228,7 @@ export async function generateTheme(params: { transformCodebase({ "srcDirPath": emailThemeSrcDirPath, - "destDirPath": getThemeDirPath("email") + "destDirPath": getThemeTypeDirPath({ "themeType": "email" }) }); } } diff --git a/src/bin/keycloakify/generateTheme/readExtraPageNames.ts b/src/bin/keycloakify/generateTheme/readExtraPageNames.ts index 3c21f399..1e5b96d0 100644 --- a/src/bin/keycloakify/generateTheme/readExtraPageNames.ts +++ b/src/bin/keycloakify/generateTheme/readExtraPageNames.ts @@ -1,9 +1,10 @@ import { crawl } from "../../tools/crawl"; -import { type ThemeType, accountThemePageIds, loginThemePageIds } from "../generateFtl"; +import { accountThemePageIds, loginThemePageIds } from "../generateFtl"; import { id } from "tsafe/id"; import { removeDuplicates } from "evt/tools/reducers/removeDuplicates"; import * as fs from "fs"; import { join as pathJoin } from "path"; +import type { ThemeType } from "../../constants"; export function readExtraPagesNames(params: { themeSrcDirPath: string; themeType: ThemeType }): string[] { const { themeSrcDirPath, themeType } = params; diff --git a/src/bin/keycloakify/generateTheme/readFieldNameUsage.ts b/src/bin/keycloakify/generateTheme/readFieldNameUsage.ts index 77aa15da..b30a7a7c 100644 --- a/src/bin/keycloakify/generateTheme/readFieldNameUsage.ts +++ b/src/bin/keycloakify/generateTheme/readFieldNameUsage.ts @@ -2,7 +2,7 @@ import { crawl } from "../../tools/crawl"; import { removeDuplicates } from "evt/tools/reducers/removeDuplicates"; import { join as pathJoin } from "path"; import * as fs from "fs"; -import type { ThemeType } from "../generateFtl"; +import type { ThemeType } from "../../constants"; /** Assumes the theme type exists */ export function readFieldNameUsage(params: { keycloakifySrcDirPath: string; themeSrcDirPath: string; themeType: ThemeType }): string[] { diff --git a/src/bin/keycloakify/generateTheme/readStaticResourcesUsage.ts b/src/bin/keycloakify/generateTheme/readStaticResourcesUsage.ts index 79a78ca2..ea62bff6 100644 --- a/src/bin/keycloakify/generateTheme/readStaticResourcesUsage.ts +++ b/src/bin/keycloakify/generateTheme/readStaticResourcesUsage.ts @@ -1,7 +1,7 @@ import { crawl } from "../../tools/crawl"; import { join as pathJoin, sep as pathSep } from "path"; import * as fs from "fs"; -import type { ThemeType } from "../generateFtl"; +import type { ThemeType } from "../../constants"; /** Assumes the theme type exists */ export function readStaticResourcesUsage(params: { keycloakifySrcDirPath: string; themeSrcDirPath: string; themeType: ThemeType }): { diff --git a/src/bin/keycloakify/keycloakify.ts b/src/bin/keycloakify/keycloakify.ts index 0ee0aba4..b2c5d3d1 100644 --- a/src/bin/keycloakify/keycloakify.ts +++ b/src/bin/keycloakify/keycloakify.ts @@ -6,18 +6,16 @@ import { generateStartKeycloakTestingContainer } from "./generateStartKeycloakTe import * as fs from "fs"; import { readBuildOptions } from "./BuildOptions"; import { getLogger } from "../tools/logger"; -import jar from "../tools/jar"; import { assert } from "tsafe/assert"; -import { Equals } from "tsafe"; import { getThemeSrcDirPath } from "../getSrcDirPath"; import { getProjectRoot } from "../tools/getProjectRoot"; import { objectKeys } from "tsafe/objectKeys"; export async function main() { - const projectDirPath = process.cwd(); + const reactAppRootDirPath = process.cwd(); const buildOptions = readBuildOptions({ - projectDirPath, + reactAppRootDirPath, "processArgv": process.argv.slice(2) }); @@ -26,19 +24,14 @@ export async function main() { const keycloakifyDirPath = getProjectRoot(); - const { themeSrcDirPath } = getThemeSrcDirPath({ projectDirPath }); + const { themeSrcDirPath } = getThemeSrcDirPath({ reactAppRootDirPath }); - for (const themeName of [buildOptions.themeName, ...buildOptions.extraThemeNames]) { + for (const themeName of buildOptions.themeNames) { await generateTheme({ - projectDirPath, - "keycloakThemeBuildingDirPath": buildOptions.keycloakifyBuildDirPath, + themeName, themeSrcDirPath, "keycloakifySrcDirPath": pathJoin(keycloakifyDirPath, "src"), - "reactAppBuildDirPath": buildOptions.reactAppBuildDirPath, - "buildOptions": { - ...buildOptions, - "themeName": themeName - }, + buildOptions, "keycloakifyVersion": (() => { const version = JSON.parse(fs.readFileSync(pathJoin(keycloakifyDirPath, "package.json")).toString("utf8"))["version"]; @@ -49,8 +42,7 @@ export async function main() { }); } - const { jarFilePath } = generateJavaStackFiles({ - "keycloakThemeBuildingDirPath": buildOptions.keycloakifyBuildDirPath, + const { jarFilePath } = await generateJavaStackFiles({ "implementedThemeTypes": (() => { const implementedThemeTypes = { "login": false, @@ -70,33 +62,14 @@ export async function main() { buildOptions }); - switch (buildOptions.bundler) { - case "none": - logger.log("😱 Skipping bundling step, there will be no jar"); - break; - case "keycloakify": - logger.log("🫶 Let keycloakify do its thang"); - await jar({ - "rootPath": buildOptions.keycloakifyBuildDirPath, - "version": buildOptions.themeVersion, - "groupId": buildOptions.groupId, - "artifactId": buildOptions.artifactId, - "targetPath": jarFilePath - }); - break; - case "mvn": - logger.log("🫙 Run maven to deliver a jar"); - child_process.execSync("mvn package", { "cwd": buildOptions.keycloakifyBuildDirPath }); - break; - default: - assert>(false); + if (buildOptions.doCreateJar) { + child_process.execSync("mvn package", { "cwd": buildOptions.keycloakifyBuildDirPath }); } // We want, however, to test in a container running the latest Keycloak version const containerKeycloakVersion = "21.1.2"; generateStartKeycloakTestingContainer({ - keycloakThemeBuildingDirPath: buildOptions.keycloakifyBuildDirPath, "keycloakVersion": containerKeycloakVersion, buildOptions }); @@ -104,9 +77,13 @@ export async function main() { logger.log( [ "", - `✅ Your keycloak theme has been generated and bundled into .${pathSep}${pathRelative(projectDirPath, jarFilePath)} 🚀`, - `It is to be placed in "/opt/keycloak/providers" in the container running a quay.io/keycloak/keycloak Docker image.`, - "", + ...(!buildOptions.doCreateJar + ? [] + : [ + `✅ Your keycloak theme has been generated and bundled into .${pathSep}${pathRelative(reactAppRootDirPath, jarFilePath)} 🚀`, + `It is to be placed in "/opt/keycloak/providers" in the container running a quay.io/keycloak/keycloak Docker image.`, + "" + ]), //TODO: Restore when we find a good Helm chart for Keycloak. //"Using Helm (https://github.com/codecentric/helm-charts), edit to reflect:", "", @@ -139,7 +116,7 @@ export async function main() { `To test your theme locally you can spin up a Keycloak ${containerKeycloakVersion} container image with the theme pre loaded by running:`, "", `👉 $ .${pathSep}${pathRelative( - projectDirPath, + reactAppRootDirPath, pathJoin(buildOptions.keycloakifyBuildDirPath, generateStartKeycloakTestingContainer.basename) )} 👈`, "", @@ -149,15 +126,15 @@ export async function main() { "- Log into the admin console 👉 http://localhost:8080/admin username: admin, password: admin 👈", `- Create a realm: Master -> AddRealm -> Name: myrealm`, `- Enable registration: Realm settings -> Login tab -> User registration: on`, - `- Enable the Account theme (optional): Realm settings -> Themes tab -> Account theme: ${buildOptions.themeName}`, - ` Clients -> account -> Login theme: ${buildOptions.themeName}`, - `- Enable the email theme (optional): Realm settings -> Themes tab -> Email theme: ${buildOptions.themeName} (option will appear only if you have ran npx initialize-email-theme)`, + `- Enable the Account theme (optional): Realm settings -> Themes tab -> Account theme: ${buildOptions.themeNames[0]}`, + ` Clients -> account -> Login theme: ${buildOptions.themeNames[0]}`, + `- Enable the email theme (optional): Realm settings -> Themes tab -> Email theme: ${buildOptions.themeNames[0]} (option will appear only if you have ran npx initialize-email-theme)`, `- Create a client Clients -> Create -> Client ID: myclient`, ` Root URL: https://www.keycloak.org/app/`, ` Valid redirect URIs: https://www.keycloak.org/app* http://localhost* (localhost is optional)`, ` Valid post logout redirect URIs: https://www.keycloak.org/app* http://localhost*`, ` Web origins: *`, - ` Login Theme: ${buildOptions.themeName}`, + ` Login Theme: ${buildOptions.themeNames[0]}`, ` Save (button at the bottom of the page)`, ``, `- Go to 👉 https://www.keycloak.org/app/ 👈 Click "Save" then "Sign in". You should see your login page`, diff --git a/src/bin/keycloakify/parsedPackageJson.ts b/src/bin/keycloakify/parsedPackageJson.ts index 972cc960..43478f14 100644 --- a/src/bin/keycloakify/parsedPackageJson.ts +++ b/src/bin/keycloakify/parsedPackageJson.ts @@ -4,8 +4,6 @@ import type { Equals } from "tsafe"; import { z } from "zod"; import { pathJoin } from "../tools/pathJoin"; -export const bundlers = ["mvn", "keycloakify", "none"] as const; -export type Bundler = (typeof bundlers)[number]; export type ParsedPackageJson = { name: string; version?: string; @@ -15,12 +13,12 @@ export type ParsedPackageJson = { areAppAndKeycloakServerSharingSameDomain?: boolean; artifactId?: string; groupId?: string; - bundler?: Bundler; - keycloakVersionDefaultAssets?: string; + doCreateJar?: boolean; + loginThemeResourcesFromKeycloakVersion?: string; reactAppBuildDirPath?: string; keycloakifyBuildDirPath?: string; - themeName?: string; - extraThemeNames?: string[]; + themeName?: string | string[]; + doBuildRetrocompatAccountTheme?: boolean; }; }; @@ -34,12 +32,12 @@ export const zParsedPackageJson = z.object({ "areAppAndKeycloakServerSharingSameDomain": z.boolean().optional(), "artifactId": z.string().optional(), "groupId": z.string().optional(), - "bundler": z.enum(bundlers).optional(), - "keycloakVersionDefaultAssets": z.string().optional(), + "doCreateJar": z.boolean().optional(), + "loginThemeResourcesFromKeycloakVersion": z.string().optional(), "reactAppBuildDirPath": z.string().optional(), "keycloakifyBuildDirPath": z.string().optional(), - "themeName": z.string().optional(), - "extraThemeNames": z.array(z.string()).optional() + "themeName": z.union([z.string(), z.array(z.string())]).optional(), + "doBuildRetrocompatAccountTheme": z.boolean().optional() }) .optional() }); @@ -47,11 +45,11 @@ export const zParsedPackageJson = z.object({ assert, ParsedPackageJson>>(); let parsedPackageJson: undefined | ReturnType<(typeof zParsedPackageJson)["parse"]>; -export function getParsedPackageJson(params: { projectDirPath: string }) { - const { projectDirPath } = params; +export function getParsedPackageJson(params: { reactAppRootDirPath: string }) { + const { reactAppRootDirPath } = params; if (parsedPackageJson) { return parsedPackageJson; } - parsedPackageJson = zParsedPackageJson.parse(JSON.parse(fs.readFileSync(pathJoin(projectDirPath, "package.json")).toString("utf8"))); + parsedPackageJson = zParsedPackageJson.parse(JSON.parse(fs.readFileSync(pathJoin(reactAppRootDirPath, "package.json")).toString("utf8"))); return parsedPackageJson; } diff --git a/src/bin/mockTestingResourcesPath.ts b/src/bin/mockTestingResourcesPath.ts deleted file mode 100644 index 1540221e..00000000 --- a/src/bin/mockTestingResourcesPath.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { pathJoin } from "./tools/pathJoin"; - -export const basenameOfKeycloakDirInPublicDir = "keycloak-resources"; -export const resourcesDirPathRelativeToPublicDir = pathJoin(basenameOfKeycloakDirInPublicDir, "resources"); -export const resourcesCommonDirPathRelativeToPublicDir = pathJoin(resourcesDirPathRelativeToPublicDir, "resources_common"); diff --git a/src/bin/tools/downloadAndUnzip.ts b/src/bin/tools/downloadAndUnzip.ts index 5d7dcc0c..4bff442b 100644 --- a/src/bin/tools/downloadAndUnzip.ts +++ b/src/bin/tools/downloadAndUnzip.ts @@ -162,7 +162,7 @@ export async function downloadAndUnzip( } & ( | { doUseCache: true; - projectDirPath: string; + cacheDirPath: string; } | { doUseCache: false; @@ -182,11 +182,10 @@ export async function downloadAndUnzip( } }); - const cacheRoot = !rest.doUseCache - ? `tmp_${Math.random().toString().slice(2, 12)}` - : pathJoin(process.env.XDG_CACHE_HOME ?? pathJoin(rest.projectDirPath, "node_modules", ".cache"), "keycloakify"); - const zipFilePath = pathJoin(cacheRoot, `${zipFileBasename}.zip`); - const extractDirPath = pathJoin(cacheRoot, `tmp_unzip_${zipFileBasename}`); + const cacheDirPath = !rest.doUseCache ? `tmp_${Math.random().toString().slice(2, 12)}` : rest.cacheDirPath; + + const zipFilePath = pathJoin(cacheDirPath, `${zipFileBasename}.zip`); + const extractDirPath = pathJoin(cacheDirPath, `tmp_unzip_${zipFileBasename}`); if (!(await exists(zipFilePath))) { const opts = await getFetchOptions(); @@ -226,7 +225,7 @@ export async function downloadAndUnzip( }); if (!rest.doUseCache) { - await rm(cacheRoot, { "recursive": true }); + await rm(cacheDirPath, { "recursive": true }); } else { await rm(extractDirPath, { "recursive": true }); } diff --git a/src/bin/tools/getAbsoluteAndInOsFormatPath.ts b/src/bin/tools/getAbsoluteAndInOsFormatPath.ts new file mode 100644 index 00000000..5b64edeb --- /dev/null +++ b/src/bin/tools/getAbsoluteAndInOsFormatPath.ts @@ -0,0 +1,15 @@ +import { isAbsolute as pathIsAbsolute, sep as pathSep, join as pathJoin } from "path"; + +export function getAbsoluteAndInOsFormatPath(params: { pathIsh: string; cwd: string }): string { + const { pathIsh, cwd } = params; + + let pathOut = pathIsh; + + pathOut = pathOut.replace(/\//g, pathSep); + + if (!pathIsAbsolute(pathOut)) { + pathOut = pathJoin(cwd, pathOut); + } + + return pathOut; +} diff --git a/src/bin/tools/jar.ts b/src/bin/tools/jar.ts deleted file mode 100644 index 33fdf2df..00000000 --- a/src/bin/tools/jar.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { dirname, relative, sep, join } from "path"; -import { createWriteStream } from "fs"; - -import walk from "./walk"; -import { ZipFile } from "yazl"; -import { mkdir } from "fs/promises"; -import trimIndent from "./trimIndent"; - -export type ZipEntry = { zipPath: string } & ({ fsPath: string } | { buffer: Buffer }); -export type ZipEntryGenerator = AsyncGenerator; - -type CommonJarArgs = { - groupId: string; - artifactId: string; - version: string; -}; - -export type JarStreamArgs = CommonJarArgs & { - asyncPathGeneratorFn(): ZipEntryGenerator; -}; - -export type JarArgs = CommonJarArgs & { - targetPath: string; - rootPath: string; -}; - -export async function jarStream({ groupId, artifactId, version, asyncPathGeneratorFn }: JarStreamArgs) { - const manifestPath = "META-INF/MANIFEST.MF"; - const manifestData = Buffer.from(trimIndent` - Manifest-Version: 1.0 - Archiver-Version: Plexus Archiver - Created-By: Keycloakify - Built-By: unknown - Build-Jdk: 19.0.0 - `); - - const pomPropsPath = `META-INF/maven/${groupId}/${artifactId}/pom.properties`; - const pomPropsData = Buffer.from(trimIndent` - # Generated by keycloakify - # ${new Date()} - artifactId=${artifactId} - groupId=${groupId} - version=${version} - `); - - const zipFile = new ZipFile(); - - for await (const entry of asyncPathGeneratorFn()) { - if ("buffer" in entry) { - zipFile.addBuffer(entry.buffer, entry.zipPath); - } else if ("fsPath" in entry) { - if (entry.fsPath.endsWith(sep)) { - zipFile.addEmptyDirectory(entry.zipPath); - } else { - zipFile.addFile(entry.fsPath, entry.zipPath); - } - } - } - - zipFile.addBuffer(manifestData, manifestPath); - zipFile.addBuffer(pomPropsData, pomPropsPath); - - zipFile.end(); - - return zipFile; -} - -/** - * Create a jar archive, using the resources found at `rootPath` (a directory) and write the - * archive to `targetPath` (a file). Use `groupId`, `artifactId` and `version` to define - * the contents of the pom.properties file which is going to be added to the archive. - * The root directory is expectedto have a conventional maven/gradle folder structure with a - * single `pom.xml` file at the root and a `src/main/resources` directory containing all - * application resources. - */ -export default async function jar({ groupId, artifactId, version, rootPath, targetPath }: JarArgs) { - await mkdir(dirname(targetPath), { recursive: true }); - - const asyncPathGeneratorFn = async function* (): ZipEntryGenerator { - const resourcesPath = join(rootPath, "src", "main", "resources"); - for await (const fsPath of walk(resourcesPath)) { - const zipPath = relative(resourcesPath, fsPath).split(sep).join("/"); - yield { fsPath, zipPath }; - } - yield { - fsPath: join(rootPath, "pom.xml"), - zipPath: `META-INF/maven/${groupId}/${artifactId}/pom.xml` - }; - }; - - const zipFile = await jarStream({ groupId, artifactId, version, asyncPathGeneratorFn }); - - await new Promise(async (resolve, reject) => { - zipFile.outputStream - .pipe(createWriteStream(targetPath, { encoding: "binary" })) - .on("close", () => resolve()) - .on("error", e => reject(e)); - }); -} diff --git a/src/bin/tools/pathJoin.ts b/src/bin/tools/pathJoin.ts index fac076a5..58e1b6e7 100644 --- a/src/bin/tools/pathJoin.ts +++ b/src/bin/tools/pathJoin.ts @@ -2,5 +2,5 @@ export function pathJoin(...path: string[]): string { return path .map((part, i) => (i === 0 ? part : part.replace(/^\/+/, ""))) .map((part, i) => (i === path.length - 1 ? part : part.replace(/\/+$/, ""))) - .join("/"); + .join(typeof process !== "undefined" && process.platform === "win32" ? "\\" : "/"); } diff --git a/src/bin/tools/walk.ts b/src/bin/tools/walk.ts deleted file mode 100644 index 57c544a6..00000000 --- a/src/bin/tools/walk.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { readdir } from "fs/promises"; -import { resolve, sep } from "path"; - -/** - * Asynchronously and recursively walk a directory tree, yielding every file and directory - * found. Directory paths will _always_ end with a path separator. - * - * @param root the starting directory - * @returns AsyncGenerator - */ -export default async function* walk(root: string): AsyncGenerator { - for (const entry of await readdir(root, { withFileTypes: true })) { - const absolutePath = resolve(root, entry.name); - if (entry.isDirectory()) { - yield absolutePath.endsWith(sep) ? absolutePath : absolutePath + sep; - yield* walk(absolutePath); - } else yield absolutePath.endsWith(sep) ? absolutePath.substring(0, absolutePath.length - 1) : absolutePath; - } -} diff --git a/src/login/kcContext/KcContext.ts b/src/login/kcContext/KcContext.ts index 3dd526d9..67b95ffe 100644 --- a/src/login/kcContext/KcContext.ts +++ b/src/login/kcContext/KcContext.ts @@ -1,4 +1,5 @@ -import type { LoginThemePageId, ThemeType } from "keycloakify/bin/keycloakify/generateFtl"; +import type { LoginThemePageId } from "keycloakify/bin/keycloakify/generateFtl"; +import { type ThemeType } from "keycloakify/bin/constants"; import { assert } from "tsafe/assert"; import type { Equals } from "tsafe"; import type { MessageKey } from "../i18n/i18n"; diff --git a/src/login/kcContext/createGetKcContext.ts b/src/login/kcContext/createGetKcContext.ts index 5f0dd6a7..211ec856 100644 --- a/src/login/kcContext/createGetKcContext.ts +++ b/src/login/kcContext/createGetKcContext.ts @@ -8,9 +8,8 @@ import { assert } from "tsafe/assert"; import type { ExtendKcContext } from "./getKcContextFromWindow"; import { getKcContextFromWindow } from "./getKcContextFromWindow"; import { pathJoin } from "keycloakify/bin/tools/pathJoin"; -import { pathBasename } from "keycloakify/tools/pathBasename"; -import { resourcesCommonDirPathRelativeToPublicDir } from "keycloakify/bin/mockTestingResourcesPath"; import { symToStr } from "tsafe/symToStr"; +import { resources_common } from "keycloakify/bin/constants"; export function createGetKcContext(params?: { mockData?: readonly DeepPartial>[]; @@ -148,11 +147,7 @@ export function createGetKcContext [attribute.name, attribute])) as any; +const PUBLIC_URL = (typeof process !== "object" ? undefined : process.env?.["PUBLIC_URL"]) || "/"; + +const resourcesPath = pathJoin(PUBLIC_URL, keycloak_resources, "login", "resources"); + export const kcContextCommonMock: KcContext.Common = { "themeVersion": "0.0.0", "keycloakifyVersion": "0.0.0", @@ -109,8 +111,8 @@ export const kcContextCommonMock: KcContext.Common = { "themeName": "my-theme-name", "url": { "loginAction": "#", - "resourcesPath": pathJoin(PUBLIC_URL, resourcesDirPathRelativeToPublicDir), - "resourcesCommonPath": pathJoin(PUBLIC_URL, resourcesCommonDirPathRelativeToPublicDir), + resourcesPath, + "resourcesCommonPath": pathJoin(resourcesPath, resources_common), "loginRestartFlowUrl": "/auth/realms/myrealm/login-actions/restart?client_id=account&tab_id=HoAx28ja4xg", "loginUrl": "/auth/realms/myrealm/login-actions/authenticate?client_id=account&tab_id=HoAx28ja4xg" }, diff --git a/test/bin/jar.spec.ts b/test/bin/jar.spec.ts deleted file mode 100644 index 65cf57f3..00000000 --- a/test/bin/jar.spec.ts +++ /dev/null @@ -1,131 +0,0 @@ -import jar, { jarStream, type ZipEntryGenerator } from "keycloakify/bin/tools/jar"; -import { fromBuffer, Entry, ZipFile } from "yauzl"; -import { it, describe, assert, afterAll } from "vitest"; -import { Readable } from "stream"; -import { tmpdir } from "os"; -import { mkdtemp, cp, mkdir, rm, writeFile } from "fs/promises"; -import path from "path"; -import { createReadStream } from "fs"; -import walk from "keycloakify/bin/tools/walk"; - -type AsyncIterable = { - [Symbol.asyncIterator](): AsyncIterableIterator; -}; - -async function arrayFromAsync(asyncIterable: AsyncIterable) { - const chunks: T[] = []; - for await (const chunk of asyncIterable) chunks.push(chunk); - return chunks; -} - -async function readToBuffer(stream: NodeJS.ReadableStream) { - return Buffer.concat(await arrayFromAsync(stream as AsyncIterable)); -} - -function unzipBuffer(buffer: Buffer) { - return new Promise((resolve, reject) => - fromBuffer(buffer, { lazyEntries: true }, (err, zipFile) => { - if (err !== null) { - reject(err); - } else { - resolve(zipFile); - } - }) - ); -} - -function readEntry(zipFile: ZipFile, entry: Entry): Promise { - return new Promise((resolve, reject) => { - zipFile.openReadStream(entry, (err, stream) => { - if (err !== null) { - reject(err); - } else { - resolve(stream); - } - }); - }); -} - -function readAll(zipFile: ZipFile): Promise> { - return new Promise>((resolve, reject) => { - const entries1: Map = new Map(); - zipFile.on("entry", async (entry: Entry) => { - const stream = await readEntry(zipFile, entry); - const buffer = await readToBuffer(stream); - entries1.set(entry.fileName, buffer); - zipFile.readEntry(); - }); - zipFile.on("end", () => resolve(entries1)); - zipFile.on("error", e => reject(e)); - zipFile.readEntry(); - }); -} - -describe("jar", () => { - const coords = { artifactId: "someArtifactId", groupId: "someGroupId", version: "1.2.3" }; - - const tmpDirs: string[] = []; - - afterAll(async () => { - await Promise.all(tmpDirs.map(dir => rm(dir, { force: true, recursive: true }))); - }); - - it("creates jar artifacts without error", async () => { - async function* mockFiles(): ZipEntryGenerator { - yield { zipPath: "foo", buffer: Buffer.from("foo") }; - } - - const zipped = await jarStream({ ...coords, asyncPathGeneratorFn: mockFiles }); - const buffered = await readToBuffer(zipped.outputStream); - const unzipped = await unzipBuffer(buffered); - const entries = await readAll(unzipped); - - assert.equal(entries.size, 3); - assert.isOk(entries.has("foo")); - assert.isOk(entries.has("META-INF/MANIFEST.MF")); - assert.isOk(entries.has("META-INF/maven/someGroupId/someArtifactId/pom.properties")); - - assert.equal("foo", entries.get("foo")?.toString("utf-8")); - - const manifest = entries.get("META-INF/MANIFEST.MF")?.toString("utf-8"); - const pomProperties = entries.get("META-INF/maven/someGroupId/someArtifactId/pom.properties")?.toString("utf-8"); - - assert.isOk(manifest?.includes("Created-By: Keycloakify")); - assert.isOk(pomProperties?.includes("1.2.3")); - assert.isOk(pomProperties?.includes("someGroupId")); - assert.isOk(pomProperties?.includes("someArtifactId")); - }); - - it("creates a jar from _real_ files without error", async () => { - const tmp = await mkdtemp(path.join(tmpdir(), "kc-jar-test-")); - - tmpDirs.push(tmp); - - const rootPath = path.join(tmp, "root"); - const resourcesPath = path.join(tmp, "root", "src", "main", "resources"); - const targetPath = path.join(tmp, "jar.jar"); - - await mkdir(resourcesPath, { recursive: true }); - await writeFile(path.join(rootPath, "pom.xml"), "foo", "utf-8"); - - await cp(path.dirname(__dirname), resourcesPath, { recursive: true }); - - await jar({ ...coords, rootPath, targetPath }); - - const buffered = await readToBuffer(createReadStream(targetPath)); - const unzipped = await unzipBuffer(buffered); - const entries = await readAll(unzipped); - const zipPaths = Array.from(entries.keys()); - - assert.isOk(entries.has("META-INF/MANIFEST.MF")); - assert.isOk(entries.has("META-INF/maven/someGroupId/someArtifactId/pom.properties")); - assert.isOk(entries.has("META-INF/maven/someGroupId/someArtifactId/pom.xml")); - - for await (const fsPath of walk(resourcesPath)) { - if (!fsPath.endsWith(path.sep)) { - const rel = path.relative(resourcesPath, fsPath).replace(path.sep === "/" ? /\//g : /\\/g, "/"); - assert.isOk(zipPaths.includes(rel), `missing '${rel}' (${rel}, '${zipPaths.join("', '")}')`); - } - } - }); -}); diff --git a/test/bin/setupSampleReactProject.spec.ts b/test/bin/setupSampleReactProject.spec.ts index 1995b49e..98b6966f 100644 --- a/test/bin/setupSampleReactProject.spec.ts +++ b/test/bin/setupSampleReactProject.spec.ts @@ -24,7 +24,7 @@ vi.mock("keycloakify/bin/keycloakify/parsed-package-json", async () => ({ vi.mock("keycloakify/bin/promptKeycloakVersion", async () => ({ ...((await vi.importActual("keycloakify/bin/promptKeycloakVersion")) as Record), - promptKeycloakVersion: () => ({ keycloakVersion: "11.0.3" }) + promptKeycloakVersion: () => ({ "keycloakVersion": "11.0.3" }) })); const nativeCwd = process.cwd; @@ -52,19 +52,25 @@ describe("Sample Project", () => { await setupSampleReactProject(sampleReactProjectDirPath); await initializeEmailTheme(); - const projectDirPath = process.cwd(); + const reactAppRootDirPath = process.cwd(); const destDirPath = pathJoin( readBuildOptions({ "processArgv": ["--silent"], - projectDirPath + reactAppRootDirPath }).keycloakifyBuildDirPath, "src", "main", "resources", "theme" ); - await downloadBuiltinKeycloakTheme({ destDirPath, "keycloakVersion": "11.0.3", projectDirPath }); + await downloadBuiltinKeycloakTheme({ + destDirPath, + "keycloakVersion": "11.0.3", + "buildOptions": { + "cacheDirPath": pathJoin(reactAppRootDirPath, "node_modules", ".cache", "keycloakify") + } + }); }, { timeout: 90000 } ); @@ -80,19 +86,25 @@ describe("Sample Project", () => { await setupSampleReactProject(pathJoin(sampleReactProjectDirPath, "custom_input")); await initializeEmailTheme(); - const projectDirPath = process.cwd(); + const reactAppRootDirPath = process.cwd(); const destDirPath = pathJoin( readBuildOptions({ "processArgv": ["--silent"], - projectDirPath + reactAppRootDirPath }).keycloakifyBuildDirPath, "src", "main", "resources", "theme" ); - await downloadBuiltinKeycloakTheme({ destDirPath, "keycloakVersion": "11.0.3", projectDirPath }); + await downloadBuiltinKeycloakTheme({ + destDirPath, + "keycloakVersion": "11.0.3", + buildOptions: { + "cacheDirPath": pathJoin(reactAppRootDirPath, "node_modules", ".cache", "keycloakify") + } + }); }, { timeout: 90000 } ); diff --git a/test/bin/tools/trimIndet.spec.ts b/test/bin/tools/trimIndet.spec.ts deleted file mode 100644 index cad19a39..00000000 --- a/test/bin/tools/trimIndet.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import trimIndent from "keycloakify/bin/tools/trimIndent"; -import { it, describe, assert } from "vitest"; - -describe("trimIndent", () => { - it("does not change a left-aligned string as expected", () => { - const txt = trimIndent`lorem -ipsum`; - assert.equal(txt, ["lorem", "ipsum"].join("\n")); - }); - - it("removes leading and trailing empty lines from a left-aligned string", () => { - const txt = trimIndent` -lorem -ipsum -`; - assert.equal(txt, ["lorem", "ipsum"].join("\n")); - }); - - it("removes indent from an aligned string", () => { - const txt = trimIndent` - lorem - ipsum - `; - assert.equal(txt, ["lorem", "ipsum"].join("\n")); - }); - - it("removes indent from unaligned string", () => { - const txt = trimIndent` - lorem - ipsum - `; - assert.equal(txt, ["lorem", " ipsum"].join("\n")); - }); - - it("removes only first and last empty line", () => { - const txt = trimIndent` - - lorem - ipsum - - `; - - assert.equal(txt, ["", "lorem", "ipsum", ""].join("\n")); - }); - - it("interpolates non-strings", () => { - const d = new Date(); - const txt = trimIndent` - lorem - ${d} - ipsum`; - - assert.equal(txt, ["lorem", String(d), "ipsum"].join("\n")); - }); - - it("inderpolates preserving new-lines in the interpolated bits", () => { - const a = ["ipsum", "dolor", "sit"].join("\n"); - const txt = trimIndent` - lorem - ${a} - amet - `; - assert.equal(txt, ["lorem", "ipsum", "dolor", "sit", "amet"].join("\n")); - }); -});