Support OAuth2

This commit is contained in:
Pig Fang 2019-04-25 23:24:24 +08:00
parent 6f2345efe4
commit e2c125648f
26 changed files with 1097 additions and 19 deletions

2
.gitignore vendored
View File

@ -17,3 +17,5 @@ junit.xml
storage/*.db
storage/*.sqlite
.vscode
storage/oauth-public.key
storage/oauth-private.key

View File

@ -142,6 +142,7 @@ class SetupController extends Controller
Artisan::call('salt:random');
}
Artisan::call('jwt:secret', ['--no-interaction' => true]);
Artisan::call('passport:keys', ['--no-interaction' => true]);
// Create tables
Artisan::call('migrate', ['--force' => true]);

View File

@ -5,12 +5,15 @@ namespace App\Models;
use DB;
use Carbon\Carbon;
use Illuminate\Support\Arr;
use Laravel\Passport\HasApiTokens;
use App\Events\EncryptUserPassword;
use Tymon\JWTAuth\Contracts\JWTSubject;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable implements JWTSubject
{
use HasApiTokens;
/**
* Permissions.
*/

View File

@ -4,6 +4,7 @@ namespace App\Providers;
use Route;
use Illuminate\Routing\Router;
use Laravel\Passport\Passport;
use App\Events\ConfigureRoutes;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
@ -44,6 +45,8 @@ class RouteServiceProvider extends ServiceProvider
$this->mapApiRoutes();
Passport::routes();
event(new ConfigureRoutes($router));
}

View File

@ -91,8 +91,14 @@ if (! function_exists('bs_menu')) {
{
$menu = config('menu');
Event::dispatch($type == 'user' ? new App\Events\ConfigureUserMenu($menu)
: new App\Events\ConfigureAdminMenu($menu));
switch ($type) {
case 'user':
event(new App\Events\ConfigureUserMenu($menu));
break;
case 'admin':
event(new App\Events\ConfigureAdminMenu($menu));
break;
}
if (! isset($menu[$type])) {
throw new InvalidArgumentException;

View File

@ -23,7 +23,8 @@
"mews/captcha": "^2.2",
"guzzlehttp/guzzle": "^6.3",
"doctrine/dbal": "^2.9",
"tymon/jwt-auth": "dev-develop"
"tymon/jwt-auth": "dev-develop",
"laravel/passport": "^7.2"
},
"require-dev": {
"fzaninotto/faker": "~1.4",

583
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "ff9f059d35e12b8b7c52f529300ce9f2",
"content-hash": "a2b9b6f33c5d48464bf9d66c7c1c2b14",
"packages": [
{
"name": "composer/semver",
@ -68,6 +68,69 @@
],
"time": "2016-08-30T16:08:34+00:00"
},
{
"name": "defuse/php-encryption",
"version": "v2.2.1",
"source": {
"type": "git",
"url": "https://github.com/defuse/php-encryption.git",
"reference": "0f407c43b953d571421e0020ba92082ed5fb7620"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/defuse/php-encryption/zipball/0f407c43b953d571421e0020ba92082ed5fb7620",
"reference": "0f407c43b953d571421e0020ba92082ed5fb7620",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"paragonie/random_compat": ">= 2",
"php": ">=5.4.0"
},
"require-dev": {
"nikic/php-parser": "^2.0|^3.0|^4.0",
"phpunit/phpunit": "^4|^5"
},
"bin": [
"bin/generate-defuse-key"
],
"type": "library",
"autoload": {
"psr-4": {
"Defuse\\Crypto\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Hornby",
"email": "taylor@defuse.ca",
"homepage": "https://defuse.ca/"
},
{
"name": "Scott Arciszewski",
"email": "info@paragonie.com",
"homepage": "https://paragonie.com"
}
],
"description": "Secure PHP Encryption Library",
"keywords": [
"aes",
"authenticated encryption",
"cipher",
"crypto",
"cryptography",
"encrypt",
"encryption",
"openssl",
"security",
"symmetric key cryptography"
],
"time": "2018-07-24T23:27:56+00:00"
},
{
"name": "devitek/yaml-translation",
"version": "4.1.0",
@ -618,6 +681,52 @@
],
"time": "2018-03-08T01:11:30+00:00"
},
{
"name": "firebase/php-jwt",
"version": "v5.0.0",
"source": {
"type": "git",
"url": "https://github.com/firebase/php-jwt.git",
"reference": "9984a4d3a32ae7673d6971ea00bae9d0a1abba0e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/firebase/php-jwt/zipball/9984a4d3a32ae7673d6971ea00bae9d0a1abba0e",
"reference": "9984a4d3a32ae7673d6971ea00bae9d0a1abba0e",
"shasum": ""
},
"require": {
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": " 4.8.35"
},
"type": "library",
"autoload": {
"psr-4": {
"Firebase\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Neuman Vong",
"email": "neuman+pear@twilio.com",
"role": "Developer"
},
{
"name": "Anant Narayanan",
"email": "anant@php.net",
"role": "Developer"
}
],
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
"homepage": "https://github.com/firebase/php-jwt",
"time": "2017-06-27T22:17:23+00:00"
},
{
"name": "guzzlehttp/guzzle",
"version": "6.3.3",
@ -1018,6 +1127,76 @@
],
"time": "2019-03-26T17:19:10+00:00"
},
{
"name": "laravel/passport",
"version": "v7.2.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/passport.git",
"reference": "c0c3fca80d8f5af90dcbf65e62bdd1abee9ac25d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/passport/zipball/c0c3fca80d8f5af90dcbf65e62bdd1abee9ac25d",
"reference": "c0c3fca80d8f5af90dcbf65e62bdd1abee9ac25d",
"shasum": ""
},
"require": {
"ext-json": "*",
"firebase/php-jwt": "~3.0|~4.0|~5.0",
"guzzlehttp/guzzle": "~6.0",
"illuminate/auth": "~5.6.0|~5.7.0|~5.8.0|~5.9.0",
"illuminate/console": "~5.6.0|~5.7.0|~5.8.0|~5.9.0",
"illuminate/container": "~5.6.0|~5.7.0|~5.8.0|~5.9.0",
"illuminate/contracts": "~5.6.0|~5.7.0|~5.8.0|~5.9.0",
"illuminate/database": "~5.6.0|~5.7.0|~5.8.0|~5.9.0",
"illuminate/encryption": "~5.6.0|~5.7.0|~5.8.0|~5.9.0",
"illuminate/http": "~5.6.0|~5.7.0|~5.8.0|~5.9.0",
"illuminate/support": "~5.6.0|~5.7.0|~5.8.0|~5.9.0",
"league/oauth2-server": "^7.0",
"php": ">=7.1",
"phpseclib/phpseclib": "^2.0",
"symfony/psr-http-message-bridge": "~1.0",
"zendframework/zend-diactoros": "~1.0|~2.0"
},
"require-dev": {
"mockery/mockery": "~1.0",
"phpunit/phpunit": "~7.4"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "7.0-dev"
},
"laravel": {
"providers": [
"Laravel\\Passport\\PassportServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Passport\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Laravel Passport provides OAuth2 server support to Laravel.",
"keywords": [
"laravel",
"oauth",
"passport"
],
"time": "2019-03-13T14:21:06+00:00"
},
{
"name": "lcobucci/jwt",
"version": "3.2.5",
@ -1076,6 +1255,56 @@
],
"time": "2018-11-11T12:22:26+00:00"
},
{
"name": "league/event",
"version": "2.2.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/event.git",
"reference": "d2cc124cf9a3fab2bb4ff963307f60361ce4d119"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/event/zipball/d2cc124cf9a3fab2bb4ff963307f60361ce4d119",
"reference": "d2cc124cf9a3fab2bb4ff963307f60361ce4d119",
"shasum": ""
},
"require": {
"php": ">=5.4.0"
},
"require-dev": {
"henrikbjorn/phpspec-code-coverage": "~1.0.1",
"phpspec/phpspec": "^2.2"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.2-dev"
}
},
"autoload": {
"psr-4": {
"League\\Event\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Frank de Jonge",
"email": "info@frenky.net"
}
],
"description": "Event package",
"keywords": [
"emitter",
"event",
"listener"
],
"time": "2018-11-26T11:52:41+00:00"
},
{
"name": "league/flysystem",
"version": "1.0.51",
@ -1160,6 +1389,83 @@
],
"time": "2019-03-30T13:22:34+00:00"
},
{
"name": "league/oauth2-server",
"version": "7.3.3",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/oauth2-server.git",
"reference": "c7f499849704ebe2c60b45b6d6bb231df5601d4a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/c7f499849704ebe2c60b45b6d6bb231df5601d4a",
"reference": "c7f499849704ebe2c60b45b6d6bb231df5601d4a",
"shasum": ""
},
"require": {
"defuse/php-encryption": "^2.1",
"ext-openssl": "*",
"lcobucci/jwt": "^3.2.2",
"league/event": "^2.1",
"php": ">=7.0.0",
"psr/http-message": "^1.0.1"
},
"replace": {
"league/oauth2server": "*",
"lncd/oauth2": "*"
},
"require-dev": {
"phpstan/phpstan": "^0.9.2",
"phpstan/phpstan-phpunit": "^0.9.4",
"phpstan/phpstan-strict-rules": "^0.9.0",
"phpunit/phpunit": "^6.3 || ^7.0",
"roave/security-advisories": "dev-master",
"zendframework/zend-diactoros": "^1.3.2"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\OAuth2\\Server\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alex Bilbie",
"email": "hello@alexbilbie.com",
"homepage": "http://www.alexbilbie.com",
"role": "Developer"
},
{
"name": "Andy Millington",
"email": "andrew@noexceptions.io",
"homepage": "https://www.noexceptions.io",
"role": "Developer"
}
],
"description": "A lightweight and powerful OAuth 2.0 authorization and resource server library with support for all the core specification grants. This library will allow you to secure your API with OAuth and allow your applications users to approve apps that want to access their data from your API.",
"homepage": "https://oauth2.thephpleague.com/",
"keywords": [
"Authentication",
"api",
"auth",
"authorisation",
"authorization",
"oauth",
"oauth 2",
"oauth 2.0",
"oauth2",
"protect",
"resource",
"secure",
"server"
],
"time": "2019-03-29T18:19:35+00:00"
},
{
"name": "mews/captcha",
"version": "2.2.5",
@ -1584,6 +1890,98 @@
],
"time": "2015-07-25T16:39:46+00:00"
},
{
"name": "phpseclib/phpseclib",
"version": "2.0.15",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
"reference": "11cf67cf78dc4acb18dc9149a57be4aee5036ce0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/11cf67cf78dc4acb18dc9149a57be4aee5036ce0",
"reference": "11cf67cf78dc4acb18dc9149a57be4aee5036ce0",
"shasum": ""
},
"require": {
"php": ">=5.3.3"
},
"require-dev": {
"phing/phing": "~2.7",
"phpunit/phpunit": "^4.8.35|^5.7|^6.0",
"sami/sami": "~2.0",
"squizlabs/php_codesniffer": "~2.0"
},
"suggest": {
"ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
"ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
"ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.",
"ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
},
"type": "library",
"autoload": {
"files": [
"phpseclib/bootstrap.php"
],
"psr-4": {
"phpseclib\\": "phpseclib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jim Wigginton",
"email": "terrafrost@php.net",
"role": "Lead Developer"
},
{
"name": "Patrick Monnerat",
"email": "pm@datasphere.ch",
"role": "Developer"
},
{
"name": "Andreas Fischer",
"email": "bantu@phpbb.com",
"role": "Developer"
},
{
"name": "Hans-Jürgen Petrich",
"email": "petrich@tronic-media.com",
"role": "Developer"
},
{
"name": "Graham Campbell",
"email": "graham@alt-three.com",
"role": "Developer"
}
],
"description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
"homepage": "http://phpseclib.sourceforge.net",
"keywords": [
"BigInteger",
"aes",
"asn.1",
"asn1",
"blowfish",
"crypto",
"cryptography",
"encryption",
"rsa",
"security",
"sftp",
"signature",
"signing",
"ssh",
"twofish",
"x.509",
"x509"
],
"time": "2019-03-10T16:53:45+00:00"
},
{
"name": "predis/predis",
"version": "v1.1.1",
@ -1683,6 +2081,58 @@
],
"time": "2017-02-14T16:28:37+00:00"
},
{
"name": "psr/http-factory",
"version": "1.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-factory.git",
"reference": "378bfe27931ecc54ff824a20d6f6bfc303bbd04c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-factory/zipball/378bfe27931ecc54ff824a20d6f6bfc303bbd04c",
"reference": "378bfe27931ecc54ff824a20d6f6bfc303bbd04c",
"shasum": ""
},
"require": {
"php": ">=7.0.0",
"psr/http-message": "^1.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "http://www.php-fig.org/"
}
],
"description": "Common interfaces for PSR-7 HTTP message factories",
"keywords": [
"factory",
"http",
"message",
"psr",
"psr-17",
"psr-7",
"request",
"response"
],
"time": "2018-07-30T21:54:04+00:00"
},
{
"name": "psr/http-message",
"version": "1.0.1",
@ -2894,6 +3344,71 @@
"homepage": "https://symfony.com",
"time": "2019-01-24T22:05:03+00:00"
},
{
"name": "symfony/psr-http-message-bridge",
"version": "v1.2.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/psr-http-message-bridge.git",
"reference": "9ab9d71f97d5c7d35a121a7fb69f74fee95cd0ad"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/9ab9d71f97d5c7d35a121a7fb69f74fee95cd0ad",
"reference": "9ab9d71f97d5c7d35a121a7fb69f74fee95cd0ad",
"shasum": ""
},
"require": {
"php": "^7.1",
"psr/http-message": "^1.0",
"symfony/http-foundation": "^3.4 || ^4.0"
},
"require-dev": {
"nyholm/psr7": "^1.1",
"symfony/phpunit-bridge": "^3.4.20 || ^4.0",
"zendframework/zend-diactoros": "^1.4.1 || ^2.0"
},
"suggest": {
"nyholm/psr7": "For a super lightweight PSR-7/17 implementation"
},
"type": "symfony-bridge",
"extra": {
"branch-alias": {
"dev-master": "1.2-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Bridge\\PsrHttpMessage\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Symfony Community",
"homepage": "http://symfony.com/contributors"
},
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
}
],
"description": "PSR HTTP message bridge",
"homepage": "http://symfony.com",
"keywords": [
"http",
"http-message",
"psr-17",
"psr-7"
],
"time": "2019-03-11T18:22:33+00:00"
},
{
"name": "symfony/routing",
"version": "v4.2.3",
@ -3352,6 +3867,72 @@
"environment"
],
"time": "2019-01-30T10:43:17+00:00"
},
{
"name": "zendframework/zend-diactoros",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/zendframework/zend-diactoros.git",
"reference": "c3c330192bc9cc51b7e9ce968ff721dc32ffa986"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/zendframework/zend-diactoros/zipball/c3c330192bc9cc51b7e9ce968ff721dc32ffa986",
"reference": "c3c330192bc9cc51b7e9ce968ff721dc32ffa986",
"shasum": ""
},
"require": {
"php": "^7.1",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.0"
},
"provide": {
"psr/http-factory-implementation": "1.0",
"psr/http-message-implementation": "1.0"
},
"require-dev": {
"ext-dom": "*",
"ext-libxml": "*",
"http-interop/http-factory-tests": "^0.5.0",
"php-http/psr7-integration-tests": "dev-master",
"phpunit/phpunit": "^7.0.2",
"zendframework/zend-coding-standard": "~1.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.1.x-dev",
"dev-develop": "2.2.x-dev",
"dev-release-1.8": "1.8.x-dev"
}
},
"autoload": {
"files": [
"src/functions/create_uploaded_file.php",
"src/functions/marshal_headers_from_sapi.php",
"src/functions/marshal_method_from_sapi.php",
"src/functions/marshal_protocol_version_from_sapi.php",
"src/functions/marshal_uri_from_sapi.php",
"src/functions/normalize_server.php",
"src/functions/normalize_uploaded_files.php",
"src/functions/parse_cookie_header.php"
],
"psr-4": {
"Zend\\Diactoros\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"description": "PSR HTTP Message implementations",
"keywords": [
"http",
"psr",
"psr-7"
],
"time": "2019-01-05T20:13:32+00:00"
}
],
"packages-dev": [

View File

@ -45,6 +45,11 @@ return [
'driver' => 'jwt',
'provider' => 'users',
],
'oauth' => [
'driver' => 'passport',
'provider' => 'users',
],
],
/*

View File

@ -14,6 +14,13 @@ $menu['user'] = [
['title' => 'general.player-manage', 'link' => 'user/player', 'icon' => 'fa-users'],
['title' => 'general.my-reports', 'link' => 'user/reports', 'icon' => 'fa-flag'],
['title' => 'general.profile', 'link' => 'user/profile', 'icon' => 'fa-user'],
[
'title' => 'general.developer',
'icon' => 'fa-code-branch',
'children' => [
['title' => 'general.oauth-manage', 'link' => 'user/oauth/manage', 'icon' => 'fa-feather-alt'],
],
],
];
$menu['admin'] = [

View File

@ -3,3 +3,5 @@
use Artisan;
Artisan::call('jwt:secret', ['--no-interaction' => true]);
Artisan::call('migrate', ['--force' => true]);
Artisan::call('passport:keys', ['--no-interaction' => true]);

View File

@ -36,6 +36,11 @@ export default [
component: () => import('../views/user/Profile.vue'),
el: '.content',
},
{
path: 'user/oauth/manage',
component: () => import('../views/user/OAuth.vue'),
el: '.content',
},
{
path: 'admin',
module: [() => import('../views/admin/Dashboard')],

View File

@ -0,0 +1,228 @@
<template>
<section class="content">
<el-button
type="primary"
class="btn-create-app"
data-toggle="modal"
data-target="#modal-create"
>
{{ $t('user.oauth.create') }}
</el-button>
<vue-good-table
:rows="clients"
:columns="columns"
:search-options="tableOptions.search"
:pagination-options="tableOptions.pagination"
style-class="vgt-table striped"
>
<template #table-row="props">
<span v-if="props.column.field === 'name'">
{{ props.formattedRow[props.column.field] }}&nbsp;
<a
:title="$t('user.oauth.modifyName')"
href="#"
data-test="name"
@click="modifyName(props.row)"
>
<i class="fas fa-edit btn-edit" />
</a>
</span>
<span v-else-if="props.column.field === 'redirect'">
{{ props.formattedRow[props.column.field] }}&nbsp;
<a
:title="$t('user.oauth.modifyUrl')"
href="#"
data-test="callback"
@click="modifyCallback(props.row)"
>
<i class="fas fa-edit btn-edit" />
</a>
</span>
<span v-else-if="props.column.field === 'operations'">
<el-button type="danger" data-test="remove" @click="remove(props.row)">
{{ $t('report.delete') }}
</el-button>
</span>
<span v-else>
{{ props.formattedRow[props.column.field] }}
</span>
</template>
</vue-good-table>
<div
id="modal-create"
class="modal fade"
tabindex="-1"
role="dialog"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
<h4 v-t="'user.oauth.create'" class="modal-title" />
</div>
<div class="modal-body">
<table class="table">
<tbody>
<tr>
<td v-t="'user.oauth.name'" class="key" />
<td class="value">
<el-input v-model="name" type="text" />
</td>
</tr>
<tr>
<td v-t="'user.oauth.redirect'" class="key" />
<td class="value">
<el-input v-model="callback" type="text" />
</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer">
<el-button data-dismiss="modal">{{ $t('general.close') }}</el-button>
<el-button type="primary" data-test="create" @click="create">
{{ $t('general.submit') }}
</el-button>
</div>
</div>
</div>
</div>
</section>
</template>
<script>
import { VueGoodTable } from 'vue-good-table'
import 'vue-good-table/dist/vue-good-table.min.css'
import tableOptions from '../../components/mixins/tableOptions'
import { walkFetch, init } from '../../scripts/net'
export default {
name: 'OAuthApps',
components: {
VueGoodTable,
},
mixins: [
tableOptions,
],
data() {
return {
name: '',
callback: '',
clients: [],
columns: [
{
field: 'id', label: this.$t('user.oauth.id'), type: 'number',
},
{ field: 'name', label: this.$t('user.oauth.name') },
{
field: 'secret',
label: this.$t('user.oauth.secret'),
sortable: false,
globalSearchDisabled: true,
},
{
field: 'redirect',
label: this.$t('user.oauth.redirect'),
sortable: false,
globalSearchDisabled: true,
},
{
field: 'operations',
label: this.$t('admin.operationsTitle'),
sortable: false,
globalSearchDisabled: true,
},
],
}
},
mounted() {
this.fetchData()
},
methods: {
async fetchData() {
this.clients = await this.$http.get('/oauth/clients')
},
async create() {
const client = await this.$http.post('/oauth/clients', {
name: this.name,
redirect: this.callback,
})
if (client.id) {
$('#modal-create').modal('hide')
this.clients.unshift(client)
} else {
this.$message.warning(client.message)
}
},
async modifyName(client) {
let name
try {
const { value } = await this.$prompt('', {
title: this.$t('user.oauth.name'),
inputValue: client.name,
})
name = value
} catch {
return
}
await this.modify(client, { name })
},
async modifyCallback(client) {
let redirect
try {
const { value } = await this.$prompt('', {
title: this.$t('user.oauth.redirect'),
inputValue: client.redirect,
})
redirect = value
} catch {
return
}
await this.modify(client, { redirect })
},
async modify(client, modified) {
const request = new Request(
`/oauth/clients/${client.id}`,
Object.assign({}, init, {
body: JSON.stringify(Object.assign({ name: client.name, redirect: client.redirect }, modified)),
method: 'PUT',
})
)
request.headers.set('Content-Type', 'application/json')
const result = await walkFetch(request)
if (result.id) {
Object.assign(client, modified)
} else {
this.$message.warning(result.message)
}
},
async remove(client) {
try {
await this.$confirm(this.$t('user.oauth.confirmRemove'), { type: 'warning' })
} catch {
return
}
const request = new Request(
`/oauth/clients/${client.id}`,
Object.assign({}, init, { method: 'DELETE' })
)
await walkFetch(request)
this.$delete(this.clients, this.clients.findIndex(({ id }) => id === client.id))
},
},
}
</script>
<style lang="stylus">
.btn-create-app
margin-bottom 5px
margin-right 10px
</style>

View File

@ -5,16 +5,6 @@ import { showAjaxError } from '@/scripts/notify'
jest.mock('@/scripts/notify')
;(window as Window & { Request: any }).Request = class {
headers: Map<string, string>
constructor(public url: string, init: Request) {
this.url = url
Object.assign(this, init)
this.headers = new Map(Object.entries(init.headers))
}
}
test('the GET method', async () => {
const json = jest.fn().mockResolvedValue({})
window.fetch = jest.fn().mockResolvedValue({

View File

@ -1,3 +1,4 @@
/* eslint-disable max-classes-per-file */
/* eslint-disable import/no-extraneous-dependencies */
import 'jest-extended'
import Vue from 'vue'
@ -20,6 +21,14 @@ window.Headers = class extends Map {
}
}
window.Request = class {
constructor(url, init) {
this.url = url
Object.assign(this, init)
this.headers = new Map(Object.entries(init.headers || {}))
}
}
const noop = () => undefined
// eslint-disable-next-line no-console
Object.keys(console).forEach(method => (console[method] = noop))

View File

@ -0,0 +1,138 @@
import Vue from 'vue'
import { mount } from '@vue/test-utils'
import { MessageBoxData } from 'element-ui/types/message-box'
import { flushPromises } from '../../utils'
import { walkFetch } from '@/scripts/net'
import OAuth from '@/views/user/OAuth.vue'
jest.mock('@/scripts/net', () => ({
walkFetch: jest.fn(),
init: {},
}))
test('basic render', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{ id: 1 },
])
const wrapper = mount(OAuth)
await wrapper.vm.$nextTick()
expect(wrapper.findAll('[data-test=remove]')).toHaveLength(1)
})
test('create app', async () => {
Object.assign(window, { $: () => ({ modal() {} }) })
Vue.prototype.$http.get.mockResolvedValue([])
Vue.prototype.$http.post
.mockResolvedValueOnce({ message: 'fail' })
.mockResolvedValueOnce({ id: 1, name: 'name' })
const wrapper = mount(OAuth)
await wrapper.vm.$nextTick()
const button = wrapper.find('[data-test=create]')
const inputs = wrapper.findAll('.value')
inputs.at(0).find('input')
.setValue('name')
inputs.at(1).find('input')
.setValue('https://example.com/')
button.trigger('click')
await wrapper.vm.$nextTick()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/oauth/clients',
{ name: 'name', redirect: 'https://example.com/' }
)
expect(Vue.prototype.$message.warning).toBeCalledWith('fail')
button.trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('name')
})
test('modify name', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{ id: 1, name: 'old' },
])
walkFetch
.mockResolvedValueOnce({ message: 'fail' })
.mockResolvedValueOnce({ id: 1, name: 'new-name' })
Vue.prototype.$prompt
.mockRejectedValueOnce('')
.mockResolvedValue({ value: 'new-name' } as MessageBoxData)
const wrapper = mount(OAuth)
await wrapper.vm.$nextTick()
const button = wrapper.find('[data-test=name]')
button.trigger('click')
await wrapper.vm.$nextTick()
expect(walkFetch).not.toBeCalled()
button.trigger('click')
await flushPromises()
expect(walkFetch).toBeCalledWith(
expect.objectContaining({
url: '/oauth/clients/1',
body: JSON.stringify({ name: 'new-name' }),
method: 'PUT',
})
)
expect(Vue.prototype.$message.warning).toBeCalledWith('fail')
button.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('new-name')
})
test('modify redirect', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{ id: 1, redirect: 'https://example.com/' },
])
walkFetch
.mockResolvedValueOnce({ message: 'fail' })
.mockResolvedValueOnce({ id: 1, redirect: 'https://example.net/' })
Vue.prototype.$prompt
.mockRejectedValueOnce('')
.mockResolvedValue({ value: 'https://example.net/' } as MessageBoxData)
const wrapper = mount(OAuth)
await wrapper.vm.$nextTick()
const button = wrapper.find('[data-test=callback]')
button.trigger('click')
await wrapper.vm.$nextTick()
expect(walkFetch).not.toBeCalled()
button.trigger('click')
await flushPromises()
expect(walkFetch).toBeCalledWith(
expect.objectContaining({
url: '/oauth/clients/1',
body: JSON.stringify({ redirect: 'https://example.net/' }),
method: 'PUT',
})
)
expect(Vue.prototype.$message.warning).toBeCalledWith('fail')
button.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('https://example.net/')
})
test('remove app', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{ id: 1, name: 'name' },
])
Vue.prototype.$confirm
.mockRejectedValueOnce('cancel')
.mockResolvedValue('confirm')
const wrapper = mount(OAuth)
await wrapper.vm.$nextTick()
const button = wrapper.find('[data-test=remove]')
button.trigger('click')
await flushPromises()
expect(walkFetch).not.toBeCalled()
button.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('No data')
})

View File

@ -78,6 +78,12 @@ logout:
success: You are now logged out.
fail: No valid session.
oauth:
authorization:
title: Authorization
introduction: A 3rd-party application ":name" is requesting permission to access your account.
button: Authorize
nickname: Nickname
email: Email
identification: Email or player name

View File

@ -223,6 +223,15 @@ user:
message: You must verify your email address before using the skin hosting service. Haven't received the email?
resend: Click here to send again.
sending: Sending...
oauth:
id: Client ID
name: App Name
secret: Client Secret
redirect: Callback URL
modifyName: Modify app name.
modifyUrl: Modify callback URL.
create: Create New App
confirmRemove: Are you sure to delete this app? You won't be able to undo this.
admin:
operationsTitle: Operations

View File

@ -13,6 +13,8 @@ back: Back
dashboard: Dashboard
my-closet: Closet
my-reports: Reports
developer: Developers
oauth-manage: OAuth2 Apps
player-manage: Players
user-manage: Users
report-manage: Reports
@ -39,6 +41,7 @@ pause: Pause
reset: Reset
submit: Submit
cancel: Cancel
op-success: Operated successfully.
notice: Notice

View File

@ -78,6 +78,12 @@ logout:
success: 登出成功
fail: 未找到已保存的登录信息
oauth:
authorization:
title: 授权
introduction: 第三方应用 :name 正在向您请求获取权限。
button: 授权
nickname: 昵称
email: Email
identification: Email 或角色名

View File

@ -221,6 +221,15 @@ user:
message: 你必须验证你的邮箱才能正常使用本站的皮肤托管等功能。没有收到验证邮件?
resend: 点击这里再次发送。
sending: 正在发送……
oauth:
id: 客户端 ID
name: 应用名
secret: 客户端 Secret
redirect: 回调 URL
modifyName: 更改应用名
modifyUrl: 更改回调 URL
create: 创建应用
confirmRemove: 确认要删除这个应用吗?此操作不可撤销。
admin:
operationsTitle: 更多操作

View File

@ -13,6 +13,8 @@ back: 返回
dashboard: 仪表盘
my-closet: 我的衣柜
my-reports: 我的举报
developer: 开发者
oauth-manage: OAuth2 应用
player-manage: 角色管理
user-manage: 用户管理
report-manage: 举报管理
@ -39,6 +41,7 @@ pause: 暂停
reset: 重置
submit: 提交
cancel: 取消
op-success: 操作成功
notice: 提示

View File

@ -0,0 +1,12 @@
@extends('user.master')
@section('title', trans('general.oauth-manage'))
@section('content')
<div class="content-wrapper">
<section class="content-header">
<h1>@lang('general.oauth-manage')</h1>
</section>
<section class="content"></section>
</div>
@endsection

View File

@ -0,0 +1,39 @@
@extends('auth.master')
@section('title', trans('auth.oauth.authorization.title'))
@section('content')
<div class="login-box">
<div class="login-logo">
<a href="{{ url('/') }}">{{ option_localized('site_name') }}</a>
</div>
<div class="login-box-body">
<p class="login-box-msg">
@lang('auth.oauth.authorization.introduction', ['name' => $client->name])
</p>
<div class="row">
<div class="col-xs-6">
<form method="post" action="{{ route('passport.authorizations.approve') }}">
@csrf
<input type="hidden" name="state" value="{{ $request->state }}">
<input type="hidden" name="client_id" value="{{ $client->id }}">
<button type="submit" class="btn btn-success btn-block btn-flat">
@lang('auth.oauth.authorization.button')
</button>
</form>
</div>
<div class="col-xs-6">
<form method="post" action="{{ route('passport.authorizations.deny') }}">
@csrf
@method('DELETE')
<input type="hidden" name="state" value="{{ $request->state }}">
<input type="hidden" name="client_id" value="{{ $client->id }}">
<button class="btn btn-default btn-block btn-flat">@lang('general.cancel')</button>
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@ -6,11 +6,11 @@ Route::prefix('auth')->group(function () {
Route::post('refresh', 'AuthController@jwtRefresh')->middleware('auth:jwt');
});
Route::prefix('user')->middleware('auth:jwt')->group(function () {
Route::prefix('user')->middleware('auth:jwt,oauth')->group(function () {
Route::put('sign', 'UserController@sign');
});
Route::prefix('players')->middleware('auth:jwt')->group(function () {
Route::prefix('players')->middleware('auth:jwt,oauth')->group(function () {
Route::get('', 'PlayerController@listAll');
Route::post('', 'PlayerController@add');
Route::delete('{pid}', 'PlayerController@delete');

View File

@ -82,6 +82,9 @@ Route::group([
Route::post('/closet/add', 'ClosetController@add');
Route::post('/closet/remove', 'ClosetController@remove');
Route::post('/closet/rename', 'ClosetController@rename');
// OAuth2 Management
Route::view('/oauth/manage', 'user.oauth');
});
/*

View File

@ -83,9 +83,12 @@ class SetupControllerTest extends TestCase
public function testInfo()
{
$this->get('/setup/info')
->assertViewIs('setup.wizard.info');
$this->get('/setup/info')->assertViewIs('setup.wizard.info');
Schema::dropIfExists('oauth_auth_codes');
Schema::dropIfExists('oauth_access_tokens');
Schema::dropIfExists('oauth_clients');
Schema::dropIfExists('oauth_personal_access_clients');
Schema::dropIfExists('oauth_refresh_tokens');
Artisan::call('migrate:refresh');
Schema::drop('users');
$this->get('/setup/info')->assertSee('already exist');
@ -167,6 +170,10 @@ class SetupControllerTest extends TestCase
->with('jwt:secret', ['--no-interaction' => true])
->once()
->andReturn(true);
Artisan::shouldReceive('call')
->with('passport:keys', ['--no-interaction' => true])
->once()
->andReturn(true);
Artisan::shouldReceive('call')
->with('migrate', ['--force' => true])
->once()