feat: account system, OIDC, meta-progression research tree, and automated tests
Account & Persistence Layer (server/db.mjs)
- SQLite via node:sqlite DatabaseSync with WAL mode, foreign keys, and
synchronous=NORMAL for microsecond response times
- Users table: UUID primary key, unique lowercase username, scrypt-hashed
password, optional OIDC sub/issuer, crystal balance, timestamps
- Sessions table: 64-byte random hex token, FK to users, configurable TTL
with automatic expiry cleanup every 10 minutes
- User upgrades table: composite PK (user_id, upgrade_id), level tracking,
ON CONFLICT DO UPDATE for idempotent merges
- User stats table: games played/won, total kills/score, highest wave
- Password hashing: crypto.scrypt with 16-byte salt and 64-byte derived
key, constant-time comparison via crypto.timingSafeEqual
- Guest merge: caps crystals at 100,000, caps levels to defined maxLevels,
uses MAX(level, new) to preserve higher account levels, ignores unknown
upgrade IDs, transactional with BEGIN IMMEDIATE/COMMIT/ROLLBACK
- Atomic crystal purchasing: subtracts cost only if balance sufficient,
upgrades level within transaction, returns full user object on success
Meta-Progression Definitions (shared/meta-upgrades.mjs)
- Single source of truth shared between server (authoritative validation)
and client (talent tree UI)
- 3 branches: Economy (start_gold, wave_bonus, obstacle_discount),
Defense (bonus_lives, shockwave, fortress_shield),
Towers (tower_range, tower_speed, dot_potency)
- 9 upgrades with 1–5 levels each, progressive cost curves
- calcCrystalsEarned(wave, score, win): base 2.5 per wave, +40 for victory,
+floor(score/250), minimum 1 crystal per game
REST API (server/server.mjs)
- /api/auth/register: username 3–16 chars a-z0-9_-; password min 8 chars;
case-insensitive uniqueness; auto-creates user_stats row; returns
session cookie (HttpOnly, SameSite=Lax, Secure when HTTPS)
- /api/auth/login: constant-time username lookup via scrypt verify;
rate-limited 20 auth attempts/IP/minute
- /api/auth/logout: deletes session server-side, clears cookie
- /api/auth/me: returns publicUser (id, username, crystals, upgrades,
stats, oidc flag) or null
- /api/auth/oidc/login: PKCE Authorization Code flow with SHA-256 S256
challenge/verifier; discovers .well-known/openid-configuration;
verifies RS256 id_token signature via JWKS public key; validates
issuer, audience, and expiry; finds or creates user by OIDC sub/issuer
- /api/auth/oidc/callback: exchanges code for tokens, verifies id_token,
issues session cookie, redirects to /?auth=ok or /?auth=error
- /api/upgrades/buy: validates upgrade ID against META_UPGRADES,
checks current level < maxLevel, deducts cost from crystals
- /api/game/finish: server-authoritative crystal calculation;
bounds-checks inputs (wave ≤ 9999, score ≤ 10M, kills ≤ 1M);
updates user_stats (games_played, games_won, total_kills, total_score,
highest_wave via MAX)
- /api/auth/merge-guest: one-time guest-to-account crystal and upgrade
migration with level caps
- /api/config: public endpoint exposing OIDC enabled state and button label
- Security: CSP header on all responses, X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, Referrer-Policy: no-referrer, cache-control
no-store on API responses, path-traversal protection on static serving
Multiplayer Fairness
- Meta-upgrades (start_gold, bonus_lives, tower_range, tower_speed,
dot_potency, wave_bonus, obstacle_discount, shockwave, fortress_shield)
applied only in solo campaign mode
- Co-op and Duel multiplayer sessions reset all meta buffs to zero,
preserving lockstep determinism and competitive balance
- Multiplayer results set crystalsEarned: 0 to prevent duplicate rewards
Frontend (Vue 3 + TypeScript)
- AuthModal.vue: username/password login and registration form with
validation, OIDC single sign-on button (shown when configured),
guest-to-account upgrade on first login
- UserProfileBar.vue: top-bar indicator showing crystal count (💎),
user display name, research and login/logout buttons, reactive
auth state via auth controller
- ResearchTree.vue: interactive talent tree modal with 3 branches,
per-upgrade cost/level display, purchase confirmation, disabled
state for unaffordable/maxed upgrades, branch icons and descriptions
- auth.ts: reactive controller managing login, registration, OIDC
redirect detection (?auth=ok/?auth=error), guest profile migration
on first login, upgrade purchasing, and game result reporting
- meta.ts: frontend helpers for branch definitions, upgrade costs,
and guest profile persistence in localStorage
- engine.ts: solo meta bonus application (start_gold, bonus_lives,
tower_range, tower_speed, dot_potency, wave_bonus, obstacle_discount,
shockwave, fortress_shield); crystal rewards in finish() path
- mpgame.ts: meta buff reset in multiplayer sessions; crystalsEarned: 0
- sound.ts: shield sound synthesis for fortress_shield absorption
- store.ts: auth state, research tree toggle, upgrade snapshot
- types.ts: SfxName extended with "shield" sound
Docker Configuration
- Multi-stage build: node:22-alpine build → node:22-alpine runtime
(production deps only: ws)
- VOLUME /app/data for persistent SQLite database
- HEALTHCHECK on /health endpoint
- docker-compose.yml: port 3001, persistent ./data volume,
commented OIDC environment variables (OIDC_ENABLED, OIDC_ISSUER,
OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_BUTTON_LABEL)
- .gitignore: data/, *.db, *.db-journal, *.db-wal, *.db-shm
Automated Tests (npm test)
- scripts/test-db.mjs (17 unit tests): isolated SQLite persistence –
scrypt hash/verify roundtrip, timing-safe constant-time comparison,
user creation with lowercase enforcement, UNIQUE constraint, session
create/get/delete lifecycle, expired session invalidation, crystal
addition, 5-level upgrade cost progression with max-level guard,
guest merge (crystal cap 100k, level cap, MAX() semantics, unknown
upgrade rejection), game result stats accumulation, calcCrystalsEarned
formula verification
- scripts/test-auth.mjs (31 integration tests): spawns real server with
isolated DATA_DIR, exercises full REST flow – register validation
(username too short, password too short, duplicate, case-insensitive),
login (wrong password, correct), session cookie attributes (HttpOnly,
SameSite=Lax, Path=/), /me endpoint, upgrade purchase (insufficient
crystals, unknown ID, successful purchase), game finish rewards
(victory, defeat, negative values clamped to 1 crystal minimum),
guest merge (crystals, level caps, unknown upgrades), logout,
OIDC-disabled endpoints (400), unauthenticated guards (401), 404
routing, CSP header on static files, rate limiting (429 after 20+
auth attempts per minute), and persistence across server restart
(kill + respawn with same DATA_DIR preserves all state)
This commit is contained in:
parent
ce2484bb8d
commit
1a9ac5bf45
24 changed files with 2216 additions and 12 deletions
213
src/components/AuthModal.vue
Normal file
213
src/components/AuthModal.vue
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { login, oidcLogin, register } from '@/game/auth'
|
||||
import { store } from '@/game/store'
|
||||
|
||||
const mode = ref<'login' | 'register'>('login')
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const displayName = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
function close(): void {
|
||||
store.auth.showAuth = false
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
const err =
|
||||
mode.value === 'login'
|
||||
? await login(username.value.trim(), password.value)
|
||||
: await register(username.value.trim(), password.value, displayName.value.trim() || username.value.trim())
|
||||
busy.value = false
|
||||
if (err) {
|
||||
error.value = err
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="store.auth.showAuth" class="backdrop" @click.self="close">
|
||||
<div class="card">
|
||||
<button class="close" @click="close">✕</button>
|
||||
<h2>{{ mode === 'login' ? 'Anmelden' : 'Konto erstellen' }}</h2>
|
||||
<p class="sub">Speichere Kristalle, Forschung & Statistiken über alle Runden hinweg.</p>
|
||||
|
||||
<div class="tabs">
|
||||
<button :class="{ active: mode === 'login' }" @click="mode = 'login'">Anmelden</button>
|
||||
<button :class="{ active: mode === 'register' }" @click="mode = 'register'">Registrieren</button>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>Benutzername</span>
|
||||
<input v-model="username" maxlength="16" autocomplete="username" @keyup.enter="submit" />
|
||||
</label>
|
||||
<label v-if="mode === 'register'" class="field">
|
||||
<span>Anzeigename (optional)</span>
|
||||
<input v-model="displayName" maxlength="24" @keyup.enter="submit" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Passwort</span>
|
||||
<input v-model="password" type="password" autocomplete="current-password" @keyup.enter="submit" />
|
||||
</label>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<button class="primary" :disabled="busy" @click="submit">
|
||||
{{ busy ? '…' : mode === 'login' ? '▶ Anmelden' : '▶ Konto erstellen' }}
|
||||
</button>
|
||||
|
||||
<div v-if="store.auth.oidcEnabled" class="divider"><span>oder</span></div>
|
||||
<button v-if="store.auth.oidcEnabled" class="oidc" @click="oidcLogin">🔑 {{ store.auth.oidcLabel }}</button>
|
||||
|
||||
<p class="hint">Als Gast spielen? Einfach schließen – Fortschritt wird lokal gespeichert.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(8, 11, 15, 0.72);
|
||||
backdrop-filter: blur(3px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
}
|
||||
.card {
|
||||
position: relative;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 16px;
|
||||
padding: 24px 28px;
|
||||
width: 380px;
|
||||
max-width: 92vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
.sub {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.tabs button {
|
||||
flex: 1;
|
||||
background: var(--panel-inset);
|
||||
border: 1px solid var(--panel-border);
|
||||
color: var(--text-dim);
|
||||
font-family: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tabs button.active {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.field span {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.field input {
|
||||
background: var(--panel-inset);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 8px;
|
||||
padding: 9px 12px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
.field input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.error {
|
||||
margin: 0;
|
||||
color: #ff8a7a;
|
||||
font-size: 13px;
|
||||
}
|
||||
.primary {
|
||||
background: linear-gradient(180deg, #59b34d, #3f8f37);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
font-size: 15px;
|
||||
padding: 11px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--panel-border);
|
||||
}
|
||||
.divider span {
|
||||
padding: 0 8px;
|
||||
}
|
||||
.oidc {
|
||||
background: var(--panel-inset);
|
||||
border: 1px solid #3d6f9e;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
padding: 11px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.oidc:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 11.5px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue