feat: implement auth system, passkeys, and user management
Add PostgreSQL and Redis integration for users and sessions Implement password and WebAuthn passkey login flows Add Docker stack, super-admin seeding, and protected routes
This commit is contained in:
492
server/utils/user-repository.ts
Normal file
492
server/utils/user-repository.ts
Normal file
@@ -0,0 +1,492 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import type { AuthenticatorTransportFuture, CredentialDeviceType } from '@simplewebauthn/server'
|
||||
|
||||
import type { AuthUser, ManagedUser, PasskeySummary, PublicContact, UserRole } from '~~/shared/auth'
|
||||
|
||||
import { encodeBase64Url, toIsoString } from './base64url'
|
||||
import { ensureDatabaseReady } from './db-init'
|
||||
import { getSqlClient } from './postgres'
|
||||
|
||||
type DbUserRow = {
|
||||
id: string
|
||||
username: string
|
||||
full_name: string
|
||||
phone_number: string | null
|
||||
role: UserRole
|
||||
password_hash: string
|
||||
must_change_password: boolean
|
||||
is_active: boolean
|
||||
created_by: string | null
|
||||
created_at: Date | string
|
||||
last_login_at: Date | string | null
|
||||
passkey_count: number | string
|
||||
}
|
||||
|
||||
type DbPasskeyRow = {
|
||||
id: string
|
||||
user_id: string
|
||||
credential_id: string
|
||||
public_key: string
|
||||
counter: number | string
|
||||
device_type: CredentialDeviceType
|
||||
backed_up: boolean
|
||||
transports: AuthenticatorTransportFuture[] | string
|
||||
label: string
|
||||
created_at: Date | string
|
||||
last_used_at: Date | string | null
|
||||
}
|
||||
|
||||
export interface UserAuthRecord extends AuthUser {
|
||||
passwordHash: string
|
||||
createdBy: string | null
|
||||
}
|
||||
|
||||
export interface PasskeyRecord {
|
||||
id: string
|
||||
userId: string
|
||||
credentialId: string
|
||||
publicKey: string
|
||||
counter: number
|
||||
deviceType: CredentialDeviceType
|
||||
backedUp: boolean
|
||||
transports: AuthenticatorTransportFuture[]
|
||||
label: string
|
||||
createdAt: string
|
||||
lastUsedAt: string | null
|
||||
}
|
||||
|
||||
function parseCount(value: number | string): number {
|
||||
return typeof value === 'number' ? value : Number.parseInt(value, 10)
|
||||
}
|
||||
|
||||
function parseCounter(value: number | string): number {
|
||||
return typeof value === 'number' ? value : Number.parseInt(value, 10)
|
||||
}
|
||||
|
||||
function parseTransports(value: AuthenticatorTransportFuture[] | string): AuthenticatorTransportFuture[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function mapAuthUser(row: DbUserRow): AuthUser {
|
||||
const passkeyCount = parseCount(row.passkey_count)
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
fullName: row.full_name,
|
||||
phoneNumber: row.phone_number,
|
||||
role: row.role,
|
||||
isActive: row.is_active,
|
||||
mustChangePassword: row.must_change_password,
|
||||
needsPasskeySetup: passkeyCount === 0,
|
||||
passkeyCount,
|
||||
createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),
|
||||
lastLoginAt: toIsoString(row.last_login_at)
|
||||
}
|
||||
}
|
||||
|
||||
function mapManagedUser(row: DbUserRow): ManagedUser {
|
||||
return {
|
||||
...mapAuthUser(row),
|
||||
createdBy: row.created_by
|
||||
}
|
||||
}
|
||||
|
||||
function mapUserAuthRecord(row: DbUserRow): UserAuthRecord {
|
||||
return {
|
||||
...mapAuthUser(row),
|
||||
passwordHash: row.password_hash,
|
||||
createdBy: row.created_by
|
||||
}
|
||||
}
|
||||
|
||||
function mapPasskeyRecord(row: DbPasskeyRow): PasskeyRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
credentialId: row.credential_id,
|
||||
publicKey: row.public_key,
|
||||
counter: parseCounter(row.counter),
|
||||
deviceType: row.device_type,
|
||||
backedUp: row.backed_up,
|
||||
transports: parseTransports(row.transports),
|
||||
label: row.label,
|
||||
createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),
|
||||
lastUsedAt: toIsoString(row.last_used_at)
|
||||
}
|
||||
}
|
||||
|
||||
function mapPasskeySummary(row: DbPasskeyRow): PasskeySummary {
|
||||
const record = mapPasskeyRecord(row)
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
label: record.label,
|
||||
createdAt: record.createdAt,
|
||||
lastUsedAt: record.lastUsedAt,
|
||||
deviceType: record.deviceType,
|
||||
backedUp: record.backedUp
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserById(userId: string): Promise<UserAuthRecord | null> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<DbUserRow[]>`
|
||||
select
|
||||
users.id,
|
||||
users.username,
|
||||
users.full_name,
|
||||
users.phone_number,
|
||||
users.role,
|
||||
users.password_hash,
|
||||
users.must_change_password,
|
||||
users.is_active,
|
||||
users.created_by,
|
||||
users.created_at,
|
||||
users.last_login_at,
|
||||
coalesce(passkey_totals.passkey_count, 0) as passkey_count
|
||||
from users
|
||||
left join (
|
||||
select user_id, count(*)::int as passkey_count
|
||||
from user_passkeys
|
||||
group by user_id
|
||||
) as passkey_totals on passkey_totals.user_id = users.id
|
||||
where users.id = ${userId}
|
||||
limit 1
|
||||
`
|
||||
|
||||
return row ? mapUserAuthRecord(row) : null
|
||||
}
|
||||
|
||||
export async function getUserByUsername(username: string): Promise<UserAuthRecord | null> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<DbUserRow[]>`
|
||||
select
|
||||
users.id,
|
||||
users.username,
|
||||
users.full_name,
|
||||
users.phone_number,
|
||||
users.role,
|
||||
users.password_hash,
|
||||
users.must_change_password,
|
||||
users.is_active,
|
||||
users.created_by,
|
||||
users.created_at,
|
||||
users.last_login_at,
|
||||
coalesce(passkey_totals.passkey_count, 0) as passkey_count
|
||||
from users
|
||||
left join (
|
||||
select user_id, count(*)::int as passkey_count
|
||||
from user_passkeys
|
||||
group by user_id
|
||||
) as passkey_totals on passkey_totals.user_id = users.id
|
||||
where users.username = ${username}
|
||||
limit 1
|
||||
`
|
||||
|
||||
return row ? mapUserAuthRecord(row) : null
|
||||
}
|
||||
|
||||
export async function listUsers(): Promise<ManagedUser[]> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const rows = await sql<DbUserRow[]>`
|
||||
select
|
||||
users.id,
|
||||
users.username,
|
||||
users.full_name,
|
||||
users.phone_number,
|
||||
users.role,
|
||||
users.password_hash,
|
||||
users.must_change_password,
|
||||
users.is_active,
|
||||
users.created_by,
|
||||
users.created_at,
|
||||
users.last_login_at,
|
||||
coalesce(passkey_totals.passkey_count, 0) as passkey_count
|
||||
from users
|
||||
left join (
|
||||
select user_id, count(*)::int as passkey_count
|
||||
from user_passkeys
|
||||
group by user_id
|
||||
) as passkey_totals on passkey_totals.user_id = users.id
|
||||
order by
|
||||
case when users.role = 'super_admin' then 0 else 1 end,
|
||||
users.created_at asc
|
||||
`
|
||||
|
||||
return rows.map(mapManagedUser)
|
||||
}
|
||||
|
||||
export async function listPublicContacts(): Promise<PublicContact[]> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const rows = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role'>[]>`
|
||||
select
|
||||
users.id,
|
||||
users.full_name,
|
||||
users.phone_number,
|
||||
users.role
|
||||
from users
|
||||
where users.is_active = true
|
||||
and users.phone_number is not null
|
||||
and users.phone_number <> ''
|
||||
order by
|
||||
case when users.role = 'super_admin' then 0 else 1 end,
|
||||
users.full_name asc
|
||||
`
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
fullName: row.full_name,
|
||||
phoneNumber: row.phone_number || '',
|
||||
role: row.role
|
||||
}))
|
||||
}
|
||||
|
||||
export async function createUser(input: {
|
||||
username: string
|
||||
fullName: string
|
||||
phoneNumber: string
|
||||
role: UserRole
|
||||
passwordHash: string
|
||||
createdBy: string
|
||||
}): Promise<UserAuthRecord> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<DbUserRow[]>`
|
||||
insert into users (
|
||||
id,
|
||||
username,
|
||||
full_name,
|
||||
phone_number,
|
||||
role,
|
||||
password_hash,
|
||||
must_change_password,
|
||||
is_active,
|
||||
created_by
|
||||
)
|
||||
values (
|
||||
${randomUUID()},
|
||||
${input.username},
|
||||
${input.fullName},
|
||||
${input.phoneNumber},
|
||||
${input.role},
|
||||
${input.passwordHash},
|
||||
true,
|
||||
true,
|
||||
${input.createdBy}
|
||||
)
|
||||
returning
|
||||
id,
|
||||
username,
|
||||
full_name,
|
||||
phone_number,
|
||||
role,
|
||||
password_hash,
|
||||
must_change_password,
|
||||
is_active,
|
||||
created_by,
|
||||
created_at,
|
||||
last_login_at,
|
||||
0::int as passkey_count
|
||||
`
|
||||
|
||||
return mapUserAuthRecord(row)
|
||||
}
|
||||
|
||||
export async function updateUserProfile(input: {
|
||||
userId: string
|
||||
fullName: string
|
||||
phoneNumber: string
|
||||
role: UserRole
|
||||
}) {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
await sql`
|
||||
update users
|
||||
set
|
||||
full_name = ${input.fullName},
|
||||
phone_number = ${input.phoneNumber},
|
||||
role = ${input.role},
|
||||
updated_at = now()
|
||||
where id = ${input.userId}
|
||||
`
|
||||
|
||||
return getUserById(input.userId)
|
||||
}
|
||||
|
||||
export async function updateUserPassword(input: {
|
||||
userId: string
|
||||
passwordHash: string
|
||||
mustChangePassword: boolean
|
||||
}) {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
await sql`
|
||||
update users
|
||||
set
|
||||
password_hash = ${input.passwordHash},
|
||||
must_change_password = ${input.mustChangePassword},
|
||||
updated_at = now()
|
||||
where id = ${input.userId}
|
||||
`
|
||||
}
|
||||
|
||||
export async function updateLastLogin(userId: string) {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
await sql`
|
||||
update users
|
||||
set
|
||||
last_login_at = now(),
|
||||
updated_at = now()
|
||||
where id = ${userId}
|
||||
`
|
||||
}
|
||||
|
||||
export async function listUserPasskeys(userId: string): Promise<PasskeySummary[]> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const rows = await sql<DbPasskeyRow[]>`
|
||||
select
|
||||
id,
|
||||
user_id,
|
||||
credential_id,
|
||||
public_key,
|
||||
counter,
|
||||
device_type,
|
||||
backed_up,
|
||||
transports,
|
||||
label,
|
||||
created_at,
|
||||
last_used_at
|
||||
from user_passkeys
|
||||
where user_id = ${userId}
|
||||
order by created_at asc
|
||||
`
|
||||
|
||||
return rows.map(mapPasskeySummary)
|
||||
}
|
||||
|
||||
export async function getCredentialForVerification(credentialId: string): Promise<PasskeyRecord | null> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<DbPasskeyRow[]>`
|
||||
select
|
||||
id,
|
||||
user_id,
|
||||
credential_id,
|
||||
public_key,
|
||||
counter,
|
||||
device_type,
|
||||
backed_up,
|
||||
transports,
|
||||
label,
|
||||
created_at,
|
||||
last_used_at
|
||||
from user_passkeys
|
||||
where credential_id = ${credentialId}
|
||||
limit 1
|
||||
`
|
||||
|
||||
return row ? mapPasskeyRecord(row) : null
|
||||
}
|
||||
|
||||
export async function listCredentialDescriptors(userId: string) {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const rows = await sql<Pick<DbPasskeyRow, 'credential_id' | 'transports'>[]>`
|
||||
select credential_id, transports
|
||||
from user_passkeys
|
||||
where user_id = ${userId}
|
||||
order by created_at asc
|
||||
`
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.credential_id,
|
||||
transports: parseTransports(row.transports)
|
||||
}))
|
||||
}
|
||||
|
||||
export async function createUserPasskey(input: {
|
||||
userId: string
|
||||
credentialId: string
|
||||
publicKey: Uint8Array
|
||||
counter: number
|
||||
deviceType: CredentialDeviceType
|
||||
backedUp: boolean
|
||||
transports: AuthenticatorTransportFuture[]
|
||||
label: string
|
||||
}) {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
await sql`
|
||||
insert into user_passkeys (
|
||||
id,
|
||||
user_id,
|
||||
credential_id,
|
||||
public_key,
|
||||
counter,
|
||||
device_type,
|
||||
backed_up,
|
||||
transports,
|
||||
label
|
||||
)
|
||||
values (
|
||||
${randomUUID()},
|
||||
${input.userId},
|
||||
${input.credentialId},
|
||||
${encodeBase64Url(input.publicKey)},
|
||||
${input.counter},
|
||||
${input.deviceType},
|
||||
${input.backedUp},
|
||||
${sql.json(input.transports)},
|
||||
${input.label}
|
||||
)
|
||||
`
|
||||
}
|
||||
|
||||
export async function updateCredentialCounter(input: {
|
||||
credentialId: string
|
||||
counter: number
|
||||
deviceType: CredentialDeviceType
|
||||
backedUp: boolean
|
||||
}) {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
await sql`
|
||||
update user_passkeys
|
||||
set
|
||||
counter = ${input.counter},
|
||||
device_type = ${input.deviceType},
|
||||
backed_up = ${input.backedUp},
|
||||
last_used_at = now()
|
||||
where credential_id = ${input.credentialId}
|
||||
`
|
||||
}
|
||||
Reference in New Issue
Block a user