mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-08 02:25:36 +08:00
Implement authentication, waitlist, and logging upgrades
This commit is contained in:
@@ -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,
|
||||
|
||||
15
server/api/auth/invitations.get.ts
Normal file
15
server/api/auth/invitations.get.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { requireUserSession } from '../../utils/auth'
|
||||
import { InvitationCode } from '../../models/InvitationCode'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUserSession(event)
|
||||
const codes = await InvitationCode.find({ createdBy: user._id }).sort({ createdAt: -1 }).lean()
|
||||
return codes.map((code) => ({
|
||||
code: code.code,
|
||||
createdAt: code.createdAt,
|
||||
expiresAt: code.expiresAt,
|
||||
usedAt: code.usedAt,
|
||||
usedBy: code.usedBy ? String(code.usedBy) : null,
|
||||
}))
|
||||
})
|
||||
|
||||
49
server/api/auth/invitations.post.ts
Normal file
49
server/api/auth/invitations.post.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { createError } from 'h3'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { requireUserSession } from '../../utils/auth'
|
||||
import { InvitationCode } from '../../models/InvitationCode'
|
||||
|
||||
function generateCode() {
|
||||
return randomBytes(4).toString('hex').toUpperCase()
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUserSession(event)
|
||||
|
||||
const accountAgeMs = Date.now() - new Date(user.createdAt).getTime()
|
||||
const twoWeeksMs = 1000 * 60 * 60 * 24 * 14
|
||||
|
||||
if (accountAgeMs < twoWeeksMs) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Einladungscodes stehen nach 14 Tagen zur Verfügung' })
|
||||
}
|
||||
|
||||
if (user.invitationCodesIssued >= 2) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Limit von zwei Einladungscodes erreicht' })
|
||||
}
|
||||
|
||||
const activeCodes = await InvitationCode.countDocuments({ createdBy: user._id, usedBy: { $exists: false } })
|
||||
if (activeCodes >= 2) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Bitte vorhandene Einladungscodes zuerst nutzen' })
|
||||
}
|
||||
|
||||
const code = generateCode()
|
||||
const now = new Date()
|
||||
const expiresAt = new Date(now.getTime() + 1000 * 60 * 60 * 24 * 30)
|
||||
|
||||
await InvitationCode.create({
|
||||
code,
|
||||
createdBy: user._id,
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
})
|
||||
|
||||
user.invitationCodesIssued += 1
|
||||
await user.save()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
code,
|
||||
expiresAt,
|
||||
}
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
46
server/api/service/auth/login.post.ts
Normal file
46
server/api/service/auth/login.post.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createError, readBody } from 'h3'
|
||||
import { issueAuthTokens, verifyPassword } from '../../../utils/auth'
|
||||
import { User } from '../../../models/User'
|
||||
|
||||
interface LoginBody {
|
||||
email?: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<LoginBody>(event)
|
||||
const email = body.email?.trim().toLowerCase()
|
||||
const password = body.password?.trim()
|
||||
|
||||
if (!email || !password) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Bitte E-Mail und Passwort angeben' })
|
||||
}
|
||||
|
||||
const user = await User.findOne({ email })
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Ungültige Zugangsdaten' })
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, user.passwordHash)
|
||||
if (!valid) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Ungültige Zugangsdaten' })
|
||||
}
|
||||
|
||||
user.lastLoginAt = new Date()
|
||||
await user.save()
|
||||
|
||||
const tokens = await issueAuthTokens(event, user)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
accessToken: tokens.accessToken,
|
||||
user: {
|
||||
id: String(user._id),
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
10
server/api/service/auth/refresh.post.ts
Normal file
10
server/api/service/auth/refresh.post.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { rotateRefreshToken } from '../../../utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const tokens = await rotateRefreshToken(event)
|
||||
return {
|
||||
success: true,
|
||||
accessToken: tokens.accessToken,
|
||||
}
|
||||
})
|
||||
|
||||
78
server/api/service/auth/register.post.ts
Normal file
78
server/api/service/auth/register.post.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { createError, readBody } from 'h3'
|
||||
import { hashPassword, issueAuthTokens } from '../../../utils/auth'
|
||||
import { User } from '../../../models/User'
|
||||
import { InvitationCode } from '../../../models/InvitationCode'
|
||||
import { WaitlistEntry } from '../../../models/WaitlistEntry'
|
||||
|
||||
interface RegisterBody {
|
||||
email?: string
|
||||
password?: string
|
||||
name?: string
|
||||
invitationCode?: string
|
||||
acceptTerms?: boolean
|
||||
acceptPrivacy?: boolean
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<RegisterBody>(event)
|
||||
const email = body.email?.trim().toLowerCase()
|
||||
const password = body.password?.trim()
|
||||
const name = body.name?.trim()
|
||||
const code = body.invitationCode?.trim().toUpperCase()
|
||||
|
||||
if (!email || !password || !code) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Bitte E-Mail, Passwort und Einladungscode angeben' })
|
||||
}
|
||||
|
||||
if (!body.acceptPrivacy || !body.acceptTerms) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Bitte AGB und Datenschutz bestätigen' })
|
||||
}
|
||||
|
||||
const existingUser = await User.findOne({ email })
|
||||
if (existingUser) {
|
||||
throw createError({ statusCode: 409, statusMessage: 'Für diese E-Mail existiert bereits ein Konto' })
|
||||
}
|
||||
|
||||
const invitation = await InvitationCode.findOne({ code })
|
||||
if (!invitation) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Einladungscode nicht gefunden' })
|
||||
}
|
||||
if (invitation.usedBy) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Einladungscode wurde bereits verwendet' })
|
||||
}
|
||||
if (invitation.expiresAt && invitation.expiresAt < new Date()) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Einladungscode ist abgelaufen' })
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password)
|
||||
const now = new Date()
|
||||
|
||||
const user = await User.create({
|
||||
email,
|
||||
passwordHash,
|
||||
name,
|
||||
acceptedPrivacyAt: now,
|
||||
acceptedTermsAt: now,
|
||||
})
|
||||
|
||||
invitation.usedBy = user._id
|
||||
invitation.usedAt = now
|
||||
await invitation.save()
|
||||
|
||||
await WaitlistEntry.findOneAndUpdate({ email }, { activatedAt: now }).catch(() => undefined)
|
||||
|
||||
const tokens = await issueAuthTokens(event, user)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
accessToken: tokens.accessToken,
|
||||
user: {
|
||||
id: String(user._id),
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
30
server/api/service/invitations/[code].get.ts
Normal file
30
server/api/service/invitations/[code].get.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createError } from 'h3'
|
||||
import { InvitationCode } from '../../../models/InvitationCode'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { code } = event.context.params || {}
|
||||
if (!code) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Code fehlt' })
|
||||
}
|
||||
|
||||
const normalized = String(code).trim().toUpperCase()
|
||||
const invitation = await InvitationCode.findOne({ code: normalized })
|
||||
|
||||
if (!invitation) {
|
||||
return { valid: false }
|
||||
}
|
||||
if (invitation.usedBy) {
|
||||
return { valid: false, reason: 'used' }
|
||||
}
|
||||
if (invitation.expiresAt && invitation.expiresAt < new Date()) {
|
||||
return { valid: false, reason: 'expired' }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
code: invitation.code,
|
||||
createdAt: invitation.createdAt,
|
||||
expiresAt: invitation.expiresAt,
|
||||
}
|
||||
})
|
||||
|
||||
28
server/api/service/waitlist.get.ts
Normal file
28
server/api/service/waitlist.get.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { WaitlistEntry } from '../../models/WaitlistEntry'
|
||||
|
||||
function maskEmail(email: string) {
|
||||
const [user, domain] = email.split('@')
|
||||
if (!domain) return email
|
||||
const visible = user.slice(0, 2)
|
||||
return `${visible}${'•'.repeat(Math.max(user.length - 2, 1))}@${domain}`
|
||||
}
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const [count, latest] = await Promise.all([
|
||||
WaitlistEntry.countDocuments(),
|
||||
WaitlistEntry.find().sort({ joinedAt: -1 }).limit(8).lean(),
|
||||
])
|
||||
|
||||
const members = latest.map((entry) => ({
|
||||
name: entry.name || maskEmail(entry.email),
|
||||
email: maskEmail(entry.email),
|
||||
joinedAt: entry.joinedAt,
|
||||
activatedAt: entry.activatedAt ?? null,
|
||||
}))
|
||||
|
||||
return {
|
||||
count,
|
||||
members,
|
||||
}
|
||||
})
|
||||
|
||||
62
server/api/service/waitlist.post.ts
Normal file
62
server/api/service/waitlist.post.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { readBody, createError } from 'h3'
|
||||
import { WaitlistEntry } from '../../models/WaitlistEntry'
|
||||
|
||||
interface WaitlistRequestBody {
|
||||
email?: string
|
||||
name?: string
|
||||
notes?: string
|
||||
consentPrivacy?: boolean
|
||||
consentTerms?: boolean
|
||||
source?: string
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<WaitlistRequestBody>(event)
|
||||
const email = body.email?.trim().toLowerCase()
|
||||
const name = body.name?.trim()
|
||||
const notes = body.notes?.trim()
|
||||
const source = body.source?.trim() || 'landing'
|
||||
|
||||
if (!email) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'E-Mail wird benötigt' })
|
||||
}
|
||||
|
||||
if (!body.consentPrivacy || !body.consentTerms) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Zustimmung zu AGB und Datenschutz ist erforderlich' })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const existing = await WaitlistEntry.findOne({ email })
|
||||
|
||||
if (existing) {
|
||||
existing.name = name || existing.name
|
||||
existing.notes = notes || existing.notes
|
||||
existing.source = source
|
||||
existing.consentPrivacy = true
|
||||
existing.consentTerms = true
|
||||
await existing.save()
|
||||
return {
|
||||
success: true,
|
||||
alreadyJoined: true,
|
||||
joinedAt: existing.joinedAt,
|
||||
}
|
||||
}
|
||||
|
||||
const entry = await WaitlistEntry.create({
|
||||
email,
|
||||
name,
|
||||
notes,
|
||||
source,
|
||||
consentPrivacy: true,
|
||||
consentTerms: true,
|
||||
joinedAt: now,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
alreadyJoined: false,
|
||||
joinedAt: entry.joinedAt,
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
26
server/models/InvitationCode.ts
Normal file
26
server/models/InvitationCode.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import mongoose from 'mongoose'
|
||||
|
||||
const { Schema } = mongoose
|
||||
|
||||
export interface InvitationCodeDocument extends mongoose.Document {
|
||||
code: string
|
||||
createdBy: mongoose.Types.ObjectId
|
||||
createdAt: Date
|
||||
expiresAt?: Date
|
||||
usedBy?: mongoose.Types.ObjectId
|
||||
usedAt?: Date
|
||||
}
|
||||
|
||||
const invitationSchema = new mongoose.Schema<InvitationCodeDocument>({
|
||||
code: { type: String, required: true, unique: true, uppercase: true, trim: true },
|
||||
createdBy: { type: Schema.Types.ObjectId, ref: 'User', required: true },
|
||||
createdAt: { type: Date, default: () => new Date() },
|
||||
expiresAt: { type: Date },
|
||||
usedBy: { type: Schema.Types.ObjectId, ref: 'User' },
|
||||
usedAt: { type: Date },
|
||||
})
|
||||
|
||||
export const InvitationCode =
|
||||
(mongoose.models.InvitationCode as mongoose.Model<InvitationCodeDocument> | undefined) ||
|
||||
mongoose.model<InvitationCodeDocument>('InvitationCode', invitationSchema)
|
||||
|
||||
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)
|
||||
|
||||
32
server/models/User.ts
Normal file
32
server/models/User.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import mongoose from 'mongoose'
|
||||
|
||||
export interface UserDocument extends mongoose.Document {
|
||||
email: string
|
||||
passwordHash: string
|
||||
name?: string
|
||||
role: 'user' | 'admin'
|
||||
createdAt: Date
|
||||
lastLoginAt?: Date
|
||||
tokenVersion: number
|
||||
invitationCodesIssued: number
|
||||
acceptedTermsAt: Date
|
||||
acceptedPrivacyAt: Date
|
||||
}
|
||||
|
||||
const userSchema = new mongoose.Schema<UserDocument>({
|
||||
email: { type: String, required: true, unique: true, lowercase: true, trim: true },
|
||||
passwordHash: { type: String, required: true },
|
||||
name: { type: String, trim: true },
|
||||
role: { type: String, enum: ['user', 'admin'], default: 'user' },
|
||||
createdAt: { type: Date, default: () => new Date() },
|
||||
lastLoginAt: { type: Date },
|
||||
tokenVersion: { type: Number, default: 0 },
|
||||
invitationCodesIssued: { type: Number, default: 0 },
|
||||
acceptedTermsAt: { type: Date, required: true },
|
||||
acceptedPrivacyAt: { type: Date, required: true },
|
||||
})
|
||||
|
||||
export const User =
|
||||
(mongoose.models.User as mongoose.Model<UserDocument> | undefined) ||
|
||||
mongoose.model<UserDocument>('User', userSchema)
|
||||
|
||||
28
server/models/WaitlistEntry.ts
Normal file
28
server/models/WaitlistEntry.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import mongoose from 'mongoose'
|
||||
|
||||
export interface WaitlistEntryDocument extends mongoose.Document {
|
||||
email: string
|
||||
name?: string
|
||||
notes?: string
|
||||
source?: string
|
||||
consentPrivacy: boolean
|
||||
consentTerms: boolean
|
||||
joinedAt: Date
|
||||
activatedAt?: Date
|
||||
}
|
||||
|
||||
const waitlistSchema = new mongoose.Schema<WaitlistEntryDocument>({
|
||||
email: { type: String, required: true, lowercase: true, trim: true, unique: true },
|
||||
name: { type: String, trim: true },
|
||||
notes: { type: String, trim: true },
|
||||
source: { type: String, default: 'landing' },
|
||||
consentPrivacy: { type: Boolean, default: false },
|
||||
consentTerms: { type: Boolean, default: false },
|
||||
joinedAt: { type: Date, default: () => new Date() },
|
||||
activatedAt: { type: Date },
|
||||
})
|
||||
|
||||
export const WaitlistEntry =
|
||||
(mongoose.models.WaitlistEntry as mongoose.Model<WaitlistEntryDocument> | undefined) ||
|
||||
mongoose.model<WaitlistEntryDocument>('WaitlistEntry', waitlistSchema)
|
||||
|
||||
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' })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user