diff --git a/apps/backend/src/auth/AuthUser.ts b/apps/backend/src/auth/AuthUser.ts index 5965ed3..95adb3f 100644 --- a/apps/backend/src/auth/AuthUser.ts +++ b/apps/backend/src/auth/AuthUser.ts @@ -1,7 +1 @@ -export interface AuthUser { - // Stable user id used everywhere in the game (player ids, rtmt client ids). - id: string; - // Display name shown to other players. Undefined for legacy userKey clients. - name?: string; - isGuest: boolean; -} +export type { AuthUser } from "@mancala/core"; diff --git a/apps/backend/src/auth/channels.ts b/apps/backend/src/auth/channels.ts deleted file mode 100644 index 11e2d40..0000000 --- a/apps/backend/src/auth/channels.ts +++ /dev/null @@ -1,11 +0,0 @@ -// WebSocket auth protocol. Move to @mancala/core together with the client implementation. - -// client -> server: { token: string } — Keycloak access token. Send right after connecting, and again after refreshing. -// Other messages are ignored until on_auth arrives. -export const channel_auth = "auth"; -// server -> client: { user: AuthUser } — the socket is authenticated as this user. -export const channel_on_auth = "on_auth"; -// server -> client: { reason: string } — sent right before the socket is closed with CLOSE_AUTH_FAILED. -export const channel_on_auth_error = "on_auth_error"; - -export const CLOSE_AUTH_FAILED = 4001; diff --git a/apps/backend/src/game/GameManager.ts b/apps/backend/src/game/GameManager.ts index bc10c95..d7a5b1a 100644 --- a/apps/backend/src/game/GameManager.ts +++ b/apps/backend/src/game/GameManager.ts @@ -18,8 +18,8 @@ import { import { GameMove } from "@mancala/core"; import { GameCrashManager } from "./GameCrashManager"; import { MatchMaker } from "../matchmaker/MatchMaker"; -import { GameUsersConnectionInfo } from "@mancala/core"; -import { BackendGame, PlayerConnectionInfo, PlayerInfo } from "../models/Game"; +import { GameUsersConnectionInfo, UserConnectionInfo } from "@mancala/core"; +import { BackendGame, PlayerInfo } from "../models/Game"; export class GameManager { gameStore: GameStore; @@ -176,7 +176,7 @@ export class GameManager { } private getGameUsersConnectionInfoFromPlayers(player1: PlayerInfo, player2: PlayerInfo): GameUsersConnectionInfo { - const toConnectionInfo = (player: PlayerInfo): PlayerConnectionInfo => + const toConnectionInfo = (player: PlayerInfo): UserConnectionInfo => ({ ...player, isOnline: this.rtmt.isClientOnline(player.userId) }); return { user1ConnectionInfo: toConnectionInfo(player1), diff --git a/apps/backend/src/models/Game.ts b/apps/backend/src/models/Game.ts index b5d851d..601a64d 100644 --- a/apps/backend/src/models/Game.ts +++ b/apps/backend/src/models/Game.ts @@ -1,4 +1,4 @@ -import { Game, UserConnectionInfo } from "@mancala/core"; +import { Game } from "@mancala/core"; export interface PlayerInfo { userId: string; @@ -7,9 +7,6 @@ export interface PlayerInfo { isGuest: boolean; } -// Sent to clients in place of UserConnectionInfo; the extra fields are ignored by older clients. -export type PlayerConnectionInfo = UserConnectionInfo & PlayerInfo; - export interface BackendGame extends Game { spectatorIds: string[]; // Keyed by user id, captured when the game is created. diff --git a/apps/backend/src/rtmt/rtmt_websocket.ts b/apps/backend/src/rtmt/rtmt_websocket.ts index a527cd9..df4b784 100644 --- a/apps/backend/src/rtmt/rtmt_websocket.ts +++ b/apps/backend/src/rtmt/rtmt_websocket.ts @@ -2,11 +2,10 @@ import { OnClientConnectionChange, OnMessage, RTMT } from "./rtmt" import WebSocket from "ws" import * as http from 'http'; -import { channel_ping, channel_pong } from "@mancala/core"; +import { channel_auth, channel_on_auth, channel_on_auth_error, channel_ping, channel_pong, CLOSE_AUTH_FAILED } from "@mancala/core"; import { RTMT_WS_PING_INTERVAL } from "../consts/config"; import { decode, encode, Message } from "./encode_decode_message"; import { AuthUser, InvalidTokenError, TokenVerifier } from "../auth"; -import { channel_auth, channel_on_auth, channel_on_auth_error, CLOSE_AUTH_FAILED } from "../auth/channels"; const DEFAULT_AUTH_TIMEOUT_MS = 5000; const LEGACY_USER_KEY_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; diff --git a/apps/backend/tests/rtmt/rtmt_websocket.test.ts b/apps/backend/tests/rtmt/rtmt_websocket.test.ts index 872cc63..ce1e3fa 100644 --- a/apps/backend/tests/rtmt/rtmt_websocket.test.ts +++ b/apps/backend/tests/rtmt/rtmt_websocket.test.ts @@ -2,7 +2,7 @@ import * as http from "http"; import { AddressInfo } from "net"; import WebSocket from "ws"; import { AuthUser, InvalidTokenError, TokenVerifier } from "../../src/auth"; -import { channel_auth, channel_on_auth, channel_on_auth_error, CLOSE_AUTH_FAILED } from "../../src/auth/channels"; +import { channel_auth, channel_on_auth, channel_on_auth_error, CLOSE_AUTH_FAILED } from "@mancala/core"; import { RTMTWS } from "../../src/rtmt/rtmt_websocket"; const LEGACY_KEY = "0b9f6d1e-7c1a-4f7e-9a51-3f0c2b6d8e11"; diff --git a/apps/compose.yaml b/apps/compose.yaml index 6b030d7..39e9a41 100644 --- a/apps/compose.yaml +++ b/apps/compose.yaml @@ -18,6 +18,8 @@ services: build: context: .. dockerfile: apps/frontend/Dockerfile + args: + KEYCLOAK_URL: ${KEYCLOAK_HOSTNAME:-http://localhost:8080} ports: - "3000:3000" keycloak-db: diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index a213b7a..fc4a1ae 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -1,7 +1,7 @@ # Build from the repo root: docker build -f apps/frontend/Dockerfile . FROM node:20-alpine3.19 AS build -ARG SERVER_ADDRESS WS_SERVER_ADDRESS +ARG SERVER_ADDRESS WS_SERVER_ADDRESS KEYCLOAK_URL WORKDIR /app diff --git a/apps/frontend/README.md b/apps/frontend/README.md index 873596b..c3ffa38 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -18,10 +18,24 @@ yarn dev ### Development Dependencies -This package depends on the local packages `@mancala/core` and `mancala.js`. Refer to their documentation for further instructions. +This package links the local packages `@mancala/core` and `mancala.js`. Build them first with `yarn build:packages` in the repo root. + +### Login + +Every visitor has a Keycloak session (see `apps/keycloak`): the first visit logs in as a new guest without any +form, and the account menu on the home page lets guests log in or sign up. Run Keycloak locally with +`docker compose up keycloak` in `apps/`; the dev server must run on port 1234, which the `mancala-web` client allows. + +| Build variable | Default | +|---|---| +| `SERVER_ADDRESS` | `http://localhost:5005` | +| `WS_SERVER_ADDRESS` | `ws://localhost:5005` | +| `KEYCLOAK_URL` | `http://localhost:8080` | ### Docker Build +From the repo root: + ```bash -docker build --build-arg SERVER_ADDRESS=http://localhost:5000 --build-arg WS_SERVER_ADDRESS=ws://localhost:5000 -t mancala-frontend . +docker build -f apps/frontend/Dockerfile --build-arg SERVER_ADDRESS=http://localhost:5000 --build-arg WS_SERVER_ADDRESS=ws://localhost:5000 --build-arg KEYCLOAK_URL=http://localhost:8080 -t mancala-frontend . ``` \ No newline at end of file diff --git a/apps/frontend/package.json b/apps/frontend/package.json index b4a74b8..0af3191 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -23,6 +23,7 @@ "i18next": "^23.11.5", "mancala.js": "link:../../packages/mancala.js", "notyf": "^3.10.0", + "oidc-client-ts": "3.5.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^14.1.2", diff --git a/apps/frontend/src/MancalaApp.tsx b/apps/frontend/src/MancalaApp.tsx index 85b1831..b381e91 100644 --- a/apps/frontend/src/MancalaApp.tsx +++ b/apps/frontend/src/MancalaApp.tsx @@ -10,7 +10,7 @@ import GamePage from './routes/GamePage'; import Home from './routes/Home'; import { initContext } from './context/context'; -import { RTMTWS, ConnectionState } from '@mancala/core'; +import { RTMTWS, ConnectionState, AuthUser } from '@mancala/core'; import { getColorByBrightness } from "@mancala/core"; import { Theme } from '@mancala/core'; import LobyPage from './routes/LobyPage'; @@ -26,7 +26,13 @@ const MancalaApp: FunctionComponent = () => { const { t, i18n } = useTranslation(); - const [userKey, setUserKey] = useState(undefined); + // Set once the server has authenticated the connection. + const [authUser, setAuthUser] = useState(undefined); + + // Pages compare this with the game's player ids. + const userKey = authUser?.id; + + const [hasSession, setHasSession] = useState(false); const [connectionState, setConnetionState] = useState("connecting"); @@ -44,35 +50,66 @@ const MancalaApp: FunctionComponent = () => { i18n.changeLanguage(deviceLanguage === "tr-TR" ? "tr" : "en") }, []); - const connectRTMT = (userKey: string) => { + const onAuthenticated = (user: AuthUser) => setAuthUser(user); + + // If the session can no longer be refreshed, start over as a new guest. + const getAccessTokenOrLoginAgain = async () => { + try { + return await context.authService.getAccessToken(); + } catch (error) { + context.authService.loginAsGuest(); + throw error; + } + } + + const connectRTMT = () => { const rtmt = context.rtmt as RTMTWS; rtmt.on("error", onConnectionError); - rtmt.on("connectionchange", onConnectionChange) - const url = server.wsServerAdress + "?userKey=" + userKey; - rtmt.connectWebSocket(url, userKey) + rtmt.on("connectionchange", onConnectionChange); + rtmt.on("authenticated", onAuthenticated); + rtmt.connectWebSocket(server.wsServerAdress, getAccessTokenOrLoginAgain); return rtmt; } - const loadUserKeyAndConnectServer = () => { - context.userKeyStore.getUserKey().then((userKey: string) => { - setUserKey(userKey); - connectRTMT(userKey); - }).catch((error) => { - //TODO: check if it is network error! - swal(t("Error") + "!", t("ErrorWhenRetrievingInformation"), "error"); - console.error(error); - }); + const startSession = async () => { + const authService = context.authService; + if (authService.isCallback()) { + let returnTo = "/"; + try { + returnTo = await authService.handleCallback(); + } catch (error) { + // e.g. the callback page was reloaded; fall back to the stored session. + console.error("(Auth) Login callback failed", error); + } + window.history.replaceState(null, "", returnTo); + } + const user = await authService.getUser(); + if (!user) { + await authService.loginAsGuest(); + return; + } + setHasSession(true); + connectRTMT(); } const disposeApp = () => { context.rtmt?.dispose(); + context.rtmt.off("authenticated", onAuthenticated); context.themeManager.off("themechange", onThemeChange); } React.useEffect(() => { - loadUserKeyAndConnectServer(); + startSession().catch((error) => { + swal(t("Error") + "!", t("ErrorWhenRetrievingInformation"), "error"); + console.error(error); + }); + const removeUserLoadedListener = context.authService.onUserLoaded( + (user) => (context.rtmt as RTMTWS).reauthenticate(user.access_token)); context.themeManager.on("themechange", onThemeChange); - return () => disposeApp(); + return () => { + removeUserLoadedListener(); + disposeApp(); + }; }, []); const textColorOnBoard = getColorByBrightness( @@ -82,9 +119,9 @@ const MancalaApp: FunctionComponent = () => { ); return ( <> - + {hasSession && - } /> + } /> } > @@ -95,7 +132,7 @@ const MancalaApp: FunctionComponent = () => { - + } {Util.getTextByConnectionState(context, connectionState, t)} diff --git a/apps/frontend/src/auth/AuthService.ts b/apps/frontend/src/auth/AuthService.ts new file mode 100644 index 0000000..6629a18 --- /dev/null +++ b/apps/frontend/src/auth/AuthService.ts @@ -0,0 +1,85 @@ +import { User, UserManager, WebStorageStateStore } from "oidc-client-ts"; + +const CALLBACK_PATH = "/auth/callback"; + +type SigninState = { returnTo: string }; + +/** + * Keycloak login for the web app. Everyone has a session: visitors without one are logged in as a new guest, + * and guests can log in or sign up to switch to an account. + */ +export class AuthService { + private readonly userManager: UserManager; + + constructor(params: { keycloakUrl: string, realm: string, clientId: string }) { + const origin = window.location.origin; + this.userManager = new UserManager({ + authority: `${params.keycloakUrl}/realms/${params.realm}`, + client_id: params.clientId, + redirect_uri: origin + CALLBACK_PATH, + post_logout_redirect_uri: origin + "/", + response_type: "code", + // offline_access keeps guests' identity after the SSO session ends. + scope: "openid offline_access", + userStore: new WebStorageStateStore({ store: window.localStorage }), + // Renews with the refresh token shortly before the access token expires. + automaticSilentRenew: true, + monitorSession: false, + }); + } + + public isCallback(): boolean { + return window.location.pathname === CALLBACK_PATH; + } + + // Completes a login redirect and returns the path the user started from. + public async handleCallback(): Promise { + const user = await this.userManager.signinRedirectCallback(); + return (user.state as SigninState | undefined)?.returnTo ?? "/"; + } + + // The stored session, refreshed if its access token has expired. Null if there is no usable session. + public async getUser(): Promise { + const user = await this.userManager.getUser(); + if (!user || !user.expired) return user; + try { + return await this.userManager.signinSilent(); + } catch (error) { + console.warn("(Auth) Could not refresh the session", error); + await this.userManager.removeUser(); + return null; + } + } + + public async getAccessToken(): Promise { + const user = await this.getUser(); + if (!user) throw new Error("Not logged in"); + return user.access_token; + } + + public onUserLoaded(callback: (user: User) => void): () => void { + this.userManager.events.addUserLoaded(callback); + return () => this.userManager.events.removeUserLoaded(callback); + } + + public loginAsGuest(returnTo = currentPath()): Promise { + return this.userManager.signinRedirect({ extraQueryParams: { guest: "true" }, state: { returnTo } }); + } + + // prompt=login shows the login form even though the guest already has a session. + public login(returnTo = currentPath()): Promise { + return this.userManager.signinRedirect({ prompt: "login", state: { returnTo } }); + } + + public signUp(returnTo = currentPath()): Promise { + return this.userManager.signinRedirect({ prompt: "create", state: { returnTo } }); + } + + public logout(): Promise { + return this.userManager.signoutRedirect(); + } +} + +function currentPath(): string { + return window.location.pathname + window.location.search; +} diff --git a/apps/frontend/src/components/headerbar/AccountMenu.tsx b/apps/frontend/src/components/headerbar/AccountMenu.tsx new file mode 100644 index 0000000..dc20841 --- /dev/null +++ b/apps/frontend/src/components/headerbar/AccountMenu.tsx @@ -0,0 +1,50 @@ +import { Menu, MenuHeader, MenuItem } from "@szhsin/react-menu"; +import * as React from "react"; +import { FunctionComponent } from "react"; +import { AuthUser, getPlayerDisplayName } from "@mancala/core"; +import { useTranslation } from "react-i18next"; +import { Context } from "../../context/context"; +import "@szhsin/react-menu/dist/index.css"; +import "@szhsin/react-menu/dist/transitions/slide.css" + +// Guests can log in or sign up; logged in users can log out. +const AccountMenu: FunctionComponent<{ context: Context, textColor: string, authUser?: AuthUser }> = (props) => { + const { context, textColor, authUser } = props; + const { t } = useTranslation(); + if (!authUser) return <>; + + const authService = context.authService; + const menuButton = + account_circle + ; + const itemStyle = { color: textColor }; + const menuItems = authUser.isGuest ? [ + authService.login()}>{t("LogIn")}, + authService.signUp()}>{t("SignUp")}, + ] : [ + authService.logout()}>{t("LogOut")}, + ]; + return ( +
+ + {getPlayerDisplayName(authUser, t)} + {menuItems} + + +
+ ); +} + +export default AccountMenu; diff --git a/apps/frontend/src/const/config.ts b/apps/frontend/src/const/config.ts index 4da4a82..c199ef8 100644 --- a/apps/frontend/src/const/config.ts +++ b/apps/frontend/src/const/config.ts @@ -15,4 +15,13 @@ export const server: Server = { wsServerAdress: process.env.WS_SERVER_ADDRESS ? `${process.env.WS_SERVER_ADDRESS}` : LOCAL_WS_SERVER_ADDRESS, } +export const LOCAL_KEYCLOAK_URL = 'http://localhost:8080'; + +export const keycloak = { + //@ts-ignore + url: process.env.KEYCLOAK_URL ? `${process.env.KEYCLOAK_URL}` : LOCAL_KEYCLOAK_URL, + realm: "mancala", + clientId: "mancala-web", +}; + export const RTMT_WS_PING_INTERVAL = 1000, RTMT_WS_PING_INTERVAL_BUFFER_TIME = 2000; \ No newline at end of file diff --git a/apps/frontend/src/context/context.tsx b/apps/frontend/src/context/context.tsx index 3dacda4..b848039 100644 --- a/apps/frontend/src/context/context.tsx +++ b/apps/frontend/src/context/context.tsx @@ -1,14 +1,14 @@ -import { server } from "../const/config"; +import { keycloak, server } from "../const/config"; import { RTMT, RTMTWS, ThemeManager } from "@mancala/core"; import { HttpServiceImpl } from "../service/HttpService"; import { GameStore, GameStoreImpl } from "../store/GameStore"; -import { UserKeyStore, UserKeyStoreImpl } from "../store/KeyStore"; +import { AuthService } from "../auth/AuthService"; import { LocalStorage } from "../storage"; import { Storage } from '@mancala/core' export type Context = { rtmt: RTMT; - userKeyStore: UserKeyStore; + authService: AuthService; themeManager: ThemeManager; gameStore: GameStore; storage: Storage; @@ -17,13 +17,13 @@ export type Context = { export const initContext = (): Context => { const rtmt = new RTMTWS(); const httpService = new HttpServiceImpl(server.serverAdress); - const userKeyStore = new UserKeyStoreImpl({ httpService }); + const authService = new AuthService({ keycloakUrl: keycloak.url, realm: keycloak.realm, clientId: keycloak.clientId }); const gameStore = new GameStoreImpl({ httpService }); const storage = new LocalStorage(); const themeManager = new ThemeManager(storage); return { rtmt: rtmt, - userKeyStore: userKeyStore, + authService, themeManager, gameStore, storage diff --git a/apps/frontend/src/routes/GamePage.tsx b/apps/frontend/src/routes/GamePage.tsx index 1abb708..68e9f20 100644 --- a/apps/frontend/src/routes/GamePage.tsx +++ b/apps/frontend/src/routes/GamePage.tsx @@ -22,7 +22,7 @@ import { Context } from '../context/context'; import useWindowDimensions from '../hooks/useWindowDimensions'; import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core"; import { Theme } from '@mancala/core'; -import { getColorByBrightness } from "@mancala/core"; +import { getColorByBrightness, getPlayerDisplayName } from "@mancala/core"; import { BoardViewModel } from "@mancala/core"; import Center from '../components/Center'; import notyf from '../util/Notyf'; @@ -84,6 +84,11 @@ const GamePage: FunctionComponent<{ return false; } + const getPlayerName = (userId: string) => { + const connectionInfos = [gameUsersConnectionInfo?.user1ConnectionInfo, gameUsersConnectionInfo?.user2ConnectionInfo]; + return getPlayerDisplayName(connectionInfos.find((info) => info?.userId === userId), t); + } + const onGameUpdateEvent = (pitAnimator: PitAnimator, message: Object) => { const newGame: Game = message as Game; newGame.mancalaGame = MancalaGame.createFromMancalaGame(newGame.mancalaGame); @@ -230,17 +235,19 @@ const GamePage: FunctionComponent<{ const showBoardView = game && boardViewModel && userKey && true; const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0"; const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1"; + const topLocatedUserName = getPlayerName(topLocatedUserId); + const bottomLocatedUserName = getPlayerName(bottomLocatedUserId); const topLocatedUser = { id: topLocatedUserId, - name: "Anonymous", + name: topLocatedUserName ?? "Anonymous", isOnline: isUserOnline(topLocatedUserId), - isAnonymous: true + isAnonymous: !topLocatedUserName }; const bottomLocatedUser = { id: bottomLocatedUserId, - name: "Anonymous", + name: bottomLocatedUserName ?? "Anonymous", isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected", - isAnonymous: true + isAnonymous: !bottomLocatedUserName }; const currentUser = isSpectator ? { id: "2", diff --git a/apps/frontend/src/routes/Home.tsx b/apps/frontend/src/routes/Home.tsx index 09da876..6e83ac0 100644 --- a/apps/frontend/src/routes/Home.tsx +++ b/apps/frontend/src/routes/Home.tsx @@ -1,13 +1,14 @@ import * as React from "react"; import { FunctionComponent, useEffect, useState } from "react"; import { getColorByBrightness } from "@mancala/core"; -import { Theme } from "@mancala/core"; +import { AuthUser, Theme } from "@mancala/core"; import HeaderBar from "../components/headerbar/HeaderBar"; import PageContainer from "../components/PageContainer"; import Row from "../components/Row"; import HeaderbarIcon from "../components/headerbar/HeaderbarIcon"; import HeaderbarTitle from "../components/headerbar/HeaderbarTitle"; import ThemeSwitchMenu from "../components/headerbar/ThemeSwitchMenu"; +import AccountMenu from "../components/headerbar/AccountMenu"; import Button from "../components/Button"; import { Context } from "../context/context"; import { Link, useNavigate } from "react-router-dom"; @@ -17,8 +18,9 @@ import { useTranslation } from "react-i18next"; const Home: FunctionComponent<{ context: Context, userKey?: string, + authUser?: AuthUser, theme: Theme, -}> = ({ context, userKey, theme }) => { +}> = ({ context, userKey, authUser, theme }) => { const navigate = useNavigate(); @@ -47,6 +49,7 @@ const Home: FunctionComponent<{ +