feat: Hybride Passkey-Freigabe für Skipper und Crew
Skipper (nur Owner) und Crew (WRITE-Collaborators) können Logbuchseiten optional per WebAuthn freigeben; klassische Unterschrift bleibt als Fallback. Signatur ist an den Eintrags-Hash gebunden, Export in CSV/PDF angepasst. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import authRouter from './routes/auth.js'
|
||||
import logbooksRouter from './routes/logbooks.js'
|
||||
import syncRouter from './routes/sync.js'
|
||||
import collaborationRouter from './routes/collaboration.js'
|
||||
import signRouter from './routes/sign.js'
|
||||
import { prisma } from './db.js'
|
||||
|
||||
dotenv.config()
|
||||
@@ -20,6 +21,7 @@ app.use('/api/auth', authRouter)
|
||||
app.use('/api/logbooks', logbooksRouter)
|
||||
app.use('/api/sync', syncRouter)
|
||||
app.use('/api/collaboration', collaborationRouter)
|
||||
app.use('/api/sign', signRouter)
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/api/health', async (req, res) => {
|
||||
|
||||
@@ -69,7 +69,50 @@ router.post('/', async (req: any, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 3. Delete a logbook
|
||||
// 3. Access metadata for a logbook (owner / collaborator)
|
||||
router.get('/:id/access', async (req: any, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const logbook = await prisma.logbook.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
collaborators: {
|
||||
where: { userId: req.userId }
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
collaborators: {
|
||||
where: { role: 'WRITE' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!logbook) {
|
||||
return res.status(404).json({ error: 'Logbook not found' })
|
||||
}
|
||||
|
||||
const isOwner = logbook.userId === req.userId
|
||||
const collaboration = logbook.collaborators[0]
|
||||
|
||||
if (!isOwner && !collaboration) {
|
||||
return res.status(403).json({ error: 'Forbidden: Access denied' })
|
||||
}
|
||||
|
||||
return res.json({
|
||||
isOwner,
|
||||
role: isOwner ? 'OWNER' : collaboration!.role,
|
||||
writeCollaboratorCount: logbook._count.collaborators
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching logbook access:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// 4. Delete a logbook
|
||||
router.delete('/:id', async (req: any, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { Router } from 'express'
|
||||
import crypto from 'crypto'
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse
|
||||
} from '@simplewebauthn/server'
|
||||
import { prisma } from '../db.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const rpID = process.env.RP_ID || 'localhost'
|
||||
const origin = process.env.ORIGIN || 'http://localhost:5173'
|
||||
|
||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
interface SigningContext {
|
||||
userId: string
|
||||
logbookId: string
|
||||
entryId: string
|
||||
entryHash: string
|
||||
role: 'skipper' | 'crew'
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const signingChallenges = new Map<string, SigningContext>()
|
||||
|
||||
function pruneExpiredChallenges() {
|
||||
const now = Date.now()
|
||||
for (const [key, ctx] of signingChallenges) {
|
||||
if (ctx.expiresAt <= now) signingChallenges.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const requireUser = (req: any, res: any, next: any) => {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
}
|
||||
req.userId = userId
|
||||
next()
|
||||
}
|
||||
|
||||
router.use(requireUser)
|
||||
|
||||
async function getLogbookWithAccess(logbookId: string, userId: string) {
|
||||
const logbook = await prisma.logbook.findUnique({
|
||||
where: { id: logbookId },
|
||||
include: {
|
||||
collaborators: {
|
||||
where: { userId }
|
||||
}
|
||||
}
|
||||
})
|
||||
if (!logbook) return null
|
||||
|
||||
const isOwner = logbook.userId === userId
|
||||
const collaboration = logbook.collaborators[0]
|
||||
if (!isOwner && !collaboration) return null
|
||||
|
||||
return { logbook, isOwner, collaboration }
|
||||
}
|
||||
|
||||
async function getAllowCredentialsForRole(
|
||||
logbookId: string,
|
||||
ownerUserId: string,
|
||||
role: 'skipper' | 'crew'
|
||||
) {
|
||||
if (role === 'skipper') {
|
||||
const credentials = await prisma.credential.findMany({
|
||||
where: { userId: ownerUserId }
|
||||
})
|
||||
return credentials.map((cred) => ({
|
||||
id: Buffer.from(cred.credentialId, 'base64url'),
|
||||
type: 'public-key' as const,
|
||||
transports: cred.transports as any[]
|
||||
}))
|
||||
}
|
||||
|
||||
const collaborations = await prisma.collaboration.findMany({
|
||||
where: { logbookId, role: 'WRITE' },
|
||||
select: { userId: true }
|
||||
})
|
||||
|
||||
const userIds = collaborations.map((c) => c.userId)
|
||||
if (userIds.length === 0) return []
|
||||
|
||||
const credentials = await prisma.credential.findMany({
|
||||
where: { userId: { in: userIds } }
|
||||
})
|
||||
|
||||
return credentials.map((cred) => ({
|
||||
id: Buffer.from(cred.credentialId, 'base64url'),
|
||||
type: 'public-key' as const,
|
||||
transports: cred.transports as any[]
|
||||
}))
|
||||
}
|
||||
|
||||
async function isAuthorizedSigner(
|
||||
logbookId: string,
|
||||
ownerUserId: string,
|
||||
signerUserId: string,
|
||||
role: 'skipper' | 'crew'
|
||||
): Promise<boolean> {
|
||||
if (role === 'skipper') {
|
||||
return signerUserId === ownerUserId
|
||||
}
|
||||
|
||||
const collaboration = await prisma.collaboration.findUnique({
|
||||
where: {
|
||||
logbookId_userId: { logbookId, userId: signerUserId }
|
||||
}
|
||||
})
|
||||
return collaboration?.role === 'WRITE'
|
||||
}
|
||||
|
||||
router.post('/options', async (req: any, res) => {
|
||||
try {
|
||||
pruneExpiredChallenges()
|
||||
|
||||
const { logbookId, entryId, entryHash, role } = req.body
|
||||
if (!logbookId || !entryId || !entryHash || !role) {
|
||||
return res.status(400).json({ error: 'logbookId, entryId, entryHash and role are required' })
|
||||
}
|
||||
if (role !== 'skipper' && role !== 'crew') {
|
||||
return res.status(400).json({ error: 'role must be skipper or crew' })
|
||||
}
|
||||
|
||||
const access = await getLogbookWithAccess(logbookId, req.userId)
|
||||
if (!access) {
|
||||
return res.status(403).json({ error: 'Forbidden: Access denied' })
|
||||
}
|
||||
|
||||
if (role === 'skipper' && !access.isOwner) {
|
||||
return res.status(403).json({ error: 'Forbidden: Only the logbook owner can sign as skipper' })
|
||||
}
|
||||
|
||||
const allowCredentials = await getAllowCredentialsForRole(
|
||||
logbookId,
|
||||
access.logbook.userId,
|
||||
role
|
||||
)
|
||||
|
||||
if (allowCredentials.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: role === 'crew'
|
||||
? 'No write collaborators with passkeys found'
|
||||
: 'No passkey credentials found for owner'
|
||||
})
|
||||
}
|
||||
|
||||
const nonce = crypto.randomBytes(16).toString('hex')
|
||||
const challengePayload = `${entryId}:${entryHash}:${role}:${nonce}`
|
||||
const derivedChallenge = crypto
|
||||
.createHash('sha256')
|
||||
.update(challengePayload)
|
||||
.digest('base64url')
|
||||
|
||||
signingChallenges.set(derivedChallenge, {
|
||||
userId: req.userId,
|
||||
logbookId,
|
||||
entryId,
|
||||
entryHash,
|
||||
role,
|
||||
expiresAt: Date.now() + CHALLENGE_TTL_MS
|
||||
})
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
challenge: derivedChallenge,
|
||||
allowCredentials,
|
||||
userVerification: 'required'
|
||||
})
|
||||
|
||||
return res.json(options)
|
||||
} catch (error: any) {
|
||||
console.error('Error generating sign options:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/verify', async (req: any, res) => {
|
||||
try {
|
||||
pruneExpiredChallenges()
|
||||
|
||||
const { credentialResponse, challenge, logbookId, entryId, entryHash, role } = req.body
|
||||
if (!credentialResponse || !challenge || !logbookId || !entryId || !entryHash || !role) {
|
||||
return res.status(400).json({ error: 'Missing required parameters' })
|
||||
}
|
||||
|
||||
const context = signingChallenges.get(challenge)
|
||||
if (!context || context.expiresAt <= Date.now()) {
|
||||
return res.status(400).json({ error: 'Challenge not found or expired' })
|
||||
}
|
||||
|
||||
if (
|
||||
context.logbookId !== logbookId ||
|
||||
context.entryId !== entryId ||
|
||||
context.entryHash !== entryHash ||
|
||||
context.role !== role
|
||||
) {
|
||||
return res.status(400).json({ error: 'Signing context mismatch' })
|
||||
}
|
||||
|
||||
signingChallenges.delete(challenge)
|
||||
|
||||
const access = await getLogbookWithAccess(logbookId, req.userId)
|
||||
if (!access) {
|
||||
return res.status(403).json({ error: 'Forbidden: Access denied' })
|
||||
}
|
||||
|
||||
if (role === 'skipper' && !access.isOwner) {
|
||||
return res.status(403).json({ error: 'Forbidden: Only the logbook owner can sign as skipper' })
|
||||
}
|
||||
|
||||
const dbCred = await prisma.credential.findUnique({
|
||||
where: { credentialId: credentialResponse.id },
|
||||
include: { user: true }
|
||||
})
|
||||
|
||||
if (!dbCred) {
|
||||
return res.status(400).json({ error: 'Credential not recognized' })
|
||||
}
|
||||
|
||||
const authorized = await isAuthorizedSigner(
|
||||
logbookId,
|
||||
access.logbook.userId,
|
||||
dbCred.userId,
|
||||
role
|
||||
)
|
||||
if (!authorized) {
|
||||
return res.status(403).json({ error: 'Forbidden: Signer not authorized for this role' })
|
||||
}
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response: credentialResponse,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
authenticator: {
|
||||
credentialID: Buffer.from(dbCred.credentialId, 'base64url'),
|
||||
credentialPublicKey: dbCred.publicKey,
|
||||
counter: Number(dbCred.counter)
|
||||
}
|
||||
})
|
||||
|
||||
if (!verification.verified || !verification.authenticationInfo) {
|
||||
return res.status(400).json({ error: 'Signature verification failed' })
|
||||
}
|
||||
|
||||
await prisma.credential.update({
|
||||
where: { id: dbCred.id },
|
||||
data: { counter: BigInt(verification.authenticationInfo.newCounter) }
|
||||
})
|
||||
|
||||
return res.json({
|
||||
verified: true,
|
||||
userId: dbCred.user.id,
|
||||
username: dbCred.user.username,
|
||||
credentialId: dbCred.credentialId,
|
||||
signedAt: new Date().toISOString()
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error verifying signature:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user