فهرست منبع

Added simple API token generation

Will Browning 5 سال پیش
والد
کامیت
55aa704e40

+ 12 - 1
README.md

@@ -19,6 +19,17 @@ I made the code open-source to show everyone what was going on behind the scenes
 
 
 I use this service myself for the vast majority of sites I'm signed up to.
 I use this service myself for the vast majority of sites I'm signed up to.
 
 
+#### **Why should I use AnonAddy?**
+
+There are a number of reasons you should consider using this service:
+
+* Protect your real email address from spam by simply deactivating/deleting aliases that receive unsolicited emails
+* Identify who has sold your data by using a different email address for every site
+* Protect your identity in the event of a data breach by making it difficult for hackers to cross-reference your accounts
+* Prevent inbox snooping by encrypting all inbound emails using GPG/OpenPGP encryption
+* Update where emails are forwarded without having to go through and change your email address for each site individually
+* Reply to forwarded emails anonymously without revealing your true email address
+
 #### **Do you store emails?**
 #### **Do you store emails?**
 
 
 No I definitely do not store/save any emails that pass through the server.
 No I definitely do not store/save any emails that pass through the server.
@@ -38,7 +49,7 @@ Here are a few reasons I can think of:
 * Open-source application code
 * Open-source application code
 * No limitation on the number of aliases that can be created
 * No limitation on the number of aliases that can be created
 * Generous monthly bandwidth
 * Generous monthly bandwidth
-* Multiple domains to choose for aliases (currently anonaddy.com and anonaddy.me)
+* Multiple domains to choose for aliases (currently anonaddy.com, anonaddy.me and another for Pro plan users)
 * Ability to generate UUID aliases
 * Ability to generate UUID aliases
 * Ability to add additional usernames to compartmentalise aliases
 * Ability to add additional usernames to compartmentalise aliases
 * New features added regularly
 * New features added regularly

+ 34 - 0
app/Http/Controllers/Api/AliasApiController.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use App\Http\Controllers\Controller;
+use App\Http\Requests\StoreAliasRequest;
+use App\Http\Resources\AliasResource;
+use Ramsey\Uuid\Uuid;
+
+class AliasApiController extends Controller
+{
+    public function store(StoreAliasRequest $request)
+    {
+        if (user()->hasReachedUuidAliasLimit()) {
+            return response('', 403);
+        }
+
+        if (user()->hasExceededNewAliasLimit()) {
+            return response('', 429);
+        }
+
+        $uuid = Uuid::uuid4();
+
+        $alias = user()->aliases()->create([
+            'id' => $uuid,
+            'email' => $uuid . '@' . $request->domain,
+            'local_part' => $uuid,
+            'domain' => $request->domain,
+            'description' => $request->description
+        ]);
+
+        return new AliasResource($alias->fresh());
+    }
+}

+ 31 - 0
app/Http/Controllers/Api/ApiTokenController.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Support\Str;
+
+class ApiTokenController extends Controller
+{
+    public function update()
+    {
+        $token = Str::random(60);
+
+        user()->forceFill([
+            'api_token' => hash('sha256', $token),
+        ])->save();
+
+        return response()->json([
+            'token' => $token
+        ]);
+    }
+
+    public function destroy()
+    {
+        user()->forceFill([
+            'api_token' => null,
+        ])->save();
+
+        return response('', 204);
+    }
+}

+ 2 - 1
app/Http/Requests/StoreAliasRequest.php

@@ -29,7 +29,8 @@ class StoreAliasRequest extends FormRequest
                 'required',
                 'required',
                 'string',
                 'string',
                 Rule::in(config('anonaddy.all_domains'))
                 Rule::in(config('anonaddy.all_domains'))
-            ]
+            ],
+            'description' => 'nullable|max:100'
         ];
         ];
     }
     }
 }
 }

+ 1 - 1
app/Providers/RouteServiceProvider.php

@@ -66,7 +66,7 @@ class RouteServiceProvider extends ServiceProvider
     protected function mapApiRoutes()
     protected function mapApiRoutes()
     {
     {
         Route::prefix('api')
         Route::prefix('api')
-             ->middleware('api')
+             ->middleware('auth:api')
              ->namespace($this->namespace)
              ->namespace($this->namespace)
              ->group(base_path('routes/api.php'));
              ->group(base_path('routes/api.php'));
     }
     }

+ 1 - 0
app/User.php

@@ -48,6 +48,7 @@ class User extends Authenticatable implements MustVerifyEmail
      */
      */
     protected $hidden = [
     protected $hidden = [
         'password',
         'password',
+        'api_token',
         'remember_token',
         'remember_token',
         'two_factor_secret',
         'two_factor_secret',
         'two_factor_backup_code'
         'two_factor_backup_code'

+ 22 - 19
composer.lock

@@ -4348,16 +4348,16 @@
         },
         },
         {
         {
             "name": "facade/flare-client-php",
             "name": "facade/flare-client-php",
-            "version": "1.0.4",
+            "version": "1.1.0",
             "source": {
             "source": {
                 "type": "git",
                 "type": "git",
                 "url": "https://github.com/facade/flare-client-php.git",
                 "url": "https://github.com/facade/flare-client-php.git",
-                "reference": "7128b251b48f24ef64e5cddd7f8d40cc3a06fd3e"
+                "reference": "4de2e8062e66edadbff261ebb5baae6eccfb799c"
             },
             },
             "dist": {
             "dist": {
                 "type": "zip",
                 "type": "zip",
-                "url": "https://api.github.com/repos/facade/flare-client-php/zipball/7128b251b48f24ef64e5cddd7f8d40cc3a06fd3e",
-                "reference": "7128b251b48f24ef64e5cddd7f8d40cc3a06fd3e",
+                "url": "https://api.github.com/repos/facade/flare-client-php/zipball/4de2e8062e66edadbff261ebb5baae6eccfb799c",
+                "reference": "4de2e8062e66edadbff261ebb5baae6eccfb799c",
                 "shasum": ""
                 "shasum": ""
             },
             },
             "require": {
             "require": {
@@ -4369,7 +4369,7 @@
             },
             },
             "require-dev": {
             "require-dev": {
                 "larapack/dd": "^1.1",
                 "larapack/dd": "^1.1",
-                "phpunit/phpunit": "^7.0",
+                "phpunit/phpunit": "^7.5.16",
                 "spatie/phpunit-snapshot-assertions": "^2.0"
                 "spatie/phpunit-snapshot-assertions": "^2.0"
             },
             },
             "type": "library",
             "type": "library",
@@ -4398,26 +4398,26 @@
                 "flare",
                 "flare",
                 "reporting"
                 "reporting"
             ],
             ],
-            "time": "2019-09-11T14:19:56+00:00"
+            "time": "2019-09-27T14:54:17+00:00"
         },
         },
         {
         {
             "name": "facade/ignition",
             "name": "facade/ignition",
-            "version": "1.8.2",
+            "version": "1.9.0",
             "source": {
             "source": {
                 "type": "git",
                 "type": "git",
                 "url": "https://github.com/facade/ignition.git",
                 "url": "https://github.com/facade/ignition.git",
-                "reference": "4ff9397a24da58b35382802e2d22325b640123ea"
+                "reference": "40461a1f680171876c186dfff419b2a4c892995c"
             },
             },
             "dist": {
             "dist": {
                 "type": "zip",
                 "type": "zip",
-                "url": "https://api.github.com/repos/facade/ignition/zipball/4ff9397a24da58b35382802e2d22325b640123ea",
-                "reference": "4ff9397a24da58b35382802e2d22325b640123ea",
+                "url": "https://api.github.com/repos/facade/ignition/zipball/40461a1f680171876c186dfff419b2a4c892995c",
+                "reference": "40461a1f680171876c186dfff419b2a4c892995c",
                 "shasum": ""
                 "shasum": ""
             },
             },
             "require": {
             "require": {
                 "ext-json": "*",
                 "ext-json": "*",
                 "ext-mbstring": "*",
                 "ext-mbstring": "*",
-                "facade/flare-client-php": "^1.0.4",
+                "facade/flare-client-php": "^1.1",
                 "facade/ignition-contracts": "^1.0",
                 "facade/ignition-contracts": "^1.0",
                 "filp/whoops": "^2.4",
                 "filp/whoops": "^2.4",
                 "illuminate/support": "~5.5.0 || ~5.6.0 || ~5.7.0 || ~5.8.0 || ^6.0",
                 "illuminate/support": "~5.5.0 || ~5.6.0 || ~5.7.0 || ~5.8.0 || ^6.0",
@@ -4452,7 +4452,10 @@
             "autoload": {
             "autoload": {
                 "psr-4": {
                 "psr-4": {
                     "Facade\\Ignition\\": "src"
                     "Facade\\Ignition\\": "src"
-                }
+                },
+                "files": [
+                    "src/helpers.php"
+                ]
             },
             },
             "notification-url": "https://packagist.org/downloads/",
             "notification-url": "https://packagist.org/downloads/",
             "license": [
             "license": [
@@ -4466,7 +4469,7 @@
                 "laravel",
                 "laravel",
                 "page"
                 "page"
             ],
             ],
-            "time": "2019-09-20T09:37:06+00:00"
+            "time": "2019-09-27T16:49:08+00:00"
         },
         },
         {
         {
             "name": "facade/ignition-contracts",
             "name": "facade/ignition-contracts",
@@ -4762,16 +4765,16 @@
         },
         },
         {
         {
             "name": "mockery/mockery",
             "name": "mockery/mockery",
-            "version": "1.2.3",
+            "version": "1.2.4",
             "source": {
             "source": {
                 "type": "git",
                 "type": "git",
                 "url": "https://github.com/mockery/mockery.git",
                 "url": "https://github.com/mockery/mockery.git",
-                "reference": "4eff936d83eb809bde2c57a3cea0ee9643769031"
+                "reference": "b3453f75fd23d9fd41685f2148f4abeacabc6405"
             },
             },
             "dist": {
             "dist": {
                 "type": "zip",
                 "type": "zip",
-                "url": "https://api.github.com/repos/mockery/mockery/zipball/4eff936d83eb809bde2c57a3cea0ee9643769031",
-                "reference": "4eff936d83eb809bde2c57a3cea0ee9643769031",
+                "url": "https://api.github.com/repos/mockery/mockery/zipball/b3453f75fd23d9fd41685f2148f4abeacabc6405",
+                "reference": "b3453f75fd23d9fd41685f2148f4abeacabc6405",
                 "shasum": ""
                 "shasum": ""
             },
             },
             "require": {
             "require": {
@@ -4785,7 +4788,7 @@
             "type": "library",
             "type": "library",
             "extra": {
             "extra": {
                 "branch-alias": {
                 "branch-alias": {
-                    "dev-master": "1.0.x-dev"
+                    "dev-master": "1.2.x-dev"
                 }
                 }
             },
             },
             "autoload": {
             "autoload": {
@@ -4823,7 +4826,7 @@
                 "test double",
                 "test double",
                 "testing"
                 "testing"
             ],
             ],
-            "time": "2019-08-07T15:01:07+00:00"
+            "time": "2019-09-30T08:30:27+00:00"
         },
         },
         {
         {
             "name": "myclabs/deep-copy",
             "name": "myclabs/deep-copy",

+ 1 - 0
config/auth.php

@@ -44,6 +44,7 @@ return [
         'api' => [
         'api' => [
             'driver' => 'token',
             'driver' => 'token',
             'provider' => 'users',
             'provider' => 'users',
+            'hash' => true,
         ],
         ],
     ],
     ],
 
 

+ 36 - 0
database/migrations/2019_09_30_155717_add_api_token_column_to_users_table.php

@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class AddApiTokenColumnToUsersTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->string('api_token', 80)
+                        ->after('password')
+                        ->unique()
+                        ->nullable()
+                        ->default(null);
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->dropColumn('api_token');
+        });
+    }
+}

+ 8 - 8
package-lock.json

@@ -5059,9 +5059,9 @@
             }
             }
         },
         },
         "laravel-mix-purgecss": {
         "laravel-mix-purgecss": {
-            "version": "4.1.0",
-            "resolved": "https://registry.npmjs.org/laravel-mix-purgecss/-/laravel-mix-purgecss-4.1.0.tgz",
-            "integrity": "sha512-uQF/TBbdehVb3UhO0wA1iZ83hzk1HhDwjHkmf0+zIO8cRVPz+ybC2uECp+ImA9hxd921G8UKlcdH3USYQ1knsw==",
+            "version": "4.2.0",
+            "resolved": "https://registry.npmjs.org/laravel-mix-purgecss/-/laravel-mix-purgecss-4.2.0.tgz",
+            "integrity": "sha512-hzphHnhK3xPiv19QhCnhuvSUK4vmPB/76S6EHDb3WPa6Oz7aOlBREXLhg57ejiisy9GdVKw5DZjYwk3JLjAkYA==",
             "requires": {
             "requires": {
                 "glob-all": "^3.1.0",
                 "glob-all": "^3.1.0",
                 "purgecss-webpack-plugin": "^1.3.0"
                 "purgecss-webpack-plugin": "^1.3.0"
@@ -7329,12 +7329,12 @@
             }
             }
         },
         },
         "purgecss-webpack-plugin": {
         "purgecss-webpack-plugin": {
-            "version": "1.5.0",
-            "resolved": "https://registry.npmjs.org/purgecss-webpack-plugin/-/purgecss-webpack-plugin-1.5.0.tgz",
-            "integrity": "sha512-ZSU6lok2DuDBuR7VCte5V12eke0Tx8xsCKxMbOnMfuJNPccPGv4jflRUm2Wvr2yGB8lFzKNZaTWaSk9g3kCv5A==",
+            "version": "1.6.0",
+            "resolved": "https://registry.npmjs.org/purgecss-webpack-plugin/-/purgecss-webpack-plugin-1.6.0.tgz",
+            "integrity": "sha512-rVrTWYsOTShUvD5gl0q/krkwTlBUILlyoqRk2XoujNm2dETt276yvK4vP9oyXVPSQyaMCjjP5YPMCq9PNgIlJQ==",
             "requires": {
             "requires": {
-                "purgecss": "^1.3.0",
-                "webpack-sources": "^1.3.0"
+                "purgecss": "^1.4.0",
+                "webpack-sources": "^1.4.3"
             }
             }
         },
         },
         "q": {
         "q": {

+ 1 - 1
package.json

@@ -15,7 +15,7 @@
         "cross-env": "^5.2.1",
         "cross-env": "^5.2.1",
         "dayjs": "^1.8.16",
         "dayjs": "^1.8.16",
         "laravel-mix": "^4.1.4",
         "laravel-mix": "^4.1.4",
-        "laravel-mix-purgecss": "^4.1.0",
+        "laravel-mix-purgecss": "^4.2.0",
         "lodash": "^4.17.15",
         "lodash": "^4.17.15",
         "portal-vue": "^2.1.6",
         "portal-vue": "^2.1.6",
         "postcss-import": "^11.1.0",
         "postcss-import": "^11.1.0",

+ 2 - 0
resources/js/app.js

@@ -24,6 +24,8 @@ Vue.use(VueGoodTablePlugin)
 Vue.component('loader', require('./components/Loader.vue').default)
 Vue.component('loader', require('./components/Loader.vue').default)
 Vue.component('dropdown', require('./components/DropdownNav.vue').default)
 Vue.component('dropdown', require('./components/DropdownNav.vue').default)
 Vue.component('icon', require('./components/Icon.vue').default)
 Vue.component('icon', require('./components/Icon.vue').default)
+Vue.component('api-token', require('./components/ApiToken.vue').default)
+
 Vue.component('aliases', require('./pages/Aliases.vue').default)
 Vue.component('aliases', require('./pages/Aliases.vue').default)
 Vue.component('recipients', require('./pages/Recipients.vue').default)
 Vue.component('recipients', require('./pages/Recipients.vue').default)
 Vue.component('domains', require('./pages/Domains.vue').default)
 Vue.component('domains', require('./pages/Domains.vue').default)

+ 162 - 0
resources/js/components/ApiToken.vue

@@ -0,0 +1,162 @@
+<template>
+  <div>
+    <h3 class="font-bold text-xl">{{ token ? 'Rotate' : 'Generate' }} API Token</h3>
+
+    <div class="my-4 w-24 border-b-2 border-grey-200"></div>
+
+    <p v-if="token" class="my-6">
+      To rotate your current API token simply click the button below.
+    </p>
+    <p v-else class="my-6">
+      To enable the use of the API simply click the button below to generate an API token.
+    </p>
+
+    <button
+      @click="rotate"
+      class="bg-cyan-400 w-full hover:bg-cyan-300 text-cyan-900 font-bold py-3 px-4 rounded focus:outline-none"
+      :class="loading ? 'cursor-not-allowed' : ''"
+      :disabled="loading"
+    >
+      {{ token ? 'Rotate' : 'Generate' }} Token
+      <loader v-if="loading" />
+    </button>
+
+    <div class="mt-6" v-if="token">
+      <h3 class="font-bold text-xl">
+        Revoke API Token
+      </h3>
+
+      <div class="my-4 w-24 border-b-2 border-grey-200"></div>
+
+      <p class="my-6">
+        To revoke the current API token simply click the button below.
+      </p>
+
+      <button
+        @click="revoke"
+        class="text-red-500 font-bold focus:outline-none"
+        :class="revokeLoading ? 'cursor-not-allowed' : ''"
+        :disabled="revokeLoading"
+      >
+        Revoke Token
+      </button>
+    </div>
+
+    <Modal :open="modalOpen" @close="closeModal">
+      <div class="max-w-lg w-full bg-white rounded-lg shadow-2xl px-6 py-6">
+        <h2
+          class="font-semibold text-grey-900 text-2xl leading-tight border-b-2 border-grey-100 pb-4"
+        >
+          API Token
+        </h2>
+        <p class="my-4 text-grey-700">
+          This is your new API token. This is the only time the token will ever be displayed, so
+          please make a note of it in a safe place (e.g. password manager)! You may revoke or rotate
+          the token at any time from your API settings.
+        </p>
+        <pre class="flex p-3 text-grey-900 bg-white border rounded">
+            <code class="break-all whitespace-normal">{{ token }}</code>
+        </pre>
+        <div class="mt-6">
+          <button
+            class="bg-cyan-400 hover:bg-cyan-300 text-cyan-900 font-bold py-3 px-4 rounded focus:outline-none"
+            v-clipboard="() => token"
+            v-clipboard:success="clipboardSuccess"
+            v-clipboard:error="clipboardError"
+          >
+            Copy To Clipboard
+          </button>
+          <button
+            @click="closeModal"
+            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"
+          >
+            Close
+          </button>
+        </div>
+      </div>
+    </Modal>
+  </div>
+</template>
+
+<script>
+import Modal from './../components/Modal.vue'
+
+export default {
+  props: {
+    initialToken: {
+      type: String,
+      required: true,
+    },
+  },
+  components: {
+    Modal,
+  },
+  data() {
+    return {
+      loading: false,
+      revokeLoading: false,
+      modalOpen: false,
+      token: this.initialToken,
+    }
+  },
+  methods: {
+    rotate() {
+      this.loading = true
+
+      axios
+        .post('/settings/api-token', {
+          headers: { 'Content-Type': 'application/json' },
+        })
+        .then(response => {
+          this.modalOpen = true
+          this.loading = false
+          this.token = response.data.token
+        })
+        .catch(error => {
+          this.loading = false
+          this.error()
+        })
+    },
+    revoke() {
+      this.revokeLoading = true
+
+      axios
+        .delete('/settings/api-token', {
+          headers: { 'Content-Type': 'application/json' },
+        })
+        .then(response => {
+          this.revokeLoading = false
+          this.token = ''
+          this.success('Token Revoked Successfully!')
+        })
+        .catch(error => {
+          this.revokeLoading = false
+          this.error()
+        })
+    },
+    closeModal() {
+      this.modalOpen = false
+    },
+    clipboardSuccess() {
+      this.success('Copied to clipboard')
+    },
+    clipboardError() {
+      this.error('Could not copy to clipboard')
+    },
+    success(text = '') {
+      this.$notify({
+        title: 'Success',
+        text: text,
+        type: 'success',
+      })
+    },
+    error(text = 'An error has occurred, please try again later') {
+      this.$notify({
+        title: 'Error',
+        text: text,
+        type: 'error',
+      })
+    },
+  },
+}
+</script>

+ 0 - 1
resources/js/pages/Domains.vue

@@ -383,7 +383,6 @@ export default {
         {
         {
           label: 'Description',
           label: 'Description',
           field: 'description',
           field: 'description',
-          width: '500px',
         },
         },
         {
         {
           label: 'Default Recipient',
           label: 'Default Recipient',

+ 2 - 1
resources/js/pages/Usernames.vue

@@ -163,7 +163,8 @@
         </h2>
         </h2>
         <p class="mt-4 text-grey-700">
         <p class="mt-4 text-grey-700">
           Please choose additional usernames carefully as you can only add a maximum of
           Please choose additional usernames carefully as you can only add a maximum of
-          {{ usernameCount }}.
+          {{ usernameCount }}. You cannot login with these usernames, only the one you originally
+          signed up with.
         </p>
         </p>
         <div class="mt-6">
         <div class="mt-6">
           <p v-show="errors.newUsername" class="mb-3 text-red-500">
           <p v-show="errors.newUsername" class="mb-3 text-red-500">

+ 13 - 0
resources/views/settings/show.blade.php

@@ -428,6 +428,19 @@
 
 
         </div>
         </div>
 
 
+        <div class="mb-4">
+            <h2 class="text-3xl font-bold">
+                API
+            </h2>
+            <p class="text-grey-500">Manage your API Token (the API is currently a work in progress)</p>
+        </div>
+
+        <div class="px-6 py-8 md:p-10 bg-white rounded-lg shadow mb-10">
+
+            <api-token initial-token="{{ $user->api_token }}" />
+
+        </div>
+
         <div class="mb-4">
         <div class="mb-4">
             <h2 class="text-3xl font-bold">
             <h2 class="text-3xl font-bold">
                 Danger Zone
                 Danger Zone

+ 7 - 0
routes/api.php

@@ -10,3 +10,10 @@
 | is assigned the "api" middleware group. Enjoy building your API!
 | is assigned the "api" middleware group. Enjoy building your API!
 |
 |
 */
 */
+
+Route::group([
+  'middleware' => ['verified'],
+  'prefix' => 'v1'
+], function () {
+    Route::post('/aliases', 'Api\AliasApiController@store');
+});

+ 18 - 12
routes/web.php

@@ -65,22 +65,28 @@ Route::middleware(['auth', 'verified', '2fa'])->group(function () {
 });
 });
 
 
 
 
-Route::middleware(['auth', '2fa'])->group(function () {
-    Route::get('/settings', 'SettingController@show')->name('settings.show');
-    Route::post('/settings/account', 'SettingController@destroy')->name('account.destroy');
+Route::group([
+    'middleware' => ['auth', '2fa'],
+    'prefix' => 'settings'
+], function () {
+    Route::get('/', 'SettingController@show')->name('settings.show');
+    Route::post('/account', 'SettingController@destroy')->name('account.destroy');
 
 
-    Route::post('/settings/default-recipient', 'DefaultRecipientController@update')->name('settings.default_recipient');
-    Route::post('/settings/edit-default-recipient', 'DefaultRecipientController@edit')->name('settings.edit_default_recipient');
+    Route::post('/default-recipient', 'DefaultRecipientController@update')->name('settings.default_recipient');
+    Route::post('/edit-default-recipient', 'DefaultRecipientController@edit')->name('settings.edit_default_recipient');
 
 
-    Route::post('/settings/from-name', 'FromNameController@update')->name('settings.from_name');
+    Route::post('/from-name', 'FromNameController@update')->name('settings.from_name');
 
 
-    Route::post('/settings/email-subject', 'EmailSubjectController@update')->name('settings.email_subject');
+    Route::post('/email-subject', 'EmailSubjectController@update')->name('settings.email_subject');
 
 
-    Route::post('/settings/banner-location', 'BannerLocationController@update')->name('settings.banner_location');
+    Route::post('/banner-location', 'BannerLocationController@update')->name('settings.banner_location');
 
 
-    Route::post('/settings/password', 'PasswordController@update')->name('settings.password');
+    Route::post('/password', 'PasswordController@update')->name('settings.password');
 
 
-    Route::post('/settings/2fa/enable', 'TwoFactorAuthController@store')->name('settings.2fa_enable');
-    Route::post('/settings/2fa/regenerate', 'TwoFactorAuthController@update')->name('settings.2fa_regenerate');
-    Route::post('/settings/2fa/disable', 'TwoFactorAuthController@destroy')->name('settings.2fa_disable');
+    Route::post('/2fa/enable', 'TwoFactorAuthController@store')->name('settings.2fa_enable');
+    Route::post('/2fa/regenerate', 'TwoFactorAuthController@update')->name('settings.2fa_regenerate');
+    Route::post('/2fa/disable', 'TwoFactorAuthController@destroy')->name('settings.2fa_disable');
+
+    Route::post('/api-token', 'Api\ApiTokenController@update')->name('api_token.update');
+    Route::delete('/api-token', 'Api\ApiTokenController@destroy')->name('api_token.destroy');
 });
 });

+ 55 - 0
tests/Feature/Api/ApiTokensTest.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace Tests\Feature\Api;
+
+use App\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Str;
+use Tests\TestCase;
+
+class ApiTokensTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $user;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->user = factory(User::class)->create();
+        $this->actingAs($this->user);
+        $this->user->recipients()->save($this->user->defaultRecipient);
+    }
+
+    /** @test */
+    public function user_can_rotate_api_token()
+    {
+        $this->assertNull($this->user->api_token);
+
+        $response = $this->json('POST', '/settings/api-token', []);
+
+        $response->assertStatus(200);
+
+        $this->assertNotNull($response->getData()->token);
+        $this->assertNotNull($this->user->refresh()->api_token);
+    }
+
+    /** @test */
+    public function user_can_revoke_api_token()
+    {
+        $token = Str::random(60);
+
+        $this->user->forceFill([
+            'api_token' => hash('sha256', $token),
+        ])->save();
+
+        $this->assertNotNull($this->user->refresh()->api_token);
+
+        $response = $this->json('DELETE', '/settings/api-token');
+
+        $response->assertStatus(204);
+
+        $this->assertNull($this->user->refresh()->api_token);
+    }
+}