KEYCLOAK-11550: Single page for credentials (initial commit)

This commit is contained in:
Stan Silvert 2019-11-21 07:51:14 -05:00 committed by Bruno Oliveira da Silva
parent 685d49c693
commit de6f90b43b
12 changed files with 289 additions and 34 deletions

View file

@ -115,6 +115,12 @@ public class AccountConsole {
map.put("isEventsEnabled", eventStore != null && realm.isEventsEnabled()); map.put("isEventsEnabled", eventStore != null && realm.isEventsEnabled());
map.put("isAuthorizationEnabled", true); 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(); FreeMarkerUtil freeMarkerUtil = new FreeMarkerUtil();
String result = freeMarkerUtil.processTemplate(map, "index.ftl", theme); String result = freeMarkerUtil.processTemplate(map, "index.ftl", theme);
Response.ResponseBuilder builder = Response.status(Response.Status.OK).type(MediaType.TEXT_HTML_UTF_8).language(Locale.ENGLISH).entity(result); Response.ResponseBuilder builder = Response.status(Response.Status.OK).type(MediaType.TEXT_HTML_UTF_8).language(Locale.ENGLISH).entity(result);

View file

@ -63,6 +63,7 @@ import java.util.Properties;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.keycloak.common.Profile; import org.keycloak.common.Profile;
import org.keycloak.credential.CredentialModel;
import org.keycloak.theme.Theme; import org.keycloak.theme.Theme;
/** /**
@ -398,6 +399,17 @@ public class AccountRestService {
return upsert(clientId, 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 * Creates or updates the consent of the given, requested consent for
* the client with the given client id. Returns the appropriate REST response. * the client with the given client id. Returns the appropriate REST response.

View file

@ -31,7 +31,8 @@
isInternationalizationEnabled : ${realm.internationalizationEnabled?c}, isInternationalizationEnabled : ${realm.internationalizationEnabled?c},
isLinkedAccountsEnabled : ${realm.identityFederationEnabled?c}, isLinkedAccountsEnabled : ${realm.identityFederationEnabled?c},
isEventsEnabled : ${isEventsEnabled?c}, isEventsEnabled : ${isEventsEnabled?c},
isMyResourcesEnabled : ${(realm.userManagedAccessAllowed && isAuthorizationEnabled)?c} isMyResourcesEnabled : ${(realm.userManagedAccessAllowed && isAuthorizationEnabled)?c},
isTotpConfigured : ${isTotpConfigured?c}
} }
var availableLocales = []; var availableLocales = [];
@ -229,8 +230,7 @@
<h6>${msg("accountSecurityIntroMessage")}</h6> <h6>${msg("accountSecurityIntroMessage")}</h6>
</div> </div>
<div class="pf-c-card__body pf-c-content"> <div class="pf-c-card__body pf-c-content">
<h5 id="changePasswordLink"><a href="#/app/security/password">${msg("changePasswordHtmlTitle")}</a></h5> <h5 id="signingInLink"><a href="#/app/security/signingin">${msg("signingIn")}</a></h5>
<h5 id="authenticatorLink"><a href="#/app/security/authenticator">${msg("authenticatorTitle")}</a></h5>
<h5 id="deviceActivityLink"><a href="#/app/security/device-activity">${msg("deviceActivityHtmlTitle")}</a></h5> <h5 id="deviceActivityLink"><a href="#/app/security/device-activity">${msg("deviceActivityHtmlTitle")}</a></h5>
<h5 id="linkedAccountsLink" style="display:none"><a href="#/app/security/linked-accounts">${msg("linkedAccountsHtmlTitle")}</a></h5> <h5 id="linkedAccountsLink" style="display:none"><a href="#/app/security/linked-accounts">${msg("linkedAccountsHtmlTitle")}</a></h5>
</div> </div>

View file

@ -7,6 +7,8 @@ continue=Continue
refreshPage=Refresh the page refreshPage=Refresh the page
done=Done done=Done
cancel=Cancel cancel=Cancel
remove=Remove
update=Update
# Device Activity Page # Device Activity Page
signedInDevices=Signed In Devices signedInDevices=Signed In Devices
@ -53,3 +55,16 @@ systemDefined=System Defined
link=Link Account link=Link Account
unLink=Unlink 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

View file

@ -26,6 +26,7 @@ module.exports = {
"@typescript-eslint/no-empty-interface": "off", "@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/no-non-null-assertion": "off", "@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-parameter-properties": "off", "@typescript-eslint/no-parameter-properties": "off",
"@typescript-eslint/explicit-member-accessibility": "off",
"no-restricted-properties": "off" "no-restricted-properties": "off"
}, },
}; };

View file

@ -17,6 +17,7 @@
import * as React from 'react'; import * as React from 'react';
import {withRouter, RouteComponentProps} from 'react-router-dom'; import {withRouter, RouteComponentProps} from 'react-router-dom';
import {AIACommand} from '../../util/AIACommand';
import {PageDef} from '../../ContentPages'; import {PageDef} from '../../ContentPages';
import {Msg} from '../../widgets/Msg'; import {Msg} from '../../widgets/Msg';
@ -47,11 +48,6 @@ interface AppInitiatedActionPageProps extends RouteComponentProps {
pageDef: ActionPageDef; pageDef: ActionPageDef;
} }
declare const baseUrl: string;
declare const realm: string;
declare const referrer: string;
declare const referrerUri: string;
/** /**
* @author Stan Silvert * @author Stan Silvert
*/ */
@ -62,28 +58,7 @@ class ApplicationInitiatedActionPage extends React.Component<AppInitiatedActionP
} }
private handleClick = (): void => { private handleClick = (): void => {
let redirectURI: string = baseUrl; new AIACommand(this.props.pageDef.kcAction, this.props.location.pathname).execute();
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;
} }
public render(): React.ReactNode { public render(): React.ReactNode {

View file

@ -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<SigningInPageProps, SigningInPageState> {
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<PasswordDetails>) => {
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 (
<ContentPage title="signingIn"
introMessage="signingInSubMessage">
<Stack gutter='md'>
<StackItem isFilled>
<Title headingLevel={TitleLevel.h2} size='2xl'>
<strong><Msg msgKey='password'/></strong>
</Title>
<DataList aria-label='foo'>
<DataListItemRow>
<DataListItemCells
dataListCells={[
<DataListCell key='password'><Msg msgKey='password'/></DataListCell>,
<DataListCell key='lastPwdUpdate'><strong><Msg msgKey='lastUpdate'/>: </strong>{lastPwdUpdate}</DataListCell>,
<DataListCell key='spacer'/>
]}/>
<DataListAction aria-labelledby='foo' aria-label='foo action' id='setPasswordAction'>
<Button variant='primary'onClick={()=> this.updatePassword.execute()}><Msg msgKey='update'/></Button>
</DataListAction>
</DataListItemRow>
</DataList>
</StackItem>
<StackItem isFilled>
<Title headingLevel={TitleLevel.h2} size='2xl'>
<strong><Msg msgKey='twoFactorAuth'/></strong>
</Title>
<DataList aria-label='foo'>
<DataListItemRow>
<DataListAction aria-labelledby='foo' aria-label='foo action' id='twoFactorOnOff'>
<Switch
aria-label='twoFactorSwitch'
label={this.state.twoFactorEnabledText}
isChecked={this.state.twoFactorEnabled}
onClick={this.handleTwoFactorSwitch}
/>
</DataListAction>
</DataListItemRow>
<DataListItemRow>
<DataListItemCells
dataListCells={[
<DataListCell key='mobileAuth'><Msg msgKey='mobileAuthDefault'/></DataListCell>
]}/>
{!this.state.isTotpConfigured &&
<DataListAction aria-labelledby='foo' aria-label='foo action' id='setMobileAuthAction'>
<Button isDisabled={!this.state.twoFactorEnabled} variant='primary' onClick={()=> this.setUpTOTP.execute()}><Msg msgKey='setUp'/></Button>
</DataListAction>
}
{this.state.isTotpConfigured &&
<DataListAction aria-labelledby='foo' aria-label='foo action' id='setMobileAuthAction'>
<ContinueCancelModal buttonTitle='remove'
isDisabled={!this.state.twoFactorEnabled}
modalTitle={Msg.localize('removeMobileAuth')}
modalMessage={Msg.localize('stopMobileAuth')}
onContinue={this.handleRemoveTOTP}
/>
</DataListAction>
}
</DataListItemRow>
</DataList>
</StackItem>
<StackItem isFilled>
<Title headingLevel={TitleLevel.h2} size='2xl'>
<strong><Msg msgKey='passwordless'/></strong>
</Title>
</StackItem>
</Stack>
</ContentPage>
);
}
};
const SigningInPageWithRouter = withRouter(SigningInPage);
export { SigningInPageWithRouter as SigningInPage};

View file

@ -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;
}
}

View file

@ -30,6 +30,7 @@ interface ContinueCancelModalProps {
modalContinueButtonLabel?: string; modalContinueButtonLabel?: string;
modalCancelButtonLabel?: string; modalCancelButtonLabel?: string;
onContinue: () => void; onContinue: () => void;
isDisabled?: boolean;
} }
interface ContinueCancelModalState { interface ContinueCancelModalState {
@ -46,7 +47,8 @@ export class ContinueCancelModal extends React.Component<ContinueCancelModalProp
protected static defaultProps = { protected static defaultProps = {
buttonVariant: 'primary', buttonVariant: 'primary',
modalContinueButtonLabel: 'continue', modalContinueButtonLabel: 'continue',
modalCancelButtonLabel: 'doCancel' modalCancelButtonLabel: 'doCancel',
isDisabled: false
}; };
public constructor(props: ContinueCancelModalProps) { public constructor(props: ContinueCancelModalProps) {
@ -72,7 +74,7 @@ export class ContinueCancelModal extends React.Component<ContinueCancelModalProp
return ( return (
<React.Fragment> <React.Fragment>
<Button variant={this.props.buttonVariant} onClick={this.handleModalToggle}> <Button variant={this.props.buttonVariant} onClick={this.handleModalToggle} isDisabled={this.props.isDisabled}>
<Msg msgKey={this.props.buttonTitle}/> <Msg msgKey={this.props.buttonTitle}/>
</Button> </Button>
<Modal <Modal

View file

@ -22,6 +22,7 @@
isLinkedAccountsEnabled: boolean; isLinkedAccountsEnabled: boolean;
isEventsEnabled: boolean; isEventsEnabled: boolean;
isMyResourcesEnabled: boolean; isMyResourcesEnabled: boolean;
isTotpConfigured: boolean;
} }

View file

@ -10,6 +10,12 @@ var content = [
label: 'Account Security', label: 'Account Security',
content: [ content: [
{ {
path: 'security/signingin',
label: 'signingIn',
modulePath: '/app/content/signingin-page/SigningInPage',
componentName: 'SigningInPage',
},
/* {
path: 'security/password', path: 'security/password',
label: 'password', label: 'password',
modulePath: '/app/content/aia-page/AppInitiatedActionPage', modulePath: '/app/content/aia-page/AppInitiatedActionPage',
@ -22,7 +28,7 @@ var content = [
modulePath: '/app/content/aia-page/AppInitiatedActionPage', modulePath: '/app/content/aia-page/AppInitiatedActionPage',
componentName: 'AppInitiatedActionPage', componentName: 'AppInitiatedActionPage',
kcAction: 'CONFIGURE_TOTP' kcAction: 'CONFIGURE_TOTP'
}, }, */
{ {
path: 'security/device-activity', path: 'security/device-activity',
label: 'device-activity', label: 'device-activity',

View file

@ -120,7 +120,7 @@
'./Radio': '@empty', //'./Radio/index.js', './Radio': '@empty', //'./Radio/index.js',
'./Select': '@empty', //'./Select/index.js', './Select': '@empty', //'./Select/index.js',
'./SkipToContent': '@empty', //'./SkipToContent/index.js', './SkipToContent': '@empty', //'./SkipToContent/index.js',
'./Switch': '@empty', //'./Switch/index.js', './Switch': './Switch/index.js',
'./Tabs': './Tabs/index.js', //'./Tabs/index.js', './Tabs': './Tabs/index.js', //'./Tabs/index.js',
'./Text': '@empty', //'./Text/index.js', './Text': '@empty', //'./Text/index.js',
'./TextArea': '@empty', //'./TextArea/index.js', './TextArea': '@empty', //'./TextArea/index.js',