diff --git a/.env.test b/.env.test index 91d1b98b7f3..4add5784885 100644 --- a/.env.test +++ b/.env.test @@ -16,3 +16,5 @@ AWS_S3_UPLOADS_ACCESS_KEY_ID="dev-s3-access-key-id" AWS_S3_UPLOADS_SECRET_ACCESS_KEY="dev-s3-secret-access-key" AWS_S3_UPLOADS_FORCE_PATH_STYLE=true FILE_UPLOAD_PROVIDER="AWS_S3" + +USER_EMAIL_ALLOW_LIST='["charlie@example.com", "mfa-enable-totp@example.com", "mfa-totp-login@example.com", "mfa-backup-code@example.com", "mfa-disable-totp@example.com", "mfa-wrong-code@example.com"]' diff --git a/apps/hash-api/src/auth/create-auth-handlers.ts b/apps/hash-api/src/auth/create-auth-handlers.ts index e2249dc0455..4839c02d856 100644 --- a/apps/hash-api/src/auth/create-auth-handlers.ts +++ b/apps/hash-api/src/auth/create-auth-handlers.ts @@ -13,7 +13,7 @@ import { createUser, getUser } from "../graph/knowledge/system-types/user"; import { systemAccountId } from "../graph/system-account"; import { hydraAdmin } from "./ory-hydra"; import type { KratosUserIdentity } from "./ory-kratos"; -import { kratosFrontendApi } from "./ory-kratos"; +import { isUserEmailVerified, kratosFrontendApi } from "./ory-kratos"; const KRATOS_API_KEY = getRequiredEnv("KRATOS_API_KEY"); @@ -106,6 +106,7 @@ export const getUserAndSession = async ({ logger: Logger; sessionToken?: string; }): Promise<{ + primaryEmailVerified?: boolean; session?: Session; user?: User; }> => { @@ -118,9 +119,11 @@ export const getUserAndSession = async ({ }) .then(({ data }) => data) .catch((err: AxiosError) => { - // 403 on toSession means that we need to request 2FA if (err.response && err.response.status === 403) { - /** @todo: figure out if this should be handled here, or in the next.js app (when implementing 2FA) */ + logger.debug( + "Session requires AAL2 but only has AAL1. Treating as unauthenticated.", + ); + return undefined; } logger.debug( `Kratos response error: Could not fetch session, got: [${ @@ -139,6 +142,13 @@ export const getUserAndSession = async ({ const { id: kratosIdentityId, traits } = identity as KratosUserIdentity; + const primaryEmailAddress = traits.emails[0]; + + const primaryEmailVerified = + identity.verifiable_addresses?.find( + ({ value }) => value === primaryEmailAddress, + )?.verified === true; + const user = await getUser(context, authentication, { kratosIdentityId, emails: traits.emails, @@ -150,7 +160,7 @@ export const getUserAndSession = async ({ ); } - return { session: kratosSession, user }; + return { primaryEmailVerified, session: kratosSession, user }; } return {}; @@ -185,6 +195,9 @@ export const createAuthMiddleware = (params: { }, ); if (user) { + req.primaryEmailVerified = await isUserEmailVerified( + user.kratosIdentityId, + ); req.user = user; next(); return; @@ -192,35 +205,15 @@ export const createAuthMiddleware = (params: { } } - const { session, user } = await getUserAndSession({ + const { primaryEmailVerified, session, user } = await getUserAndSession({ context, cookie: req.header("cookie"), logger, sessionToken: accessOrSessionToken, }); - - const kratosSession = await kratosFrontendApi - .toSession({ - cookie: req.header("cookie"), - xSessionToken: accessOrSessionToken, - }) - .then(({ data }) => data) - .catch((err: AxiosError) => { - // 403 on toSession means that we need to request 2FA - if (err.response && err.response.status === 403) { - /** @todo: figure out if this should be handled here, or in the next.js app (when implementing 2FA) */ - } - logger.debug( - `Kratos response error: Could not fetch session, got: [${ - err.response?.status - }] ${JSON.stringify(err.response?.data)}`, - ); - return undefined; - }); - - if (kratosSession) { + if (session) { + req.primaryEmailVerified = primaryEmailVerified; req.session = session; - req.user = user; } diff --git a/apps/hash-api/src/auth/create-unverified-email-cleanup-job.ts b/apps/hash-api/src/auth/create-unverified-email-cleanup-job.ts new file mode 100644 index 00000000000..5d3d0fbaee4 --- /dev/null +++ b/apps/hash-api/src/auth/create-unverified-email-cleanup-job.ts @@ -0,0 +1,221 @@ +import type { Logger } from "@local/hash-backend-utils/logger"; +import { queryEntities } from "@local/hash-graph-sdk/entity"; +import { + currentTimeInstantTemporalAxes, + generateVersionedUrlMatchingFilter, +} from "@local/hash-isomorphic-utils/graph-queries"; +import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; +import type { User as UserEntity } from "@local/hash-isomorphic-utils/system-types/user"; +import type { Identity } from "@ory/kratos-client"; + +import type { ImpureGraphContext } from "../graph/context-types"; +import { getUserFromEntity } from "../graph/knowledge/system-types/user"; +import { systemAccountId } from "../graph/system-account"; +import { deleteKratosIdentity, kratosIdentityApi } from "./ory-kratos"; + +/** + * Identities created before this date are excluded from cleanup, preventing + * retroactive deletion of accounts that existed before email verification + * was introduced. + */ +const DEFAULT_ROLLOUT_AT = new Date("2026-02-14T00:00:00.000Z"); +const DEFAULT_RELEASE_TTL_HOURS = 24 * 7; +const DEFAULT_SWEEP_INTERVAL_MINUTES = 60; + +const parsePositiveIntegerEnv = ( + rawValue: string | undefined, + fallback: number, + envVarName: string, +) => { + if (!rawValue) { + return fallback; + } + + const parsedValue = Number.parseInt(rawValue, 10); + if (Number.isNaN(parsedValue) || parsedValue <= 0) { + throw new Error( + `${envVarName} must be a positive integer, got "${rawValue}"`, + ); + } + + return parsedValue; +}; + +const parseRolloutDate = (rawValue: string | undefined): Date => { + if (!rawValue) { + return DEFAULT_ROLLOUT_AT; + } + + const parsedDate = new Date(rawValue); + if (Number.isNaN(parsedDate.getTime())) { + throw new Error( + `HASH_EMAIL_VERIFICATION_ROLLOUT_AT must be an ISO-8601 date, got "${rawValue}"`, + ); + } + + return parsedDate; +}; + +const parseIdentityCreatedAt = (identity: Identity): Date | undefined => { + if (!identity.created_at) { + return undefined; + } + + const createdAt = new Date(identity.created_at); + + if (Number.isNaN(createdAt.getTime())) { + return undefined; + } + + return createdAt; +}; + +const isPrimaryEmailVerified = (identity: Identity): boolean => { + const identityTraits = identity.traits as { emails?: string[] }; + const primaryEmailAddress = identityTraits.emails?.[0]; + + if (!primaryEmailAddress) { + return false; + } + + return ( + identity.verifiable_addresses?.find( + ({ value }) => value === primaryEmailAddress, + )?.verified === true + ); +}; + +export const createUnverifiedEmailCleanupJob = ({ + context, + logger, +}: { + context: ImpureGraphContext; + logger: Logger; +}) => { + const rolloutAt = parseRolloutDate( + process.env.HASH_EMAIL_VERIFICATION_ROLLOUT_AT, + ); + + const releaseTtlHours = parsePositiveIntegerEnv( + process.env.HASH_EMAIL_VERIFICATION_RELEASE_TTL_HOURS, + DEFAULT_RELEASE_TTL_HOURS, + "HASH_EMAIL_VERIFICATION_RELEASE_TTL_HOURS", + ); + + const sweepIntervalMinutes = parsePositiveIntegerEnv( + process.env.HASH_EMAIL_VERIFICATION_RELEASE_SWEEP_INTERVAL_MINUTES, + DEFAULT_SWEEP_INTERVAL_MINUTES, + "HASH_EMAIL_VERIFICATION_RELEASE_SWEEP_INTERVAL_MINUTES", + ); + + const releaseTtlMs = releaseTtlHours * 60 * 60 * 1_000; + const sweepIntervalMs = sweepIntervalMinutes * 60 * 1_000; + + const cleanupUnverifiedUsers = async () => { + const now = Date.now(); + const authentication = { actorId: systemAccountId }; + + const { entities: userEntities } = await queryEntities( + context, + authentication, + { + filter: { + all: [ + generateVersionedUrlMatchingFilter( + systemEntityTypes.user.entityTypeId, + { + ignoreParents: true, + }, + ), + { + equal: [{ path: ["archived"] }, { parameter: false }], + }, + ], + }, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: false, + includePermissions: false, + }, + ); + + let releasedEmailCount = 0; + + for (const userEntity of userEntities) { + const user = getUserFromEntity({ entity: userEntity }); + + if (user.isAccountSignupComplete) { + continue; + } + + try { + const { data: identity } = await kratosIdentityApi.getIdentity({ + id: user.kratosIdentityId, + }); + + const createdAt = parseIdentityCreatedAt(identity); + if (!createdAt || createdAt < rolloutAt) { + continue; + } + + if (now - createdAt.getTime() < releaseTtlMs) { + continue; + } + + const primaryEmail = user.emails[0]; + if (!primaryEmail) { + logger.warn( + `User ${user.accountId} (${user.kratosIdentityId}) has no email addresses, skipping`, + ); + continue; + } + + if (isPrimaryEmailVerified(identity)) { + continue; + } + + await user.entity.archive( + context.graphApi, + authentication, + context.provenance, + ); + await deleteKratosIdentity({ + kratosIdentityId: user.kratosIdentityId, + }); + + releasedEmailCount += 1; + } catch (error) { + logger.warn( + `Failed to process unverified user ${user.accountId} (${user.kratosIdentityId}) for email release: ${error}`, + ); + } + } + + if (releasedEmailCount > 0) { + logger.info( + `Released ${releasedEmailCount} unverified email address${releasedEmailCount === 1 ? "" : "es"}.`, + ); + } + }; + + let interval: NodeJS.Timeout | undefined; + let inFlightCleanup: Promise | undefined; + + return { + start: async () => { + logger.info( + `Starting unverified-email cleanup job (rolloutAt=${rolloutAt.toISOString()}, ttlHours=${releaseTtlHours}, intervalMinutes=${sweepIntervalMinutes})`, + ); + + await cleanupUnverifiedUsers(); + interval = setInterval(() => { + inFlightCleanup = cleanupUnverifiedUsers(); + }, sweepIntervalMs); + }, + stop: async () => { + if (interval) { + clearInterval(interval); + } + await inFlightCleanup; + }, + }; +}; diff --git a/apps/hash-api/src/auth/ory-kratos.ts b/apps/hash-api/src/auth/ory-kratos.ts index 5057401c5b7..127298df450 100644 --- a/apps/hash-api/src/auth/ory-kratos.ts +++ b/apps/hash-api/src/auth/ory-kratos.ts @@ -27,13 +27,35 @@ export type KratosUserIdentity = Omit & { export const createKratosIdentity = async ( params: Omit & { traits: KratosUserIdentityTraits; + /** + * If true, all emails in the traits will be marked as verified + * in the created identity. This is useful in tests to bypass + * email verification requirements. + */ + verifyEmails?: boolean; }, ): Promise => { + const { verifyEmails, ...rest } = params; + + const createIdentityBody: CreateIdentityBody = { + schema_id: "default", + ...rest, + }; + + if (verifyEmails) { + createIdentityBody.verifiable_addresses = params.traits.emails.map( + (email) => ({ + value: email, + verified: true, + verified_at: new Date().toISOString(), + via: "email" as const, + status: "completed", + }), + ); + } + const { data: kratosUserIdentity } = await kratosIdentityApi.createIdentity({ - createIdentityBody: { - schema_id: "default", - ...params, - }, + createIdentityBody, }); return kratosUserIdentity; @@ -46,3 +68,48 @@ export const deleteKratosIdentity = async (params: { id: params.kratosIdentityId, }); }; + +export const isUserEmailVerified = async ( + kratosIdentityId: string, +): Promise => { + const { data: identity } = await kratosIdentityApi.getIdentity({ + id: kratosIdentityId, + }); + + return ( + identity.verifiable_addresses?.some(({ verified }) => verified) ?? false + ); +}; + +/** + * Mark all verifiable email addresses on a Kratos identity as verified + * using the admin API. This is useful in tests to bypass email verification + * when the identity was created without `verifyEmails: true`. + */ +export const verifyAllKratosIdentityEmails = async ( + kratosIdentityId: string, +): Promise => { + const { data: identity } = await kratosIdentityApi.getIdentity({ + id: kratosIdentityId, + }); + + const verifiedAddresses = (identity.verifiable_addresses ?? []).map( + (address) => ({ + ...address, + verified: true, + verified_at: address.verified_at ?? new Date().toISOString(), + status: "completed", + }), + ); + + await kratosIdentityApi.patchIdentity({ + id: kratosIdentityId, + jsonPatch: [ + { + op: "replace", + path: "/verifiable_addresses", + value: verifiedAddresses, + }, + ], + }); +}; diff --git a/apps/hash-api/src/express.d.ts b/apps/hash-api/src/express.d.ts index 4c0bb22050e..96bee9b7d84 100644 --- a/apps/hash-api/src/express.d.ts +++ b/apps/hash-api/src/express.d.ts @@ -10,6 +10,7 @@ declare global { context: ImpureGraphContext & { vaultClient?: VaultClient; }; + primaryEmailVerified: boolean | undefined; session: Session | undefined; user: User | undefined; } diff --git a/apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts b/apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts index ccb2b2af6d9..2cdb80120b6 100644 --- a/apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts +++ b/apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts @@ -13,6 +13,7 @@ import { import type { UserProperties } from "@local/hash-isomorphic-utils/system-types/user"; import { GraphQLError } from "graphql"; +import { isUserEmailVerified } from "../../../../../auth/ory-kratos"; import * as Error from "../../../../../graphql/error"; import { userHasAccessToHash } from "../../../../../shared/user-has-access-to-hash"; import type { ImpureGraphContext } from "../../../../context-types"; @@ -189,6 +190,12 @@ export const userBeforeEntityUpdateHookCallback: BeforeUpdateEntityHookCallback ); } + if (!(await isUserEmailVerified(user.kratosIdentityId))) { + throw Error.forbidden( + "You must verify your email address before completing account setup.", + ); + } + if (!updatedShortname || !updatedDisplayName) { throw Error.badUserInput( "You must set both shortname and display name to complete account signup.", diff --git a/apps/hash-api/src/graphql/resolvers/index.ts b/apps/hash-api/src/graphql/resolvers/index.ts index 2d88cc9b6e4..101b28388a2 100644 --- a/apps/hash-api/src/graphql/resolvers/index.ts +++ b/apps/hash-api/src/graphql/resolvers/index.ts @@ -119,17 +119,14 @@ export const resolvers: Omit & { Mutation: Required; } = { Query: { - // Logged in and signed up users only, - getBlockProtocolBlocks: getBlockProtocolBlocksResolver, - // Logged in users only - me: loggedInMiddleware(meResolver), - getWaitlistPosition: loggedInMiddleware(getWaitlistPositionResolver), - // Admins - getUsageRecords: loggedInMiddleware(getUsageRecordsResolver), - // Any user + /** Any user, including anonymous */ isShortnameTaken: isShortnameTakenResolver, embedCode, - // Ontology + hashInstanceSettings: hashInstanceSettingsResolver, + hasAccessToHash: hasAccessToHashResolver, + getPendingInvitationByEntityId: getPendingInvitationByEntityIdResolver, + + /** Any user – type fetching */ queryDataTypes: queryDataTypesResolver, queryDataTypeSubgraph: queryDataTypeSubgraphResolver, findDataTypeConversionTargets: findDataTypeConversionTargetsResolver, @@ -138,10 +135,19 @@ export const resolvers: Omit & { queryEntityTypes: queryEntityTypesResolver, queryEntityTypeSubgraph: queryEntityTypeSubgraphResolver, getClosedMultiEntityTypes: getClosedMultiEntityTypesResolver, - // Knowledge + + /** Logged in users (who may not have completed signup) */ + me: loggedInMiddleware(meResolver), + getWaitlistPosition: loggedInMiddleware(getWaitlistPositionResolver), + + /** Logged in and signed up users */ + getBlockProtocolBlocks: loggedInAndSignedUpMiddleware( + getBlockProtocolBlocksResolver, + ), + getUsageRecords: loggedInAndSignedUpMiddleware(getUsageRecordsResolver), pageComments: loggedInAndSignedUpMiddleware(pageCommentsResolver), blocks: loggedInAndSignedUpMiddleware(blocksResolver), - getEntityDiffs: getEntityDiffsResolver, + getEntityDiffs: loggedInAndSignedUpMiddleware(getEntityDiffsResolver), getFlowRuns: loggedInAndSignedUpMiddleware(getFlowRunsResolver), getFlowRunById: loggedInAndSignedUpMiddleware(getFlowRunByIdResolver), isEntityPublic: loggedInAndSignedUpMiddleware(isEntityPublicResolver), @@ -150,37 +156,46 @@ export const resolvers: Omit & { "`getEntityAuthorizationRelationships` is not implemented", ); }), - countEntities: countEntitiesResolver, - queryEntities: queryEntitiesResolver, - queryEntitySubgraph: queryEntitySubgraphResolver, - hashInstanceSettings: hashInstanceSettingsResolver, + countEntities: loggedInAndSignedUpMiddleware(countEntitiesResolver), + queryEntities: loggedInAndSignedUpMiddleware(queryEntitiesResolver), + queryEntitySubgraph: loggedInAndSignedUpMiddleware( + queryEntitySubgraphResolver, + ), getMyPendingInvitations: loggedInAndSignedUpMiddleware( getMyPendingInvitationsResolver, ), - getPendingInvitationByEntityId: getPendingInvitationByEntityIdResolver, - // Integration + getLinearOrganization: loggedInAndSignedUpMiddleware( getLinearOrganizationResolver, ), checkUserPermissionsOnEntity: (_, { metadata }, context, info) => checkUserPermissionsOnEntity({ metadata }, _, context, info), - checkUserPermissionsOnEntityType: checkUserPermissionsOnEntityTypeResolver, - checkUserPermissionsOnDataType: checkUserPermissionsOnDataTypeResolver, - hasAccessToHash: loggedInMiddleware(hasAccessToHashResolver), - // Generation - generateInverse: loggedInMiddleware(generateInverseResolver), - generatePlural: loggedInMiddleware(generatePluralResolver), + checkUserPermissionsOnEntityType: loggedInAndSignedUpMiddleware( + checkUserPermissionsOnEntityTypeResolver, + ), + checkUserPermissionsOnDataType: loggedInAndSignedUpMiddleware( + checkUserPermissionsOnDataTypeResolver, + ), + + generateInverse: loggedInAndSignedUpMiddleware(generateInverseResolver), + generatePlural: loggedInAndSignedUpMiddleware(generatePluralResolver), isGenerationAvailable: isGenerationAvailableResolver, - validateEntity: validateEntityResolver, + validateEntity: loggedInAndSignedUpMiddleware(validateEntityResolver), }, Mutation: { - // Logged in and signed up users only + /** Logged in users (who may not have completed signup) */ + submitEarlyAccessForm: loggedInMiddleware(submitEarlyAccessFormResolver), + /** The resolver itself gates updates to only the user entity if they haven't completed signup */ + updateEntity: loggedInMiddleware(updateEntityResolver), + + /** Logged in and signed up users */ updateBlockCollectionContents: loggedInAndSignedUpMiddleware( updateBlockCollectionContents, ), requestFileUpload: loggedInAndSignedUpMiddleware(requestFileUpload), createFileFromUrl: loggedInAndSignedUpMiddleware(createFileFromUrl), + // Ontology createPropertyType: loggedInAndSignedUpMiddleware( createPropertyTypeResolver, @@ -205,12 +220,12 @@ export const resolvers: Omit & { unarchiveEntityType: loggedInAndSignedUpMiddleware( unarchiveEntityTypeResolver, ), + // Knowledge createEntity: loggedInAndSignedUpMiddleware(createEntityResolver), - updateEntity: loggedInMiddleware(updateEntityResolver), - updateEntities: loggedInMiddleware(updateEntitiesResolver), - archiveEntity: loggedInMiddleware(archiveEntityResolver), - archiveEntities: loggedInMiddleware(archiveEntitiesResolver), + updateEntities: loggedInAndSignedUpMiddleware(updateEntitiesResolver), + archiveEntity: loggedInAndSignedUpMiddleware(archiveEntityResolver), + archiveEntities: loggedInAndSignedUpMiddleware(archiveEntitiesResolver), createPage: loggedInAndSignedUpMiddleware(createPageResolver), setParentPage: loggedInAndSignedUpMiddleware(setParentPageResolver), updatePage: loggedInAndSignedUpMiddleware(updatePageResolver), @@ -229,8 +244,6 @@ export const resolvers: Omit & { ), removeUserFromOrg: loggedInAndSignedUpMiddleware(removeUserFromOrgResolver), - submitEarlyAccessForm: loggedInMiddleware(submitEarlyAccessFormResolver), - addEntityOwner: loggedInAndSignedUpMiddleware(() => { throw new Error("`addEntityOwner` is not implemented"); }), diff --git a/apps/hash-api/src/graphql/resolvers/knowledge/user/has-access-to-hash.ts b/apps/hash-api/src/graphql/resolvers/knowledge/user/has-access-to-hash.ts index 496c1500541..d985b39e453 100644 --- a/apps/hash-api/src/graphql/resolvers/knowledge/user/has-access-to-hash.ts +++ b/apps/hash-api/src/graphql/resolvers/knowledge/user/has-access-to-hash.ts @@ -1,17 +1,17 @@ import { userHasAccessToHash } from "../../../../shared/user-has-access-to-hash"; import type { Query, ResolverFn } from "../../../api-types.gen"; -import type { LoggedInGraphQLContext } from "../../../context"; +import type { GraphQLContext } from "../../../context"; import { graphQLContextToImpureGraphContext } from "../../util"; export const hasAccessToHashResolver: ResolverFn< Query["hasAccessToHash"], Record, - LoggedInGraphQLContext, + GraphQLContext, Record > = async (_, __, context) => { return userHasAccessToHash( graphQLContextToImpureGraphContext(context), context.authentication, - context.user, + context.user ?? null, ); }; diff --git a/apps/hash-api/src/index.ts b/apps/hash-api/src/index.ts index 59ab4aec700..159c85e1028 100644 --- a/apps/hash-api/src/index.ts +++ b/apps/hash-api/src/index.ts @@ -53,6 +53,7 @@ import { addKratosAfterRegistrationHandler, createAuthMiddleware, } from "./auth/create-auth-handlers"; +// import { createUnverifiedEmailCleanupJob } from "./auth/create-unverified-email-cleanup-job"; import { getActorIdFromRequest } from "./auth/get-actor-id"; import { oauthConsentRequestHandler, @@ -78,7 +79,13 @@ import { GRAPHQL_PATH, LOCAL_FILE_UPLOAD_PATH, } from "./lib/config"; -import { isDevEnv, isProdEnv, isStatsDEnabled, port } from "./lib/env-config"; +import { + isDevEnv, + isProdEnv, + isStatsDEnabled, + isTestEnv, + port, +} from "./lib/env-config"; import { logger } from "./logger"; import { seedOrgsAndUsers } from "./seed-data"; import { @@ -94,8 +101,8 @@ const httpServer = http.createServer(app); const shutdown = new GracefulShutdown(logger, "SIGINT", "SIGTERM"); const baseRateLimitOptions: Partial = { - windowMs: process.env.NODE_ENV === "test" ? 10 : 1000 * 30, // 30 seconds - limit: 10, // Limit each IP to 10 requests every 30 seconds + windowMs: process.env.NODE_ENV === "test" ? 10 : 1000 * 10, // 10 seconds + limit: 12, // Limit each IP to 12 requests every 10 seconds standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers }; @@ -814,6 +821,7 @@ const main = async () => { // Start the Apollo GraphQL server. shutdown.addCleanup("ApolloServer", async () => apolloServer.stop()); + app.use( GRAPHQL_PATH, cors(CORS_CONFIG), @@ -833,6 +841,21 @@ const main = async () => { }); }); + if (!isTestEnv) { + /** + * H-6218 – introduce this after optimising the query and doing more testing + */ + // const unverifiedEmailCleanupJob = createUnverifiedEmailCleanupJob({ + // context: machineActorContext, + // logger, + // }); + // await unverifiedEmailCleanupJob.start(); + // shutdown.addCleanup( + // "Unverified email cleanup job", + // unverifiedEmailCleanupJob.stop, + // ); + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (realtimeSyncEnabled && enabledIntegrations.linear) { if (!vaultClient) { diff --git a/apps/hash-api/src/seed-data/seed-users.ts b/apps/hash-api/src/seed-data/seed-users.ts index b42624b70ab..96a784734e8 100644 --- a/apps/hash-api/src/seed-data/seed-users.ts +++ b/apps/hash-api/src/seed-data/seed-users.ts @@ -91,6 +91,7 @@ export const ensureUsersAreSeeded = async ({ }, }, }, + verifyEmails: isDevEnv || isTestEnv, }).catch((error: AxiosError) => { if (error.response?.status === 409) { // The user already exists on 409 CONFLICT, which is fine diff --git a/apps/hash-api/src/shared/user-has-access-to-hash.ts b/apps/hash-api/src/shared/user-has-access-to-hash.ts index 3140041be88..627c812895f 100644 --- a/apps/hash-api/src/shared/user-has-access-to-hash.ts +++ b/apps/hash-api/src/shared/user-has-access-to-hash.ts @@ -46,8 +46,12 @@ if (process.env.USER_EMAIL_ALLOW_LIST) { export const userHasAccessToHash = async ( context: ImpureGraphContext, authentication: AuthenticationContext, - user: User, + user: User | null, ) => { + if (!user) { + return false; + } + if (!userEmailAllowList) { return true; } diff --git a/apps/hash-external-services/docker-compose.prod.yml b/apps/hash-external-services/docker-compose.prod.yml index aaf3188a71b..718bf008226 100644 --- a/apps/hash-external-services/docker-compose.prod.yml +++ b/apps/hash-external-services/docker-compose.prod.yml @@ -18,7 +18,7 @@ services: COOKIES_DOMAIN: "${KRATOS_COOKIE_DOMAIN}" COOKIES_SAME_SITE: "Lax" OAUTH2_PROVIDER_URL: "http://hydra:4445" - SERVE_PUBLIC_BASE_URL: "${FRONTEND_URL}/api/ory" + SERVE_PUBLIC_BASE_URL: "${FRONTEND_URL}" SERVE_PUBLIC_CORS_ALLOWED_HEADERS: "Authorization,Content-Type,X-Session-Token,X-CSRF-Token" SERVE_PUBLIC_CORS_ALLOWED_ORIGINS: "${FRONTEND_URL}" SELFSERVICE_DEFAULT_BROWSER_RETURN_URL: "${FRONTEND_URL}/" @@ -27,10 +27,10 @@ services: SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL: "${FRONTEND_URL}/signin" SELFSERVICE_FLOWS_LOGIN_UI_URL: "${FRONTEND_URL}/signin" SELFSERVICE_FLOWS_REGISTRATION_UI_URL: "${FRONTEND_URL}/signup" - SELFSERVICE_METHODS_LINK_CONFIG_BASE_URL: "${FRONTEND_URL}/api/ory" + SELFSERVICE_METHODS_LINK_CONFIG_BASE_URL: "${FRONTEND_URL}" SELFSERVICE_FLOWS_VERIFICATION_UI_URL: "${FRONTEND_URL}/verification" SELFSERVICE_FLOWS_RECOVERY_UI_URL: "${FRONTEND_URL}/recovery" - SELFSERVICE_FLOWS_SETTINGS_UI_URL: "${FRONTEND_URL}/settings" + SELFSERVICE_FLOWS_SETTINGS_UI_URL: "${FRONTEND_URL}/settings/security" LOG_LEAK_SENSITIVE_VALUES: "false" COURIER_SMTP_FROM_ADDRESS: "noreply@hash.ai" COURIER_SMTP_FROM_NAME: "HASH" diff --git a/apps/hash-external-services/docker-compose.test.yml b/apps/hash-external-services/docker-compose.test.yml index 4217d6a8dc9..9cf4f90595f 100644 --- a/apps/hash-external-services/docker-compose.test.yml +++ b/apps/hash-external-services/docker-compose.test.yml @@ -70,6 +70,12 @@ services: - "4433:4433" # public - "4434:4434" # admin + mailslurper: + ports: + - "1025:1025" # SMTP + - "4436:4436" # web UI + - "4437:4437" # API (used by tests to fetch verification emails) + redis: ports: - "6379:6379" diff --git a/apps/hash-external-services/docker-compose.yml b/apps/hash-external-services/docker-compose.yml index 9b584021190..10f9797c91d 100644 --- a/apps/hash-external-services/docker-compose.yml +++ b/apps/hash-external-services/docker-compose.yml @@ -65,10 +65,10 @@ services: SECRETS_COOKIE: "VERY-INSECURE-AND-SHOULD-ONLY-BE-USED-IN-DEV" SECRETS_CIPHER: "32-LONG-SECRET-NOT-SECURE-AT-ALL" DSN: "postgres://${HASH_KRATOS_PG_USER}:${HASH_KRATOS_PG_PASSWORD}@postgres:${POSTGRES_PORT}/${HASH_KRATOS_PG_DATABASE}" - SERVE_PUBLIC_BASE_URL: "http://localhost:4433" + SERVE_PUBLIC_BASE_URL: "http://localhost:3000" SELFSERVICE_DEFAULT_BROWSER_RETURN_URL: "http://localhost:3000/" SELFSERVICE_ALLOWED_RETURN_URLS: "http://localhost:3000" - SELFSERVICE_METHODS_LINK_CONFIG_BASE_URL: "http://localhost:3000/api/ory" + SELFSERVICE_METHODS_LINK_CONFIG_BASE_URL: "http://localhost:3000" SELFSERVICE_FLOWS_ERROR_UI_URL: "http://localhost:3000/error" SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL: "http://localhost:3000/signin" SELFSERVICE_FLOWS_LOGIN_UI_URL: "http://localhost:3000/signin" @@ -77,7 +77,7 @@ services: SELFSERVICE_FLOWS_REGISTRATION_AFTER_PASSWORD_HOOKS_0_CONFIG_AUTH_CONFIG_VALUE: "${KRATOS_API_KEY}" SELFSERVICE_FLOWS_VERIFICATION_UI_URL: "http://localhost:3000/verification" SELFSERVICE_FLOWS_RECOVERY_UI_URL: "http://localhost:3000/recovery" - SELFSERVICE_FLOWS_SETTINGS_UI_URL: "http://localhost:3000/change-password" + SELFSERVICE_FLOWS_SETTINGS_UI_URL: "http://localhost:3000/settings/security" COURIER_SMTP_CONNECTION_URI: "smtps://test:test@mailslurper:1025/?skip_ssl_verify=true" LOG_LEVEL: debug LOG_FORMAT: text diff --git a/apps/hash-external-services/kratos/identity.schema.json b/apps/hash-external-services/kratos/identity.schema.json index 3e6560ee0c8..34bd2f7c9e7 100644 --- a/apps/hash-external-services/kratos/identity.schema.json +++ b/apps/hash-external-services/kratos/identity.schema.json @@ -20,23 +20,15 @@ "credentials": { "password": { "identifier": true + }, + "totp": { + "account_name": true } }, "verification": { "via": "email" }, "recovery": { "via": "email" } } } - }, - "shortname": { - "type": "string", - "title": "Shortname", - "ory.sh/kratos": { - "credentials": { - "password": { - "identifier": true - } - } - } } }, "required": ["emails"], diff --git a/apps/hash-external-services/kratos/kratos.yml b/apps/hash-external-services/kratos/kratos.yml index dedcf7c83df..5f71e83e8d2 100644 --- a/apps/hash-external-services/kratos/kratos.yml +++ b/apps/hash-external-services/kratos/kratos.yml @@ -13,6 +13,8 @@ serve: session: # Let sessions live for 3 years lifespan: 26280h # 24 h * 365 days * 3 years + whoami: + required_aal: highest_available selfservice: # Set `default_browser_return_url` through the `SELFSERVICE_DEFAULT_BROWSER_RETURN_URL` environment variable @@ -24,14 +26,22 @@ selfservice: link: config: - # The URL for verification emails are set through the link method - # but we're using the code method, so we disable this method for usage. + # The link method is disabled (we use the code method instead). + # link.base_url is used by Kratos to construct {{ .VerificationURL }} + # in email templates, but our templates extract the flow ID from it + # and construct their own link to /verification directly. enabled: false # Set `base_url` through the `SELFSERVICE_METHODS_LINK_CONFIG_BASE_URL` environment variable code: config: # and make sure to enable the code method. enabled: true + totp: + config: + issuer: HASH + enabled: true + lookup_secret: + enabled: true flows: error: @@ -68,6 +78,7 @@ selfservice: name: KRATOS_API_KEY # Set `value` through the `SELFSERVICE_FLOWS_REGISTRATION_AFTER_PASSWORD_HOOKS_0_CONFIG_AUTH_CONFIG_VALUE` environment variable in: header + - hook: show_verification_ui - hook: session verification: diff --git a/apps/hash-external-services/kratos/templates/recovery_code/invalid/email.body.gotmpl b/apps/hash-external-services/kratos/templates/recovery_code/invalid/email.body.gotmpl index 0d3c65685a4..4cbe425c68f 100644 --- a/apps/hash-external-services/kratos/templates/recovery_code/invalid/email.body.gotmpl +++ b/apps/hash-external-services/kratos/templates/recovery_code/invalid/email.body.gotmpl @@ -1,11 +1,34 @@ -Hi, - -You (or someone else) entered this email address while trying to recover access to a HASH account. - -However, this email address is not currently associated with any registered HASH user and the attempt has therefore failed. - -If this was you, and you remain unable to access your account, please check to see if you signed up using another email address. - -If this was not you, please ignore this email. - -HASH + + + + + + + + + + + + +
+ + + + +
+

+ Recovery attempted +

+

+ Someone attempted to recover access to a HASH account using this email address, but it is not currently associated with any registered user. +

+

+ If this was you and you're unable to access your account, check whether you signed up with a different email address. +

+

+ If you didn't request this, you can safely ignore this email. +

+
+
+ + diff --git a/apps/hash-external-services/kratos/templates/recovery_code/invalid/email.body.plaintext.gotmpl b/apps/hash-external-services/kratos/templates/recovery_code/invalid/email.body.plaintext.gotmpl index 0d3c65685a4..17d8ce3a4e3 100644 --- a/apps/hash-external-services/kratos/templates/recovery_code/invalid/email.body.plaintext.gotmpl +++ b/apps/hash-external-services/kratos/templates/recovery_code/invalid/email.body.plaintext.gotmpl @@ -1,11 +1,7 @@ -Hi, +Recovery attempted -You (or someone else) entered this email address while trying to recover access to a HASH account. +Someone attempted to recover access to a HASH account using this email address, but it is not currently associated with any registered user. -However, this email address is not currently associated with any registered HASH user and the attempt has therefore failed. +If this was you and you're unable to access your account, check whether you signed up with a different email address. -If this was you, and you remain unable to access your account, please check to see if you signed up using another email address. - -If this was not you, please ignore this email. - -HASH +If you didn't request this, you can safely ignore this email. diff --git a/apps/hash-external-services/kratos/templates/recovery_code/invalid/email.subject.gotmpl b/apps/hash-external-services/kratos/templates/recovery_code/invalid/email.subject.gotmpl index 6a867db1742..b6d47c4dbc6 100644 --- a/apps/hash-external-services/kratos/templates/recovery_code/invalid/email.subject.gotmpl +++ b/apps/hash-external-services/kratos/templates/recovery_code/invalid/email.subject.gotmpl @@ -1 +1 @@ -HASH account access attempted +HASH account recovery attempted diff --git a/apps/hash-external-services/kratos/templates/recovery_code/valid/email.body.gotmpl b/apps/hash-external-services/kratos/templates/recovery_code/valid/email.body.gotmpl index a3d708e08cf..9d3a6a065a3 100644 --- a/apps/hash-external-services/kratos/templates/recovery_code/valid/email.body.gotmpl +++ b/apps/hash-external-services/kratos/templates/recovery_code/valid/email.body.gotmpl @@ -1,11 +1,52 @@ -Hi, - -Please continue with the recovery of your HASH account by entering the following code: - -{{ .RecoveryCode }} - -You should enter this code in HASH directly. Do not share this code with anybody else. - -If you did not request this code, please contact support@hash.ai - -HASH + + + + + + + + + + + + +
+ + + + + + + + + + + + + +
+

+ Recover your account +

+

+ Enter the code below in HASH to recover access to your account. +

+
+ + + + +
+ {{ .RecoveryCode }} +
+
+

+ Enter this code in HASH directly. Do not share this code with anyone. +

+

+ If you didn't request this, please contact support@hash.ai. +

+
+
+ + diff --git a/apps/hash-external-services/kratos/templates/recovery_code/valid/email.body.plaintext.gotmpl b/apps/hash-external-services/kratos/templates/recovery_code/valid/email.body.plaintext.gotmpl index a3d708e08cf..730a86bb831 100644 --- a/apps/hash-external-services/kratos/templates/recovery_code/valid/email.body.plaintext.gotmpl +++ b/apps/hash-external-services/kratos/templates/recovery_code/valid/email.body.plaintext.gotmpl @@ -1,11 +1,9 @@ -Hi, +Recover your account -Please continue with the recovery of your HASH account by entering the following code: +Enter this code in HASH to recover access to your account: {{ .RecoveryCode }} -You should enter this code in HASH directly. Do not share this code with anybody else. +Enter this code in HASH directly. Do not share this code with anyone. -If you did not request this code, please contact support@hash.ai - -HASH +If you didn't request this, please contact support@hash.ai. diff --git a/apps/hash-external-services/kratos/templates/recovery_code/valid/email.subject.gotmpl b/apps/hash-external-services/kratos/templates/recovery_code/valid/email.subject.gotmpl index 84223fb3778..329b1c361c9 100644 --- a/apps/hash-external-services/kratos/templates/recovery_code/valid/email.subject.gotmpl +++ b/apps/hash-external-services/kratos/templates/recovery_code/valid/email.subject.gotmpl @@ -1 +1 @@ -Recover access to your HASH account +Your HASH recovery code: {{ .RecoveryCode }} diff --git a/apps/hash-external-services/kratos/templates/verification_code/invalid/email.body.gotmpl b/apps/hash-external-services/kratos/templates/verification_code/invalid/email.body.gotmpl index e19a8b9881d..99c0adc9047 100644 --- a/apps/hash-external-services/kratos/templates/verification_code/invalid/email.body.gotmpl +++ b/apps/hash-external-services/kratos/templates/verification_code/invalid/email.body.gotmpl @@ -1,11 +1,34 @@ -Hi, - -You (or someone else) entered this email address while trying to verify a HASH account. - -However, this email address is not currently associated with any registered HASH user and the attempt has therefore failed. - -If this was you, and you remain unable to access your account, please check to see if you signed up using another email address. - -If this was not you, please ignore this email. - -HASH + + + + + + + + + + + + +
+ + + + +
+

+ Verification attempted +

+

+ Someone attempted to verify this email address for a HASH account, but it is not currently associated with any registered user. +

+

+ If this was you and you're unable to access your account, check whether you signed up with a different email address. +

+

+ If you didn't request this, you can safely ignore this email. +

+
+
+ + diff --git a/apps/hash-external-services/kratos/templates/verification_code/invalid/email.body.plaintext.gotmpl b/apps/hash-external-services/kratos/templates/verification_code/invalid/email.body.plaintext.gotmpl index e19a8b9881d..130bd5a1a08 100644 --- a/apps/hash-external-services/kratos/templates/verification_code/invalid/email.body.plaintext.gotmpl +++ b/apps/hash-external-services/kratos/templates/verification_code/invalid/email.body.plaintext.gotmpl @@ -1,11 +1,7 @@ -Hi, +Verification attempted -You (or someone else) entered this email address while trying to verify a HASH account. +Someone attempted to verify this email address for a HASH account, but it is not currently associated with any registered user. -However, this email address is not currently associated with any registered HASH user and the attempt has therefore failed. +If this was you and you're unable to access your account, check whether you signed up with a different email address. -If this was you, and you remain unable to access your account, please check to see if you signed up using another email address. - -If this was not you, please ignore this email. - -HASH +If you didn't request this, you can safely ignore this email. diff --git a/apps/hash-external-services/kratos/templates/verification_code/invalid/email.subject.gotmpl b/apps/hash-external-services/kratos/templates/verification_code/invalid/email.subject.gotmpl index e24f2c1da32..2d98a331c0a 100644 --- a/apps/hash-external-services/kratos/templates/verification_code/invalid/email.subject.gotmpl +++ b/apps/hash-external-services/kratos/templates/verification_code/invalid/email.subject.gotmpl @@ -1 +1 @@ -Someone tried to verify this email address +HASH email verification attempted diff --git a/apps/hash-external-services/kratos/templates/verification_code/valid/email.body.gotmpl b/apps/hash-external-services/kratos/templates/verification_code/valid/email.body.gotmpl index b47d68120ba..e1340bb0aa2 100644 --- a/apps/hash-external-services/kratos/templates/verification_code/valid/email.body.gotmpl +++ b/apps/hash-external-services/kratos/templates/verification_code/valid/email.body.gotmpl @@ -1,11 +1,69 @@ -Hi, - -Please verify your HASH account access by entering the following code:
-{{ .VerificationCode }}
- -or by clicking the link below:
-{{ .VerificationURL }}
- -The link is valid for 48 hours. If you were not expecting this email, please ignore it. - -HASH +{{- $flowId := .VerificationURL | regexFind "flow=[^&]+" | trimPrefix "flow=" -}} +{{- $parsed := urlParse .VerificationURL -}} +{{- $verifyLink := printf "%s://%s/verification?code=%s&flow=%s" $parsed.scheme $parsed.host .VerificationCode $flowId -}} + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+

+ Verify your email address +

+

+ Enter the code below in HASH to verify your email address. +

+
+ + + + +
+ {{ .VerificationCode }} +
+
+

+ Or click the button below to verify automatically: +

+ + + + +
+ + Verify email address + +
+
+

+ This code expires in 48 hours. If you didn't create a HASH account, you can safely ignore this email. +

+
+
+ + diff --git a/apps/hash-external-services/kratos/templates/verification_code/valid/email.body.plaintext.gotmpl b/apps/hash-external-services/kratos/templates/verification_code/valid/email.body.plaintext.gotmpl index 39f600a1928..0cacab7d49a 100644 --- a/apps/hash-external-services/kratos/templates/verification_code/valid/email.body.plaintext.gotmpl +++ b/apps/hash-external-services/kratos/templates/verification_code/valid/email.body.plaintext.gotmpl @@ -1,13 +1,13 @@ -Hi, +{{- $flowId := .VerificationURL | regexFind "flow=[^&]+" | trimPrefix "flow=" -}} +{{- $parsed := urlParse .VerificationURL -}} +{{- $verifyLink := printf "%s://%s/verification?code=%s&flow=%s" $parsed.scheme $parsed.host .VerificationCode $flowId -}} +Verify your email address -Please verify your HASH account access by entering the following code: +Enter this code in HASH to verify your email address: {{ .VerificationCode }} -Alternatively, you can click the link below, or copy and paste it into your web browser: +Or open this link to verify automatically: +{{ $verifyLink }} -{{ .VerificationURL }} - -This link is valid for 48 hours. If you were not expecting this email, please ignore it. - -HASH +This code expires in 48 hours. If you didn't create a HASH account, you can safely ignore this email. diff --git a/apps/hash-external-services/kratos/templates/verification_code/valid/email.subject.gotmpl b/apps/hash-external-services/kratos/templates/verification_code/valid/email.subject.gotmpl index 3f0aceec1a7..a88e5b5094a 100644 --- a/apps/hash-external-services/kratos/templates/verification_code/valid/email.subject.gotmpl +++ b/apps/hash-external-services/kratos/templates/verification_code/valid/email.subject.gotmpl @@ -1 +1 @@ -Please verify your email address +Your HASH verification code: {{ .VerificationCode }} diff --git a/apps/hash-frontend/src/components/hooks/use-hash-instance.ts b/apps/hash-frontend/src/components/hooks/use-hash-instance.ts index 6c23f7869a1..9103abfcb0f 100644 --- a/apps/hash-frontend/src/components/hooks/use-hash-instance.ts +++ b/apps/hash-frontend/src/components/hooks/use-hash-instance.ts @@ -12,9 +12,6 @@ import type { } from "../../graphql/api-types.gen"; import { getHashInstanceSettings } from "../../graphql/queries/knowledge/hash-instance.queries"; -/** - * Retrieves the HASH instance. - */ export const useHashInstance = (): { loading: boolean; hashInstance?: Simplified>; diff --git a/apps/hash-frontend/src/components/hooks/use-orgs-with-links.ts b/apps/hash-frontend/src/components/hooks/use-orgs-with-links.ts index a289eb77882..58f0a7e35e3 100644 --- a/apps/hash-frontend/src/components/hooks/use-orgs-with-links.ts +++ b/apps/hash-frontend/src/components/hooks/use-orgs-with-links.ts @@ -18,6 +18,8 @@ import { queryEntitySubgraphQuery } from "../../graphql/queries/knowledge/entity import type { Org } from "../../lib/user-and-org"; import { constructOrg, isEntityOrgEntity } from "../../lib/user-and-org"; +const emptyOrgsArray: Org[] = []; + /** * Retrieves a specific set of organizations, with their avatars and members populated */ @@ -98,6 +100,10 @@ export const useOrgsWithLinks = ({ const { queryEntitySubgraph: queryEntitySubgraphResponse } = data ?? {}; const orgs = useMemo(() => { + if (orgAccountGroupIds?.length === 0) { + return emptyOrgsArray; + } + if (!queryEntitySubgraphResponse) { return undefined; } @@ -114,11 +120,11 @@ export const useOrgsWithLinks = ({ } return constructOrg({ subgraph, orgEntity }); }); - }, [queryEntitySubgraphResponse]); + }, [orgAccountGroupIds, queryEntitySubgraphResponse]); return { loading, - orgs: orgAccountGroupIds && orgAccountGroupIds.length === 0 ? [] : orgs, + orgs, refetch, }; }; diff --git a/apps/hash-frontend/src/lib/user-and-org.ts b/apps/hash-frontend/src/lib/user-and-org.ts index 90f951485ff..b5c48e061be 100644 --- a/apps/hash-frontend/src/lib/user-and-org.ts +++ b/apps/hash-frontend/src/lib/user-and-org.ts @@ -46,6 +46,7 @@ import type { ServiceAccount, } from "@local/hash-isomorphic-utils/system-types/shared"; import type { User as UserEntity } from "@local/hash-isomorphic-utils/system-types/user"; +import type { VerifiableIdentityAddress } from "@ory/client"; import type { UserPreferences } from "../shared/use-user-preferences"; @@ -364,6 +365,7 @@ export const constructUser = (params: { subgraph: Subgraph>; resolvedOrgs?: Org[]; userEntity: Entity; + verifiableAddresses?: VerifiableIdentityAddress[]; }): User => { const { orgMembershipLinks, resolvedOrgs, subgraph, userEntity } = params; @@ -372,11 +374,10 @@ export const constructUser = (params: { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- permissions means this may be undefined. @todo types to account for property-level permissions const primaryEmailAddress = email?.[0] ?? ""; - // @todo implement email verification - // const isPrimaryEmailAddressVerified = - // params.kratosSession.identity.verifiable_addresses?.find( - // ({ value }) => value === primaryEmailAddress, - // )?.verified === true; + const isPrimaryEmailAddressVerified = + params.verifiableAddresses?.find( + ({ value }) => value === primaryEmailAddress, + )?.verified === true; const minimalUser = constructMinimalUser({ userEntity }); @@ -552,7 +553,7 @@ export const constructUser = (params: { emails: [ { address: primaryEmailAddress, - verified: false, + verified: isPrimaryEmailAddressVerified, primary: true, }, ], diff --git a/apps/hash-frontend/src/pages/_app.page.tsx b/apps/hash-frontend/src/pages/_app.page.tsx index 312ea81905d..3595380c1df 100644 --- a/apps/hash-frontend/src/pages/_app.page.tsx +++ b/apps/hash-frontend/src/pages/_app.page.tsx @@ -55,6 +55,7 @@ import { redirectInGetInitialProps } from "./shared/_app.util"; import { AuthInfoProvider, useAuthInfo } from "./shared/auth-info-context"; import { DataTypesContextProvider } from "./shared/data-types-context"; import { maintenanceRoute } from "./shared/maintenance"; +import { type IdentityTraits, oryKratosClient } from "./shared/ory-kratos"; import { setSentryUser } from "./shared/sentry"; import { SlideStackProvider } from "./shared/slide-stack"; import { WorkspaceContextProvider } from "./shared/workspace-context"; @@ -64,6 +65,7 @@ const clientSideEmotionCache = createEmotionCache(); type AppInitialProps = { initialAuthenticatedUserSubgraph?: Subgraph>; user?: MinimalUser; + redirectTo?: string; }; type AppProps = { @@ -72,10 +74,35 @@ type AppProps = { } & AppInitialProps & NextAppProps; +const unverifiedUserPermittedPagePathnames = ["/verification", "/signup"]; + +const globalStyles = ( + +); + const App: FunctionComponent = ({ Component, pageProps, emotionCache = clientSideEmotionCache, + redirectTo, }) => { // Helps prevent tree mismatch between server and client on initial render const [ssr, setSsr] = useState(true); @@ -90,7 +117,23 @@ const App: FunctionComponent = ({ setSsr(false); }, []); - const { authenticatedUser } = useAuthInfo(); + const { aal2Required, authenticatedUser, emailVerificationStatusKnown } = + useAuthInfo(); + + const awaitingEmailVerificationStatus = + !!authenticatedUser && !emailVerificationStatusKnown && !aal2Required; + + /** + * Handle client-side redirects that were determined in getInitialProps. + * On the server these are HTTP 307s; on the client getInitialProps returns + * a `redirectTo` prop instead, and this effect performs the navigation after + * the current route transition completes (avoiding NProgress stalls). + */ + useEffect(() => { + if (redirectTo) { + void router.replace(redirectTo); + } + }, [redirectTo, router]); useEffect(() => { setSentryUser({ authenticatedUser }); @@ -100,7 +143,8 @@ const App: FunctionComponent = ({ // router.query is empty during server-side rendering for pages that don’t use // getServerSideProps. By showing app skeleton on the server, we avoid UI // mismatches during rehydration and improve type-safety of param extraction. - if (ssr || !router.isReady) { + // We also gate on `redirectTo` so the page doesn't flash before navigating. + if (ssr || !router.isReady || awaitingEmailVerificationStatus || redirectTo) { return ; // Replace with app skeleton } @@ -151,25 +195,7 @@ const App: FunctionComponent = ({ - + {globalStyles} ); }; @@ -196,12 +222,38 @@ const publiclyAccessiblePagePathnames = [ "/[shortname]/[page-slug]", "/signin", "/signup", + "/verification", "/recovery", "/", ]; const redirectIfAuthenticatedPathnames = ["/signup"]; +const getPrimaryEmailVerificationStatus = async (cookie?: string) => + oryKratosClient + .toSession({ cookie }) + .then(({ data }) => { + const identity = data.identity; + + if (!identity) { + return undefined; + } + + const identityTraits = identity.traits as IdentityTraits; + const primaryEmailAddress = identityTraits.emails[0]; + + if (!primaryEmailAddress) { + return false; + } + + return ( + identity.verifiable_addresses?.find( + ({ value }) => value === primaryEmailAddress, + )?.verified === true + ); + }) + .catch(() => undefined); + /** * A map from a feature flag, to the list of pages which should not be accessible * if that feature flag is not enabled for the user. @@ -250,10 +302,12 @@ AppWithTypeSystemContextProvider.getInitialProps = async (appContext) => { /** @todo: make additional pages publicly accessible */ if (!userEntity) { + let redirectTo: string | undefined; + // If the user is logged out and not on a page that should be publicly accessible... if (!publiclyAccessiblePagePathnames.includes(pathname)) { // ...redirect them to the sign in page - redirectInGetInitialProps({ + redirectTo = redirectInGetInitialProps({ appContext, location: `/signin${ ["", "/", "/404"].includes(pathname) @@ -263,11 +317,36 @@ AppWithTypeSystemContextProvider.getInitialProps = async (appContext) => { }); } - return {}; + return { redirectTo }; } const user = constructMinimalUser({ userEntity }); + const primaryEmailVerified = await getPrimaryEmailVerificationStatus(cookie); + + if (primaryEmailVerified === false) { + let redirectTo: string | undefined; + + if (!unverifiedUserPermittedPagePathnames.includes(pathname)) { + redirectTo = redirectInGetInitialProps({ + appContext, + location: "/verification", + }); + } + + return { initialAuthenticatedUserSubgraph, user, redirectTo }; + } + + if (primaryEmailVerified === true && pathname === "/verification") { + const redirectTo = redirectInGetInitialProps({ + appContext, + location: "/", + }); + return { initialAuthenticatedUserSubgraph, user, redirectTo }; + } + + let redirectTo: string | undefined; + // If the user is logged in but hasn't completed signup... if (!user.accountSignupComplete) { const hasAccessToHash = await apolloClient @@ -280,45 +359,60 @@ AppWithTypeSystemContextProvider.getInitialProps = async (appContext) => { // ...if they have access to HASH but aren't on the signup page... if (hasAccessToHash && !pathname.startsWith("/signup")) { // ...then redirect them to the signup page. - redirectInGetInitialProps({ appContext, location: "/signup" }); + redirectTo = redirectInGetInitialProps({ + appContext, + location: "/signup", + }); // ...if they don't have access to HASH but aren't on the home page... } else if (!hasAccessToHash && pathname !== "/") { // ...then redirect them to the home page. - redirectInGetInitialProps({ appContext, location: "/" }); + redirectTo = redirectInGetInitialProps({ + appContext, + location: "/", + }); } } else if (redirectIfAuthenticatedPathnames.includes(pathname)) { /** * If the user has completed signup and is on a page they shouldn't be on * (e.g. /signup), then redirect them to the home page. */ - redirectInGetInitialProps({ appContext, location: "/" }); + redirectTo = redirectInGetInitialProps({ + appContext, + location: "/", + }); } // For each feature flag... - for (const featureFlag of featureFlags) { - /** - * ...if the user has not enabled the feature flag, - * and the page is a hidden pathname for that feature flag... - */ - if ( - !user.enabledFeatureFlags.includes(featureFlag) && - featureFlagHiddenPathnames[featureFlag].includes(pathname) - ) { - const isUserAdmin = await apolloClient - .query({ - query: getHashInstanceSettings, - context: { headers: { cookie } }, - }) - .then(({ data }) => !!data.hashInstanceSettings?.isUserAdmin); - - if (!isUserAdmin) { - // ...then redirect them to the home page instead. - redirectInGetInitialProps({ appContext, location: "/" }); + if (!redirectTo) { + for (const featureFlag of featureFlags) { + /** + * ...if the user has not enabled the feature flag, + * and the page is a hidden pathname for that feature flag... + */ + if ( + !user.enabledFeatureFlags.includes(featureFlag) && + featureFlagHiddenPathnames[featureFlag].includes(pathname) + ) { + const isUserAdmin = await apolloClient + .query({ + query: getHashInstanceSettings, + context: { headers: { cookie } }, + }) + .then(({ data }) => !!data.hashInstanceSettings?.isUserAdmin); + + if (!isUserAdmin) { + // ...then redirect them to the home page instead. + redirectTo = redirectInGetInitialProps({ + appContext, + location: "/", + }); + break; + } } } } - return { initialAuthenticatedUserSubgraph, user }; + return { initialAuthenticatedUserSubgraph, user, redirectTo }; }; export default AppWithTypeSystemContextProvider; diff --git a/apps/hash-frontend/src/pages/settings/organizations/index.page.tsx b/apps/hash-frontend/src/pages/settings/organizations/index.page.tsx index 60df4da773d..ea8cb842f5d 100644 --- a/apps/hash-frontend/src/pages/settings/organizations/index.page.tsx +++ b/apps/hash-frontend/src/pages/settings/organizations/index.page.tsx @@ -45,7 +45,7 @@ const OrganizationListPage: NextPageWithLayout = () => { /> } - heading={<>Organizations} + heading="Organizations" ref={topRef} > {authenticatedUser.memberOf.length > 0 ? ( diff --git a/apps/hash-frontend/src/pages/settings/security.page.tsx b/apps/hash-frontend/src/pages/settings/security.page.tsx new file mode 100644 index 00000000000..46f2b4e2c85 --- /dev/null +++ b/apps/hash-frontend/src/pages/settings/security.page.tsx @@ -0,0 +1,806 @@ +import { Modal, TextField } from "@hashintel/design-system"; +import { Box, Divider, Grid, Typography } from "@mui/material"; +import type { SettingsFlow, UpdateSettingsFlowBody } from "@ory/client"; +import { + isUiNodeImageAttributes, + isUiNodeInputAttributes, + isUiNodeTextAttributes, +} from "@ory/integrations/ui"; +import type { AxiosError } from "axios"; +import { useRouter } from "next/router"; +import type { FormEventHandler } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import type { NextPageWithLayout } from "../../shared/layout"; +import { Button } from "../../shared/ui"; +import { useAuthInfo } from "../shared/auth-info-context"; +import { + mustGetCsrfTokenFromFlow, + oryKratosClient, +} from "../shared/ory-kratos"; +import { getSettingsLayout } from "../shared/settings-layout"; +import { useKratosErrorHandler } from "../shared/use-kratos-flow-error-handler"; +import { SettingsPageContainer } from "./shared/settings-page-container"; + +const getUiTextValue = (text: unknown): string | undefined => { + if (typeof text === "string") { + return text; + } + + if ( + typeof text === "object" && + text !== null && + "text" in text && + typeof (text as { text?: unknown }).text === "string" + ) { + return (text as { text: string }).text; + } + + return undefined; +}; + +const extractBackupCodesFromFlow = (flow: SettingsFlow): string[] => { + let codesText: string | undefined; + + for (const { group, attributes } of flow.ui.nodes) { + if ( + group === "lookup_secret" && + isUiNodeTextAttributes(attributes) && + attributes.id === "lookup_secret_codes" + ) { + codesText = getUiTextValue(attributes.text); + break; + } + } + + if (!codesText) { + return []; + } + + // Extract backup codes directly by pattern rather than stripping HTML first. + // Kratos may return codes in an HTML-formatted string (with
tags, etc.), + // but we only care about the alphanumeric code values themselves. + const regexMatches = codesText.match(/[A-Z0-9]{4}(?:-[A-Z0-9]{4})+/gi); + if (regexMatches?.length) { + return regexMatches; + } + + // Fallback: replace
with newlines, then use DOMParser to safely extract + // plain text. DOMParser creates an inert document — no scripts execute, no + // resources load, and no event handlers fire, unlike innerHTML on a live element. + // The data comes from Kratos in any case. + const withNewlines = codesText.replace(//gi, "\n"); + const parsed = new DOMParser().parseFromString(withNewlines, "text/html"); + const plainText = parsed.body.textContent; + + return plainText + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); +}; + +const SecurityPage: NextPageWithLayout = () => { + const router = useRouter(); + const { flow: flowId } = router.query; + const { authenticatedUser } = useAuthInfo(); + const usernameForPasswordManagers = + authenticatedUser?.emails[0]?.address ?? ""; + + const [flow, setFlow] = useState(); + const [currentPassword, setCurrentPassword] = useState(""); + const [password, setPassword] = useState(""); + const [currentPasswordError, setCurrentPasswordError] = useState(); + const [totpCode, setTotpCode] = useState(""); + const [disableTotpCode, setDisableTotpCode] = useState(""); + const [showTotpSetupForm, setShowTotpSetupForm] = useState(false); + const [showTotpDisableForm, setShowTotpDisableForm] = useState(false); + const [backupCodes, setBackupCodes] = useState([]); + const [showBackupCodesModal, setShowBackupCodesModal] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + + const [updatingPassword, setUpdatingPassword] = useState(false); + const [enablingTotp, setEnablingTotp] = useState(false); + const [disablingTotp, setDisablingTotp] = useState(false); + const [regeneratingBackupCodes, setRegeneratingBackupCodes] = useState(false); + const [confirmingBackupCodes, setConfirmingBackupCodes] = useState(false); + + const { handleFlowError } = useKratosErrorHandler({ + flowType: "settings", + setFlow, + setErrorMessage, + }); + + const persistFlowIdInUrl = useCallback( + (settingsFlow: SettingsFlow) => { + void router.push( + { + pathname: "/settings/security", + query: { flow: settingsFlow.id }, + }, + undefined, + { shallow: true }, + ); + }, + [router], + ); + + const submitSettingsUpdate = useCallback( + async ( + currentFlow: SettingsFlow, + updateSettingsFlowBody: UpdateSettingsFlowBody, + ): Promise => + oryKratosClient + .updateSettingsFlow({ + flow: String(currentFlow.id), + updateSettingsFlowBody, + }) + .then(({ data }) => { + setFlow(data); + return data; + }) + .catch(handleFlowError) + .catch((error: AxiosError) => { + if (error.response?.status === 400) { + setFlow(error.response.data); + return undefined; + } + + return Promise.reject(error); + }), + [handleFlowError], + ); + + useEffect(() => { + if (!router.isReady || flow) { + return; + } + + if (flowId) { + oryKratosClient + .getSettingsFlow({ id: String(flowId) }) + .then(({ data }) => setFlow(data)) + .catch(handleFlowError); + return; + } + + oryKratosClient + .createBrowserSettingsFlow() + .then(({ data }) => setFlow(data)) + .catch(handleFlowError); + }, [flow, flowId, handleFlowError, router.isReady]); + + const passwordInputUiNode = useMemo( + () => + flow?.ui.nodes.find( + ({ group, attributes }) => + group === "password" && + isUiNodeInputAttributes(attributes) && + attributes.name === "password", + ), + [flow], + ); + + const totpNodes = useMemo( + () => flow?.ui.nodes.filter(({ group }) => group === "totp") ?? [], + [flow], + ); + + const totpCodeUiNode = useMemo( + () => + totpNodes.find( + ({ attributes }) => + isUiNodeInputAttributes(attributes) && + attributes.name === "totp_code", + ), + [totpNodes], + ); + + const isTotpEnabled = useMemo( + () => + totpNodes.some( + ({ attributes }) => + isUiNodeInputAttributes(attributes) && + attributes.name === "totp_unlink", + ), + [totpNodes], + ); + + useEffect(() => { + if (isTotpEnabled) { + setShowTotpSetupForm(false); + return; + } + + setShowTotpDisableForm(false); + }, [isTotpEnabled]); + + const totpQrCodeDataUri = useMemo(() => { + for (const { attributes } of totpNodes) { + if ( + isUiNodeImageAttributes(attributes) && + typeof attributes.src === "string" + ) { + return attributes.src; + } + } + + return undefined; + }, [totpNodes]); + + const totpSecretKey = useMemo(() => { + for (const { attributes } of totpNodes) { + if ( + isUiNodeTextAttributes(attributes) && + attributes.id === "totp_secret_key" + ) { + const text = getUiTextValue(attributes.text); + + if (text) { + return text; + } + } + } + + return undefined; + }, [totpNodes]); + + const handlePasswordSubmit: FormEventHandler = (event) => { + event.preventDefault(); + + if (!flow || !currentPassword || !password) { + return; + } + + setUpdatingPassword(true); + setCurrentPasswordError(undefined); + persistFlowIdInUrl(flow); + + // Step 1: Verify the current password by creating and submitting a + // refresh login flow. This also refreshes the session to + // "privileged", ensuring the settings update won't be rejected. + void oryKratosClient + .createBrowserLoginFlow({ refresh: true }) + .then(({ data: loginFlow }) => + oryKratosClient.updateLoginFlow({ + flow: loginFlow.id, + updateLoginFlowBody: { + method: "password", + identifier: usernameForPasswordManagers, + password: currentPassword, + csrf_token: mustGetCsrfTokenFromFlow(loginFlow), + }, + }), + ) + .then( + // Step 2: Current password verified, now update to the new password + async () => { + const nextFlow = await submitSettingsUpdate(flow, { + method: "password", + password, + csrf_token: mustGetCsrfTokenFromFlow(flow), + }); + + if (nextFlow) { + setCurrentPassword(""); + setPassword(""); + } + }, + ) + .catch((error: AxiosError) => { + if (error.response?.status === 400) { + setCurrentPasswordError("Current password is incorrect."); + return; + } + + void handleFlowError(error); + }) + .finally(() => setUpdatingPassword(false)); + }; + + const handleEnableTotpSubmit: FormEventHandler = (event) => { + event.preventDefault(); + + if (!flow || !totpCode) { + return; + } + + setEnablingTotp(true); + persistFlowIdInUrl(flow); + + void submitSettingsUpdate(flow, { + method: "totp", + totp_code: totpCode, + csrf_token: mustGetCsrfTokenFromFlow(flow), + }) + .then(async (totpEnabledFlow) => { + if (!totpEnabledFlow) { + return; + } + + setShowTotpSetupForm(false); + setTotpCode(""); + + const flowWithBackupCodes = await submitSettingsUpdate( + totpEnabledFlow, + { + method: "lookup_secret", + lookup_secret_regenerate: true, + csrf_token: mustGetCsrfTokenFromFlow(totpEnabledFlow), + }, + ); + + if (!flowWithBackupCodes) { + return; + } + + const regeneratedCodes = + extractBackupCodesFromFlow(flowWithBackupCodes); + + if (regeneratedCodes.length > 0) { + setBackupCodes(regeneratedCodes); + setShowBackupCodesModal(true); + } + }) + .finally(() => setEnablingTotp(false)); + }; + + const handleDisableTotpSubmit: FormEventHandler = ( + event, + ) => { + event.preventDefault(); + + if (!flow || !disableTotpCode) { + return; + } + + setDisablingTotp(true); + persistFlowIdInUrl(flow); + + // Step 1: Validate the TOTP code to prove the user has authenticator access + void submitSettingsUpdate(flow, { + method: "totp", + totp_code: disableTotpCode, + csrf_token: mustGetCsrfTokenFromFlow(flow), + }) + .then(async (verifiedFlow) => { + if (!verifiedFlow) { + return; + } + + // Step 2: Code was valid, now unlink TOTP + const unlinkedFlow = await submitSettingsUpdate(verifiedFlow, { + method: "totp", + totp_unlink: true, + csrf_token: mustGetCsrfTokenFromFlow(verifiedFlow), + }); + + if (unlinkedFlow) { + setDisableTotpCode(""); + setShowTotpDisableForm(false); + } + }) + .finally(() => setDisablingTotp(false)); + }; + + const handleRegenerateBackupCodes = () => { + if (!flow) { + return; + } + + setRegeneratingBackupCodes(true); + persistFlowIdInUrl(flow); + + void submitSettingsUpdate(flow, { + method: "lookup_secret", + lookup_secret_regenerate: true, + csrf_token: mustGetCsrfTokenFromFlow(flow), + }) + .then((nextFlow) => { + if (!nextFlow) { + return; + } + + const regeneratedCodes = extractBackupCodesFromFlow(nextFlow); + + if (regeneratedCodes.length > 0) { + setBackupCodes(regeneratedCodes); + setShowBackupCodesModal(true); + } + }) + .finally(() => setRegeneratingBackupCodes(false)); + }; + + const handleConfirmBackupCodesSaved = () => { + if (!flow) { + setShowBackupCodesModal(false); + return; + } + + setConfirmingBackupCodes(true); + + void submitSettingsUpdate(flow, { + method: "lookup_secret", + lookup_secret_confirm: true, + csrf_token: mustGetCsrfTokenFromFlow(flow), + }) + .then((nextFlow) => { + if (nextFlow) { + setShowBackupCodesModal(false); + } + }) + .finally(() => setConfirmingBackupCodes(false)); + }; + + return ( + <> + + + + + + Password + + + { + setCurrentPassword(target.value); + setCurrentPasswordError(undefined); + }} + error={!!currentPasswordError} + helperText={ + currentPasswordError ? ( + {currentPasswordError} + ) : undefined + } + required + /> + setPassword(target.value)} + error={ + !!passwordInputUiNode?.messages.find( + ({ type }) => type === "error", + ) + } + helperText={passwordInputUiNode?.messages.map( + ({ id, text }) => {text}, + )} + required + /> + + + + + + + + + {/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- @todo H-6219 restore this */} + {false && ( + + + Two-factor authentication + + + {isTotpEnabled ? ( + + palette.gray[80] }}> + TOTP is enabled for your account. + + {showTotpDisableForm ? ( + + + setDisableTotpCode(target.value) + } + error={ + !!totpCodeUiNode?.messages.find( + ({ type }) => type === "error", + ) + } + helperText={totpCodeUiNode?.messages.map( + ({ id, text }) => ( + {text} + ), + )} + required + inputProps={{ inputMode: "numeric" }} + /> + + + + + + ) : ( + + + + + )} + + ) : showTotpSetupForm ? ( + + palette.gray[80] }} + > + Scan the QR code with your authenticator app, then enter the + 6-digit code to enable TOTP. + + {totpQrCodeDataUri ? ( + + `1px solid ${palette.gray[30]}`, + }} + /> + ) : null} + {totpSecretKey ? ( + + ({ + color: palette.gray[80], + mb: 0.75, + display: "block", + })} + > + {totpQrCodeDataUri + ? "Alternatively, use the secret key below for manual setup." + : "QR code unavailable. Use the secret key below for manual setup."} + + palette.gray[20], + fontFamily: "monospace", + }} + > + {totpSecretKey} + + + ) : null} + setTotpCode(target.value)} + error={ + !!totpCodeUiNode?.messages.find( + ({ type }) => type === "error", + ) + } + helperText={totpCodeUiNode?.messages.map(({ id, text }) => ( + {text} + ))} + required + inputProps={{ inputMode: "numeric" }} + /> + + + + + + ) : ( + + palette.gray[80] }}> + TOTP is currently disabled for your account. + + + + + + )} + + )} + + {flow?.ui.messages?.map(({ id, text }) => ( + {text} + ))} + {errorMessage ? {errorMessage} : null} + + + + setShowBackupCodesModal(false)} + > + + + Backup codes + + + These codes will only be shown once. Save them securely. + + + {backupCodes.map((backupCode) => ( + + ({ + border: `1px solid ${palette.gray[30]}`, + borderRadius: 1, + px: 1.5, + py: 1, + background: palette.gray[20], + fontFamily: "monospace", + })} + > + {backupCode} + + + ))} + + + + + + + + + ); +}; + +SecurityPage.getLayout = (page) => getSettingsLayout(page); + +export default SecurityPage; diff --git a/apps/hash-frontend/src/pages/shared/_app.util.ts b/apps/hash-frontend/src/pages/shared/_app.util.ts index 89f46804841..aa04f6f9fd2 100644 --- a/apps/hash-frontend/src/pages/shared/_app.util.ts +++ b/apps/hash-frontend/src/pages/shared/_app.util.ts @@ -8,26 +8,32 @@ export type AppPage

, IP = P> = NextComponentType< P >; +/** + * Redirect during getInitialProps. Server-side, this sends an HTTP 307. + * Client-side, returns the redirect location so the caller can pass it as a + * prop – the component then handles it via useEffect, avoiding calling + * router.push during an active route transition (which stalls NProgress). + */ export const redirectInGetInitialProps = (params: { appContext: AppContext; location: string; -}) => { +}): string | undefined => { const { appContext: { - ctx: { req, res }, - router, + ctx: { res }, }, location, } = params; - if (req && res) { + if (res) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- not a function whilst building next, so return instead. if (!res.writeHead) { return; } res.writeHead(307, { Location: location }); res.end(); - } else { - void router.push(location); } + + // On client-side, return the location for the component to handle. + return location; }; diff --git a/apps/hash-frontend/src/pages/shared/auth-info-context.tsx b/apps/hash-frontend/src/pages/shared/auth-info-context.tsx index ae541a3229e..a27383e58e4 100644 --- a/apps/hash-frontend/src/pages/shared/auth-info-context.tsx +++ b/apps/hash-frontend/src/pages/shared/auth-info-context.tsx @@ -14,12 +14,16 @@ import { type HashEntity, HashLinkEntity } from "@local/hash-graph-sdk/entity"; import { mapGqlSubgraphFieldsFragmentToSubgraph } from "@local/hash-isomorphic-utils/graph-queries"; import { systemLinkEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; import type { IsMemberOf } from "@local/hash-isomorphic-utils/system-types/shared"; +import type { VerifiableIdentityAddress } from "@ory/client"; +import type { AxiosError } from "axios"; import type { FunctionComponent, ReactElement } from "react"; import { createContext, useCallback, useContext, + useEffect, useMemo, + useRef, useState, } from "react"; @@ -29,13 +33,16 @@ import type { MeQuery } from "../../graphql/api-types.gen"; import { meQuery } from "../../graphql/queries/user.queries"; import type { User } from "../../lib/user-and-org"; import { constructUser, isEntityUserEntity } from "../../lib/user-and-org"; +import { oryKratosClient } from "./ory-kratos"; type RefetchAuthInfoFunction = () => Promise<{ authenticatedUser?: User; }>; type AuthInfoContextValue = { + aal2Required: boolean; authenticatedUser?: User; + emailVerificationStatusKnown: boolean; isInstanceAdmin: boolean | undefined; refetch: RefetchAuthInfoFunction; }; @@ -56,6 +63,12 @@ export const AuthInfoProvider: FunctionComponent = ({ const [authenticatedUserSubgraph, setAuthenticatedUserSubgraph] = useState( initialAuthenticatedUserSubgraph, ); // use the initial server-sent data to start – after that, the client controls the value + const [verifiableAddresses, setVerifiableAddresses] = useState< + VerifiableIdentityAddress[] + >([]); + const [aal2Required, setAal2Required] = useState(false); + const [emailVerificationStatusKnown, setEmailVerificationStatusKnown] = + useState(false); const userMemberOfLinks = useMemo(() => { if (!authenticatedUserSubgraph) { @@ -94,7 +107,10 @@ export const AuthInfoProvider: FunctionComponent = ({ }); const constructUserValue = useCallback( - (subgraph: Subgraph> | undefined) => { + ( + subgraph: Subgraph> | undefined, + suppliedVerifiableAddresses: VerifiableIdentityAddress[], + ) => { if (!subgraph) { return undefined; } @@ -112,6 +128,7 @@ export const AuthInfoProvider: FunctionComponent = ({ subgraph, resolvedOrgs, userEntity, + verifiableAddresses: suppliedVerifiableAddresses, }); }, [resolvedOrgs, userMemberOfLinks], @@ -121,6 +138,13 @@ export const AuthInfoProvider: FunctionComponent = ({ const { isUserAdmin: isInstanceAdmin } = useHashInstance(); + /** + * Use a ref to avoid `fetchAuthenticatedUser` depending on the identity of `constructUserValue`, + * which changes whenever `resolvedOrgs` or `userMemberOfLinks` change. + */ + const constructUserValueRef = useRef(constructUserValue); + constructUserValueRef.current = constructUserValue; + const fetchAuthenticatedUser = useCallback(async () => { /** @@ -132,17 +156,42 @@ export const AuthInfoProvider: FunctionComponent = ({ * @see https://linear.app/hash/issue/H-2182/upgrade-apolloclient-to-latest-version-to-fix-uselazyquery-behaviour * @see https://github.com/apollographql/apollo-client/issues/6086 */ - const subgraph = await apolloClient - .query({ - query: meQuery, - fetchPolicy: "network-only", - }) - .then(({ data }) => - mapGqlSubgraphFieldsFragmentToSubgraph>( - data.me.subgraph, - ), - ) - .catch(() => undefined); + const [subgraph, kratosSessionResult] = await Promise.all([ + apolloClient + .query({ + query: meQuery, + fetchPolicy: "network-only", + }) + .then(({ data }) => + mapGqlSubgraphFieldsFragmentToSubgraph>( + data.me.subgraph, + ), + ) + .catch(() => undefined), + oryKratosClient + .toSession() + .then(({ data }) => ({ + aal2Required: false, + emailVerificationStatusKnown: true, + session: data, + })) + .catch((error: AxiosError) => ({ + aal2Required: error.response?.status === 403, + emailVerificationStatusKnown: error.response?.status !== 403, + session: undefined, + })), + ]); + + if (kratosSessionResult.emailVerificationStatusKnown) { + setVerifiableAddresses( + kratosSessionResult.session?.identity?.verifiable_addresses ?? [], + ); + } + + setAal2Required(kratosSessionResult.aal2Required); + setEmailVerificationStatusKnown( + kratosSessionResult.emailVerificationStatusKnown, + ); if (!subgraph) { setAuthenticatedUserSubgraph(undefined); @@ -151,25 +200,55 @@ export const AuthInfoProvider: FunctionComponent = ({ setAuthenticatedUserSubgraph(subgraph); - return { authenticatedUser: constructUserValue(subgraph) }; - }, [constructUserValue, apolloClient]); + const newVerifiableAddresses = + kratosSessionResult.session?.identity?.verifiable_addresses ?? []; + + return { + authenticatedUser: constructUserValueRef.current( + subgraph, + newVerifiableAddresses, + ), + }; + }, [apolloClient]); + + useEffect(() => { + void fetchAuthenticatedUser(); + }, [fetchAuthenticatedUser]); const authenticatedUser = useMemo( - () => constructUserValue(authenticatedUserSubgraph), - [authenticatedUserSubgraph, constructUserValue], + () => constructUserValue(authenticatedUserSubgraph, verifiableAddresses), + [authenticatedUserSubgraph, constructUserValue, verifiableAddresses], ); const value = useMemo( () => ({ + aal2Required, authenticatedUser, + emailVerificationStatusKnown, isInstanceAdmin, refetch: async () => { - // Refetch the detail on orgs in case this refetch is following them being modified - await refetchOrgs(); + // Refetch the detail on orgs in case this refetch is following them being modified. + // Only attempt if the user has completed signup – users who haven't finished + // setup (e.g. still verifying email) cannot query entities yet. + if (authenticatedUser?.accountSignupComplete) { + try { + await refetchOrgs(); + } catch { + // Swallow so that fetchAuthenticatedUser still runs + } + } + return fetchAuthenticatedUser(); }, }), - [authenticatedUser, isInstanceAdmin, fetchAuthenticatedUser, refetchOrgs], + [ + aal2Required, + authenticatedUser, + emailVerificationStatusKnown, + isInstanceAdmin, + fetchAuthenticatedUser, + refetchOrgs, + ], ); return ( diff --git a/apps/hash-frontend/src/pages/shared/auth-layout.tsx b/apps/hash-frontend/src/pages/shared/auth-layout.tsx index 31561e1f756..e09866e4270 100644 --- a/apps/hash-frontend/src/pages/shared/auth-layout.tsx +++ b/apps/hash-frontend/src/pages/shared/auth-layout.tsx @@ -38,6 +38,7 @@ export const AuthLayout: FunctionComponent< flexGrow: 1, display: "flex", alignItems: "center", + justifyContent: "center", position: "relative", }} > diff --git a/apps/hash-frontend/src/pages/shared/auth-utils.ts b/apps/hash-frontend/src/pages/shared/auth-utils.ts index 237f195672a..08343104c93 100644 --- a/apps/hash-frontend/src/pages/shared/auth-utils.ts +++ b/apps/hash-frontend/src/pages/shared/auth-utils.ts @@ -1,41 +1,38 @@ -/** - * @todo H-2421: Check this file for redundancy after implementing email verification. - */ - -import type { ParsedUrlQueryInput } from "node:querystring"; - import type { GraphQLError } from "graphql"; export const SYNTHETIC_LOADING_TIME_MS = 700; -type ParsedAuthQuery = { - verificationId: string; - verificationCode: string; -}; - -export const isParsedAuthQuery = ( - query: ParsedUrlQueryInput, -): query is ParsedAuthQuery => - typeof query.verificationId === "string" && - typeof query.verificationCode === "string"; - export const parseGraphQLError = ( errors: GraphQLError[], priorityErrorCode?: string, ): { errorCode: string; message: string } => { - const priorityError = errors.find( - ({ extensions }) => extensions.code === priorityErrorCode, - ); + const extractErrorCode = (error: GraphQLError) => + typeof error.extensions.code === "string" + ? error.extensions.code + : "unknown"; + + if (errors.length === 0) { + return { + errorCode: "unknown", + message: "An unexpected error occurred.", + }; + } + + const priorityError = priorityErrorCode + ? errors.find(({ extensions }) => extensions.code === priorityErrorCode) + : undefined; if (priorityError) { return { - errorCode: priorityError.extensions.code as string, + errorCode: extractErrorCode(priorityError), message: priorityError.message, }; } + const firstError = errors[0]!; + return { - errorCode: errors[0]!.extensions.code as string, - message: errors[0]!.message, + errorCode: extractErrorCode(firstError), + message: firstError.message, }; }; diff --git a/apps/hash-frontend/src/pages/shared/settings-layout.tsx b/apps/hash-frontend/src/pages/shared/settings-layout.tsx index 7011d68f30c..e2344404291 100644 --- a/apps/hash-frontend/src/pages/shared/settings-layout.tsx +++ b/apps/hash-frontend/src/pages/shared/settings-layout.tsx @@ -5,6 +5,7 @@ import { useMemo } from "react"; import type { Org } from "../../lib/user-and-org"; import { HouseSolidIcon } from "../../shared/icons/house-solid-icon"; +import { LockSolidIcon } from "../../shared/icons/lock-solid-icon"; import { PeopleGroupIcon } from "../../shared/icons/people-group-icon"; import { PlugSolidIcon } from "../../shared/icons/plug-solid-icon"; import { LayoutWithSidebar } from "../../shared/layout/layout-with-sidebar"; @@ -45,6 +46,11 @@ const generateMenuLinks = ( const menuItems: SidebarItemData[] = [ // { label: "Personal info", href: "/settings/personal" }, + { + label: "Security", + href: "/settings/security", + icon: LockSolidIcon, + }, { label: "Organizations", href: "/settings/organizations", diff --git a/apps/hash-frontend/src/pages/shared/verify-code.tsx b/apps/hash-frontend/src/pages/shared/verify-code.tsx deleted file mode 100644 index ce2cc06eadd..00000000000 --- a/apps/hash-frontend/src/pages/shared/verify-code.tsx +++ /dev/null @@ -1,301 +0,0 @@ -/** - * @todo H-2421: Check this file for redundancy after implementing email verification. - */ - -import { Box } from "@mui/material"; -import type { - ClipboardEventHandler, - FormEvent, - FunctionComponent, -} from "react"; -import { useCallback, useEffect, useRef, useState } from "react"; - -import { KeyboardReturnIcon } from "../../shared/icons"; -import { SYNTHETIC_LOADING_TIME_MS } from "./auth-utils"; - -type VerifyCodeProps = { - defaultCode?: string; - goBack: () => void; - loading: boolean; - errorMessage?: string; - loginIdentifier: string; - handleSubmit: (code: string, withSyntheticLoading?: boolean) => void; - requestCode: () => void | Promise; - requestCodeLoading: boolean; -}; - -const isShortname = (identifier: string) => !identifier.includes("@"); - -const parseVerificationCodeInput = (inputCode: string) => - inputCode.replace(/\s/g, ""); - -const doesVerificationCodeLookValid = (code: string) => { - const units = code.split("-"); - return units.length >= 4 && units[3]!.length > 0; -}; - -export const VerifyCode: FunctionComponent = ({ - defaultCode, - goBack, - errorMessage, - loginIdentifier, - handleSubmit, - loading, - requestCode, - requestCodeLoading, -}) => { - const [state, setState] = useState({ - text: defaultCode ?? "", - emailResent: false, - syntheticLoading: false, - }); - - const { text, emailResent, syntheticLoading } = state; - const inputRef = useRef(null); - - const updateState = useCallback((newState: Partial) => { - setState((prevState) => ({ - ...prevState, - ...newState, - })); - }, []); - - useEffect(() => { - inputRef.current?.select(); - }, []); - - const isInputValid = useCallback( - () => doesVerificationCodeLookValid(text), - [text], - ); - - const onSubmit = (evt: FormEvent) => { - evt.preventDefault(); - handleSubmit(text); - }; - - const handleResendCode = () => { - updateState({ syntheticLoading: true }); - // eslint-disable-next-line @typescript-eslint/no-misused-promises - setTimeout(async () => { - try { - await requestCode(); - updateState({ emailResent: true, syntheticLoading: false }); - setTimeout(() => updateState({ emailResent: false }), 5000); - } catch { - updateState({ syntheticLoading: false }); - } - }, SYNTHETIC_LOADING_TIME_MS); - }; - - // The handler supports partial code pasting. Use case: - // 1. Open email, accidentally select all characters but the first one. - // 2. Manually type in the first character and then paste. - // 3. The form submits the entire code and not only clipboardData. - const handleInputPaste: ClipboardEventHandler = ({ - currentTarget, - }) => { - const originalValue = currentTarget.value; - - setImmediate(() => { - const valueAfterPasting = currentTarget.value; - if (!valueAfterPasting || originalValue === valueAfterPasting) { - return; - } - - const pastedCode = parseVerificationCodeInput(valueAfterPasting); - if (doesVerificationCodeLookValid(pastedCode)) { - handleSubmit(pastedCode, true); - } - }); - }; - - return ( -

-
-
-

- A verification code has been sent to{" "} - - {isShortname(loginIdentifier) - ? "your primary email address" - : loginIdentifier} - -

-

- Click the link in this email or enter the verification phrase below - to continue -

-
- - updateState({ text: parseVerificationCodeInput(target.value) }) - } - onPaste={handleInputPaste} - value={text} - ref={inputRef} - data-testid="verify-code-input" - /> - - {loading ? ( - Loading - ) : ( - <> - Submit - - - )} - - - {errorMessage && ( - - {errorMessage} - - )} -
-
-
- - ←{" "} - - Try logging in another way - - - {emailResent ? ( -
- No email yet? - - Email Resent - -
- ) : ( -
- No email yet? - - Resend email - -
- )} -
-
- ); -}; diff --git a/apps/hash-frontend/src/pages/shared/verify-email-step.tsx b/apps/hash-frontend/src/pages/shared/verify-email-step.tsx new file mode 100644 index 00000000000..2867368eb4a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/verify-email-step.tsx @@ -0,0 +1,312 @@ +import { TextField } from "@hashintel/design-system"; +import { Box, Typography } from "@mui/material"; +import type { VerificationFlow } from "@ory/client"; +import { isUiNodeInputAttributes } from "@ory/integrations/ui"; +import type { AxiosError } from "axios"; +import type { FormEventHandler, FunctionComponent } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; + +import { isProduction } from "../../lib/config"; +import { Button } from "../../shared/ui"; +import { AuthHeading } from "./auth-heading"; +import { AuthPaper } from "./auth-paper"; +import { mustGetCsrfTokenFromFlow, oryKratosClient } from "./ory-kratos"; +import { useKratosErrorHandler } from "./use-kratos-flow-error-handler"; + +type VerifyEmailStepProps = { + email: string; + /** An error message to display initially (e.g. from a failed auto-verify attempt). */ + initialError?: string; + /** + * An existing verification flow ID (e.g. from Ory's `continue_with` after + * registration). If provided, the flow is fetched on mount so the code input + * is shown immediately without sending an additional email. + */ + initialVerificationFlowId?: string; + onVerified: () => void | Promise; +}; + +export const VerifyEmailStep: FunctionComponent = ({ + email, + initialError, + initialVerificationFlowId, + onVerified, +}) => { + const [flow, setFlow] = useState(); + const [code, setCode] = useState(""); + const [errorMessage, setErrorMessage] = useState( + initialError, + ); + const [sendingCode, setSendingCode] = useState(false); + const [verifyingCode, setVerifyingCode] = useState(false); + + const { handleFlowError } = useKratosErrorHandler({ + flowType: "verification", + setFlow, + setErrorMessage, + }); + + /** + * Use a ref so that callbacks don't depend on the identity of + * `handleFlowError`, which changes when `authenticatedUser` updates in the + * auth context. + */ + const handleFlowErrorRef = useRef(handleFlowError); + handleFlowErrorRef.current = handleFlowError; + + /** + * The active flow ID – either from a flow we created in this session, or + * passed in from the registration response's `continue_with`. + */ + const activeFlowId = flow?.id ?? initialVerificationFlowId; + + const extractCodeValue = useCallback((nextFlow: VerificationFlow) => { + const codeInputNode = nextFlow.ui.nodes.find( + ({ attributes }) => + isUiNodeInputAttributes(attributes) && attributes.name === "code", + ); + + if ( + codeInputNode && + isUiNodeInputAttributes(codeInputNode.attributes) && + "value" in codeInputNode.attributes && + typeof codeInputNode.attributes.value === "string" + ) { + setCode(codeInputNode.attributes.value); + } + }, []); + + const createAndSendVerificationCode = useCallback(() => { + if (!email) { + setErrorMessage("Could not determine the email address to verify."); + return; + } + + setErrorMessage(undefined); + setCode(""); + setSendingCode(true); + + void oryKratosClient + .createBrowserVerificationFlow() + .then(async ({ data: verificationFlow }) => + oryKratosClient.updateVerificationFlow({ + flow: verificationFlow.id, + updateVerificationFlowBody: { + method: "code", + email, + csrf_token: mustGetCsrfTokenFromFlow(verificationFlow), + }, + }), + ) + .then(({ data }) => { + setFlow(data); + extractCodeValue(data); + }) + .catch((error: AxiosError) => handleFlowErrorRef.current(error)) + .catch((error: AxiosError) => { + if (error.response?.status === 400) { + setFlow(error.response.data); + return; + } + + return Promise.reject(error); + }) + .finally(() => setSendingCode(false)); + }, [email, extractCodeValue]); + + const codeInputUiNode = useMemo( + () => + flow?.ui.nodes.find( + ({ attributes }) => + isUiNodeInputAttributes(attributes) && attributes.name === "code", + ), + [flow], + ); + + const submitCode = useCallback( + (codeToSubmit: string) => { + if (!activeFlowId || !codeToSubmit) { + return; + } + + setVerifyingCode(true); + + let succeeded = false; + + const getFlowForSubmission = flow + ? Promise.resolve(flow) + : oryKratosClient + .getVerificationFlow({ id: activeFlowId }) + .then(({ data }) => { + setFlow(data); + return data; + }); + + void getFlowForSubmission + .then((resolvedFlow) => + oryKratosClient.updateVerificationFlow({ + flow: resolvedFlow.id, + updateVerificationFlowBody: { + method: "code", + code: codeToSubmit, + csrf_token: mustGetCsrfTokenFromFlow(resolvedFlow), + }, + }), + ) + .then(async ({ data: updatedFlow }) => { + if (updatedFlow.state === "passed_challenge") { + await onVerified(); + succeeded = true; + } else { + // Kratos returns 200 even when the code is invalid – the error + // details are inside the flow's `ui.messages` / node messages. + setFlow(updatedFlow); + } + }) + .catch(async (error: AxiosError) => { + await handleFlowErrorRef.current(error); + }) + .catch((error: AxiosError) => { + if (error.response?.status === 400) { + setFlow(error.response.data); + return; + } + + return Promise.reject(error); + }) + .finally(() => { + // Only reset on failure – on success, onVerified triggers navigation. + if (!succeeded) { + setVerifyingCode(false); + } + }); + }, + [activeFlowId, flow, onVerified], + ); + + const handleSubmit: FormEventHandler = (event) => { + event.preventDefault(); + submitCode(code); + }; + + return ( + + Verify your email address + palette.gray[70], + mb: 3, + }} + > + {activeFlowId ? ( + `Enter the verification code sent to ${email}` + ) : ( + <> + We've sent a verification code to {email}. Click + the link in the email to verify instantly, or request a new code + below to enter manually. + + )} + + + {activeFlowId ? ( + <> + { + const value = target.value; + setCode(value); + + if (/^\d{6}$/.test(value)) { + submitCode(value); + } + }} + error={ + !!codeInputUiNode?.messages.find(({ type }) => type === "error") + } + helperText={codeInputUiNode?.messages.map(({ id, text }) => ( + {text} + ))} + required + inputProps={{ + maxLength: 6, + inputMode: "numeric", + pattern: "[0-9]{6}", + }} + /> + + + + ) : ( + + )} + {flow?.ui.messages?.map(({ id, text, type }) => ( + + type === "error" ? palette.error.main : palette.gray[70], + }} + > + {text} + + ))} + {errorMessage ? {errorMessage} : null} + + {!isProduction ? ( + palette.gray[50], + textAlign: "center", + }} + > + [DEV] check{" "} + + MailSlurper (localhost:4436) + {" "} + for the verification email. + + ) : null} + + ); +}; diff --git a/apps/hash-frontend/src/pages/signin.page.tsx b/apps/hash-frontend/src/pages/signin.page.tsx index 85ff2556c83..427c1be0530 100644 --- a/apps/hash-frontend/src/pages/signin.page.tsx +++ b/apps/hash-frontend/src/pages/signin.page.tsx @@ -44,7 +44,7 @@ const SignupButton = styled((props: ButtonProps) => ( const SigninPage: NextPageWithLayout = () => { // Get ?flow=... from the URL const router = useRouter(); - const { refetch } = useAuthInfo(); + const { aal2Required, refetch } = useAuthInfo(); const { updateActiveWorkspaceWebId } = useContext(WorkspaceContext); const { hashInstance } = useHashInstance(); @@ -105,6 +105,9 @@ const SigninPage: NextPageWithLayout = () => { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const [totpCode, setTotpCode] = useState(""); + const [lookupSecret, setLookupSecret] = useState(""); + const [useLookupSecretInput, setUseLookupSecretInput] = useState(false); const [errorMessage, setErrorMessage] = useState(); const { handleFlowError } = useKratosErrorHandler({ @@ -149,6 +152,67 @@ const SigninPage: NextPageWithLayout = () => { handleFlowError, ]); + const isAal2Flow = useMemo( + () => + flow?.requested_aal === "aal2" || + flow?.ui.nodes.some(({ group }) => + ["totp", "lookup_secret"].includes(group), + ) === true, + [flow], + ); + + const emailInputUiNode = flow?.ui.nodes.find( + ({ attributes }) => + isUiNodeInputAttributes(attributes) && + attributes.name === "traits.emails", + ); + + const passwordInputUiNode = flow?.ui.nodes.find( + ({ attributes }) => + isUiNodeInputAttributes(attributes) && attributes.name === "password", + ); + + const totpInputUiNode = flow?.ui.nodes.find( + ({ attributes }) => + isUiNodeInputAttributes(attributes) && attributes.name === "totp_code", + ); + + const lookupSecretInputUiNode = flow?.ui.nodes.find( + ({ attributes }) => + isUiNodeInputAttributes(attributes) && + attributes.name === "lookup_secret", + ); + + const handleValidationError = (err: AxiosError) => { + if (err.response?.status === 400) { + setFlow(err.response.data); + return; + } + + if (err.response?.status === 429) { + setErrorMessage("Too many attempts, please try again shortly."); + return; + } + + return Promise.reject(err); + }; + + const completeSignin = async (activeFlow: LoginFlow) => { + const { authenticatedUser } = await refetch(); + + if (!authenticatedUser) { + if (aal2Required) { + void router.push("/signin?aal=aal2"); + return; + } + + throw new Error("Could not fetch authenticated user after logging in."); + } + + updateActiveWorkspaceWebId(authenticatedUser.accountId as WebId); + void router.push(returnTo ?? activeFlow.return_to ?? "/"); + }; + const handleSubmit: FormEventHandler = (event) => { event.preventDefault(); @@ -158,12 +222,41 @@ const SigninPage: NextPageWithLayout = () => { ); } - if (!email || !password) { + if (!isAal2Flow && (!email || !password)) { + return; + } + + if (isAal2Flow && !useLookupSecretInput && !totpCode) { + return; + } + + if (isAal2Flow && useLookupSecretInput && !lookupSecret) { return; } const csrf_token = mustGetCsrfTokenFromFlow(flow); + const updateLoginFlowBody = isAal2Flow + ? useLookupSecretInput + ? { + csrf_token, + method: "lookup_secret" as const, + lookup_secret: lookupSecret, + } + : { + csrf_token, + method: "totp" as const, + totp_code: totpCode, + } + : { + csrf_token, + method: "password" as const, + identifier: email, + password, + }; + + setErrorMessage(undefined); + void router // On submission, add the flow ID to the URL but do not navigate. This prevents the user losing // their data when they reload the page. @@ -172,60 +265,55 @@ const SigninPage: NextPageWithLayout = () => { oryKratosClient .updateLoginFlow({ flow: String(flow.id), - updateLoginFlowBody: { - csrf_token, - method: "password", - identifier: email, - password, - }, + updateLoginFlowBody, }) // We logged in successfully! Let's redirect the user. - .then(async () => { - // Otherwise, redirect the user to their workspace. - const { authenticatedUser } = await refetch(); - - if (!authenticatedUser) { - throw new Error( - "Could not fetch authenticated user after logging in.", + .then(async ({ data: loginResponse }) => { + if (!isAal2Flow) { + const redirectAction = loginResponse.continue_with?.find( + ( + action, + ): action is { + action: "redirect_browser_to"; + redirect_browser_to: string; + } => + action.action === "redirect_browser_to" && + "redirect_browser_to" in action && + typeof action.redirect_browser_to === "string", ); - } - updateActiveWorkspaceWebId(authenticatedUser.accountId as WebId); + if (redirectAction?.redirect_browser_to) { + void router.push(redirectAction.redirect_browser_to); + return; + } - void router.push(returnTo ?? flow.return_to ?? "/"); - }) - .catch(handleFlowError) - .catch((err: AxiosError) => { - // If the previous handler did not catch the error it's most likely a form validation error - if (err.response?.status === 400) { - // Yup, it is! - setFlow(err.response.data); - return; - } + try { + await oryKratosClient.toSession(); + } catch (error) { + const maybeAal2Error = error as AxiosError<{ + redirect_browser_to?: string; + }>; + + if (maybeAal2Error.response?.status === 403) { + const redirectTo = + maybeAal2Error.response.data.redirect_browser_to ?? + "/signin?aal=aal2"; - if (err.response?.status === 429) { - // This is a rate limiting error - setErrorMessage("Too many attempts, please try again shortly."); - return; + void router.push(redirectTo); + return; + } + + throw error; + } } - // This is an unexpected error, throw it so that it's reported - return Promise.reject(err); - }), + await completeSignin(flow); + }) + .catch(handleFlowError) + .catch(handleValidationError), ); }; - const emailInputUiNode = flow?.ui.nodes.find( - ({ attributes }) => - isUiNodeInputAttributes(attributes) && - attributes.name === "traits.emails", - ); - - const passwordInputUiNode = flow?.ui.nodes.find( - ({ attributes }) => - isUiNodeInputAttributes(attributes) && attributes.name === "password", - ); - const { userSelfRegistrationIsEnabled } = hashInstance?.properties ?? {}; return ( @@ -259,7 +347,11 @@ const SigninPage: NextPageWithLayout = () => { maxWidth: 600, }} > - Sign in to your account + + {isAal2Flow + ? "Enter your authentication code" + : "Sign in to your account"} + { gap: 1, }} > - setEmail(target.value)} - error={ - !!emailInputUiNode?.messages.find( - ({ type }) => type === "error", - ) - } - helperText={emailInputUiNode?.messages.map(({ id, text }) => ( - {text} - ))} - required - inputProps={{ "data-1p-ignore": false }} - /> - setPassword(target.value)} - error={ - !!passwordInputUiNode?.messages.find( - ({ type }) => type === "error", - ) - } - helperText={passwordInputUiNode?.messages.map(({ id, text }) => ( - {text} - ))} - required - inputProps={{ "data-1p-ignore": false }} - // eslint-disable-next-line react/jsx-no-duplicate-props - InputProps={{ - endAdornment: ( + {isAal2Flow ? ( + <> + palette.gray[70] }}> + Open your authenticator app and enter the code to continue. + + { + if (useLookupSecretInput) { + setLookupSecret(target.value); + } else { + setTotpCode(target.value); + } + }} + error={ + !!(useLookupSecretInput + ? lookupSecretInputUiNode?.messages.find( + ({ type }) => type === "error", + ) + : totpInputUiNode?.messages.find( + ({ type }) => type === "error", + )) + } + helperText={ + useLookupSecretInput + ? lookupSecretInputUiNode?.messages.map( + ({ id, text }) => ( + {text} + ), + ) + : totpInputUiNode?.messages.map(({ id, text }) => ( + {text} + )) + } + required + /> + + - ), - }} - /> - {errorMessage ? {errorMessage} : null} + + + ) : ( + <> + setEmail(target.value)} + error={ + !!emailInputUiNode?.messages.find( + ({ type }) => type === "error", + ) + } + helperText={emailInputUiNode?.messages.map(({ id, text }) => ( + {text} + ))} + required + inputProps={{ "data-1p-ignore": false }} + /> + setPassword(target.value)} + error={ + !!passwordInputUiNode?.messages.find( + ({ type }) => type === "error", + ) + } + helperText={passwordInputUiNode?.messages.map( + ({ id, text }) => {text}, + )} + required + inputProps={{ "data-1p-ignore": false }} + // eslint-disable-next-line react/jsx-no-duplicate-props + InputProps={{ + endAdornment: ( + + ), + }} + /> + + )} + {errorMessage ? ( + palette.red[70] }} + variant="smallTextParagraphs" + > + {errorMessage} + + ) : null} {flow?.ui.messages?.map(({ text, id }) => ( {text} ))} diff --git a/apps/hash-frontend/src/pages/signup.page.tsx b/apps/hash-frontend/src/pages/signup.page.tsx index e85151e1dea..17536fcba6f 100644 --- a/apps/hash-frontend/src/pages/signup.page.tsx +++ b/apps/hash-frontend/src/pages/signup.page.tsx @@ -1,9 +1,9 @@ -import { useMutation, useQuery } from "@apollo/client"; +import { useLazyQuery, useMutation, useQuery } from "@apollo/client"; import type { EntityId } from "@blockprotocol/type-system"; import { ArrowUpRightRegularIcon } from "@hashintel/design-system"; import { Grid, styled } from "@mui/material"; import { useRouter } from "next/router"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useUpdateAuthenticatedUser } from "../components/hooks/use-update-authenticated-user"; import type { @@ -25,6 +25,7 @@ import { Button } from "../shared/ui"; import { useAuthInfo } from "./shared/auth-info-context"; import { AuthLayout } from "./shared/auth-layout"; import { parseGraphQLError } from "./shared/auth-utils"; +import { VerifyEmailStep } from "./shared/verify-email-step"; import { AcceptOrgInvitation } from "./signup.page/accept-org-invitation"; import type { AccountSetupFormData } from "./signup.page/account-setup-form"; import { AccountSetupForm } from "./signup.page/account-setup-form"; @@ -69,12 +70,24 @@ const SignupPage: NextPageWithLayout = () => { const { authenticatedUser, refetch: refetchAuthenticatedUser } = useAuthInfo(); - const { data: userHasAccessToHashData } = useQuery( - hasAccessToHashQuery, - { - skip: !authenticatedUser, - }, - ); + const userHasVerifiedEmail = + authenticatedUser?.emails.find(({ verified }) => verified) !== undefined; + + const [fetchHasAccess, { data: userHasAccessToHashData }] = + useLazyQuery(hasAccessToHashQuery, { + fetchPolicy: "network-only", + }); + + /** + * Eagerly fetch access when the user already has a verified email on mount + * (e.g. page refresh after verification). The lazy query in `onVerified` + * handles the in-session verification flow. + */ + useEffect(() => { + if (userHasVerifiedEmail && !userHasAccessToHashData) { + void fetchHasAccess(); + } + }, [userHasVerifiedEmail, userHasAccessToHashData, fetchHasAccess]); const { invitationId } = router.query; @@ -121,6 +134,7 @@ const SignupPage: NextPageWithLayout = () => { if (errors && errors.length > 0) { const { message } = parseGraphQLError([...errors]); setErrorMessage(message); + return; } if (invitation) { @@ -144,10 +158,10 @@ const SignupPage: NextPageWithLayout = () => { ], ); - /** @todo: un-comment this to actually check whether the email is verified */ - // const userHasVerifiedEmail = - // authenticatedUser?.emails.find(({ verified }) => verified) !== undefined; - const userHasVerifiedEmail = true; + const verificationFlowId = + typeof router.query.verificationFlowId === "string" + ? router.query.verificationFlowId + : undefined; return ( { invitation={invitation} onAccept={() => setShowInvitationStep(false)} /> - ) : authenticatedUser && userHasAccessToHashData?.hasAccessToHash ? ( - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- @todo improve logic or types to remove this comment + ) : authenticatedUser ? ( userHasVerifiedEmail ? ( - + ) : null + ) : ( + { + await refetchAuthenticatedUser(); + + const { data } = await fetchHasAccess(); + + if (!data?.hasAccessToHash) { + void router.replace("/"); + } + }} /> - ) : /** @todo: add verification form */ - null + ) ) : ( )} @@ -209,7 +237,9 @@ const SignupPage: NextPageWithLayout = () => { currentStep={ invitation && !authenticatedUser ? "accept-invitation" - : "reserve-username" + : !userHasVerifiedEmail + ? "verify-email" + : "reserve-username" } withInvitation={!!invitation} /> diff --git a/apps/hash-frontend/src/pages/signup.page/signup-registration-form.tsx b/apps/hash-frontend/src/pages/signup.page/signup-registration-form.tsx index 64ea3f449c4..635ce0027e2 100644 --- a/apps/hash-frontend/src/pages/signup.page/signup-registration-form.tsx +++ b/apps/hash-frontend/src/pages/signup.page/signup-registration-form.tsx @@ -1,4 +1,3 @@ -import { useLazyQuery } from "@apollo/client"; import { TextField } from "@hashintel/design-system"; import { Box, Typography } from "@mui/material"; import type { RegistrationFlow } from "@ory/client"; @@ -6,11 +5,9 @@ import { isUiNodeInputAttributes } from "@ory/integrations/ui"; import type { AxiosError } from "axios"; import { useRouter } from "next/router"; import type { FormEventHandler, FunctionComponent } from "react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useHashInstance } from "../../components/hooks/use-hash-instance"; -import type { HasAccessToHashQuery } from "../../graphql/api-types.gen"; -import { hasAccessToHashQuery } from "../../graphql/queries/user.queries"; import { EnvelopeRegularIcon } from "../../shared/icons/envelope-regular-icon"; import { Button, Link } from "../../shared/ui"; import { AuthHeading } from "../shared/auth-heading"; @@ -64,8 +61,14 @@ export const SignupRegistrationForm: FunctionComponent = () => { setErrorMessage, }); - const [checkUserAccess] = - useLazyQuery(hasAccessToHashQuery); + /** + * Use a ref so the flow-init useEffect doesn't depend on the identity of + * `handleFlowError`, which changes when `authenticatedUser` updates in the + * auth context. Without this, the effect re-fires after registration and + * tries to create/fetch a flow that has already been consumed. + */ + const handleFlowErrorRef = useRef(handleFlowError); + handleFlowErrorRef.current = handleFlowError; // Get ?flow=... from the URL const { flow: flowId, return_to: returnTo } = router.query; @@ -83,7 +86,7 @@ export const SignupRegistrationForm: FunctionComponent = () => { .getRegistrationFlow({ id: String(flowId) }) // We received the flow - let's use its data and render the form! .then(({ data }) => setFlow(data)) - .catch(handleFlowError); + .catch((error) => handleFlowErrorRef.current(error)); return; } @@ -93,8 +96,8 @@ export const SignupRegistrationForm: FunctionComponent = () => { returnTo: returnTo ? String(returnTo) : undefined, }) .then(({ data }) => setFlow(data)) - .catch(handleFlowError); - }, [flowId, router, router.isReady, returnTo, flow, handleFlowError]); + .catch((error) => handleFlowErrorRef.current(error)); + }, [flowId, router, router.isReady, returnTo, flow]); const handleSubmit: FormEventHandler = (event) => { event.preventDefault(); @@ -133,15 +136,31 @@ export const SignupRegistrationForm: FunctionComponent = () => { method: "password", }, }) - .then(async () => { - const hasAccessToHash = await checkUserAccess().then( - ({ data }) => data?.hasAccessToHash, + .then(async ({ data: registrationResponse }) => { + // Extract the verification flow ID from Ory's continue_with so + // the verify-email step can reuse the flow Kratos already created + // (and already sent an email for) instead of creating a new one. + const verificationFlowId = registrationResponse.continue_with?.find( + ( + action, + ): action is { + action: "show_verification_ui"; + flow: { id: string; url: string; verifiable_address: string }; + } => action.action === "show_verification_ui", + )?.flow.id; + + // Clear the consumed registration flow ID from the URL and + // persist the verification flow ID as a query param so it + // survives the component remount that occurs when _app.page.tsx + // switches from the full to the minimal provider tree. + await router.replace( + { + pathname: "/signup", + query: verificationFlowId ? { verificationFlowId } : undefined, + }, + undefined, + { shallow: true }, ); - if (!hasAccessToHash) { - await refetch(); - void router.push("/"); - return; - } // If the user has successfully logged in and has access to complete signup, // refetch the authenticated user which should transition the user to the next step of the signup flow. diff --git a/apps/hash-frontend/src/pages/signup.page/signup-steps.tsx b/apps/hash-frontend/src/pages/signup.page/signup-steps.tsx index b5467635bb9..e82891da7eb 100644 --- a/apps/hash-frontend/src/pages/signup.page/signup-steps.tsx +++ b/apps/hash-frontend/src/pages/signup.page/signup-steps.tsx @@ -7,9 +7,14 @@ import { useMemo } from "react"; import { Circle1RegularIcon } from "../../shared/icons/circle-1-regular-icon"; import { Circle2RegularIcon } from "../../shared/icons/circle-2-regular-icon"; import { Circle3RegularIcon } from "../../shared/icons/circle-3-regular-icon"; +import { Circle4RegularIcon } from "../../shared/icons/circle-4-regular-icon"; import { CircleArrowRightRegularIcon } from "../../shared/icons/circle-arrow-right-regular-icon"; -type StepName = "reserve-username" | "start-using-hash" | "accept-invitation"; +type StepName = + | "verify-email" + | "reserve-username" + | "start-using-hash" + | "accept-invitation"; type Step = { name: StepName; @@ -18,11 +23,11 @@ type Step = { }; const stepsWithoutInvitation: Step[] = [ - // { - // name: "verify-email", - // label: "Verify your email address", - // labelPastTense: "Email address verified", - // }, + { + name: "verify-email", + label: "Verify your email address", + labelPastTense: "Email address verified", + }, { name: "reserve-username", label: "Reserve your username", @@ -47,12 +52,14 @@ const stepNumberToHumanReadable: Record = { 1: "One", 2: "Two", 3: "Three", + 4: "Four", }; const stepNumberToCircleIcon: Record = { 1: , 2: , 3: , + 4: , }; export const SignupSteps: FunctionComponent<{ diff --git a/apps/hash-frontend/src/pages/verification.page.tsx b/apps/hash-frontend/src/pages/verification.page.tsx index ebcd4d356c3..fd1f20f6434 100644 --- a/apps/hash-frontend/src/pages/verification.page.tsx +++ b/apps/hash-frontend/src/pages/verification.page.tsx @@ -1,180 +1,216 @@ -import { TextField } from "@hashintel/design-system"; -import { Box, Container, Typography } from "@mui/material"; +import { Box, CircularProgress, styled, Typography } from "@mui/material"; import type { VerificationFlow } from "@ory/client"; -import { isUiNodeInputAttributes } from "@ory/integrations/ui"; +import type { AxiosError } from "axios"; import { useRouter } from "next/router"; -import type { FormEventHandler } from "react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useLogoutFlow } from "../components/hooks/use-logout-flow"; import type { NextPageWithLayout } from "../shared/layout"; import { getPlainLayout } from "../shared/layout"; +import type { ButtonProps } from "../shared/ui"; import { Button } from "../shared/ui"; -import { - gatherUiNodeValuesFromFlow, - oryKratosClient, -} from "./shared/ory-kratos"; -import { useKratosErrorHandler } from "./shared/use-kratos-flow-error-handler"; - -const VerificationPage: NextPageWithLayout = () => { - // Get ?flow=... from the URL +import { useAuthInfo } from "./shared/auth-info-context"; +import { AuthLayout } from "./shared/auth-layout"; +import { mustGetCsrfTokenFromFlow, oryKratosClient } from "./shared/ory-kratos"; +import { VerifyEmailStep } from "./shared/verify-email-step"; + +const LogoutButton = styled((props: ButtonProps) => ( + - {flow?.ui.messages?.map(({ text, id }) => ( - {text} - ))} - {errorMessage ? {errorMessage} : null} - - - + ); }; -VerificationPage.getLayout = getPlainLayout; +VerifyEmailPage.getLayout = getPlainLayout; -export default VerificationPage; +export default VerifyEmailPage; diff --git a/apps/hash-frontend/src/shared/draft-entities-count-context.tsx b/apps/hash-frontend/src/shared/draft-entities-count-context.tsx index 8071e519edc..d211fd111b2 100644 --- a/apps/hash-frontend/src/shared/draft-entities-count-context.tsx +++ b/apps/hash-frontend/src/shared/draft-entities-count-context.tsx @@ -66,7 +66,7 @@ export const DraftEntitiesCountContextProvider: FunctionComponent< }, pollInterval, fetchPolicy: "network-only", - skip: !authenticatedUser, + skip: !authenticatedUser?.accountSignupComplete, }, ); diff --git a/apps/hash-frontend/src/shared/icons/circle-4-regular-icon.tsx b/apps/hash-frontend/src/shared/icons/circle-4-regular-icon.tsx new file mode 100644 index 00000000000..3c8cdb53df1 --- /dev/null +++ b/apps/hash-frontend/src/shared/icons/circle-4-regular-icon.tsx @@ -0,0 +1,14 @@ +import type { SvgIconProps } from "@mui/material"; +import { SvgIcon } from "@mui/material"; +import type { FunctionComponent } from "react"; + +export const Circle4RegularIcon: FunctionComponent = (props) => { + return ( + + + + + + + ); +}; diff --git a/apps/hash-frontend/src/shared/icons/lock-solid-icon.tsx b/apps/hash-frontend/src/shared/icons/lock-solid-icon.tsx new file mode 100644 index 00000000000..82bc5f796b1 --- /dev/null +++ b/apps/hash-frontend/src/shared/icons/lock-solid-icon.tsx @@ -0,0 +1,11 @@ +import type { SvgIconProps } from "@mui/material"; +import { SvgIcon } from "@mui/material"; +import type { FunctionComponent } from "react"; + +export const LockSolidIcon: FunctionComponent = (props) => { + return ( + + + + ); +}; diff --git a/apps/hash-frontend/src/shared/invites-context.tsx b/apps/hash-frontend/src/shared/invites-context.tsx index 5171ea80bbc..e2289040ca5 100644 --- a/apps/hash-frontend/src/shared/invites-context.tsx +++ b/apps/hash-frontend/src/shared/invites-context.tsx @@ -45,7 +45,7 @@ export const InvitesContextProvider: FunctionComponent = ({ GetMyPendingInvitationsQueryVariables >(getMyPendingInvitationsQuery, { pollInterval, - skip: !authenticatedUser, + skip: !authenticatedUser?.accountSignupComplete, fetchPolicy: "network-only", }); diff --git a/apps/hash-frontend/src/shared/layout/plain-layout.tsx b/apps/hash-frontend/src/shared/layout/plain-layout.tsx index 051b4f8c56f..6d67ec0e225 100644 --- a/apps/hash-frontend/src/shared/layout/plain-layout.tsx +++ b/apps/hash-frontend/src/shared/layout/plain-layout.tsx @@ -33,7 +33,10 @@ export const PlainLayout: FunctionComponent<{ const router = useRouter(); - const { authenticatedUser } = useAuthInfo(); + const { authenticatedUser, emailVerificationStatusKnown } = useAuthInfo(); + + const primaryEmailVerified = + authenticatedUser?.emails.find(({ primary }) => primary)?.verified ?? false; return ( <> @@ -53,7 +56,11 @@ export const PlainLayout: FunctionComponent<{ options={{ showSpinner: false }} showOnShallow /> - {authenticatedUser?.accountSignupComplete ? : null} + {authenticatedUser?.accountSignupComplete && + emailVerificationStatusKnown && + primaryEmailVerified ? ( + + ) : null} {children} ); diff --git a/apps/hash-frontend/src/shared/notification-count-context.tsx b/apps/hash-frontend/src/shared/notification-count-context.tsx index 5165c9ddfde..d3a48636f7a 100644 --- a/apps/hash-frontend/src/shared/notification-count-context.tsx +++ b/apps/hash-frontend/src/shared/notification-count-context.tsx @@ -113,7 +113,7 @@ export const NotificationCountContextProvider: FunctionComponent< includeDrafts: false, }, }, - skip: !authenticatedUser, + skip: !authenticatedUser?.accountSignupComplete, fetchPolicy: "network-only", }, ); diff --git a/tests/hash-backend-integration/src/tests/graph/knowledge/system-types/user.test.ts b/tests/hash-backend-integration/src/tests/graph/knowledge/system-types/user.test.ts index 5fd73e54d24..a8aec0e0706 100644 --- a/tests/hash-backend-integration/src/tests/graph/knowledge/system-types/user.test.ts +++ b/tests/hash-backend-integration/src/tests/graph/knowledge/system-types/user.test.ts @@ -43,6 +43,12 @@ const graphContext = createTestImpureGraphContext(); const shortname = generateRandomShortname("userTest"); +/** + * Email addresses that are permitted to sign up in a test environment. + * See USER_EMAIL_ALLOW_LIST in .env.local + */ +const allowListedEmail = "charlie@example.com"; + describe("User model class", () => { beforeAll(async () => { await ensureSystemGraphIsInitialized({ @@ -65,6 +71,7 @@ describe("User model class", () => { traits: { emails: ["test-user@example.com"], }, + verifyEmails: true, }); createdUser = await createUser(graphContext, authentication, { @@ -202,12 +209,13 @@ describe("User model class", () => { const identity = await createKratosIdentity({ traits: { - emails: ["incomplete-user@example.com"], + emails: [allowListedEmail], }, + verifyEmails: true, }); incompleteUser = await createUser(graphContext, authentication, { - emails: ["incomplete-user@example.com"], + emails: [allowListedEmail], kratosIdentityId: identity.id, }); diff --git a/tests/hash-backend-integration/src/tests/util.ts b/tests/hash-backend-integration/src/tests/util.ts index 300ee42de0a..8c7e7b6a95d 100644 --- a/tests/hash-backend-integration/src/tests/util.ts +++ b/tests/hash-backend-integration/src/tests/util.ts @@ -102,9 +102,9 @@ export const createTestUser = async ( const identity = await createKratosIdentity({ traits: { - shortname, emails: [`${shortname}@example.com`], }, + verifyEmails: true, }).catch((err) => { logger.error( `Error when creating Kratos Identity, ${shortname}: ${ diff --git a/tests/hash-playwright/tests/mfa.spec.ts b/tests/hash-playwright/tests/mfa.spec.ts new file mode 100644 index 00000000000..b2f8aace650 --- /dev/null +++ b/tests/hash-playwright/tests/mfa.spec.ts @@ -0,0 +1,172 @@ +import { resetDb } from "./shared/reset-db"; +import { expect, type Page, test } from "./shared/runtime"; +import { createUserAndCompleteSignup } from "./shared/signup-utils"; +import { generateTotpCode, waitForFreshTotpWindow } from "./shared/totp-utils"; + +const enableTotpForCurrentUser = async (page: Page) => { + await page.goto("/settings/security"); + await page.click('[data-testid="show-enable-totp-form-button"]'); + + const secretKeyLocator = page.locator('[data-testid="totp-secret-key"]'); + await expect(secretKeyLocator).toBeVisible(); + + const secret = + (await secretKeyLocator.textContent())?.replace(/\s/g, "") ?? ""; + if (!secret) { + throw new Error("Could not read TOTP secret key from settings page."); + } + + await waitForFreshTotpWindow(); + await page.fill( + '[placeholder="Enter your 6-digit code"]', + generateTotpCode(secret), + ); + await page.click('[data-testid="enable-totp-button"]'); + + const backupCodesModal = page.locator('[data-testid="backup-codes-modal"]'); + await expect(backupCodesModal).toBeVisible(); + + const backupCodes = ( + await page.locator('[data-testid="backup-code-item"]').allTextContents() + ) + .map((code) => code.trim()) + .filter((code) => code.length > 0); + + await page.click('[data-testid="confirm-backup-codes-button"]'); + await expect(backupCodesModal).not.toBeVisible(); + + await expect( + page.locator('[data-testid="disable-totp-button"]'), + ).toBeVisible(); + + return { backupCodes, secret }; +}; + +const signInWithPassword = async ( + page: Page, + { email, password }: { email: string; password: string }, +) => { + await page.goto("/signin"); + await page.fill('[placeholder="Enter your email address"]', email); + await page.fill('[placeholder="Enter your password"]', password); + await page.click("text=Submit"); +}; + +test.beforeEach(async () => { + await resetDb(); +}); + +/** + * @todo H-6219 restore these tests when restoring TOTP functionality + */ +test.skip("user can enable TOTP", async ({ page }) => { + await createUserAndCompleteSignup(page, { + email: "mfa-enable-totp@example.com", + shortname: "mfa-enable-totp", + }); + + const { backupCodes } = await enableTotpForCurrentUser(page); + + expect(backupCodes.length).toBeGreaterThan(0); +}); + +test.skip("user with TOTP is prompted for code at login", async ({ page }) => { + const credentials = await createUserAndCompleteSignup(page, { + email: "mfa-totp-login@example.com", + shortname: "mfa-totp-login", + }); + const { secret } = await enableTotpForCurrentUser(page); + + await page.context().clearCookies(); + + await signInWithPassword(page, credentials); + + await expect( + page.locator("text=Enter your authentication code"), + ).toBeVisible(); + + await waitForFreshTotpWindow(); + await page.fill( + '[data-testid="signin-aal2-code-input"]', + generateTotpCode(secret), + ); + await page.click('[data-testid="signin-aal2-submit-button"]'); + + await expect(page.locator("text=Get support")).toBeVisible(); +}); + +test.skip("user can use backup code instead of TOTP", async ({ page }) => { + const credentials = await createUserAndCompleteSignup(page, { + email: "mfa-backup-code@example.com", + shortname: "mfa-backup-code", + }); + const { backupCodes } = await enableTotpForCurrentUser(page); + + expect(backupCodes.length).toBeGreaterThan(0); + + await page.context().clearCookies(); + + await signInWithPassword(page, credentials); + await expect( + page.locator("text=Enter your authentication code"), + ).toBeVisible(); + + await page.click('[data-testid="signin-aal2-toggle-method-button"]'); + await page.fill('[data-testid="signin-aal2-code-input"]', backupCodes[0]!); + await page.click('[data-testid="signin-aal2-submit-button"]'); + + await expect(page.locator("text=Get support")).toBeVisible(); +}); + +test.skip("user can disable TOTP", async ({ page }) => { + const credentials = await createUserAndCompleteSignup(page, { + email: "mfa-disable-totp@example.com", + shortname: "mfa-disable-totp", + }); + const { secret } = await enableTotpForCurrentUser(page); + + await page.goto("/settings/security"); + await page.click('[data-testid="disable-totp-button"]'); + await waitForFreshTotpWindow(); + await page.fill( + '[placeholder="Enter a current code to disable"]', + generateTotpCode(secret), + ); + await page.click('[data-testid="confirm-disable-totp-button"]'); + + await expect( + page.locator('[data-testid="show-enable-totp-form-button"]'), + ).toBeVisible(); + + await page.context().clearCookies(); + + await signInWithPassword(page, credentials); + + await expect(page.locator("text=Get support")).toBeVisible(); + await expect( + page.locator("text=Enter your authentication code"), + ).not.toBeVisible(); +}); + +test.skip("wrong TOTP code shows error at login", async ({ page }) => { + const credentials = await createUserAndCompleteSignup(page, { + email: "mfa-wrong-code@example.com", + shortname: "mfa-wrong-code", + }); + await enableTotpForCurrentUser(page); + + await page.context().clearCookies(); + + await signInWithPassword(page, credentials); + await expect( + page.locator("text=Enter your authentication code"), + ).toBeVisible(); + + await page.fill('[data-testid="signin-aal2-code-input"]', "000000"); + await page.click('[data-testid="signin-aal2-submit-button"]'); + + await expect(page.locator("text=/invalid|expired|used/i")).toBeVisible(); + await expect( + page.locator("text=Enter your authentication code"), + ).toBeVisible(); +}); diff --git a/tests/hash-playwright/tests/shared/get-kratos-verification-code.ts b/tests/hash-playwright/tests/shared/get-kratos-verification-code.ts new file mode 100644 index 00000000000..5f4bc02b0cc --- /dev/null +++ b/tests/hash-playwright/tests/shared/get-kratos-verification-code.ts @@ -0,0 +1,148 @@ +import { sleep } from "@local/hash-isomorphic-utils/sleep"; + +type MailslurperMailAddress = { + address?: string; +}; + +type MailslurperMailItem = { + body?: string; + dateSent?: string; + subject?: string; + toAddresses?: Array; +}; + +const extractToAddresses = ( + toAddresses: MailslurperMailItem["toAddresses"], +): string[] => + (toAddresses ?? []) + .map((toAddress) => { + if (typeof toAddress === "string") { + return toAddress; + } + + return toAddress.address; + }) + .filter((toAddress): toAddress is string => typeof toAddress === "string"); + +const extractVerificationCode = (emailBody: string): string | undefined => + emailBody.match(/following code:\s*(?:\s*)?(\d{6})/is)?.[1] ?? + emailBody.match(/\b(\d{6})\b/)?.[1]; + +/** + * Matches the email subject for verification emails. + * Handles both the Kratos default subject and the custom HASH template subject. + */ +const isVerificationSubject = (subject?: string): boolean => { + if (!subject) { + return false; + } + return ( + subject === "Please verify your email address" || + subject.startsWith("Your HASH verification code:") + ); +}; + +export const getKratosVerificationCode = async ( + emailAddress: string, + afterTimestamp?: number, +): Promise => { + const maxWaitMs = 10_000; + const pollIntervalMs = 250; + let elapsed = 0; + let lastError: unknown; + let lastMailItems: MailslurperMailItem[] | undefined; + + while (elapsed < maxWaitMs) { + try { + const response = await fetch("http://localhost:4437/mail"); + + if (!response.ok) { + throw new Error( + `Unable to fetch emails from mailslurper: ${response.status} ${response.statusText}`, + ); + } + + const data = (await response.json()) as { + mailItems?: MailslurperMailItem[]; + }; + + lastMailItems = data.mailItems; + + const matchingMailItems = + data.mailItems + ?.filter((mailItem) => { + const sentTimestamp = mailItem.dateSent + ? new Date(mailItem.dateSent).getTime() + : undefined; + + return ( + isVerificationSubject(mailItem.subject) && + extractToAddresses(mailItem.toAddresses).includes(emailAddress) && + (!afterTimestamp || + (typeof sentTimestamp === "number" && + sentTimestamp >= afterTimestamp)) + ); + }) + .sort((a, b) => { + const aTimestamp = a.dateSent ? new Date(a.dateSent).getTime() : 0; + const bTimestamp = b.dateSent ? new Date(b.dateSent).getTime() : 0; + + return bTimestamp - aTimestamp; + }) ?? []; + + for (const mailItem of matchingMailItems) { + const code = mailItem.body + ? extractVerificationCode(mailItem.body) + : undefined; + + if (code) { + return code; + } + } + } catch (error) { + lastError = error; + } + + await sleep(pollIntervalMs); + elapsed += pollIntervalMs; + } + + const lastErrorMessage = + lastError instanceof Error ? ` Last error: ${lastError.message}` : ""; + + // Build diagnostic summary from the last poll to help debug failures. + const allItems = lastMailItems ?? []; + const toTargetAddress = allItems.filter((item) => + extractToAddresses(item.toAddresses).includes(emailAddress), + ); + const verificationToTarget = toTargetAddress.filter((item) => + isVerificationSubject(item.subject), + ); + const timestampFilteredOut = + afterTimestamp !== undefined + ? verificationToTarget.filter((item) => { + const sent = item.dateSent + ? new Date(item.dateSent).getTime() + : undefined; + return typeof sent === "number" && sent < afterTimestamp; + }) + : []; + + const diagnostics = [ + `Total emails in mailslurper: ${allItems.length}`, + `Emails to ${emailAddress}: ${toTargetAddress.length}`, + `Verification emails to ${emailAddress}: ${verificationToTarget.length}`, + afterTimestamp !== undefined + ? `Filtered out by timestamp (sent before ${new Date(afterTimestamp).toISOString()}): ${timestampFilteredOut.length}` + : null, + toTargetAddress.length > 0 + ? `Subjects to target: ${toTargetAddress.map((item) => JSON.stringify(item.subject)).join(", ")}` + : null, + ] + .filter(Boolean) + .join("; "); + + throw new Error( + `No verification email found for ${emailAddress} within ${maxWaitMs}ms.${lastErrorMessage} [${diagnostics}]`, + ); +}; diff --git a/tests/hash-playwright/tests/shared/signup-utils.ts b/tests/hash-playwright/tests/shared/signup-utils.ts new file mode 100644 index 00000000000..a99c8d67d21 --- /dev/null +++ b/tests/hash-playwright/tests/shared/signup-utils.ts @@ -0,0 +1,116 @@ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; + +import { getKratosVerificationCode } from "./get-kratos-verification-code"; + +const defaultPassword = "some-complex-pw-1ab2"; + +/** + * Fill in the signup form and submit it. + * Returns the email and the timestamp just before submission (for email polling). + */ +export const registerUser = async ( + page: Page, + { email, password = defaultPassword }: { email: string; password?: string }, +) => { + const registrationFlowReady = page.waitForResponse( + (response) => + response.request().method() === "GET" && + response.url().includes("/auth/self-service/registration/browser"), + { timeout: 15_000 }, + ); + + await page.goto("/signup"); + await registrationFlowReady; + + await page.fill('[placeholder="Enter your email address"]', email); + await page.fill('[type="password"]', password); + + const emailDispatchTimestamp = Date.now(); + const registrationSubmitComplete = page.waitForResponse( + (response) => + response.request().method() === "POST" && + response.url().includes("/auth/self-service/registration"), + { timeout: 15_000 }, + ); + + await page.getByRole("button", { name: "Sign up" }).click(); + await registrationSubmitComplete; + + return { email, emailDispatchTimestamp, password }; +}; + +/** + * On the verification screen, fetch the verification code from Mailslurper + * and enter it into the form. + */ +export const verifyEmailOnPage = async ( + page: Page, + { email, afterTimestamp }: { email: string; afterTimestamp: number }, +) => { + await expect( + page.getByRole("heading", { name: "Verify your email address" }), + ).toBeVisible({ timeout: 15_000 }); + + const verificationCode = await getKratosVerificationCode( + email, + afterTimestamp, + ); + + await page.fill( + '[placeholder="Enter your verification code"]', + verificationCode, + ); +}; + +/** + * Complete the signup form by entering a shortname and display name. + * Expects to be on the account completion page (after email verification). + */ +export const completeSignup = async ( + page: Page, + { shortname, displayName }: { shortname: string; displayName: string }, +) => { + await expect( + page.locator("text=Thanks for confirming your account"), + ).toBeVisible({ timeout: 15_000 }); + + await page.fill('[placeholder="example"]', shortname); + await page.fill('[placeholder="Jonathan Smith"]', displayName); + await page.getByRole("button", { name: "Continue" }).click(); + + await page.waitForURL("/"); + await expect(page.locator("text=Get support")).toBeVisible(); +}; + +/** + * Full flow: register a user, verify email, and complete signup. + */ +export const createUserAndCompleteSignup = async ( + page: Page, + { + email, + shortname, + displayName = shortname, + password = defaultPassword, + }: { + email: string; + shortname: string; + displayName?: string; + password?: string; + }, +) => { + const { emailDispatchTimestamp } = await registerUser(page, { + email, + password, + }); + + await verifyEmailOnPage(page, { + email, + afterTimestamp: emailDispatchTimestamp, + }); + + await completeSignup(page, { shortname, displayName }); + + return { email, password }; +}; diff --git a/tests/hash-playwright/tests/shared/totp-utils.ts b/tests/hash-playwright/tests/shared/totp-utils.ts new file mode 100644 index 00000000000..dbc3a7d83ac --- /dev/null +++ b/tests/hash-playwright/tests/shared/totp-utils.ts @@ -0,0 +1,71 @@ +import { createHmac } from "node:crypto"; + +const base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +const decodeBase32 = (encodedSecret: string): Buffer => { + const normalizedSecret = encodedSecret + .replace(/\s/g, "") + .replace(/=+$/, "") + .toUpperCase(); + + let bits = 0; + let value = 0; + const bytes: number[] = []; + + for (const character of normalizedSecret) { + const index = base32Alphabet.indexOf(character); + + if (index === -1) { + continue; + } + + value = value * 32 + index; + bits += 5; + + if (bits >= 8) { + bytes.push(Math.floor(value / 2 ** (bits - 8)) % 256); + bits -= 8; + } + } + + return Buffer.from(bytes); +}; + +export const generateTotpCode = ( + secret: string, + timestamp: number = Date.now(), +): string => { + const key = decodeBase32(secret); + const counter = Math.floor(timestamp / 1_000 / 30); + const counterBuffer = Buffer.alloc(8); + const highCounter = Math.floor(counter / 2 ** 32); + const lowCounter = counter % 2 ** 32; + + counterBuffer.writeUInt32BE(highCounter, 0); + counterBuffer.writeUInt32BE(lowCounter, 4); + + const hmac = createHmac("sha1", key).update(counterBuffer).digest(); + const offset = hmac[hmac.length - 1]! % 16; + + const binaryCode = + (hmac[offset]! % 128) * 16_777_216 + + hmac[offset + 1]! * 65_536 + + hmac[offset + 2]! * 256 + + hmac[offset + 3]!; + + return (binaryCode % 1_000_000).toString().padStart(6, "0"); +}; + +/** + * If fewer than `bufferMs` remain in the current 30-second TOTP window, + * wait for the next window so the generated code has enough validity time. + */ +export const waitForFreshTotpWindow = async (bufferMs = 5_000) => { + const secondsIntoWindow = (Date.now() / 1_000) % 30; + const msRemaining = (30 - secondsIntoWindow) * 1_000; + if (msRemaining < bufferMs) { + await new Promise((resolve) => { + setTimeout(resolve, msRemaining + 200); + }); + } +}; diff --git a/tests/hash-playwright/tests/signup.spec.ts b/tests/hash-playwright/tests/signup.spec.ts index b90d803e100..c3705cda015 100644 --- a/tests/hash-playwright/tests/signup.spec.ts +++ b/tests/hash-playwright/tests/signup.spec.ts @@ -1,48 +1,66 @@ import { resetDb } from "./shared/reset-db"; import { expect, test } from "./shared/runtime"; +import { + completeSignup, + registerUser, + verifyEmailOnPage, +} from "./shared/signup-utils"; test.beforeEach(async () => { await resetDb(); }); -test("user can sign up", async ({ page }) => { - await page.goto("/"); +const allowlistedEmail = "charlie@example.com"; - await page.click("text=Sign in"); +test("allowlisted user can verify email and complete signup", async ({ + page, +}) => { + const { email, emailDispatchTimestamp } = await registerUser(page, { + email: allowlistedEmail, + }); - await page.waitForURL("**/signin"); + await verifyEmailOnPage(page, { + email, + afterTimestamp: emailDispatchTimestamp, + }); - await expect(page.locator("text=SIGN IN TO YOUR ACCOUNT")).toBeVisible(); - await expect(page.locator("text=Create a free account")).toBeVisible(); + const uniqueSuffix = `${Date.now()}${Math.floor(Math.random() * 1_000)}`; + const shortname = `signup${uniqueSuffix}`.slice(0, 24); - await page.click("text=Create a free account"); - - await page.waitForURL("**/signup"); - - const randomNumber = Math.floor(Math.random() * 10_000) - .toString() - .padEnd(4, "0"); // shortnames must be at least 4 characters + await completeSignup(page, { shortname, displayName: "New User" }); +}); - await page.fill( - '[placeholder="Enter your email address"]', - `${randomNumber}@example.com`, - ); +test("waitlisted user is redirected to waitlist after signup", async ({ + page, +}) => { + const uniqueSuffix = `${Date.now()}${Math.floor(Math.random() * 1_000)}`; + const waitlistedEmail = `signup-${uniqueSuffix}@example.com`; - await page.fill('[type="password"]', "some-complex-pw-1ab2"); + const { emailDispatchTimestamp } = await registerUser(page, { + email: waitlistedEmail, + }); - await page.click("text=Sign up"); + // Waitlisted users must also verify their email before proceeding + await verifyEmailOnPage(page, { + email: waitlistedEmail, + afterTimestamp: emailDispatchTimestamp, + }); + await page.waitForURL("/"); await expect( - page.locator("text=Thanks for confirming your account"), + page.getByText("on the waitlist", { exact: false }), ).toBeVisible(); - await page.fill('[placeholder="example"]', randomNumber.toString()); - - await page.fill('[placeholder="Jonathan Smith"]', "New User"); - - await page.click("text=Continue"); + await page.goto("/settings/security"); + await page.waitForURL("/"); + await expect( + page.getByText("on the waitlist", { exact: false }), + ).toBeVisible(); + await page.goto("/signup"); await page.waitForURL("/"); - await expect(page.locator("text=Get support")).toBeVisible(); + await expect( + page.getByText("on the waitlist", { exact: false }), + ).toBeVisible(); });