Authentication token generation WIP (#98)

You can now generate links to join chatrooms with your current user. This can be used to create Docks in OBS for example. This could also be used to generate authentication token to join the chat from 3rd party tools.
This commit is contained in:
John Livingston 2024-06-16 19:48:02 +02:00
parent e83150cf87
commit 90afdafbd9
No known key found for this signature in database
GPG Key ID: B17B5640CE66CDBC
24 changed files with 988 additions and 205 deletions

View File

@ -9,6 +9,7 @@ TODO: tag conversejs livechat branch, and replace commit ID in build-converse.js
* Overhauled configuration page, with more broadly customizable lists of parameters by @Murazaki ([See pull request #352](https://github.com/JohnXLivingston/peertube-plugin-livechat/pull/352)).
* #377: new setting to listen C2S connection on non-localhost interfaces.
* #130: channel custom emojis.
* #98: OBS Dock. You can now generate links to join chatrooms with your current user. This can be used to create Docks in OBS for example. This could also be used to generate authentication token to join the chat from 3rd party tools.
### Minor changes and fixes

View File

@ -10,42 +10,15 @@
@use "sass:color";
@use "../variables";
@use "../mixins/buttons";
@use "../mixins/tables";
livechat-dynamic-table-form {
// We need this variable to be known at that time
$bs-green: #39cc0b;
display: block;
margin-bottom: 3rem;
table {
table-layout: fixed;
text-align: center;
tr {
border: 1px var(--greyBackgroundColor) solid;
}
td,
th {
word-wrap: break-word;
vertical-align: top;
padding: 5px 7px;
}
td:last-child {
vertical-align: middle;
min-width: 28px;
> input:not([type="checkbox"]),
textarea {
min-width: 150px;
}
}
tbody tr:nth-child(odd) {
background-color: var(--greySecondaryBackgroundColor);
}
@include tables.data-table;
.livechat-dynamic-table-form-description-header {
font-size: small;
@ -57,74 +30,11 @@ livechat-dynamic-table-form {
text-align: left;
}
.dynamic-table-add-row,
.dynamic-table-remove-row {
// Peertube rounded-line-height-1-5 mixins
line-height: variables.$button-calc-line-height;
// Peertube peertube-button mixin (but with less horizontal padding)
padding: 4px;
border: 0;
font-weight: variables.$font-semibold;
border-radius: 3px !important;
text-align: center;
cursor: pointer;
font-size: variables.$button-font-size;
}
.dynamic-table-add-row {
background-color: var(--bs-green);
&,
&:active,
&:focus {
color: #fff;
background-color: color.adjust($bs-green, $lightness: 5%);
}
&:focus,
&.focus-visible {
box-shadow: 0 0 0 0.2rem color.adjust($bs-green, $lightness: 20%);
}
&:hover {
color: #fff;
background-color: color.adjust($bs-green, $lightness: 10%);
}
&[disabled],
&.disabled {
cursor: default;
color: #fff;
background-color: var(--inputBorderColor);
}
@include buttons.button-row-add;
}
.dynamic-table-remove-row {
background-color: var(--bs-orange);
&,
&:active,
&:focus {
color: #fff;
background-color: var(--mainColor);
}
&:focus,
&.focus-visible {
box-shadow: 0 0 0 0.2rem var(--mainHoverColor);
}
&:hover {
color: #fff;
background-color: var(--mainHoverColor);
}
&[disabled],
&.disabled {
cursor: default;
color: #fff;
background-color: var(--inputBorderColor);
}
@include buttons.button-row-remove;
}
}

View File

@ -11,3 +11,4 @@
@use "share-chat";
@use "spinner";
@use "tags-input";
@use "token-list";

View File

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@use "../mixins/buttons";
@use "../mixins/tables";
livechat-token-list {
table {
@include tables.data-table;
}
.livechat-create-token {
@include buttons.button-row-add;
}
.livechat-revoke-token {
@include buttons.button-row-remove;
}
}

View File

@ -0,0 +1,89 @@
/*
* SPDX-FileCopyrightText: 2024 Mehdi Benadel <https://mehdibenadel.com>
* SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@use "sass:color";
@use "../variables";
/* We are disabling stylelint-disable custom-property-pattern so we can use Peertube var without warnings. */
/* stylelint-disable custom-property-pattern */
// We need this variable to be known at that time
$bs-green: #39cc0b;
@mixin button-row {
// Peertube rounded-line-height-1-5 mixins
line-height: variables.$button-calc-line-height;
// Peertube peertube-button mixin (but with less horizontal padding)
padding: 4px;
border: 0;
font-weight: variables.$font-semibold;
border-radius: 3px !important;
text-align: center;
cursor: pointer;
font-size: variables.$button-font-size;
}
@mixin button-row-add {
@include button-row;
background-color: var(--bs-green);
&,
&:active,
&:focus {
color: #fff;
background-color: color.adjust($bs-green, $lightness: 5%);
}
&:focus,
&.focus-visible {
box-shadow: 0 0 0 0.2rem color.adjust($bs-green, $lightness: 20%);
}
&:hover {
color: #fff;
background-color: color.adjust($bs-green, $lightness: 10%);
}
&[disabled],
&.disabled {
cursor: default;
color: #fff;
background-color: var(--inputBorderColor);
}
}
@mixin button-row-remove {
@include button-row;
background-color: var(--bs-orange);
&,
&:active,
&:focus {
color: #fff;
background-color: var(--mainColor);
}
&:focus,
&.focus-visible {
box-shadow: 0 0 0 0.2rem var(--mainHoverColor);
}
&:hover {
color: #fff;
background-color: var(--mainHoverColor);
}
&[disabled],
&.disabled {
cursor: default;
color: #fff;
background-color: var(--inputBorderColor);
}
}

View File

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2024 Mehdi Benadel <https://mehdibenadel.com>
* SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* We are disabling stylelint-disable custom-property-pattern so we can use Peertube var without warnings. */
/* stylelint-disable custom-property-pattern */
@mixin data-table {
table-layout: fixed;
text-align: center;
tr {
border: 1px var(--greyBackgroundColor) solid;
}
td,
th {
word-wrap: break-word;
vertical-align: top;
padding: 5px 7px;
}
td:last-child {
vertical-align: middle;
min-width: 28px;
> input:not([type="checkbox"]),
textarea {
min-width: 150px;
}
}
tbody tr:nth-child(odd) {
background-color: var(--greySecondaryBackgroundColor);
}
}

View File

@ -113,3 +113,11 @@ declare const LOC_LOADING_ERROR: string
declare const LOC_SHARE_CHAT_EMBED: string
declare const LOC_SHARE_CHAT_PEERTUBE_TIPS: string
declare const LOC_SHARE_CHAT_DOCK: string
declare const LOC_SHARE_CHAT_DOCK_TIPS: string
declare const LOC_TOKEN_LABEL: string
declare const LOC_TOKEN_JID: string
declare const LOC_TOKEN_PASSWORD: string
declare const LOC_TOKEN_ACTION_CREATE: string
declare const LOC_TOKEN_ACTION_REVOKE: string
declare const LOC_TOKEN_DEFAULT_LABEL: string

View File

@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: 2024 Mehdi Benadel <https://mehdibenadel.com>
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
// This content comes from the file assets/images/plus-square.svg, from the Feather icons set https://feathericons.com/
export const AddSVG: string =
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" class="feather feather-plus-square">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line>
</svg>`
// This content comes from the file assets/images/x-square.svg, from the Feather icons set https://feathericons.com/
export const RemoveSVG: string =
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" class="feather feather-x-square">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="9" x2="15" y2="15"></line><line x1="15" y1="9" x2="9" y2="15"></line>
</svg>`

View File

@ -15,24 +15,7 @@ import { unsafeHTML } from 'lit/directives/unsafe-html.js'
import { classMap } from 'lit/directives/class-map.js'
import { LivechatElement } from './livechat'
import { ptTr } from '../directives/translation'
// This content comes from the file assets/images/plus-square.svg, from the Feather icons set https://feathericons.com/
const AddSVG: string =
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" class="feather feather-plus-square">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line>
</svg>`
// This content comes from the file assets/images/x-square.svg, from the Feather icons set https://feathericons.com/
const RemoveSVG: string =
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" class="feather feather-x-square">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="9" x2="15" y2="15"></line><line x1="15" y1="9" x2="9" y2="15"></line>
</svg>`
import { AddSVG, RemoveSVG } from '../buttons'
type DynamicTableAcceptedTypes = number | string | boolean | Date | Array<number | string>

View File

@ -10,4 +10,5 @@ import './configuration-section-header'
import './tags-input'
import './image-file-input'
import './spinner'
import './token-list'
import './error'

View File

@ -0,0 +1,74 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import type { LivechatTokenListElement } from '../token-list'
import { html, TemplateResult } from 'lit'
import { unsafeHTML } from 'lit/directives/unsafe-html.js'
import { repeat } from 'lit/directives/repeat.js'
import { ptTr } from '../../directives/translation'
import { AddSVG, RemoveSVG } from '../../buttons'
export function tplTokenList (el: LivechatTokenListElement): TemplateResult {
return html`<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th scope="col"></th>
<th scope="col">${ptTr(LOC_TOKEN_LABEL)}</th>
<th scope="col">${ptTr(LOC_TOKEN_JID)}</th>
<th scope="col">${ptTr(LOC_TOKEN_PASSWORD)}</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
${
repeat(el.tokenList ?? [], (token) => token.id, (token) => {
html`<tr>
<td>${
el.mode === 'select'
? html`<input
type="radio"
?selected=${el.currentSelectedToken?.id === token.id}
@click=${el.selectToken}
/>`
: ''
}</td>
<td>${token.label}</td>
<td>${token.jid}</td>
<td>${token.password}</td>
<td>
<button type="button"
class="livechat-revoke-token"
.title=${ptTr(LOC_TOKEN_ACTION_REVOKE) as any}
?disabled=${el.actionDisabled}
@click=${() => {
el.revokeToken(token).then(() => {}, () => {})
}}
>
${unsafeHTML(RemoveSVG)}
</button>
</td>
</tr>`
})
}
</tbody>
<tfoot>
<tr>
<td>
<button type="button"
class="livechat-create-token"
.title=${ptTr(LOC_TOKEN_ACTION_CREATE) as any}
?disabled=${el.actionDisabled}
@click=${() => {
el.createToken().then(() => {}, () => {})
}}
>
${unsafeHTML(AddSVG)}
</button>
</td>
</tr>
</tfoot>
</table>
</div>`
}

View File

@ -0,0 +1,102 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { html } from 'lit'
import { customElement, property } from 'lit/decorators.js'
import { LivechatElement } from './livechat'
import { TokenListService } from '../services/token-list'
import { tplTokenList } from './templates/token-list'
import { Task } from '@lit/task'
import { LivechatToken } from 'shared/lib/types'
@customElement('livechat-token-list')
export class LivechatTokenListElement extends LivechatElement {
/**
* Indicate the mode to use:
* * list: just display tokens
* * select: select one token
*/
@property({ attribute: true })
public mode: 'select' | 'list' = 'list'
@property({ attribute: false })
public tokenList?: LivechatToken[]
@property({ attribute: false })
public currentSelectedToken?: LivechatToken
@property({ attribute: false })
public actionDisabled: boolean = false
private readonly _tokenListService: TokenListService
private readonly _asyncTaskRender: Task
constructor () {
super()
this._tokenListService = new TokenListService()
this._asyncTaskRender = this._initTask()
}
protected _initTask (): Task {
return new Task(this, {
task: async () => {
this.tokenList = await this._tokenListService.fetchTokenList()
this.actionDisabled = false
},
args: () => []
})
}
protected override render = (): unknown => {
return this._asyncTaskRender.render({
pending: () => html`<livechat-spinner></livechat-spinner>`,
error: () => html`<livechat-error></livechat-error>`,
complete: () => tplTokenList(this)
})
}
public selectToken (ev: Event, token: LivechatToken): void {
ev.preventDefault()
if (!this.tokenList?.includes(token)) { return }
this.currentSelectedToken = token
this.dispatchEvent(new CustomEvent('update', {}))
}
public async revokeToken (token: LivechatToken): Promise<void> {
this.actionDisabled = true
try {
await this._tokenListService.revokeToken(token)
this.tokenList = this.tokenList?.filter(t => t !== token) ?? []
if (this.currentSelectedToken === token) {
this.currentSelectedToken = undefined
}
this.requestUpdate('tokenList')
this.dispatchEvent(new CustomEvent('update', {}))
} catch (err: any) {
this.logger.error(err)
this.ptNotifier.error(err.toString(), await this.ptTranslate(LOC_ERROR))
} finally {
this.actionDisabled = false
}
}
public async createToken (): Promise<void> {
this.actionDisabled = true
try {
const token = await this._tokenListService.createToken(await this.ptTranslate(LOC_TOKEN_DEFAULT_LABEL))
this.tokenList ??= []
this.tokenList.push(token)
if (this.mode === 'select') {
this.currentSelectedToken = token
}
this.requestUpdate('tokenList')
this.dispatchEvent(new CustomEvent('update', {}))
} catch (err: any) {
this.logger.error(err)
this.ptNotifier.error(err.toString(), await this.ptTranslate(LOC_ERROR))
} finally {
this.actionDisabled = false
}
}
}

View File

@ -0,0 +1,69 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { getPtContext } from '../contexts/peertube'
import { getBaseRoute } from '../../../utils/uri'
import { LivechatToken } from 'shared/lib/types'
export class TokenListService {
private readonly _headers: any = {}
private readonly _apiUrl: string
constructor () {
this._headers = getPtContext().ptOptions.peertubeHelpers.getAuthHeader() ?? {}
this._headers['content-type'] = 'application/json;charset=UTF-8'
this._apiUrl = getBaseRoute(getPtContext().ptOptions) + '/api/auth/tokens'
}
public async fetchTokenList (): Promise<LivechatToken[]> {
const response = await fetch(
this._apiUrl,
{
method: 'GET',
headers: this._headers
}
)
if (!response.ok) {
throw new Error('Can\'t get livechat token list.')
}
return response.json()
}
public async createToken (label: string): Promise<LivechatToken> {
const response = await fetch(
this._apiUrl,
{
method: 'POST',
headers: this._headers,
body: JSON.stringify({
label
})
}
)
if (!response.ok) {
throw new Error('Can\'t create livechat token.')
}
return response.json()
}
public async revokeToken (token: LivechatToken): Promise<void> {
const response = await fetch(
this._apiUrl + '/' + encodeURIComponent(token.id),
{
method: 'DELETE',
headers: this._headers
}
)
if (!response.ok) {
throw new Error('Can\'t delete livechat token.')
}
return response.json()
}
}

View File

@ -10,8 +10,9 @@ import { LivechatElement } from '../../lib/elements/livechat'
import { tplShareChatCopy, tplShareChatTips, tplShareChatTabs, tplShareChatOptions } from './templates/share-chat'
import { isAutoColorsAvailable } from 'shared/lib/autocolors'
import { getIframeUri, getXMPPAddr, UriOptions } from '../uri'
import { isAnonymousUser } from '../../../utils/user'
const validTabNames = ['peertube', 'embed', 'xmpp'] as const
const validTabNames = ['peertube', 'embed', 'dock', 'xmpp'] as const
type ValidTabNames = typeof validTabNames[number]
@ -60,6 +61,12 @@ export class ShareChatElement extends LivechatElement {
@property({ attribute: false })
public xmppUriEnabled: boolean = false
/**
* Should we render the Dock tab?
*/
@property({ attribute: false })
public dockEnabled: boolean = false
/**
* Can we use autocolors?
*/
@ -100,6 +107,10 @@ export class ShareChatElement extends LivechatElement {
super.firstUpdated(changedProperties)
const settings = this._settings
this.xmppUriEnabled = !!settings['prosody-room-allow-s2s']
// Note: for dockEnabled, we check:
// * that the user is logged in
// * that the video is local (for remote video, tests case are too complicated, and it's not the main use case, so…)
this.dockEnabled = !isAnonymousUser(this.ptContext.ptOptions) && this._video.isLocal
this.autocolorsAvailable = isAutoColorsAvailable(settings['converse-theme'])
this._restorePreviousState()
@ -154,6 +165,9 @@ export class ShareChatElement extends LivechatElement {
if (!this.xmppUriEnabled && this.currentTab === 'xmpp') {
this.currentTab = 'peertube'
}
if (!this.dockEnabled && this.currentTab === 'dock') {
this.currentTab = 'peertube'
}
}
protected _saveCurrentState (): void {
@ -177,6 +191,7 @@ export class ShareChatElement extends LivechatElement {
switch (this.currentTab) {
case 'peertube': return this._computeUrlPeertube()
case 'embed': return this._computeUrlEmbed()
case 'dock': return this._computeUrlDock()
case 'xmpp': return this._computeUrlXMPP()
default:
return {
@ -206,6 +221,32 @@ export class ShareChatElement extends LivechatElement {
}
}
protected _computeUrlDock (): ComputedUrl {
return {
shareString: '',
openUrl: undefined
}
// const uriOptions: UriOptions = {
// ignoreAutoColors: true,
// permanent: true
// }
// // Note: for the "embed" case, the url is always the same as the iframe.
// // So we use getIframeUri to compte, and just change the finale result if we really want the iframe.
// const url = getIframeUri(this.ptContext.ptOptions, this._settings, this._video, uriOptions)
// if (!url) {
// return {
// shareString: '',
// openUrl: undefined
// }
// }
// return {
// shareString: url,
// openUrl: url
// }
}
protected _computeUrlEmbed (): ComputedUrl {
const uriOptions: UriOptions = {
ignoreAutoColors: this.autocolorsAvailable ? !this.embedAutocolors : true,

View File

@ -22,7 +22,11 @@ export function tplShareChatCopy (el: ShareChatElement): TemplateResult {
input.setSelectionRange(0, 99999) /* For mobile devices */
}}
/>
<button type="button" class="btn btn-outline-secondary text-uppercase" @click=${el.copyUrl}>
<button
type="button" class="btn btn-outline-secondary text-uppercase"
@click=${el.copyUrl}
?disabled=${!computedUrl.shareString}
>
${ptTr(LOC_COPY)}
</button>
<button
@ -60,6 +64,11 @@ export function tplShareChatTabs (el: ShareChatElement): TemplateResult {
return html`
${_tplShareChatTab(el, 'peertube', LOC_WEB)}
${_tplShareChatTab(el, 'embed', LOC_SHARE_CHAT_EMBED)}
${
el.dockEnabled
? _tplShareChatTab(el, 'dock', LOC_SHARE_CHAT_DOCK)
: ''
}
${
el.xmppUriEnabled
? _tplShareChatTab(el, 'xmpp', LOC_CONNECT_USING_XMPP)
@ -79,6 +88,10 @@ export function tplShareChatTips (el: ShareChatElement): TemplateResult {
label = LOC_TIPS_FOR_STREAMERS
tips = html`<livechat-help-button .page=${'documentation/user/obs'}></livechat-help-button>`
break
case 'dock':
label = LOC_SHARE_CHAT_DOCK_TIPS
tips = html`<livechat-help-button .page=${'documentation/user/obs'}></livechat-help-button>`
break
case 'xmpp':
label = LOC_CONNECT_USING_XMPP_HELP
break
@ -154,6 +167,10 @@ function _tplShareChatEmbedOptions (el: ShareChatElement): TemplateResult {
`
}
function _tplShareChatDockOptions (_el: ShareChatElement): TemplateResult {
return html`<livechat-token-list mode="select"></livechat-token-list>`
}
function _tplShareChatXMPPOptions (_el: ShareChatElement): TemplateResult {
return html``
}
@ -167,6 +184,9 @@ export function tplShareChatOptions (el: ShareChatElement): TemplateResult {
case 'embed':
tpl = _tplShareChatEmbedOptions(el)
break
case 'dock':
tpl = _tplShareChatDockOptions(el)
break
case 'xmpp':
tpl = _tplShareChatXMPPOptions(el)
break

View File

@ -12,7 +12,7 @@ import {
remoteRoomAnonymousParams,
remoteRoomAuthenticatedParams
} from './lib/converse-params'
import { getLocalAuthentInfos } from './lib/auth'
import { getLocalAuthentInfos, getLivechatTokenAuthInfos } from './lib/auth'
import { randomNick } from './lib/nick'
import { slowModePlugin } from './lib/plugins/slow-mode'
import { windowTitlePlugin } from './lib/plugins/window-title'
@ -120,7 +120,12 @@ async function initConverse (
// OIDC (OpenID Connect):
const tryOIDC = (initConverseParams.externalAuthOIDC?.length ?? 0) > 0
const auth = await getLocalAuthentInfos(authenticationUrl, tryOIDC, peertubeAuthHeader)
let auth
if (chatIncludeMode === 'chat-only') {
// In this mode, we can check if there is a token in the url.
auth = getLivechatTokenAuthInfos()
}
auth ??= await getLocalAuthentInfos(authenticationUrl, tryOIDC, peertubeAuthHeader)
if (auth) {
if (!isRemoteChat) {

View File

@ -2,12 +2,7 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
interface AuthentInfos {
type: 'peertube' | 'oidc'
jid: string
password: string
nickname?: string
}
import type { ProsodyAuthentInfos } from 'shared/lib/types'
interface AuthHeader { [key: string]: string }
@ -15,7 +10,7 @@ async function getLocalAuthentInfos (
authenticationUrl: string,
tryExternalAuth: boolean,
peertubeAuthHeader?: AuthHeader | null
): Promise<false | AuthentInfos> {
): Promise<false | ProsodyAuthentInfos> {
try {
if (authenticationUrl === '') {
console.error('Missing authenticationUrl')
@ -101,7 +96,37 @@ async function getLocalAuthentInfos (
}
}
export {
AuthentInfos,
getLocalAuthentInfos
/**
* Reads the livechat-token if relevant.
* This token can be passed to the page by adding the following hash to the window.location:
* `?j=the_xmpp_id&p=XXXXXXX&n=MyNickname`
*/
function getLivechatTokenAuthInfos (): ProsodyAuthentInfos | undefined {
try {
const hash = window.location.hash
if (!hash || !hash.startsWith('#?')) { return undefined }
// We try to read the hash as a queryString.
const u = new URL('http://localhost' + hash.substring(1))
const jid = u.searchParams.get('j')
const password = u.searchParams.get('p')
if (!jid || !password) { return undefined }
const nickname = u.searchParams.get('n') ?? undefined
return {
type: 'livechat-token',
jid,
password,
nickname
}
} catch (error) {
console.error(error)
return undefined
}
}
export {
ProsodyAuthentInfos,
getLocalAuthentInfos,
getLivechatTokenAuthInfos
}

View File

@ -3,7 +3,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { InitConverseJSParams } from 'shared/lib/types'
import type { AuthentInfos } from './auth'
import type { ProsodyAuthentInfos } from './auth'
/**
* Instanciate defaults params to use for ConverseJS.
@ -140,7 +140,7 @@ function defaultConverseParams (
*/
function localRoomAuthenticatedParams (
initConverseParams: InitConverseJSParams,
auth: AuthentInfos, params: any
auth: ProsodyAuthentInfos, params: any
): void {
_fillAuthenticatedParams(initConverseParams, auth, params)
_fillLocalProtocols(initConverseParams, params)
@ -164,7 +164,7 @@ function localRoomAnonymousParams (initConverseParams: InitConverseJSParams, par
*/
function remoteRoomAuthenticatedParams (
initConverseParams: InitConverseJSParams,
auth: AuthentInfos, params: any
auth: ProsodyAuthentInfos, params: any
): void {
_fillAuthenticatedParams(initConverseParams, auth, params)
_fillLocalProtocols(initConverseParams, params)
@ -178,7 +178,7 @@ function remoteRoomAuthenticatedParams (
*/
function remoteRoomAnonymousParams (
initConverseParams: InitConverseJSParams,
auth: AuthentInfos | null,
auth: ProsodyAuthentInfos | null,
params: any
): void {
params.jid = initConverseParams.remoteAnonymousJID
@ -188,7 +188,11 @@ function remoteRoomAnonymousParams (
_fillRemoteProtocols(initConverseParams, params)
}
function _fillAuthenticatedParams (initConverseParams: InitConverseJSParams, auth: AuthentInfos, params: any): void {
function _fillAuthenticatedParams (
initConverseParams: InitConverseJSParams,
auth: ProsodyAuthentInfos,
params: any
): void {
params.authentication = 'login'
params.auto_login = true
params.jid = auth.jid

View File

@ -521,3 +521,18 @@ action_remove_entry_confirm: Are you sure you want to remove this entry?
loading_error: An error occured while loading data.
share_chat_embed: Embed
share_chat_peertube_tips: This link will open the chat within the Peertube interface.
share_chat_dock: Dock
share_chat_dock_tips: |
You can generate a link that will open the chat in full page, logged in with your Peertube account.
This can be use, for example, to have a web dock in your OBS, so you can read and interact with the chat directly from OBS.
Don't share this link to anyone, as it would allow them to connect as yourself.
Please find bellow the list of authentication token you have already generated.
You can create a new one, or revoke any previous token.
Please note that these tokens have no expiration date.
token_label: Label
token_jid: Username
token_password: Password token
token_action_create: Create a new token
token_action_revoke: Revoke the token
token_default_label: Token generated from the web interface

View File

@ -2,73 +2,352 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
/*
This module provides user credential for the builtin prosody module.
*/
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
}
const PASSWORDS: Map<string, Password> = new Map()
function _getAndClean (user: string): Password | undefined {
const entry = PASSWORDS.get(user)
if (entry) {
if (entry.validity > Date.now()) {
return entry
}
PASSWORDS.delete(user)
}
return undefined
type SavedLivechatToken = Omit<LivechatToken, 'jid' | 'nickname' | 'password'> & {
encryptedPassword: string
}
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
/**
* A user can get a password thanks to a call to prosodyRegisterUser (see api user/auth).
* This class handles user/passwords for Peertube users to the Prosody service.
*
* Then, we can test that the user exists with prosodyUserRegistered, and test password with prosodyCheckUserPassword.
* 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
*
* Passwords are randomly generated.
*
* These password are stored internally in a global variable, and are valid for 24h.
* Each call to registerUser 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
* 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, he also must have access
* to DB to get the secret key and decrypt passwords.
*/
async function prosodyRegisterUser (user: string): Promise<string> {
const entry = _getAndClean(user)
const validity = Date.now() + (24 * 60 * 60 * 1000) // 24h
if (entry) {
entry.validity = validity
return entry.password
export class LivechatProsodyAuth {
private readonly _options: RegisterServerOptions
private readonly _prosodyDomain: string
private readonly _tokensPath: string
private readonly _passwords: Map<string, Password> = new Map()
private readonly _jidTokens: Map<string, LivechatToken[]> = 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
}
const password = Math.random().toString(36).slice(2, 12) + Math.random().toString(36).slice(2, 12)
PASSWORDS.set(user, {
password: password,
validity: validity
})
return password
}
private readonly _encryptionOptions = {
algorithm: 'aes256' as string,
inputEncoding: 'utf8' as Encoding,
outputEncoding: 'hex' as Encoding
}
async function prosodyUserRegistered (user: string): Promise<boolean> {
const entry = _getAndClean(user)
return !!entry
}
constructor (options: RegisterServerOptions, prosodyDomain: string, secretKey: string) {
this._options = options
this._prosodyDomain = prosodyDomain
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)
}
}
async function prosodyCheckUserPassword (user: string, password: string): Promise<boolean> {
const entry = _getAndClean(user)
if (entry && entry.password === password) {
/**
* 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)
return !!entry
}
public async checkUserPassword (normalizedUsername: string, password: string): Promise<boolean> {
const entry = this._getAndClean(normalizedUsername)
if (entry && entry.password === password) {
return true
}
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> {
const normalizedUsername = this._normalizeUsername(user)
if (!normalizedUsername) {
return undefined
}
const nickname: string | undefined = await getUserNickname(this._options, user)
const jid = normalizedUsername + '@' + this._prosodyDomain
const tokens = await this._getJIDTokens(jid)
for (const token of tokens) {
token.nickname = nickname
}
return tokens
}
public async createUserToken (user: MUserDefault, label: string): Promise<LivechatToken | 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._createJIDToken(jid, label)
token.nickname = nickname
return token
}
public async revokeUserToken (user: MUserDefault, id: number): Promise<boolean> {
const normalizedUsername = this._normalizeUsername(user)
if (!normalizedUsername) {
return false
}
const jid = normalizedUsername + '@' + this._prosodyDomain
let tokens = await this._getJIDTokens(jid)
tokens = tokens.filter(t => t.id !== id)
await this._saveJIDTokens(jid, tokens)
return true
}
return false
}
export {
prosodyRegisterUser,
prosodyUserRegistered,
prosodyCheckUserPassword
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) {
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 (user: string): Password | undefined {
const entry = this._passwords.get(user)
if (entry) {
if (entry.validity > Date.now()) {
return entry
}
this._passwords.delete(user)
}
return undefined
}
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 _getJIDTokens (jid: string): Promise<LivechatToken[]> {
try {
const cached = this._jidTokens.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 SavedLivechatToken[]
if (!Array.isArray(json)) {
throw new Error('Invalid token file content')
}
const tokens: LivechatToken[] = []
for (const entry of json) {
const token: LivechatToken = {
jid,
password: await this._decrypt(entry.encryptedPassword),
date: entry.date,
label: entry.label,
id: entry.id
}
tokens.push(token)
}
this._jidTokens.set(jid, tokens)
return tokens
} catch (err: any) {
if (('code' in err) && err.code === 'ENOENT') {
// User has no token, this is normal.
this._jidTokens.set(jid, [])
return []
}
throw err
}
}
private async _createJIDToken (jid: string, label: string): Promise<LivechatToken> {
const tokens = await this._getJIDTokens(jid)
// 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 (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
}
tokens.push(newToken)
await this._saveJIDTokens(jid, tokens)
return newToken
}
private async _saveJIDTokens (jid: string, tokens: LivechatToken[]): Promise<void> {
this._jidTokens.set(jid, tokens)
const toSave: SavedLivechatToken[] = []
for (const t of tokens) {
toSave.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)
}
singleton = new LivechatProsodyAuth(options, prosodyDomain, secretKey)
return singleton
}
public static async destroySingleton (): Promise<void> {
// TODO: sync to disk
singleton = undefined
}
}

View File

@ -6,8 +6,7 @@ import type { RegisterServerOptions } from '@peertube/peertube-types'
import type { Router, Request, Response, NextFunction } from 'express'
import { asyncMiddleware } from '../../middlewares/async'
import { getProsodyDomain } from '../../prosody/config/domain'
import { prosodyRegisterUser, prosodyCheckUserPassword, prosodyUserRegistered } from '../../prosody/auth'
import { getUserNickname } from '../../helpers'
import { LivechatProsodyAuth } from '../../prosody/auth'
import { ExternalAuthOIDC } from '../../external-auth/oidc'
/**
@ -45,28 +44,76 @@ async function initAuthApiRouter (options: RegisterServerOptions, router: Router
}
}
if (!user) {
const tempPassword = await LivechatProsodyAuth.singleton().getUserTempPassword(user)
if (!tempPassword) {
res.sendStatus(403)
return
}
if (user.blocked) {
res.sendStatus(403)
return
res.status(200).json(tempPassword)
}
))
router.get('/auth/tokens', asyncMiddleware(
async (req: Request, res: Response, _next: NextFunction) => {
const user = await options.peertubeHelpers.user.getAuthUser(res)
try {
const tokens = await LivechatProsodyAuth.singleton().getUserTokens(user)
if (!tokens) {
res.sendStatus(403)
return
}
res.status(200).json(tokens)
} catch (err) {
options.peertubeHelpers.logger.error(err as string)
res.sendStatus(500)
}
}
))
router.post('/auth/tokens', asyncMiddleware(
async (req: Request, res: Response, _next: NextFunction) => {
const user = await options.peertubeHelpers.user.getAuthUser(res)
try {
const label = req.body.label
if ((typeof label !== 'string') || !label) {
res.sendStatus(400)
return
}
const token = await LivechatProsodyAuth.singleton().createUserToken(user, label)
if (!token) {
res.sendStatus(403)
return
}
res.status(200).json(token)
} catch (err) {
options.peertubeHelpers.logger.error(err as string)
res.sendStatus(500)
}
}
))
router.delete('/auth/tokens/:tokenId', asyncMiddleware(
async (req: Request, res: Response, _next: NextFunction) => {
const user = await options.peertubeHelpers.user.getAuthUser(res)
try {
const tokenId = parseInt(req.params.tokenId)
if (isNaN(tokenId)) {
res.sendStatus(400)
return
}
const r = await LivechatProsodyAuth.singleton().revokeUserToken(user, tokenId)
if (!r) {
res.sendStatus(403)
return
}
res.status(200).json(true)
} catch (err) {
options.peertubeHelpers.logger.error(err as string)
res.sendStatus(500)
}
// 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()
const prosodyDomain = await getProsodyDomain(options)
const password: string = await prosodyRegisterUser(normalizedUsername)
const nickname: string | undefined = await getUserNickname(options, user)
res.status(200).json({
jid: normalizedUsername + '@' + prosodyDomain,
password: password,
nickname: nickname,
type: 'peertube'
})
}
))
}
@ -97,7 +144,7 @@ async function initUserAuthApiRouter (options: RegisterServerOptions, router: Ro
res.status(200).send('false')
return
}
if (user && pass && await prosodyCheckUserPassword(user as string, pass as string)) {
if (user && pass && await LivechatProsodyAuth.singleton().checkUserPassword(user as string, pass as string)) {
res.status(200).send('true')
return
}
@ -115,7 +162,7 @@ async function initUserAuthApiRouter (options: RegisterServerOptions, router: Ro
res.status(200).send('false')
return
}
if (user && await prosodyUserRegistered(user as string)) {
if (user && await LivechatProsodyAuth.singleton().userRegistered(user as string)) {
res.status(200).send('true')
return
}

View File

@ -19,6 +19,7 @@ import { BotsCtl } from './lib/bots/ctl'
import { ExternalAuthOIDC } from './lib/external-auth/oidc'
import { migrateMUCAffiliations } from './lib/prosody/migration/migrateV10'
import { Emojis } from './lib/emojis'
import { LivechatProsodyAuth } from './lib/prosody/auth'
import decache from 'decache'
// FIXME: Peertube unregister don't have any parameter.
@ -51,6 +52,7 @@ async function register (options: RegisterServerOptions): Promise<any> {
await initSettings(options)
await Emojis.initSingleton(options) // after settings, before routes
await LivechatProsodyAuth.initSingleton(options)
await initCustomFields(options)
await initRouters(options)
@ -115,6 +117,7 @@ async function unregister (): Promise<any> {
await BotConfiguration.destroySingleton()
await ExternalAuthOIDC.destroySingletons()
await Emojis.destroySingleton()
await LivechatProsodyAuth.destroySingleton()
const module = __filename
OPTIONS?.peertubeHelpers.logger.info(`Unloading module ${module}...`)

View File

@ -166,6 +166,22 @@ interface ChannelEmojisConfiguration {
emojis: ChannelEmojis
}
interface ProsodyAuthentInfos {
type: 'peertube' | 'oidc' | 'livechat-token'
jid: string
password: string
nickname?: string
}
interface LivechatToken {
id: number
label: string
jid: string
password: string
nickname?: string
date: number
}
export type {
ConverseJSTheme,
InitConverseJSParams,
@ -184,5 +200,7 @@ export type {
ExternalAuthOIDCType,
CustomEmojiDefinition,
ChannelEmojis,
ChannelEmojisConfiguration
ChannelEmojisConfiguration,
ProsodyAuthentInfos,
LivechatToken
}

View File

@ -116,3 +116,8 @@ For example, the channel `1` will contain:
* `emojis/channel/1/definition.json`: the JSON file containing the emojis definitions
* `emojis/channel/1/files/42.png`: N image files (png, jpg, ...), using numbers as filenames.
## tokens
The `tokens` folder contains long term token to connect to the chat.
See the `LivechatProsodyAuth` class for more information.