Implement authentication, waitlist, and logging upgrades

This commit is contained in:
Remi
2025-09-16 17:28:34 +02:00
parent a7eebf0baf
commit 19f253f53e
12 changed files with 620 additions and 46 deletions

68
app/composables/useApi.ts Normal file
View File

@@ -0,0 +1,68 @@
import { useAuthStore } from '~/stores/auth'
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
interface ApiRequestOptions<T = any> {
method?: HttpMethod
body?: T
query?: Record<string, any>
headers?: HeadersInit
auth?: boolean
}
export function useApi() {
const auth = useAuthStore()
const execute = async <T>(path: string, options: ApiRequestOptions = {}) => {
const { method = 'GET', body, query, headers = {}, auth: requiresAuth = true } = options
const computedHeaders: Record<string, string> = {
Accept: 'application/json',
...(headers as Record<string, string>),
}
if (requiresAuth && auth.accessToken) {
computedHeaders.Authorization = `Bearer ${auth.accessToken}`
}
const requestOptions: any = {
method,
headers: computedHeaders,
query,
body,
}
if (body && !(body instanceof FormData)) {
computedHeaders['Content-Type'] = 'application/json'
requestOptions.body = body
}
try {
return await $fetch<T>(path, requestOptions)
} catch (error: any) {
const status = error?.status || error?.response?.status
if (status === 401 && requiresAuth) {
const refreshed = await auth.tryRefresh()
if (refreshed) {
if (auth.accessToken) {
computedHeaders.Authorization = `Bearer ${auth.accessToken}`
}
return await $fetch<T>(path, requestOptions)
}
await auth.logout()
}
throw error
}
}
return {
request: execute,
get: <T>(path: string, options: ApiRequestOptions = {}) => execute<T>(path, { ...options, method: 'GET' }),
post: <T>(path: string, body?: any, options: ApiRequestOptions = {}) =>
execute<T>(path, { ...options, method: 'POST', body }),
put: <T>(path: string, body?: any, options: ApiRequestOptions = {}) =>
execute<T>(path, { ...options, method: 'PUT', body }),
del: <T>(path: string, options: ApiRequestOptions = {}) => execute<T>(path, { ...options, method: 'DELETE' }),
}
}

View File

@@ -460,8 +460,20 @@
<v-card class="bg-white/5 border border-white/10">
<v-card-text class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">Communication Log</h3>
<v-chip size="small" color="cyan" variant="outlined">{{ log.length }}</v-chip>
<div class="flex items-center gap-2">
<h3 class="text-lg font-semibold">Communication Log</h3>
<v-chip size="small" color="cyan" variant="outlined">{{ log.length }}</v-chip>
</div>
<v-btn
size="small"
variant="text"
color="cyan"
class="text-xs uppercase tracking-[0.2em]"
:disabled="log.length === 0"
@click="clearLog"
>
Leeren
</v-btn>
</div>
<div class="space-y-2 max-h-64 overflow-y-auto">
@@ -563,15 +575,22 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import useCommunicationsEngine from "../../shared/utils/communicationsEngine";
import { useAuthStore } from '~/stores/auth'
import { useApi } from '~/composables/useApi'
// Core State
const engine = useCommunicationsEngine()
const auth = useAuthStore()
const api = useApi()
const router = useRouter()
const {
currentState,
nextCandidates,
activeFrequency,
communicationLog: log,
clearCommunicationLog,
variables: vars,
flags,
flightContext,
@@ -586,6 +605,11 @@ const {
getStateDetails
} = engine
const clearLog = () => {
clearCommunicationLog()
lastTransmission.value = ''
}
// UI State
const currentScreen = ref<'login' | 'flightselect' | 'monitor'>('login')
const loading = ref(false)
@@ -608,6 +632,32 @@ const frequencies = ref({
standby: '118.100'
})
onMounted(async () => {
if (!auth.accessToken) {
const refreshed = await auth.tryRefresh()
if (!refreshed) {
router.push('/login')
return
}
}
if (!auth.user) {
await auth.fetchUser().catch((err) => {
console.error('Session initialisation failed', err)
router.push('/login')
})
}
})
watch(
() => auth.accessToken,
(token) => {
if (!token) {
router.push('/login')
}
}
)
// VATSIM Integration
const vatsimId = ref('1857215')
const flightPlans = ref<any[]>([])
@@ -821,17 +871,14 @@ const playAudioWithEffects = async (base64: string) => {
const speakPrepared = async (prepared: PreparedSpeech, options: SpeechOptions = {}) => {
try {
const response = await $fetch('/api/atc/say', {
method: 'POST',
body: {
text: options.useNormalizedForTTS === false ? prepared.plain : prepared.normalized,
level: signalStrength.value,
voice: options.voice || 'alloy',
speed: 0.95,
moduleId: 'pilot-monitoring',
lessonId: currentState.value?.id || 'general',
tag: options.tag || 'controller-reply'
}
const response = await api.post('/api/atc/say', {
text: options.useNormalizedForTTS === false ? prepared.plain : prepared.normalized,
level: signalStrength.value,
voice: options.voice || 'alloy',
speed: 0.95,
moduleId: 'pilot-monitoring',
lessonId: currentState.value?.id || 'general',
tag: options.tag || 'controller-reply'
})
if (response.success && response.audio) {
@@ -898,10 +945,7 @@ const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' =
const ctx = buildLLMContext(transcript)
try {
const decision = await $fetch('/api/llm/decide', {
method: 'POST',
body: ctx
})
const decision = await api.post('/api/llm/decide', ctx)
applyLLMDecision(decision)
@@ -922,7 +966,9 @@ const loadFlightPlans = async () => {
error.value = ''
try {
const response = await $fetch(`/api/vatsim/flightplans?cid=${vatsimId.value}`)
const response = await api.get('/api/vatsim/flightplans', {
query: { cid: vatsimId.value }
})
if (Array.isArray(response) && response.length > 0) {
flightPlans.value = response.slice(0, 10)
@@ -1039,22 +1085,19 @@ const processTransmission = async (audioBlob: Blob, isIntercom: boolean) => {
const base64Audio = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)))
if (isIntercom) {
const result = await $fetch('/api/atc/ptt', {
method: 'POST',
body: {
audio: base64Audio,
context: {
state_id: currentState.value?.id || 'INTERCOM',
state: {},
candidates: [],
variables: { callsign: vars.value.callsign },
flags: {}
},
moduleId: 'pilot-monitoring-intercom',
lessonId: 'intercom',
format: 'webm',
autoDecide: false
}
const result = await api.post('/api/atc/ptt', {
audio: base64Audio,
context: {
state_id: currentState.value?.id || 'INTERCOM',
state: {},
candidates: [],
variables: { callsign: vars.value.callsign },
flags: {}
},
moduleId: 'pilot-monitoring-intercom',
lessonId: 'intercom',
format: 'webm',
autoDecide: false
})
if (result.success) {
@@ -1072,16 +1115,13 @@ const processTransmission = async (audioBlob: Blob, isIntercom: boolean) => {
} else {
const ctx = buildLLMContext('')
const result = await $fetch('/api/atc/ptt', {
method: 'POST',
body: {
audio: base64Audio,
context: ctx,
moduleId: 'pilot-monitoring',
lessonId: currentState.value?.id || 'general',
format: 'webm',
autoDecide: false
}
const result = await api.post('/api/atc/ptt', {
audio: base64Audio,
context: ctx,
moduleId: 'pilot-monitoring',
lessonId: currentState.value?.id || 'general',
format: 'webm',
autoDecide: false
})
if (result.success) {

128
app/stores/auth.ts Normal file
View File

@@ -0,0 +1,128 @@
import { defineStore } from 'pinia'
interface Credentials {
email: string
password: string
}
interface RegisterPayload extends Credentials {
name?: string
invitationCode: string
acceptTerms: boolean
acceptPrivacy: boolean
}
interface AuthUser {
id: string
email: string
name?: string
role: string
createdAt: string
}
interface AuthState {
accessToken: string
user: AuthUser | null
initialized: boolean
}
function loadToken(): string {
if (typeof window === 'undefined') return ''
return localStorage.getItem('os_access_token') || ''
}
function persistToken(token: string) {
if (typeof window === 'undefined') return
if (token) {
localStorage.setItem('os_access_token', token)
} else {
localStorage.removeItem('os_access_token')
}
}
export const useAuthStore = defineStore('auth', {
state: (): AuthState => ({
accessToken: loadToken(),
user: null,
initialized: false,
}),
getters: {
isAuthenticated: (state) => Boolean(state.accessToken),
},
actions: {
setAccessToken(token: string) {
this.accessToken = token
persistToken(token)
},
setUser(user: AuthUser | null) {
this.user = user
},
async login(payload: Credentials) {
const response = await $fetch<{ accessToken: string; user: AuthUser }>('/api/service/auth/login', {
method: 'POST',
body: payload,
})
this.setAccessToken(response.accessToken)
this.setUser(response.user)
return response.user
},
async register(payload: RegisterPayload) {
const response = await $fetch<{ accessToken: string; user: AuthUser }>('/api/service/auth/register', {
method: 'POST',
body: payload,
})
this.setAccessToken(response.accessToken)
this.setUser(response.user)
return response.user
},
async tryRefresh() {
try {
const response = await $fetch<{ accessToken: string }>('/api/service/auth/refresh', {
method: 'POST',
})
this.setAccessToken(response.accessToken)
return true
} catch {
this.setAccessToken('')
return false
}
},
async fetchUser() {
if (!this.accessToken) {
this.setUser(null)
this.initialized = true
return null
}
try {
const user = await $fetch<AuthUser>('/api/auth/me', {
headers: { Authorization: `Bearer ${this.accessToken}` },
})
this.setUser(user)
this.initialized = true
return user
} catch (err) {
console.warn('Failed to load user session', err)
this.setAccessToken('')
this.setUser(null)
this.initialized = true
return null
}
},
async logout() {
try {
if (this.accessToken) {
await $fetch('/api/auth/logout', {
method: 'POST',
headers: { Authorization: `Bearer ${this.accessToken}` },
})
}
} catch (err) {
console.warn('Logout failed', err)
} finally {
this.setAccessToken('')
this.setUser(null)
}
},
},
})

View File

@@ -16,7 +16,15 @@ export default defineNuxtConfig({
openaiKey: process.env.OPENAI_API_KEY,
llmModel: process.env.LLM_MODEL || 'gpt-5-nano',
ttsModel: process.env.TTS_MODEL || 'tts-1',
public: {}
jwtSecret: process.env.JWT_SECRET,
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET,
mongoose: {
uri: process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/opensquawk',
options: {},
},
public: {
apiDocumentationUrl: '/api-docs',
},
},
vuetify: {
vuetifyOptions: {

View File

@@ -7,6 +7,8 @@ import { randomUUID } from "node:crypto";
import { execFile } from "node:child_process";
import { openai, routeDecision } from "../../utils/openai";
import { createReadStream } from "node:fs";
import { TransmissionLog } from "../../models/TransmissionLog";
import { getUserFromEvent } from "../../utils/auth";
interface PTTRequest {
audio: string; // Base64 encoded audio
@@ -120,6 +122,25 @@ export default defineEventHandler(async (event) => {
await rm(tmpAudioWav).catch(() => {});
}
try {
const user = await getUserFromEvent(event)
await TransmissionLog.create({
user: user?._id,
role: "pilot",
channel: "ptt",
direction: "incoming",
text: transcribedText,
metadata: {
moduleId: body.moduleId,
lessonId: body.lessonId,
decision,
autoDecide: shouldAutoDecide,
},
})
} catch (logError) {
console.warn("Transmission logging failed", logError)
}
const result: PTTResponse = {
success: true,
transcription: transcribedText

View File

@@ -6,6 +6,8 @@ import {join} from "node:path";
import {randomUUID} from "node:crypto";
import {normalize, TTS_MODEL, normalizeATC} from "../../utils/normalize";
import {request} from "node:http";
import { TransmissionLog } from "../../models/TransmissionLog";
import { getUserFromEvent } from "../../utils/auth";
// dotenv config
import {config} from "dotenv";
@@ -132,6 +134,29 @@ export default defineEventHandler(async (event) => {
// await writeFile(fileJson, JSON.stringify(meta, null, 2), "utf-8");
try {
const user = await getUserFromEvent(event)
await TransmissionLog.create({
user: user?._id,
role: "atc",
channel: "say",
direction: "outgoing",
text: raw,
normalized,
metadata: {
level,
voice,
speed,
moduleId: body?.moduleId || null,
lessonId: body?.lessonId || null,
tag: body?.tag || null,
radioQuality: radioQuality.description,
}
})
} catch (logError) {
console.warn("Transmission logging failed", logError)
}
return {
success: true,
id,

View File

@@ -0,0 +1,10 @@
import { clearRefreshTokenCookie, requireUserSession } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event)
user.tokenVersion += 1
await user.save()
clearRefreshTokenCookie(event)
return { success: true }
})

15
server/api/auth/me.get.ts Normal file
View File

@@ -0,0 +1,15 @@
import { requireUserSession } from '../../utils/auth'
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event)
return {
id: String(user._id),
email: user.email,
name: user.name,
role: user.role,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
invitationCodesIssued: user.invitationCodesIssued,
}
})

View File

@@ -0,0 +1,17 @@
import { defineEventHandler, getRequestURL } from 'h3'
import { requireUserSession } from '../utils/auth'
export default defineEventHandler(async (event) => {
const url = getRequestURL(event)
if (!url.pathname.startsWith('/api')) {
return
}
if (url.pathname.startsWith('/api/service/')) {
return
}
if (event.node.req.method === 'OPTIONS') {
return
}
await requireUserSession(event)
})

View File

@@ -0,0 +1,30 @@
import mongoose from 'mongoose'
const { Schema } = mongoose
export interface TransmissionLogDocument extends mongoose.Document {
user?: mongoose.Types.ObjectId
role: string
channel: 'ptt' | 'say' | 'text'
direction: 'incoming' | 'outgoing'
text: string
normalized?: string
metadata?: Record<string, any>
createdAt: Date
}
const transmissionSchema = new mongoose.Schema<TransmissionLogDocument>({
user: { type: Schema.Types.ObjectId, ref: 'User' },
role: { type: String, required: true },
channel: { type: String, enum: ['ptt', 'say', 'text'], required: true },
direction: { type: String, enum: ['incoming', 'outgoing'], required: true },
text: { type: String, required: true },
normalized: { type: String },
metadata: { type: Schema.Types.Mixed },
createdAt: { type: Date, default: () => new Date() },
})
export const TransmissionLog =
(mongoose.models.TransmissionLog as mongoose.Model<TransmissionLogDocument> | undefined) ||
mongoose.model<TransmissionLogDocument>('TransmissionLog', transmissionSchema)

206
server/utils/auth.ts Normal file
View File

@@ -0,0 +1,206 @@
import { createError, getHeader, H3Event, setCookie, deleteCookie } from 'h3'
import { useRuntimeConfig } from '#imports'
import { createHmac, randomBytes, timingSafeEqual, scrypt as _scrypt } from 'node:crypto'
import { promisify } from 'node:util'
import type { UserDocument } from '../models/User'
import { User } from '../models/User'
const scrypt = promisify(_scrypt) as (password: string | Buffer, salt: string | Buffer, keylen: number) => Promise<Buffer>
const ACCESS_TOKEN_TTL_SECONDS = 60 * 15
const REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 7
const REFRESH_COOKIE_NAME = 'os_refresh_token'
const PASSWORD_SALT_BYTES = 16
const PASSWORD_KEYLEN = 64
function getSecrets() {
const config = useRuntimeConfig()
if (!config.jwtSecret) {
throw new Error('JWT secret missing bitte JWT_SECRET in .env setzen')
}
return {
accessSecret: config.jwtSecret as string,
refreshSecret: (config.jwtRefreshSecret as string) || (config.jwtSecret as string),
}
}
function base64url(buffer: Buffer) {
return buffer.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
}
function fromBase64url(input: string) {
let sanitized = input.replace(/-/g, '+').replace(/_/g, '/')
const pad = sanitized.length % 4
if (pad === 2) sanitized += '=='
else if (pad === 3) sanitized += '='
else if (pad !== 0) sanitized += '==='
return Buffer.from(sanitized, 'base64')
}
function createJwtToken(payload: Record<string, any>, secret: string, ttlSeconds: number) {
const header = { alg: 'HS256', typ: 'JWT' }
const now = Math.floor(Date.now() / 1000)
const body = { ...payload, iat: now, exp: now + ttlSeconds }
const encodedHeader = base64url(Buffer.from(JSON.stringify(header)))
const encodedPayload = base64url(Buffer.from(JSON.stringify(body)))
const data = `${encodedHeader}.${encodedPayload}`
const signature = createHmac('sha256', secret).update(data).digest()
return `${data}.${base64url(signature)}`
}
function verifyJwtToken(token: string, secret: string) {
const parts = token.split('.')
if (parts.length !== 3) throw new Error('Malformed token')
const [encodedHeader, encodedPayload, signature] = parts
const data = `${encodedHeader}.${encodedPayload}`
const expectedSignature = createHmac('sha256', secret).update(data).digest()
const receivedSignature = fromBase64url(signature)
if (receivedSignature.length !== expectedSignature.length || !timingSafeEqual(receivedSignature, expectedSignature)) {
throw new Error('Invalid signature')
}
const header = JSON.parse(fromBase64url(encodedHeader).toString('utf8'))
if (header.alg !== 'HS256') throw new Error('Unsupported algorithm')
const payload = JSON.parse(fromBase64url(encodedPayload).toString('utf8'))
if (payload.exp && Math.floor(Date.now() / 1000) > payload.exp) {
throw new Error('Token expired')
}
return payload as Record<string, any>
}
export async function hashPassword(password: string) {
const salt = randomBytes(PASSWORD_SALT_BYTES)
const derived = await scrypt(password, salt, PASSWORD_KEYLEN)
return `${salt.toString('hex')}.${derived.toString('hex')}`
}
export async function verifyPassword(password: string, stored: string) {
const [saltHex, hashHex] = stored.split('.')
if (!saltHex || !hashHex) return false
const salt = Buffer.from(saltHex, 'hex')
const expected = Buffer.from(hashHex, 'hex')
const derived = await scrypt(password, salt, expected.length)
if (derived.length !== expected.length) return false
return timingSafeEqual(derived, expected)
}
export function createAccessToken(user: UserDocument) {
const { accessSecret } = getSecrets()
return createJwtToken(
{
sub: String(user._id),
email: user.email,
version: user.tokenVersion,
},
accessSecret,
ACCESS_TOKEN_TTL_SECONDS,
)
}
export function createRefreshToken(user: UserDocument) {
const { refreshSecret } = getSecrets()
return createJwtToken(
{
sub: String(user._id),
type: 'refresh',
version: user.tokenVersion,
},
refreshSecret,
REFRESH_TOKEN_TTL_SECONDS,
)
}
export function setRefreshTokenCookie(event: H3Event, token: string) {
setCookie(event, REFRESH_COOKIE_NAME, token, {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: REFRESH_TOKEN_TTL_SECONDS,
secure: process.env.NODE_ENV === 'production',
})
}
export function clearRefreshTokenCookie(event: H3Event) {
deleteCookie(event, REFRESH_COOKIE_NAME, { path: '/' })
}
function parseAuthorizationHeader(event: H3Event) {
const header = getHeader(event, 'authorization')
if (!header) return null
const [scheme, token] = header.split(' ')
if (!token || scheme?.toLowerCase() !== 'bearer') return null
return token
}
export async function resolveUserFromToken(event: H3Event) {
const token = parseAuthorizationHeader(event)
if (!token) return null
try {
const { accessSecret } = getSecrets()
const payload = verifyJwtToken(token, accessSecret)
if (!payload?.sub) return null
const user = await User.findById(payload.sub)
if (!user) return null
if (typeof payload.version === 'number' && payload.version !== user.tokenVersion) {
return null
}
return user
} catch {
return null
}
}
export async function requireUserSession(event: H3Event) {
if (event.context?.user) {
return event.context.user as UserDocument
}
const user = await resolveUserFromToken(event)
if (!user) {
throw createError({ statusCode: 401, statusMessage: 'Authentication required' })
}
event.context.user = user
return user
}
export async function getUserFromEvent(event: H3Event) {
if (event.context?.user) return event.context.user as UserDocument
const user = await resolveUserFromToken(event)
if (user) {
event.context.user = user
}
return user
}
export async function issueAuthTokens(event: H3Event, user: UserDocument) {
const accessToken = createAccessToken(user)
const refreshToken = createRefreshToken(user)
setRefreshTokenCookie(event, refreshToken)
return { accessToken }
}
export async function rotateRefreshToken(event: H3Event) {
const cookieHeader = event.node.req.headers?.cookie || ''
const match = cookieHeader.split(';').map((p) => p.trim()).find((p) => p.startsWith(`${REFRESH_COOKIE_NAME}=`))
if (!match) {
throw createError({ statusCode: 401, statusMessage: 'No refresh token present' })
}
const token = match.substring(REFRESH_COOKIE_NAME.length + 1)
try {
const { refreshSecret } = getSecrets()
const payload = verifyJwtToken(token, refreshSecret)
if (!payload?.sub || payload.type !== 'refresh') {
throw new Error('Invalid token payload')
}
const user = await User.findById(payload.sub)
if (!user) {
throw new Error('User missing')
}
if (typeof payload.version === 'number' && payload.version !== user.tokenVersion) {
throw new Error('Token version mismatch')
}
return issueAuthTokens(event, user)
} catch (err) {
clearRefreshTokenCookie(event)
throw createError({ statusCode: 401, statusMessage: 'Refresh token invalid' })
}
}

View File

@@ -477,7 +477,12 @@ export default function useCommunicationsEngine() {
}
function exposeCtx() {
return { variables: variables.value, flags: flags.value }
return {
...variables.value,
...flags.value,
variables: variables.value,
flags: flags.value,
}
}
function exposeCtxFlat() {
@@ -559,6 +564,7 @@ export default function useCommunicationsEngine() {
nextCandidates,
activeFrequency,
communicationLog: readonly(communicationLog),
clearCommunicationLog: () => { communicationLog.value = [] },
// pm_alt.vue Integration
flightContext: readonly(flightContext),