First working version
This commit is contained in:
271
server/server.js
Normal file
271
server/server.js
Normal file
@ -0,0 +1,271 @@
|
||||
// useraccesstokens plugin, server-side component
|
||||
// These handlers are launched with the wiki server.
|
||||
|
||||
import fs from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
import bcrypt from 'bcrypt'
|
||||
|
||||
const SALT_ROUNDS = 12
|
||||
const TOKEN_PREFIX = 'fwuat-'
|
||||
const TOKEN_BYTES = 32
|
||||
|
||||
class TokenManager {
|
||||
constructor(statusPath) {
|
||||
this.statusPath = statusPath
|
||||
this.tokensFile = path.join(statusPath, 'user-access-tokens.json')
|
||||
}
|
||||
|
||||
async ensureTokensFile() {
|
||||
try {
|
||||
await fsp.access(this.tokensFile)
|
||||
} catch {
|
||||
await fsp.writeFile(this.tokensFile, JSON.stringify([], null, 2))
|
||||
}
|
||||
}
|
||||
|
||||
async loadTokens() {
|
||||
await this.ensureTokensFile()
|
||||
const data = await fsp.readFile(this.tokensFile, 'utf8')
|
||||
return JSON.parse(data)
|
||||
}
|
||||
|
||||
async saveTokens(tokens) {
|
||||
await fsp.writeFile(this.tokensFile, JSON.stringify(tokens, null, 2))
|
||||
}
|
||||
|
||||
generateToken() {
|
||||
const tokenBytes = crypto.randomBytes(TOKEN_BYTES)
|
||||
const token = TOKEN_PREFIX + tokenBytes.toString('base64url')
|
||||
return token
|
||||
}
|
||||
|
||||
async hashToken(token) {
|
||||
return await bcrypt.hash(token, SALT_ROUNDS)
|
||||
}
|
||||
|
||||
async verifyToken(token, hash) {
|
||||
return await bcrypt.compare(token, hash)
|
||||
}
|
||||
|
||||
getDisplayHint(token) {
|
||||
return token.slice(-4)
|
||||
}
|
||||
|
||||
async createToken(user, name, expiresInDays = null) {
|
||||
const tokens = await this.loadTokens()
|
||||
|
||||
// Check if token name already exists for this user
|
||||
const existingToken = tokens.find(t => t.user === user && t.name === name && !t.revoked)
|
||||
if (existingToken) {
|
||||
throw new Error(`Token with name "${name}" already exists`)
|
||||
}
|
||||
|
||||
const token = this.generateToken()
|
||||
const tokenHash = await this.hashToken(token)
|
||||
const created = new Date().toISOString()
|
||||
const expires = expiresInDays ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000).toISOString() : null
|
||||
|
||||
const tokenRecord = {
|
||||
name,
|
||||
user,
|
||||
tokenHash,
|
||||
displayHint: this.getDisplayHint(token),
|
||||
created,
|
||||
expires,
|
||||
lastUsed: null,
|
||||
revoked: false,
|
||||
scopes: ['site:read', 'site:write'] // Default scopes
|
||||
}
|
||||
|
||||
tokens.push(tokenRecord)
|
||||
await this.saveTokens(tokens)
|
||||
|
||||
return { token, record: tokenRecord }
|
||||
}
|
||||
|
||||
async listTokens(user) {
|
||||
const tokens = await this.loadTokens()
|
||||
return tokens
|
||||
.filter(t => t.user === user)
|
||||
.map(t => {
|
||||
const { tokenHash, ...safeToken } = t
|
||||
return safeToken
|
||||
})
|
||||
}
|
||||
|
||||
async revokeToken(user, name) {
|
||||
const tokens = await this.loadTokens()
|
||||
const tokenIndex = tokens.findIndex(t => t.user === user && t.name === name)
|
||||
|
||||
if (tokenIndex === -1) {
|
||||
throw new Error(`Token "${name}" not found`)
|
||||
}
|
||||
|
||||
tokens[tokenIndex].revoked = true
|
||||
await this.saveTokens(tokens)
|
||||
|
||||
return tokens[tokenIndex]
|
||||
}
|
||||
|
||||
async deleteToken(user, name) {
|
||||
const tokens = await this.loadTokens()
|
||||
const tokenIndex = tokens.findIndex(t => t.user === user && t.name === name)
|
||||
|
||||
if (tokenIndex === -1) {
|
||||
throw new Error(`Token "${name}" not found`)
|
||||
}
|
||||
|
||||
const deletedToken = tokens.splice(tokenIndex, 1)[0]
|
||||
await this.saveTokens(tokens)
|
||||
|
||||
return deletedToken
|
||||
}
|
||||
|
||||
async validateToken(token) {
|
||||
if (!token.startsWith(TOKEN_PREFIX)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tokens = await this.loadTokens()
|
||||
const now = new Date()
|
||||
|
||||
for (const tokenRecord of tokens) {
|
||||
if (tokenRecord.revoked) continue
|
||||
if (tokenRecord.expires && new Date(tokenRecord.expires) < now) continue
|
||||
|
||||
const isValid = await this.verifyToken(token, tokenRecord.tokenHash)
|
||||
if (isValid) {
|
||||
// Update last used timestamp
|
||||
tokenRecord.lastUsed = now.toISOString()
|
||||
await this.saveTokens(tokens)
|
||||
return tokenRecord
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const startServer = async function (params) {
|
||||
const { app, argv } = params
|
||||
|
||||
// Initialize token manager
|
||||
const tokenManager = new TokenManager(argv.status)
|
||||
|
||||
// Middleware to check if user is authenticated
|
||||
const authenticated = function (req, res, next) {
|
||||
if (!app.securityhandler.isAuthorized(req)) {
|
||||
return res.status(401).json({ error: 'Must be authenticated' })
|
||||
}
|
||||
return next()
|
||||
}
|
||||
|
||||
// Middleware to get current user
|
||||
const getCurrentUser = function (req) {
|
||||
return app.securityhandler.getUser(req)
|
||||
}
|
||||
|
||||
// API Routes
|
||||
|
||||
// POST /plugin/useraccesstokens/create - Create a new token
|
||||
app.post('/plugin/useraccesstokens/create', authenticated, async (req, res) => {
|
||||
try {
|
||||
const user = getCurrentUser(req)
|
||||
const { name, expiresInDays } = req.body
|
||||
|
||||
if (!name || typeof name !== 'string' || name.trim() === '') {
|
||||
return res.status(400).json({ error: 'Token name is required' })
|
||||
}
|
||||
|
||||
const result = await tokenManager.createToken(user, name.trim(), expiresInDays)
|
||||
|
||||
res.json({
|
||||
token: result.token,
|
||||
name: result.record.name,
|
||||
displayHint: result.record.displayHint,
|
||||
created: result.record.created,
|
||||
expires: result.record.expires,
|
||||
scopes: result.record.scopes
|
||||
})
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /plugin/useraccesstokens/list - List user's tokens
|
||||
app.get('/plugin/useraccesstokens/list', authenticated, async (req, res) => {
|
||||
try {
|
||||
const user = getCurrentUser(req)
|
||||
const tokens = await tokenManager.listTokens(user)
|
||||
res.json(tokens)
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /plugin/useraccesstokens/revoke - Revoke a token
|
||||
app.post('/plugin/useraccesstokens/revoke', authenticated, async (req, res) => {
|
||||
try {
|
||||
const user = getCurrentUser(req)
|
||||
const { name } = req.body
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Token name is required' })
|
||||
}
|
||||
|
||||
await tokenManager.revokeToken(user, name)
|
||||
res.json({ message: `Token "${name}" revoked successfully` })
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /plugin/useraccesstokens/delete - Delete a token
|
||||
app.delete('/plugin/useraccesstokens/delete', authenticated, async (req, res) => {
|
||||
try {
|
||||
const user = getCurrentUser(req)
|
||||
const { name } = req.body
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Token name is required' })
|
||||
}
|
||||
|
||||
await tokenManager.deleteToken(user, name)
|
||||
res.json({ message: `Token "${name}" deleted successfully` })
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Middleware for token-based authentication
|
||||
app.use('/api', async (req, res, next) => {
|
||||
const authHeader = req.headers.authorization
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7)
|
||||
|
||||
try {
|
||||
const tokenRecord = await tokenManager.validateToken(token)
|
||||
if (tokenRecord) {
|
||||
// Set user context for token-based requests
|
||||
req.tokenAuth = {
|
||||
user: tokenRecord.user,
|
||||
scopes: tokenRecord.scopes,
|
||||
tokenName: tokenRecord.name
|
||||
}
|
||||
return next()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token validation error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
console.log('UserAccessTokens plugin server started')
|
||||
}
|
||||
|
||||
export { startServer, TokenManager }
|
Reference in New Issue
Block a user