Display ente authenticator codes (#983)

This commit is contained in:
Neeraj Gupta 2023-04-03 12:25:36 +05:30 committed by GitHub
commit a5c7c3169e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 680 additions and 1 deletions

View file

@ -58,6 +58,7 @@
"ml-matrix": "^6.8.2",
"next": "^13.1.2",
"next-transpile-modules": "^10.0.0",
"otpauth": "^9.0.2",
"p-queue": "^7.1.0",
"photoswipe": "file:./thirdparty/photoswipe",
"piexifjs": "^1.0.6",
@ -77,6 +78,7 @@
"similarity-transformation": "^0.0.1",
"styled-components": "^5.3.5",
"transformation-matrix": "^2.10.0",
"vscode-uri": "^3.0.7",
"workbox-precaching": "^6.1.5",
"workbox-recipes": "^6.1.5",
"workbox-routing": "^6.1.5",

View file

@ -368,6 +368,7 @@
"UPLOAD_DIRS": "Folder",
"UPLOAD_GOOGLE_TAKEOUT": "Google takeout",
"DEDUPLICATE_FILES": "Deduplicate files",
"AUTHENTICATOR_SECTION": "Authenticator",
"NO_DUPLICATES_FOUND": "You've no duplicate files that can be cleared",
"CLUB_BY_CAPTURE_TIME": "Club by capture time",
"FILES": "Files",

View file

@ -0,0 +1,25 @@
import { Button } from '@mui/material';
export const AuthFooter = () => {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}>
<p>Download our mobile app to add &amp; manage your secrets.</p>
<a href="https://github.com/ente-io/auth#-download" download>
<Button
style={{
backgroundColor: 'green',
padding: '12px 18px',
color: 'white',
}}>
Download
</Button>
</a>
</div>
);
};

View file

@ -0,0 +1,190 @@
import React, { useState, useEffect } from 'react';
import { TOTP, HOTP } from 'otpauth';
import { Code } from 'types/authenticator/code';
import TimerProgress from './TimerProgress';
const TOTPDisplay = ({ issuer, account, code, nextCode }) => {
return (
<div
style={{
padding: '4px 16px',
display: 'flex',
alignItems: 'flex-start',
minWidth: '320px',
borderRadius: '4px',
backgroundColor: 'rgba(40, 40, 40, 0.6)',
justifyContent: 'space-between',
}}>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
minWidth: '200px',
}}>
<p
style={{
fontWeight: 'bold',
marginBottom: '0px',
fontSize: '14px',
textAlign: 'left',
}}>
{issuer}
</p>
<p
style={{
marginBottom: '8px',
textAlign: 'left',
fontSize: '12px',
}}>
{account}
</p>
<p
style={{
fontSize: '24px',
fontWeight: 'bold',
textAlign: 'left',
}}>
{code}
</p>
</div>
<div style={{ flex: 1 }} />
<div
style={{
display: 'flex',
flexDirection: 'column',
marginTop: '32px',
alignItems: 'flex-end',
minWidth: '120px',
textAlign: 'right',
}}>
<p
style={{
fontWeight: 'bold',
marginBottom: '0px',
fontSize: '10px',
marginTop: 'auto',
textAlign: 'right',
}}>
next
</p>
<p
style={{
fontSize: '14px',
fontWeight: 'bold',
marginBottom: '0px',
marginTop: 'auto',
textAlign: 'right',
}}>
{nextCode}
</p>
</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 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);
}
};
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' }}>
<TimerProgress period={codeInfo.period ?? Code.defaultPeriod} />
{codeErr === '' ? (
<TOTPDisplay
issuer={codeInfo.issuer}
account={codeInfo.account}
code={code}
nextCode={nextCode}
/>
) : (
<BadCodeInfo codeInfo={codeInfo} codeErr={codeErr} />
)}
</div>
);
};
export default OTPDisplay;

View file

@ -0,0 +1,46 @@
import React, { useState, useEffect } 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={{
width: '100%',
height: '3px',
backgroundColor: 'transparent',
}}>
<div
style={{
width: `${progress * 100}%`,
height: '100%',
backgroundColor: color,
}}></div>
</div>
);
};
export default TimerProgress;

View file

@ -63,6 +63,8 @@ export default function UtilitySection({ closeSidebar }) {
const redirectToDeduplicatePage = () => router.push(PAGES.DEDUPLICATE);
const redirectToAuthenticatorPage = () => router.push(PAGES.AUTH);
const somethingWentWrong = () =>
setDialogMessage({
title: t('ERROR'),
@ -98,6 +100,11 @@ export default function UtilitySection({ closeSidebar }) {
<SidebarButton onClick={redirectToDeduplicatePage}>
{t('DEDUPLICATE_FILES')}
</SidebarButton>
{isInternalUser() && (
<SidebarButton onClick={redirectToAuthenticatorPage}>
{t('AUTHENTICATOR_SECTION')}
</SidebarButton>
)}
<SidebarButton onClick={openPreferencesOptions}>
{t('PREFERENCES')}
</SidebarButton>

View file

@ -14,4 +14,6 @@ export enum PAGES {
SHARED_ALBUMS = '/shared-albums',
// ML_DEBUG = '/ml-debug',
DEDUPLICATE = '/deduplicate',
// AUTH page is used to show (auth)enticator codes
AUTH = '/auth',
}

91
src/pages/auth/index.tsx Normal file
View file

@ -0,0 +1,91 @@
import React, { useEffect, useState } from 'react';
import OTPDisplay from 'components/Authenicator/OTPDisplay';
import { getAuthCodes } from 'services/authenticator/authenticatorService';
import { CustomError } from 'utils/error';
import { PAGES } from 'constants/pages';
import { useRouter } from 'next/router';
import { AuthFooter } from 'components/Authenicator/AuthFooder';
const AuthenticatorCodesPage = () => {
const router = useRouter();
const [codes, setCodes] = useState([]);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
const fetchCodes = async () => {
try {
const res = await getAuthCodes();
setCodes(res);
} catch (err) {
if (err.message === CustomError.KEY_MISSING) {
router.push({
pathname: PAGES.CREDENTIALS,
query: { redirectPage: PAGES.AUTH },
});
} else {
// do not log errors
}
}
};
fetchCodes();
}, []);
const filteredCodes = codes.filter(
(secret) =>
(secret.issuer ?? '')
.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
(secret.account ?? '')
.toLowerCase()
.includes(searchTerm.toLowerCase())
);
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-start',
}}>
<div style={{ marginBottom: '2rem' }} />
<h2>ente Authenticator</h2>
{filteredCodes.length === 0 && searchTerm.length === 0 ? (
<></>
) : (
<input
type="text"
placeholder="Search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
)}
<div style={{ marginBottom: '1rem' }} />
{filteredCodes.length === 0 ? (
<div
style={{
alignItems: 'center',
display: 'flex',
textAlign: 'center',
marginTop: '32px',
}}>
{searchTerm.length !== 0 ? (
<p>No results found.</p>
) : (
<div />
)}
</div>
) : (
filteredCodes.map((code) => (
<OTPDisplay codeInfo={code} key={code.id} />
))
)}
<div style={{ marginBottom: '2rem' }} />
<AuthFooter />
<div style={{ marginBottom: '4rem' }} />
</div>
);
};
export default AuthenticatorCodesPage;

View file

@ -31,6 +31,8 @@ import VerifyMasterPasswordForm, {
export default function Credentials() {
const router = useRouter();
const routerRedirectPage =
router.query.redirectPage?.toString() ?? PAGES.GALLERY;
const [keyAttributes, setKeyAttributes] = useState<KeyAttributes>();
const appContext = useContext(AppContext);
const [user, setUser] = useState<User>();
@ -86,7 +88,7 @@ export default function Credentials() {
await decryptAndStoreToken(key);
const redirectURL = appContext.redirectURL;
appContext.setRedirectURL(null);
router.push(redirectURL ?? PAGES.GALLERY);
router.push(redirectURL ?? routerRedirectPage ?? PAGES.GALLERY);
} catch (e) {
logError(e, 'useMasterPassword failed');
}

View file

@ -0,0 +1,101 @@
import HTTPService from 'services/HTTPService';
import { AuthEntity, AuthKey } from 'types/authenticator/api';
import { Code } from 'types/authenticator/code';
import ComlinkCryptoWorker from 'utils/comlink/ComlinkCryptoWorker';
import { getEndpoint } from 'utils/common/apiUtil';
import { getActualKey, getToken } from 'utils/common/key';
import { logError } from 'utils/sentry';
const ENDPOINT = getEndpoint();
export const getAuthCodes = async (): Promise<Code[]> => {
const masterKey = await getActualKey();
try {
const authKeyData = await getAuthKey();
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
const authenticatorKey = await cryptoWorker.decryptB64(
authKeyData.encryptedKey,
authKeyData.header,
masterKey
);
// always fetch all data from server for now
const authEntity: AuthEntity[] = await getDiff(0);
const authCodes = await Promise.all(
authEntity
.filter((f) => !f.isDeleted)
.map(async (entity) => {
try {
const decryptedCode =
await cryptoWorker.decryptMetadata(
entity.encryptedData,
entity.header,
authenticatorKey
);
return Code.fromRawData(entity.id, decryptedCode);
} catch (e) {
logError(
Error('failed to parse code'),
'codeId = ' + entity.id
);
return null;
}
})
.filter((f) => f !== null || f !== undefined)
);
// sort by issuer name which can be undefined also
authCodes.sort((a, b) => {
if (a.issuer && b.issuer) {
return a.issuer.localeCompare(b.issuer);
}
if (a.issuer) {
return -1;
}
if (b.issuer) {
return 1;
}
return 0;
});
return authCodes;
} catch (e) {
logError(e, 'get authenticator entities failed');
throw e;
}
};
export const getAuthKey = async (): Promise<AuthKey> => {
try {
const resp = await HTTPService.get(
`${ENDPOINT}/authenticator/key`,
{},
{
'X-Auth-Token': getToken(),
}
);
return resp.data;
} catch (e) {
logError(e, 'Get key failed');
throw e;
}
};
// return a promise which resolves to list of AuthEnitity
export const getDiff = async (
sinceTime: number,
limit = 2500
): Promise<AuthEntity[]> => {
try {
const resp = await HTTPService.get(
`${ENDPOINT}/authenticator/entity/diff`,
{
sinceTime,
limit,
},
{
'X-Auth-Token': getToken(),
}
);
return resp.data.diff;
} catch (e) {
logError(e, 'Get diff failed');
throw e;
}
};

View file

@ -0,0 +1,13 @@
export interface AuthEntity {
id: string;
encryptedData: string | null;
header: string | null;
isDeleted: boolean;
createdAt: number;
updatedAt: number;
}
export interface AuthKey {
encryptedKey: string;
header: string;
}

View file

@ -0,0 +1,182 @@
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 === 'totp') {
return 'totp';
} else if (oauthType === 'hotp') {
return 'hotp';
}
throw new Error(`Unsupported format with host ${oauthType}`);
}
static getSanitizedSecret(uriParams): string {
return uriParams['secret'].replace(/ /g, '').toUpperCase();
}
}

View file

@ -3214,6 +3214,11 @@ json5@^1.0.1:
dependencies:
minimist "^1.2.0"
jssha@~3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/jssha/-/jssha-3.3.0.tgz#44b5531bcf55a12f4a388476c647a9a1cca92839"
integrity sha512-w9OtT4ALL+fbbwG3gw7erAO0jvS5nfvrukGPMWIAoea359B26ALXGpzy4YJSp9yGnpUvuvOw1nSjSoHDfWSr1w==
"jsx-ast-utils@^2.4.1 || ^3.0.0":
version "3.2.0"
resolved "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.0.tgz"
@ -3736,6 +3741,13 @@ optionator@^0.9.1:
type-check "^0.4.0"
word-wrap "^1.2.3"
otpauth@^9.0.2:
version "9.0.2"
resolved "https://registry.yarnpkg.com/otpauth/-/otpauth-9.0.2.tgz#5f369bdeb74513fb6c0f25c5ae5ac6b3780ebc89"
integrity sha512-0TzpkJYg24VvIK3/K91HKpTtMlwm73UoThhcGY8fZsXcwHDrqf008rfdOjj3NnQuyuT11+vHyyO//qRzi6OZ9A==
dependencies:
jssha "~3.3.0"
p-limit@^3.0.2:
version "3.1.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
@ -4880,6 +4892,11 @@ util-deprecate@^1.0.1, util-deprecate@~1.0.1:
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
vscode-uri@^3.0.7:
version "3.0.7"
resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.7.tgz#6d19fef387ee6b46c479e5fb00870e15e58c1eb8"
integrity sha512-eOpPHogvorZRobNqJGhapa0JdwaxpjVvyBp0QIUMRMSf8ZAlqOdEquKuRmw9Qwu0qXtJIWqFtMkmvJjUZmMjVA==
void-elements@3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09"