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:
parent
e83150cf87
commit
90afdafbd9
@ -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
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -11,3 +11,4 @@
|
||||
@use "share-chat";
|
||||
@use "spinner";
|
||||
@use "tags-input";
|
||||
@use "token-list";
|
||||
|
22
assets/styles/elements/_token-list.scss
Normal file
22
assets/styles/elements/_token-list.scss
Normal 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;
|
||||
}
|
||||
}
|
89
assets/styles/mixins/_buttons.scss
Normal file
89
assets/styles/mixins/_buttons.scss
Normal 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);
|
||||
}
|
||||
}
|
39
assets/styles/mixins/tables.scss
Normal file
39
assets/styles/mixins/tables.scss
Normal 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);
|
||||
}
|
||||
}
|
8
client/@types/global.d.ts
vendored
8
client/@types/global.d.ts
vendored
@ -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
|
||||
|
22
client/common/lib/buttons.ts
Normal file
22
client/common/lib/buttons.ts
Normal 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>`
|
@ -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>
|
||||
|
||||
|
@ -10,4 +10,5 @@ import './configuration-section-header'
|
||||
import './tags-input'
|
||||
import './image-file-input'
|
||||
import './spinner'
|
||||
import './token-list'
|
||||
import './error'
|
||||
|
74
client/common/lib/elements/templates/token-list.ts
Normal file
74
client/common/lib/elements/templates/token-list.ts
Normal 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>`
|
||||
}
|
102
client/common/lib/elements/token-list.ts
Normal file
102
client/common/lib/elements/token-list.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
69
client/common/lib/services/token-list.ts
Normal file
69
client/common/lib/services/token-list.ts
Normal 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()
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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}...`)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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.
|
||||
|
Loading…
x
Reference in New Issue
Block a user