[web] Auth cleanup - Part 2/x (#1834)
Preparing for steam support (sibling of https://github.com/ente-io/ente/pull/1820)
This commit is contained in:
commit
1712bf60cb
6 changed files with 132 additions and 116 deletions
|
@ -6,6 +6,7 @@
|
|||
"@/next": "*",
|
||||
"@ente/accounts": "*",
|
||||
"@ente/eslint-config": "*",
|
||||
"@ente/shared": "*"
|
||||
"@ente/shared": "*",
|
||||
"otpauth": "^9"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,10 +15,9 @@ 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 { generateOTPs, type Code } from "services/code";
|
||||
import { getAuthCodes } from "services/remote";
|
||||
|
||||
const AuthenticatorCodesPage = () => {
|
||||
|
@ -43,7 +42,7 @@ const AuthenticatorCodesPage = () => {
|
|||
}
|
||||
setHasFetched(true);
|
||||
};
|
||||
fetchCodes();
|
||||
void fetchCodes();
|
||||
appContext.showNavBar(false);
|
||||
}, []);
|
||||
|
||||
|
@ -122,12 +121,12 @@ const AuthenticatorCodesPage = () => {
|
|||
</div>
|
||||
) : (
|
||||
filteredCodes.map((code) => (
|
||||
<CodeDisplay codeInfo={code} key={code.id} />
|
||||
<CodeDisplay key={code.id} code={code} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginBottom: "2rem" }} />
|
||||
<AuthFooter />
|
||||
<Footer />
|
||||
<div style={{ marginBottom: "4rem" }} />
|
||||
</div>
|
||||
</>
|
||||
|
@ -163,97 +162,67 @@ const AuthNavbar: React.FC = () => {
|
|||
};
|
||||
|
||||
interface CodeDisplay {
|
||||
codeInfo: Code;
|
||||
code: Code;
|
||||
}
|
||||
|
||||
const CodeDisplay: React.FC<CodeDisplay> = ({ codeInfo }) => {
|
||||
const CodeDisplay: React.FC<CodeDisplay> = ({ code }) => {
|
||||
const [otp, setOTP] = useState("");
|
||||
const [nextOTP, setNextOTP] = useState("");
|
||||
const [codeErr, setCodeErr] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [hasCopied, setHasCopied] = useState(false);
|
||||
|
||||
const generateCodes = () => {
|
||||
const regen = () => {
|
||||
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 [m, n] = generateOTPs(code);
|
||||
setOTP(m);
|
||||
setNextOTP(n);
|
||||
} catch (e) {
|
||||
setErrorMessage(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const copyCode = () => {
|
||||
navigator.clipboard.writeText(otp);
|
||||
setHasCopied(true);
|
||||
setTimeout(() => {
|
||||
setHasCopied(false);
|
||||
}, 2000);
|
||||
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;
|
||||
// Generate to set the initial otp and nextOTP on component mount.
|
||||
regen();
|
||||
const codeType = code.type;
|
||||
const codePeriodInMs = code.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
|
||||
const interval = 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();
|
||||
// We need to call regen() once before the interval loop to set the
|
||||
// initial otp and nextOTP.
|
||||
regen();
|
||||
codeType.toLowerCase() === "totp" ||
|
||||
codeType.toLowerCase() === "hotp"
|
||||
? setInterval(() => {
|
||||
generateCodes();
|
||||
regen();
|
||||
}, codePeriodInMs)
|
||||
: null;
|
||||
}, timeToNextCode);
|
||||
|
||||
return () => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [codeInfo]);
|
||||
}, [code]);
|
||||
|
||||
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>
|
||||
{errorMessage ? (
|
||||
<UnparseableCode {...{ code, errorMessage }} />
|
||||
) : (
|
||||
<BadCodeInfo codeInfo={codeInfo} codeErr={codeErr} />
|
||||
<ButtonBase component="div" onClick={copyCode}>
|
||||
<OTPDisplay {...{ code, otp, nextOTP }} />
|
||||
<Snackbar open={hasCopied} message={t("COPIED")} />
|
||||
</ButtonBase>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
@ -376,19 +345,15 @@ interface TimerProgressProps {
|
|||
|
||||
const TimerProgress: React.FC<TimerProgressProps> = ({ period }) => {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const microSecondsInPeriod = period * 1000000;
|
||||
const us = period * 1e6;
|
||||
|
||||
useEffect(() => {
|
||||
const updateTimeRemaining = () => {
|
||||
const timeRemaining =
|
||||
microSecondsInPeriod -
|
||||
((new Date().getTime() * 1000) % microSecondsInPeriod);
|
||||
setProgress(timeRemaining / microSecondsInPeriod);
|
||||
const advance = () => {
|
||||
const timeRemaining = us - ((new Date().getTime() * 1000) % us);
|
||||
setProgress(timeRemaining / us);
|
||||
};
|
||||
|
||||
const ticker = setInterval(() => {
|
||||
updateTimeRemaining();
|
||||
}, 10);
|
||||
const ticker = setInterval(advance, 10);
|
||||
|
||||
return () => clearInterval(ticker);
|
||||
}, []);
|
||||
|
@ -407,17 +372,25 @@ const TimerProgress: React.FC<TimerProgressProps> = ({ period }) => {
|
|||
);
|
||||
};
|
||||
|
||||
function BadCodeInfo({ codeInfo, codeErr }) {
|
||||
interface UnparseableCodeProps {
|
||||
code: Code;
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
const UnparseableCode: React.FC<UnparseableCodeProps> = ({
|
||||
code,
|
||||
errorMessage,
|
||||
}) => {
|
||||
const [showRawData, setShowRawData] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="code-info">
|
||||
<div>{codeInfo.title}</div>
|
||||
<div>{codeErr}</div>
|
||||
<div>{code.issuer}</div>
|
||||
<div>{errorMessage}</div>
|
||||
<div>
|
||||
{showRawData ? (
|
||||
<div onClick={() => setShowRawData(false)}>
|
||||
{codeInfo.uriString ?? "(no raw data)"}
|
||||
{code.uriString}
|
||||
</div>
|
||||
) : (
|
||||
<div onClick={() => setShowRawData(true)}>Show rawData</div>
|
||||
|
@ -425,9 +398,9 @@ function BadCodeInfo({ codeInfo, codeErr }) {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const AuthFooter: React.FC = () => {
|
||||
const Footer: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import { HOTP, TOTP } from "otpauth";
|
||||
import { URI } from "vscode-uri";
|
||||
|
||||
/**
|
||||
* A parsed representation of an xOTP code URI.
|
||||
* A parsed representation of an *OTP 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. */
|
||||
/** A unique id for the corresponding "auth entity" in our system. */
|
||||
id?: String;
|
||||
/** The type of the code. */
|
||||
type: "totp" | "hotp";
|
||||
|
@ -14,16 +15,21 @@ export interface Code {
|
|||
account: string;
|
||||
/** The name of the entity that issued this code. */
|
||||
issuer: string;
|
||||
/** Number of digits in the code. */
|
||||
/** Number of digits in the generated OTP. */
|
||||
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. */
|
||||
/**
|
||||
* The secret that is used to drive the OTP generator.
|
||||
*
|
||||
* This is an arbitrary key encoded in Base32 that drives the HMAC (in a
|
||||
* {@link type}-specific manner).
|
||||
*/
|
||||
secret: string;
|
||||
/** The (hashing) algorithim used by the OTP generator. */
|
||||
/** The (HMAC) algorithm used by the OTP generator. */
|
||||
algorithm: "sha1" | "sha256" | "sha512";
|
||||
/** The original string from which this code was generated. */
|
||||
uriString?: string;
|
||||
|
@ -38,22 +44,15 @@ export interface Code {
|
|||
* code. These strings are of the form:
|
||||
*
|
||||
* - (TOTP)
|
||||
* otpauth://totp/account:user@example.org?algorithm=SHA1&digits=6&issuer=issuer&period=30&secret=ALPHANUM
|
||||
* otpauth://totp/ACME:user@example.org?algorithm=SHA1&digits=6&issuer=acme&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 santizedRawData = uriString
|
||||
.replaceAll("+", "%2B")
|
||||
.replaceAll(":", "%3A")
|
||||
.replaceAll("\r", "")
|
||||
// trim quotes
|
||||
.replace(/^"|"$/g, "");
|
||||
|
||||
const uriParams = {};
|
||||
const searchParamsString =
|
||||
|
@ -78,12 +77,22 @@ export const codeFromURIString = (id: string, uriString: string): Code => {
|
|||
issuer: _getIssuer(uriPath, uriParams),
|
||||
digits: parseDigits(uriParams),
|
||||
period: parsePeriod(uriParams),
|
||||
secret: getSanitizedSecret(uriParams),
|
||||
secret: parseSecret(uriParams),
|
||||
algorithm: parseAlgorithm(uriParams),
|
||||
uriString,
|
||||
};
|
||||
};
|
||||
|
||||
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 _getAccount = (uriPath: string): string => {
|
||||
try {
|
||||
const path = decodeURIComponent(uriPath);
|
||||
|
@ -139,16 +148,45 @@ const parseAlgorithm = (uriParams): Code["algorithm"] => {
|
|||
}
|
||||
};
|
||||
|
||||
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 parseSecret = (uriParams): string =>
|
||||
uriParams["secret"].replaceAll(" ", "").toUpperCase();
|
||||
|
||||
const getSanitizedSecret = (uriParams): string => {
|
||||
return uriParams["secret"].replace(/ /g, "").toUpperCase();
|
||||
/**
|
||||
* Generate a pair of OTPs (one time passwords) from the given {@link code}.
|
||||
*
|
||||
* @param code The parsed code data, including the secret and code type.
|
||||
*
|
||||
* @returns a pair of OTPs, the current one and the next one, using the given
|
||||
* {@link code}.
|
||||
*/
|
||||
export const generateOTPs = (code: Code): [otp: string, nextOTP: string] => {
|
||||
let otp: string;
|
||||
let nextOTP: string;
|
||||
switch (code.type) {
|
||||
case "totp": {
|
||||
const totp = new TOTP({
|
||||
secret: code.secret,
|
||||
algorithm: code.algorithm,
|
||||
period: code.period,
|
||||
digits: code.digits,
|
||||
});
|
||||
otp = totp.generate();
|
||||
nextOTP = totp.generate({
|
||||
timestamp: new Date().getTime() + code.period * 1000,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "hotp": {
|
||||
const hotp = new HOTP({
|
||||
secret: code.secret,
|
||||
counter: 0,
|
||||
algorithm: code.algorithm,
|
||||
});
|
||||
otp = hotp.generate();
|
||||
nextOTP = hotp.generate({ counter: 1 });
|
||||
break;
|
||||
}
|
||||
}
|
||||
return [otp, nextOTP];
|
||||
};
|
||||
|
|
|
@ -28,7 +28,6 @@
|
|||
"localforage": "^1.9.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"ml-matrix": "^6.11",
|
||||
"otpauth": "^9.0.2",
|
||||
"p-debounce": "^4.0.0",
|
||||
"p-queue": "^7.1.0",
|
||||
"photoswipe": "file:./thirdparty/photoswipe",
|
||||
|
|
|
@ -193,3 +193,8 @@ some cases.
|
|||
|
||||
- [hdbscan](https://github.com/shaileshpandit/hdbscan-js) is used for face
|
||||
clustering.
|
||||
|
||||
## Auth app specific
|
||||
|
||||
- [otpauth](https://github.com/hectorm/otpauth) is used for the generation of
|
||||
the actual OTP from the user's TOTP/HOTP secret.
|
||||
|
|
|
@ -3700,10 +3700,10 @@ optionator@^0.9.3:
|
|||
prelude-ls "^1.2.1"
|
||||
type-check "^0.4.0"
|
||||
|
||||
otpauth@^9.0.2:
|
||||
version "9.2.2"
|
||||
resolved "https://registry.yarnpkg.com/otpauth/-/otpauth-9.2.2.tgz#64bda9ea501a5d86e69a964a45062f1f17f740f4"
|
||||
integrity sha512-2VcnYRUmq1dNckIfySNYP32ITWp1bvTeAEW0BSCR6G3GBf3a5zb9E+ubY62t3Dma9RjoHlvd7QpmzHfJZRkiNg==
|
||||
otpauth@^9:
|
||||
version "9.2.4"
|
||||
resolved "https://registry.yarnpkg.com/otpauth/-/otpauth-9.2.4.tgz#3b7941a689f63c31db43ab2494d3c2d90bc1f150"
|
||||
integrity sha512-t0Nioq2Up2ZaT5AbpXZLTjrsNtLc/g/rVSaEThmKLErAuT9mrnAKJryiPOKc3rCH+3ycWBgKpRHYn+DHqfaPiQ==
|
||||
dependencies:
|
||||
jssha "~3.3.1"
|
||||
|
||||
|
|
Loading…
Reference in a new issue