整个激活流程如下:
1、用户注册成功后,自动生成激活令牌;
2、将激活令牌以链接的形式附带在注册邮件里面,并将邮件发送到用户的注册邮箱上;
3、用户点击注册链接跳到指定路由,路由收到激活令牌参数后映射给相关控制器动作处理;
4、控制器拿到激活令牌并进行验证,验证通过后对该用户进行激活,并将其激活状态设置为已激活;
5、用户激活成功,自动登录;
接下来让我们跟之前一样,新建一个 Git 分支来开发新功能。
git checkout master
git checkout -b account-activation-password-resets
添加字段
在用户的账号激活功能中,我们需要为激活令牌 (activation_token) 和激活状态 (activated) 字段新增一个迁移,来将这两个字段添加到用户表中。由于我们进行的是字段添加操作,因此在命名迁移文件时需要加上前缀,遵照如 add_column_to_table 这样的命名规范,并在生成迁移文件的命令中启用 --table 项目,用于指定对应的数据库表。最终的生成命令如下:
php artisan make:migration add_activation_to_users_table --table=users
使用随机字符来生成用户的激活令牌,因此这里的激活令牌字段需要为 string 类型,在用户成功激活以后,我们还会对激活令牌进行清空,避免用户进行多次使用,因此我们还需要将字段设置为 nullable,代表该字段允许为空。而用户的激活状态只有已激活和未激活两种状态,默认为未激活的状态,因此我们可以将激活状态设置为 boolean 类型,当其值为真时,代表已激活,反之亦然。
现在让我们来为新增的迁移文件加上这两个字段。
database/migrations/[timestamp]_add_activation_to_users_table.php
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddActivationToUsersTable extends Migration
{
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->string('activation_token')->nullable();
$table->boolean('activated')->default(false);
});
}
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('activation_token');
$table->dropColumn('activated');
});
}
}
接着我们还需要运行迁移,将字段加入到用户表中。
php artisan migrate
生成令牌
用户的激活令牌需要在用户创建(注册)之前就先生成好,这样当用户注册成功之后我们才可以将令牌附带到注册链接上,并通过邮件的形式发送给用户。
creating 用于监听模型被创建之前的事件,created 用于监听模型被创建之后的事件。接下来我们要生成的用户激活令牌需要在用户模型创建之前生成,因此需要监听的是 creating 方法。
在用户模型中添加 creating 方法如下(注意顶部 use Illuminate\Support\Str;)
app/Models/User.php
<?php
namespace App\Models;
.
.
.
use Illuminate\Support\Str;
class User extends Authenticatable
{
.
.
.
protected $hidden = [
'password', 'remember_token',
];
public static function boot()
{
parent::boot();
static::creating(function ($user) {
$user->activation_token = Str::random(10);
});
}
.
.
.
}
boot 方法会在用户模型类完成初始化之后进行加载,因此我们对事件的监听需要放在该方法中。
现在,我们需要更新模型工厂,将生成的假用户都设为已激活状态:
database/factories/UserFactory.php
<?php
use App\Models\User;
use Illuminate\Support\Str;
use Faker\Generator as Faker;
$factory->define(User::class, function (Faker $faker) {
$date_time = $faker->date . ' ' . $faker->time;
return [
'name' => $faker->name,
'email' => $faker->unique()->safeEmail,
'email_verified_at' => now(),
'activated' => true,
'password' => '$2y$10$TKh8H1.PfQx37YgCzwiKb.KjNyWgaHb9cbcoQgdIVFlYg7B77UdFm', // secret
'remember_token' => Str::random(10),
'created_at' => $date_time,
'updated_at' => $date_time,
];
});
完成之后,我们重置并填充数据库。
php artisan migrate:refresh --seed
邮件程序
对环境配置文件 .env 进行配置,使用 log 邮件驱动的方式来调试邮件发送功能
.
.
.
MAIL_DRIVER=log
.
.
.
激活路由
routes/web.php
<?php
Route::get('/', 'StaticPagesController@home')->name('home');
Route::get('/help', 'StaticPagesController@help')->name('help');
Route::get('/about', 'StaticPagesController@about')->name('about');
Route::get('signup', 'UsersController@create')->name('signup');
Route::resource('users', 'UsersController');
Route::get('login', 'SessionsController@create')->name('login');
Route::post('login', 'SessionsController@store')->name('login');
Route::delete('logout', 'SessionsController@destroy')->name('logout');
Route::get('signup/confirm/{token}', 'UsersController@confirmEmail')->name('confirm_email');
在 Laravel 中,我们使用视图来构建邮件模板,在用户查收邮件时,该模板将作为内容展示视图。接下来我们需要创建一个用于渲染注册邮件的 confirm 视图。
resources/views/emails/confirm.blade.php
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>注册确认链接</title>
</head>
<body>
<h1>感谢您在 Weibo App 网站进行注册!</h1>
<p>
请点击下面的链接完成注册:
<a href="{{ route('confirm_email', $user->activation_token) }}">
{{ route('confirm_email', $user->activation_token) }}
</a>
</p>
<p>
如果这不是您本人的操作,请忽略此邮件。
</p>
</body>
</html>
登录时检查是否已激活#
在我们前面章节加入的登录操作中,用户即使没有激活也能够正常登录。接下来我们需要对之前的登录代码进行修改,当用户没有激活时,则视为认证失败,用户将会被重定向至首页,并显示消息提醒去引导用户查收邮件。
app/Http/Controllers/SessionsController.php
<?php
namespace App\Http\Controllers;
.
.
.
class SessionsController extends Controller
{
.
.
.
public function store(Request $request)
{
$credentials = $this->validate($request, [
'email' => 'required|email|max:255',
'password' => 'required'
]);
if (Auth::attempt($credentials, $request->has('remember'))) {
if(Auth::user()->activated) {
session()->flash('success', '欢迎回来!');
$fallback = route('users.show', Auth::user());
return redirect()->intended($fallback);
} else {
Auth::logout();
session()->flash('warning', '你的账号未激活,请检查邮箱中的注册邮件进行激活。');
return redirect('/');
}
} else {
session()->flash('danger', '很抱歉,您的邮箱和密码不匹配');
return redirect()->back()->withInput();
}
}
.
.
.
}
发送邮件#
接下来我们要开始使用邮箱发送功能,在 Laravel 中,可以通过 Mail 接口的 send 方法来进行邮件发送,
接下来让我们为用户控制器定义一个 sendEmailConfirmationTo 方法,该方法将用于发送邮件给指定用户。我们会在用户注册成功之后调用该方法来发送激活邮件,具体代码实现如下
(请注意顶部加载 use Mail;):
app/Http/Controllers/UsersController.php
<?php
namespace App\Http\Controllers;.
.
.
.
use Mail;
class UsersController extends Controller
{
.
.
.
public function store(Request $request)
{
$this->validate($request, [
'name' => 'required|max:50',
'email' => 'required|email|unique:users|max:255',
'password' => 'required|confirmed|min:6'
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => bcrypt($request->password),
]);
$this->sendEmailConfirmationTo($user);
session()->flash('success', '验证邮件已发送到你的注册邮箱上,请注意查收。');
return redirect('/');
}
.
.
.
protected function sendEmailConfirmationTo($user)
{
$view = 'emails.confirm';
$data = compact('user');
$from = 'summer@example.com';
$name = 'Summer';
$to = $user->email;
$subject = "感谢注册 Weibo 应用!请确认你的邮箱。";
Mail::send($view, $data, function ($message) use ($from, $name, $to, $subject) {
$message->from($from, $name)->to($to)->subject($subject);
});
}
}
请注意我们需要调用 use Mail 来引入邮件相关的操作方法。通过上面代码可以看到,我们把之前用户注册成功之后进行的登录操作:
Auth::login($user);
替换为了激活邮箱的发送操作:
$this->sendEmailConfirmationTo($user);
注册成功提示语改为查看邮箱的提示语。在激活邮件发送成功之后,我们还会将用户重定向至首页,而并非之前的用户个人页。
激活功能
现在的邮箱发送功能已经能够正常使用,接下来让我们完成前面定义的 confirm_email 路由对应的控制器方法 confirmEmail,来完成用户的激活操作。并且在 __construct 方法里开启未登录用户访问权限。
app/Http/Controllers/UsersController.php
<?php
namespace App\Http\Controllers;.
.
.
.
class UsersController extends Controller
{
public function __construct()
{
$this->middleware('auth', [
'except' => ['show', 'create', 'store', 'index', 'confirmEmail']
]);
.
.
.
}
.
.
.
public function confirmEmail($token)
{
$user = User::where('activation_token', $token)->firstOrFail();
$user->activated = true;
$user->activation_token = null;
$user->save();
Auth::login($user);
session()->flash('success', '恭喜你,激活成功!');
return redirect()->route('users.show', [$user]);
}
}
Auth 中间件黑名单中,我们增加了 confirmEmail 来开启未登录用户的访问。
在 confirmEmail 中,我们会先根据路由传送过来的 activation_token 参数从数据库中查找相对应的用户,Eloquent 的 where 方法接收两个参数,第一个参数为要进行查找的字段名称,第二个参数为对应的值,查询结果返回的是一个数组,因此我们需要使用 firstOrFail 方法来取出第一个用户,在查询不到指定用户时将返回一个 404 响应。在查询到用户信息后,我们会将该用户的激活状态改为 true,激活令牌设置为空。最后将激活成功的用户进行登录,并在页面上显示消息提示和重定向到个人页面。
现在打开 Laravel 的 Log 文件
storage/logs/laravel.log
Git 代码版本控制#
接着让我们将本次更改纳入版本控制中:
$ git add -A
$ git commit -m "用户激活"
密码重设
密码重设的步骤如下:
用户点击进入 忘记密码页面;在忘记密码页面 提交邮箱信息;控制器通过该邮箱查找到指定用户并为该用户生成一个密码令牌,接着将该令牌以链接的形式发送到用户提交的邮箱上;用户查看自己个人邮箱,点击重置密码链接跳转到重置密码页面;用户在该页面输入自己的邮箱和密码并提交;控制器对用户的邮箱和密码重置令牌进行匹配,匹配成功则更新用户密码;
了解其中的整个流程之后,接下来的开发就容易多了。
本节我们来开发 1~ 3,下一节开发 4~6 。
新增路由#
routes/web.php
.
.
.
Route::get('password/reset', 'PasswordController@showLinkRequestForm')->name('password.request');
Route::post('password/email', 'PasswordController@sendResetLinkEmail')->name('password.email');
Route::get('password/reset/{token}', 'PasswordController@showResetForm')->name('password.reset');
Route::post('password/reset', 'PasswordController@reset')->name('password.update');
四个路由分别是:
showLinkRequestForm —— 填写 Email 的表单 sendResetLinkEmail —— 处理表单提交,成功的话就发送邮件,附带 Token 的链接 showResetForm —— 显示更新密码的表单,包含 tokenreset —— 对提交过来的 token 和 email 数据进行配对,正确的话更新密码
新增忘记密码入口
接下来让我们修改用户的登录页面,添加修改密码的链接,方便忘记密码的用户在第一时间找到入口。
resources/views/sessions/create.blade.php
@extends('layouts.default')
@section('title', '登录')
@section('content')
<div class="offset-md-2 col-md-8">
<div class="card ">
<div class="card-header">
<h5>登录</h5>
</div>
<div class="card-body">
@include('shared._errors')
<form method="POST" action="{{ route('login') }}">
{{ csrf_field() }}
<div class="form-group">
<label for="email">邮箱:</label>
<input type="text" name="email" class="form-control" value="{{ old('email') }}">
</div>
<div class="form-group">
<label for="password">密码(<a href="{{ route('password.request') }}">忘记密码</a>):</label>
<input type="password" name="password" class="form-control" value="{{ old('password') }}">
</div>
<div class="form-group">
<div class="form-check">
<input type="checkbox" class="form-check-input" name="remember" id="exampleCheck1">
<label class="form-check-label" for="exampleCheck1">记住我</label>
</div>
</div>
<button type="submit" class="btn btn-primary">登录</button>
</form>
<hr>
<p>还没账号?<a href="{{ route('signup') }}">现在注册!</a></p>
</div>
</div>
</div>
@stop
忘记密码页面#
创建页面:
resources/views/auth/passwords/email.blade.php
@extends('layouts.default')
@section('title', '重置密码')
@section('content')
<div class="col-md-8 offset-md-2">
<div class="card ">
<div class="card-header"><h5>重置密码</h5></div>
<div class="card-body">
@if (session('status'))
<div class="alert alert-success">
{{ session('status') }}
</div>
@endif
<form class="" method="POST" action="{{ route('password.email') }}">
{{ csrf_field() }}
<div class="form-group{{ $errors->has('email') ? ' has-error' : '' }}">
<label for="email" class="form-control-label">邮箱地址:</label>
<input id="email" type="email" class="form-control" name="email" value="{{ old('email') }}" required>
@if ($errors->has('email'))
<span class="form-text">
<strong>{{ $errors->first('email') }}</strong>
</span>
@endif
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">
发送密码重置邮件
</button>
</div>
</form>
</div>
</div>
</div>
@endsection
接下来创建控制器方法:
app/Http/Controllers/PasswordController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class PasswordController extends Controller
{
public function showLinkRequestForm()
{
return view('auth.passwords.email');
}
}
访问忘记密码页面 weibo.test/password/reset :
发送重置链接#
在密码重设功能中,我们还会用到一个用来保存密码重置令牌的数据表,Laravel 已为我们生成好了该数据表:
database/migrations/2014_10_12_100000_create_password_resets_table.php
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreatePasswordResetsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('password_resets', function (Blueprint $table) {
$table->string('email')->index();
$table->string('token')->index();
$table->timestamp('created_at');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('password_resets');
}
}
可以看到 Laravel 默认生成的密码重置表有三个字段 email, token, created_at,分别用于生成用户邮箱、密码重置令牌、密码重置令牌的创建时间,并为邮箱和密码重置令牌加上了索引,这样在数据库使用这两个字段进行查找时效率更快。
接下来新增 sendResetLinkEmail() 方法来处理发送找回密码邮件的逻辑:
app/Http/Controllers/PasswordController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\User;
use Hash;
use Illuminate\Support\Str;
use DB;
use Mail;
use Carbon\Carbon;
class PasswordController extends Controller
{
public function showLinkRequestForm()
{
return view('auth.passwords.email');
}
public function sendResetLinkEmail(Request $request)
{
// 1. 验证邮箱
$request->validate(['email' => 'required|email']);
$email = $request->email;
// 2. 获取对应用户
$user = User::where("email", $email)->first();
// 3. 如果不存在
if (is_null($user)) {
session()->flash('danger', '邮箱未注册');
return redirect()->back()->withInput();
}
// 4. 生成 Token,会在视图 emails.reset_link 里拼接链接
$token = hash_hmac('sha256', Str::random(40), config('app.key'));
// 5. 入库,使用 updateOrInsert 来保持 Email 唯一
DB::table('password_resets')->updateOrInsert(['email' => $email], [
'email' => $email,
'token' => Hash::make($token),
'created_at' => new Carbon,
]);
// 6. 将 Token 链接发送给用户
Mail::send('emails.reset_link', compact('token'), function ($message) use ($email) {
$message->to($email)->subject("忘记密码");
});
session()->flash('success', '重置邮件发送成功,请查收');
return redirect()->back();
}
}
请仔细阅读代码中的注释。
需要注意的是,我们发送给用户的链接里的 Token 与存放在数据库里的 token 并非同一个值。这么做是为了增加安全的门槛。
接下来创建 emails.reset_link 模板:
resources/views/emails/reset_link.blade.php
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>找回密码</title>
</head>
<body>
<h1>您正在尝试找回密码</h1>
<p>
请点击以下链接进入下一步操作:
<a href="{{ route('password.reset', $token) }}">
{{ route('password.reset', $token) }}
</a>
</p>
<p>
如果这不是您本人的操作,请忽略此邮件。
</p>
</body>
</html>
测试一下#
重新访问忘记密码页面 weibo.test/password/reset ,并填入正确的用户 Email 来找回密码:
Git 代码版本控制#
接着让我们将本次更改纳入版本控制中:
$ git add -A
$ git commit -m "发送找回密码链接"
重置密码。
重温下相关的两个控制器方法:
showResetForm —— 显示更新密码的表单,隐藏 token 到表单数据 reset —— 对提交过来的 token 和 email 数据进行配对,正确的话更新密码
控制器方法#
上节我们成功提交邮箱,会发送带验证信息的链接到用户的邮箱中。
打开 Log 文件,可见类似的:
storage/logs/laravel.log
提示找不到控制器方法,接下来创建:
app/Http/Controllers/PasswordController.php
.
.
.
public function showResetForm(Request $request)
{
$token = $request->route()->parameter('token');
return view('auth.passwords.reset', compact('token'));
}
}
接下来创建 reset.blade.php 视图:
resources/views/auth/passwords/reset.blade.php
@extends('layouts.default')
@section('title', '更新密码')
@section('content')
<div class="offset-md-1 col-md-10">
<div class="card">
<div class="card-header">
<h5>更新密码</h5>
</div>
<div class="card-body">
<form method="POST" action="{{ route('password.update') }}">
@csrf
<input type="hidden" name="token" value="{{ $token }}">
<div class="form-group row">
<label for="email" class="col-md-4 col-form-label text-md-right">Email 地址</label>
<div class="col-md-6">
<input id="email" type="email" class="form-control{{ $errors->has('email') ? ' is-invalid' : '' }}" name="email" value="{{ $email ?? old('email') }}" required autofocus>
@if ($errors->has('email'))
<span class="invalid-feedback" role="alert">
<strong>{{ $errors->first('email') }}</strong>
</span>
@endif
</div>
</div>
<div class="form-group row">
<label for="password" class="col-md-4 col-form-label text-md-right">密码</label>
<div class="col-md-6">
<input id="password" type="password" class="form-control{{ $errors->has('password') ? ' is-invalid' : '' }}" name="password" required>
@if ($errors->has('password'))
<span class="invalid-feedback" role="alert">
<strong>{{ $errors->first('password') }}</strong>
</span>
@endif
</div>
</div>
<div class="form-group row">
<label for="password-confirm" class="col-md-4 col-form-label text-md-right">确认密码</label>
<div class="col-md-6">
<input id="password-confirm" type="password" class="form-control" name="password_confirmation" required>
</div>
</div>
<div class="form-group row mb-0">
<div class="col-md-6 offset-md-4">
<button type="submit" class="btn btn-primary">
重置密码
</button>
</div>
</div>
</form>
</div>
</div>
</div>
@endsection
我们在用户进行表单提交时,会将密码重置的令牌信息通过隐藏输入框一同提交给密码控制器的 getReset 进行处理。
<input type="hidden" name="token" value="{{ $token }}">
重置密码#
接下来是处理重置密码的控制器方法:
app/Http/Controllers/PasswordController.php
.
.
.
public function reset(Request $request)
{
// 1. 验证数据是否合规
$request->validate([
'token' => 'required',
'email' => 'required|email',
'password' => 'required|confirmed|min:8',
]);
$email = $request->email;
$token = $request->token;
// 找回密码链接的有效时间
$expires = 60 * 10;
// 2. 获取对应用户
$user = User::where("email", $email)->first();
// 3. 如果不存在
if (is_null($user)) {
session()->flash('danger', '邮箱未注册');
return redirect()->back()->withInput();
}
// 4. 读取重置的记录
$record = (array) DB::table('password_resets')->where('email', $email)->first();
// 5. 记录存在
if ($record) {
// 5.1. 检查是否过期
if (Carbon::parse($record['created_at'])->addSeconds($expires)->isPast()) {
session()->flash('danger', '链接已过期,请重新尝试');
return redirect()->back();
}
// 5.2. 检查是否正确
if ( ! Hash::check($token, $record['token'])) {
session()->flash('danger', '令牌错误');
return redirect()->back();
}
// 5.3. 一切正常,更新用户密码
$user->update(['password' => bcrypt($request->password)]);
// 5.4. 提示用户更新成功
session()->flash('success', '密码重置成功,请使用新密码登录');
return redirect()->route('login');
}
// 6. 记录不存在
session()->flash('danger', '未找到重置记录');
return redirect()->back();
}
}
至此,整个用户密码重设功能便完成了。
Git 代码版本控制#
接着让我们将本次更改纳入版本控制中:
$ git add -A
$ git commit -m "重置密码"
限流是在一定时间内,限制用户对应用某个链接进行访问的次数。
一般来讲,服务器限流会出于以下两个目的:
安全 —— 例如登录表单限制访问次数以此来防止暴力破解用户密码;资源控制 —— 例如找回密码页面,会有类似数据库查询、发送邮件等比较消耗资源的操作,限流可以对资源浪费进行有效控制。
本节中,我们将对以下路由进行限流,对应规则如下:
注册 —— 一个小时内只能提交 10 次请求;登录 —— 10 分钟内只能尝试 10 次发送密码重置邮件 —— 10 分钟内只能尝试 3 次
限流功能#
Laravel 中内置了限流的中间件,见:
app/Http/Kernel.php
.
.
.
/**
* The application's route middleware.
*
* These middleware may be assigned to groups or used individually.
*
* @var array
*/
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
];
}
以上 throttle 就是限流的中间件。
我们先来测试如何使用,打开忘记密码的控制器:
app/Http/Controllers/PasswordController.php
.
.
.
class PasswordController extends Controller
{
public function __construct()
{
$this->middleware('throttle:2,1', [
'only' => ['showLinkRequestForm']
]);
}
.
.
.
以上针对控制器方法 showLinkRequestForm() 做了限流,一分钟内只能允许访问两次。
打开对应的链接 weibo.test/password/reset ,连续刷新三次(超过两次)
就是超过限流后用户会看见的页面。
限流密码重置邮件#
发送密码重置邮件,限流规则为 —— 10 分钟内只能尝试 3 次:
app/Http/Controllers/PasswordController.php
.
.
.
class PasswordController extends Controller
{
public function __construct()
{
$this->middleware('throttle:3,10', [
'only' => ['sendResetLinkEmail']
]);
}
.
.
.
登录限流#
规则是 10 分钟内只能尝试 10 次:
规则是 10 分钟内只能尝试 10 次:
.
.
.
class SessionsController extends Controller
{
public function __construct()
{
$this->middleware('guest', [
'only' => ['create']
]);
// 限流 10 分钟十次
$this->middleware('throttle:10,10', [
'only' => ['store']
]);
}
.
.
.
注册限流#
规则是一个小时内只能提交 10 次请求:
app/Http/Controllers/UsersController.php
.
.
.
class UsersController extends Controller
{
public function __construct()
{
.
.
.
// 限流 一个小时内只能提交 10 次请求;
$this->middleware('throttle:10,60', [
'only' => ['store']
]);
}
.
.
.
代码版本#
开始下一节之前,我们先来为代码做下版本标记:
$ git add .
$ git commit -m "访问限流"
暂时未提供生产环境下的邮件发送