peertube-plugin-livechat/server/lib/prosody/auth.ts
2024-07-10 11:55:54 +02:00

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
}
}