Will Browning 4 年之前
父节点
当前提交
793f052eca
共有 34 个文件被更改,包括 1234 次插入402 次删除
  1. 2 2
      .env.example
  2. 2 1
      app/Console/Commands/ReceiveEmail.php
  3. 32 0
      app/Http/Controllers/Api/FailedDeliveryController.php
  4. 7 1
      app/Http/Controllers/Auth/WebauthnController.php
  5. 27 0
      app/Http/Controllers/Auth/WebauthnEnabledKeyController.php
  6. 1 1
      app/Http/Controllers/ShowFailedDeliveryController.php
  7. 1 1
      app/Http/Kernel.php
  8. 64 0
      app/Http/Middleware/VerifyWebauthn.php
  9. 27 0
      app/Http/Resources/FailedDeliveryResource.php
  10. 1 1
      app/Models/Domain.php
  11. 3 1
      app/Models/FailedDelivery.php
  12. 37 0
      app/Models/WebauthnKey.php
  13. 22 0
      app/Services/Webauthn.php
  14. 434 117
      composer.lock
  15. 4 4
      config/version.yml
  16. 2 1
      database/factories/FailedDeliveryFactory.php
  17. 32 0
      database/migrations/2021_08_03_085607_add_attempted_at_to_failed_deliveries_table.php
  18. 32 0
      database/migrations/2021_08_04_104448_add_enabled_to_webauthn_keys_table.php
  19. 236 246
      package-lock.json
  20. 5 1
      resources/js/app.js
  21. 43 1
      resources/js/components/WebauthnKeys.vue
  22. 102 0
      resources/js/pages/FailedDeliveries.vue
  23. 2 2
      resources/views/settings/show.blade.php
  24. 29 8
      resources/views/vendor/webauthn/authenticate.blade.php
  25. 4 0
      routes/api.php
  26. 2 0
      routes/web.php
  27. 1 1
      tests/Feature/Api/AccountDetailsTest.php
  28. 2 2
      tests/Feature/Api/AdditionalUsernamesTest.php
  29. 4 4
      tests/Feature/Api/AliasesTest.php
  30. 1 1
      tests/Feature/Api/AppVersionTest.php
  31. 2 2
      tests/Feature/Api/DomainsTest.php
  32. 67 0
      tests/Feature/Api/FailedDeliveriesTest.php
  33. 2 2
      tests/Feature/Api/RecipientsTest.php
  34. 2 2
      tests/Feature/Api/RulesTest.php

+ 2 - 2
.env.example

@@ -30,9 +30,9 @@ REDIS_PORT=6379
 MAIL_FROM_NAME=Example
 MAIL_FROM_ADDRESS=mailer@example.com
 MAIL_DRIVER=smtp
-MAIL_HOST=mail.example.com
+MAIL_HOST=localhost
 MAIL_PORT=25
-MAIL_ENCRYPTION=tls
+MAIL_ENCRYPTION=null
 
 ANONADDY_RETURN_PATH=mailer@example.com
 ANONADDY_ADMIN_USERNAME=johndoe

+ 2 - 1
app/Console/Commands/ReceiveEmail.php

@@ -316,7 +316,7 @@ class ReceiveEmail extends Command
             }
 
             // Try to find a user from the bounced email address
-            if ($recipient = Recipient::select(['id', 'email', 'email_verified_at'])->whereNotNull('email_verified_at')->get()->firstWhere('email', $bouncedEmailAddress)) {
+            if ($recipient = Recipient::select(['id', 'email'])->get()->firstWhere('email', $bouncedEmailAddress)) {
                 if (!isset($user)) {
                     $user = $recipient->user;
                 }
@@ -343,6 +343,7 @@ class ReceiveEmail extends Command
                     'email_type' => $parts[0] ?? null,
                     'status' => $dsn['Status'] ?? null,
                     'code' => $diagnosticCode,
+                    'attempted_at' => $postfixQueueId->created_at
                 ]);
             } else {
                 Log::info([

+ 32 - 0
app/Http/Controllers/Api/FailedDeliveryController.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use App\Http\Controllers\Controller;
+use App\Http\Resources\FailedDeliveryResource;
+
+class FailedDeliveryController extends Controller
+{
+    public function index()
+    {
+        $failedDeliveries = user()->failedDeliveries()->latest();
+
+        return FailedDeliveryResource::collection($failedDeliveries->get());
+    }
+
+    public function show($id)
+    {
+        $failedDelivery = user()->failedDeliveries()->findOrFail($id);
+
+        return new FailedDeliveryResource($failedDelivery);
+    }
+
+    public function destroy($id)
+    {
+        $failedDelivery = user()->failedDeliveries()->findOrFail($id);
+
+        $failedDelivery->delete();
+
+        return response('', 204);
+    }
+}

+ 7 - 1
app/Http/Controllers/Auth/WebauthnController.php

@@ -16,7 +16,7 @@ class WebauthnController extends ControllersWebauthnController
 {
     public function index()
     {
-        return user()->webauthnKeys()->latest()->select(['id','name','created_at'])->get()->values();
+        return user()->webauthnKeys()->latest()->select(['id','name','enabled','created_at'])->get()->values();
     }
 
     /**
@@ -75,6 +75,12 @@ class WebauthnController extends ControllersWebauthnController
     protected function redirectAfterSuccessRegister($webauthnKey)
     {
         if ($this->config->get('webauthn.register.postSuccessRedirectRoute', '') !== '') {
+
+            // If the user already has at least one key do not generate a new backup code.
+            if (user()->webauthnKeys()->count() > 1) {
+                return Redirect::intended($this->config->get('webauthn.register.postSuccessRedirectRoute'));
+            }
+
             user()->update([
                 'two_factor_backup_code' => bcrypt($code = Str::random(40))
             ]);

+ 27 - 0
app/Http/Controllers/Auth/WebauthnEnabledKeyController.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace App\Http\Controllers\Auth;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+
+class WebauthnEnabledKeyController extends Controller
+{
+    public function store(Request $request)
+    {
+        $webauthnKey = user()->webauthnKeys()->findOrFail($request->id);
+
+        $webauthnKey->enable();
+
+        return response('', 201);
+    }
+
+    public function destroy($id)
+    {
+        $webauthnKey = user()->webauthnKeys()->findOrFail($id);
+
+        $webauthnKey->disable();
+
+        return response('', 204);
+    }
+}

+ 1 - 1
app/Http/Controllers/ShowFailedDeliveryController.php

@@ -10,7 +10,7 @@ class ShowFailedDeliveryController extends Controller
             'failedDeliveries' => user()
                 ->failedDeliveries()
                 ->with(['recipient:id,email','alias:id,email'])
-                ->select(['alias_id','bounce_type','code','created_at','id','recipient_id','remote_mta','sender'])
+                ->select(['alias_id','bounce_type','code','attempted_at','created_at','id','recipient_id','remote_mta','sender'])
                 ->latest()
                 ->get()
         ]);

+ 1 - 1
app/Http/Kernel.php

@@ -64,6 +64,6 @@ class Kernel extends HttpKernel
         'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
         'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
         '2fa' => \App\Http\Middleware\VerifyTwoFactorAuth::class,
-        'webauthn' => \LaravelWebauthn\Http\Middleware\WebauthnMiddleware::class,
+        'webauthn' => \App\Http\Middleware\VerifyWebauthn::class,
     ];
 }

+ 64 - 0
app/Http/Middleware/VerifyWebauthn.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use App\Facades\Webauthn;
+use Closure;
+use Illuminate\Contracts\Auth\Factory as AuthFactory;
+use Illuminate\Contracts\Config\Repository as Config;
+use Illuminate\Support\Facades\Redirect;
+
+class VerifyWebauthn
+{
+    /**
+     * The config repository instance.
+     *
+     * @var \Illuminate\Contracts\Config\Repository
+     */
+    protected $config;
+
+    /**
+     * The auth factory instance.
+     *
+     * @var \Illuminate\Contracts\Auth\Factory
+     */
+    protected $auth;
+
+    /**
+     * Create a Webauthn.
+     *
+     * @param \Illuminate\Contracts\Config\Repository $config
+     * @param \Illuminate\Contracts\Auth\Factory $auth
+     */
+    public function __construct(Config $config, AuthFactory $auth)
+    {
+        $this->config = $config;
+        $this->auth = $auth;
+    }
+
+    /**
+     * Handle an incoming request.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \Closure  $next
+     * @param  string|null  $guard
+     * @return mixed
+     */
+    public function handle($request, Closure $next, $guard = null)
+    {
+        if ((bool) $this->config->get('webauthn.enable', true) &&
+            ! Webauthn::check()) {
+            abort_if($this->auth->guard($guard)->guest(), 401, trans('webauthn::errors.user_unauthenticated'));
+
+            if (Webauthn::enabled($request->user($guard))) {
+                if ($request->hasSession() && $request->session()->has('url.intended')) {
+                    return Redirect::to(route('webauthn.login'));
+                } else {
+                    return Redirect::guest(route('webauthn.login'));
+                }
+            }
+        }
+
+        return $next($request);
+    }
+}

+ 27 - 0
app/Http/Resources/FailedDeliveryResource.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Resources\Json\JsonResource;
+
+class FailedDeliveryResource extends JsonResource
+{
+    public function toArray($request)
+    {
+        return [
+            'id' => $this->id,
+            'user_id' => $this->user_id,
+            'recipient_id' => $this->recipient_id,
+            'alias_id' => $this->alias_id,
+            'bounce_type' => $this->bounce_type,
+            'remote_mta' => $this->remote_mta,
+            'sender' => $this->sender,
+            'email_type' => $this->email_type,
+            'status' => $this->status,
+            'code' => $this->code,
+            'attempted_at' => $this->attempted_at ? $this->attempted_at->toDateTimeString() : null,
+            'created_at' => $this->created_at->toDateTimeString(),
+            'updated_at' => $this->updated_at->toDateTimeString(),
+        ];
+    }
+}

+ 1 - 1
app/Models/Domain.php

@@ -275,7 +275,7 @@ class Domain extends Model
 
         return response()->json([
             'success' => true,
-            'message' => 'Records successfully verified for sending.',
+            'message' => 'Records successfully verified.',
             'data' => new DomainResource($this->fresh())
         ]);
     }

+ 3 - 1
app/Models/FailedDelivery.php

@@ -29,10 +29,12 @@ class FailedDelivery extends Model
         'sender',
         'email_type',
         'status',
-        'code'
+        'code',
+        'attempted_at'
     ];
 
     protected $dates = [
+        'attempted_at',
         'created_at',
         'updated_at'
     ];

+ 37 - 0
app/Models/WebauthnKey.php

@@ -12,4 +12,41 @@ class WebauthnKey extends ModelsWebauthnKey
     public $incrementing = false;
 
     protected $keyType = 'string';
+
+    protected $fillable = [
+        'user_id',
+        'name',
+        'enabled',
+        'credentialId',
+        'type',
+        'transports',
+        'attestationType',
+        'trustPath',
+        'aaguid',
+        'credentialPublicKey',
+        'counter',
+        'timestamp',
+    ];
+
+    protected $casts = [
+        'enabled' => 'boolean',
+        'counter' => 'integer',
+        'transports' => 'array',
+    ];
+
+    /**
+     * Enabled the key for use.
+     */
+    public function enable()
+    {
+        $this->update(['enabled' => true]);
+    }
+
+    /**
+     * Disable the key for use.
+     */
+    public function disable()
+    {
+        $this->update(['enabled' => false]);
+    }
 }

+ 22 - 0
app/Services/Webauthn.php

@@ -46,4 +46,26 @@ class Webauthn extends ServicesWebauthn
 
         return $webauthnKey;
     }
+
+    /**
+     * Test if the user has one webauthn key set or more.
+     *
+     * @param \Illuminate\Contracts\Auth\Authenticatable  $user
+     * @return bool
+     */
+    public function enabled(User $user): bool
+    {
+        return (bool) $this->config->get('webauthn.enable', true) && $this->hasKey($user);
+    }
+
+    /**
+     * Detect if user has a key that is enabled.
+     *
+     * @param User $user
+     * @return bool
+     */
+    public function hasKey(User $user): bool
+    {
+        return WebauthnKey::where('user_id', $user->getAuthIdentifier())->where('enabled', true)->count() > 0;
+    }
 }

文件差异内容过多而无法显示
+ 434 - 117
composer.lock


+ 4 - 4
config/version.yml

@@ -3,11 +3,11 @@ blade-directive: version
 current:
   label: v
   major: 0
-  minor: 7
-  patch: 5
-  prerelease: 1-g7b9a95c
+  minor: 8
+  patch: 0
+  prerelease: ''
   buildmetadata: ''
-  commit: 7b9a95
+  commit: 193b32
   timestamp:
     year: 2020
     month: 10

+ 2 - 1
database/factories/FailedDeliveryFactory.php

@@ -22,7 +22,8 @@ class FailedDeliveryFactory extends Factory
     public function definition()
     {
         return [
-            //
+            'status' => '5.7.1',
+            'code' => $this->faker->sentence(5)
         ];
     }
 }

+ 32 - 0
database/migrations/2021_08_03_085607_add_attempted_at_to_failed_deliveries_table.php

@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class AddAttemptedAtToFailedDeliveriesTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('failed_deliveries', function (Blueprint $table) {
+            $table->timestamp('attempted_at')->nullable()->after('code');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('failed_deliveries', function (Blueprint $table) {
+            $table->dropColumn('attempted_at');
+        });
+    }
+}

+ 32 - 0
database/migrations/2021_08_04_104448_add_enabled_to_webauthn_keys_table.php

@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class AddEnabledToWebauthnKeysTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('webauthn_keys', function (Blueprint $table) {
+            $table->boolean('enabled')->after('name')->default(true);
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('webauthn_keys', function (Blueprint $table) {
+            $table->dropColumn('enabled');
+        });
+    }
+}

文件差异内容过多而无法显示
+ 236 - 246
package-lock.json


+ 5 - 1
resources/js/app.js

@@ -39,7 +39,11 @@ Vue.component(
 Vue.component('webauthn-keys', require('./components/WebauthnKeys.vue').default)
 
 Vue.filter('formatDate', value => {
-  return dayjs(value).format('Do MMM YYYY')
+  return dayjs.utc(value).format('Do MMM YYYY')
+})
+
+Vue.filter('formatDateTime', value => {
+  return dayjs.utc(value).format('Do MMM YYYY h:mm A')
 })
 
 Vue.filter('timeAgo', value => {

+ 43 - 1
resources/js/components/WebauthnKeys.vue

@@ -7,7 +7,7 @@
 
       <p class="my-6">
         Webauthn Keys you have registered for 2nd factor authentication. To remove a key simply
-        click the delete button next to it.
+        click the delete button next to it. Disabling all keys will turn off 2FA on your account.
       </p>
 
       <div>
@@ -17,6 +17,7 @@
           <div class="table-row">
             <div class="table-cell p-1 md:p-4 font-semibold">Name</div>
             <div class="table-cell p-1 md:p-4 font-semibold">Created</div>
+            <div class="table-cell p-1 md:p-4 font-semibold">Enabled</div>
             <div class="table-cell p-1 md:p-4 text-right">
               <a href="/webauthn/register" class="text-indigo-700">Add New Device</a>
             </div>
@@ -24,6 +25,9 @@
           <div v-for="key in keys" :key="key.id" class="table-row even:bg-grey-50 odd:bg-white">
             <div class="table-cell p-1 md:p-4">{{ key.name }}</div>
             <div class="table-cell p-1 md:p-4">{{ key.created_at | timeAgo }}</div>
+            <div class="table-cell p-1 md:p-4">
+              <Toggle v-model="key.enabled" @on="enableKey(key.id)" @off="disableKey(key.id)" />
+            </div>
             <div class="table-cell p-1 md:p-4 text-right">
               <a
                 class="text-red-500 font-bold cursor-pointer focus:outline-none"
@@ -96,10 +100,12 @@
 
 <script>
 import Modal from './Modal.vue'
+import Toggle from './../components/Toggle.vue'
 
 export default {
   components: {
     Modal,
+    Toggle,
   },
   data() {
     return {
@@ -139,6 +145,42 @@ export default {
         }
       })
     },
+    enableKey(id) {
+      axios
+        .post(
+          `/webauthn/enabled-keys`,
+          JSON.stringify({
+            id: id,
+          }),
+          {
+            headers: { 'Content-Type': 'application/json' },
+          }
+        )
+        .then(response => {
+          //
+        })
+        .catch(error => {
+          if (error.response !== undefined) {
+            this.error(error.response.data)
+          } else {
+            this.error()
+          }
+        })
+    },
+    disableKey(id) {
+      axios
+        .delete(`/webauthn/enabled-keys/${id}`)
+        .then(response => {
+          //
+        })
+        .catch(error => {
+          if (error.response !== undefined) {
+            this.error(error.response.data)
+          } else {
+            this.error()
+          }
+        })
+    },
     closeDeleteKeyModal() {
       this.deleteKeyModalOpen = false
     },

+ 102 - 0
resources/js/pages/FailedDeliveries.vue

@@ -139,6 +139,19 @@
         <span v-else-if="props.column.field == 'code'" class="text-sm">
           {{ props.row.code }}
         </span>
+        <span
+          v-else-if="props.column.field == 'attempted_at'"
+          class="tooltip outline-none text-sm"
+          :data-tippy-content="rows[props.row.originalIndex].attempted_at | formatDateTime"
+          >{{ props.row.attempted_at | timeAgo }}
+        </span>
+        <span v-else class="flex items-center justify-center outline-none" tabindex="-1">
+          <icon
+            name="trash"
+            class="block w-6 h-6 text-grey-300 fill-current cursor-pointer"
+            @click.native="openDeleteModal(props.row.id)"
+          />
+        </span>
       </template>
     </vue-good-table>
 
@@ -158,10 +171,64 @@
         </p>
       </div>
     </div>
+
+    <Modal :open="deleteFailedDeliveryModalOpen" @close="closeDeleteModal">
+      <div class="max-w-lg w-full bg-white rounded-lg shadow-2xl p-6">
+        <h2
+          class="font-semibold text-grey-900 text-2xl leading-tight border-b-2 border-grey-100 pb-4"
+        >
+          Delete Failed Delivery
+        </h2>
+        <p class="mt-4 text-grey-700">Are you sure you want to delete this failed delivery?</p>
+        <p class="mt-4 text-grey-700">
+          Failed deliveries are automatically removed when they are more than 3 days old.
+        </p>
+        <div class="mt-6">
+          <button
+            type="button"
+            @click="deleteFailedDelivery(failedDeliveryIdToDelete)"
+            class="
+              px-4
+              py-3
+              text-white
+              font-semibold
+              bg-red-500
+              hover:bg-red-600
+              border border-transparent
+              rounded
+              focus:outline-none
+            "
+            :class="deleteFailedDeliveryLoading ? 'cursor-not-allowed' : ''"
+            :disabled="deleteFailedDeliveryLoading"
+          >
+            Delete failed delivery
+            <loader v-if="deleteFailedDeliveryLoading" />
+          </button>
+          <button
+            @click="closeDeleteModal"
+            class="
+              ml-4
+              px-4
+              py-3
+              text-grey-800
+              font-semibold
+              bg-white
+              hover:bg-grey-50
+              border border-grey-100
+              rounded
+              focus:outline-none
+            "
+          >
+            Cancel
+          </button>
+        </div>
+      </div>
+    </Modal>
   </div>
 </template>
 
 <script>
+import Modal from './../components/Modal.vue'
 import { roundArrow } from 'tippy.js'
 import 'tippy.js/dist/svg-arrow.css'
 import 'tippy.js/dist/tippy.css'
@@ -174,10 +241,16 @@ export default {
       required: true,
     },
   },
+  components: {
+    Modal,
+  },
   data() {
     return {
       search: '',
       errors: {},
+      deleteFailedDeliveryLoading: false,
+      deleteFailedDeliveryModalOpen: false,
+      failedDeliveryIdToDelete: null,
       columns: [
         {
           label: 'Created',
@@ -219,6 +292,11 @@ export default {
           field: 'code',
           sortable: false,
         },
+        {
+          label: 'First Attempted',
+          field: 'attempted_at',
+          globalSearchDisabled: true,
+        },
         {
           label: '',
           field: 'actions',
@@ -244,6 +322,30 @@ export default {
     debounceToolips: _.debounce(function () {
       this.addTooltips()
     }, 50),
+    openDeleteModal(id) {
+      this.deleteFailedDeliveryModalOpen = true
+      this.failedDeliveryIdToDelete = id
+    },
+    closeDeleteModal() {
+      this.deleteFailedDeliveryModalOpen = false
+      this.failedDeliveryIdToDelete = null
+    },
+    deleteFailedDelivery(id) {
+      this.deleteFailedDeliveryLoading = true
+
+      axios
+        .delete(`/api/v1/failed-deliveries/${id}`)
+        .then(response => {
+          this.rows = _.reject(this.rows, delivery => delivery.id === id)
+          this.deleteFailedDeliveryModalOpen = false
+          this.deleteFailedDeliveryLoading = false
+        })
+        .catch(error => {
+          this.error()
+          this.deleteFailedDeliveryLoading = false
+          this.deleteFailedDeliveryModalOpen = false
+        })
+    },
     clipboardSuccess() {
       this.success('Copied to clipboard')
     },

+ 2 - 2
resources/views/settings/show.blade.php

@@ -438,7 +438,7 @@
 
                 @else
 
-                    @if(App\Facades\Webauthn::enabled($user))
+                    @if(LaravelWebauthn\Facades\Webauthn::enabled($user))
 
                         <webauthn-keys />
 
@@ -506,7 +506,7 @@
                             <a
                             type="button"
                             href="/webauthn/register"
-                            class="bg-cyan-400 w-full hover:bg-cyan-300 text-cyan-900 font-bold py-3 px-4 rounded focus:outline-none text-center"
+                            class="block bg-cyan-400 w-full hover:bg-cyan-300 text-cyan-900 font-bold py-3 px-4 rounded focus:outline-none text-center"
                             >
                                 Register U2F Device
                             </a>

+ 29 - 8
resources/views/vendor/webauthn/authenticate.blade.php

@@ -31,6 +31,8 @@
                         {{ trans('webauthn::messages.buttonAdvise') }}
                         <br />
                         {{ trans('webauthn::messages.noButtonAdvise') }}
+                        <br />
+                        If nothing happens then click the button below to authenticate.
                     </p>
 
                     <form method="POST" action="{{ route('webauthn.auth') }}" id="form">
@@ -38,6 +40,12 @@
                         <input type="hidden" name="data" id="data" />
                     </form>
 
+                    <div class="mt-4">
+                        <button onclick="authenticateDevice()" class="bg-cyan-400 w-full hover:bg-cyan-300 text-cyan-900 font-bold py-3 px-4 rounded focus:outline-none ml-auto">
+                            Authenticate
+                        </button>
+                    </div>
+
                 </div>
 
                 <div class="px-6 md:px-10 py-4 bg-grey-50 border-t border-grey-100 flex flex-wrap justify-between">
@@ -92,14 +100,27 @@
             }
         }
 
-        webauthn.sign(
-            publicKey,
-            function (datas) {
-                document.getElementById("success").classList.remove("hidden");
-                document.getElementById("data").value = JSON.stringify(datas);
-                document.getElementById("form").submit();
-            }
-        );
+        if (! /apple/i.test(navigator.vendor)) {
+            webauthn.sign(
+                publicKey,
+                function (datas) {
+                    document.getElementById("success").classList.remove("hidden");
+                    document.getElementById("data").value = JSON.stringify(datas);
+                    document.getElementById("form").submit();
+                }
+            );
+        }
+
+        function authenticateDevice() {
+            webauthn.sign(
+                publicKey,
+                function (datas) {
+                    document.getElementById("success").classList.remove("hidden");
+                    document.getElementById("data").value = JSON.stringify(datas);
+                    document.getElementById("form").submit();
+                }
+            );
+        }
     </script>
 @endsection
 

+ 4 - 0
routes/api.php

@@ -77,6 +77,10 @@ Route::group([
     Route::post('/active-rules', 'Api\ActiveRuleController@store');
     Route::delete('/active-rules/{id}', 'Api\ActiveRuleController@destroy');
 
+    Route::get('/failed-deliveries', 'Api\FailedDeliveryController@index');
+    Route::get('/failed-deliveries/{id}', 'Api\FailedDeliveryController@show');
+    Route::delete('/failed-deliveries/{id}', 'Api\FailedDeliveryController@destroy');
+
     Route::get('/domain-options', 'Api\DomainOptionController@index');
 
     Route::get('/account-details', 'Api\AccountDetailController@index');

+ 2 - 0
routes/web.php

@@ -31,6 +31,8 @@ Route::group([
     Route::get('keys', 'Auth\WebauthnController@index')->name('webauthn.index');
     Route::post('register', 'Auth\WebauthnController@create')->name('webauthn.create');
     Route::delete('{id}', 'Auth\WebauthnController@destroy')->name('webauthn.destroy');
+    Route::post('enabled-keys', 'Auth\WebauthnEnabledKeyController@store')->name('webauthn.enabled_key.store');
+    Route::delete('enabled-keys/{id}', 'Auth\WebauthnEnabledKeyController@destroy')->name('webauthn.enabled_key.destroy');
 });
 
 Route::middleware(['auth', 'verified', '2fa', 'webauthn'])->group(function () {

+ 1 - 1
tests/Feature/Api/AccountDetailsTest.php

@@ -18,7 +18,7 @@ class AccountDetailsTest extends TestCase
     /** @test */
     public function user_can_get_account_details()
     {
-        $response = $this->get('/api/v1/account-details');
+        $response = $this->json('GET', '/api/v1/account-details');
 
         $response->assertSuccessful();
 

+ 2 - 2
tests/Feature/Api/AdditionalUsernamesTest.php

@@ -28,7 +28,7 @@ class AdditionalUsernamesTest extends TestCase
         ]);
 
         // Act
-        $response = $this->get('/api/v1/usernames');
+        $response = $this->json('GET', '/api/v1/usernames');
 
         // Assert
         $response->assertSuccessful();
@@ -44,7 +44,7 @@ class AdditionalUsernamesTest extends TestCase
         ]);
 
         // Act
-        $response = $this->get('/api/v1/usernames/'.$username->id);
+        $response = $this->json('GET', '/api/v1/usernames/'.$username->id);
 
         // Assert
         $response->assertSuccessful();

+ 4 - 4
tests/Feature/Api/AliasesTest.php

@@ -28,7 +28,7 @@ class AliasesTest extends TestCase
         ]);
 
         // Act
-        $response = $this->get('/api/v1/aliases');
+        $response = $this->json('GET', '/api/v1/aliases');
 
         // Assert
         $response->assertSuccessful();
@@ -49,7 +49,7 @@ class AliasesTest extends TestCase
         ]);
 
         // Act
-        $response = $this->get('/api/v1/aliases?deleted=with');
+        $response = $this->json('GET', '/api/v1/aliases?deleted=with');
 
         // Assert
         $response->assertSuccessful();
@@ -70,7 +70,7 @@ class AliasesTest extends TestCase
         ]);
 
         // Act
-        $response = $this->get('/api/v1/aliases?deleted=only');
+        $response = $this->json('GET', '/api/v1/aliases?deleted=only');
 
         // Assert
         $response->assertSuccessful();
@@ -86,7 +86,7 @@ class AliasesTest extends TestCase
         ]);
 
         // Act
-        $response = $this->get('/api/v1/aliases/'.$alias->id);
+        $response = $this->json('GET', '/api/v1/aliases/'.$alias->id);
 
         // Assert
         $response->assertSuccessful();

+ 1 - 1
tests/Feature/Api/AppVersionTest.php

@@ -19,7 +19,7 @@ class AppVersionTest extends TestCase
     /** @test */
     public function user_can_get_app_version()
     {
-        $response = $this->get('/api/v1/app-version');
+        $response = $this->json('GET', '/api/v1/app-version');
 
         $response->assertSuccessful();
 

+ 2 - 2
tests/Feature/Api/DomainsTest.php

@@ -26,7 +26,7 @@ class DomainsTest extends TestCase
         ]);
 
         // Act
-        $response = $this->get('/api/v1/domains');
+        $response = $this->json('GET', '/api/v1/domains');
 
         // Assert
         $response->assertSuccessful();
@@ -42,7 +42,7 @@ class DomainsTest extends TestCase
         ]);
 
         // Act
-        $response = $this->get('/api/v1/domains/'.$domain->id);
+        $response = $this->json('GET', '/api/v1/domains/'.$domain->id);
 
         // Assert
         $response->assertSuccessful();

+ 67 - 0
tests/Feature/Api/FailedDeliveriesTest.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace Tests\Feature\Api;
+
+use App\Models\FailedDelivery;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class FailedDeliveriesTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        parent::setUpPassport();
+
+        $this->user->update(['username' => 'johndoe']);
+        $this->user->recipients()->save($this->user->defaultRecipient);
+    }
+
+    /** @test */
+    public function user_can_get_all_failed_deliveries()
+    {
+        // Arrange
+        FailedDelivery::factory()->count(3)->create([
+            'user_id' => $this->user->id
+        ]);
+
+        // Act
+        $response = $this->json('GET', '/api/v1/failed-deliveries');
+
+        // Assert
+        $response->assertSuccessful();
+        $this->assertCount(3, $response->json()['data']);
+    }
+
+    /** @test */
+    public function user_can_get_individual_failed_delivery()
+    {
+        // Arrange
+        $failedDelivery = FailedDelivery::factory()->create([
+            'user_id' => $this->user->id
+        ]);
+
+        // Act
+        $response = $this->json('GET', '/api/v1/failed-deliveries/'.$failedDelivery->id);
+
+        // Assert
+        $response->assertSuccessful();
+        $this->assertCount(1, $response->json());
+        $this->assertEquals($failedDelivery->code, $response->json()['data']['code']);
+    }
+
+    /** @test */
+    public function user_can_delete_failed_delivery()
+    {
+        $failedDelivery = FailedDelivery::factory()->create([
+            'user_id' => $this->user->id
+        ]);
+
+        $response = $this->json('DELETE', '/api/v1/failed-deliveries/'.$failedDelivery->id);
+
+        $response->assertStatus(204);
+        $this->assertEmpty($this->user->failedDelivery);
+    }
+}

+ 2 - 2
tests/Feature/Api/RecipientsTest.php

@@ -26,7 +26,7 @@ class RecipientsTest extends TestCase
         ]);
 
         // Act
-        $response = $this->get('/api/v1/recipients');
+        $response = $this->json('GET', '/api/v1/recipients');
 
         // Assert
         $response->assertSuccessful();
@@ -42,7 +42,7 @@ class RecipientsTest extends TestCase
         ]);
 
         // Act
-        $response = $this->get('/api/v1/recipients/'.$recipient->id);
+        $response = $this->json('GET', '/api/v1/recipients/'.$recipient->id);
 
         // Assert
         $response->assertSuccessful();

+ 2 - 2
tests/Feature/Api/RulesTest.php

@@ -33,7 +33,7 @@ class RulesTest extends TestCase
         ]);
 
         // At
-        $response = $this->get('/api/v1/rules');
+        $response = $this->json('GET', '/api/v1/rules');
 
         // Assert
         $response->assertSuccessful();
@@ -49,7 +49,7 @@ class RulesTest extends TestCase
         ]);
 
         // Act
-        $response = $this->get('/api/v1/rules/'.$rule->id);
+        $response = $this->json('GET', '/api/v1/rules/'.$rule->id);
 
         // Assert
         $response->assertSuccessful();

部分文件因为文件数量过多而无法显示