From de6f90b43bf697b6f7ca0c635025d69a212de604 Mon Sep 17 00:00:00 2001 From: Stan Silvert Date: Thu, 21 Nov 2019 07:51:14 -0500 Subject: [PATCH] KEYCLOAK-11550: Single page for credentials (initial commit) --- .../resources/account/AccountConsole.java | 6 + .../resources/account/AccountRestService.java | 12 ++ .../theme/keycloak-preview/account/index.ftl | 6 +- .../account/messages/messages_en.properties | 15 ++ .../account/resources/.eslintrc.js | 1 + .../aia-page/AppInitiatedActionPage.tsx | 29 +-- .../content/signingin-page/SigningInPage.tsx | 183 ++++++++++++++++++ .../account/resources/app/util/AIACommand.ts | 54 ++++++ .../app/widgets/ContinueCancelModal.tsx | 6 +- .../account/resources/app/widgets/features.ts | 1 + .../account/resources/content.js | 8 +- .../account/resources/systemjs.config.js | 2 +- 12 files changed, 289 insertions(+), 34 deletions(-) create mode 100644 themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/signingin-page/SigningInPage.tsx create mode 100644 themes/src/main/resources/theme/keycloak-preview/account/resources/app/util/AIACommand.ts diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java b/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java index e673287162..36c7d07ce4 100644 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java @@ -114,6 +114,12 @@ public class AccountConsole { EventStoreProvider eventStore = session.getProvider(EventStoreProvider.class); map.put("isEventsEnabled", eventStore != null && realm.isEventsEnabled()); map.put("isAuthorizationEnabled", true); + + boolean isTotpConfigured = false; + if (user != null) { + isTotpConfigured = session.userCredentialManager().isConfiguredFor(realm, user, realm.getOTPPolicy().getType()); + } + map.put("isTotpConfigured", isTotpConfigured); FreeMarkerUtil freeMarkerUtil = new FreeMarkerUtil(); String result = freeMarkerUtil.processTemplate(map, "index.ftl", theme); diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java b/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java index 216f2b8f43..c28d58e41f 100755 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java @@ -63,6 +63,7 @@ import java.util.Properties; import java.util.stream.Collectors; import org.keycloak.common.Profile; +import org.keycloak.credential.CredentialModel; import org.keycloak.theme.Theme; /** @@ -397,6 +398,17 @@ public class AccountRestService { final ConsentRepresentation consent) { return upsert(clientId, consent); } + + @Path("/totp/remove") + @DELETE + public Response removeTOTP() { + auth.require(AccountRoles.MANAGE_ACCOUNT); + + session.userCredentialManager().disableCredentialType(realm, user, CredentialModel.OTP); + event.event(EventType.REMOVE_TOTP).client(auth.getClient()).user(auth.getUser()).success(); + + return Cors.add(request, Response.accepted()).build(); + } /** * Creates or updates the consent of the given, requested consent for diff --git a/themes/src/main/resources/theme/keycloak-preview/account/index.ftl b/themes/src/main/resources/theme/keycloak-preview/account/index.ftl index b1e0e1192a..a9aeaa2311 100644 --- a/themes/src/main/resources/theme/keycloak-preview/account/index.ftl +++ b/themes/src/main/resources/theme/keycloak-preview/account/index.ftl @@ -31,7 +31,8 @@ isInternationalizationEnabled : ${realm.internationalizationEnabled?c}, isLinkedAccountsEnabled : ${realm.identityFederationEnabled?c}, isEventsEnabled : ${isEventsEnabled?c}, - isMyResourcesEnabled : ${(realm.userManagedAccessAllowed && isAuthorizationEnabled)?c} + isMyResourcesEnabled : ${(realm.userManagedAccessAllowed && isAuthorizationEnabled)?c}, + isTotpConfigured : ${isTotpConfigured?c} } var availableLocales = []; @@ -229,8 +230,7 @@
${msg("accountSecurityIntroMessage")}
- - +
diff --git a/themes/src/main/resources/theme/keycloak-preview/account/messages/messages_en.properties b/themes/src/main/resources/theme/keycloak-preview/account/messages/messages_en.properties index 5c007bbb59..7b4a170547 100644 --- a/themes/src/main/resources/theme/keycloak-preview/account/messages/messages_en.properties +++ b/themes/src/main/resources/theme/keycloak-preview/account/messages/messages_en.properties @@ -7,6 +7,8 @@ continue=Continue refreshPage=Refresh the page done=Done cancel=Cancel +remove=Remove +update=Update # Device Activity Page signedInDevices=Signed In Devices @@ -53,3 +55,16 @@ systemDefined=System Defined link=Link Account unLink=Unlink Account +# Signing In Page +signingIn=Signing In +signingInSubMessage=Configure ways to sign in. +twoFactorEnabled=Two-factor authentication is enabled. +twoFactorDisabled=Two-factor authentication is disabled. +twoFactorAuth=Two-Factor Authentication +mobileAuthDefault=Mobile Authenticator (Default) +removeMobileAuth=Remove Mobile Authenticator +stopMobileAuth=Stop using Mobile Authenticator? +setUp=Set Up +passwordless=Passwordless +lastUpdate=Last Update +unknown=Unknown diff --git a/themes/src/main/resources/theme/keycloak-preview/account/resources/.eslintrc.js b/themes/src/main/resources/theme/keycloak-preview/account/resources/.eslintrc.js index 1731a4a540..d8ca1a71d1 100644 --- a/themes/src/main/resources/theme/keycloak-preview/account/resources/.eslintrc.js +++ b/themes/src/main/resources/theme/keycloak-preview/account/resources/.eslintrc.js @@ -26,6 +26,7 @@ module.exports = { "@typescript-eslint/no-empty-interface": "off", "@typescript-eslint/no-non-null-assertion": "off", "@typescript-eslint/no-parameter-properties": "off", + "@typescript-eslint/explicit-member-accessibility": "off", "no-restricted-properties": "off" }, }; diff --git a/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/aia-page/AppInitiatedActionPage.tsx b/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/aia-page/AppInitiatedActionPage.tsx index 2875a83b1a..90c04c1e06 100644 --- a/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/aia-page/AppInitiatedActionPage.tsx +++ b/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/aia-page/AppInitiatedActionPage.tsx @@ -17,6 +17,7 @@ import * as React from 'react'; import {withRouter, RouteComponentProps} from 'react-router-dom'; +import {AIACommand} from '../../util/AIACommand'; import {PageDef} from '../../ContentPages'; import {Msg} from '../../widgets/Msg'; @@ -47,11 +48,6 @@ interface AppInitiatedActionPageProps extends RouteComponentProps { pageDef: ActionPageDef; } -declare const baseUrl: string; -declare const realm: string; -declare const referrer: string; -declare const referrerUri: string; - /** * @author Stan Silvert */ @@ -62,28 +58,7 @@ class ApplicationInitiatedActionPage extends React.Component { - let redirectURI: string = baseUrl; - - if (typeof referrer !== 'undefined') { - // '_hash_' is a workaround for when uri encoding is not - // sufficient to escape the # character properly. - // The problem is that both the redirect and the application URL contain a hash. - // The browser will consider anything after the first hash to be client-side. So - // it sees the hash in the redirect param and stops. - redirectURI += "?referrer=" + referrer + "&referrer_uri=" + referrerUri.replace('#', '_hash_'); - } - - redirectURI = encodeURIComponent(redirectURI); - - const href: string = "/auth/realms/" + realm + - "/protocol/openid-connect/auth/" + - "?response_type=code" + - "&client_id=account&scope=openid" + - "&kc_action=" + this.props.pageDef.kcAction + - "&redirect_uri=" + redirectURI + - encodeURIComponent("/#" + this.props.location.pathname); // return to this page - - window.location.href = href; + new AIACommand(this.props.pageDef.kcAction, this.props.location.pathname).execute(); } public render(): React.ReactNode { diff --git a/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/signingin-page/SigningInPage.tsx b/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/signingin-page/SigningInPage.tsx new file mode 100644 index 0000000000..8bcd85d999 --- /dev/null +++ b/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/signingin-page/SigningInPage.tsx @@ -0,0 +1,183 @@ +/* + * Copyright 2018 Red Hat, Inc. and/or its affiliates. + * + * 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. + */ + +import * as React from 'react'; +import * as moment from 'moment'; +import {AxiosResponse} from 'axios'; + +import {withRouter, RouteComponentProps} from 'react-router-dom'; +import { + Button, + DataList, + DataListAction, + DataListItemCells, + DataListCell, + DataListItemRow, + Stack, + StackItem, + Switch, + Title, + TitleLevel + } from '@patternfly/react-core'; + +import {AIACommand} from '../../util/AIACommand'; +import {AccountServiceClient} from '../../account-service/account.service'; +import {ContinueCancelModal} from '../../widgets/ContinueCancelModal'; +import {Features} from '../../widgets/features'; +import {Msg} from '../../widgets/Msg'; +import {ContentPage} from '../ContentPage'; +import {ContentAlert} from '../ContentAlert'; + +declare const features: Features; + +interface PasswordDetails { + registered: boolean; + lastUpdate: number; +} + +interface SigningInPageProps extends RouteComponentProps { +} + +interface SigningInPageState { + twoFactorEnabled: boolean; + twoFactorEnabledText: string; + isTotpConfigured: boolean; + lastPasswordUpdate?: number; +} + +/** + * @author Stan Silvert ssilvert@redhat.com (C) 2018 Red Hat Inc. + */ +class SigningInPage extends React.Component { + private readonly updatePassword: AIACommand = new AIACommand('UPDATE_PASSWORD', this.props.location.pathname); + private readonly setUpTOTP: AIACommand = new AIACommand('CONFIGURE_TOTP', this.props.location.pathname); + + public constructor(props: SigningInPageProps) { + super(props); + this.state = { + twoFactorEnabled: true, + twoFactorEnabledText: Msg.localize('twoFactorEnabled'), + isTotpConfigured: features.isTotpConfigured, + } + this.setLastPwdUpdate(); + } + + private setLastPwdUpdate(): void { + AccountServiceClient.Instance.doGet("/credentials/password") + .then((response: AxiosResponse) => { + if (response.data.lastUpdate) { + const lastUpdate: number = response.data.lastUpdate; + this.setState({lastPasswordUpdate: lastUpdate}); + } + }); + } + + private handleTwoFactorSwitch = () => { + if (this.state.twoFactorEnabled) { + this.setState({twoFactorEnabled: false, twoFactorEnabledText: Msg.localize('twoFactorDisabled')}) + } else { + this.setState({twoFactorEnabled: true, twoFactorEnabledText: Msg.localize('twoFactorEnabled')}) + } + } + + private handleRemoveTOTP = () => { + AccountServiceClient.Instance.doDelete("/totp/remove") + .then(() => { + this.setState({isTotpConfigured: false}); + ContentAlert.success('successTotpRemovedMessage'); + }); + } + + public render(): React.ReactNode { + let lastPwdUpdate: string = Msg.localize('unknown'); + if (this.state.lastPasswordUpdate) { + lastPwdUpdate = moment(this.state.lastPasswordUpdate).format('LLL'); + } + + return ( + + + + + <strong><Msg msgKey='password'/></strong> + + + + , + : {lastPwdUpdate}, + + ]}/> + + + + + + + + + <strong><Msg msgKey='twoFactorAuth'/></strong> + + + + + + + + + + ]}/> + {!this.state.isTotpConfigured && + + + + } + {this.state.isTotpConfigured && + + + + } + + + + + + <strong><Msg msgKey='passwordless'/></strong> + + + + + ); + } + +}; + +const SigningInPageWithRouter = withRouter(SigningInPage); +export { SigningInPageWithRouter as SigningInPage}; \ No newline at end of file diff --git a/themes/src/main/resources/theme/keycloak-preview/account/resources/app/util/AIACommand.ts b/themes/src/main/resources/theme/keycloak-preview/account/resources/app/util/AIACommand.ts new file mode 100644 index 0000000000..757573559d --- /dev/null +++ b/themes/src/main/resources/theme/keycloak-preview/account/resources/app/util/AIACommand.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2019 Red Hat, Inc. and/or its affiliates. + * + * 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. + */ + + + +declare const baseUrl: string; +declare const realm: string; +declare const referrer: string; +declare const referrerUri: string; + +/** + * @author Stan Silvert + */ +export class AIACommand { + constructor(private action: string, private redirectPath: string) {} + + public execute(): void { + let redirectURI: string = baseUrl; + + if (typeof referrer !== 'undefined') { + // '_hash_' is a workaround for when uri encoding is not + // sufficient to escape the # character properly. + // The problem is that both the redirect and the application URL contain a hash. + // The browser will consider anything after the first hash to be client-side. So + // it sees the hash in the redirect param and stops. + redirectURI += "?referrer=" + referrer + "&referrer_uri=" + referrerUri.replace('#', '_hash_'); + } + + redirectURI = encodeURIComponent(redirectURI); + + const href: string = "/auth/realms/" + realm + + "/protocol/openid-connect/auth/" + + "?response_type=code" + + "&client_id=account&scope=openid" + + "&kc_action=" + this.action + + "&redirect_uri=" + redirectURI + + encodeURIComponent("/#" + this.redirectPath); // return to this page + + window.location.href = href; + } +} \ No newline at end of file diff --git a/themes/src/main/resources/theme/keycloak-preview/account/resources/app/widgets/ContinueCancelModal.tsx b/themes/src/main/resources/theme/keycloak-preview/account/resources/app/widgets/ContinueCancelModal.tsx index cbf3637811..4a14054630 100644 --- a/themes/src/main/resources/theme/keycloak-preview/account/resources/app/widgets/ContinueCancelModal.tsx +++ b/themes/src/main/resources/theme/keycloak-preview/account/resources/app/widgets/ContinueCancelModal.tsx @@ -30,6 +30,7 @@ interface ContinueCancelModalProps { modalContinueButtonLabel?: string; modalCancelButtonLabel?: string; onContinue: () => void; + isDisabled?: boolean; } interface ContinueCancelModalState { @@ -46,7 +47,8 @@ export class ContinueCancelModal extends React.Component -