From d6fa52f190fd6ce602940cbdcc44537fc30d713a Mon Sep 17 00:00:00 2001 From: Sidney Date: Wed, 29 Jul 2026 05:39:16 +0200 Subject: [PATCH 1/4] Upgrade guest accounts to real Ory accounts Guests can turn their profile into a real account from the signup form (reachable from the settings sheet), keeping uuid, name and game history. anonLink now reads the ory session from the ory_kratos_session cookie instead of a request body, and clears the anon_token cookie on success so AuthMiddleware stops preferring it. The .http files follow the same switch from X-Session-Token to cookie auth. --- client/src/api/client.ts | 3 +- client/src/api/generated/index.ts | 2 +- client/src/api/generated/sdk.gen.ts | 11 +-- client/src/api/generated/types.gen.ts | 9 +- client/src/components/settings.tsx | 16 +++- client/src/components/signup-form.tsx | 91 ++++++++++++------- client/src/hooks/use-ory.tsx | 46 +++++++++- client/src/i18n/de.json | 8 ++ client/src/i18n/en.json | 8 ++ http/http-client.env.json | 5 +- http/ory/auth.http | 30 ++++-- http/yass/admin.http | 8 +- http/yass/auth.http | 2 +- http/yass/game.http | 2 +- .../kotlin/ch/yass/identity/AuthController.kt | 50 +++++----- .../ch/yass/identity/api/AnonLinkRequest.kt | 3 - server/main/resources/openapi-spec.yaml | 18 +--- 17 files changed, 198 insertions(+), 114 deletions(-) delete mode 100644 server/main/kotlin/ch/yass/identity/api/AnonLinkRequest.kt diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 9ba116d7..aa03091c 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1,6 +1,5 @@ import * as generatedApi from './generated'; import { - AnonLinkData, AnonSignupData, ChooseTrumpRequest, CreateCustomGameRequest, JoinGameRequest, MessageRequest, PingSeatRequest, @@ -38,7 +37,7 @@ export const api = { subscribe: (data: SubscribeData['body']) => generatedApi.subscribe({client: customClient, body: data}), anonSignup: (data: AnonSignupData['body']) => generatedApi.anonSignup({client: customClient, body: data}), anonLogout: () => generatedApi.anonLogout({client: customClient}), - anonLink: (data: AnonLinkData['body']) => generatedApi.anonLink({client: customClient, body: data}), + anonLink: () => generatedApi.anonLink({client: customClient}), connect: () => generatedApi.connect({client: customClient}), // Game diff --git a/client/src/api/generated/index.ts b/client/src/api/generated/index.ts index bc52d646..a930ec3e 100644 --- a/client/src/api/generated/index.ts +++ b/client/src/api/generated/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts export { anonLink, anonLogout, anonSignup, connect, getAdminAnalyzeGameByCode, type Options, postAdminMessage, postGameCreate, postGameJoin, postGamePing, postGamePlay, postGameSchiebe, postGameTrump, postGameWeisen, subscribe, whoami } from './sdk.gen'; -export type { AnalyzeGameStateResponse, AnalyzeHand, AnonLinkData, AnonLinkErrors, AnonLinkRequest, AnonLinkResponse, AnonLinkResponses, AnonLogoutData, AnonLogoutResponse, AnonLogoutResponses, AnonSignupData, AnonSignupErrors, AnonSignupRequest, AnonSignupResponse, AnonSignupResponse2, AnonSignupResponses, Card, CardInHand, CardOnTable, ChooseTrumpRequest, ClientOptions, ConnectData, ConnectErrors, ConnectResponse, ConnectResponses, CreateCustomGameRequest, CreateCustomGameResponse, GameFinished, GetAdminAnalyzeGameByCodeData, GetAdminAnalyzeGameByCodeResponse, GetAdminAnalyzeGameByCodeResponses, JoinGameRequest, JoinGameResponse, MessageRequest, PingSeatRequest, PlayCardRequest, PlayedCard, PlayedCardWithPlayer, Player, PlayerAtTable, PlayerWithCards, PlayerWithWeise, Position, PostAdminMessageData, PostAdminMessageResponse, PostAdminMessageResponses, PostGameCreateData, PostGameCreateResponse, PostGameCreateResponses, PostGameJoinData, PostGameJoinResponse, PostGameJoinResponses, PostGamePingData, PostGamePingResponse, PostGamePingResponses, PostGamePlayData, PostGamePlayResponse, PostGamePlayResponses, PostGameSchiebeData, PostGameSchiebeResponse, PostGameSchiebeResponses, PostGameTrumpData, PostGameTrumpResponse, PostGameTrumpResponses, PostGameWeisenData, PostGameWeisenResponse, PostGameWeisenResponses, Rank, SchiebeRequest, SeatState, State, SubscribeData, SubscribeErrors, SubscribeRequest, SubscribeResponse, SubscribeResponses, SuccessfulActionResponse, Suit, TeamWithPoints, TotalPoints, TrickWithCards, Trump, Weis, WeisenRequest, WeisType, WeisWithPoints, WhoamiData, WhoamiErrors, WhoamiResponse, WhoAmIResponse, WhoamiResponses } from './types.gen'; +export type { AnalyzeGameStateResponse, AnalyzeHand, AnonLinkData, AnonLinkErrors, AnonLinkResponse, AnonLinkResponses, AnonLogoutData, AnonLogoutResponse, AnonLogoutResponses, AnonSignupData, AnonSignupErrors, AnonSignupRequest, AnonSignupResponse, AnonSignupResponse2, AnonSignupResponses, Card, CardInHand, CardOnTable, ChooseTrumpRequest, ClientOptions, ConnectData, ConnectErrors, ConnectResponse, ConnectResponses, CreateCustomGameRequest, CreateCustomGameResponse, GameFinished, GetAdminAnalyzeGameByCodeData, GetAdminAnalyzeGameByCodeResponse, GetAdminAnalyzeGameByCodeResponses, JoinGameRequest, JoinGameResponse, MessageRequest, PingSeatRequest, PlayCardRequest, PlayedCard, PlayedCardWithPlayer, Player, PlayerAtTable, PlayerWithCards, PlayerWithWeise, Position, PostAdminMessageData, PostAdminMessageResponse, PostAdminMessageResponses, PostGameCreateData, PostGameCreateResponse, PostGameCreateResponses, PostGameJoinData, PostGameJoinResponse, PostGameJoinResponses, PostGamePingData, PostGamePingResponse, PostGamePingResponses, PostGamePlayData, PostGamePlayResponse, PostGamePlayResponses, PostGameSchiebeData, PostGameSchiebeResponse, PostGameSchiebeResponses, PostGameTrumpData, PostGameTrumpResponse, PostGameTrumpResponses, PostGameWeisenData, PostGameWeisenResponse, PostGameWeisenResponses, Rank, SchiebeRequest, SeatState, State, SubscribeData, SubscribeErrors, SubscribeRequest, SubscribeResponse, SubscribeResponses, SuccessfulActionResponse, Suit, TeamWithPoints, TotalPoints, TrickWithCards, Trump, Weis, WeisenRequest, WeisType, WeisWithPoints, WhoamiData, WhoamiErrors, WhoamiResponse, WhoAmIResponse, WhoamiResponses } from './types.gen'; diff --git a/client/src/api/generated/sdk.gen.ts b/client/src/api/generated/sdk.gen.ts index d5cedd9f..2410d48f 100644 --- a/client/src/api/generated/sdk.gen.ts +++ b/client/src/api/generated/sdk.gen.ts @@ -201,14 +201,13 @@ export const anonSignup = (options: Option /** * Link anonymous account to Ory account + * + * Links the anon player of the anon_token cookie to the Ory identity of the ory_kratos_session cookie. Both cookies have to be present, the anon_token cookie gets cleared on success. + * */ -export const anonLink = (options: Options) => (options.client ?? client).post({ +export const anonLink = (options?: Options) => (options?.client ?? client).post({ responseType: 'json', security: [{ name: 'X-Anon-Token', type: 'apiKey' }], url: '/auth/anon/link', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } + ...options }); diff --git a/client/src/api/generated/types.gen.ts b/client/src/api/generated/types.gen.ts index eeb7ad50..c3042433 100644 --- a/client/src/api/generated/types.gen.ts +++ b/client/src/api/generated/types.gen.ts @@ -239,13 +239,6 @@ export type AnonSignupResponse = { name: string; }; -export type AnonLinkRequest = { - /** - * Ory session token to link with anonymous account - */ - orySession: string; -}; - export type MessageRequest = { /** * The feedback or message content @@ -528,7 +521,7 @@ export type AnonSignupResponses = { export type AnonSignupResponse2 = AnonSignupResponses[keyof AnonSignupResponses]; export type AnonLinkData = { - body: AnonLinkRequest; + body?: never; path?: never; query?: never; url: '/auth/anon/link'; diff --git a/client/src/components/settings.tsx b/client/src/components/settings.tsx index 0995c0da..d5a20592 100644 --- a/client/src/components/settings.tsx +++ b/client/src/components/settings.tsx @@ -1,4 +1,4 @@ -import {Gauge, Languages, LogOut, SettingsIcon, Spade, User} from 'lucide-react'; +import {Gauge, Languages, LogOut, SettingsIcon, Spade, User, UserPlus} from 'lucide-react'; import {useSettingsStore} from "@/store/settings.ts"; import {PlaySpeed} from "@/types/play-speed.ts"; import {RadioGroup, RadioGroupItem} from "@/components/ui/radio-group.tsx"; @@ -35,6 +35,7 @@ interface SettingsProps { const Settings = ({ triggerVariant = 'fixed', open: controlledOpen, onOpenChange }: SettingsProps) => { const {language, setLanguage, cardDeck, setCardDeck, playSpeed, setPlaySpeed} = useSettingsStore(); const isAuthenticated = usePlayerStore(state => state.isAuthenticated) + const isAnon = usePlayerStore(state => state.isAnon) const name = usePlayerStore(state => state.name) const gameUuid = useGameStateStore(state => state.gameUuid) const code = useGameStateStore(state => state.code) @@ -125,6 +126,19 @@ const Settings = ({ triggerVariant = 'fixed', open: controlledOpen, onOpenChange + {isAnon && ( + + )} )} diff --git a/client/src/components/signup-form.tsx b/client/src/components/signup-form.tsx index 609bf871..eaafc57c 100644 --- a/client/src/components/signup-form.tsx +++ b/client/src/components/signup-form.tsx @@ -15,25 +15,38 @@ import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx"; import {useAnon} from "@/hooks/use-anon.tsx"; import {useAsyncAction} from "@/hooks/use-async-action"; import {useAuth} from "@/hooks/use-auth.tsx"; +import {usePlayerStore} from "@/store/player.ts"; export function SignupForm() { const location = useLocation() const navigate = useNavigate() const [isGuest, setIsGuest] = useState(location.state?.isGuest || false) const {t} = useTranslation() - const {signup, signupError} = useOry() + const {signup, upgradeGuest, signupError} = useOry() const {anonSignup, anonSignupError} = useAnon() const {isAuthenticated, initialized} = useAuth() + const isAnon = usePlayerStore(state => state.isAnon) + const guestName = usePlayerStore(state => state.name) + + // A logged in guest lands here to upgrade the existing profile instead of creating a new one. + const isUpgrade = isAuthenticated && isAnon const redirectTo = getValidRedirectPath(location.state?.from) useEffect(() => { - if (initialized && isAuthenticated) { + if (initialized && isAuthenticated && !isAnon) { navigate('/lobby', {replace: true}) } - }, [initialized, isAuthenticated, navigate]) - + }, [initialized, isAuthenticated, isAnon, navigate]) + const {execute: executeSignup, isLoading, hasError, reset} = useAsyncAction(async (data: {username: string, email?: string, password?: string}) => { + if (isUpgrade) { + return upgradeGuest({ + username: guestName!, + email: data.email!, + password: data.password! + }, redirectTo || '/lobby') + } if (isGuest) { return anonSignup(data.username, redirectTo) } @@ -66,7 +79,10 @@ export function SignupForm() { return ( - {t('auth.signup.title')} + {t(isUpgrade ? 'auth.upgrade.title' : 'auth.signup.title')} + {isUpgrade && ( +

{t('auth.upgrade.description')}

+ )}
@@ -88,8 +104,13 @@ export function SignupForm() { name="username" id="username" type="text" + defaultValue={isUpgrade ? guestName : undefined} + readOnly={isUpgrade} required /> + {isUpgrade && ( +

{t('auth.upgrade.usernameHint')}

+ )} {(signupError?.field == "traits.name" || anonSignupError) && (

{signupError?.text || anonSignupError?.text} @@ -97,25 +118,27 @@ export function SignupForm() { )} -

- - - - - - - - -

{t('auth.signup.guestProfileInfo')}

-
-
-
-
- {!isGuest && ( + {!isUpgrade && ( +
+ + + + + + + + +

{t('auth.signup.guestProfileInfo')}

+
+
+
+
+ )} + {(!isGuest || isUpgrade) && ( <>
@@ -158,15 +181,21 @@ export function SignupForm() { variant={hasError ? "destructive" : "default"} > {isLoading && } - {t('auth.signup.submit')} + {t(isUpgrade ? 'auth.upgrade.submit' : 'auth.signup.submit')} -
- {t("auth.login.prompt")}  - - {t("auth.login.link")} - -
+ {isUpgrade ? ( + + ) : ( +
+ {t("auth.login.prompt")}  + + {t("auth.login.link")} + +
+ )}
diff --git a/client/src/hooks/use-ory.tsx b/client/src/hooks/use-ory.tsx index 268c4b79..fa310880 100644 --- a/client/src/hooks/use-ory.tsx +++ b/client/src/hooks/use-ory.tsx @@ -2,6 +2,7 @@ import {useNavigate} from "react-router-dom" import {usePlayerStore} from "@/store/player.ts"; import {useAxiosErrorHandler} from "@/hooks/use-axios-error-handler.tsx"; import {ory} from "@/api/ory.ts"; +import {api} from "@/api/client.ts"; import {ErrorMessage, getOryErrorMessage} from "@/api/helpers.ts"; import {UiNode} from "@ory/client"; import {useState} from "react"; @@ -46,7 +47,7 @@ export const useOry = () => { logout() }) - const signup = (credentials: { email: string; password: string; username: string }, redirectTo?: string) => + const register = (credentials: { email: string; password: string; username: string }) => ory.createBrowserRegistrationFlow() .then(flow => ory.updateRegistrationFlow({ flow: flow.data.id, @@ -60,6 +61,9 @@ export const useOry = () => { csrf_token: getCsrfToken(flow.data) } })) + + const signup = (credentials: { email: string; password: string; username: string }, redirectTo?: string) => + register(credentials) .then(response => { const oryUuid = response.data.identity.id const username = response.data.identity.traits.name @@ -71,5 +75,43 @@ export const useOry = () => { handleError(error) }) - return {login, loginError, signup, signupError} + const currentIdentity = () => + ory.toSession().then(response => response.data.identity).catch(() => undefined) + + const oryLogout = () => + ory.createBrowserLogoutFlow().then(flow => ory.updateLogoutFlow({token: flow.data.logout_token})) + + /** + * Turns the current guest into a real account. Ory registration gives us the session cookie, the + * link call then moves the existing player row over to that identity so uuid, name and game + * history are kept. + * + * A guest can still be holding an ory session, since AuthMiddleware prefers the anon token over it. + * Kratos refuses to register while a session exists, so we either reuse that session when it is the + * account being created (an upgrade that registered but never linked) or drop it and start over. + */ + const upgradeGuest = async (credentials: { email: string; password: string; username: string }, redirectTo?: string) => { + try { + let identity = await currentIdentity() + + if (identity?.traits.email !== credentials.email) { + if (identity) await oryLogout() + identity = (await register(credentials)).data.identity + } + + await api.anonLink() + setOryPlayer(identity!.id, identity!.traits.name) + navigate(redirectTo || '/') + } catch (error) { + const response = (error as { response?: { data?: { ui?: unknown } } }).response + if (response?.data?.ui) { + setSignupError(getOryErrorMessage(response.data, t)) + } else { + setSignupError({id: 0, text: t("errors.upgradeFailed")}) + } + handleError(error) + } + } + + return {login, loginError, signup, upgradeGuest, signupError} } \ No newline at end of file diff --git a/client/src/i18n/de.json b/client/src/i18n/de.json index 772c2006..c6f625cf 100644 --- a/client/src/i18n/de.json +++ b/client/src/i18n/de.json @@ -43,6 +43,7 @@ "errors": { "title": "Fehler", "anonSignup": "Muss zwischen 3 und 15 Zeichen lang sein und darf nur Buchstaben und Zahlen enthalten", + "upgradeFailed": "Dein Konto konnte nicht umgewandelt werden. Bitte versuche es später erneut.", "ory": { "4000006": "E-Mail oder Passwort falsch", "4000007": "Bitte verwende eine andere E-Mail", @@ -161,6 +162,13 @@ "guestProfile": "Gast-Profil", "submit": "Los geht's", "guestProfileInfo": "Das Gast-Profil ist nur auf diesem Gerät verfügbar,\n kann jedoch zu jedem Zeitpunkt in einen richtigen \nAccount umgewandelt werden." + }, + "upgrade": { + "title": "Konto umwandeln", + "description": "Ergänze dein Gast-Profil mit E-Mail und Passwort. Name, Spiele und Statistiken bleiben erhalten.", + "usernameHint": "Dein Name bleibt bestehen.", + "submit": "Konto umwandeln", + "cancel": "Später" } }, "weisen": { diff --git a/client/src/i18n/en.json b/client/src/i18n/en.json index 4bfcfe56..be91431c 100644 --- a/client/src/i18n/en.json +++ b/client/src/i18n/en.json @@ -42,6 +42,7 @@ }, "errors": { "title": "Ooops", + "upgradeFailed": "Your account could not be upgraded. Please try again later.", "ory": { "4000006": "E-Mail or password incorrect", "4000007": "Please use a different E-Mail", @@ -161,6 +162,13 @@ "guestProfile": "Guest-Profile", "submit": "Let's play", "guestProfileInfo": "The Guest-Profile ist only on this device available.\n Can be converted to a real account at any time." + }, + "upgrade": { + "title": "Upgrade account", + "description": "Add an email and a password to your guest profile. Your name, games and stats stay the same.", + "usernameHint": "Your name stays the same.", + "submit": "Upgrade account", + "cancel": "Not now" } }, "weisen": { diff --git a/http/http-client.env.json b/http/http-client.env.json index 2434705b..97e51333 100644 --- a/http/http-client.env.json +++ b/http/http-client.env.json @@ -1,6 +1,7 @@ { "local": { - "oryBaseUrl": "127.0.0.1:4434", + "oryBaseUrl": "127.0.0.1:4433", + "oryAdminUrl": "127.0.0.1:4434", "yassBaseUrl": "127.0.0.1:8080" } -} \ No newline at end of file +} diff --git a/http/ory/auth.http b/http/ory/auth.http index 3281eaec..41a21f1c 100644 --- a/http/ory/auth.http +++ b/http/ory/auth.http @@ -1,27 +1,37 @@ // @name Get new login flow -GET /self-service/login/api?refresh=&aal= HTTP/1.1 +// Browser flow (not /api) because the yass server authenticates via the ory_kratos_session +// cookie, see AuthMiddleware. The api flow only hands out a session token. +GET /self-service/login/browser HTTP/1.1 Host: {{oryBaseUrl}} Accept: application/json > {% client.global.set("oryAuthFlow", response.body.id); + client.global.set( + "oryCsrfToken", + response.body.ui.nodes.find(node => node.attributes.name === "csrf_token").attributes.value + ); %} ### // @name Login -POST /self-service/login?flow={{oryAuthFlow}} +POST /self-service/login?flow={{oryAuthFlow}} HTTP/1.1 Host: {{oryBaseUrl}} Accept: application/json -Content-Type: application/x-www-form-urlencoded +Content-Type: application/json -method = password & -password = FooBar318318 & -identifier = hello%40sidney.dev +{ + "method": "password", + "identifier": "hello@sidney.dev", + "password": "FooBar318318", + "csrf_token": "{{oryCsrfToken}}" +} > {% - client.global.set("orySessionToken", response.body.session_token); -%} - - + const session = response.headers.valuesOf("Set-Cookie") + .map(value => value.toString()) + .find(value => value.startsWith("ory_kratos_session=")); + client.global.set("orySessionCookie", session.split(";")[0].split("=")[1]); +%} diff --git a/http/yass/admin.http b/http/yass/admin.http index c3cb6dea..881ac80a 100644 --- a/http/yass/admin.http +++ b/http/yass/admin.http @@ -5,21 +5,21 @@ Host: {{yassBaseUrl}} // @name Generate Hand GET /admin/generate/hand HTTP/1.1 Host: {{yassBaseUrl}} -X-Session-Token: {{orySessionToken}} +Cookie: ory_kratos_session={{orySessionCookie}} ### // @name Generate Bot Game GET /admin/generate/bot/game HTTP/1.1 Host: {{yassBaseUrl}} -X-Session-Token: {{orySessionToken}} +Cookie: ory_kratos_session={{orySessionCookie}} ### // @name Generate State Game GET /admin/generate/state/game HTTP/1.1 Host: {{yassBaseUrl}} -X-Session-Token: {{orySessionToken}} +Cookie: ory_kratos_session={{orySessionCookie}} ### @@ -29,4 +29,4 @@ X-Session-Token: {{orySessionToken}} %} GET /admin/analyze/game/{{gameCode}} HTTP/1.1 Host: {{yassBaseUrl}} -X-Session-Token: {{orySessionToken}} \ No newline at end of file +Cookie: ory_kratos_session={{orySessionCookie}} \ No newline at end of file diff --git a/http/yass/auth.http b/http/yass/auth.http index 7432c1c1..7b21301b 100644 --- a/http/yass/auth.http +++ b/http/yass/auth.http @@ -1,4 +1,4 @@ // @name Whoami GET /auth/whoami HTTP/1.1 Host: {{yassBaseUrl}} -X-Session-Token: {{orySessionToken}} \ No newline at end of file +Cookie: ory_kratos_session={{orySessionCookie}} \ No newline at end of file diff --git a/http/yass/game.http b/http/yass/game.http index 8bbe63de..9b88e2ba 100644 --- a/http/yass/game.http +++ b/http/yass/game.http @@ -1,7 +1,7 @@ // @name Play Card POST /game/play HTTP/1.1 Host: {{yassBaseUrl}} -X-Session-Token: {{orySessionToken}} +Cookie: ory_kratos_session={{orySessionCookie}} Content-Type: application/json { diff --git a/server/main/kotlin/ch/yass/identity/AuthController.kt b/server/main/kotlin/ch/yass/identity/AuthController.kt index f9944789..d19f3c51 100644 --- a/server/main/kotlin/ch/yass/identity/AuthController.kt +++ b/server/main/kotlin/ch/yass/identity/AuthController.kt @@ -54,13 +54,17 @@ class AuthController( */ private fun anonLink(ctx: Context) { either { - val request = validate(ctx.body()) + val sessionCookie = ctx.cookieMap()["ory_kratos_session"] ?: "" val player = player(ctx) - ensureNotNull(player.anonToken) { CanNotLinkAnonAccount(player, request.orySession) } + ensureNotNull(player.anonToken) { CanNotLinkAnonAccount(player, sessionCookie) } - val orySession = getSession(player, request.orySession) - playerService.linkAnonAccount(player, orySession.identity!!.id.toUUID(), request.orySession) + val orySession = getSession(player, sessionCookie) + playerService.linkAnonAccount(player, orySession.identity!!.id.toUUID(), sessionCookie) + + // The anon token is gone from the DB now, so the client has to stop sending it. Otherwise + // AuthMiddleware would keep preferring it over the ory session and reject every request. + ctx.cookie(anonTokenCookie("", 0)) object { val success = true @@ -77,16 +81,7 @@ class AuthController( val token = createToken() val player = playerService.create(NewAnonPlayer(request.name, hashToken(token))) - ctx.cookie( - Cookie("anon_token", token).apply { - isHttpOnly = true - path = "/" - sameSite = SameSite.NONE - secure = true - domain = config.getString("server.cookieDomain") - maxAge = 60 * 60 * 24 * 365 // 1 year - } - ) + ctx.cookie(anonTokenCookie(token, 60 * 60 * 24 * 365)) // 1 year logger().info("trigger_alert: New guest user signed up ${player.name} (${player.uuid})") AnonSignupResponse(player.uuid, player.name) }.fold( @@ -97,21 +92,22 @@ class AuthController( private fun anonLogout(ctx: Context) { playerService.resetAnonToken(player(ctx)) - ctx.cookie( - Cookie("anon_token", "").apply { - isHttpOnly = true - path = "/" - sameSite = SameSite.NONE - secure = true - domain = config.getString("server.cookieDomain") - maxAge = 0 // Delete cookie by setting maxAge to 0 - } - ) + ctx.cookie(anonTokenCookie("", 0)) // Delete cookie by setting maxAge to 0 successResponse(ctx, object { val result = object {} }) } + private fun anonTokenCookie(token: String, maxAge: Int) = + Cookie("anon_token", token).apply { + isHttpOnly = true + path = "/" + sameSite = SameSite.NONE + secure = true + domain = config.getString("server.cookieDomain") + this.maxAge = maxAge + } + private fun whoami(ctx: Context) { successResponse(ctx, WhoAmIResponse.from(player(ctx))) } @@ -154,9 +150,9 @@ class AuthController( } context(r: Raise) - private fun getSession(player: InternalPlayer, orySession: String): Session = + private fun getSession(player: InternalPlayer, sessionCookie: String): Session = try { - oryClient.frontend.toSession(orySession, null, null) - } catch (_: ApiException) { r.raise(CanNotLinkAnonAccount(player, orySession)) } + oryClient.frontend.toSession(null, "ory_kratos_session=$sessionCookie", null) + } catch (_: ApiException) { r.raise(CanNotLinkAnonAccount(player, sessionCookie)) } } \ No newline at end of file diff --git a/server/main/kotlin/ch/yass/identity/api/AnonLinkRequest.kt b/server/main/kotlin/ch/yass/identity/api/AnonLinkRequest.kt deleted file mode 100644 index 3f2a4f25..00000000 --- a/server/main/kotlin/ch/yass/identity/api/AnonLinkRequest.kt +++ /dev/null @@ -1,3 +0,0 @@ -package ch.yass.identity.api - -data class AnonLinkRequest(val orySession: String) \ No newline at end of file diff --git a/server/main/resources/openapi-spec.yaml b/server/main/resources/openapi-spec.yaml index 0752c560..215dd095 100644 --- a/server/main/resources/openapi-spec.yaml +++ b/server/main/resources/openapi-spec.yaml @@ -273,17 +273,14 @@ paths: /auth/anon/link: post: summary: Link anonymous account to Ory account + description: > + Links the anon player of the anon_token cookie to the Ory identity of the ory_kratos_session + cookie. Both cookies have to be present, the anon_token cookie gets cleared on success. operationId: anonLink tags: - Auth security: - anon_token: [ ] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/AnonLinkRequest' responses: '200': description: Successfully linked accounts @@ -813,15 +810,6 @@ components: - name - uuid - AnonLinkRequest: - type: object - properties: - orySession: - type: string - description: Ory session token to link with anonymous account - required: - - orySession - MessageRequest: type: object required: From 9ecdad304e8f0b924cb7d7044e4243fece7ff132 Mon Sep 17 00:00:00 2001 From: Sidney Date: Wed, 29 Jul 2026 06:04:11 +0200 Subject: [PATCH 2/4] Fix review findings of the guest account upgrade - Restrict guest names to ascii so they stay valid for the ory identity schema, and let the upgrade form edit the prefilled name - Keep the ory session cookie out of the logs, CanNotLinkAnonAccount now only carries the player uuid and answers 400 instead of 500 - Answer 409 when the ory identity already belongs to another player and tell the user to log in instead - Require the expected origin on anon/link, both credentials are cookies - Reset isAnon on logout --- client/src/components/signup-form.tsx | 3 +-- client/src/hooks/use-ory.tsx | 13 ++++++++++--- client/src/i18n/de.json | 5 +++-- client/src/i18n/en.json | 4 +++- client/src/store/player.ts | 1 + .../kotlin/ch/yass/core/error/DomainError.kt | 6 +++++- .../kotlin/ch/yass/core/helper/response.kt | 6 ++++++ .../kotlin/ch/yass/core/valiktor/Username.kt | 6 +++++- .../main/kotlin/ch/yass/game/PlayerService.kt | 8 ++++---- .../kotlin/ch/yass/identity/AuthController.kt | 19 +++++++++++++++---- 10 files changed, 53 insertions(+), 18 deletions(-) diff --git a/client/src/components/signup-form.tsx b/client/src/components/signup-form.tsx index eaafc57c..a3b568bb 100644 --- a/client/src/components/signup-form.tsx +++ b/client/src/components/signup-form.tsx @@ -42,7 +42,7 @@ export function SignupForm() { const {execute: executeSignup, isLoading, hasError, reset} = useAsyncAction(async (data: {username: string, email?: string, password?: string}) => { if (isUpgrade) { return upgradeGuest({ - username: guestName!, + username: data.username, email: data.email!, password: data.password! }, redirectTo || '/lobby') @@ -105,7 +105,6 @@ export function SignupForm() { id="username" type="text" defaultValue={isUpgrade ? guestName : undefined} - readOnly={isUpgrade} required /> {isUpgrade && ( diff --git a/client/src/hooks/use-ory.tsx b/client/src/hooks/use-ory.tsx index fa310880..a0bf520f 100644 --- a/client/src/hooks/use-ory.tsx +++ b/client/src/hooks/use-ory.tsx @@ -4,7 +4,7 @@ import {useAxiosErrorHandler} from "@/hooks/use-axios-error-handler.tsx"; import {ory} from "@/api/ory.ts"; import {api} from "@/api/client.ts"; import {ErrorMessage, getOryErrorMessage} from "@/api/helpers.ts"; -import {UiNode} from "@ory/client"; +import {UiNode, UiText} from "@ory/client"; import {useState} from "react"; import {useTranslation} from "react-i18next"; @@ -103,7 +103,14 @@ export const useOry = () => { setOryPlayer(identity!.id, identity!.traits.name) navigate(redirectTo || '/') } catch (error) { - const response = (error as { response?: { data?: { ui?: unknown } } }).response + const response = (error as { response?: { status?: number, data?: { ui?: { messages?: UiText[], nodes?: UiNode[] } } } }).response + + // The server answers 409 when the ory identity already belongs to another player + if (response?.status === 409) { + setSignupError({id: 0, text: t("errors.upgradeExists")}) + return + } + if (response?.data?.ui) { setSignupError(getOryErrorMessage(response.data, t)) } else { @@ -114,4 +121,4 @@ export const useOry = () => { } return {login, loginError, signup, upgradeGuest, signupError} -} \ No newline at end of file +} diff --git a/client/src/i18n/de.json b/client/src/i18n/de.json index c6f625cf..8dae554b 100644 --- a/client/src/i18n/de.json +++ b/client/src/i18n/de.json @@ -42,8 +42,9 @@ }, "errors": { "title": "Fehler", - "anonSignup": "Muss zwischen 3 und 15 Zeichen lang sein und darf nur Buchstaben und Zahlen enthalten", + "anonSignup": "Muss zwischen 3 und 15 Zeichen lang sein und darf nur Buchstaben (a-z) und Zahlen enthalten", "upgradeFailed": "Dein Konto konnte nicht umgewandelt werden. Bitte versuche es später erneut.", + "upgradeExists": "Für diese E-Mail gibt es bereits ein Konto. Bitte melde dich stattdessen an.", "ory": { "4000006": "E-Mail oder Passwort falsch", "4000007": "Bitte verwende eine andere E-Mail", @@ -166,7 +167,7 @@ "upgrade": { "title": "Konto umwandeln", "description": "Ergänze dein Gast-Profil mit E-Mail und Passwort. Name, Spiele und Statistiken bleiben erhalten.", - "usernameHint": "Dein Name bleibt bestehen.", + "usernameHint": "Dein Name wird übernommen, du kannst ihn hier aber noch anpassen.", "submit": "Konto umwandeln", "cancel": "Später" } diff --git a/client/src/i18n/en.json b/client/src/i18n/en.json index be91431c..43ad55cd 100644 --- a/client/src/i18n/en.json +++ b/client/src/i18n/en.json @@ -42,7 +42,9 @@ }, "errors": { "title": "Ooops", + "anonSignup": "Must be between 3 and 15 characters long and may only contain letters (a-z) and numbers", "upgradeFailed": "Your account could not be upgraded. Please try again later.", + "upgradeExists": "An account with this email already exists. Please log in instead.", "ory": { "4000006": "E-Mail or password incorrect", "4000007": "Please use a different E-Mail", @@ -166,7 +168,7 @@ "upgrade": { "title": "Upgrade account", "description": "Add an email and a password to your guest profile. Your name, games and stats stay the same.", - "usernameHint": "Your name stays the same.", + "usernameHint": "Your name carries over, but you can still adjust it here.", "submit": "Upgrade account", "cancel": "Not now" } diff --git a/client/src/store/player.ts b/client/src/store/player.ts index e5b904dc..e33f4f90 100644 --- a/client/src/store/player.ts +++ b/client/src/store/player.ts @@ -29,5 +29,6 @@ export const usePlayerStore = create((set) => ({ uuid: undefined, name: undefined, isAuthenticated: false, + isAnon: false, }) })) \ No newline at end of file diff --git a/server/main/kotlin/ch/yass/core/error/DomainError.kt b/server/main/kotlin/ch/yass/core/error/DomainError.kt index 00341fc1..f8cab4c9 100644 --- a/server/main/kotlin/ch/yass/core/error/DomainError.kt +++ b/server/main/kotlin/ch/yass/core/error/DomainError.kt @@ -27,7 +27,11 @@ data class OryIdentityWithoutName(val identity: Identity) : AuthError data class UnauthorizedSubscription(val error: DomainError) : AuthError data class InvalidAnonToken(val token: String) : AuthError data class CanNotImpersonate(val player: InternalPlayer, val impersonateUuid: UUID) : AuthError -data class CanNotLinkAnonAccount(val player: InternalPlayer, val orySession: String) : AuthError + +// Carry only the player uuid, these errors end up in the logs and InternalPlayer holds the anon token +data class CanNotLinkAnonAccount(val playerUuid: UUID) : AuthError +data class OryAccountAlreadyLinked(val playerUuid: UUID, val oryUuid: UUID) : AuthError +data class UnexpectedOrigin(val origin: String) : AuthError // Game or Game-State related Errors sealed interface GameError : DomainError diff --git a/server/main/kotlin/ch/yass/core/helper/response.kt b/server/main/kotlin/ch/yass/core/helper/response.kt index b395080f..53314db8 100644 --- a/server/main/kotlin/ch/yass/core/helper/response.kt +++ b/server/main/kotlin/ch/yass/core/helper/response.kt @@ -42,6 +42,12 @@ fun errorResponse(ctx: Context, error: DomainError): Context { val error = CentrifugoErrorResponse(403, "denied") }) + is CanNotLinkAnonAccount -> ctx.status(400).json(ErrorResponse("can not link anon account")) + + is OryAccountAlreadyLinked -> ctx.status(409).json(ErrorResponse("ory account is already linked to a player")) + + is UnexpectedOrigin -> ctx.status(403).json(ErrorResponse("unexpected origin")) + is GameWithCodeNotFound -> ctx.status(404).json(ErrorResponse("no game with code ${error.code} found")) else -> { logger().error("DomainError `${error.javaClass.name}` encountered: $error") diff --git a/server/main/kotlin/ch/yass/core/valiktor/Username.kt b/server/main/kotlin/ch/yass/core/valiktor/Username.kt index cf396e06..0210e85c 100644 --- a/server/main/kotlin/ch/yass/core/valiktor/Username.kt +++ b/server/main/kotlin/ch/yass/core/valiktor/Username.kt @@ -5,6 +5,10 @@ import org.valiktor.Validator object Username : Constraint +/** + * Ascii only, a guest name has to stay valid for the ory identity schema (`^[a-zA-Z0-9_-]+$`) + * when the guest upgrades to a real account. + */ fun Validator.Property.isUsername(): Validator.Property = this.validate(Username) { - it == null || it.all { char -> char.isLetterOrDigit() } + it == null || it.all { char -> char in 'a'..'z' || char in 'A'..'Z' || char in '0'..'9' } } \ No newline at end of file diff --git a/server/main/kotlin/ch/yass/game/PlayerService.kt b/server/main/kotlin/ch/yass/game/PlayerService.kt index ca81bb1e..e2f70fc8 100644 --- a/server/main/kotlin/ch/yass/game/PlayerService.kt +++ b/server/main/kotlin/ch/yass/game/PlayerService.kt @@ -2,7 +2,7 @@ package ch.yass.game import arrow.core.raise.Raise import arrow.core.raise.ensure -import ch.yass.core.error.CanNotLinkAnonAccount +import ch.yass.core.error.OryAccountAlreadyLinked import ch.yass.core.error.OryIdentityWithoutName import ch.yass.core.error.StringNoValidUUID import ch.yass.core.helper.hashToken @@ -90,11 +90,11 @@ class PlayerService(private val db: DSLContext) { ) } - context(r: Raise) - fun linkAnonAccount(player: InternalPlayer, oryUuid: UUID, orySession: String): InternalPlayer { + context(r: Raise) + fun linkAnonAccount(player: InternalPlayer, oryUuid: UUID): InternalPlayer { // Just make sure no funky stuff is going on and we don't have an existing player with this ory uuid already set val existingOryUuids = db.fetchCount(PLAYER, PLAYER.ORY_UUID.eq(oryUuid.toString())) - r.ensure(existingOryUuids == 0) { CanNotLinkAnonAccount(player, orySession) } + r.ensure(existingOryUuids == 0) { OryAccountAlreadyLinked(player.uuid, oryUuid) } return db.update(PLAYER) .setNull(PLAYER.ANON_TOKEN) diff --git a/server/main/kotlin/ch/yass/identity/AuthController.kt b/server/main/kotlin/ch/yass/identity/AuthController.kt index d19f3c51..2ea832c6 100644 --- a/server/main/kotlin/ch/yass/identity/AuthController.kt +++ b/server/main/kotlin/ch/yass/identity/AuthController.kt @@ -54,13 +54,18 @@ class AuthController( */ private fun anonLink(ctx: Context) { either { - val sessionCookie = ctx.cookieMap()["ory_kratos_session"] ?: "" val player = player(ctx) - ensureNotNull(player.anonToken) { CanNotLinkAnonAccount(player, sessionCookie) } + // Both credentials are ambient cookies, so this POST is only safe from our own client + ensureExpectedOrigin(ctx) + + ensureNotNull(player.anonToken) { CanNotLinkAnonAccount(player.uuid) } + val sessionCookie = ensureNotNull(ctx.cookieMap()["ory_kratos_session"]) { + CanNotLinkAnonAccount(player.uuid) + } val orySession = getSession(player, sessionCookie) - playerService.linkAnonAccount(player, orySession.identity!!.id.toUUID(), sessionCookie) + playerService.linkAnonAccount(player, orySession.identity!!.id.toUUID()) // The anon token is gone from the DB now, so the client has to stop sending it. Otherwise // AuthMiddleware would keep preferring it over the ory session and reject every request. @@ -153,6 +158,12 @@ class AuthController( private fun getSession(player: InternalPlayer, sessionCookie: String): Session = try { oryClient.frontend.toSession(null, "ory_kratos_session=$sessionCookie", null) - } catch (_: ApiException) { r.raise(CanNotLinkAnonAccount(player, sessionCookie)) } + } catch (_: ApiException) { r.raise(CanNotLinkAnonAccount(player.uuid)) } + + context(r: Raise) + private fun ensureExpectedOrigin(ctx: Context) { + val origin = ctx.header("Origin") ?: return // Non-browser clients don't send one + r.ensure(origin == config.getString("server.cors")) { UnexpectedOrigin(origin) } + } } \ No newline at end of file From 9f59dad729b2dbfcd4a940cdfd8232f962f39a08 Mon Sep 17 00:00:00 2001 From: Sidney Date: Thu, 30 Jul 2026 18:16:15 +0200 Subject: [PATCH 3/4] Address review feedback on the anon account upgrade - Drop the origin check on /auth/anon/link, this gets solved globally later - Allow german and french accents in usernames, in our validator and the kratos identity schema (both have to agree or an anon name blocks the upgrade) - Rename guest to anon everywhere except in text exposed to the user --- .../email-password/identity.schema.json | 2 +- client/src/components/landing/header.tsx | 4 +-- client/src/components/login-form.tsx | 4 +-- client/src/components/signup-form.tsx | 26 +++++++++---------- client/src/hooks/use-ory.tsx | 11 ++++---- client/src/i18n/de.json | 10 +++---- client/src/i18n/en.json | 10 +++---- .../kotlin/ch/yass/core/error/DomainError.kt | 1 - .../kotlin/ch/yass/core/helper/response.kt | 2 -- .../kotlin/ch/yass/core/valiktor/Username.kt | 11 +++++--- .../main/kotlin/ch/yass/game/PlayerService.kt | 2 +- .../kotlin/ch/yass/identity/AuthController.kt | 12 +-------- 12 files changed, 43 insertions(+), 52 deletions(-) diff --git a/.docker/kratos/email-password/identity.schema.json b/.docker/kratos/email-password/identity.schema.json index 3342922d..02ea2773 100644 --- a/.docker/kratos/email-password/identity.schema.json +++ b/.docker/kratos/email-password/identity.schema.json @@ -31,7 +31,7 @@ "title": "Player name", "minLength": 3, "maxLength": 15, - "pattern": "^[a-zA-Z0-9_-]+$" + "pattern": "^[a-zA-Z0-9À-ÖØ-öø-ÿŒœ_-]+$" } }, "required": [ diff --git a/client/src/components/landing/header.tsx b/client/src/components/landing/header.tsx index d4d46b58..67753190 100644 --- a/client/src/components/landing/header.tsx +++ b/client/src/components/landing/header.tsx @@ -39,10 +39,10 @@ export function Header() { )} diff --git a/client/src/components/login-form.tsx b/client/src/components/login-form.tsx index 991e592f..3c1f9b14 100644 --- a/client/src/components/login-form.tsx +++ b/client/src/components/login-form.tsx @@ -67,7 +67,7 @@ export function LoginForm() { type="button" variant="outline" className="w-full" - onClick={() => navigate('/signup', {state: {isGuest: true, from: location.state?.from}})} + onClick={() => navigate('/signup', {state: {isAnonSignup: true, from: location.state?.from}})} > - {t("auth.login.guestPlay")} + {t("auth.login.anonPlay")}
state.isAnon) - const guestName = usePlayerStore(state => state.name) + const anonName = usePlayerStore(state => state.name) - // A logged in guest lands here to upgrade the existing profile instead of creating a new one. + // A logged in anon player lands here to upgrade the existing profile instead of creating a new one. const isUpgrade = isAuthenticated && isAnon const redirectTo = getValidRedirectPath(location.state?.from) @@ -41,13 +41,13 @@ export function SignupForm() { const {execute: executeSignup, isLoading, hasError, reset} = useAsyncAction(async (data: {username: string, email?: string, password?: string}) => { if (isUpgrade) { - return upgradeGuest({ + return upgradeAnon({ username: data.username, email: data.email!, password: data.password! }, redirectTo || '/lobby') } - if (isGuest) { + if (isAnonSignup) { return anonSignup(data.username, redirectTo) } return signup({ @@ -104,7 +104,7 @@ export function SignupForm() { name="username" id="username" type="text" - defaultValue={isUpgrade ? guestName : undefined} + defaultValue={isUpgrade ? anonName : undefined} required /> {isUpgrade && ( @@ -120,24 +120,24 @@ export function SignupForm() { {!isUpgrade && (
- + -

{t('auth.signup.guestProfileInfo')}

+

{t('auth.signup.anonProfileInfo')}

)} - {(!isGuest || isUpgrade) && ( + {(!isAnonSignup || isUpgrade) && ( <>
diff --git a/client/src/hooks/use-ory.tsx b/client/src/hooks/use-ory.tsx index a0bf520f..0a5923de 100644 --- a/client/src/hooks/use-ory.tsx +++ b/client/src/hooks/use-ory.tsx @@ -82,15 +82,15 @@ export const useOry = () => { ory.createBrowserLogoutFlow().then(flow => ory.updateLogoutFlow({token: flow.data.logout_token})) /** - * Turns the current guest into a real account. Ory registration gives us the session cookie, the + * Turns the current anon player into a real account. Ory registration gives us the session cookie, the * link call then moves the existing player row over to that identity so uuid, name and game * history are kept. * - * A guest can still be holding an ory session, since AuthMiddleware prefers the anon token over it. + * An anon player can still be holding an ory session, since AuthMiddleware prefers the anon token over it. * Kratos refuses to register while a session exists, so we either reuse that session when it is the * account being created (an upgrade that registered but never linked) or drop it and start over. */ - const upgradeGuest = async (credentials: { email: string; password: string; username: string }, redirectTo?: string) => { + const upgradeAnon = async (credentials: { email: string; password: string; username: string }, redirectTo?: string) => { try { let identity = await currentIdentity() @@ -105,7 +105,8 @@ export const useOry = () => { } catch (error) { const response = (error as { response?: { status?: number, data?: { ui?: { messages?: UiText[], nodes?: UiNode[] } } } }).response - // The server answers 409 when the ory identity already belongs to another player + // The server answers 409 when the ory identity of the reused session already belongs to another + // player row. A fresh registration can't hit this, ory rejects a duplicate email before we link. if (response?.status === 409) { setSignupError({id: 0, text: t("errors.upgradeExists")}) return @@ -120,5 +121,5 @@ export const useOry = () => { } } - return {login, loginError, signup, upgradeGuest, signupError} + return {login, loginError, signup, upgradeAnon, signupError} } diff --git a/client/src/i18n/de.json b/client/src/i18n/de.json index 8dae554b..55ee7c4e 100644 --- a/client/src/i18n/de.json +++ b/client/src/i18n/de.json @@ -42,7 +42,7 @@ }, "errors": { "title": "Fehler", - "anonSignup": "Muss zwischen 3 und 15 Zeichen lang sein und darf nur Buchstaben (a-z) und Zahlen enthalten", + "anonSignup": "Muss zwischen 3 und 15 Zeichen lang sein und darf nur Buchstaben, Zahlen sowie «-» und «_» enthalten", "upgradeFailed": "Dein Konto konnte nicht umgewandelt werden. Bitte versuche es später erneut.", "upgradeExists": "Für diese E-Mail gibt es bereits ein Konto. Bitte melde dich stattdessen an.", "ory": { @@ -151,7 +151,7 @@ "username": "Nickname" }, "login": { - "guestPlay": "Als Gast spielen", + "anonPlay": "Als Gast spielen", "title": "Hallo!", "prompt": "Bereits registriert?", "link": "Login" @@ -160,9 +160,9 @@ "title": "Registrieren", "prompt": "Noch kein Konto?", "link": "Registrieren", - "guestProfile": "Gast-Profil", + "anonProfile": "Gast-Profil", "submit": "Los geht's", - "guestProfileInfo": "Das Gast-Profil ist nur auf diesem Gerät verfügbar,\n kann jedoch zu jedem Zeitpunkt in einen richtigen \nAccount umgewandelt werden." + "anonProfileInfo": "Das Gast-Profil ist nur auf diesem Gerät verfügbar,\n kann jedoch zu jedem Zeitpunkt in einen richtigen \nAccount umgewandelt werden." }, "upgrade": { "title": "Konto umwandeln", @@ -254,7 +254,7 @@ "title": "Unser liebstes Nationalspiel — modern umgesetzt", "description": "Keine Installation, keine Werbung und gratis. Den Foiver unds s'Weggli halt.", "signUp": "Registrieren", - "playAsGuest": "als Gast spielen" + "playAsAnon": "als Gast spielen" }, "features": { "title": "Yass Features", diff --git a/client/src/i18n/en.json b/client/src/i18n/en.json index 43ad55cd..002c6a1c 100644 --- a/client/src/i18n/en.json +++ b/client/src/i18n/en.json @@ -42,7 +42,7 @@ }, "errors": { "title": "Ooops", - "anonSignup": "Must be between 3 and 15 characters long and may only contain letters (a-z) and numbers", + "anonSignup": "Must be between 3 and 15 characters long and may only contain letters, numbers and «-» or «_»", "upgradeFailed": "Your account could not be upgraded. Please try again later.", "upgradeExists": "An account with this email already exists. Please log in instead.", "ory": { @@ -153,7 +153,7 @@ }, "login": { "title": "Welcome back", - "guestPlay": "Play as guest", + "anonPlay": "Play as guest", "prompt": "Already signed up?", "link": "Login" }, @@ -161,9 +161,9 @@ "prompt": "Don't have an account?", "link": "Sign up", "title": "Sign-up", - "guestProfile": "Guest-Profile", + "anonProfile": "Guest-Profile", "submit": "Let's play", - "guestProfileInfo": "The Guest-Profile ist only on this device available.\n Can be converted to a real account at any time." + "anonProfileInfo": "The Guest-Profile ist only on this device available.\n Can be converted to a real account at any time." }, "upgrade": { "title": "Upgrade account", @@ -255,7 +255,7 @@ "title": "Our beloved national cardgame — modernized", "description": "No installation, no ads and free. Or as we say in swissgerman: De Foiver und s'Weggli", "signUp": "Sign Up", - "playAsGuest": "play as guest" + "playAsAnon": "play as guest" }, "features": { "title": "Yass Features", diff --git a/server/main/kotlin/ch/yass/core/error/DomainError.kt b/server/main/kotlin/ch/yass/core/error/DomainError.kt index f8cab4c9..9b7083e4 100644 --- a/server/main/kotlin/ch/yass/core/error/DomainError.kt +++ b/server/main/kotlin/ch/yass/core/error/DomainError.kt @@ -31,7 +31,6 @@ data class CanNotImpersonate(val player: InternalPlayer, val impersonateUuid: UU // Carry only the player uuid, these errors end up in the logs and InternalPlayer holds the anon token data class CanNotLinkAnonAccount(val playerUuid: UUID) : AuthError data class OryAccountAlreadyLinked(val playerUuid: UUID, val oryUuid: UUID) : AuthError -data class UnexpectedOrigin(val origin: String) : AuthError // Game or Game-State related Errors sealed interface GameError : DomainError diff --git a/server/main/kotlin/ch/yass/core/helper/response.kt b/server/main/kotlin/ch/yass/core/helper/response.kt index 53314db8..587fa6b4 100644 --- a/server/main/kotlin/ch/yass/core/helper/response.kt +++ b/server/main/kotlin/ch/yass/core/helper/response.kt @@ -46,8 +46,6 @@ fun errorResponse(ctx: Context, error: DomainError): Context { is OryAccountAlreadyLinked -> ctx.status(409).json(ErrorResponse("ory account is already linked to a player")) - is UnexpectedOrigin -> ctx.status(403).json(ErrorResponse("unexpected origin")) - is GameWithCodeNotFound -> ctx.status(404).json(ErrorResponse("no game with code ${error.code} found")) else -> { logger().error("DomainError `${error.javaClass.name}` encountered: $error") diff --git a/server/main/kotlin/ch/yass/core/valiktor/Username.kt b/server/main/kotlin/ch/yass/core/valiktor/Username.kt index 0210e85c..92d9f5d3 100644 --- a/server/main/kotlin/ch/yass/core/valiktor/Username.kt +++ b/server/main/kotlin/ch/yass/core/valiktor/Username.kt @@ -6,9 +6,12 @@ import org.valiktor.Validator object Username : Constraint /** - * Ascii only, a guest name has to stay valid for the ory identity schema (`^[a-zA-Z0-9_-]+$`) - * when the guest upgrades to a real account. + * Latin letters incl. the usual german and french accents, digits, `_` and `-`. Everything else + * (emojis, symbols, whitespace) is out, an anon name has to stay valid for the ory identity schema + * (`.docker/kratos/email-password/identity.schema.json`) when the anon upgrades to a real account. */ +private val usernamePattern = Regex("^[a-zA-Z0-9À-ÖØ-öø-ÿŒœ_-]+$") + fun Validator.Property.isUsername(): Validator.Property = this.validate(Username) { - it == null || it.all { char -> char in 'a'..'z' || char in 'A'..'Z' || char in '0'..'9' } -} \ No newline at end of file + it == null || usernamePattern.matches(it) +} diff --git a/server/main/kotlin/ch/yass/game/PlayerService.kt b/server/main/kotlin/ch/yass/game/PlayerService.kt index e2f70fc8..16009370 100644 --- a/server/main/kotlin/ch/yass/game/PlayerService.kt +++ b/server/main/kotlin/ch/yass/game/PlayerService.kt @@ -92,7 +92,7 @@ class PlayerService(private val db: DSLContext) { context(r: Raise) fun linkAnonAccount(player: InternalPlayer, oryUuid: UUID): InternalPlayer { - // Just make sure no funky stuff is going on and we don't have an existing player with this ory uuid already set + // Make sure no funky stuff is going on and no other player of ours claimed this ory uuid already val existingOryUuids = db.fetchCount(PLAYER, PLAYER.ORY_UUID.eq(oryUuid.toString())) r.ensure(existingOryUuids == 0) { OryAccountAlreadyLinked(player.uuid, oryUuid) } diff --git a/server/main/kotlin/ch/yass/identity/AuthController.kt b/server/main/kotlin/ch/yass/identity/AuthController.kt index 2ea832c6..9b618cce 100644 --- a/server/main/kotlin/ch/yass/identity/AuthController.kt +++ b/server/main/kotlin/ch/yass/identity/AuthController.kt @@ -56,9 +56,6 @@ class AuthController( either { val player = player(ctx) - // Both credentials are ambient cookies, so this POST is only safe from our own client - ensureExpectedOrigin(ctx) - ensureNotNull(player.anonToken) { CanNotLinkAnonAccount(player.uuid) } val sessionCookie = ensureNotNull(ctx.cookieMap()["ory_kratos_session"]) { CanNotLinkAnonAccount(player.uuid) @@ -87,7 +84,7 @@ class AuthController( val player = playerService.create(NewAnonPlayer(request.name, hashToken(token))) ctx.cookie(anonTokenCookie(token, 60 * 60 * 24 * 365)) // 1 year - logger().info("trigger_alert: New guest user signed up ${player.name} (${player.uuid})") + logger().info("trigger_alert: New anon user signed up ${player.name} (${player.uuid})") AnonSignupResponse(player.uuid, player.name) }.fold( { errorResponse(ctx, it) }, @@ -159,11 +156,4 @@ class AuthController( try { oryClient.frontend.toSession(null, "ory_kratos_session=$sessionCookie", null) } catch (_: ApiException) { r.raise(CanNotLinkAnonAccount(player.uuid)) } - - context(r: Raise) - private fun ensureExpectedOrigin(ctx: Context) { - val origin = ctx.header("Origin") ?: return // Non-browser clients don't send one - r.ensure(origin == config.getString("server.cors")) { UnexpectedOrigin(origin) } - } - } \ No newline at end of file From d1c2f622b7b6c64555657579d251a20fbd01427a Mon Sep 17 00:00:00 2001 From: Sidney Date: Thu, 30 Jul 2026 18:19:17 +0200 Subject: [PATCH 4/4] Drop the ory uuid uniqueness check on anon link Removes the check itself plus everything that only existed for it: the OryAccountAlreadyLinked error, its 409 mapping and the client side handling. CanNotLinkAnonAccount loses its special 400 and falls through to the generic 500, so a failed link shows up in the error log. --- client/src/hooks/use-ory.tsx | 7 ------- client/src/i18n/de.json | 1 - client/src/i18n/en.json | 1 - server/main/kotlin/ch/yass/core/error/DomainError.kt | 3 --- server/main/kotlin/ch/yass/core/helper/response.kt | 4 ---- server/main/kotlin/ch/yass/game/PlayerService.kt | 7 ------- 6 files changed, 23 deletions(-) diff --git a/client/src/hooks/use-ory.tsx b/client/src/hooks/use-ory.tsx index 0a5923de..51183651 100644 --- a/client/src/hooks/use-ory.tsx +++ b/client/src/hooks/use-ory.tsx @@ -105,13 +105,6 @@ export const useOry = () => { } catch (error) { const response = (error as { response?: { status?: number, data?: { ui?: { messages?: UiText[], nodes?: UiNode[] } } } }).response - // The server answers 409 when the ory identity of the reused session already belongs to another - // player row. A fresh registration can't hit this, ory rejects a duplicate email before we link. - if (response?.status === 409) { - setSignupError({id: 0, text: t("errors.upgradeExists")}) - return - } - if (response?.data?.ui) { setSignupError(getOryErrorMessage(response.data, t)) } else { diff --git a/client/src/i18n/de.json b/client/src/i18n/de.json index 55ee7c4e..76a82276 100644 --- a/client/src/i18n/de.json +++ b/client/src/i18n/de.json @@ -44,7 +44,6 @@ "title": "Fehler", "anonSignup": "Muss zwischen 3 und 15 Zeichen lang sein und darf nur Buchstaben, Zahlen sowie «-» und «_» enthalten", "upgradeFailed": "Dein Konto konnte nicht umgewandelt werden. Bitte versuche es später erneut.", - "upgradeExists": "Für diese E-Mail gibt es bereits ein Konto. Bitte melde dich stattdessen an.", "ory": { "4000006": "E-Mail oder Passwort falsch", "4000007": "Bitte verwende eine andere E-Mail", diff --git a/client/src/i18n/en.json b/client/src/i18n/en.json index 002c6a1c..3ada0f9f 100644 --- a/client/src/i18n/en.json +++ b/client/src/i18n/en.json @@ -44,7 +44,6 @@ "title": "Ooops", "anonSignup": "Must be between 3 and 15 characters long and may only contain letters, numbers and «-» or «_»", "upgradeFailed": "Your account could not be upgraded. Please try again later.", - "upgradeExists": "An account with this email already exists. Please log in instead.", "ory": { "4000006": "E-Mail or password incorrect", "4000007": "Please use a different E-Mail", diff --git a/server/main/kotlin/ch/yass/core/error/DomainError.kt b/server/main/kotlin/ch/yass/core/error/DomainError.kt index 9b7083e4..76489fde 100644 --- a/server/main/kotlin/ch/yass/core/error/DomainError.kt +++ b/server/main/kotlin/ch/yass/core/error/DomainError.kt @@ -27,10 +27,7 @@ data class OryIdentityWithoutName(val identity: Identity) : AuthError data class UnauthorizedSubscription(val error: DomainError) : AuthError data class InvalidAnonToken(val token: String) : AuthError data class CanNotImpersonate(val player: InternalPlayer, val impersonateUuid: UUID) : AuthError - -// Carry only the player uuid, these errors end up in the logs and InternalPlayer holds the anon token data class CanNotLinkAnonAccount(val playerUuid: UUID) : AuthError -data class OryAccountAlreadyLinked(val playerUuid: UUID, val oryUuid: UUID) : AuthError // Game or Game-State related Errors sealed interface GameError : DomainError diff --git a/server/main/kotlin/ch/yass/core/helper/response.kt b/server/main/kotlin/ch/yass/core/helper/response.kt index 587fa6b4..b395080f 100644 --- a/server/main/kotlin/ch/yass/core/helper/response.kt +++ b/server/main/kotlin/ch/yass/core/helper/response.kt @@ -42,10 +42,6 @@ fun errorResponse(ctx: Context, error: DomainError): Context { val error = CentrifugoErrorResponse(403, "denied") }) - is CanNotLinkAnonAccount -> ctx.status(400).json(ErrorResponse("can not link anon account")) - - is OryAccountAlreadyLinked -> ctx.status(409).json(ErrorResponse("ory account is already linked to a player")) - is GameWithCodeNotFound -> ctx.status(404).json(ErrorResponse("no game with code ${error.code} found")) else -> { logger().error("DomainError `${error.javaClass.name}` encountered: $error") diff --git a/server/main/kotlin/ch/yass/game/PlayerService.kt b/server/main/kotlin/ch/yass/game/PlayerService.kt index 16009370..baace70e 100644 --- a/server/main/kotlin/ch/yass/game/PlayerService.kt +++ b/server/main/kotlin/ch/yass/game/PlayerService.kt @@ -1,8 +1,6 @@ package ch.yass.game import arrow.core.raise.Raise -import arrow.core.raise.ensure -import ch.yass.core.error.OryAccountAlreadyLinked import ch.yass.core.error.OryIdentityWithoutName import ch.yass.core.error.StringNoValidUUID import ch.yass.core.helper.hashToken @@ -90,12 +88,7 @@ class PlayerService(private val db: DSLContext) { ) } - context(r: Raise) fun linkAnonAccount(player: InternalPlayer, oryUuid: UUID): InternalPlayer { - // Make sure no funky stuff is going on and no other player of ours claimed this ory uuid already - val existingOryUuids = db.fetchCount(PLAYER, PLAYER.ORY_UUID.eq(oryUuid.toString())) - r.ensure(existingOryUuids == 0) { OryAccountAlreadyLinked(player.uuid, oryUuid) } - return db.update(PLAYER) .setNull(PLAYER.ANON_TOKEN) .set(PLAYER.ORY_UUID, oryUuid.toString())