Fediverse-based authentication (#1846)

* Able to authenticate user against IndieAuth. For #1273

* WIP server indieauth endpoint. For https://github.com/owncast/owncast/issues/1272

* Add migration to remove access tokens from user

* Add authenticated bool to user for display purposes

* Add indieauth modal and auth flair to display names. For #1273

* Validate URLs and display errors

* Renames, cleanups

* Handle relative auth endpoint paths. Add error handling for missing redirects.

* Disallow using display names in use by registered users. Closes #1810

* Verify code verifier via code challenge on callback

* Use relative path to authorization_endpoint

* Post-rebase fixes

* Use a timestamp instead of a bool for authenticated

* Propertly handle and display error in modal

* Use auth'ed timestamp to derive authenticated flag to display in chat

* Fediverse chat auth via OTP

* Increase validity time just in case

* Add fediverse auth into auth modal

* Text, validation, cleanup updates for fedi auth

* Fix typo

* Remove unused images

* Remove unused file

* Add chat display name to auth modal text
This commit is contained in:
Gabe Kangas
2022-04-22 17:23:14 -07:00
committed by GitHub
parent 8b7e2b945e
commit a082cf3a77
21 changed files with 855 additions and 81 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@@ -6,7 +6,6 @@ import { URL_WEBSOCKET } from './utils/constants.js';
import { OwncastPlayer } from './components/player.js';
import SocialIconsList from './components/platform-logos-list.js';
import UsernameForm from './components/chat/username.js';
import VideoPoster from './components/video-poster.js';
import Followers from './components/federation/followers.js';
import Chat from './components/chat/chat.js';
@@ -635,7 +634,7 @@ export default class App extends Component {
showAuthModal() {
const data = {
title: 'Chat',
title: 'Authenticate with chat',
};
this.setState({ authModalData: data });
}
@@ -664,6 +663,7 @@ export default class App extends Component {
// user details are so we can display them properly.
const { user } = e;
const { displayName, authenticated } = user;
this.setState({
username: displayName,
authenticated,
@@ -909,6 +909,7 @@ export default class App extends Component {
authenticated=${authenticated}
onClose=${this.closeAuthModal}
indieAuthEnabled=${indieAuthEnabled}
federationEnabled=${federation.enabled}
/>`}
/>
`;
@@ -1082,6 +1083,7 @@ export default class App extends Component {
${chat} ${externalActionModal} ${fediverseFollowModal}
${notificationModal} ${authModal}
</div>
`;
}

View File

@@ -1,3 +0,0 @@
import { URL_CHAT_INDIEAUTH_BEGIN } from '../utils/constants.js';
export async function beginIndieAuthFlow() {}

View File

@@ -0,0 +1,206 @@
import { h, Component } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
const html = htm.bind(h);
export default class FediverseAuth extends Component {
constructor(props) {
super(props);
this.submitButtonPressed = this.submitButtonPressed.bind(this);
this.state = {
account: '',
code: '',
errorMessage: null,
loading: false,
verifying: false,
valid: false,
};
}
async makeRequest(url, data) {
const rawResponse = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
const content = await rawResponse.json();
if (content.message) {
this.setState({ errorMessage: content.message, loading: false });
return;
}
}
switchToCodeVerify() {
this.setState({ verifying: true, loading: false });
}
async validateCodeButtonPressed() {
const { accessToken } = this.props;
const { code } = this.state;
this.setState({ loading: true, errorMessage: null });
const url = `/api/auth/fediverse/verify?accessToken=${accessToken}`;
const data = { code: code };
try {
await this.makeRequest(url, data);
// Success. Reload the page.
window.location = '/';
} catch (e) {
console.error(e);
this.setState({ errorMessage: e, loading: false });
}
}
async registerAccountButtonPressed() {
const { accessToken } = this.props;
const { account, valid } = this.state;
if (!valid) {
return;
}
const url = `/api/auth/fediverse?accessToken=${accessToken}`;
const normalizedAccount = account.replace(/^@+/, '');
const data = { account: normalizedAccount };
this.setState({ loading: true, errorMessage: null });
try {
await this.makeRequest(url, data);
this.switchToCodeVerify();
} catch (e) {
console.error(e);
this.setState({ errorMessage: e, loading: false });
}
}
async submitButtonPressed() {
const { verifying } = this.state;
if (verifying) {
this.validateCodeButtonPressed();
} else {
this.registerAccountButtonPressed();
}
}
onInput = (e) => {
const { value } = e.target;
const { verifying } = this.state;
if (verifying) {
this.setState({ code: value });
return;
}
const valid = validateAccount(value);
this.setState({ account: value, valid });
};
render() {
const { errorMessage, account, code, valid, loading, verifying } =
this.state;
const { authenticated, username } = this.props;
const buttonState = valid ? '' : 'cursor-not-allowed opacity-50';
const loaderStyle = loading ? 'flex' : 'none';
const message = verifying
? 'Paste in the code that was sent to your Fediverse account. If you did not receive a code, make sure you can accept direct messages.'
: !authenticated
? html`Receive a direct message from on the Fediverse to ${' '} link your
account to ${' '} <span class="font-bold">${username}</span>, or login
as a previously linked chat user.`
: html`<span
><b>You are already authenticated</b>. However, you can add other
accounts or log in as a different user.</span
>`;
const label = verifying ? 'Code' : 'Your fediverse account';
const placeholder = verifying ? '123456' : 'youraccount@fediverse.server';
const buttonText = verifying ? 'Verify' : 'Authenticate with Fediverse';
const error = errorMessage
? html` <div
class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
role="alert"
>
<div class="font-bold mb-2">There was an error.</div>
<div class="block mt-2">
Server error:
<div>${errorMessage}</div>
</div>
</div>`
: null;
return html`
<div class="bg-gray-100 bg-center bg-no-repeat">
<p class="text-gray-700 text-md">${message}</p>
${error}
<div class="mb34">
<label
class="block text-gray-700 text-sm font-semibold mt-6"
for="username"
>
${label}
</label>
<input
onInput=${this.onInput}
type="url"
value=${verifying ? code : account}
class="border bg-white rounded w-full py-2 px-3 mb-2 mt-2 text-indigo-700 leading-tight focus:outline-none focus:shadow-outline"
id="username"
type="text"
placeholder=${placeholder}
/>
<button
class="bg-indigo-500 hover:bg-indigo-600 text-white font-bold py-2 mt-6 px-4 rounded focus:outline-none focus:shadow-outline ${buttonState}"
type="button"
onClick=${this.submitButtonPressed}
>
${buttonText}
</button>
</div>
<p class="mt-4">
<details>
<summary class="cursor-pointer">
Learn more about using the Fediverse to authenticate with chat.
</summary>
<div class="inline">
<p class="mt-4">
You can link your chat identity with your Fediverse identity.
Next time you want to use this chat identity you can again go
through the Fediverse authentication.
</p>
</div>
</details>
</p>
<div
id="follow-loading-spinner-container"
style="display: ${loaderStyle}"
>
<img id="follow-loading-spinner" src="/img/loading.gif" />
<p class="text-gray-700 text-lg">Authenticating.</p>
<p class="text-gray-600 text-lg">Please wait...</p>
</div>
</div>
`;
}
}
function validateAccount(account) {
account = account.replace(/^@+/, '');
var regex =
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return regex.test(String(account).toLowerCase());
}

View File

@@ -16,7 +16,7 @@ export default class IndieAuthForm extends Component {
}
async submitButtonPressed() {
const { accessToken, authenticated } = this.props;
const { accessToken } = this.props;
const { host, valid } = this.state;
if (!valid) {
@@ -68,17 +68,17 @@ export default class IndieAuthForm extends Component {
render() {
const { errorMessage, loading, host, valid } = this.state;
const { authenticated } = this.props;
const { authenticated, username } = this.props;
const buttonState = valid ? '' : 'cursor-not-allowed opacity-50';
const loaderStyle = loading ? 'flex' : 'none';
const message = !authenticated
? `While you can chat completely anonymously you can also add
authentication so you can rejoin with the same chat persona from any
device or browser.`
? html`Use your own domain to authenticate ${' '}
<span class="font-bold">${username}</span> or login as a previously
${' '} authenticated chat user using IndieAuth.`
: html`<span
><b>You are already authenticated</b>. However, you can add other
external sites or log in as a different user.</span
domains or log in as a different user.</span
>`;
let errorMessageText = errorMessage;
@@ -134,7 +134,7 @@ export default class IndieAuthForm extends Component {
<p class="mt-4">
<details>
<summary class="cursor-pointer">
Learn more about <span class="text-blue-500">IndieAuth</span>
Learn more about using IndieAuth to authenticate with chat.
</summary>
<div class="inline">
<p class="mt-4">
@@ -153,11 +153,6 @@ export default class IndieAuthForm extends Component {
</details>
</p>
<p class="mt-4">
<b>Note:</b> This is for authentication purposes only, and no personal
information will be accessed or stored.
</p>
<div
id="follow-loading-spinner-container"
style="display: ${loaderStyle}"

View File

@@ -0,0 +1,162 @@
import { h, Component } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
import { ExternalActionButton } from './external-action-modal.js';
const html = htm.bind(h);
export default class AuthModal extends Component {
constructor(props) {
super(props);
this.submitButtonPressed = this.submitButtonPressed.bind(this);
this.state = {
errorMessage: null,
loading: false,
valid: false,
};
}
async submitButtonPressed() {
const { accessToken } = this.props;
const { host, valid } = this.state;
if (!valid) {
return;
}
const url = `/api/auth/indieauth?accessToken=${accessToken}`;
const data = { authHost: host };
this.setState({ loading: true });
try {
const rawResponse = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
const content = await rawResponse.json();
if (content.message) {
this.setState({ errorMessage: content.message, loading: false });
return;
} else if (!content.redirect) {
this.setState({
errorMessage: 'Auth provider did not return a redirect URL.',
loading: false,
});
return;
}
const redirect = content.redirect;
window.location = redirect;
} catch (e) {
console.error(e);
this.setState({ errorMessage: e, loading: false });
}
}
onInput = (e) => {
const { value } = e.target;
let valid = validateURL(value);
this.setState({ host: value, valid });
};
render() {
const { errorMessage, host, valid, loading } = this.state;
const { authenticated } = this.props;
const buttonState = valid ? '' : 'cursor-not-allowed opacity-50';
const loaderStyle = loading ? 'flex' : 'none';
const message = !authenticated
? `While you can chat completely anonymously you can also add
authentication so you can rejoin with the same chat persona from any
device or browser.`
: `You are already authenticated, however you can add other external sites or accounts to your chat account or log in as a different user.`;
const error = errorMessage
? html` <div
class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
role="alert"
>
<div class="font-bold mb-2">There was an error.</div>
<div class="block mt-2">
Server error:
<div>${errorMessage}</div>
</div>
</div>`
: null;
return html`
<div class="bg-gray-100 bg-center bg-no-repeat p-4">
<p class="text-gray-700 text-md">${message}</p>
${error}
<div class="mb34">
<label
class="block text-gray-700 text-sm font-semibold mt-6"
for="username"
>
IndieAuth with your own site
</label>
<input
onInput=${this.onInput}
type="url"
value=${host}
class="border bg-white rounded w-full py-2 px-3 mb-2 mt-2 text-indigo-700 leading-tight focus:outline-none focus:shadow-outline"
id="username"
type="text"
placeholder="https://yoursite.com"
/>
<button
class="bg-indigo-500 hover:bg-indigo-600 text-white font-bold py-2 mt-6 px-4 rounded focus:outline-none focus:shadow-outline ${buttonState}"
type="button"
onClick=${this.submitButtonPressed}
>
Authenticate with IndieAuth
</button>
<p>
Learn more about
<a class="underline" href="https://indieauth.net/">IndieAuth</a>.
</p>
</div>
<div
id="follow-loading-spinner-container"
style="display: ${loaderStyle}"
>
<img id="follow-loading-spinner" src="/img/loading.gif" />
<p class="text-gray-700 text-lg">Authenticating.</p>
<p class="text-gray-600 text-lg">Please wait...</p>
</div>
</div>
`;
}
}
function validateURL(url) {
if (!url) {
return false;
}
try {
const u = new URL(url);
if (!u) {
return false;
}
if (u.protocol !== 'https:') {
return false;
}
} catch (e) {
return false;
}
return true;
}

View File

@@ -2,6 +2,7 @@ import { h, Component } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
import TabBar from './tab-bar.js';
import IndieAuthForm from './auth-indieauth.js';
import FediverseAuth from './auth-fediverse.js';
const html = htm.bind(h);
@@ -10,13 +11,14 @@ export default class ChatSettingsModal extends Component {
const {
accessToken,
authenticated,
federationEnabled,
username,
onUsernameChange,
indieAuthEnabled,
} = this.props;
const TAB_CONTENT = [
{
const TAB_CONTENT = [];
if (indieAuthEnabled) {
TAB_CONTENT.push({
label: html`<span style=${{ display: 'flex', alignItems: 'center' }}
><img
style=${{
@@ -31,13 +33,40 @@ export default class ChatSettingsModal extends Component {
content: html`<${IndieAuthForm}}
accessToken=${accessToken}
authenticated=${authenticated}
username=${username}
/>`,
},
];
});
}
if (federationEnabled) {
TAB_CONTENT.push({
label: html`<span style=${{ display: 'flex', alignItems: 'center' }}
><img
style=${{
display: 'inline',
height: '0.8em',
marginRight: '5px',
}}
src="/img/fediverse-black.png"
/>
FediAuth</span
>`,
content: html`<${FediverseAuth}}
authenticated=${authenticated}
accessToken=${accessToken}
authenticated=${authenticated}
username=${username}
/>`,
});
}
return html`
<div class="bg-gray-100 bg-center bg-no-repeat p-5">
<${TabBar} tabs=${TAB_CONTENT} ariaLabel="Chat settings" />
<p class="mt-4">
<b>Note:</b> This is for authentication purposes only, and no personal
information will be accessed or stored.
</p>
</div>
`;
}

View File

@@ -572,6 +572,7 @@ header {
width: 100%;
height: 100%;
opacity: 0.7;
z-index: 100;
}
#follow-loading-spinner {