Browse Source

Add Sign-in with Mastodon

Daniel Supernault 2 years ago
parent
commit
45b9404ec1

+ 19 - 0
app/Models/RemoteAuth.php

@@ -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'
+    ];
+}

+ 13 - 0
app/Models/RemoteAuthInstance.php

@@ -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 = [];
+}

+ 183 - 0
app/Services/Account/RemoteAuthService.php

@@ -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;
+    }
+}

+ 1 - 7
app/User.php

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

+ 56 - 0
config/remote-auth.php

@@ -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)
+        ]
+    ],
+];

+ 38 - 0
database/migrations/2023_07_07_025757_create_remote_auths_table.php

@@ -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');
+    }
+};

+ 37 - 0
database/migrations/2023_07_07_030427_create_remote_auth_instances_table.php

@@ -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');
+    }
+};

+ 262 - 0
resources/assets/components/remote-auth/GettingStartedComponent.vue

@@ -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>

+ 113 - 0
resources/assets/components/remote-auth/StartComponent.vue

@@ -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>

+ 10 - 0
resources/views/auth/remote/onboarding.blade.php

@@ -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

+ 10 - 0
resources/views/auth/remote/start.blade.php

@@ -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