KEYCLOAK-11550: Single page for credentials (initial commit)
This commit is contained in:
parent
685d49c693
commit
de6f90b43b
12 changed files with 289 additions and 34 deletions
|
@ -115,6 +115,12 @@ public class AccountConsole {
|
|||
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);
|
||||
Response.ResponseBuilder builder = Response.status(Response.Status.OK).type(MediaType.TEXT_HTML_UTF_8).language(Locale.ENGLISH).entity(result);
|
||||
|
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
@ -398,6 +399,17 @@ public class AccountRestService {
|
|||
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
|
||||
* the client with the given client id. Returns the appropriate REST response.
|
||||
|
|
|
@ -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 @@
|
|||
<h6>${msg("accountSecurityIntroMessage")}</h6>
|
||||
</div>
|
||||
<div class="pf-c-card__body pf-c-content">
|
||||
<h5 id="changePasswordLink"><a href="#/app/security/password">${msg("changePasswordHtmlTitle")}</a></h5>
|
||||
<h5 id="authenticatorLink"><a href="#/app/security/authenticator">${msg("authenticatorTitle")}</a></h5>
|
||||
<h5 id="signingInLink"><a href="#/app/security/signingin">${msg("signingIn")}</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>
|
||||
</div>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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<AppInitiatedActionP
|
|||
}
|
||||
|
||||
private handleClick = (): 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.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 {
|
||||
|
|
|
@ -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};
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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<ContinueCancelModalProp
|
|||
protected static defaultProps = {
|
||||
buttonVariant: 'primary',
|
||||
modalContinueButtonLabel: 'continue',
|
||||
modalCancelButtonLabel: 'doCancel'
|
||||
modalCancelButtonLabel: 'doCancel',
|
||||
isDisabled: false
|
||||
};
|
||||
|
||||
public constructor(props: ContinueCancelModalProps) {
|
||||
|
@ -72,7 +74,7 @@ export class ContinueCancelModal extends React.Component<ContinueCancelModalProp
|
|||
|
||||
return (
|
||||
<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}/>
|
||||
</Button>
|
||||
<Modal
|
||||
|
|
|
@ -22,6 +22,7 @@
|
|||
isLinkedAccountsEnabled: boolean;
|
||||
isEventsEnabled: boolean;
|
||||
isMyResourcesEnabled: boolean;
|
||||
isTotpConfigured: boolean;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -10,6 +10,12 @@ var content = [
|
|||
label: 'Account Security',
|
||||
content: [
|
||||
{
|
||||
path: 'security/signingin',
|
||||
label: 'signingIn',
|
||||
modulePath: '/app/content/signingin-page/SigningInPage',
|
||||
componentName: 'SigningInPage',
|
||||
},
|
||||
/* {
|
||||
path: 'security/password',
|
||||
label: 'password',
|
||||
modulePath: '/app/content/aia-page/AppInitiatedActionPage',
|
||||
|
@ -22,7 +28,7 @@ var content = [
|
|||
modulePath: '/app/content/aia-page/AppInitiatedActionPage',
|
||||
componentName: 'AppInitiatedActionPage',
|
||||
kcAction: 'CONFIGURE_TOTP'
|
||||
},
|
||||
}, */
|
||||
{
|
||||
path: 'security/device-activity',
|
||||
label: 'device-activity',
|
||||
|
|
|
@ -120,7 +120,7 @@
|
|||
'./Radio': '@empty', //'./Radio/index.js',
|
||||
'./Select': '@empty', //'./Select/index.js',
|
||||
'./SkipToContent': '@empty', //'./SkipToContent/index.js',
|
||||
'./Switch': '@empty', //'./Switch/index.js',
|
||||
'./Switch': './Switch/index.js',
|
||||
'./Tabs': './Tabs/index.js', //'./Tabs/index.js',
|
||||
'./Text': '@empty', //'./Text/index.js',
|
||||
'./TextArea': '@empty', //'./TextArea/index.js',
|
||||
|
|
Loading…
Reference in a new issue