[web] Auth cleanup - Part 1/x (#1820)
In preparation for adding steam support
This commit is contained in:
commit
a7e96d055c
22 changed files with 621 additions and 664 deletions
|
@ -1,23 +0,0 @@
|
|||
import { Button } from "@mui/material";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const AuthFooter = () => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<p>{t("AUTH_DOWNLOAD_MOBILE_APP")}</p>
|
||||
<a
|
||||
href="https://github.com/ente-io/ente/tree/main/auth#-download"
|
||||
download
|
||||
>
|
||||
<Button color="accent">{t("DOWNLOAD")}</Button>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,35 +0,0 @@
|
|||
import { HorizontalFlex } from "@ente/shared/components/Container";
|
||||
import { EnteLogo } from "@ente/shared/components/EnteLogo";
|
||||
import NavbarBase from "@ente/shared/components/Navbar/base";
|
||||
import OverflowMenu from "@ente/shared/components/OverflowMenu/menu";
|
||||
import { OverflowMenuOption } from "@ente/shared/components/OverflowMenu/option";
|
||||
import LogoutOutlined from "@mui/icons-material/LogoutOutlined";
|
||||
import MoreHoriz from "@mui/icons-material/MoreHoriz";
|
||||
import { t } from "i18next";
|
||||
import { AppContext } from "pages/_app";
|
||||
import React from "react";
|
||||
|
||||
export default function AuthNavbar() {
|
||||
const { isMobile, logout } = React.useContext(AppContext);
|
||||
return (
|
||||
<NavbarBase isMobile={isMobile}>
|
||||
<HorizontalFlex flex={1} justifyContent={"center"}>
|
||||
<EnteLogo />
|
||||
</HorizontalFlex>
|
||||
<HorizontalFlex position={"absolute"} right="24px">
|
||||
<OverflowMenu
|
||||
ariaControls={"auth-options"}
|
||||
triggerButtonIcon={<MoreHoriz />}
|
||||
>
|
||||
<OverflowMenuOption
|
||||
color="critical"
|
||||
startIcon={<LogoutOutlined />}
|
||||
onClick={logout}
|
||||
>
|
||||
{t("LOGOUT")}
|
||||
</OverflowMenuOption>
|
||||
</OverflowMenu>
|
||||
</HorizontalFlex>
|
||||
</NavbarBase>
|
||||
);
|
||||
}
|
|
@ -1,237 +0,0 @@
|
|||
import { ButtonBase, Snackbar } from "@mui/material";
|
||||
import { t } from "i18next";
|
||||
import { HOTP, TOTP } from "otpauth";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Code } from "types/code";
|
||||
import TimerProgress from "./TimerProgress";
|
||||
|
||||
const TOTPDisplay = ({ issuer, account, code, nextCode, period }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "rgba(40, 40, 40, 0.6)",
|
||||
borderRadius: "4px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<TimerProgress period={period ?? Code.defaultPeriod} />
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 20px 0px 20px",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
minWidth: "320px",
|
||||
minHeight: "120px",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
minWidth: "200px",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
margin: "0px",
|
||||
fontSize: "14px",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{issuer}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
marginTop: "0px",
|
||||
marginBottom: "8px",
|
||||
textAlign: "left",
|
||||
fontSize: "12px",
|
||||
maxWidth: "200px",
|
||||
minHeight: "16px",
|
||||
color: "grey",
|
||||
}}
|
||||
>
|
||||
{account}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
margin: "0px",
|
||||
marginBottom: "1rem",
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-end",
|
||||
minWidth: "120px",
|
||||
textAlign: "right",
|
||||
marginTop: "auto",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
marginBottom: "0px",
|
||||
fontSize: "10px",
|
||||
marginTop: "auto",
|
||||
textAlign: "right",
|
||||
color: "grey",
|
||||
}}
|
||||
>
|
||||
{t("AUTH_NEXT")}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "0px",
|
||||
marginTop: "auto",
|
||||
textAlign: "right",
|
||||
color: "grey",
|
||||
}}
|
||||
>
|
||||
{nextCode}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function BadCodeInfo({ codeInfo, codeErr }) {
|
||||
const [showRawData, setShowRawData] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="code-info">
|
||||
<div>{codeInfo.title}</div>
|
||||
<div>{codeErr}</div>
|
||||
<div>
|
||||
{showRawData ? (
|
||||
<div onClick={() => setShowRawData(false)}>
|
||||
{codeInfo.rawData ?? "no raw data"}
|
||||
</div>
|
||||
) : (
|
||||
<div onClick={() => setShowRawData(true)}>Show rawData</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OTPDisplayProps {
|
||||
codeInfo: Code;
|
||||
}
|
||||
|
||||
const OTPDisplay = (props: OTPDisplayProps) => {
|
||||
const { codeInfo } = props;
|
||||
const [code, setCode] = useState("");
|
||||
const [nextCode, setNextCode] = useState("");
|
||||
const [codeErr, setCodeErr] = useState("");
|
||||
const [hasCopied, setHasCopied] = useState(false);
|
||||
|
||||
const generateCodes = () => {
|
||||
try {
|
||||
const currentTime = new Date().getTime();
|
||||
if (codeInfo.type.toLowerCase() === "totp") {
|
||||
const totp = new TOTP({
|
||||
secret: codeInfo.secret,
|
||||
algorithm: codeInfo.algorithm ?? Code.defaultAlgo,
|
||||
period: codeInfo.period ?? Code.defaultPeriod,
|
||||
digits: codeInfo.digits ?? Code.defaultDigits,
|
||||
});
|
||||
setCode(totp.generate());
|
||||
setNextCode(
|
||||
totp.generate({
|
||||
timestamp: currentTime + codeInfo.period * 1000,
|
||||
}),
|
||||
);
|
||||
} else if (codeInfo.type.toLowerCase() === "hotp") {
|
||||
const hotp = new HOTP({
|
||||
secret: codeInfo.secret,
|
||||
counter: 0,
|
||||
algorithm: codeInfo.algorithm,
|
||||
});
|
||||
setCode(hotp.generate());
|
||||
setNextCode(hotp.generate({ counter: 1 }));
|
||||
}
|
||||
} catch (err) {
|
||||
setCodeErr(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const copyCode = () => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setHasCopied(true);
|
||||
setTimeout(() => {
|
||||
setHasCopied(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// this is to set the initial code and nextCode on component mount
|
||||
generateCodes();
|
||||
const codeType = codeInfo.type;
|
||||
const codePeriodInMs = codeInfo.period * 1000;
|
||||
const timeToNextCode =
|
||||
codePeriodInMs - (new Date().getTime() % codePeriodInMs);
|
||||
const intervalId = null;
|
||||
// wait until we are at the start of the next code period,
|
||||
// and then start the interval loop
|
||||
setTimeout(() => {
|
||||
// we need to call generateCodes() once before the interval loop
|
||||
// to set the initial code and nextCode
|
||||
generateCodes();
|
||||
codeType.toLowerCase() === "totp" ||
|
||||
codeType.toLowerCase() === "hotp"
|
||||
? setInterval(() => {
|
||||
generateCodes();
|
||||
}, codePeriodInMs)
|
||||
: null;
|
||||
}, timeToNextCode);
|
||||
|
||||
return () => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
};
|
||||
}, [codeInfo]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: "8px" }}>
|
||||
{codeErr === "" ? (
|
||||
<ButtonBase
|
||||
component="div"
|
||||
onClick={() => {
|
||||
copyCode();
|
||||
}}
|
||||
>
|
||||
<TOTPDisplay
|
||||
period={codeInfo.period}
|
||||
issuer={codeInfo.issuer}
|
||||
account={codeInfo.account}
|
||||
code={code}
|
||||
nextCode={nextCode}
|
||||
/>
|
||||
<Snackbar
|
||||
open={hasCopied}
|
||||
message="Code copied to clipboard"
|
||||
/>
|
||||
</ButtonBase>
|
||||
) : (
|
||||
<BadCodeInfo codeInfo={codeInfo} codeErr={codeErr} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OTPDisplay;
|
|
@ -1,41 +0,0 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
const TimerProgress = ({ period }) => {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [ticker, setTicker] = useState(null);
|
||||
const microSecondsInPeriod = period * 1000000;
|
||||
|
||||
const startTicker = () => {
|
||||
const ticker = setInterval(() => {
|
||||
updateTimeRemaining();
|
||||
}, 10);
|
||||
setTicker(ticker);
|
||||
};
|
||||
|
||||
const updateTimeRemaining = () => {
|
||||
const timeRemaining =
|
||||
microSecondsInPeriod -
|
||||
((new Date().getTime() * 1000) % microSecondsInPeriod);
|
||||
setProgress(timeRemaining / microSecondsInPeriod);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
startTicker();
|
||||
return () => clearInterval(ticker);
|
||||
}, []);
|
||||
|
||||
const color = progress > 0.4 ? "green" : "orange";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderTopLeftRadius: "3px",
|
||||
width: `${progress * 100}%`,
|
||||
height: "3px",
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimerProgress;
|
449
web/apps/auth/src/pages/auth.tsx
Normal file
449
web/apps/auth/src/pages/auth.tsx
Normal file
|
@ -0,0 +1,449 @@
|
|||
import {
|
||||
HorizontalFlex,
|
||||
VerticallyCentered,
|
||||
} from "@ente/shared/components/Container";
|
||||
import { EnteLogo } from "@ente/shared/components/EnteLogo";
|
||||
import EnteSpinner from "@ente/shared/components/EnteSpinner";
|
||||
import NavbarBase from "@ente/shared/components/Navbar/base";
|
||||
import OverflowMenu from "@ente/shared/components/OverflowMenu/menu";
|
||||
import { OverflowMenuOption } from "@ente/shared/components/OverflowMenu/option";
|
||||
import { AUTH_PAGES as PAGES } from "@ente/shared/constants/pages";
|
||||
import { CustomError } from "@ente/shared/error";
|
||||
import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore";
|
||||
import LogoutOutlined from "@mui/icons-material/LogoutOutlined";
|
||||
import MoreHoriz from "@mui/icons-material/MoreHoriz";
|
||||
import { Button, ButtonBase, Snackbar, TextField } from "@mui/material";
|
||||
import { t } from "i18next";
|
||||
import { useRouter } from "next/router";
|
||||
import { HOTP, TOTP } from "otpauth";
|
||||
import { AppContext } from "pages/_app";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Code } from "services/code";
|
||||
import { getAuthCodes } from "services/remote";
|
||||
|
||||
const AuthenticatorCodesPage = () => {
|
||||
const appContext = useContext(AppContext);
|
||||
const router = useRouter();
|
||||
const [codes, setCodes] = useState([]);
|
||||
const [hasFetched, setHasFetched] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCodes = async () => {
|
||||
try {
|
||||
const res = await getAuthCodes();
|
||||
setCodes(res);
|
||||
} catch (err) {
|
||||
if (err.message === CustomError.KEY_MISSING) {
|
||||
InMemoryStore.set(MS_KEYS.REDIRECT_URL, PAGES.AUTH);
|
||||
router.push(PAGES.ROOT);
|
||||
} else {
|
||||
// do not log errors
|
||||
}
|
||||
}
|
||||
setHasFetched(true);
|
||||
};
|
||||
fetchCodes();
|
||||
appContext.showNavBar(false);
|
||||
}, []);
|
||||
|
||||
const filteredCodes = codes.filter(
|
||||
(secret) =>
|
||||
(secret.issuer ?? "")
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()) ||
|
||||
(secret.account ?? "")
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
if (!hasFetched) {
|
||||
return (
|
||||
<>
|
||||
<VerticallyCentered>
|
||||
<EnteSpinner></EnteSpinner>
|
||||
</VerticallyCentered>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthNavbar />
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "800px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: "1rem" }} />
|
||||
{filteredCodes.length === 0 && searchTerm.length === 0 ? (
|
||||
<></>
|
||||
) : (
|
||||
<TextField
|
||||
id="search"
|
||||
name="search"
|
||||
label={t("SEARCH")}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
variant="filled"
|
||||
style={{ width: "350px" }}
|
||||
value={searchTerm}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: "1rem" }} />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{filteredCodes.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
alignItems: "center",
|
||||
display: "flex",
|
||||
textAlign: "center",
|
||||
marginTop: "32px",
|
||||
}}
|
||||
>
|
||||
{searchTerm.length !== 0 ? (
|
||||
<p>{t("NO_RESULTS")}</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredCodes.map((code) => (
|
||||
<CodeDisplay codeInfo={code} key={code.id} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginBottom: "2rem" }} />
|
||||
<AuthFooter />
|
||||
<div style={{ marginBottom: "4rem" }} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthenticatorCodesPage;
|
||||
|
||||
const AuthNavbar: React.FC = () => {
|
||||
const { isMobile, logout } = useContext(AppContext);
|
||||
|
||||
return (
|
||||
<NavbarBase isMobile={isMobile}>
|
||||
<HorizontalFlex flex={1} justifyContent={"center"}>
|
||||
<EnteLogo />
|
||||
</HorizontalFlex>
|
||||
<HorizontalFlex position={"absolute"} right="24px">
|
||||
<OverflowMenu
|
||||
ariaControls={"auth-options"}
|
||||
triggerButtonIcon={<MoreHoriz />}
|
||||
>
|
||||
<OverflowMenuOption
|
||||
color="critical"
|
||||
startIcon={<LogoutOutlined />}
|
||||
onClick={logout}
|
||||
>
|
||||
{t("LOGOUT")}
|
||||
</OverflowMenuOption>
|
||||
</OverflowMenu>
|
||||
</HorizontalFlex>
|
||||
</NavbarBase>
|
||||
);
|
||||
};
|
||||
|
||||
interface CodeDisplay {
|
||||
codeInfo: Code;
|
||||
}
|
||||
|
||||
const CodeDisplay: React.FC<CodeDisplay> = ({ codeInfo }) => {
|
||||
const [otp, setOTP] = useState("");
|
||||
const [nextOTP, setNextOTP] = useState("");
|
||||
const [codeErr, setCodeErr] = useState("");
|
||||
const [hasCopied, setHasCopied] = useState(false);
|
||||
|
||||
const generateCodes = () => {
|
||||
try {
|
||||
const currentTime = new Date().getTime();
|
||||
if (codeInfo.type === "totp") {
|
||||
const totp = new TOTP({
|
||||
secret: codeInfo.secret,
|
||||
algorithm: codeInfo.algorithm,
|
||||
period: codeInfo.period,
|
||||
digits: codeInfo.digits,
|
||||
});
|
||||
setOTP(totp.generate());
|
||||
setNextOTP(
|
||||
totp.generate({
|
||||
timestamp: currentTime + codeInfo.period * 1000,
|
||||
}),
|
||||
);
|
||||
} else if (codeInfo.type === "hotp") {
|
||||
const hotp = new HOTP({
|
||||
secret: codeInfo.secret,
|
||||
counter: 0,
|
||||
algorithm: codeInfo.algorithm,
|
||||
});
|
||||
setOTP(hotp.generate());
|
||||
setNextOTP(hotp.generate({ counter: 1 }));
|
||||
}
|
||||
} catch (err) {
|
||||
setCodeErr(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const copyCode = () => {
|
||||
navigator.clipboard.writeText(otp);
|
||||
setHasCopied(true);
|
||||
setTimeout(() => {
|
||||
setHasCopied(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// this is to set the initial code and nextCode on component mount
|
||||
generateCodes();
|
||||
const codeType = codeInfo.type;
|
||||
const codePeriodInMs = codeInfo.period * 1000;
|
||||
const timeToNextCode =
|
||||
codePeriodInMs - (new Date().getTime() % codePeriodInMs);
|
||||
const intervalId = null;
|
||||
// wait until we are at the start of the next code period,
|
||||
// and then start the interval loop
|
||||
setTimeout(() => {
|
||||
// we need to call generateCodes() once before the interval loop
|
||||
// to set the initial code and nextCode
|
||||
generateCodes();
|
||||
codeType.toLowerCase() === "totp" ||
|
||||
codeType.toLowerCase() === "hotp"
|
||||
? setInterval(() => {
|
||||
generateCodes();
|
||||
}, codePeriodInMs)
|
||||
: null;
|
||||
}, timeToNextCode);
|
||||
|
||||
return () => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
};
|
||||
}, [codeInfo]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: "8px" }}>
|
||||
{codeErr === "" ? (
|
||||
<ButtonBase
|
||||
component="div"
|
||||
onClick={() => {
|
||||
copyCode();
|
||||
}}
|
||||
>
|
||||
<OTPDisplay code={codeInfo} otp={otp} nextOTP={nextOTP} />
|
||||
<Snackbar
|
||||
open={hasCopied}
|
||||
message="Code copied to clipboard"
|
||||
/>
|
||||
</ButtonBase>
|
||||
) : (
|
||||
<BadCodeInfo codeInfo={codeInfo} codeErr={codeErr} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface OTPDisplayProps {
|
||||
code: Code;
|
||||
otp: string;
|
||||
nextOTP: string;
|
||||
}
|
||||
|
||||
const OTPDisplay: React.FC<OTPDisplayProps> = ({ code, otp, nextOTP }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "rgba(40, 40, 40, 0.6)",
|
||||
borderRadius: "4px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<TimerProgress period={code.period} />
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 20px 0px 20px",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
minWidth: "320px",
|
||||
minHeight: "120px",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
minWidth: "200px",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
margin: "0px",
|
||||
fontSize: "14px",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{code.issuer}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
marginTop: "0px",
|
||||
marginBottom: "8px",
|
||||
textAlign: "left",
|
||||
fontSize: "12px",
|
||||
maxWidth: "200px",
|
||||
minHeight: "16px",
|
||||
color: "grey",
|
||||
}}
|
||||
>
|
||||
{code.account}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
margin: "0px",
|
||||
marginBottom: "1rem",
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{otp}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-end",
|
||||
minWidth: "120px",
|
||||
textAlign: "right",
|
||||
marginTop: "auto",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
marginBottom: "0px",
|
||||
fontSize: "10px",
|
||||
marginTop: "auto",
|
||||
textAlign: "right",
|
||||
color: "grey",
|
||||
}}
|
||||
>
|
||||
{t("AUTH_NEXT")}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "0px",
|
||||
marginTop: "auto",
|
||||
textAlign: "right",
|
||||
color: "grey",
|
||||
}}
|
||||
>
|
||||
{nextOTP}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TimerProgressProps {
|
||||
period: number;
|
||||
}
|
||||
|
||||
const TimerProgress: React.FC<TimerProgressProps> = ({ period }) => {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const microSecondsInPeriod = period * 1000000;
|
||||
|
||||
useEffect(() => {
|
||||
const updateTimeRemaining = () => {
|
||||
const timeRemaining =
|
||||
microSecondsInPeriod -
|
||||
((new Date().getTime() * 1000) % microSecondsInPeriod);
|
||||
setProgress(timeRemaining / microSecondsInPeriod);
|
||||
};
|
||||
|
||||
const ticker = setInterval(() => {
|
||||
updateTimeRemaining();
|
||||
}, 10);
|
||||
|
||||
return () => clearInterval(ticker);
|
||||
}, []);
|
||||
|
||||
const color = progress > 0.4 ? "green" : "orange";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderTopLeftRadius: "3px",
|
||||
width: `${progress * 100}%`,
|
||||
height: "3px",
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function BadCodeInfo({ codeInfo, codeErr }) {
|
||||
const [showRawData, setShowRawData] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="code-info">
|
||||
<div>{codeInfo.title}</div>
|
||||
<div>{codeErr}</div>
|
||||
<div>
|
||||
{showRawData ? (
|
||||
<div onClick={() => setShowRawData(false)}>
|
||||
{codeInfo.uriString ?? "(no raw data)"}
|
||||
</div>
|
||||
) : (
|
||||
<div onClick={() => setShowRawData(true)}>Show rawData</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AuthFooter: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<p>{t("AUTH_DOWNLOAD_MOBILE_APP")}</p>
|
||||
<a
|
||||
href="https://github.com/ente-io/ente/tree/main/auth#-download"
|
||||
download
|
||||
>
|
||||
<Button color="accent">{t("DOWNLOAD")}</Button>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,129 +0,0 @@
|
|||
import { VerticallyCentered } from "@ente/shared/components/Container";
|
||||
import EnteSpinner from "@ente/shared/components/EnteSpinner";
|
||||
import { AUTH_PAGES as PAGES } from "@ente/shared/constants/pages";
|
||||
import { CustomError } from "@ente/shared/error";
|
||||
import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore";
|
||||
import { TextField } from "@mui/material";
|
||||
import { AuthFooter } from "components/AuthFooter";
|
||||
import AuthNavbar from "components/Navbar";
|
||||
import OTPDisplay from "components/OTPDisplay";
|
||||
import { t } from "i18next";
|
||||
import { useRouter } from "next/router";
|
||||
import { AppContext } from "pages/_app";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { getAuthCodes } from "services";
|
||||
|
||||
const AuthenticatorCodesPage = () => {
|
||||
const appContext = useContext(AppContext);
|
||||
const router = useRouter();
|
||||
const [codes, setCodes] = useState([]);
|
||||
const [hasFetched, setHasFetched] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCodes = async () => {
|
||||
try {
|
||||
const res = await getAuthCodes();
|
||||
setCodes(res);
|
||||
} catch (err) {
|
||||
if (err.message === CustomError.KEY_MISSING) {
|
||||
InMemoryStore.set(MS_KEYS.REDIRECT_URL, PAGES.AUTH);
|
||||
router.push(PAGES.ROOT);
|
||||
} else {
|
||||
// do not log errors
|
||||
}
|
||||
}
|
||||
setHasFetched(true);
|
||||
};
|
||||
fetchCodes();
|
||||
appContext.showNavBar(false);
|
||||
}, []);
|
||||
|
||||
const filteredCodes = codes.filter(
|
||||
(secret) =>
|
||||
(secret.issuer ?? "")
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()) ||
|
||||
(secret.account ?? "")
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
if (!hasFetched) {
|
||||
return (
|
||||
<>
|
||||
<VerticallyCentered>
|
||||
<EnteSpinner></EnteSpinner>
|
||||
</VerticallyCentered>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthNavbar />
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "800px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: "1rem" }} />
|
||||
{filteredCodes.length === 0 && searchTerm.length === 0 ? (
|
||||
<></>
|
||||
) : (
|
||||
<TextField
|
||||
id="search"
|
||||
name="search"
|
||||
label={t("SEARCH")}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
variant="filled"
|
||||
style={{ width: "350px" }}
|
||||
value={searchTerm}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: "1rem" }} />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{filteredCodes.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
alignItems: "center",
|
||||
display: "flex",
|
||||
textAlign: "center",
|
||||
marginTop: "32px",
|
||||
}}
|
||||
>
|
||||
{searchTerm.length !== 0 ? (
|
||||
<p>{t("NO_RESULTS")}</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredCodes.map((code) => (
|
||||
<OTPDisplay codeInfo={code} key={code.id} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginBottom: "2rem" }} />
|
||||
<AuthFooter />
|
||||
<div style={{ marginBottom: "4rem" }} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthenticatorCodesPage;
|
|
@ -1,7 +1,7 @@
|
|||
import TwoFactorVerifyPage from "@ente/accounts/pages/two-factor/verify";
|
||||
import { APPS } from "@ente/shared/apps/constants";
|
||||
import { useContext } from "react";
|
||||
import { AppContext } from "../../_app";
|
||||
import { AppContext } from "../_app";
|
||||
|
||||
export default function TwoFactorVerify() {
|
||||
const appContext = useContext(AppContext);
|
154
web/apps/auth/src/services/code.ts
Normal file
154
web/apps/auth/src/services/code.ts
Normal file
|
@ -0,0 +1,154 @@
|
|||
import { URI } from "vscode-uri";
|
||||
|
||||
/**
|
||||
* A parsed representation of an xOTP code URI.
|
||||
*
|
||||
* This is all the data we need to drive a OTP generator.
|
||||
*/
|
||||
export interface Code {
|
||||
/** The uniquue id for the corresponding auth entity. */
|
||||
id?: String;
|
||||
/** The type of the code. */
|
||||
type: "totp" | "hotp";
|
||||
/** The user's account or email for which this code is used. */
|
||||
account: string;
|
||||
/** The name of the entity that issued this code. */
|
||||
issuer: string;
|
||||
/** Number of digits in the code. */
|
||||
digits: number;
|
||||
/**
|
||||
* The time period (in seconds) for which a single OTP generated from this
|
||||
* code remains valid.
|
||||
*/
|
||||
period: number;
|
||||
/** The secret that is used to drive the OTP generator. */
|
||||
secret: string;
|
||||
/** The (hashing) algorithim used by the OTP generator. */
|
||||
algorithm: "sha1" | "sha256" | "sha512";
|
||||
/** The original string from which this code was generated. */
|
||||
uriString?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a OTP code URI into its parse representation, a {@link Code}.
|
||||
*
|
||||
* @param id A unique ID of this code within the auth app.
|
||||
*
|
||||
* @param uriString A string specifying how to generate a TOTP/HOTP/Steam OTP
|
||||
* code. These strings are of the form:
|
||||
*
|
||||
* - (TOTP)
|
||||
* otpauth://totp/account:user@example.org?algorithm=SHA1&digits=6&issuer=issuer&period=30&secret=ALPHANUM
|
||||
*/
|
||||
export const codeFromURIString = (id: string, uriString: string): Code => {
|
||||
let santizedRawData = uriString
|
||||
.replace(/\+/g, "%2B")
|
||||
.replace(/:/g, "%3A")
|
||||
.replaceAll("\r", "");
|
||||
if (santizedRawData.startsWith('"')) {
|
||||
santizedRawData = santizedRawData.substring(1);
|
||||
}
|
||||
if (santizedRawData.endsWith('"')) {
|
||||
santizedRawData = santizedRawData.substring(
|
||||
0,
|
||||
santizedRawData.length - 1,
|
||||
);
|
||||
}
|
||||
|
||||
const uriParams = {};
|
||||
const searchParamsString =
|
||||
decodeURIComponent(santizedRawData).split("?")[1];
|
||||
searchParamsString.split("&").forEach((pair) => {
|
||||
const [key, value] = pair.split("=");
|
||||
uriParams[key] = value;
|
||||
});
|
||||
|
||||
const uri = URI.parse(santizedRawData);
|
||||
let uriPath = decodeURIComponent(uri.path);
|
||||
if (uriPath.startsWith("/otpauth://") || uriPath.startsWith("otpauth://")) {
|
||||
uriPath = uriPath.split("otpauth://")[1];
|
||||
} else if (uriPath.startsWith("otpauth%3A//")) {
|
||||
uriPath = uriPath.split("otpauth%3A//")[1];
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
type: _getType(uriPath),
|
||||
account: _getAccount(uriPath),
|
||||
issuer: _getIssuer(uriPath, uriParams),
|
||||
digits: parseDigits(uriParams),
|
||||
period: parsePeriod(uriParams),
|
||||
secret: getSanitizedSecret(uriParams),
|
||||
algorithm: parseAlgorithm(uriParams),
|
||||
uriString,
|
||||
};
|
||||
};
|
||||
|
||||
const _getAccount = (uriPath: string): string => {
|
||||
try {
|
||||
const path = decodeURIComponent(uriPath);
|
||||
if (path.includes(":")) {
|
||||
return path.split(":")[1];
|
||||
} else if (path.includes("/")) {
|
||||
return path.split("/")[1];
|
||||
}
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const _getIssuer = (uriPath: string, uriParams: { get?: any }): string => {
|
||||
try {
|
||||
if (uriParams["issuer"] !== undefined) {
|
||||
let issuer = uriParams["issuer"];
|
||||
// This is to handle bug in the ente auth app
|
||||
if (issuer.endsWith("period")) {
|
||||
issuer = issuer.substring(0, issuer.length - 6);
|
||||
}
|
||||
return issuer;
|
||||
}
|
||||
let path = decodeURIComponent(uriPath);
|
||||
if (path.startsWith("totp/") || path.startsWith("hotp/")) {
|
||||
path = path.substring(5);
|
||||
}
|
||||
if (path.includes(":")) {
|
||||
return path.split(":")[0];
|
||||
} else if (path.includes("-")) {
|
||||
return path.split("-")[0];
|
||||
}
|
||||
return path;
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const parseDigits = (uriParams): number =>
|
||||
parseInt(uriParams["digits"] ?? "", 10) || 6;
|
||||
|
||||
const parsePeriod = (uriParams): number =>
|
||||
parseInt(uriParams["period"] ?? "", 10) || 30;
|
||||
|
||||
const parseAlgorithm = (uriParams): Code["algorithm"] => {
|
||||
switch (uriParams["algorithm"]?.toLowerCase()) {
|
||||
case "sha256":
|
||||
return "sha256";
|
||||
case "sha512":
|
||||
return "sha512";
|
||||
default:
|
||||
return "sha1";
|
||||
}
|
||||
};
|
||||
|
||||
const _getType = (uriPath: string): Code["type"] => {
|
||||
const oauthType = uriPath.split("/")[0].substring(0);
|
||||
if (oauthType.toLowerCase() === "totp") {
|
||||
return "totp";
|
||||
} else if (oauthType.toLowerCase() === "hotp") {
|
||||
return "hotp";
|
||||
}
|
||||
throw new Error(`Unsupported format with host ${oauthType}`);
|
||||
};
|
||||
|
||||
const getSanitizedSecret = (uriParams): string => {
|
||||
return uriParams["secret"].replace(/ /g, "").toUpperCase();
|
||||
};
|
|
@ -6,10 +6,10 @@ import { getEndpoint } from "@ente/shared/network/api";
|
|||
import { getToken } from "@ente/shared/storage/localStorage/helpers";
|
||||
import { getActualKey } from "@ente/shared/user";
|
||||
import { HttpStatusCode } from "axios";
|
||||
import { AuthEntity, AuthKey } from "types/api";
|
||||
import { Code } from "types/code";
|
||||
import { codeFromURIString, type Code } from "services/code";
|
||||
|
||||
const ENDPOINT = getEndpoint();
|
||||
|
||||
export const getAuthCodes = async (): Promise<Code[]> => {
|
||||
const masterKey = await getActualKey();
|
||||
try {
|
||||
|
@ -33,7 +33,7 @@ export const getAuthCodes = async (): Promise<Code[]> => {
|
|||
entity.header,
|
||||
authenticatorKey,
|
||||
);
|
||||
return Code.fromRawData(entity.id, decryptedCode);
|
||||
return codeFromURIString(entity.id, decryptedCode);
|
||||
} catch (e) {
|
||||
log.error(`failed to parse codeId = ${entity.id}`);
|
||||
return null;
|
||||
|
@ -65,6 +65,20 @@ export const getAuthCodes = async (): Promise<Code[]> => {
|
|||
}
|
||||
};
|
||||
|
||||
interface AuthEntity {
|
||||
id: string;
|
||||
encryptedData: string | null;
|
||||
header: string | null;
|
||||
isDeleted: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface AuthKey {
|
||||
encryptedKey: string;
|
||||
header: string;
|
||||
}
|
||||
|
||||
export const getAuthKey = async (): Promise<AuthKey> => {
|
||||
try {
|
||||
const resp = await HTTPService.get(
|
|
@ -1,13 +0,0 @@
|
|||
export interface AuthEntity {
|
||||
id: string;
|
||||
encryptedData: string | null;
|
||||
header: string | null;
|
||||
isDeleted: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface AuthKey {
|
||||
encryptedKey: string;
|
||||
header: string;
|
||||
}
|
|
@ -1,182 +0,0 @@
|
|||
import { URI } from "vscode-uri";
|
||||
|
||||
type Type = "totp" | "TOTP" | "hotp" | "HOTP";
|
||||
|
||||
type AlgorithmType =
|
||||
| "sha1"
|
||||
| "SHA1"
|
||||
| "sha256"
|
||||
| "SHA256"
|
||||
| "sha512"
|
||||
| "SHA512";
|
||||
|
||||
export class Code {
|
||||
static readonly defaultDigits = 6;
|
||||
static readonly defaultAlgo = "sha1";
|
||||
static readonly defaultPeriod = 30;
|
||||
|
||||
// id for the corresponding auth entity
|
||||
id?: String;
|
||||
account: string;
|
||||
issuer: string;
|
||||
digits?: number;
|
||||
period: number;
|
||||
secret: string;
|
||||
algorithm: AlgorithmType;
|
||||
type: Type;
|
||||
rawData?: string;
|
||||
|
||||
constructor(
|
||||
account: string,
|
||||
issuer: string,
|
||||
digits: number | undefined,
|
||||
period: number,
|
||||
secret: string,
|
||||
algorithm: AlgorithmType,
|
||||
type: Type,
|
||||
rawData?: string,
|
||||
id?: string,
|
||||
) {
|
||||
this.account = account;
|
||||
this.issuer = issuer;
|
||||
this.digits = digits;
|
||||
this.period = period;
|
||||
this.secret = secret;
|
||||
this.algorithm = algorithm;
|
||||
this.type = type;
|
||||
this.rawData = rawData;
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
static fromRawData(id: string, rawData: string): Code {
|
||||
let santizedRawData = rawData
|
||||
.replace(/\+/g, "%2B")
|
||||
.replace(/:/g, "%3A")
|
||||
.replaceAll("\r", "");
|
||||
if (santizedRawData.startsWith('"')) {
|
||||
santizedRawData = santizedRawData.substring(1);
|
||||
}
|
||||
if (santizedRawData.endsWith('"')) {
|
||||
santizedRawData = santizedRawData.substring(
|
||||
0,
|
||||
santizedRawData.length - 1,
|
||||
);
|
||||
}
|
||||
|
||||
const uriParams = {};
|
||||
const searchParamsString =
|
||||
decodeURIComponent(santizedRawData).split("?")[1];
|
||||
searchParamsString.split("&").forEach((pair) => {
|
||||
const [key, value] = pair.split("=");
|
||||
uriParams[key] = value;
|
||||
});
|
||||
|
||||
const uri = URI.parse(santizedRawData);
|
||||
let uriPath = decodeURIComponent(uri.path);
|
||||
if (
|
||||
uriPath.startsWith("/otpauth://") ||
|
||||
uriPath.startsWith("otpauth://")
|
||||
) {
|
||||
uriPath = uriPath.split("otpauth://")[1];
|
||||
} else if (uriPath.startsWith("otpauth%3A//")) {
|
||||
uriPath = uriPath.split("otpauth%3A//")[1];
|
||||
}
|
||||
|
||||
return new Code(
|
||||
Code._getAccount(uriPath),
|
||||
Code._getIssuer(uriPath, uriParams),
|
||||
Code._getDigits(uriParams),
|
||||
Code._getPeriod(uriParams),
|
||||
Code.getSanitizedSecret(uriParams),
|
||||
Code._getAlgorithm(uriParams),
|
||||
Code._getType(uriPath),
|
||||
rawData,
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
||||
private static _getAccount(uriPath: string): string {
|
||||
try {
|
||||
const path = decodeURIComponent(uriPath);
|
||||
if (path.includes(":")) {
|
||||
return path.split(":")[1];
|
||||
} else if (path.includes("/")) {
|
||||
return path.split("/")[1];
|
||||
}
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static _getIssuer(
|
||||
uriPath: string,
|
||||
uriParams: { get?: any },
|
||||
): string {
|
||||
try {
|
||||
if (uriParams["issuer"] !== undefined) {
|
||||
let issuer = uriParams["issuer"];
|
||||
// This is to handle bug in the ente auth app
|
||||
if (issuer.endsWith("period")) {
|
||||
issuer = issuer.substring(0, issuer.length - 6);
|
||||
}
|
||||
return issuer;
|
||||
}
|
||||
let path = decodeURIComponent(uriPath);
|
||||
if (path.startsWith("totp/") || path.startsWith("hotp/")) {
|
||||
path = path.substring(5);
|
||||
}
|
||||
if (path.includes(":")) {
|
||||
return path.split(":")[0];
|
||||
} else if (path.includes("-")) {
|
||||
return path.split("-")[0];
|
||||
}
|
||||
return path;
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static _getDigits(uriParams): number {
|
||||
try {
|
||||
return parseInt(uriParams["digits"], 10) || Code.defaultDigits;
|
||||
} catch (e) {
|
||||
return Code.defaultDigits;
|
||||
}
|
||||
}
|
||||
|
||||
private static _getPeriod(uriParams): number {
|
||||
try {
|
||||
return parseInt(uriParams["period"], 10) || Code.defaultPeriod;
|
||||
} catch (e) {
|
||||
return Code.defaultPeriod;
|
||||
}
|
||||
}
|
||||
|
||||
private static _getAlgorithm(uriParams): AlgorithmType {
|
||||
try {
|
||||
const algorithm = uriParams["algorithm"].toLowerCase();
|
||||
if (algorithm === "sha256") {
|
||||
return algorithm;
|
||||
} else if (algorithm === "sha512") {
|
||||
return algorithm;
|
||||
}
|
||||
} catch (e) {
|
||||
// nothing
|
||||
}
|
||||
return "sha1";
|
||||
}
|
||||
|
||||
private static _getType(uriPath: string): Type {
|
||||
const oauthType = uriPath.split("/")[0].substring(0);
|
||||
if (oauthType.toLowerCase() === "totp") {
|
||||
return "totp";
|
||||
} else if (oauthType.toLowerCase() === "hotp") {
|
||||
return "hotp";
|
||||
}
|
||||
throw new Error(`Unsupported format with host ${oauthType}`);
|
||||
}
|
||||
|
||||
static getSanitizedSecret(uriParams): string {
|
||||
return uriParams["secret"].replace(/ /g, "").toUpperCase();
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue