Skip to content

Commit

Permalink
Implement 'Sync OTP token' page
Browse files Browse the repository at this point in the history
The 'sync OTP Token' link must redirect to
a page to allow syncing the OTP token keys
as in the current WebUI.

Signed-off-by: Carla Martinez <[email protected]>
  • Loading branch information
carma12 committed Oct 16, 2024
1 parent 95d232e commit 0226ad1
Show file tree
Hide file tree
Showing 4 changed files with 274 additions and 9 deletions.
28 changes: 19 additions & 9 deletions src/login/LoginMainPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,34 @@ import {
import { useAppDispatch } from "src/store/hooks";
import { setIsLogin } from "src/store/Global/auth-slice";
// Navigation
import { useNavigate } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
import { Link } from "react-router-dom";

interface StateFromSyncOtpPage {
alertMessage: string;
}

const LoginMainPage = () => {
// Redux
const dispatch = useAppDispatch();

// Navigate
const navigate = useNavigate();
const location = useLocation();

// Alerts to show in the UI
const alerts = useAlerts();

// There are some cases (e.g., sync OTP token) that the
// login page can receive a given state from navigate.
// This message must be shown as an alert.
React.useEffect(() => {
if (location.state) {
const { alertMessage } = location.state as StateFromSyncOtpPage;
alerts.addAlert("sync-otp-message", alertMessage, "success");
}
}, [location.state]);

const [showHelperText, setShowHelperText] = React.useState(false);
const [username, setUsername] = React.useState("");
const [isValidUsername, setIsValidUsername] = React.useState(true);
Expand Down Expand Up @@ -284,14 +299,9 @@ const LoginMainPage = () => {
<Text>Login using Certificate</Text>
</TextContent>
</LoginMainFooterLinksItem>
<LoginMainFooterLinksItem
href="#"
linkComponentProps={{ "aria-label": "Syncronize OTP Token" }}
>
<TextContent name="sync">
<Text>Sync OTP Token</Text>
</TextContent>
</LoginMainFooterLinksItem>
<Link aria-label="Synchronize otp token" to="/sync-otp">
Sync OTP Token
</Link>
</React.Fragment>
);

Expand Down
210 changes: 210 additions & 0 deletions src/login/SyncOtpPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import React from "react";
// PatternFly
import {
Form,
FormGroup,
TextInput,
ActionGroup,
Button,
LoginPage,
ListVariant,
} from "@patternfly/react-core";
// Images
import BrandImg from "src/assets/images/product-name.png";
import BackgroundImg from "src/assets/images/login-screen-background.jpg";
// Hooks
import useAlerts from "src/hooks/useAlerts";
// RPC
import {
MetaResponse,
SyncOtpPayload,
useSyncOtpMutation,
} from "src/services/rpcAuth";
// React router DOM
import { useNavigate } from "react-router-dom";
// Components
import PasswordInput from "src/components/layouts/PasswordInput";

const SyncOtpPage = () => {
// Navigate
const navigate = useNavigate();

// Alerts to show in the UI
const alerts = useAlerts();

// API calls
const [syncOtpToken] = useSyncOtpMutation();

// Main states
const [uid, setUid] = React.useState<string>("");
const [password, setPassword] = React.useState<string>("");
const [firstOtp, setFirstOtp] = React.useState<string>("");
const [secondOtp, setSecondOtp] = React.useState<string>("");
const [tokenId, setTokenId] = React.useState<string>("");
const [btnSpinning, setBtnSpinning] = React.useState<boolean>(false);

// Visibility of fields
const [passwordHidden, setPasswordHidden] = React.useState<boolean>(true);
const [newFirstOtpHidden, setFirstOtpHidden] = React.useState<boolean>(true);
const [secondOtpHidden, setSecondOtpHidden] = React.useState<boolean>(true);

// Sync button should be disabled if some conditions are met
const evaluateSyncButtonDisabled = () => {
if (
uid === undefined ||
password === "" ||
firstOtp === "" ||
secondOtp === ""
) {
return true;
} else {
return false;
}
};

const isSyncButtonDisabled = evaluateSyncButtonDisabled();

// Clear fields when the sync operation failed
const clearFields = () => {
setUid("");
setPassword("");
setFirstOtp("");
setSecondOtp("");
setTokenId("");
};

// Sync API call
const onSyncOtp = () => {
const payload: SyncOtpPayload = {
user: uid,
password: password,
first_code: firstOtp,
second_code: secondOtp,
token: tokenId,
};

setBtnSpinning(true);

syncOtpToken(payload).then((response) => {
if ("error" in response) {
const receivedError = response.error as MetaResponse;
const reason = receivedError.response?.headers.get(
"x-ipa-tokensync-result"
);

if (reason === "invalid-credentials") {
alerts.addAlert(
"sync-otp-error",
"Token sync rejected. The username, password or token codes are not correct.",
"danger"
);
clearFields();
} else if (reason === "ok") {
alerts.addAlert(
"sync-otp-success",
"OTP token synced successfully",
"success"
);
navigate("/login", {
replace: true,
state: {
alertMessage: "OTP token synced successfully",
},
});
}
setBtnSpinning(false);
}
});
};

// Form fields
const formFields = (
<Form isHorizontal>
<FormGroup label="Username" fieldId="username" required>
<TextInput
id="username"
name="user"
type="text"
value={uid}
onChange={(_ev, newUid) => setUid(newUid)}
isRequired={true}
/>
</FormGroup>
<FormGroup label="Password" fieldId="password" required>
<PasswordInput
id="form-password"
name="password"
value={password}
onChange={setPassword}
onRevealHandler={setPasswordHidden}
passwordHidden={passwordHidden}
isRequired={true}
/>
</FormGroup>
<FormGroup label="First OTP" fieldId="firstotp" required>
<PasswordInput
id="form-first-otp"
name="first_code"
value={firstOtp}
onChange={setFirstOtp}
onRevealHandler={setFirstOtpHidden}
passwordHidden={newFirstOtpHidden}
isRequired={true}
/>
</FormGroup>
<FormGroup label="Second OTP" fieldId="secondotp" required>
<PasswordInput
id="form-second-otp"
name="second_code"
value={secondOtp}
onChange={setSecondOtp}
onRevealHandler={setSecondOtpHidden}
passwordHidden={secondOtpHidden}
isRequired={true}
/>
</FormGroup>
<FormGroup label="Token ID" fieldId="tokenid">
<TextInput
id="form-token-id"
name="token"
value={tokenId}
onChange={(_ev, newToken) => setTokenId(newToken)}
/>
</FormGroup>
<ActionGroup>
<Button variant="link" onClick={() => navigate(-1)}>
Cancel
</Button>
<Button
variant="primary"
isDisabled={isSyncButtonDisabled || btnSpinning}
onClick={onSyncOtp}
isLoading={btnSpinning}
>
{btnSpinning ? "Syncing OTP token " : "Sync OTP token"}
</Button>
</ActionGroup>
</Form>
);

return (
<>
<alerts.ManagedAlerts />
<LoginPage
style={{ whiteSpace: "pre-line" }}
footerListVariants={ListVariant.inline}
brandImgSrc={BrandImg}
brandImgAlt="FreeIPA logo"
backgroundImgSrc={BackgroundImg}
textContent={
"OTP (One-Time Password): Generate new OTP code for each OTP field."
}
loginTitle="Sync OTP token"
>
{formFields}
</LoginPage>
</>
);
};

export default SyncOtpPage;
3 changes: 3 additions & 0 deletions src/navigation/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import HBACServiceGroupsTabs from "src/pages/HBACServiceGroups/HBACServiceGroups
import ResetPasswordPage from "src/login/ResetPasswordPage";
import SetupBrowserConfig from "src/pages/SetupBrowserConfig";
import Configuration from "src/pages/Configuration/Configuration";
import SyncOtpPage from "src/login/SyncOtpPage";

// Renders routes (React)
export const AppRoutes = ({ isInitialDataLoaded }): React.ReactElement => {
Expand Down Expand Up @@ -391,6 +392,8 @@ export const AppRoutes = ({ isInitialDataLoaded }): React.ReactElement => {
)}
{/* Browser configuration page */}
<Route path="browser-config" element={<SetupBrowserConfig />} />
{/* Sync OTP token page */}
<Route path="sync-otp" element={<SyncOtpPage />} />
{/* 404 page */}
<Route path="*" element={<NotFound />} />
</Routes>
Expand Down
42 changes: 42 additions & 0 deletions src/services/rpcAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,20 @@ export interface MetaResponse {
};
}

export interface SyncOtpPayload {
user: string;
password: string;
first_code: string;
second_code: string;
token?: string;
}

// List of URLs
export const LOGIN_URL = "/ipa/session/login_password";
export const KERBEROS_URL = "/ipa/session/login_kerberos";
export const X509_URL = "/ipa/session/login_x509";
export const RESET_PASSWORD_URL = "/ipa/session/change_password";
export const SYNC_OTP_URL = "/ipa/session/sync_token";

// Utils
export const encodeURIObject = (obj: Record<string, string>) => {
Expand Down Expand Up @@ -200,6 +209,38 @@ const extendedApi = api.injectEndpoints({
return meta as unknown as MetaResponse;
},
}),
syncOtp: build.mutation<FindRPCResponse | MetaResponse, SyncOtpPayload>({
query: (payload) => {
const encodedCredentials = encodeURIObject({
user: payload.user,
password: payload.password,
first_code: payload.first_code,
second_code: payload.second_code,
});

if (payload.token) {
encodedCredentials.concat("&token=" + payload.token);
}

const syncOtpRequest = {
url: SYNC_OTP_URL,
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Data-Type": "html",
},
body: encodedCredentials,
};

return syncOtpRequest;
},
transformErrorResponse: (
response: FetchBaseQueryError,
meta: FetchBaseQueryMeta
) => {
return meta as unknown as MetaResponse;
},
}),
}),
});

Expand All @@ -209,4 +250,5 @@ export const {
useKrbLoginMutation,
useX509LoginMutation,
useResetPasswordMutation,
useSyncOtpMutation,
} = extendedApi;

0 comments on commit 0226ad1

Please sign in to comment.