Merge branch 'log-in-with-mastodon' into 'main'

logging in with mastodon

See merge request Avris/Zaimki!226
This commit is contained in:
Andrea Vos 2021-12-13 19:22:24 +00:00
commit de4b89db10
22 changed files with 186 additions and 19 deletions

View File

@ -12,9 +12,19 @@
<div class="row flex-row-reverse">
<div class="col-12 col-md-4">
<div class="btn-group-vertical w-100 mb-3">
<a :href="`${homeUrl}/api/user/social-redirect/${provider}/${config.locale}`" v-for="(providerOptions, provider) in socialProviders" class="btn btn-outline-primary">
<a :href="!providerOptions.instanceRequired ? `${homeUrl}/api/user/social-redirect/${provider}/${config.locale}` : null"
:class="providerOptions.instanceRequired ? 'btn border-primary text-primary' : 'btn btn-outline-primary'"
v-for="(providerOptions, provider) in socialProviders">
<Icon :v="providerOptions.icon || provider" set="b"/>
{{ providerOptions.name }}
<form :action="`${homeUrl}/api/user/social-redirect/${provider}/${config.locale}`"
v-if="providerOptions.instanceRequired" class="input-group">
<input type="text" name="instance" class="form-control"
:placeholder="$t('user.login.instancePlaceholder')">
<button type="submit" class="btn btn-outline-primary">
<Icon v="arrow-right"/>
</button>
</form>
</a>
</div>
</div>

View File

@ -5,7 +5,15 @@
{{ providerOptions.name }}
</span>
<span v-if="connection === undefined">
<a :href="`${homeUrl}/api/user/social-redirect/${provider}/${config.locale}`" class="badge bg-light text-dark border">
<form :action="`${homeUrl}/api/user/social-redirect/${provider}/${config.locale}`"
v-if="providerOptions.instanceRequired" class="input-group input-group-sm">
<input type="text" name="instance" class="form-control"
:placeholder="$t('user.login.instancePlaceholder')">
<button type="submit" class="btn btn-outline-secondary">
<Icon v="link"/>
</button>
</form>
<a v-else :href="`${homeUrl}/api/user/social-redirect/${provider}/${config.locale}`" class="badge bg-light text-dark border">
<Icon v="link"/>
<T>user.socialConnection.connect</T>
</a>
@ -18,7 +26,8 @@
{{connection.name}}
</span>
<br class="d-md-none"/>
<a :href="`${homeUrl}/api/user/social-redirect/${provider}/${config.locale}`" class="badge bg-light text-dark border">
<a :href="`${homeUrl}/api/user/social-redirect/${provider}/${config.locale}` + (providerOptions.instanceRequired ? '?instance=' + connection.name.split('@')[1] : '')"
class="badge bg-light text-dark border">
<Icon v="sync"/>
<T>user.socialConnection.refresh</T>
</a>

View File

@ -486,6 +486,7 @@ user:
why: >
Registering lets you manage your cards ({/@example=like this one}).
passwordless: 'The website doesn''t store any passwords. {https://avris.it/blog/passwords-are-passé=More info.}'
instancePlaceholder: 'Instance'
code:
action: 'Validate'
invalid: 'Invalid code.'

View File

@ -384,6 +384,7 @@ user:
why: >
Mit der Registrierung kannst du deine Visitenkarten verwalten ({/@example=wie diese}).
passwordless: 'Die Website speichert keine Passwörter. {https://avris.it/blog/passwords-are-passé=Weitere Infos.}'
instancePlaceholder: 'Instance' # TODO
code:
action: 'Validieren'
invalid: 'Ungültiger Code.'

View File

@ -487,6 +487,7 @@ user:
why: >
Registering lets you manage your cards ({/@example=like this one}).
passwordless: 'The website doesn''t store any passwords. {https://avris.it/blog/passwords-are-passé=More info.}'
instancePlaceholder: 'Instance'
code:
action: 'Validate'
invalid: 'Invalid code.'

View File

@ -397,6 +397,7 @@ user:
why: >
Registrarte te permite manejar tus tarjetas ({/@example=como esta}).
passwordless: 'Este sitio web no guarda las contraseñas. {https://avris.it/blog/passwords-are-passé=More info.}'
instancePlaceholder: 'Instance' # TODO
code:
action: 'Validar'
invalid: 'Código inválido.'

View File

@ -393,6 +393,7 @@ user:
why: >
Sinscrire vous permet de gérer vos cartes ({/@example=comme celle-ci}).
passwordless: 'Ce site ne stocke aucun mot de passe. {https://avris.it/blog/passwords-are-passé=Plus dinfos.}'
instancePlaceholder: 'Instance' # TODO
code:
action: 'Valider'
invalid: 'Code invalide.'

View File

@ -396,6 +396,7 @@ user:
why: >
Registrar-se te permite dirigir os cartões ({/@example=como esta}).
passwordless: 'O site não grava qualquer senha. {https://avris.it/blog/passwords-are-passé=More info.}'
instancePlaceholder: 'Instance' # TODO
code:
action: 'Validar'
invalid: 'Código inválido.'

View File

@ -400,6 +400,7 @@ user:
why: >
ご登録いただいた方は、カードの設定を行うことができます。({/@example=こんなに}).
passwordless: 'このウェブサイトはパスワードを保存しません。 {https://avris.it/blog/passwords-are-passé=詳細はこちら。}'
instancePlaceholder: 'Instance' # TODO
code:
action: '確認'
invalid: '無効なコード'

View File

@ -373,6 +373,7 @@ user:
why: >
Door te registreren kun je een kaart ({/@example=zoals deze}) maken.
passwordless: 'De website slaat geen wachtwoorden op. {https://avris.it/blog/passwords-are-passé=Meer info.}'
instancePlaceholder: 'Instance' # TODO
code:
action: 'Valideer'
invalid: 'Ongeldige code.'

View File

@ -388,6 +388,7 @@ user:
why: >
Å registrere seg lar deg redigere kortene dine ({/@example=sånn som denne}).
passwordless: 'Denne nettsiden lagrer ingen passord. {https://avris.it/blog/passwords-are-passé=More info.}'
instancePlaceholder: 'Instance' # TODO
code:
action: 'Gyldig'
invalid: 'Ugyldig kode.'

View File

@ -1184,6 +1184,7 @@ user:
why: >
Założenie konta pozwala na zarządzanie swoimi wizytówkami ({/@example=takimi jak ta}).
passwordless: 'Strona nie zapisuje żadnych haseł. {https://avris.it/blog/passwords-are-passé=Więcej info.}'
instancePlaceholder: 'Instancja'
code:
action: 'Sprawdź'
invalid: 'Kod nieprawidłowy.'

View File

@ -392,6 +392,7 @@ user:
why: >
Registrar-se te permite dirigir os cartões ({/@example=como esta}).
passwordless: 'O site não grava qualquer senha. {https://avris.it/blog/passwords-are-passé=More info.}'
instancePlaceholder: 'Instance' # TODO
code:
action: 'Validar'
invalid: 'Código inválido.'

View File

@ -425,6 +425,7 @@ user:
why: >
Регистрация позволяет вам управлять своими аккаунтами/карточками ({/@excemple=как, например, этой}).
passwordless: 'Сайт не хранит пароли. {https://avris.it/blog/passwords-are-passé=Больше информации}'
instancePlaceholder: 'Instance' # TODO
code:
action: 'Подтвердить'
invalid: 'Неверный код.'

View File

@ -392,6 +392,7 @@ user:
why: >
Registering lets you manage your cards ({/@example=like this one}).
passwordless: 'The website doesn''t store any passwords. {https://avris.it/blog/passwords-are-passé=More info.}'
instancePlaceholder: 'Instance' # TODO
code:
action: 'Validate'
invalid: 'Invalid code.'

View File

@ -361,6 +361,7 @@ user:
why: >
註冊可以讓你管理你的卡({/@example=像這個})。
passwordless: '該網站不存儲任何密碼。 {https://avris.it/blog/passwords-are-passé=更多信息。}'
instancePlaceholder: 'Instance' # TODO
code:
action: '證實'
invalid: '不對代碼'

View File

@ -0,0 +1,12 @@
-- Up
CREATE TABLE oauth_keys (
instance TEXT NOT NULL PRIMARY KEY,
provider TEXT NOT NULL,
client_id TEXT NOT NULL,
client_secret TEXT NOT NULL
);
-- Down
DROP TABLE oauth_keys;

View File

@ -82,6 +82,7 @@ app.use(async function (req, res, next) {
}
});
app.use(require('./routes/grantOverrides').default);
router.use(grant.express()(require('./social').config));
app.use(require('./routes/home').default);

View File

@ -0,0 +1,105 @@
// grant doesn't care about the specifics of some services,
// so for some services we don't care about grant :))))
import { Router } from 'express';
import SQL from 'sql-template-strings';
import fetch from 'node-fetch';
import assert from 'assert';
import { handleErrorAsync } from "../../src/helpers";
const normalizeDomainName = (domain) => {
const url = new URL('https://' + domain);
assert(url.port === '');
return url.hostname;
}
const config = {
mastodon: {
scopes: ['read:accounts'],
redirect_uri: `${process.env.HOME_URL || 'https://pronouns.page'}/api/user/social/mastodon`,
},
};
const router = Router();
const mastodonGetOAuthKeys = async (db, instance) => {
const existingKeys = await db.get(SQL`
SELECT client_id, client_secret
FROM oauth_keys
WHERE instance = ${instance}
AND provider = 'mastodon'
`);
if (existingKeys) {
return existingKeys;
}
const keys = await fetch(`https://${instance}/api/v1/apps`, {
method: 'POST',
body: new URLSearchParams({
client_name: 'pronouns.page',
redirect_uris: config.mastodon.redirect_uri,
scopes: config.mastodon.scopes.join(' '),
website: process.env.HOME_URL,
}).toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'pronouns.page',
},
}).then(res => res.json());
assert(keys.client_id && keys.client_secret && !keys.error);
db.get(SQL`
INSERT INTO oauth_keys (instance, provider, client_id, client_secret)
VALUES (${instance}, 'mastodon', ${keys.client_id}, ${keys.client_secret})
`);
return keys;
};
router.get('/connect/mastodon', handleErrorAsync(async (req, res) => {
assert(req.query.instance);
const instance = normalizeDomainName(req.query.instance);
const { client_id, client_secret } = await mastodonGetOAuthKeys(req.db, instance);
req.session.grant = { instance, client_id, client_secret };
res.redirect(`https://${instance}/oauth/authorize?` + new URLSearchParams({
client_id,
scope: config.mastodon.scopes.join(' '),
redirect_uri: config.mastodon.redirect_uri,
response_type: 'code',
}));
}));
router.get('/user/social/mastodon', handleErrorAsync(async (req, res, next) => {
if (!req.session.grant || !req.session.grant.instance || !req.query.code) {
next();
return;
}
const { instance, client_id, client_secret } = req.session.grant;
const response = await fetch(`https://${instance}/oauth/token`, {
method: 'POST',
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id,
client_secret,
redirect_uri: config.mastodon.redirect_uri,
scope: config.mastodon.scopes.join(' '),
code: req.query.code,
}).toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'pronouns.page',
},
}).then(res => res.json());
if (!response.access_token || response.error) {
next();
return;
}
const profile = await fetch(`https://${instance}/api/v1/accounts/verify_credentials`, {
headers: {
Authorization: `Bearer ${response.access_token}`,
'User-Agent': 'pronouns.page',
},
}).then(res => res.json());
response.profile = profile;
response.instance = instance;
req.session.grant.response = response;
next();
return;
}));
export default router;

View File

@ -388,7 +388,9 @@ router.post('/user/:id/set-roles', handleErrorAsync(async (req, res) => {
// happens on home
router.get('/user/social-redirect/:provider/:locale', handleErrorAsync(async (req, res) => {
req.session.socialRedirect = req.params.locale;
return res.redirect(`/api/connect/${req.params.provider}`);
return res.redirect(`/api/connect/${req.params.provider}?${new URLSearchParams({
instance: req.query.instance || undefined,
})}`);
}));
// happens on home

View File

@ -30,6 +30,8 @@ export const config = {
callback: '/api/user/social/discord',
scope: ['identify', 'email'],
},
// non-grant, but things break if it's not there
mastodon: {},
}
export const handlers = {
@ -73,4 +75,15 @@ export const handlers = {
access_secret: r.access_secret,
}
},
mastodon(r) {
const acct = `${r.profile.username}@${r.instance}`;
return {
id: acct,
// very possibly not really operated by the user
email: acct,
name: acct,
avatar: r.profile.avatar,
access_token: r.access_token,
};
},
};

View File

@ -7,6 +7,7 @@ export const socialProviders = {
facebook: { name: 'Facebook' },
google: { name: 'Google' },
discord: { name: 'Discord' },
mastodon: { name: 'Mastodon', instanceRequired: true },
}
import pronounsRaw from '../data/pronouns/pronouns.tsv';