使用 Sanctum 对 React SPA 应用进行身份验证

Laravel

Sanctum 是 Laravel 的轻量级 API 身份验证扩展包。在本教程中, 我将研究通过 Laravel 后端使用 Sanctum 对基于 React 的单页应用(SPA)进行身份验证。假设应用程序的前端和后端是同一顶级域的子域,我们可以使用 Sanctum 基于 cookie 的身份验证,从而避免了管理 API 令牌的麻烦。为此,我设置了 Homestead 两个域:api.sanctum.test,指向 后端(我们将创建的新 Laravel 项目)的 public 文件夹,以及 sanctum.test,它指向一个完全独立的目录:前端(React)。我还提供了一个 MySQL 数据库 sanctum_backend

译者强调:前端使用 localhost:3000,而访问后端 API 的 url 是 127.0.0.1:8000 时,认证不会通过,要使用同一个。另外,Sanctum SPA 认证目前没有像 JWT 那样可以设置 cookie 有效期,而是一直有效。

可以在本文末尾找到指向最终代码的链接。

后端

让我们从 API 开始:

laravel new backend

我们的 API 可以是任何东西 - 假设它是用于图书馆的,我们只有一个资源:books。我们可以使用一个 artisan 命令来创建所需的大部分内容:

php artisan make:model Book -mr

-m 标志生成迁移,而 -r 创建一个资源控制器,该控制器具有用于所有 CRUD 操作的方法。在本教程中,我们只需要 index,但是很高兴知道有此选项的存在。接着,让我们在迁移中创建几个字段:

Schema::create('books', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->string('author');
    $table->timestamps();
});

…并运行迁移(不要忘记更新 .env 文件里的数据库凭据):

php artisan migrate

现在更新 DatabaseSeeder.php 以便提供一些书籍(以及后续要用的一个用户):

Book::truncate();
$faker = \Faker\Factory::create();
for ($i = 0; $i < 50; $i++) {
    Book::create([
        'title' => $faker->sentence,
        'author' => $faker->name,
    ]);
}
User::truncate();
User::create([
    'name' => 'Alex',
    'email' => 'alex@alex.com',
    'password' => Hash::make('pwdpwd'),
]);

现在运行 php artisan db:seed 生成此数据。最后,我们需要创建路由和控制器动作。这很简单。将此添加到 routes/api.php 文件中:

Route::get('/book', 'BookController@index');

然后在 BookControllerindex 方法中,返回所有书籍:

return response()->json(Book::all());

当然,在真实的 API 中,我们可能想使用 Laravel 的 API资源 之类的东西来转换那些对象,但是现在就可以了。现在,如果我们在浏览器或所选的 HTTP 客户端(Postman,Insomnia 等)中访问 api.sanctum.test/api/book,您应该会看到所有书籍的列表。

前端

要创建 SPA, 我将使用 create-react-app:

npx create-react-app frontend
cd frontend

我们将要使用 react-router-dom 软件包将路由添加到应用程序,以及使用 Axios 进行 HTTP 请求。完成后,启动应用程序:

npm install axios react-router-dom
npm start

现在让我们创建一个简单的 Books 组件,该组件将使用 Axios 调用 books 节点并在无序列表中显示这些书:

import React from 'react';
import axios from 'axios';

const Books = () => {
    const [books, setBooks] = React.useState([]);
    React.useEffect(() => {
        axios.get('https://api.sanctum.test/api/book')
        .then(response => {
            setBooks(response.data)
        })
        .catch(error => console.error(error));
    }, []);
    const bookList = books.map((book) => 
        <li key={book.id}>{book.title}</li>
    );
    return (
        <ul>{bookList}</ul>
    );
}

export default Books;

App.js 中引用此组件,我们准备好了:

import React from 'react';
import { BrowserRouter as Router, Switch, Route, NavLink } from 'react-router-dom';
import Books from './components/Books';

const App = () => {
    return (
        <Router>
            <div>
                <NavLink to='/books'>Books</NavLink>
            </div>
            <Switch>
                <Route path='/books' component={Books} />
            </Switch>
        </Router>
    );
};

export default App;

在浏览器中访问 books 页面,您将看到节点返回的书籍列表。

现在,假设我们想让谁来看这些书。或者,API 可以向不同的用户显示不同的书籍。这是 Sanctum 发挥作用的地方。因此,回到 后端 目录,我们需要 Sanctum 软件包:

composer require laravel/sanctum laravel/ui

我们还需要 laravel/ui 软件包,因为它为我们提供了一些身份验证模版。要创建它并发布 Sanctum 配置,请运行:

php artisan ui:auth
php artisan vendor:publish

…并将 sanctum 中间件添加到路由:

Route::middleware('auth:sanctum')->get('/book', 'BookController@index');

由于我们的目标是让前端 sanctum.test 与后端 api.sanctum.test 进行通信,从现在就使用 npm run build 去构建 SPA。这样,我们可以通过 sanctum.test (而不是开发服务器默认的 localhost )访问SPA。另外,要配置 Sanctum 的有效状态域,登录才有效果。

因此,构建前端代码,然后尝试再次点击该 books 页面。如果您在浏览器的开发工具中查看请求,则会看到 401 Unauthenticated 错误:我们需要登录。这是 SPA 的 登录 组件:

import React from 'react';
import axios from 'axios';

const Login = (props) => {
    const [email, setEmail] = React.useState('');
    const [password, setPassword] = React.useState('');
    const handleSubmit = (e) => {
        e.preventDefault();
        axios.post('https://api.sanctum.test/login', {
            email: email,
            password: password
        }).then(response => {
            console.log(response)
        });
    }
    return (
        <div>
            <h1>Login</h1>
            <form onSubmit={handleSubmit}>
                <input
                    type="email"
                    name="email"
                    placeholder="Email"
                    value={email}
                    onChange={e => setEmail(e.target.value)}
                    required
                />
                <input
                    type="password"
                    name="password"
                    placeholder="Password"
                    value={password}
                    onChange={e => setPassword(e.target.value)}
                    required
                />
                <button type="submit">Login</button>  
            </form>
        </div>
    );
}

export default Login;

这只是一个基础表单,它使用 Axios 将电子邮件和密码 post 到后端的 login 路由并记录响应。将其添加到 App 组件中:

import Login from './components/Login';
[...]
<Switch>
    <Route path='/books' component={Books} />
    <Route path='/login' component={Login} />
</Switch>

现在,访问登录页面,填写用户的详细信息(如上所示),然后单击“登录”。糟糕!看一下控制台:它向我们显示了 「Cross-Origin Request Blocked”」错误。

题外话:CORS

同源策略(same-origin policy)是一种嵌入在浏览器中的安全措施,可防止脚本在一个源上运行(源由其架构【httphttpsftp 等】,主机名和端口号定义)来访问存储在另一个源上的数据。在我们的上下文中,这特别适用于 Fetch / XMLHttpRequest 调用。同源策略虽然允许我们向其他域调用(因此使我们容易受到 CSRF 攻击的影响,稍后将对此进行介绍),但它不允许我们读取其他域的响应。

CORS (跨域资源共享) 是针对这类问题的浏览器解决方案: 它允许您发送带有一个 Origin 标头的请求,而服务器会返回一个带有 Access-Control-Allow-Origin 标头的响应。如果两者匹配,则响应就是被认可的并且可以被浏览器接收。

这样固然很好,但是如果您查看 network (注:开发者工具中的「网络」) 标签,您会发现我们甚至还没有发出 POST 请求。事实上,我们所看到的只是一个 OPTIONS 请求。 这是为什么?嗯,原因是我们的请求不属于所谓的 「简单请求」,因为它的 Content-Type 标头是 application/json。这使其成为「预检请求」:在发送实际请求之前,将「预检」OPTIONS 请求发送到服务器,服务器将以一组标头作为响应,浏览器可以根据这些标头确定是否继续进行实际请求。 由于我们的 Laravel 应用尚未针对 CORS 进行设置,因此它不会发送回任何Access-Control- 标头,所以也就不会发出正确的请求。实际上前端已经为我们考虑到这些了,因为浏览器会在请求时自动发送 Origin 标头。因此我们只需要设置后端。

实际上,从 Laravel 7 开始,该框架带有一个开箱即用的 CORS 中间件。您可以在 cors.config 文件中进行配置。打开该文件,您可以看到默认情况下 allowed_origins 被设置为 * – 也就是说,所有内容都可以发出读取请求。那它为什么不起作用呢?好吧,请注意在稍远的地方有一个 paths 键,它的设置表示允许在 api 命名空间中进行任何操作。但是默认情况下,我们的登录路由是在根命名空间中:/login。因此,我们需要将 login放到 paths 数组中。现在,再次填写登录表单并提交试试。

CSRF

一个新的错误!419。检查响应:「CSRF 令牌不匹配」。进入下一个 issue ! CSRF 表式「跨站请求伪造」:它是恶意代理在已通过身份验证的环境中执行操作的一种方式。这里有一个来自 OWASP guide 的示例: 假设您已登录到网上银行。通过社会工程,您被诱骗访问了一个网站,然而,表面上您仍然是登录到银行的站点。黑客诱骗您「访问」的 URL 可能会隐藏在电子邮件中的 0x0 图片上,或者是诱人的链接,等等。无论如何,此 URL 会命中银行的 API,并且会对您的帐户造成可怕的影响。由于您已经登录到银行账户,因此不再需要执行任何身份验证步骤。恐怖随之而来。

如何解决这个问题? 一种方法,也是我们此处追求的方法,是让服务器将一个随机令牌放在 cookie 中发送给客户端,然后将令牌作为自定义标头包含在对服务器的每个请求中。如果我们运行php artisan route:list,将会看到 login 路由属于 web 中间件组,其中包括 VerifyCsrfToken 中间件。在其handle 方法中,我们看到了这样的条件:

if (
    $this->isReading($request) ||
    $this->runningUnitTests() ||
    $this->inExceptArray($request) ||
    $this->tokensMatch($request)
)

如果此条件的计算结果为 false,则抛出 TokenMismatchException 异常。现在,由于我们没有读取(正在发送 POST 请求),也没有运行单元测试,并且没有配置任何异常,因此它将运行 tokensMatch 方法。而且由于我们尚未发送令牌,因此这也会失败,因此我们就会得到这个异常。

Ok,这样就为我们设置了门禁。但是,我们如何首先获得 CSRF 令牌呢? 如果我们是在服务器端,则可以让 Laravel 在框架内将其传递,例如,从控制器到视图。但是在这里前端视图并没有在我们的框架内,因此框架需要以某种方式将 CSRF 令牌发送给我们。api 身份验证也没有为我们提供开箱即用的功能。这里该 Sanctum 登场了。Sanctum 将允许我们请求 CSRF 令牌,然后可以将其在标头中传递。如果你执行

php artisan route:list

您会在此处看到一条新路由:GET /sanctum/csrf-cookie。 (框架如何知道这一点?它来自 SanctumServiceProvider 的boot 方法中的 defineRoutes 方法,而该方法又是在我们运行 artisan vendor:publish 时触发的。)

好,让我们来首次请求 CSRF 路由。回到前端的登录代码,我将修改您提交登录表单时发出的 Axios 调用。 首先,它将进行呼叫以请求 CSRF 令牌; 然后它将进行 login 调用:

axios.get('https://api.sanctum.test/sanctum/csrf-cookie')
    .then(response => {
        axios.post('https://api.sanctum.test/login', {
            email: email,
            password: password
        }).then(response => {
            console.log(response)
        })
    });

填写表单, 敲回车, 然后… 报错了! 「跨域请求被阻止」。我们思考如何解决这个问题? 像前面那样: 我们需要将此新录用放到 cors 配置文件的 path 列表中:

'paths' => ['api/*', 'login', 'sanctum/csrf-cookie'],

现在,再次点击提交表单之前,请打开浏览器的开发工具,然后查看 “Storage” 标签(Firefox)或 “Application” 标签(Chrome)。希望您不会在其中看到任何 cookie(如果有,则将其删除)。现在提交表格。emmm......仍然没有 cookies!这是怎么了?

在网络选项卡中查看:您对 sanctum/csrf-cookie 的请求获得了 204 响应,这很好。单击请求,然后单击 Cookies 选项卡:您将看到两个 cookie,即 Laravel 会话 cookie 和我们想要的 cookie:XSRF-TOKEN。但是,如果您进入浏览器存储,则不会保存这些 Cookie。为什么不?

好吧,这与 cookie 范围 有关。正如 MDN 文档所建议的那样, Domain 指令将允许您指定 cookie 适用的子域。让我们看一下 XSRF-TOKEN cookie 的结构,该结构在网络请求的响应标头中可见:

XSRF-TOKEN=<token>; expires=Sat, 02-May-2020 21:40:15 GMT; Max-Age=7200; path=/; samesite=lax

当然,那里没有 domain 指令。让我们将其添加到后端的 .env 文件中:

SESSION_DOMAIN=sanctum.test

现在,重新提交登录请求,但是 cookie 仍未列出,这是因为我们在前端漏掉另外一个问题。如果我们查看 MDN docs ,则会看到以下内容:

来自其他域的 XMLHttpRequest 响应不能为自己的域设置 cookie 值,除非在发出请求之前将 withCredentials 设置为 true

因此,我们需要在 Axios 配置中将 withCredentials 设置为 true。由于我们需要针对所有请求执行此操作,因此,我们重构 SPA 代码以集中 API 配置。在 src 目录中创建一个新的文件夹 services,并添加一个具有以下内容的文件 api.js

import axios from 'axios';

const apiClient = axios.create({
    baseURL: 'https://api.sanctum.test',
    withCredentials: true,
});

export default apiClient;

现在我们可以将其导入我们的 BookLogin 组件中:

import apiClient from '../services/api';

替代 axios,改为调用 apiClient,由于我们在 Axios 配置的 baseURL 中定义了主机名,因此省略了该主机名:

apiClient.get('/sanctum/csrf-cookie')
    .then(response => {
        apiClient.post('/login', {
            email: email,
            password: password
        }).then(response => {
            console.log(response)
        })
    });

现在,再次登录,然后查看浏览器工具中的 cookie :这一次它们应该出现。但是,如果您查看控制台,则会再次看到 「跨域请求被阻止」错误,但这一次是有新原因的:「expected “true” in CORS header “Access-Control-Allow-Credentials”」。为了更加安全,仅当服务器将此标志设置为 true 时,浏览器才会执行此请求。我们可以通过在 cors.php 中将 supports_credentials 设置为 true 来实现此目的。

现在,如果您使用正确的凭据(您之前 seed 过的凭据)登录,您将看到来自登录请求的 204 响应。很好:您已通过身份验证。但是,如果您转到 书籍' 页面,当 Axios 调用 book 节点时,您仍然会收到 401 Unauthenticated 错误。解决此问题的方法是使用 Sanctum 的「有效状态域」。打开 app/Http/Kernel.php,然后将 EnsureFrontendRequestsAreStateful 中间件添加到 api 组中:

'api' => [
    EnsureFrontendRequestsAreStateful::class,
    'throttle:60,1',
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
],

让我们看一下 handle 方法的 this class 看它能做什么:

config([
    'session.http_only' => true,
    'session.same_site' => 'lax',
]);

首先,它会覆盖 session 配置。它将 http_only 设置为 true,这意味着客户端脚本(例如使用 XSS 攻击您的应用程序的恶意脚本)无法访问令牌。(正如 此OWASP文章 所述,「大多数 XSS 攻击均以 session cookie 的盗用为目标」)。它还将 same_site 设置为「lax」。根据 MDN docs,这将防止针对跨站点请求发送 cookie,除非该请求来自另一个站点到您站点的链接( 此博客文章 很好地解释了为什么这很有用)。

return (new Pipeline(app()))
    ->send($request)
    ->through(static::fromFrontend($request) ? [
        // Middleware
    ] : [])
    ->then(function ($request) use ($next) {
        return $next($request);
    });

理解了该方法的这一部分,对弄懂 Laravel 的中间件是由 Pipeline 处理是很有帮助的,该管道是 Laravel 的实用工具类,它允许您连接一系列管道以通过其发送数据。如果您查看 Illuminate\Foundation\Http\Kernel.phpsendRequestThroughRouter 方法,就会看到与上面类似的代码:

return (new Pipeline($this->app))
    ->send($request)
    ->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)
    ->then($this->dispatchToRouter());

因此,Sanctum 的 「EnsureFrontendRequestsAreStateful」中间件实际上会插入更多的中间件。但是只有在请求来自前端时才会如此,这才是检查的目的:

static::fromFrontend($request) ? [ 
    // some middleware 
] : []

如果请求来自前端,将此中间件排队,否则,只需为管道提供一个空数组。静态方法 fromFrontend 会查看 referer 标头:如果它包含您在 Sanctum 配置中设置的字符串,它将知道请求应通过特定于Sanctum 的中间件发出。这些用来与 referer 标头比较的字符串可以使用 .env 中的 SANCTUM_STATEFUL_DOMAINS 变量来设置:

SANCTUM_STATEFUL_DOMAINS=sanctum.test

什么是 Sanctum 专用中间件呢?

[
    config('sanctum.middleware.encrypt_cookies', \Illuminate\Cookie\Middleware\EncryptCookies::class),
    \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
    \Illuminate\Session\Middleware\StartSession::class,
    config('sanctum.middleware.verify_csrf_token', \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class),
]

这四个中间件管道是标准的:如果您查看 Kernel.php 中的 web 中间件,那么您将在其中看到所有四个中间件。

  • EncryptCookies:对 cookie 进行加密意味着即使攻击者可以获取 cookie,修改其内容然后发回,也会被服务器拒绝。
  • AddQueuedCookiesToResponse:处理已被 Cookie 门面队列化的任何 cookie。
  • StartSession:设置 Laravel 会话及其会话 cookie,并将其添加到响应中。
  • VerifyCsrfToken:使用 CSRF 令牌检查一切是否正常。

Authentication

现在,添加此中间件可以解决 cookie 流程。但是实际起作用的身份验证是因为我们在 API 路由中设置了 auth:sanctum 。这意味着:使用「sanctum」guard 进行身份验证。但是,如果我们来看看 Sanctum guard class ,似乎有些奇怪。根据 adding custom guards 文档,自定义 guard 必须实现 Illuminate\Contracts\Auth\Guard 接口。但是此接口中并没有任何方法包含在此 guard 中,甚至在类定义中都没有 implements 关键字。相反地,我们只看到这个 __invoke 魔术方法。

让我们看一下 SanctumServiceProvider 来厘清这个问题。它使用了 docs 中建议的 $auth->extend :

$auth->extend('sanctum', function ($app, $name, array $config) use ($auth) {
    return tap($this->createGuard($auth, $config), function ($guard) {
        $this->app->refresh('request', $guard, 'setRequest');
    });
});

这很令人困惑,所以让我们将其分解为更小的部分来看。tap 命令是「创建 guard,然后将其传递给第二个参数的闭包;然后返回 guard」速记法。让我们看一下 createGuard

return new RequestGuard(
    new Guard($auth, config('sanctum.expiration'), $config['provider']),
    $this->app['request'],
    $auth->createUserProvider()
);

首先,我们可以看到它返回了 RequestGuard 的实例,由于它实现了Guard,因此它满足了 extend 方法的参数类型。这个 RequestGuard 的第一个参数是闭包,在我们的例子中是 Sanctum 的 Guard 类。唯一的区别是「closure」( Sanctum 的 Guard 类)是一个带有 __invoke 魔术方法的类:您可以将此类视为带有状态的 closure :它为您提供了一个简单的可调用函数,该函数也可以具有属性。

然后,RequestGuard 使用回调函数返回用户。 这里是 Sanctum guard 的相关内容:

if ($user = $this->auth->guard(config('sanctum.guard', 'web'))->user()) {
    return $this->supportsTokens($user)
                ? $user->withAccessToken(new TransientToken)
                : $user;
}

第一行从web guard 中获取用户(因为我们使用常规的Web身份验证路由登录)。 如果找到用户,则 guard 将其返回; 否则,不返回任何内容。

现在,一旦您设置了 SANCTUM_STATEFUL_DOMAINS 环境变量,您就应该能够以经过身份验证的用户身份登录并查看图书页面。

完善 SPA

因此,现在我们在后端有一个可运行的身份验证系统,我们可以完善前端代码了。 本文的这一部分与 Sanctum 没有明确关系,因此可以忽略它。

首先,我们希望 App 组件中的某些状态显示用户是否已登录,默认为false

const [loggedIn, setLoggedIn] = React.useState(false);

让我们添加一个名为 login 的方法,该方法将此变量设置为 true

const login = () => {
    setLoggedIn(true);
};

现在我们可以将此方法传递给 Login 组件:

<Route path='/login' render={props => (
    <Login {...props} login={login} />
)} />

然后在检查是否已通过调用登录路径获得预期的 204 响应后,在我们的 handleSubmit 方法中调用此 login 方法:

apiClient.get('/sanctum/csrf-cookie')
    .then(response => {
        apiClient.post('/login', {
            email: email,
            password: password
        }).then(response => {
            if (response.status === 204) {
                props.login();
            }
        })
    });

(登录后,还有一些逻辑可以重定向到首页, 最终代码仓库 里可以看到具体代码。) App 组件知道用户何时登录,可以将其传递给 Books 组件,以便其可以采取相应地行动:

<Route path='/books' render={props => (
    <Books {...props} loggedIn={loggedIn} />
)} />

现在,如果 loggedIn 是false, Books 组件知道不会尝试加载书籍,而是向用户显示一条有用的消息:

React.useEffect(() => {
    if (props.loggedIn) {
        apiClient.get('/api/book')
        .then(response => {
            setBooks(response.data)
        })
        .catch(error => console.error(error));
    }
});
// ...
if (props.loggedIn) {
    return (
        <ul>{bookList}</ul>
    );
}
return (
    <div>You are not logged in.</div>
);

如何注销? 让我们修改当前的“登录”链接,以便在用户登录后有条件地显示“退出”按钮,以及在用户未登录时有条件地显示“登录”页面的链接。

const authLink = loggedIn 
    ? <button onClick={logout}>Logout</button>
    : <NavLink to='/login'>Login</NavLink>;
return (
    <Router>
        <div>
            <NavLink to='/books'>Books</NavLink>
            {authLink}
        </div>
        <Switch>
            <Route path='/books' component={Books} />
            <Route path='/login' render={props => (
                <Login {...props} login={login} />
            )} />
        </Switch>
    </Router>
);

现在我们可以添加一个 logout 方法。 Laravel 的 auth 脚手架为我们提供了一个POST 登出路由,因此我们可以向 App 组件中添加一个方法:

const logout = () => {
    apiClient.post('/logout').then(response => {
        if (response.status === 204) {
            setLoggedIn(false);
        }
    })
};    

试试这个,然后...哎呀! 另一个 CORS 错误。 将 logout添加到我们的 cors.php配置文件中的 paths 数组中:

'paths' => ['api/*', 'login', 'logout', 'sanctum/csrf-cookie'],

现在登录,然后再次注销,您应该会看到菜单项正在更新。 登出后,您将无法访问书籍页面。

最后一步是将 loggedIn 布尔值保存到浏览器的存储中。 如果我们不这样做,则当用户刷新浏览器时,SPA会将用户重置为未登录状态。 我们可以为此使用浏览器的 sessionStorage API:

const [loggedIn, setLoggedIn] = React.useState(
    sessionStorage.getItem('loggedIn') == 'true' || false
);
const login = () => {
    setLoggedIn(true);
    sessionStorage.setItem('loggedIn', true);
};
const logout = () => {
    apiClient.post('/logout').then(response => {
        if (response.status === 204) {
            setLoggedIn(false);
            sessionStorage.setItem('loggedIn', false);
        }
    })
}; 

后端和前端的最终代码可以在这里找到:

本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。

原文地址:https://laravel-news.com/using-sanctum-t...

译文地址:https://learnku.com/laravel/t/46342

本文为协同翻译文章,如您发现瑕疵请点击「改进」按钮提交优化建议
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
讨论数量: 1

文章长了点,一步步照着试试,谢谢大佬分享

3年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!