155 lines
4.0 KiB
JavaScript
155 lines
4.0 KiB
JavaScript
// useraccesstokens plugin, TokenManager class
|
|
// Provides token management functionality for the security enhancer
|
|
|
|
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 => JSON.stringify(t.user) === JSON.stringify(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()
|
|
|
|
const filteredTokens = tokens.filter(t => {
|
|
return JSON.stringify(t.user) === JSON.stringify(user)
|
|
})
|
|
|
|
return filteredTokens.map(t => {
|
|
const { tokenHash, ...safeToken } = t
|
|
return safeToken
|
|
})
|
|
}
|
|
|
|
async revokeToken(user, name) {
|
|
const tokens = await this.loadTokens()
|
|
const tokenIndex = tokens.findIndex(t => JSON.stringify(t.user) === JSON.stringify(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 => JSON.stringify(t.user) === JSON.stringify(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
|
|
}
|
|
}
|
|
|
|
export { TokenManager }
|