From 0e1e55d8809128472211a42e5d4efc4a66df7324 Mon Sep 17 00:00:00 2001 From: Josh Field Date: Tue, 30 Jul 2024 12:55:23 +1000 Subject: [PATCH] Add TOS Checks & Disable Chat and Media if user has not accepted (#10732) * add terms of service and age checkboxes, disable chat and media if user does not accept terms * replace verify scope with check scope * add under 13 check and message about accessing chat * bug fixes --- packages/client-core/i18n/en/common.json | 2 + packages/client-core/i18n/en/user.json | 5 + .../src/components/InstanceChat/index.tsx | 25 +++ .../src/components/World/EngineHooks.tsx | 7 +- .../components/UserMenu/menus/ProfileMenu.tsx | 145 ++++++++++++++++-- .../src/user/services/AuthService.ts | 1 + .../common/src/schemas/user/user.schema.ts | 1 + .../instanceserver/src/NetworkFunctions.ts | 6 +- .../instanceserver/src/SocketFunctions.ts | 2 +- packages/instanceserver/src/channels.ts | 23 ++- .../20240726030053_add-accept-tos.ts | 64 ++++++++ .../server-core/src/user/user/user.hooks.ts | 1 + .../src/user/user/user.resolvers.ts | 14 +- 13 files changed, 278 insertions(+), 18 deletions(-) create mode 100644 packages/server-core/src/user/user/migrations/20240726030053_add-accept-tos.ts diff --git a/packages/client-core/i18n/en/common.json b/packages/client-core/i18n/en/common.json index f63d0fe9e0..c630031ae2 100755 --- a/packages/client-core/i18n/en/common.json +++ b/packages/client-core/i18n/en/common.json @@ -47,6 +47,8 @@ "loadingAllowed": "Loading allowed routes...", "loadingXRSystems": "Loading immersive session...", "connectingToWorld": "Connecting to world...", + "needToAcceptTOS": "You need to accept the terms of service to access chat.", + "needToLogIn": "You need to log in to access chat.", "connectingToMedia": "Connecting to media...", "entering": "Entering world...", "loading": "Loading...", diff --git a/packages/client-core/i18n/en/user.json b/packages/client-core/i18n/en/user.json index 62be7cb06d..dce98ae445 100755 --- a/packages/client-core/i18n/en/user.json +++ b/packages/client-core/i18n/en/user.json @@ -222,6 +222,11 @@ "issueVC": "Issue a VC", "requestVC": "Request a VC", "addSocial": "Connect your Social Logins", + "logIn": "Log In", + "acceptTOS": "I accept the ", + "termsOfService": "Terms of Service", + "confirmAge13": "I confirm that I am 13 years or older", + "confirmAge18": "I confirm that I am 18 years or older", "removeSocial": "Remove Social Logins", "connections": { "title": "Connections", diff --git a/packages/client-core/src/components/InstanceChat/index.tsx b/packages/client-core/src/components/InstanceChat/index.tsx index ba9e4997eb..3e0506685f 100755 --- a/packages/client-core/src/components/InstanceChat/index.tsx +++ b/packages/client-core/src/components/InstanceChat/index.tsx @@ -459,9 +459,34 @@ export const InstanceChatWrapper = () => { const { t } = useTranslation() const { bottomShelfStyle } = useShelfStyles() + const acceptedTOS = useMutableState(AuthState).user.acceptedTOS.value + const isGuest = useMutableState(AuthState).user.isGuest.value + const networkWorldConfig = useHookstate(getMutableState(NetworkState).config.world) const targetChannelId = useHookstate(getMutableState(ChannelState).targetChannelId) + if (isGuest) + return ( + <> +
+
+

{t('common:loader.needToLogIn')}

+
+
+ + ) + + if (!acceptedTOS) + return ( + <> +
+
+

{t('common:loader.needToAcceptTOS')}

+
+
+ + ) + return ( <> {targetChannelId.value ? ( diff --git a/packages/client-core/src/components/World/EngineHooks.tsx b/packages/client-core/src/components/World/EngineHooks.tsx index 18863fc664..f10689cc5a 100755 --- a/packages/client-core/src/components/World/EngineHooks.tsx +++ b/packages/client-core/src/components/World/EngineHooks.tsx @@ -54,6 +54,7 @@ import { EngineState } from '@etherealengine/spatial/src/EngineState' import { RouterState } from '../../common/services/RouterService' import { LocationState } from '../../social/services/LocationService' +import { AuthState } from '../../user/services/AuthService' const logger = multiLogger.child({ component: 'client-core:world' }) @@ -143,15 +144,17 @@ export const useLoadEngineWithScene = () => { } export const useNetwork = (props: { online?: boolean }) => { + const acceptedTOS = useMutableState(AuthState).user.acceptedTOS.value + useEffect(() => { getMutableState(NetworkState).config.set({ world: !!props.online, - media: !!props.online, + media: !!props.online && acceptedTOS, friends: !!props.online, instanceID: !!props.online, roomID: false }) - }, [props.online]) + }, [props.online, acceptedTOS]) /** Offline/local world network */ useEffect(() => { diff --git a/packages/client-core/src/user/components/UserMenu/menus/ProfileMenu.tsx b/packages/client-core/src/user/components/UserMenu/menus/ProfileMenu.tsx index 9f05aff51f..07be53866b 100755 --- a/packages/client-core/src/user/components/UserMenu/menus/ProfileMenu.tsx +++ b/packages/client-core/src/user/components/UserMenu/menus/ProfileMenu.tsx @@ -27,7 +27,7 @@ Ethereal Engine. All Rights Reserved. import { QRCodeSVG } from 'qrcode.react' import React, { useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { useLocation } from 'react-router-dom' +import { Link, useLocation } from 'react-router-dom' import Avatar from '@etherealengine/client-core/src/common/components/Avatar' import Button from '@etherealengine/client-core/src/common/components/Button' @@ -44,14 +44,23 @@ import Menu from '@etherealengine/client-core/src/common/components/Menu' import Text from '@etherealengine/client-core/src/common/components/Text' import config, { validateEmail, validatePhoneNumber } from '@etherealengine/common/src/config' import multiLogger from '@etherealengine/common/src/logger' -import { authenticationSettingPath, clientSettingPath, UserName } from '@etherealengine/common/src/schema.type.module' +import { + authenticationSettingPath, + clientSettingPath, + UserName, + userPath +} from '@etherealengine/common/src/schema.type.module' import { getMutableState, useHookstate } from '@etherealengine/hyperflux' import { useFind } from '@etherealengine/spatial/src/common/functions/FeathersHooks' import Box from '@etherealengine/ui/src/primitives/mui/Box' +import Checkbox from '@etherealengine/ui/src/primitives/mui/Checkbox' import CircularProgress from '@etherealengine/ui/src/primitives/mui/CircularProgress' +import FormControlLabel from '@etherealengine/ui/src/primitives/mui/FormControlLabel' import Icon from '@etherealengine/ui/src/primitives/mui/Icon' import IconButton from '@etherealengine/ui/src/primitives/mui/IconButton' +import { Engine } from '@etherealengine/ecs' +import Grid from '@etherealengine/ui/src/primitives/mui/Grid' import { initialAuthState, initialOAuthConnectedState } from '../../../../common/initialAuthState' import { NotificationService } from '../../../../common/services/NotificationService' import { useZendesk } from '../../../../hooks/useZendesk' @@ -64,6 +73,8 @@ import { UserMenus } from '../../../UserUISystem' import styles from '../index.module.scss' import { PopupMenuServices } from '../PopupMenuService' +const termsOfService = config.client.tosAddress ?? '/terms-of-service' + const logger = multiLogger.child({ component: 'engine:ecs:ProfileMenu', modifier: clientContextParams }) interface Props { @@ -96,6 +107,32 @@ const ProfileMenu = ({ hideLogin, onClose, isPopover }: Props): JSX.Element => { const userId = selfUser.id.value const apiKey = selfUser.apiKey?.token?.value const isGuest = selfUser.isGuest.value + const acceptedTOS = !!selfUser.acceptedTOS.value + + const checkedTOS = useHookstate(!isGuest) + const checked13OrOver = useHookstate(!isGuest) + const checked18OrOver = useHookstate(acceptedTOS) + const hasAcceptedTermsAndAge = checkedTOS.value && checked13OrOver.value + + const originallyAcceptedTOS = useHookstate(acceptedTOS) + + useEffect(() => { + if (!originallyAcceptedTOS.value && checked18OrOver.value) { + Engine.instance.api + .service(userPath) + .patch(userId, { acceptedTOS: true }) + .then(() => { + selfUser.acceptedTOS.set(true) + logger.info({ + event_name: 'accept_tos', + event_value: '' + }) + }) + .catch((e) => { + console.error(e, 'Error updating user') + }) + } + }, [checked18OrOver]) const hasAdminAccess = useUserHasAccessHook('admin:admin') const avatarThumbnail = useUserAvatarThumbnail(userId) @@ -393,7 +430,7 @@ const ProfileMenu = ({ hideLogin, onClose, isPopover }: Props): JSX.Element => { PopupMenuServices.showPopupMenu(UserMenus.AvatarSelect)} /> @@ -403,28 +440,113 @@ const ProfileMenu = ({ hideLogin, onClose, isPopover }: Props): JSX.Element => { {hasAdminAccess ? ' Admin' : isGuest ? ' Guest' : ' User'}. - {selfUser?.inviteCode.value && ( + {hasAcceptedTermsAndAge && selfUser?.inviteCode.value && ( {t('user:usermenu.profile.inviteCode')}: {selfUser.inviteCode.value} )} - {!selfUser?.isGuest.value && ( + {hasAcceptedTermsAndAge && !selfUser?.isGuest.value && ( createLoginLink()}> {t('user:usermenu.profile.createLoginLink')} )} - showUserId.set(!showUserId.value)}> - {showUserId.value ? t('user:usermenu.profile.hideUserId') : t('user:usermenu.profile.showUserId')} - + {hasAcceptedTermsAndAge && ( + showUserId.set(!showUserId.value)}> + {showUserId.value ? t('user:usermenu.profile.hideUserId') : t('user:usermenu.profile.showUserId')} + + )} - {selfUser?.apiKey?.id && ( + {hasAcceptedTermsAndAge && selfUser?.apiKey?.id && ( showApiKey.set(!showApiKey.value)}> {showApiKey.value ? t('user:usermenu.profile.hideApiKey') : t('user:usermenu.profile.showApiKey')} )} + {isGuest && ( + + checkedTOS.set(e.target.checked)} + color="primary" + name="isAgreedTermsOfService" + /> + } + label={ +
+ {t('user:usermenu.profile.acceptTOS')} + + {t('user:usermenu.profile.termsOfService')} + +
+ } + /> + checked13OrOver.set(e.target.checked)} + color="primary" + name="is13OrOver" + /> + } + label={ +
+ {t('user:usermenu.profile.confirmAge13')} +
+ } + /> +
+ )} + + {!isGuest && !originallyAcceptedTOS.value && ( + + checked18OrOver.set(e.target.checked)} + color="primary" + name="is13OrOver" + /> + } + label={ +
+ {t('user:usermenu.profile.confirmAge18')} +
+ } + /> +
+ )} + {!isGuest && ( {t('user:usermenu.profile.logout')} @@ -491,6 +613,7 @@ const ProfileMenu = ({ hideLogin, onClose, isPopover }: Props): JSX.Element => {
{ )} - {!hideLogin && ( + {!hideLogin && hasAcceptedTermsAndAge && ( <> {isGuest && enableConnect && ( <> @@ -618,7 +741,7 @@ const ProfileMenu = ({ hideLogin, onClose, isPopover }: Props): JSX.Element => { <> {selfUser?.isGuest.value && ( - {t('user:usermenu.profile.addSocial')} + {hasAcceptedTermsAndAge ? t('user:usermenu.profile.addSocial') : t('user:usermenu.profile.logIn')} )}
diff --git a/packages/client-core/src/user/services/AuthService.ts b/packages/client-core/src/user/services/AuthService.ts index dbf21114a3..ffa470af30 100755 --- a/packages/client-core/src/user/services/AuthService.ts +++ b/packages/client-core/src/user/services/AuthService.ts @@ -95,6 +95,7 @@ export const UserSeed: UserType = { createdAt: '', updatedAt: '' }, + acceptedTOS: false, userSetting: { id: '' as UserSettingID, themeModes: {}, diff --git a/packages/common/src/schemas/user/user.schema.ts b/packages/common/src/schemas/user/user.schema.ts index 048337ac3e..c813bf8db3 100644 --- a/packages/common/src/schemas/user/user.schema.ts +++ b/packages/common/src/schemas/user/user.schema.ts @@ -62,6 +62,7 @@ export const userSchema = Type.Object( format: 'uuid' }), name: TypedString(), + acceptedTOS: Type.Boolean(), isGuest: Type.Boolean(), inviteCode: Type.Optional(TypedString()), avatarId: TypedString({ diff --git a/packages/instanceserver/src/NetworkFunctions.ts b/packages/instanceserver/src/NetworkFunctions.ts index 447e099b46..add297ed15 100755 --- a/packages/instanceserver/src/NetworkFunctions.ts +++ b/packages/instanceserver/src/NetworkFunctions.ts @@ -149,7 +149,11 @@ export async function cleanupOldInstanceservers(app: Application): Promise * @param userId * @returns */ -export const authorizeUserToJoinServer = async (app: Application, instance: InstanceType, userId: UserID) => { +export const authorizeUserToJoinServer = async (app: Application, instance: InstanceType, user: UserType) => { + const userId = user.id + // disallow users from joining media servers if they haven't accepted the TOS + if (instance.channelId && !user.acceptedTOS) return false + const authorizedUsers = (await app.service(instanceAuthorizedUserPath).find({ query: { instanceId: instance.id, diff --git a/packages/instanceserver/src/SocketFunctions.ts b/packages/instanceserver/src/SocketFunctions.ts index 96f41e1705..7b8489fee4 100644 --- a/packages/instanceserver/src/SocketFunctions.ts +++ b/packages/instanceserver/src/SocketFunctions.ts @@ -104,7 +104,7 @@ export const setupSocketFunctions = async (app: Application, spark: any) => { // Check that this use is allowed on this instance const instance = await app.service(instancePath).get(getState(InstanceServerState).instance.id) - if (!(await authorizeUserToJoinServer(app, instance, userId))) { + if (!(await authorizeUserToJoinServer(app, instance, user))) { authTask.status = 'fail' authTask.error = AuthError.USER_NOT_AUTHORIZED logger.error('[MessageTypes.Authorization]: user %s not authorized over peer %s %o', userId, peerID, authTask) diff --git a/packages/instanceserver/src/channels.ts b/packages/instanceserver/src/channels.ts index 997e122280..fc584f61e7 100755 --- a/packages/instanceserver/src/channels.ts +++ b/packages/instanceserver/src/channels.ts @@ -206,7 +206,12 @@ const initializeInstance = async ({ if (existingInstanceResult.total > 0) { const instance = existingInstanceResult.data[0] - if (userId && !(await authorizeUserToJoinServer(app, instance, userId))) return false + if (userId) { + const user = await app.service(userPath).get(userId) + if (!user) return false + const authorised = await authorizeUserToJoinServer(app, instance, user) + if (!authorised) return false + } if (instance.locationId) { const existingChannel = (await app.service(channelPath).find({ query: { @@ -418,7 +423,12 @@ const updateInstance = async ({ }, 1000) }) const instance = await app.service(instancePath).get(instanceServerState.instance.id, { headers }) - if (userId && !(await authorizeUserToJoinServer(app, instance, userId))) return false + if (userId) { + const user = await app.service(userPath).get(userId) + if (!user) return false + const authorised = await authorizeUserToJoinServer(app, instance, user) + if (!authorised) return false + } logger.info(`Authorized user ${userId} to join server`) await serverState.agonesSDK.allocate() @@ -607,6 +617,15 @@ export const onConnection = (app: Application) => async (connection: PrimusConne logger.info(`user ${userId} joining ${locationId ?? channelId} and room code ${roomCode}`) + if (userId) { + const user = await app.service(userPath).get(userId) + // disallow users from joining media servers if they haven't accepted the TOS + if (channelId && !user.acceptedTOS) { + logger.warn('User tried to connect without accepting TOS') + return + } + } + const instanceServerState = getState(InstanceServerState) const serverState = getState(ServerState) diff --git a/packages/server-core/src/user/user/migrations/20240726030053_add-accept-tos.ts b/packages/server-core/src/user/user/migrations/20240726030053_add-accept-tos.ts new file mode 100644 index 0000000000..0c0b5f268a --- /dev/null +++ b/packages/server-core/src/user/user/migrations/20240726030053_add-accept-tos.ts @@ -0,0 +1,64 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +import type { Knex } from 'knex' + +import { userPath } from '@etherealengine/common/src/schemas/user/user.schema' + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export async function up(knex: Knex): Promise { + await knex.raw('SET FOREIGN_KEY_CHECKS=0') + + const acceptedTOSColumnExists = await knex.schema.hasColumn(userPath, 'acceptedTOS') + + if (!acceptedTOSColumnExists) { + await knex.schema.alterTable(userPath, async (table) => { + table.boolean('acceptedTOS').nullable().defaultTo(false) + }) + } + + await knex.raw('SET FOREIGN_KEY_CHECKS=1') +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export async function down(knex: Knex): Promise { + await knex.raw('SET FOREIGN_KEY_CHECKS=0') + + const acceptedTOSColumnExists = await knex.schema.hasColumn(userPath, 'acceptedTOS') + + if (acceptedTOSColumnExists) { + await knex.schema.alterTable(userPath, async (table) => { + table.dropColumn('acceptedTOS') + }) + } + + await knex.raw('SET FOREIGN_KEY_CHECKS=1') +} diff --git a/packages/server-core/src/user/user/user.hooks.ts b/packages/server-core/src/user/user/user.hooks.ts index fbb26454c4..93529db683 100755 --- a/packages/server-core/src/user/user/user.hooks.ts +++ b/packages/server-core/src/user/user/user.hooks.ts @@ -100,6 +100,7 @@ const restrictUserPatch = async (context: HookContext) => { // selective define allowed props as not to accidentally pass an undefined value (which will be interpreted as NULL) if (typeof item.avatarId !== 'undefined') data.avatarId = item.avatarId if (typeof item.name !== 'undefined') data.name = item.name + if (typeof item.acceptedTOS !== 'undefined') data.acceptedTOS = item.acceptedTOS return data } diff --git a/packages/server-core/src/user/user/user.resolvers.ts b/packages/server-core/src/user/user/user.resolvers.ts index 73559d4c0a..9f5648730a 100644 --- a/packages/server-core/src/user/user/user.resolvers.ts +++ b/packages/server-core/src/user/user/user.resolvers.ts @@ -48,6 +48,8 @@ import { InviteCode, UserID, UserName, UserQuery, UserType } from '@etherealengi import { fromDateTimeSql, getDateTimeSql } from '@etherealengine/common/src/utils/datetime-sql' import type { HookContext } from '@etherealengine/server-core/declarations' +import { isDev } from '@etherealengine/common/src/config' +import checkScope from '../../hooks/check-scope' import getFreeInviteCode from '../../util/get-free-invite-code' export const userResolver = resolve({ @@ -164,7 +166,17 @@ export const userExternalResolver = resolve({ paginate: false })) as LocationBanType[] }), - isGuest: async (value, user) => !!user.isGuest // https://stackoverflow.com/a/56523892/2077741 + // https://stackoverflow.com/a/56523892/2077741 + isGuest: async (value, user) => !!user.isGuest, + /** This must not be returned for other users */ + acceptedTOS: virtual(async (user, context) => { + if (isDev) return true + const isSelfOrAdmin = context.params.user + ? context.params.user?.id === user.id || (await checkScope('admin', 'admin')(context)) + : false + if (!isSelfOrAdmin) return undefined + return !!user.acceptedTOS + }) }) export const userDataResolver = resolve({