488 lines
15 KiB
TypeScript
488 lines
15 KiB
TypeScript
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import type { RegisterServerOptions, MUserDefault } from '@peertube/peertube-types'
|
|
import type { ProsodyAuthentInfos, LivechatToken } from '../../../shared/lib/types'
|
|
import { getProsodyDomain } from './config/domain'
|
|
import { getUserNickname } from '../helpers'
|
|
import { createCipheriv, createDecipheriv, randomBytes, Encoding, randomFillSync } from 'node:crypto'
|
|
import * as path from 'node:path'
|
|
import * as fs from 'node:fs'
|
|
|
|
interface Password {
|
|
password: string
|
|
validity: number
|
|
}
|
|
|
|
type SavedLivechatToken = Omit<LivechatToken, 'jid' | 'nickname' | 'password'> & {
|
|
encryptedPassword: string
|
|
}
|
|
|
|
interface SavedUserData {
|
|
userId: number
|
|
tokens: SavedLivechatToken[]
|
|
}
|
|
|
|
interface LivechatTokenInfos {
|
|
userId: number
|
|
tokens: LivechatToken[]
|
|
}
|
|
|
|
async function getRandomBytes (size: number): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
randomBytes(size, (err, buf) => {
|
|
if (err) return reject(err)
|
|
|
|
return resolve(buf)
|
|
})
|
|
})
|
|
}
|
|
|
|
function generatePassword (length: number): string {
|
|
const characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
|
return Array.from(randomFillSync(new Uint32Array(length)))
|
|
.map((x) => characters[x % characters.length])
|
|
.join('')
|
|
}
|
|
|
|
let singleton: LivechatProsodyAuth | undefined
|
|
|
|
/**
|
|
* This class handles user/passwords for Peertube users to the Prosody service.
|
|
*
|
|
* There are 2 types of authentication:
|
|
* * temporary passwords, generated when the user connects with the Peertube authentication
|
|
* * livechat-token, that are used to generate long-term token to connect to the chat
|
|
*
|
|
* The livechat tokens password are encrypted in data files.
|
|
* The associated secret key is in the database.
|
|
* This is to ensure an additional security level: if an attacker has access to file system, they also must have access
|
|
* to DB to get the secret key and decrypt passwords.
|
|
*/
|
|
export class LivechatProsodyAuth {
|
|
private readonly _options: RegisterServerOptions
|
|
private readonly _prosodyDomain: string
|
|
private _userTokensEnabled: boolean
|
|
private readonly _tokensPath: string
|
|
private readonly _passwords: Map<string, Password> = new Map()
|
|
private readonly _tokensInfoByJID: Map<string, LivechatTokenInfos | undefined> = new Map()
|
|
private readonly _secretKey: string
|
|
protected readonly _logger: {
|
|
debug: (s: string) => void
|
|
info: (s: string) => void
|
|
warn: (s: string) => void
|
|
error: (s: string) => void
|
|
}
|
|
|
|
private readonly _encryptionOptions = {
|
|
algorithm: 'aes256' as string,
|
|
inputEncoding: 'utf8' as Encoding,
|
|
outputEncoding: 'hex' as Encoding
|
|
}
|
|
|
|
constructor (options: RegisterServerOptions, prosodyDomain: string, userTokensEnabled: boolean, secretKey: string) {
|
|
this._options = options
|
|
this._prosodyDomain = prosodyDomain
|
|
this._userTokensEnabled = userTokensEnabled
|
|
this._secretKey = secretKey
|
|
this._tokensPath = path.join(
|
|
options.peertubeHelpers.plugin.getDataDirectoryPath(),
|
|
'tokens'
|
|
)
|
|
this._logger = {
|
|
debug: (s) => options.peertubeHelpers.logger.debug('[LivechatProsodyAuth] ' + s),
|
|
info: (s) => options.peertubeHelpers.logger.info('[LivechatProsodyAuth] ' + s),
|
|
warn: (s) => options.peertubeHelpers.logger.warn('[LivechatProsodyAuth] ' + s),
|
|
error: (s) => options.peertubeHelpers.logger.error('[LivechatProsodyAuth] ' + s)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A user can get a password thanks to a call to getUserTempPassword (see api user/auth).
|
|
*
|
|
* Then, we can test that the user exists with userRegistered, and test password with checkUserPassword.
|
|
*
|
|
* Passwords are randomly generated.
|
|
*
|
|
* These password are stored internally in a global variable, and are valid for 24h.
|
|
* Each call to getUserTempPassword extends the validity by 24h.
|
|
*
|
|
* Prosody will use an API call to api/user/check_password to check the password transmitted by the frontend.
|
|
* @param user username
|
|
* @returns the password to use to connect to Prosody
|
|
*/
|
|
public async getUserTempPassword (user: MUserDefault): Promise<ProsodyAuthentInfos | undefined> {
|
|
const normalizedUsername = this._normalizeUsername(user)
|
|
if (!normalizedUsername) {
|
|
return undefined
|
|
}
|
|
|
|
const password = this._getOrSetTempPassword(normalizedUsername)
|
|
const nickname: string | undefined = await getUserNickname(this._options, user)
|
|
return {
|
|
jid: normalizedUsername + '@' + this._prosodyDomain,
|
|
password: password,
|
|
nickname: nickname,
|
|
type: 'peertube'
|
|
}
|
|
}
|
|
|
|
public async userRegistered (normalizedUsername: string): Promise<boolean> {
|
|
const entry = this._getAndClean(normalizedUsername)
|
|
if (entry) {
|
|
return true
|
|
}
|
|
if (this._userTokensEnabled) {
|
|
try {
|
|
const tokensInfo = await this._getTokensInfoForJID(normalizedUsername + '@' + this._prosodyDomain)
|
|
if (!tokensInfo || !tokensInfo.tokens.length) {
|
|
return false
|
|
}
|
|
// Checking that the user is valid:
|
|
if (await this._userIdValid(tokensInfo.userId)) {
|
|
return true
|
|
}
|
|
} catch (err) {
|
|
this._logger.error(err as string)
|
|
return false
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
public async checkUserPassword (normalizedUsername: string, password: string): Promise<boolean> {
|
|
const entry = this._getAndClean(normalizedUsername)
|
|
if (entry && entry.password === password) {
|
|
return true
|
|
}
|
|
if (this._userTokensEnabled) {
|
|
try {
|
|
const tokensInfo = await this._getTokensInfoForJID(normalizedUsername + '@' + this._prosodyDomain)
|
|
if (!tokensInfo || !tokensInfo.tokens.length) {
|
|
return false
|
|
}
|
|
// Checking that the user is valid:
|
|
if (!await this._userIdValid(tokensInfo.userId)) {
|
|
return false
|
|
}
|
|
|
|
// Is the password in tokens?
|
|
if (tokensInfo.tokens.find((t) => t.password === password)) {
|
|
return true
|
|
}
|
|
} catch (err) {
|
|
this._logger.error(err as string)
|
|
return false
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Returns the long-term livechat tokens for the given user.
|
|
* Returns undefined if the user is invalid.
|
|
* @param user the user
|
|
*/
|
|
public async getUserTokens (user: MUserDefault): Promise<LivechatToken[] | undefined> {
|
|
if (!this._userTokensEnabled) {
|
|
return undefined
|
|
}
|
|
if (!user || !user.id) {
|
|
return undefined
|
|
}
|
|
if (user.blocked) {
|
|
return undefined
|
|
}
|
|
const normalizedUsername = this._normalizeUsername(user)
|
|
if (!normalizedUsername) {
|
|
return undefined
|
|
}
|
|
const nickname: string | undefined = await getUserNickname(this._options, user)
|
|
const jid = normalizedUsername + '@' + this._prosodyDomain
|
|
const tokensInfo = await this._getTokensInfoForJID(jid)
|
|
if (!tokensInfo) { return [] }
|
|
|
|
if (tokensInfo.userId !== user.id) {
|
|
return undefined
|
|
}
|
|
|
|
const tokens = []
|
|
for (const token of tokensInfo.tokens) {
|
|
// Cloning, and adding the nickname.
|
|
tokens.push(
|
|
Object.assign({}, token, {
|
|
nickname
|
|
})
|
|
)
|
|
}
|
|
return tokens
|
|
}
|
|
|
|
/**
|
|
* Enable or disable user tokens. Must be called when the settings change.
|
|
* @param enabled
|
|
*/
|
|
public setUserTokensEnabled (enabled: boolean): void {
|
|
this._userTokensEnabled = !!enabled
|
|
if (!this.userRegistered) {
|
|
// Empty the cache:
|
|
this._tokensInfoByJID.clear()
|
|
}
|
|
}
|
|
|
|
public async createUserToken (user: MUserDefault, label: string): Promise<LivechatToken | undefined> {
|
|
if (!this._userTokensEnabled) {
|
|
return undefined
|
|
}
|
|
if (!user || !user.id) {
|
|
return undefined
|
|
}
|
|
if (user.blocked) {
|
|
return undefined
|
|
}
|
|
const normalizedUsername = this._normalizeUsername(user)
|
|
if (!normalizedUsername) {
|
|
return undefined
|
|
}
|
|
const nickname: string | undefined = await getUserNickname(this._options, user)
|
|
const jid = normalizedUsername + '@' + this._prosodyDomain
|
|
const token = await this._createToken(user.id, jid, label)
|
|
|
|
token.nickname = nickname
|
|
return token
|
|
}
|
|
|
|
public async revokeUserToken (user: MUserDefault, id: number): Promise<boolean> {
|
|
if (!this._userTokensEnabled) {
|
|
return false
|
|
}
|
|
if (!user || !user.id) {
|
|
return false
|
|
}
|
|
if (user.blocked) {
|
|
return false
|
|
}
|
|
const normalizedUsername = this._normalizeUsername(user)
|
|
if (!normalizedUsername) {
|
|
return false
|
|
}
|
|
const jid = normalizedUsername + '@' + this._prosodyDomain
|
|
const tokensInfo = await this._getTokensInfoForJID(jid)
|
|
|
|
if (!tokensInfo) {
|
|
// No saved token, consider ok.
|
|
return true
|
|
}
|
|
|
|
if (tokensInfo.userId !== user.id) {
|
|
return false
|
|
}
|
|
|
|
await this._saveTokens(user.id, jid, tokensInfo.tokens.filter(t => t.id !== id))
|
|
return true
|
|
}
|
|
|
|
private _getOrSetTempPassword (normalizedUsername: string): string {
|
|
const entry = this._getAndClean(normalizedUsername)
|
|
const validity = Date.now() + (24 * 60 * 60 * 1000) // 24h
|
|
if (entry) {
|
|
entry.validity = validity
|
|
return entry.password
|
|
}
|
|
|
|
const password = generatePassword(20)
|
|
this._passwords.set(normalizedUsername, {
|
|
password: password,
|
|
validity: validity
|
|
})
|
|
return password
|
|
}
|
|
|
|
private _normalizeUsername (user: MUserDefault): string | undefined {
|
|
if (!user || !user.id) {
|
|
return undefined
|
|
}
|
|
if (user.blocked) {
|
|
return undefined
|
|
}
|
|
// NB 2021-08-05: Peertube usernames should be lowercase. But it seems that
|
|
// in some old installation, there can be uppercase letters in usernames.
|
|
// When Peertube checks username unicity, it does a lowercase search.
|
|
// So it feels safe to normalize usernames like so:
|
|
const normalizedUsername = user.username.toLowerCase()
|
|
return normalizedUsername
|
|
}
|
|
|
|
private _getAndClean (normalizedUsername: string): Password | undefined {
|
|
const entry = this._passwords.get(normalizedUsername)
|
|
if (entry) {
|
|
if (entry.validity > Date.now()) {
|
|
return entry
|
|
}
|
|
this._passwords.delete(normalizedUsername)
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
private async _userIdValid (userId: number): Promise<boolean> {
|
|
try {
|
|
const user = await this._options.peertubeHelpers.user.loadById(userId)
|
|
if (!user || user.blocked) { return false }
|
|
return true
|
|
} catch (err) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
private _jidTokenPath (jid: string): string {
|
|
// Simple security check:
|
|
if (jid === '.' || jid === '..' || jid.includes('/')) {
|
|
throw new Error('Invalid jid')
|
|
}
|
|
return path.join(this._tokensPath, jid + '.json')
|
|
}
|
|
|
|
private async _getTokensInfoForJID (jid: string): Promise<LivechatTokenInfos | undefined> {
|
|
try {
|
|
const cached = this._tokensInfoByJID.get(jid)
|
|
if (cached) {
|
|
return cached
|
|
}
|
|
|
|
const filePath = this._jidTokenPath(jid)
|
|
const content = await fs.promises.readFile(filePath)
|
|
const json = JSON.parse(content.toString()) as SavedUserData
|
|
if ((typeof json !== 'object') || (typeof json.userId !== 'number') || (!Array.isArray(json.tokens))) {
|
|
throw new Error('Invalid token file content')
|
|
}
|
|
|
|
const tokens: LivechatToken[] = []
|
|
for (const entry of json.tokens) {
|
|
const token: LivechatToken = {
|
|
jid,
|
|
password: await this._decrypt(entry.encryptedPassword),
|
|
date: entry.date,
|
|
label: entry.label,
|
|
id: entry.id
|
|
}
|
|
tokens.push(token)
|
|
}
|
|
|
|
const d = {
|
|
userId: json.userId,
|
|
tokens
|
|
}
|
|
this._tokensInfoByJID.set(jid, d)
|
|
return d
|
|
} catch (err: any) {
|
|
if (('code' in err) && err.code === 'ENOENT') {
|
|
// User has no token, this is normal.
|
|
this._tokensInfoByJID.set(jid, undefined)
|
|
return undefined
|
|
}
|
|
throw err
|
|
}
|
|
}
|
|
|
|
private async _createToken (userId: number, jid: string, label: string): Promise<LivechatToken> {
|
|
const tokensInfo = (await this._getTokensInfoForJID(jid)) ?? { userId, tokens: [] }
|
|
|
|
// Using Date.now result as id, so we are pretty sure to not have 2 tokens with the same id.
|
|
const now = Date.now()
|
|
const id = now
|
|
if (tokensInfo.tokens.find(t => t.id === id)) {
|
|
throw new Error('There is already a token with this id.')
|
|
}
|
|
|
|
const password = generatePassword(30)
|
|
|
|
const newToken: LivechatToken = {
|
|
id,
|
|
jid,
|
|
date: now,
|
|
password,
|
|
label
|
|
}
|
|
tokensInfo.tokens.push(newToken)
|
|
await this._saveTokens(userId, jid, tokensInfo.tokens)
|
|
return newToken
|
|
}
|
|
|
|
private async _saveTokens (userId: number, jid: string, tokens: LivechatToken[]): Promise<void> {
|
|
const ti = {
|
|
userId,
|
|
tokens
|
|
}
|
|
this._tokensInfoByJID.set(jid, ti)
|
|
|
|
const toSave: SavedUserData = {
|
|
userId,
|
|
tokens: []
|
|
}
|
|
for (const t of tokens) {
|
|
toSave.tokens.push({
|
|
id: t.id,
|
|
date: t.date,
|
|
encryptedPassword: await this._encrypt(t.password),
|
|
label: t.label
|
|
})
|
|
}
|
|
const content = JSON.stringify(toSave)
|
|
await fs.promises.mkdir(this._tokensPath, {
|
|
recursive: true
|
|
})
|
|
await fs.promises.writeFile(this._jidTokenPath(jid), content)
|
|
}
|
|
|
|
private async _encrypt (data: string): Promise<string> {
|
|
const { algorithm, inputEncoding, outputEncoding } = this._encryptionOptions
|
|
|
|
const iv = await getRandomBytes(16)
|
|
|
|
const cipher = createCipheriv(algorithm, this._secretKey, iv)
|
|
let encrypted = cipher.update(data, inputEncoding, outputEncoding)
|
|
encrypted += cipher.final(outputEncoding)
|
|
|
|
return iv.toString(outputEncoding) + ':' + encrypted
|
|
}
|
|
|
|
private async _decrypt (data: string): Promise<string> {
|
|
const { algorithm, inputEncoding, outputEncoding } = this._encryptionOptions
|
|
|
|
const encryptedArray = data.split(':')
|
|
const iv = Buffer.from(encryptedArray[0], outputEncoding)
|
|
const encrypted = Buffer.from(encryptedArray[1], outputEncoding)
|
|
const decipher = createDecipheriv(algorithm, this._secretKey, iv)
|
|
|
|
// FIXME: dismiss the "as any" below (dont understand why Typescript is not happy without)
|
|
return decipher.update(encrypted as any, outputEncoding, inputEncoding) + decipher.final(inputEncoding)
|
|
}
|
|
|
|
public static singleton (): LivechatProsodyAuth {
|
|
if (!singleton) {
|
|
throw new Error('LivechatProsodyAuth singleton not initialized yet')
|
|
}
|
|
return singleton
|
|
}
|
|
|
|
public static async initSingleton (options: RegisterServerOptions): Promise<LivechatProsodyAuth> {
|
|
const prosodyDomain = await getProsodyDomain(options)
|
|
let secretKey = await options.storageManager.getData('livechat-prosody-auth-secretkey')
|
|
if (!secretKey) {
|
|
// Generating the secret key
|
|
secretKey = (await getRandomBytes(16)).toString('hex')
|
|
await options.storageManager.storeData('livechat-prosody-auth-secretkey', secretKey)
|
|
}
|
|
|
|
const userTokenDisabled = await options.settingsManager.getSetting('livechat-token-disabled')
|
|
|
|
singleton = new LivechatProsodyAuth(options, prosodyDomain, !userTokenDisabled, secretKey)
|
|
return singleton
|
|
}
|
|
|
|
public static async destroySingleton (): Promise<void> {
|
|
singleton = undefined
|
|
}
|
|
}
|