宝宝发现我网站的更新频率太低了, 想写点东西又不知从何写起. 可能一部分原因是前阵子做的东西是公司的, 没办法开源, 还有一个原因是最近在做的是私有的项目, 不方便开源. 当然, 还有更重要的原因是...肚里没货. [笑哭]
最近宝宝在学Laravel, 刚开始学, 以下内容如果有不正确之处, 欢迎斧正.
既然是做私有项目了, 而且这个项目是web的, 通过浏览器访问. 用的是Laravel, 它有一套自带的认证机制(5.2以上), 但是我觉得密码不安全, 虽然我已经用了20位的随机生成的密码了, 认证机制里又没有提供二步认证, 所以就有了这篇文章.
二步认证现在也是用得比较多了, 有的是用短信, 有的是用app生成一个6位数的随机码, 然后在登录的时候要输入, 比如将军令之类的.
当然了, 使用短信也可以, 但是考虑到我的是私有项目, 所以要控制成本- -(主要是穷). 然后就选择了Google Authenticator
原理: 谷歌验证 (Google Authenticator) 的实现原理是什么?
直接用的是 PHPGangsta/GoogleAuthenticator
修改laravel目录下的composer.json
在require里加入PHPGangstaAuthenticator, 就像这样
"require": { "php": ">=5.5.9", "laravel/framework": "5.2.*", "phpgangsta/googleauthenticator": "dev-master" },
然后
[root@superxc laravel]# composer install
[root@superxc laravel]# composer update
如果安装的过程中提示内存不足, 就像这样(我的服务器因为内存小, 所以出现了这个问题)
The following exception is caused by a lack of memory or swap, or not having swap configured
就需要手动挂载一个swap分区.
dd if=/dev/zero of=/var/swap.1 bs=1M count=1024 //生成一个大文件
mkswap /var/swap.1 //格式化成swap格式
swapon /var/swap.1 //挂载
free //查看swap是否正确挂载
swapoff -a //反挂载所有交换分区
先use \PHPGangsta_GoogleAuthenticator;
然后就可以用了.
咱先写个控制器试一下:
public function test(Request $request) { $ga = new PHPGangsta_GoogleAuthenticator(); $secret = $ga->createSecret(); echo "Secret is: ".$secret."
"; $qrCodeUrl = $ga->getQRCodeGoogleUrl('Blog', $secret); echo "Google Charts URL for the QR-Code: ".$qrCodeUrl."
"; $oneCode = $ga->getCode($secret); echo "Checking Code '$oneCode' and Secret '$secret':
"; $checkResult = $ga->verifyCode($secret, $oneCode, 2); // 2 = 2*30sec clock tolerance if ($checkResult) { echo 'OK'; } else { echo 'FAILED'; } }
正常来说, 如果没有问题的话, 结果是这样的. 当然了, Secret肯定是不一样的. 反正看到OK就行了.
羊皮卷 里记载了如果要手动认证的话, 可以自己写个AuthController, 但实际上我并不想重写这个控制器, 使用默认的就好了, 何况默认还提供了错误尝试限制, 默认是5次, 达到限制后, 60秒内无法登录(针对用户名, 邮箱, IP地址).
使用了artisan make:auth 创建了视图后默认会更新app/Http/routes.php, 我们发现里面有一条Route::auth().
注意: 请不要在生产环境下使用artisan make:auth 这个命令, 这个命令应该只在干净的环境下使用.
这个方法的实现在laravel/vendor/laravel/framework/src/Illuminate/Routing/Router.php 的367行左右.
会看到有一个login的post路由, 跟进去(app/Http/Controllers/Auth/AuthController.php).
发现这个控制器里根本没有login方法.
而且它继承的Controller应该是不可能定义了login方法. 等等, 我们看到了一个注释(13行左右).
/*
|--------------------------------------------------------------------------
| Registration & Login Controller
|--------------------------------------------------------------------------
|
| This controller handles the registration of new users, as well as the
| authentication of existing users. By default, this controller uses
| a simple trait to add these behaviors. Why don't you explore it?
|
*/
这个控制器使用了一些个 traits.
use AuthenticatesAndRegistersUsers, ThrottlesLogins;
先跟第一个, 第二个一看就是错误尝试限制, 先放一边. 怎么跟?
看最上面的use Illuminate\Foundation\Auth\AuthenticatesAndRegistersUsers;
这个trait的路径是
laravel/vendor/laravel/framework/src/Illuminate/Foundation/Auth/AuthenticatesAndRegistersUsers.php
细心的朋友可能已经发现了, laravel/vendor/laravel/framework/src/这个目录下包含了Laravel的一些个类的源码.
AuthenticatesAndRegistersUsers这个trait又使用了两个trait.
use AuthenticatesUsers, RegistersUsers { AuthenticatesUsers::redirectPath insteadof RegistersUsers; AuthenticatesUsers::getGuard insteadof RegistersUsers; }
先跟第一个AuthenticatesUsers.
AuthenticatesAndRegistersUsers这个trait的开头没有use, 所以 AuthenticatesUsers 和 AuthenticatesAndRegistersUsers是在同一个命名空间下的, 根据规范, 他俩应该在同一个目录.
一看, 果然有这个文件, AuthenticatesUsers.php
一打开就发现找对了, 就像Router里描述的, AuthenticatesUsers里有个login方法.(57行左右)
public function login(Request $request) { $this->validateLogin($request); // If the class is using the ThrottlesLogins trait, we can automatically throttle // the login attempts for this application. We'll key this by the username and // the IP address of the client making these requests into this application. $throttles = $this->isUsingThrottlesLoginsTrait(); if ($throttles && $lockedOut = $this->hasTooManyLoginAttempts($request)) { $this->fireLockoutEvent($request); return $this->sendLockoutResponse($request); } $credentials = $this->getCredentials($request); if (Auth::guard($this->getGuard())->attempt($credentials, $request->has('remember'))) { return $this->handleUserWasAuthenticated($request, $throttles); } // If the login attempt was unsuccessful we will increment the number of attempts // to login and redirect the user back to the login form. Of course, when this // user surpasses their maximum number of attempts they will get locked out. if ($throttles && ! $lockedOut) { $this->incrementLoginAttempts($request); } return $this->sendFailedLoginResponse($request); }
其实他的注释写得很清楚了, 可能有的朋友英文不大好, 那我简单解释下
这个方法先验证表单输入是否合法(不是用户名密码是否正确), 然后查看是否因为多次尝试被限制登录, 如果登录次数超过限制就直接返回并提示. 否则调用guard的attempt进行用户名密码验证. 如果attempt返回真, 就执行handleUserWasAuthenticated方法, 否则, 说明用户名, 密码错误.
可以有的朋友看到这里就准备动手自己干了, 不听我瞎逼逼了.
还记得 羊皮卷 吗? 里面也说了用attempt方法进行验证用户. 注意里面提了这么一嘴.
If the two hashed passwords match an authenticated session will be started for the user.
这个attempt方法在验证用户的时候, 如果验证通过, 会返回真, 而且自动把用户已经登录的状态存到session里.
所以如果你在handleUserWasAuthenticated方法里写二步认证的话, 已经没啥意义了, 因为用户已经被标记为已登录状态了. (用户可以直接在地址栏里输入仪表盘的地址, 然后进行跳转, 所以这样写的二步认证没个球用)
所以, 我的做法是, 继续跟attempt方法, 试图让它正常验证, 但是不保存登录状态. 然后我们在handleUserWasAuthenticated方法里把用户重定向到二步认证的页面, 用户完成二步认证后, 保存登录状态,然后重定向回仪表盘(登录成功).
通过查 API 发现guard确实有attempt方法, 看下class\ Illuminate \ Auth \ Guard 这个结构
目测应该在laravel/vendor/laravel/framework/src/Illuminate/Auth/这个目录下.
发现有三个guard: RequestGuard, SessionGuard, TokenGuard. Laravel的auth默认是使用session驱动的, 而不是token.(查看 config/auth.php)
在SessionGuard的349行发现有attempt的实现,
/** * Attempt to authenticate a user using the given credentials. * * @param array $credentials * @param bool $remember * @param bool $login * @return bool */ public function attempt(array $credentials = [], $remember = false, $login = true) { $this->fireAttemptEvent($credentials, $remember, $login); $this->lastAttempted = $user = $this->provider->retrieveByCredentials($credentials); // If an implementation of UserInterface was returned, we'll ask the provider // to validate the user against the given credentials, and if they are in // fact valid we'll log the users into the application and return true. if ($this->hasValidCredentials($user, $credentials)) { if ($login) { $this->login($user, $remember); } return true; } // If the authentication attempt fails we will fire an event so that the user // may be notified of any suspicious attempts to access their account from // an unrecognized user. A developer may listen to this event as needed. if ($login) { $this->fireFailedEvent($user, $credentials); } return false; }
注释说 $this->hasValidCredentials($user, $credentials) 是请求一个provider去验证用户名和密码是否正确, 如果正确, 我们将会把用户标记为已登录状态, 并返回真.
那么应该就是在$this->login()这个方法里保存session登录状态了.
跟进去, 发现就是它保存登录状态了.
按照刚才我说的做法, 这里不直接写入登录状态, 而是直接返回真. 看我改的代码:
if ($this->hasValidCredentials($user, $credentials)) { if ($login) { if($remember) { session(['remember' => 'YES']); } session(['user' => $user]); // $this->login($user, $remember); // not to log user to application immediately, it need 2-factor-authentication // modify be superxc // 2016-11-21 // [email protected] } return true; }
直接返回真, 不写入登录状态, 并在返回前保存$remember和$user到session里, 用于我们完成二步认证后调用$this->login()写入登录状态.
然后改handleUserWasAuthenticated方法(在AuthenticatesUsers这个文件里的108行左右)
这个方法是在attempt验证用户密码正确的情况下调用的.
它原来是这个样子的.
/** * Send the response after the user was authenticated. * * @param \Illuminate\Http\Request $request * @param bool $throttles * @return \Illuminate\Http\Response */ protected function handleUserWasAuthenticated(Request $request, $throttles) { if ($throttles) { $this->clearLoginAttempts($request); } if (method_exists($this, 'authenticated')) { return $this->authenticated($request, Auth::guard($this->getGuard())->user()); } return redirect()->intended($this->redirectPath()); }
清除错误尝试限制的记录, 然后如果有authenticated方法的话, 就调用authenticated方法进行一些初始化.
然后返回登录前的页面.
显然, 我们并不想在这里就清除错误尝试限制的记录, 因为二步认证也需要这个限制. 还有就是, 我们是想跳转二步认证的页面, 而不是登录前的页面.
修改后的handleUserWasAuthenticated就像这样.
protected function handleUserWasAuthenticated(Request $request, $throttles) { return redirect('/gauth')->with('login_success', true); }
重定向了时候加了个flash是因为保证二步认证的页面只有在该闪照有效的时候才能访问.(用户名密码正确才能进入二步认证的页面)
然后可以开始写二步认证的页面了, 页面都会写吧, 我就不贴代码了.
看下新的路由.
public function auth() { // Authentication Routes... $this->get('login', 'Auth\AuthController@showLoginForm'); $this->post('login', 'Auth\AuthController@login'); $this->get('logout', 'Auth\AuthController@logout'); $this->get('gauth', 'Auth\AuthController@showGauthForm'); $this->post('gauth', 'Auth\AuthController@gauth'); // Registration Routes... $this->get('register', 'Auth\AuthController@showRegistrationForm'); $this->post('register', 'Auth\AuthController@register'); // Password Reset Routes... $this->get('password/reset/{token?}', 'Auth\PasswordController@showResetForm'); $this->post('password/email', 'Auth\PasswordController@sendResetLinkEmail'); $this->post('password/reset', 'Auth\PasswordController@reset'); }
这里添加了两条路由用于处理二步认证.
showGauthForm和gauth都写在AuthenticatesUsers这个文件里.
他俩看起来像这样.
/** * Show the application recertification form. * * @return \Illuminate\Http\Response */ public function showGauthForm() { if(session()->has('login_success')){ return view('auth.gauth'); } return back(); }
/** * Handle a recertification(2-factor-authentication) request to the application. * * @param \Illuminate\Http\Request $request * @return \Illuminate\Http\Response */ public function gauth(Request $request) { $throttles = $this->isUsingThrottlesLoginsTrait(); $lockedOut = $this->hasTooManyLoginAttempts($request); $ga = new PHPGangsta_GoogleAuthenticator(); $secret = config('superxc.google_auth_secret'); $oneCode = strval($request->input('auth_code')); if($ga->verifyCode($secret, $oneCode, 2)){ Auth::guard($this->getGuard())->login(session()->pull('user'), session()->has('remember')); if ($throttles) { $this->clearLoginAttempts($request); } if (method_exists($this, 'authenticated')) { return $this->authenticated($request, Auth::guard($this->getGuard())->user()); } return redirect($this->redirectPath()); } if ($throttles && ! $lockedOut) { $this->incrementLoginAttempts($request); } return redirect('/login'); }
这个方法里用到了config('superxc.google_auth_secret')是因为我的私有项目只需要一个用户, 所以我直接把GoogleAuthenticator生成的Secret保存在这里, 正常是应该把Secret保存用户表里.
用户输入的code正确时, 会清除错误尝试限制, 然后重定向到仪表盘, 否则重定向到登录页面.
二步认证到此成功实现.