Add Sign-in with Mastodon

This commit is contained in:
Daniel Supernault 2023-07-16 07:09:15 -06:00
parent ce02f05718
commit 45b9404ec1
No known key found for this signature in database
GPG key ID: 0DEF1C662C9033F7
11 changed files with 742 additions and 7 deletions

19
app/Models/RemoteAuth.php Normal file
View file

@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class RemoteAuth extends Model
{
use HasFactory;
protected $guarded = [];
protected $casts = [
'verify_credentials' => 'array',
'last_successful_login_at' => 'datetime',
'last_verify_credentials_at' => 'datetime'
];
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class RemoteAuthInstance extends Model
{
use HasFactory;
protected $guarded = [];
}

View file

@ -0,0 +1,183 @@
<?php
namespace App\Services\Account;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use App\Models\RemoteAuthInstance;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
class RemoteAuthService
{
const CACHE_KEY = 'pf:services:remoteauth:';
public static function getMastodonClient($domain)
{
if(RemoteAuthInstance::whereDomain($domain)->exists()) {
return RemoteAuthInstance::whereDomain($domain)->first();
}
try {
$url = 'https://' . $domain . '/api/v1/apps';
$res = Http::asForm()->throw()->timeout(10)->post($url, [
'client_name' => config('pixelfed.domain.app', 'pixelfed'),
'redirect_uris' => url('/auth/mastodon/callback'),
'scopes' => 'read',
'website' => 'https://pixelfed.org'
]);
if(!$res->ok()) {
return false;
}
} catch (RequestException $e) {
return false;
} catch (ConnectionException $e) {
return false;
} catch (Exception $e) {
return false;
}
$body = $res->json();
if(!$body || !isset($body['client_id'])) {
return false;
}
$raw = RemoteAuthInstance::updateOrCreate([
'domain' => $domain
], [
'client_id' => $body['client_id'],
'client_secret' => $body['client_secret'],
'redirect_uri' => $body['redirect_uri'],
]);
return $raw;
}
public static function getToken($domain, $code)
{
$raw = RemoteAuthInstance::whereDomain($domain)->first();
if(!$raw || !$raw->active || $raw->banned) {
return false;
}
$url = 'https://' . $domain . '/oauth/token';
$res = Http::asForm()->post($url, [
'code' => $code,
'grant_type' => 'authorization_code',
'client_id' => $raw->client_id,
'client_secret' => $raw->client_secret,
'redirect_uri' => $raw->redirect_uri,
'scope' => 'read'
]);
return $res;
}
public static function getVerifyCredentials($domain, $code)
{
$raw = RemoteAuthInstance::whereDomain($domain)->first();
if(!$raw || !$raw->active || $raw->banned) {
return false;
}
$url = 'https://' . $domain . '/api/v1/accounts/verify_credentials';
$res = Http::withToken($code)->get($url);
return $res->json();
}
public static function getFollowing($domain, $code, $id)
{
$raw = RemoteAuthInstance::whereDomain($domain)->first();
if(!$raw || !$raw->active || $raw->banned) {
return false;
}
$url = 'https://' . $domain . '/api/v1/accounts/' . $id . '/following?limit=80';
$key = self::CACHE_KEY . 'get-following:code:' . substr($code, 0, 16) . substr($code, -5) . ':domain:' . $domain. ':id:' .$id;
return Cache::remember($key, 3600, function() use($url, $code) {
$res = Http::withToken($code)->get($url);
return $res->json();
});
}
public static function isDomainCompatible($domain = false)
{
if(!$domain) {
return false;
}
return Cache::remember(self::CACHE_KEY . 'domain-compatible:' . $domain, 14400, function() use($domain) {
try {
$res = Http::timeout(20)->retry(3, 750)->get('https://beagle.pixelfed.net/api/v1/raa/domain?domain=' . $domain);
if(!$res->ok()) {
return false;
}
} catch (RequestException $e) {
return false;
} catch (ConnectionException $e) {
return false;
} catch (Exception $e) {
return false;
}
$json = $res->json();
if(!in_array('compatible', $json)) {
return false;
}
return $res['compatible'];
});
}
public static function lookupWebfingerUses($wf)
{
try {
$res = Http::timeout(20)->retry(3, 750)->get('https://beagle.pixelfed.net/api/v1/raa/lookup?webfinger=' . $wf);
if(!$res->ok()) {
return false;
}
} catch (RequestException $e) {
return false;
} catch (ConnectionException $e) {
return false;
} catch (Exception $e) {
return false;
}
$json = $res->json();
if(!$json || !isset($json['count'])) {
return false;
}
return $json['count'];
}
public static function submitToBeagle($ow, $ou, $dw, $du)
{
try {
$url = 'https://beagle.pixelfed.net/api/v1/raa/submit';
$res = Http::throw()->timeout(10)->get($url, [
'ow' => $ow,
'ou' => $ou,
'dw' => $dw,
'du' => $du,
]);
if(!$res->ok()) {
return;
}
} catch (RequestException $e) {
return;
} catch (ConnectionException $e) {
return;
} catch (Exception $e) {
return;
}
return;
}
}

View file

@ -31,13 +31,7 @@ class User extends Authenticatable
* @var array * @var array
*/ */
protected $fillable = [ protected $fillable = [
'name', 'name', 'username', 'email', 'password', 'app_register_ip', 'email_verified_at', 'register_source'
'username',
'email',
'password',
'app_register_ip',
'email_verified_at',
'last_active_at'
]; ];
/** /**

56
config/remote-auth.php Normal file
View file

@ -0,0 +1,56 @@
<?php
return [
'mastodon' => [
'enabled' => env('PF_LOGIN_WITH_MASTODON_ENABLED', false),
'contraints' => [
/*
* Skip email verification
*
* To improve the onboarding experience, you can opt to skip the email
* verification process and automatically verify their email
*/
'skip_email_verification' => env('PF_LOGIN_WITH_MASTODON_SKIP_EMAIL', true),
],
'domains' => [
'default' => 'mastodon.social,mastodon.online,mstdn.social,mas.to',
/*
* Custom mastodon domains
*
* Define a comma separated list of custom domains to allow
*/
'custom' => env('PF_LOGIN_WITH_MASTODON_DOMAINS'),
/*
* Use only default domains
*
* Allow Sign-in with Mastodon using only the default domains
*/
'only_default' => env('PF_LOGIN_WITH_MASTODON_ONLY_DEFAULT', true),
/*
* Use only custom domains
*
* Allow Sign-in with Mastodon using only the custom domains
* you define, in comma separated format
*/
'only_custom' => env('PF_LOGIN_WITH_MASTODON_ONLY_CUSTOM', false),
],
'max_uses' => [
/*
* Max Uses
*
* Using a centralized service operated by pixelfed.org that tracks mastodon imports,
* you can set a limit of how many times a mastodon account can be imported across
* all known and reporting Pixelfed instances to prevent the same masto account from
* abusing this
*/
'enabled' => env('PF_LOGIN_WITH_MASTODON_ENFORCE_MAX_USES', true),
'limit' => env('PF_LOGIN_WITH_MASTODON_MAX_USES_LIMIT', 3)
]
],
];

View file

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('remote_auths', function (Blueprint $table) {
$table->id();
$table->string('software')->nullable();
$table->string('domain')->nullable()->index();
$table->string('webfinger')->nullable()->unique()->index();
$table->unsignedInteger('instance_id')->nullable()->index();
$table->unsignedInteger('user_id')->nullable()->unique()->index();
$table->unsignedInteger('client_id')->nullable()->index();
$table->string('ip_address')->nullable();
$table->text('bearer_token')->nullable();
$table->json('verify_credentials')->nullable();
$table->timestamp('last_successful_login_at')->nullable();
$table->timestamp('last_verify_credentials_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('remote_auths');
}
};

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('remote_auth_instances', function (Blueprint $table) {
$table->id();
$table->string('domain')->nullable()->unique()->index();
$table->unsignedInteger('instance_id')->nullable()->index();
$table->string('client_id')->nullable();
$table->string('client_secret')->nullable();
$table->string('redirect_uri')->nullable();
$table->string('root_domain')->nullable()->index();
$table->boolean('allowed')->nullable()->index();
$table->boolean('banned')->default(false)->index();
$table->boolean('active')->default(true)->index();
$table->timestamp('last_refreshed_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('remote_auth_instances');
}
};

View file

@ -0,0 +1,262 @@
<template>
<div class="container remote-auth-getting-started">
<div class="row mt-5 justify-content-center">
<div class="col-12 col-xl-5 col-md-7">
<div v-if="!error" class="card shadow-none border" style="border-radius: 20px;">
<div v-if="!loaded && !existing && !maxUsesReached" class="card-body d-flex align-items-center flex-column" style="min-height: 400px;">
<div class="w-100">
<p class="lead text-center font-weight-bold">Sign-in with Mastodon</p>
<hr />
</div>
<div class="w-100 d-flex align-items-center justify-content-center flex-grow-1 flex-column gap-1">
<div class="position-relative w-100">
<p class="pa-center">Please wait...</p>
<instagram-loader></instagram-loader>
</div>
<div class="w-100">
<hr>
<p class="text-center mb-0">
<a class="font-weight-bold" href="/login">Go back to login</a>
</p>
</div>
</div>
</div>
<div v-else-if="!loaded && !existing && maxUsesReached" class="card-body d-flex align-items-center flex-column" style="min-height: 660px;">
<div class="w-100">
<p class="lead text-center font-weight-bold">Sign-in with Mastodon</p>
<hr />
</div>
<div class="w-100 d-flex align-items-center justify-content-center flex-grow-1 flex-column gap-1">
<p class="lead text-center font-weight-bold mt-3">Oops!</p>
<p class="mb-2 text-center">We cannot complete your request at this time</p>
<p class="mb-3 text-center text-xs">It appears that you've signed-in on other Pixelfed instances and reached the max limit that we accept.</p>
</div>
<div class="w-100">
<p class="text-center mb-0">
<a class="font-weight-bold" href="/site/contact">Contact Support</a>
</p>
<hr>
<p class="text-center mb-0">
<a class="font-weight-bold" href="/login">Go back to login</a>
</p>
</div>
</div>
<div v-else-if="!loaded && existing" class="card-body d-flex align-items-center flex-column" style="min-height: 660px;">
<div class="w-100">
<p class="lead text-center font-weight-bold">Sign-in with Mastodon</p>
<hr />
</div>
<div class="w-100 d-flex align-items-center justify-content-center flex-grow-1 flex-column gap-1">
<b-spinner />
<div class="text-center">
<p class="lead mb-0">Welcome back!</p>
<p class="text-xs text-muted">One moment please, we're logging you in...</p>
</div>
</div>
</div>
<register-form v-else :initialData="prefill" v-on:setCanReload="setCanReload" />
</div>
<div v-else class="card shadow-none border">
<div class="card-body d-flex align-items-center flex-column" style="min-height: 660px;">
<div class="w-100">
<p class="lead text-center font-weight-bold">Sign-in with Mastodon</p>
<hr />
</div>
<div class="w-100 d-flex align-items-center justify-content-center flex-grow-1 flex-column gap-1">
<p class="lead text-center font-weight-bold mt-3">Oops, something went wrong!</p>
<p class="mb-3">We cannot complete your request at this time, please try again later.</p>
<p class="text-xs text-muted mb-1">This can happen for a few different reasons:</p>
<ul class="text-xs text-muted">
<li>The remote instance cannot be reached</li>
<li>The remote instance is not supported yet</li>
<li>The remote instance has been disabled by admins</li>
<li>The remote instance does not allow remote logins</li>
</ul>
</div>
<div class="w-100">
<hr>
<p class="text-center mb-0">
<a class="font-weight-bold" href="/login">Go back to login</a>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script type="text/javascript">
import { InstagramLoader } from 'vue-content-loader';
import RegisterForm from './partials/RegisterForm.vue';
export default {
components: {
InstagramLoader,
RegisterForm
},
data() {
return {
loaded: false,
error: false,
prefill: false,
existing: undefined,
maxUsesReached: undefined,
tab: 'loading',
canReload: false,
}
},
mounted() {
this.validateSession();
window.onbeforeunload = function () {
if(!this.canReload) {
alert('You are trying to leave.');
return false;
}
}
},
methods: {
validateSession() {
axios.post('/auth/raw/mastodon/s/check')
.then(res => {
if(!res && !res.hasOwnProperty('action')) {
swal('Oops!', 'An unexpected error occured, please try again later', 'error');
return;
}
switch(res.data.action) {
case 'onboard':
this.getPrefillData();
return;
break;
case 'redirect_existing_user':
this.existing = true;
this.canReload = true;
window.onbeforeunload = undefined;
this.redirectExistingUser();
return;
break;
case 'max_uses_reached':
this.maxUsesReached = true;
this.canReload = true;
window.onbeforeunload = undefined;
return;
break;
default:
this.error = true;
return;
break;
}
})
.catch(error => {
this.canReload = true;
window.onbeforeunload = undefined;
this.error = true;
})
},
setCanReload() {
this.canReload = true;
window.onbeforeunload = undefined;
},
redirectExistingUser() {
this.canReload = true;
setTimeout(() => {
this.handleLogin();
}, 1500);
},
handleLogin() {
axios.post('/auth/raw/mastodon/s/login')
.then(res => {
setTimeout(() => {
window.location.reload();
}, 1500);
})
.catch(err => {
this.canReload = false;
this.error = true;
})
},
getPrefillData() {
axios.post('/auth/raw/mastodon/s/prefill')
.then(res => {
this.prefill = res.data;
})
.catch(error => {
this.error = true;
})
.finally(() => {
setTimeout(() => {
this.loaded = true;
}, 1000);
})
}
}
}
</script>
<style lang="scss">
@use '../../../../node_modules/bootstrap/scss/bootstrap';
.remote-auth-getting-started {
.text-xs {
font-size: 12px;
}
.gap-1 {
gap: 1rem;
}
.opacity-50 {
opacity: .3;
}
.server-btn {
@extend .btn;
@extend .btn-primary;
@extend .btn-block;
@extend .rounded-pill;
@extend .font-weight-light;
background: linear-gradient(#6364FF, #563ACC);
}
.other-server-btn {
@extend .btn;
@extend .btn-dark;
@extend .btn-block;
@extend .rounded-pill;
@extend .font-weight-light;
}
.pa-center {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%);
font-weight: 600;
font-size: 16px;
}
}
</style>

View file

@ -0,0 +1,113 @@
<template>
<div class="container remote-auth-start">
<div class="row mt-5 justify-content-center">
<div class="col-12 col-md-5">
<div class="card shadow-none border" style="border-radius: 20px;">
<div v-if="!loaded" class="card-body d-flex justify-content-center flex-column" style="min-height: 662px;">
<p class="lead text-center font-weight-bold mb-0">Sign-in with Mastodon</p>
<div class="w-100">
<hr>
</div>
<div class="d-flex justify-content-center align-items-center flex-grow-1">
<b-spinner />
</div>
</div>
<div v-else class="card-body" style="min-height: 662px;">
<p class="lead text-center font-weight-bold">Sign-in with Mastodon</p>
<hr>
<p class="small text-center mb-3">Select your Mastodon server:</p>
<button
v-for="domain in domains"
type="button"
class="server-btn"
@click="handleRedirect(domain)">
Sign-in with <span class="font-weight-bold">{{ domain }}</span>
</button>
<hr>
<p class="text-center">
<button type="button" class="other-server-btn">Sign-in with a different server</button>
</p>
<div class="w-100">
<hr>
<p class="text-center mb-0">
<a class="font-weight-bold" href="/login">Go back to login</a>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script type="text/javascript">
export default {
data() {
return {
loaded: false,
domains: []
}
},
mounted() {
this.fetchDomains();
},
methods: {
fetchDomains() {
axios.post('/auth/raw/mastodon/domains')
.then(res => {
this.domains = res.data;
})
.finally(() => {
setTimeout(() => {
this.loaded = true;
}, 500);
})
},
handleRedirect(domain) {
axios.post('/auth/raw/mastodon/redirect', { domain: domain })
.then(res => {
if(!res || !res.data.hasOwnProperty('ready')) {
return;
}
if(res.data.hasOwnProperty('action') && res.data.action === 'incompatible_domain') {
swal('Oops!', 'This server is not compatible, please choose another or try again later!', 'error');
return;
}
if(res.data.ready) {
window.location.href = '/auth/raw/mastodon/preflight?d=' + domain + '&dsh=' + res.data.dsh;
}
})
}
}
}
</script>
<style lang="scss">
@use '../../../../node_modules/bootstrap/scss/bootstrap';
.remote-auth-start {
.server-btn {
@extend .btn;
@extend .btn-primary;
@extend .btn-block;
@extend .rounded-pill;
@extend .font-weight-light;
background: linear-gradient(#6364FF, #563ACC);
}
.other-server-btn {
@extend .btn;
@extend .btn-dark;
@extend .btn-block;
@extend .rounded-pill;
@extend .font-weight-light;
}
}
</style>

View file

@ -0,0 +1,10 @@
@extends('layouts.app')
@section('content')
<remote-auth-getting-started-component />
@endsection
@push('scripts')
<script type="text/javascript" src="{{ mix('js/remote_auth.js')}}"></script>
<script type="text/javascript">App.boot();</script>
@endpush

View file

@ -0,0 +1,10 @@
@extends('layouts.app')
@section('content')
<remote-auth-start-component />
@endsection
@push('scripts')
<script type="text/javascript" src="{{ mix('js/remote_auth.js')}}"></script>
<script type="text/javascript">App.boot();</script>
@endpush