diff --git a/app/components/OneTimePasswordInput.tsx b/app/components/OneTimePasswordInput.tsx new file mode 100644 index 0000000000..6ebfdc99cd --- /dev/null +++ b/app/components/OneTimePasswordInput.tsx @@ -0,0 +1,59 @@ +import * as OneTimePasswordField from "@radix-ui/react-one-time-password-field"; +import * as React from "react"; +import styled from "styled-components"; +import { s } from "@shared/styles"; + +type Props = React.ComponentProps & { + /** The length of the OTP */ + length?: number; +}; + +export const OneTimePasswordInput = React.forwardRef( + function _OneTimePasswordInput( + { length = 6, ...rest }: Props, + ref: React.RefObject + ) { + return ( + + {Array.from({ length }, (_, i) => ( + + ))} + + + ); + } +); + +const OneTimePasswordRoot = styled(OneTimePasswordField.Root)` + display: flex; + gap: 0.5rem; + flex-wrap: nowrap; + justify-content: space-between; +`; + +const OneTimePasswordInputField = styled(OneTimePasswordField.Input)` + all: unset; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + text-align: center; + border-radius: 4px; + font-size: 15px; + color: ${s("text")}; + background: ${s("background")}; + box-shadow: 0 0 0 1px ${s("inputBorder")}; + padding: 0; + height: 38px; + width: 38px; + line-height: 1; + transition: box-shadow 0.1s ease-in-out; + + &:focus { + box-shadow: 0 0 0 2px ${s("inputBorderFocused")}; + } + &::selection { + background-color: ${s("background")}; + color: ${s("text")}; + } +`; diff --git a/app/scenes/Login/Login.tsx b/app/scenes/Login/Login.tsx index a4f03a4ca6..f12ba85a54 100644 --- a/app/scenes/Login/Login.tsx +++ b/app/scenes/Login/Login.tsx @@ -7,7 +7,8 @@ import { useLocation, Link, Redirect } from "react-router-dom"; import styled from "styled-components"; import { getCookie, setCookie } from "tiny-cookie"; import { s } from "@shared/styles"; -import { UserPreference } from "@shared/types"; +import { Client, UserPreference } from "@shared/types"; +import { isPWA } from "@shared/utils/browser"; import { parseDomain } from "@shared/utils/domains"; import { Config } from "~/stores/AuthStore"; import { AvatarSize } from "~/components/Avatar"; @@ -18,6 +19,7 @@ import Heading from "~/components/Heading"; import OutlineIcon from "~/components/Icons/OutlineIcon"; import Input from "~/components/Input"; import LoadingIndicator from "~/components/LoadingIndicator"; +import { OneTimePasswordInput } from "~/components/OneTimePasswordInput"; import PageTitle from "~/components/PageTitle"; import TeamLogo from "~/components/TeamLogo"; import Text from "~/components/Text"; @@ -199,6 +201,8 @@ function Login({ children, onBack }: Props) { config.providers, (provider) => provider.id === auth.lastSignedIn && !isCreate ); + const clientType = Desktop.isElectron() ? Client.Desktop : Client.Web; + const preferOTP = Desktop.isElectron() || isPWA; if (firstRun) { return ; @@ -212,14 +216,43 @@ function Login({ children, onBack }: Props) { {t("Check your email")} - - }} - /> - -
+ {preferOTP ? ( + <> + + }} + /> + . + +
+ + + + +
+ + {t("Continue")} + + + + ) : ( + <> + + }} + /> + +
+ + )} {t("Back to login")} @@ -324,6 +357,10 @@ function Login({ children, onBack }: Props) { ); } +const Form = styled.form` + margin: 1em 0; +`; + const StyledHeading = styled(Heading)` margin: 0; `; diff --git a/app/scenes/Login/components/AuthenticationProvider.tsx b/app/scenes/Login/components/AuthenticationProvider.tsx index d70f3dcb8d..9a2682d75a 100644 --- a/app/scenes/Login/components/AuthenticationProvider.tsx +++ b/app/scenes/Login/components/AuthenticationProvider.tsx @@ -2,6 +2,8 @@ import { EmailIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; +import { Client } from "@shared/types"; +import { isPWA } from "@shared/utils/browser"; import ButtonLarge from "~/components/ButtonLarge"; import InputLarge from "~/components/InputLarge"; import PluginIcon from "~/components/PluginIcon"; @@ -17,12 +19,16 @@ type Props = React.ComponentProps & { onEmailSuccess: (email: string) => void; }; +type AuthState = "initial" | "email" | "code"; + function AuthenticationProvider(props: Props) { const { t } = useTranslation(); - const [showEmailSignin, setShowEmailSignin] = React.useState(false); + const [authState, setAuthState] = React.useState("initial"); const [isSubmitting, setSubmitting] = React.useState(false); const [email, setEmail] = React.useState(""); const { isCreate, id, name, authUrl, onEmailSuccess, ...rest } = props; + const clientType = Desktop.isElectron() ? Client.Desktop : Client.Web; + const preferOTP = Desktop.isElectron() || isPWA; const handleChangeEmail = (event: React.ChangeEvent) => { setEmail(event.target.value); @@ -33,25 +39,27 @@ function AuthenticationProvider(props: Props) { ) => { event.preventDefault(); - if (showEmailSignin && email) { + if (authState === "email" && email) { setSubmitting(true); try { const response = await client.post(event.currentTarget.action, { email, - client: Desktop.isElectron() ? "desktop" : undefined, + client: clientType, + preferOTP, }); if (response.redirect) { window.location.href = response.redirect; } else { - onEmailSuccess(email); + setSubmitting(false); + onEmailSuccess?.(email); } - } finally { + } catch (_err) { setSubmitting(false); } } else { - setShowEmailSignin(true); + setAuthState("email"); } }; @@ -65,7 +73,7 @@ function AuthenticationProvider(props: Props) { return (
- {showEmailSignin ? ( + {authState === "email" ? ( <> Sorry, the code you entered is invalid or has expired. + ); case "domain-not-allowed": return ( diff --git a/package.json b/package.json index 7ac110969d..7798cf01a3 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-visually-hidden": "^1.2.2", + "@radix-ui/react-one-time-password-field": "^0.1.7", "@renderlesskit/react": "^0.11.0", "@sentry/node": "^7.120.3", "@sentry/react": "^7.120.3", diff --git a/plugins/email/server/auth/email.ts b/plugins/email/server/auth/email.ts index edbd387616..58e574a945 100644 --- a/plugins/email/server/auth/email.ts +++ b/plugins/email/server/auth/email.ts @@ -6,11 +6,13 @@ import SigninEmail from "@server/emails/templates/SigninEmail"; import WelcomeEmail from "@server/emails/templates/WelcomeEmail"; import env from "@server/env"; import { AuthorizationError } from "@server/errors"; +import Logger from "@server/logging/Logger"; import { rateLimiter } from "@server/middlewares/rateLimiter"; import validate from "@server/middlewares/validate"; import { User, Team } from "@server/models"; import { APIContext } from "@server/types"; import { RateLimiterStrategy } from "@server/utils/RateLimiter"; +import { VerificationCode } from "@server/utils/VerificationCode"; import { signIn } from "@server/utils/authentication"; import { getUserForEmailSigninToken } from "@server/utils/jwt"; import * as T from "./schema"; @@ -22,7 +24,7 @@ router.post( rateLimiter(RateLimiterStrategy.TenPerHour), validate(T.EmailSchema), async (ctx: APIContext) => { - const { email, client } = ctx.input.body; + const { email, client, preferOTP } = ctx.input.body; const domain = parseDomain(ctx.request.hostname); @@ -68,12 +70,19 @@ router.post( return; } - // send email to users email address with a short-lived token + // Generate both a link token and a 6-digit verification code + const token = preferOTP ? undefined : user.getEmailSigninToken(); + const verificationCode = preferOTP + ? await user.getEmailVerificationCode() + : undefined; + + // send email to users email address with a short-lived token and code await new SigninEmail({ to: user.email, - token: user.getEmailSigninToken(), + token, teamUrl: team.url, client, + verificationCode, }).schedule(); user.lastSigninEmailSentAt = new Date(); @@ -91,6 +100,8 @@ const emailCallback = async (ctx: APIContext) => { const token = query?.token || body?.token; const client = query?.client || body?.client || Client.Web; const follow = query?.follow || body?.follow; + const code = query?.code || body?.code; + const email = query?.email || body?.email; // The link in the email does not include the follow query param, this // is to help prevent anti-virus, and email clients from pre-fetching the link @@ -103,10 +114,32 @@ const emailCallback = async (ctx: APIContext) => { let user!: User; try { - user = await getUserForEmailSigninToken(token as string); - } catch (_err) { - ctx.redirect(`/?notice=expired-token`); - return; + if (token) { + user = await getUserForEmailSigninToken(token as string); + } else if (code && email) { + user = await User.scope("withTeam").findOne({ + rejectOnEmpty: true, + where: { + email: email.trim().toLowerCase(), + }, + }); + + const isValid = await VerificationCode.verify(email, code); + + if (!isValid) { + ctx.redirect(`/?notice=invalid-code`); + return; + } + + // Delete the code after successful verification + await VerificationCode.delete(email); + } else { + ctx.redirect("/?notice=auth-error"); + return; + } + } catch (err) { + Logger.debug("authentication", err); + return ctx.redirect("/?notice=auth-error"); } if (!user.team.emailSigninEnabled) { diff --git a/plugins/email/server/auth/schema.ts b/plugins/email/server/auth/schema.ts index 9202df3bb8..572ca5452e 100644 --- a/plugins/email/server/auth/schema.ts +++ b/plugins/email/server/auth/schema.ts @@ -6,22 +6,31 @@ export const EmailSchema = BaseSchema.extend({ body: z.object({ email: z.string().email(), client: z.nativeEnum(Client).default(Client.Web), + preferOTP: z.boolean().default(false), }), }); export type EmailReq = z.infer; +const callbackDataSchema = z + .object({ + token: z.string().optional(), + code: z.string().optional(), + email: z.string().email().optional(), + client: z.nativeEnum(Client).optional(), + follow: z.string().default(""), + }) + .refine( + (data: { code?: string; email?: string; token?: string }) => + !(data.code && !data.email) && !(data.email && !data.code && !data.token), + { + message: "Both code and email must be provided together", + } + ); + export const EmailCallbackSchema = BaseSchema.extend({ - query: z.object({ - token: z.string().optional(), - client: z.nativeEnum(Client).optional(), - follow: z.string().default(""), - }), - body: z.object({ - token: z.string().optional(), - client: z.nativeEnum(Client).optional(), - follow: z.string().default(""), - }), + query: callbackDataSchema, + body: callbackDataSchema, }); export type EmailCallbackReq = z.infer; diff --git a/server/emails/templates/SigninEmail.tsx b/server/emails/templates/SigninEmail.tsx index dc87a3ae2c..a42721a368 100644 --- a/server/emails/templates/SigninEmail.tsx +++ b/server/emails/templates/SigninEmail.tsx @@ -1,4 +1,3 @@ -import * as React from "react"; import { Client } from "@shared/types"; import env from "@server/env"; import logger from "@server/logging/Logger"; @@ -12,9 +11,10 @@ import Header from "./components/Header"; import Heading from "./components/Heading"; type Props = EmailProps & { - token: string; + token?: string; teamUrl: string; client: Client; + verificationCode?: string; }; /** @@ -25,51 +25,101 @@ export default class SigninEmail extends BaseEmail { return EmailMessageCategory.Authentication; } - protected subject() { - return "Magic signin link"; + protected subject({ token }: Props) { + return token ? "Magic signin link" : "Sign in verification code"; } protected preview(): string { return `Here’s your link to signin to ${env.APP_NAME}.`; } - protected renderAsText({ token, teamUrl, client }: Props): string { - return ` -Use the link below to signin to ${env.APP_NAME}: + protected renderAsText({ + token, + teamUrl, + client, + verificationCode, + }: Props): string { + if (token) { + return ` +Use the link below to sign in: ${this.signinLink(token, client)} -If your magic link expired you can request a new one from your team’s +If the link expired you can request a new one from your team's +signin page at: ${teamUrl} +`; + } + + return ` +Enter this verification code: ${verificationCode} + +If the code expired you can request a new one from your team's signin page at: ${teamUrl} `; } - protected render({ token, client, teamUrl }: Props) { + protected render({ token, client, teamUrl, verificationCode }: Props) { if (env.isDevelopment) { - logger.debug("email", `Sign-In link: ${this.signinLink(token, client)}`); + if (token) { + logger.debug( + "email", + `Sign-In link: ${this.signinLink(token, client)}` + ); + } + if (verificationCode) { + logger.debug("email", `Verification code: ${verificationCode}`); + } } return (
- - Magic Sign-in Link -

Click the button below to sign in to {env.APP_NAME}.

- -

- -

- -

- If your magic link expired you can request a new one from your - team’s sign-in page at: {teamUrl} -

- - + {token ? ( + + Magic Sign-in Link +

Click the button below to sign in to {env.APP_NAME}.

+ +

+ +

+ +

+ If the link expired you can request a new one from your team's + sign-in page at: {teamUrl} +

+ + ) : ( + + Sign-in Code +

Enter this code on your team's sign-in page to continue.

+ +

+ {verificationCode} +

+ +

+ If the code expired you can request a new one from your team's + sign-in page at: {teamUrl} +

+ + )}