mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-01-21 19:25:43 +00:00
Add TOTP-based two-factor authentication
- Add 2FA setup, enable, disable, and backup code management - Integrate 2FA challenge flow into login process - Add frontend modal for 2FA configuration - Support backup codes for account recovery
This commit is contained in:
@@ -8,8 +8,77 @@ import { intl, T } from "src/locale";
|
||||
import { validateEmail, validateString } from "src/modules/Validations";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
export default function Login() {
|
||||
const emailRef = useRef(null);
|
||||
function TwoFactorForm() {
|
||||
const codeRef = useRef<HTMLInputElement>(null);
|
||||
const [formErr, setFormErr] = useState("");
|
||||
const { verifyTwoFactor, cancelTwoFactor } = useAuthState();
|
||||
|
||||
const onSubmit = async (values: any, { setSubmitting }: any) => {
|
||||
setFormErr("");
|
||||
try {
|
||||
await verifyTwoFactor(values.code);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setFormErr(err.message);
|
||||
}
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
codeRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="h2 text-center mb-4">
|
||||
<T id="login.2fa-title" />
|
||||
</h2>
|
||||
<p className="text-secondary text-center mb-4">
|
||||
<T id="login.2fa-description" />
|
||||
</p>
|
||||
{formErr !== "" && <Alert variant="danger">{formErr}</Alert>}
|
||||
<Formik initialValues={{ code: "" }} onSubmit={onSubmit}>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<div className="mb-3">
|
||||
<Field name="code" validate={validateString(6, 20)}>
|
||||
{({ field, form }: any) => (
|
||||
<label className="form-label">
|
||||
<T id="login.2fa-code" />
|
||||
<input
|
||||
{...field}
|
||||
ref={codeRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
required
|
||||
maxLength={20}
|
||||
className={`form-control ${form.errors.code && form.touched.code ? "is-invalid" : ""}`}
|
||||
placeholder={intl.formatMessage({ id: "login.2fa-code-placeholder" })}
|
||||
/>
|
||||
<div className="invalid-feedback">{form.errors.code}</div>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="form-footer d-flex gap-2">
|
||||
<Button type="button" fullWidth onClick={cancelTwoFactor} disabled={isSubmitting}>
|
||||
<T id="cancel" />
|
||||
</Button>
|
||||
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
|
||||
<T id="login.2fa-verify" />
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginForm() {
|
||||
const emailRef = useRef<HTMLInputElement>(null);
|
||||
const [formErr, setFormErr] = useState("");
|
||||
const { login } = useAuthState();
|
||||
|
||||
@@ -26,10 +95,79 @@ export default function Login() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
|
||||
emailRef.current.focus();
|
||||
emailRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="h2 text-center mb-4">
|
||||
<T id="login.title" />
|
||||
</h2>
|
||||
{formErr !== "" && <Alert variant="danger">{formErr}</Alert>}
|
||||
<Formik
|
||||
initialValues={
|
||||
{
|
||||
email: "",
|
||||
password: "",
|
||||
} as any
|
||||
}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<div className="mb-3">
|
||||
<Field name="email" validate={validateEmail()}>
|
||||
{({ field, form }: any) => (
|
||||
<label className="form-label">
|
||||
<T id="email-address" />
|
||||
<input
|
||||
{...field}
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
required
|
||||
className={`form-control ${form.errors.email && form.touched.email ? " is-invalid" : ""}`}
|
||||
placeholder={intl.formatMessage({ id: "email-address" })}
|
||||
/>
|
||||
<div className="invalid-feedback">{form.errors.email}</div>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<Field name="password" validate={validateString(8, 255)}>
|
||||
{({ field, form }: any) => (
|
||||
<>
|
||||
<label className="form-label">
|
||||
<T id="password" />
|
||||
<input
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
maxLength={255}
|
||||
className={`form-control ${form.errors.password && form.touched.password ? " is-invalid" : ""}`}
|
||||
placeholder={intl.formatMessage({ id: "password" })}
|
||||
/>
|
||||
<div className="invalid-feedback">{form.errors.password}</div>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="form-footer">
|
||||
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
|
||||
<T id="sign-in" />
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const { twoFactorChallenge } = useAuthState();
|
||||
const health = useHealth();
|
||||
|
||||
const getVersion = () => {
|
||||
@@ -56,68 +194,7 @@ export default function Login() {
|
||||
</div>
|
||||
<div className="card card-md">
|
||||
<div className="card-body">
|
||||
<h2 className="h2 text-center mb-4">
|
||||
<T id="login.title" />
|
||||
</h2>
|
||||
{formErr !== "" && <Alert variant="danger">{formErr}</Alert>}
|
||||
<Formik
|
||||
initialValues={
|
||||
{
|
||||
email: "",
|
||||
password: "",
|
||||
} as any
|
||||
}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<div className="mb-3">
|
||||
<Field name="email" validate={validateEmail()}>
|
||||
{({ field, form }: any) => (
|
||||
<label className="form-label">
|
||||
<T id="email-address" />
|
||||
<input
|
||||
{...field}
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
required
|
||||
className={`form-control ${form.errors.email && form.touched.email ? " is-invalid" : ""}`}
|
||||
placeholder={intl.formatMessage({ id: "email-address" })}
|
||||
/>
|
||||
<div className="invalid-feedback">{form.errors.email}</div>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<Field name="password" validate={validateString(8, 255)}>
|
||||
{({ field, form }: any) => (
|
||||
<>
|
||||
<label className="form-label">
|
||||
<T id="password" />
|
||||
<input
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
maxLength={255}
|
||||
className={`form-control ${form.errors.password && form.touched.password ? " is-invalid" : ""}`}
|
||||
placeholder={intl.formatMessage({ id: "password" })}
|
||||
/>
|
||||
<div className="invalid-feedback">{form.errors.password}</div>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="form-footer">
|
||||
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
|
||||
<T id="sign-in" />
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
{twoFactorChallenge ? <TwoFactorForm /> : <LoginForm />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center text-secondary mt-3">{getVersion()}</div>
|
||||
|
||||
Reference in New Issue
Block a user