KEYCLOAK-11550: Signing In page

This commit is contained in:
Stan Silvert 2020-01-22 16:41:41 -05:00
parent 812b69af13
commit 210fd92d23
9 changed files with 401 additions and 139 deletions

View file

@ -57,6 +57,145 @@ public class AccountCredentialResource {
// models.forEach(c -> c.setSecretData(null));
// return models.stream().map(ModelToRepresentation::toRepresentation).collect(Collectors.toList());
// }
private static class CredentialContainer {
// ** These first three attributes can be ordinary UI text or a key into
// a localized message bundle. Typically, it will be a key, but
// the UI will work just fine if you don't care about localization
// and you want to just send UI text.
//
// Also, the ${} shown in Apicurio is not needed.
private String category; // **
private String type; // **
private String helptext; // **
private boolean enabled;
private String createAction;
private String updateAction;
private boolean removeable;
private List<CredentialModel> userCredentials;
public CredentialContainer(String category, String type, String helptext, boolean enabled, String createAction, String updateAction, boolean removeable,List<CredentialModel> userCredentials) {
this.category = category;
this.type = type;
this.helptext = helptext;
this.enabled = enabled;
this.createAction = createAction;
this.updateAction = updateAction;
this.removeable = removeable;
this.userCredentials = userCredentials;
}
public String getCategory() {
return category;
}
public String getType() {
return type;
}
public String getHelptext() {
return helptext;
}
public boolean isEnabled() {
return enabled;
}
public String getCreateAction() {
return createAction;
}
public String getUpdateAction() {
return updateAction;
}
public boolean isRemoveable() {
return removeable;
}
public List<CredentialModel> getUserCredentials() {
return userCredentials;
}
}
@GET
@NoCache
@Produces(javax.ws.rs.core.MediaType.APPLICATION_JSON)
public List<CredentialContainer> dummyCredentialTypes(){
auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE);
List<CredentialModel> models = session.userCredentialManager().getStoredCredentials(realm, user);
List<CredentialModel> passwordUserCreds = new java.util.ArrayList<>();
passwordUserCreds.add(models.get(0));
List<CredentialModel> otpUserCreds = new java.util.ArrayList<>();
if (models.size() > 1) otpUserCreds.add(models.get(1));
if (models.size() > 2) otpUserCreds.add(models.get(2));
List<CredentialModel> webauthnUserCreds = new java.util.ArrayList<>();
CredentialModel webauthnCred = new CredentialModel();
webauthnCred.setId("bogus-id");
webauthnCred.setUserLabel("yubikey key");
webauthnCred.setCreatedDate(1579122652382L);
webauthnUserCreds.add(webauthnCred);
List<CredentialModel> webauthnStrongUserCreds = new java.util.ArrayList<>();
CredentialModel webauthnStrongCred = new CredentialModel();
webauthnStrongCred.setId("bogus-id-for-webauthnStrong");
webauthnStrongCred.setUserLabel("My very strong key with required PIN");
webauthnStrongCred.setCreatedDate(1579122652382L);
webauthnUserCreds.add(webauthnStrongCred);
CredentialContainer password = new CredentialContainer(
"password",
"password",
"passwordHelptext",
true,
null, // no create action
"UPDATE_PASSWORD",
false,
passwordUserCreds
);
CredentialContainer otp = new CredentialContainer(
"two-factor",
"otp",
"otpHelptext",
true,
"CONFIGURE_TOTP",
null, // no update action
true,
otpUserCreds
);
CredentialContainer webAuthn = new CredentialContainer(
"two-factor",
"webauthn",
"webauthnHelptext",
true,
"CONFIGURE_WEBAUTHN",
null, // no update action
true,
webauthnUserCreds
);
CredentialContainer passwordless = new CredentialContainer(
"passwordless",
"webauthn-passwordless",
"webauthn-passwordlessHelptext",
true,
"CONFIGURE_WEBAUTHN_STRONG",
null, // no update action
true,
webauthnStrongUserCreds
);
List<CredentialContainer> dummyCreds = new java.util.ArrayList<>();
dummyCreds.add(password);
dummyCreds.add(otp);
dummyCreds.add(webAuthn);
dummyCreds.add(passwordless);
return dummyCreds;
}
//
//
// @GET
@ -72,17 +211,17 @@ public class AccountCredentialResource {
// .collect(Collectors.toList());
// }
//
// /**
// * Remove a credential for a user
// *
// */
// @Path("{credentialId}")
// @DELETE
// @NoCache
// public void removeCredential(final @PathParam("credentialId") String credentialId) {
// auth.require(AccountRoles.MANAGE_ACCOUNT);
// session.userCredentialManager().removeStoredCredential(realm, user, credentialId);
// }
/**
* Remove a credential for a user
*
*/
@Path("{credentialId}")
@DELETE
@NoCache
public void removeCredential(final @PathParam("credentialId") String credentialId) {
auth.require(AccountRoles.MANAGE_ACCOUNT);
session.userCredentialManager().removeStoredCredential(realm, user, credentialId);
}
//
// /**
// * Update a credential label for a user

View file

@ -59,16 +59,21 @@ 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
credentialCreatedAt=Created
successRemovedMessage={0} was removed.
stopUsingCred=Stop using {0}?
removeCred=Remove {0}
setUpNew=Set up {0}
notSetUp={0} is not set up.
two-factor=Two-Factor Authentication
passwordless=Passwordless
lastUpdate=Last Update
unknown=Unknown
otp=One Time Password
webauthn=WebAuthn
webauthn-passwordless=WebAuthn Passwordless
otpHelptext=A one-time password (OTP), is a password that is valid for only one login session.
webauthnHelptext=WebAuthn lets web applications authenticate users without storing their passwords on servers.
webauthn-passwordlessHelptext=I need help with this help text. Any suggestions?
# Applications page
applicationsPageTitle=Applications

View file

@ -104,7 +104,7 @@ export class AccountServiceClient {
}
private makeConfig(endpoint: string, config: AxiosRequestConfig = {}): Promise<AxiosRequestConfig> {
return new Promise( (resolve: ConfigResolve, reject: ErrorReject) => {
return new Promise( (resolve: ConfigResolve) => {
this.kcSvc.getToken()
.then( (token: string) => {
resolve( {

View file

@ -39,38 +39,38 @@ export class ContentAlert extends React.Component<ContentAlertProps, ContentAler
/**
* @param message A literal text message or localization key.
*/
public static success(message: string): void {
ContentAlert.instance.postAlert(message, 'success');
public static success(message: string, params?: string[]): void {
ContentAlert.instance.postAlert('success', message, params);
}
/**
* @param message A literal text message or localization key.
*/
public static danger(message: string): void {
ContentAlert.instance.postAlert(message, 'danger');
public static danger(message: string, params?: string[]): void {
ContentAlert.instance.postAlert('danger', message, params);
}
/**
* @param message A literal text message or localization key.
*/
public static warning(message: string): void {
ContentAlert.instance.postAlert(message, 'warning');
public static warning(message: string, params?: string[]): void {
ContentAlert.instance.postAlert('warning', message, params);
}
/**
* @param message A literal text message or localization key.
*/
public static info(message: string): void {
ContentAlert.instance.postAlert(message, 'info');
public static info(message: string, params?: string[]): void {
ContentAlert.instance.postAlert('info', message, params);
}
private hideAlert = () => {
this.setState({isVisible: false});
}
private postAlert = (message: string, variant: AlertVariant) => {
private postAlert = (variant: AlertVariant, message: string, params?: string[]) => {
this.setState({isVisible: true,
message: Msg.localize(message),
message: Msg.localize(message, params),
variant});
if (variant !== 'danger') {

View file

@ -29,7 +29,7 @@ import {
GridItem,
} from '@patternfly/react-core';
import { InfoAltIcon, CheckIcon, LinkIcon, BuilderImageIcon } from '@patternfly/react-icons';
import { InfoAltIcon, CheckIcon, BuilderImageIcon } from '@patternfly/react-icons';
import { ContentPage } from '../ContentPage';
import { ContinueCancelModal } from '../../widgets/ContinueCancelModal';
import { AccountServiceClient } from '../../account-service/account.service';
@ -110,6 +110,8 @@ export class ApplicationsPage extends React.Component<ApplicationsPageProps, App
<ContentPage title={Msg.localize('applicationsPageTitle')}>
<DataList id="applications-list" aria-label={Msg.localize('applicationsPageTitle')}>
{this.state.applications.map((application: Application, appIndex: number) => {
const appUrl: string = application.userConsentRequired ? application.baseUrl : '/auth' + application.baseUrl;
return (
<DataListItem key={'application-' + appIndex} aria-labelledby="applications-list" isExpanded={this.state.isRowOpen[appIndex]}>
<DataListItemRow>
@ -132,7 +134,10 @@ export class ApplicationsPage extends React.Component<ApplicationsPageProps, App
{application.inUse ? Msg.localize('inUse') : Msg.localize('notInUse')}
</DataListCell>,
<DataListCell width={4} key={'baseUrl-' + appIndex}>
<a href={application.userConsentRequired ? application.baseUrl : '/auth' + application.baseUrl} target="_blank"><LinkIcon /> {application.baseUrl}</a>
<button className="pf-c-button pf-m-link" type="button" onClick={() => window.open(appUrl)}>
<span className="pf-c-button__icon">
<i className="fas fa-link" aria-hidden="true"></i>
</span>{application.baseUrl}</button>
</DataListCell>,
]}
/>

View file

@ -158,7 +158,7 @@ export class DeviceActivityPage extends React.Component<DeviceActivityPageProps,
return moment(time * 1000).format('LLLL');
}
private elementId(item: string, session: Session) : string {
private elementId(item: string, session: Session): string {
return `session-${session.id.substring(0,7)}-${item}`;
}

View file

@ -25,12 +25,12 @@ import {
DataListAction,
DataListItemCells,
DataListCell,
DataListItem,
DataListItemRow,
Stack,
StackItem,
Switch,
Title,
TitleLevel
TitleLevel,
} from '@patternfly/react-core';
import {AIACommand} from '../../util/AIACommand';
@ -48,14 +48,36 @@ interface PasswordDetails {
lastUpdate: number;
}
type CredCategory = 'password' | 'two-factor' | 'passwordless';
type CredType = string;
type CredTypeMap = Map<CredType, CredentialContainer>;
type CredContainerMap = Map<CredCategory, CredTypeMap>;
interface UserCredential {
id: string;
type: string;
userLabel: string;
createdDate: number;
strCreatedDate?: string;
}
// A CredentialContainer is unique by combo of credential type and credential category
interface CredentialContainer {
category: CredCategory;
type: CredType;
helptext?: string;
createAction: string;
updateAction: string;
removeable: boolean;
userCredentials: UserCredential[];
}
interface SigningInPageProps extends RouteComponentProps {
}
interface SigningInPageState {
twoFactorEnabled: boolean;
twoFactorEnabledText: string;
isTotpConfigured: boolean;
lastPasswordUpdate?: number;
// Credential containers organized by category then type
credentialContainers: CredContainerMap;
}
/**
@ -68,116 +90,205 @@ class SigningInPage extends React.Component<SigningInPageProps, SigningInPageSta
public constructor(props: SigningInPageProps) {
super(props);
this.state = {
twoFactorEnabled: true,
twoFactorEnabledText: Msg.localize('twoFactorEnabled'),
isTotpConfigured: features.isTotpConfigured,
}
this.setLastPwdUpdate();
credentialContainers: new Map()
}
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});
this.getCredentialContainers();
}
private getCredentialContainers(): void {
AccountServiceClient.Instance.doGet("/credentials")
.then((response: AxiosResponse<CredentialContainer[]>) => {
const allContainers: CredContainerMap = new Map();
response.data.forEach(container => {
let categoryMap = allContainers.get(container.category);
if (!categoryMap) {
categoryMap = new Map();
allContainers.set(container.category, categoryMap);
}
categoryMap.set(container.type, container);
});
this.setState({credentialContainers: allContainers});
console.log({allContainers})
});
}
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")
private handleRemove = (credentialId: string, userLabel: string) => {
AccountServiceClient.Instance.doDelete("/credentials/" + credentialId)
.then(() => {
this.setState({isTotpConfigured: false});
ContentAlert.success('successTotpRemovedMessage');
this.getCredentialContainers();
ContentAlert.success('successRemovedMessage', [userLabel]);
});
}
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>
{this.renderCategories()}
</Stack>
</ContentPage>
);
}
private renderCategories(): React.ReactNode {
return (<> {
Array.from(this.state.credentialContainers.keys()).map(category => (
<StackItem key={category} isFilled>
<Title headingLevel={TitleLevel.h2} size='2xl'>
<strong><Msg msgKey={category}/></strong>
</Title>
<DataList aria-label='foo'>
{this.renderTypes(this.state.credentialContainers.get(category)!)}
</DataList>
</StackItem>
))
}</>)
}
private renderTypes(credTypeMap: CredTypeMap): React.ReactNode {
return (<> {
Array.from(credTypeMap.keys()).map((credType: CredType, index: number, typeArray: string[]) => ([
this.renderCredTypeTitle(credTypeMap.get(credType)!),
this.renderUserCredentials(credTypeMap, credType),
this.renderEmptyRow(credTypeMap.get(credType)!.type, index === typeArray.length - 1)
]))
}</>)
}
private renderEmptyRow(type: string, isLast: boolean): React.ReactNode {
if (isLast) return; // don't put empty row at the end
return (
<DataListItem aria-labelledby={'empty-list-item-' + type}>
<DataListItemRow key={'empty-row-' + type}>
<DataListItemCells dataListCells={[<DataListCell></DataListCell>]}/>
</DataListItemRow>
</DataListItem>
)
}
private renderUserCredentials(credTypeMap: CredTypeMap, credType: CredType): React.ReactNode {
const userCredentials: UserCredential[] = credTypeMap.get(credType)!.userCredentials;
const removeable: boolean = credTypeMap.get(credType)!.removeable;
const updateAction: string = credTypeMap.get(credType)!.updateAction;
const type: string = credTypeMap.get(credType)!.type;
if (userCredentials.length === 0) {
const localizedType = Msg.localize(type);
return (
<DataListItem aria-labelledby='no-credentials-list-item'>
<DataListItemRow key='no-credentials-list-item-row'>
<DataListItemCells
dataListCells={[
<DataListCell/>,
<strong><Msg msgKey='notSetUp' params={[localizedType]}/></strong>,
<DataListCell/>
]}/>
</DataListItemRow>
</DataListItem>
);
}
userCredentials.forEach(credential => {
if (!credential.userLabel) credential.userLabel = Msg.localize(credential.type);
credential.strCreatedDate = moment(credential.createdDate).format('LLL');
});
let updateAIA: AIACommand;
if (updateAction) updateAIA = new AIACommand(updateAction, this.props.location.pathname);
return (
<React.Fragment key='userCredentials'> {
userCredentials.map(credential => (
<DataListItem aria-labelledby={'credential-list-item-' + credential.userLabel}>
<DataListItemRow key={'userCredentialRow-' + credential.id}>
<DataListItemCells
dataListCells={[
<DataListCell key={'userLabel-' + credential.id}>{credential.userLabel}</DataListCell>,
<DataListCell key={'created-' + credential.id}><strong><Msg msgKey='credentialCreatedAt'/>: </strong>{credential.strCreatedDate}</DataListCell>,
<DataListCell key={'spacer-' + credential.id}/>
]}/>
<CredentialAction credential={credential}
removeable={removeable}
updateAction={updateAIA}
credRemover={this.handleRemove}/>
</DataListItemRow>
</DataListItem>
))
}
</React.Fragment>)
}
private renderCredTypeTitle(credContainer: CredentialContainer): React.ReactNode {
if (!credContainer.createAction) return;
const setupAction: AIACommand = new AIACommand(credContainer.createAction, this.props.location.pathname);
const credContainerType: string = Msg.localize(credContainer.type);
return (
<DataListItem aria-labelledby={'type-datalistitem-' + credContainer.type}>
<DataListItemRow key={'credTitleRow-' + credContainer.type}>
<DataListItemCells
dataListCells={[
<DataListCell width={5} key={'credTypeTitle-' + credContainer.type}>
<Title headingLevel={TitleLevel.h3} size='2xl'>
<strong><Msg msgKey={credContainer.type}/></strong>
</Title>
<Msg msgKey={credContainer.helptext}/>
</DataListCell>,
]}/>
<DataListAction aria-labelledby='foo' aria-label='foo action' id={'setUpAction-' + credContainer.type}>
<button className="pf-c-button pf-m-link" type="button" onClick={()=> setupAction.execute()}>
<span className="pf-c-button__icon">
<i className="fas fa-plus-circle" aria-hidden="true"></i>
</span>
<Msg msgKey='setUpNew' params={[credContainerType]}/>
</button>
</DataListAction>
</DataListItemRow>
</DataListItem>
)
}
};
type CredRemover = (credentialId: string, userLabel: string) => void;
interface CredentialActionProps {credential: UserCredential;
removeable: boolean;
updateAction: AIACommand;
credRemover: CredRemover;};
class CredentialAction extends React.Component<CredentialActionProps> {
render(): React.ReactNode {
if (this.props.updateAction) {
return (
<DataListAction aria-labelledby='foo' aria-label='foo action' id={'updateAction-' + this.props.credential.id}>
<Button variant='primary'onClick={()=> this.props.updateAction.execute()}><Msg msgKey='update'/></Button>
</DataListAction>
)
}
if (this.props.removeable) {
const userLabel: string = this.props.credential.userLabel;
return (
<DataListAction aria-labelledby='foo' aria-label='foo action' id={'removeAction-' + this.props.credential.id }>
<ContinueCancelModal buttonTitle='remove'
modalTitle={Msg.localize('removeCred', [userLabel])}
modalMessage={Msg.localize('stopUsingCred', [userLabel])}
onContinue={() => this.props.credRemover(this.props.credential.id, userLabel)}
/>
</DataListAction>
)
}
}
}
const SigningInPageWithRouter = withRouter(SigningInPage);
export { SigningInPageWithRouter as SigningInPage};

View file

@ -19,7 +19,7 @@ import * as React from 'react';
declare const l18nMsg: {[key: string]: string};
export interface MsgProps {
readonly msgKey: string;
readonly msgKey: string | undefined;
readonly params?: string[];
}
@ -35,7 +35,9 @@ export class Msg extends React.Component<MsgProps> {
);
}
public static localize(msgKey: string, params?: string[]): string {
public static localize(msgKey: string | undefined, params?: string[]): string {
if (msgKey === undefined) return '';
let message: string = l18nMsg[this.processKey(msgKey)];
if (message === undefined) message = msgKey;

View file

@ -1333,7 +1333,7 @@
'./icons/playstation-icon.js': '@empty',
'./icons/plug-icon.js': '@empty',
'./icons/plugged-icon.js': '@empty',
'./icons/plus-circle-icon.js': '@empty',
//'./icons/plus-circle-icon.js': '@empty',
'./icons/plus-icon.js': '@empty',
'./icons/plus-square-icon.js': '@empty',
'./icons/podcast-icon.js': '@empty',