mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
updating types
This commit is contained in:
@@ -45,12 +45,40 @@ const parseAll = (rows: AccountRow[]): Account[] => rows.map((r) => JSON.parse(r
|
||||
|
||||
/** Word lists for auto-assigned usernames (players don't pick one on signup). */
|
||||
const ADJECTIVES = [
|
||||
'Swift', 'Brave', 'Clever', 'Happy', 'Mighty', 'Lucky', 'Sunny', 'Cosmic',
|
||||
'Witty', 'Nimble', 'Jolly', 'Bold', 'Gentle', 'Fuzzy', 'Speedy', 'Shiny',
|
||||
'Swift',
|
||||
'Brave',
|
||||
'Clever',
|
||||
'Happy',
|
||||
'Mighty',
|
||||
'Lucky',
|
||||
'Sunny',
|
||||
'Cosmic',
|
||||
'Witty',
|
||||
'Nimble',
|
||||
'Jolly',
|
||||
'Bold',
|
||||
'Gentle',
|
||||
'Fuzzy',
|
||||
'Speedy',
|
||||
'Shiny',
|
||||
]
|
||||
const NOUNS = [
|
||||
'Fox', 'Otter', 'Falcon', 'Panda', 'Tiger', 'Comet', 'Maple', 'Pixel',
|
||||
'Robin', 'Wolf', 'Koala', 'Dragon', 'Penguin', 'Badger', 'Heron', 'Lynx',
|
||||
'Fox',
|
||||
'Otter',
|
||||
'Falcon',
|
||||
'Panda',
|
||||
'Tiger',
|
||||
'Comet',
|
||||
'Maple',
|
||||
'Pixel',
|
||||
'Robin',
|
||||
'Wolf',
|
||||
'Koala',
|
||||
'Dragon',
|
||||
'Penguin',
|
||||
'Badger',
|
||||
'Heron',
|
||||
'Lynx',
|
||||
]
|
||||
|
||||
/** A random, readable username (e.g. "SwiftFox4821"). */
|
||||
@@ -117,3 +145,29 @@ export async function createAccount(
|
||||
await db.prepare('INSERT INTO accounts (data) VALUES (?1)').bind(JSON.stringify(account)).run()
|
||||
return account
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the account's stored password hash (`salt:hash`), or null when the account
|
||||
* has none / doesn't exist. Kept in the account JSON blob but out of the public
|
||||
* account DTO (which projects only known fields), so it never leaks.
|
||||
*/
|
||||
export async function getPasswordHash(db: D1Database, id: number): Promise<string | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
"SELECT json_extract(data, '$.passwordHash') AS hash FROM accounts WHERE account_id = ?1"
|
||||
)
|
||||
.bind(id)
|
||||
.first<{ hash: string | null }>()
|
||||
return row?.hash ?? null
|
||||
}
|
||||
|
||||
/** Persist the account's password hash. Returns false when no such account exists. */
|
||||
export async function setPasswordHash(db: D1Database, id: number, hash: string): Promise<boolean> {
|
||||
const { meta } = await db
|
||||
.prepare(
|
||||
"UPDATE accounts SET data = json_set(data, '$.passwordHash', ?2) WHERE account_id = ?1"
|
||||
)
|
||||
.bind(id, hash)
|
||||
.run()
|
||||
return meta.changes > 0
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
|
||||
import { createAccount } from './accounts-db'
|
||||
import { generateToken, TOKEN_TTL_SECONDS } from './jwt'
|
||||
import { createAccount, getPasswordHash, setPasswordHash } from './accounts-db'
|
||||
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from './jwt'
|
||||
import { hashPassword, verifyPassword } from './password'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from './context'
|
||||
|
||||
/** OAuth scopes granted by `/connect/token`. */
|
||||
@@ -54,8 +56,7 @@ async function placeNewPlayerInOrientation(env: App['Bindings'], accountId: numb
|
||||
const room = JSON.parse(row.data) as Record<string, unknown>
|
||||
const subRooms = room.SubRooms
|
||||
const sub = (Array.isArray(subRooms) ? subRooms[0] : undefined) as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
Record<string, unknown> | undefined
|
||||
const str = (v: unknown, fallback = '') => (typeof v === 'string' ? v : fallback)
|
||||
const num = (v: unknown, fallback: number) => (typeof v === 'number' ? v : fallback)
|
||||
|
||||
@@ -92,6 +93,15 @@ async function placeNewPlayerInOrientation(env: App['Bindings'], accountId: numb
|
||||
})
|
||||
}
|
||||
|
||||
/** The Bearer token's account id (`sub`), or null when there's no valid token. */
|
||||
async function authedId(c: Context<App>): Promise<number | null> {
|
||||
const authHeader = c.req.header('Authorization') ?? ''
|
||||
if (!authHeader.toLowerCase().startsWith('bearer ')) return null
|
||||
const sub = await validateAndGetAccountId(authHeader.slice('Bearer '.length))
|
||||
const id = sub ? Number.parseInt(sub, 10) : Number.NaN
|
||||
return Number.isNaN(id) ? null : id
|
||||
}
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -170,6 +180,31 @@ const app = new Hono<App>()
|
||||
})
|
||||
})
|
||||
|
||||
// Change the caller's password. Auth-gated. Stores a PBKDF2 hash on the account
|
||||
// row (the raw password is never persisted). When the account already has a
|
||||
// password, `oldPassword` must match; the first time it's set, `oldPassword` is
|
||||
// empty (as the client sends).
|
||||
.post('/account/me/changepassword', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const oldPassword = typeof body.oldPassword === 'string' ? body.oldPassword : ''
|
||||
const newPassword = typeof body.newPassword === 'string' ? body.newPassword : ''
|
||||
if (newPassword === '') {
|
||||
return c.json({ success: false, error: 'You must enter a new password.' }, 400)
|
||||
}
|
||||
|
||||
const currentHash = await getPasswordHash(c.env.DB, id)
|
||||
if (currentHash && !(await verifyPassword(oldPassword, currentHash))) {
|
||||
return c.json({ success: false, error: 'Your old password is incorrect.' }, 400)
|
||||
}
|
||||
|
||||
const ok = await setPasswordHash(c.env.DB, id, await hashPassword(newPassword))
|
||||
if (!ok) return c.body(null, 404)
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// Developer role lookup. Not implemented yet.
|
||||
.get('/role/developer/:id', (c) => {
|
||||
const { id } = c.req.param()
|
||||
|
||||
@@ -18,6 +18,51 @@ function base64url(input: ArrayBuffer | string): string {
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
function base64urlToBytes(input: string): Uint8Array {
|
||||
const padded = input.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4))
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
||||
return bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an HS256 token and return its `sub` (account id) claim, or `null` when
|
||||
* the token is malformed, has a bad signature, or is expired.
|
||||
*/
|
||||
export async function validateAndGetAccountId(
|
||||
token: string,
|
||||
secret: string = DEV_SECRET
|
||||
): Promise<string | null> {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
const [header, payload, signature] = parts
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['verify']
|
||||
)
|
||||
const valid = await crypto.subtle.verify(
|
||||
'HMAC',
|
||||
key,
|
||||
base64urlToBytes(signature),
|
||||
new TextEncoder().encode(`${header}.${payload}`)
|
||||
)
|
||||
if (!valid) return null
|
||||
|
||||
let claims: { sub?: string; exp?: number }
|
||||
try {
|
||||
claims = JSON.parse(new TextDecoder().decode(base64urlToBytes(payload)))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (typeof claims.exp === 'number' && claims.exp < Math.floor(Date.now() / 1000)) return null
|
||||
return claims.sub ?? null
|
||||
}
|
||||
|
||||
/** Scopes stamped onto every token (as a claim array). */
|
||||
const TOKEN_SCOPES = [
|
||||
'profile',
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Password hashing for /account/me/changepassword. PBKDF2-SHA256 with a random
|
||||
* per-password salt, stored as `salt:hash` (both base64). Not login-critical yet
|
||||
* (login is account-id based), but we never store the raw password.
|
||||
*/
|
||||
const ITERATIONS = 100_000
|
||||
|
||||
const b64 = (bytes: Uint8Array): string => btoa(String.fromCharCode(...bytes))
|
||||
const fromB64 = (s: string): Uint8Array => Uint8Array.from(atob(s), (ch) => ch.charCodeAt(0))
|
||||
|
||||
async function deriveBits(password: string, salt: Uint8Array): Promise<Uint8Array> {
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits']
|
||||
)
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: 'PBKDF2', salt, iterations: ITERATIONS, hash: 'SHA-256' },
|
||||
keyMaterial,
|
||||
256
|
||||
)
|
||||
return new Uint8Array(bits)
|
||||
}
|
||||
|
||||
/** Hash a password into a `salt:hash` string (both base64). */
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16))
|
||||
return `${b64(salt)}:${b64(await deriveBits(password, salt))}`
|
||||
}
|
||||
|
||||
/** Verify a password against a stored `salt:hash`. */
|
||||
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
const [saltB64, hashB64] = stored.split(':')
|
||||
if (!saltB64 || !hashB64) return false
|
||||
const actual = b64(await deriveBits(password, fromB64(saltB64)))
|
||||
return actual === hashB64
|
||||
}
|
||||
@@ -48,14 +48,29 @@ function decodePayload(token: string): Record<string, unknown> {
|
||||
) as Record<string, unknown>
|
||||
}
|
||||
|
||||
async function tokenFor(body: string): Promise<Record<string, unknown>> {
|
||||
async function accessTokenFor(body: string): Promise<string> {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
})
|
||||
const { access_token } = (await res.json()) as { access_token: string }
|
||||
return decodePayload(access_token)
|
||||
return ((await res.json()) as { access_token: string }).access_token
|
||||
}
|
||||
|
||||
async function tokenFor(body: string): Promise<Record<string, unknown>> {
|
||||
return decodePayload(await accessTokenFor(body))
|
||||
}
|
||||
|
||||
/** POST a form-urlencoded body to changepassword with an optional bearer token. */
|
||||
function changePassword(body: string, token?: string): Promise<Response> {
|
||||
return exports.default.fetch(`${ORIGIN}/account/me/changepassword`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
describe('auth worker routes', () => {
|
||||
@@ -165,6 +180,38 @@ describe('auth worker routes', () => {
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /account/me/changepassword 401s without a token', async () => {
|
||||
const res = await changePassword('oldPassword=&newPassword=secret123')
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /account/me/changepassword 400s without a new password', async () => {
|
||||
const token = await accessTokenFor('grant_type=create_account&platform_id=steam-pw0')
|
||||
const res = await changePassword('oldPassword=&newPassword=', token)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
test('POST /account/me/changepassword sets then rotates the password', async () => {
|
||||
const token = await accessTokenFor('grant_type=create_account&platform_id=steam-pw1')
|
||||
|
||||
// First set — oldPassword is empty (as the client sends it).
|
||||
const set = await changePassword('oldPassword=&newPassword=first-password', token)
|
||||
expect(set.status).toBe(200)
|
||||
expect(await set.json()).toEqual({ success: true })
|
||||
|
||||
// A wrong old password is now rejected.
|
||||
const wrong = await changePassword('oldPassword=nope&newPassword=second-password', token)
|
||||
expect(wrong.status).toBe(400)
|
||||
|
||||
// The correct old password rotates it.
|
||||
const rotate = await changePassword(
|
||||
'oldPassword=first-password&newPassword=second-password',
|
||||
token
|
||||
)
|
||||
expect(rotate.status).toBe(200)
|
||||
expect(await rotate.json()).toEqual({ success: true })
|
||||
})
|
||||
|
||||
test('GET /role/developer/:id returns ok', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/role/developer/42`)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
Reference in New Issue
Block a user