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/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/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")}
{ 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..5ce39d00 100644 --- a/client/src/components/signup-form.tsx +++ b/client/src/components/signup-form.tsx @@ -15,26 +15,39 @@ 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 [isAnonSignup, setIsAnonSignup] = useState(location.state?.isAnonSignup || false) const {t} = useTranslation() - const {signup, signupError} = useOry() + const {signup, upgradeAnon, signupError} = useOry() const {anonSignup, anonSignupError} = useAnon() const {isAuthenticated, initialized} = useAuth() + const isAnon = usePlayerStore(state => state.isAnon) + const anonName = usePlayerStore(state => state.name) + + // 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) 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 (isGuest) { + if (isUpgrade) { + return upgradeAnon({ + username: data.username, + email: data.email!, + password: data.password! + }, redirectTo || '/lobby') + } + if (isAnonSignup) { return anonSignup(data.username, redirectTo) } return signup({ @@ -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,12 @@ export function SignupForm() { name="username" id="username" type="text" + defaultValue={isUpgrade ? anonName : undefined} required /> + {isUpgrade && ( +

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

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

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

- - - - - - - - -

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

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

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

+
+
+
+
+ )} + {(!isAnonSignup || isUpgrade) && ( <>
@@ -158,15 +180,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..51183651 100644 --- a/client/src/hooks/use-ory.tsx +++ b/client/src/hooks/use-ory.tsx @@ -2,8 +2,9 @@ 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 {UiNode, UiText} from "@ory/client"; import {useState} from "react"; import {useTranslation} from "react-i18next"; @@ -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,44 @@ export const useOry = () => { handleError(error) }) - return {login, loginError, signup, signupError} -} \ No newline at end of file + 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 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. + * + * 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 upgradeAnon = 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?: { status?: number, data?: { ui?: { messages?: UiText[], nodes?: UiNode[] } } } }).response + + if (response?.data?.ui) { + setSignupError(getOryErrorMessage(response.data, t)) + } else { + setSignupError({id: 0, text: t("errors.upgradeFailed")}) + } + handleError(error) + } + } + + return {login, loginError, signup, upgradeAnon, signupError} +} diff --git a/client/src/i18n/de.json b/client/src/i18n/de.json index 772c2006..76a82276 100644 --- a/client/src/i18n/de.json +++ b/client/src/i18n/de.json @@ -42,7 +42,8 @@ }, "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, Zahlen sowie «-» und «_» 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", @@ -149,7 +150,7 @@ "username": "Nickname" }, "login": { - "guestPlay": "Als Gast spielen", + "anonPlay": "Als Gast spielen", "title": "Hallo!", "prompt": "Bereits registriert?", "link": "Login" @@ -158,9 +159,16 @@ "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", + "description": "Ergänze dein Gast-Profil mit E-Mail und Passwort. Name, Spiele und Statistiken bleiben erhalten.", + "usernameHint": "Dein Name wird übernommen, du kannst ihn hier aber noch anpassen.", + "submit": "Konto umwandeln", + "cancel": "Später" } }, "weisen": { @@ -245,7 +253,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 4bfcfe56..3ada0f9f 100644 --- a/client/src/i18n/en.json +++ b/client/src/i18n/en.json @@ -42,6 +42,8 @@ }, "errors": { "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.", "ory": { "4000006": "E-Mail or password incorrect", "4000007": "Please use a different E-Mail", @@ -150,7 +152,7 @@ }, "login": { "title": "Welcome back", - "guestPlay": "Play as guest", + "anonPlay": "Play as guest", "prompt": "Already signed up?", "link": "Login" }, @@ -158,9 +160,16 @@ "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", + "description": "Add an email and a password to your guest profile. Your name, games and stats stay the same.", + "usernameHint": "Your name carries over, but you can still adjust it here.", + "submit": "Upgrade account", + "cancel": "Not now" } }, "weisen": { @@ -245,7 +254,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/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/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/core/error/DomainError.kt b/server/main/kotlin/ch/yass/core/error/DomainError.kt index 00341fc1..76489fde 100644 --- a/server/main/kotlin/ch/yass/core/error/DomainError.kt +++ b/server/main/kotlin/ch/yass/core/error/DomainError.kt @@ -27,7 +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 -data class CanNotLinkAnonAccount(val player: InternalPlayer, val orySession: String) : AuthError +data class CanNotLinkAnonAccount(val playerUuid: UUID) : AuthError // Game or Game-State related Errors sealed interface GameError : DomainError diff --git a/server/main/kotlin/ch/yass/core/valiktor/Username.kt b/server/main/kotlin/ch/yass/core/valiktor/Username.kt index cf396e06..92d9f5d3 100644 --- a/server/main/kotlin/ch/yass/core/valiktor/Username.kt +++ b/server/main/kotlin/ch/yass/core/valiktor/Username.kt @@ -5,6 +5,13 @@ import org.valiktor.Validator object Username : Constraint +/** + * 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.isLetterOrDigit() } -} \ 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 ca81bb1e..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.CanNotLinkAnonAccount 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, orySession: String): 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) } - + fun linkAnonAccount(player: InternalPlayer, oryUuid: UUID): InternalPlayer { return db.update(PLAYER) .setNull(PLAYER.ANON_TOKEN) .set(PLAYER.ORY_UUID, oryUuid.toString()) diff --git a/server/main/kotlin/ch/yass/identity/AuthController.kt b/server/main/kotlin/ch/yass/identity/AuthController.kt index f9944789..9b618cce 100644 --- a/server/main/kotlin/ch/yass/identity/AuthController.kt +++ b/server/main/kotlin/ch/yass/identity/AuthController.kt @@ -54,13 +54,19 @@ class AuthController( */ private fun anonLink(ctx: Context) { either { - val request = validate(ctx.body()) val player = player(ctx) - ensureNotNull(player.anonToken) { CanNotLinkAnonAccount(player, request.orySession) } + 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()) - val orySession = getSession(player, request.orySession) - playerService.linkAnonAccount(player, orySession.identity!!.id.toUUID(), request.orySession) + // 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,17 +83,8 @@ 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 - } - ) - logger().info("trigger_alert: New guest user signed up ${player.name} (${player.uuid})") + ctx.cookie(anonTokenCookie(token, 60 * 60 * 24 * 365)) // 1 year + logger().info("trigger_alert: New anon user signed up ${player.name} (${player.uuid})") AnonSignupResponse(player.uuid, player.name) }.fold( { errorResponse(ctx, it) }, @@ -97,21 +94,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 +152,8 @@ 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.uuid)) } } \ 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: