mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 08:55:54 +08:00
Implement authentication, waitlist, and logging upgrades
This commit is contained in:
68
app/composables/useApi.ts
Normal file
68
app/composables/useApi.ts
Normal 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' }),
|
||||
}
|
||||
}
|
||||
|
||||
128
app/pages/pm.vue
128
app/pages/pm.vue
@@ -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
128
app/stores/auth.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
10
server/api/auth/logout.post.ts
Normal file
10
server/api/auth/logout.post.ts
Normal 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
15
server/api/auth/me.get.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
|
||||
17
server/middleware/auth.global.ts
Normal file
17
server/middleware/auth.global.ts
Normal 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)
|
||||
})
|
||||
|
||||
30
server/models/TransmissionLog.ts
Normal file
30
server/models/TransmissionLog.ts
Normal 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
206
server/utils/auth.ts
Normal 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' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user