Đã có Magic Logic Links! Giết Password được rồi!

Bước xác minh, trong nhiều năm qua, đã có những bước tiến mạnh mẽ. Chúng ta đã chứng kiến sự thay đổi từ tổ hợp email-password sang xác minh mạng xã hội, và cuối cùng là xác minh lược bỏ password (mà thực ra lại giống kiểu xác minh “chỉ email” hơn). Trong trường hợp login lược bỏ password, ứng dụng sẽ giả định bạn nhận login link từ inbox nếu email được cung cấp đúng là của bạn.

Đã có Magic Logic Links Giết Password được rồi

Quy trình thường thấy của một hệ thống login không password diễn ra như sau:

  • Người dùng truy cập vào login page
  • Nhập địa chỉ email và xác nhận
  • Một đường link được gửi đến email
  • Khi click vào link, họ được chuyển hướng trở lại ứng dụng và đăng nhập
  • Đường link bị vô hiệu hóa

Đây là một cách xác minh tiện lợi nếu bạn không tài nào nhớ được password cho ứng dụng đó, nhưng bạn lại nhớ email khai báo lúc đăng ký. Một điểm thú vị là thận chí cả Slack cũng dùng đến kỹ thuật này.

Trong bài viết này, chúng ta sẽ tìm cách tích hợp hệ thống trên vào ứng dụng Laravel. Đoạn code hoàn chỉnh có thể được tìm thấy ở đây.

Tạo ứng dụng

Hãy bắt đầu bằng cách tạo ứng dụng Laravel mới. Tôi sẽ sử dụng Laravel 5.2 trong bài viết này:

composer createproject laravel/laravel passwordlesslaravel 5.2.*

Nếu bạn đã có sẵn một Laravel project với users và passwords, đừng lo lắng – chúng ta sẽ không động đến lớp xác minh đã có, mà tạo thêm một lớp mới chồng lên. User vẫn sẽ có tùy chọn đăng nhập thông qua password.

Database Setup

Kế tiếp, chúng ta sẽ phải set up MySQL database trước khi chạy bất cứ migration nào.

Mở file .env trong thư mục root và nhập vào hostname, username, và database name:

DB_CONNECTION=mysql
DB_HOST=localhost
DB_DATABASE=passwordlessapp
DB_USERNAME=username
DB_PASSWORD=
[...]

Nếu bạn đang dùng Homestead Improved box, tổ hợp database/username/password sẽ là homestead, homestead, secret.

Scaffolding Auth

Laravel 5.2 có một tính năng cực kỳ hay, đó là khả năng thêm lớp xác minh đã tạo sẵn với một dòng lệnh duy nhất. Hãy làm thử nhé

php artisan make:auth

Lệnh này sẽ giúp ta lên khung cho mọi thứ cần có ở bước xác minh như Views, Controllers, và Routes.

Migrations

Nếu nhìn vào database/migration, ta sẽ nhận thấy rằng ứng dụng Laravel được tạo đi kèm với migration để tạo user table và password_resets table.

Chúng ta sẽ không thay đổi gì cả vì ta vẫn muốn ứng dụng có bước xác minh thông thường.

Để tạo table, chạy:

php artisan migrate

Giờ đây ta đã có thể đưa ứng dụng vào hoạt động, và có thể đăng ký/đăng nhập bằng link trong nav.

Kế tiếp, chúng ta muốn thay đổi đường link login để chuyển hướng người dùng sang custom login view, tại đây người dùng sẽ cung cấp địa chỉ email mà không phải nhập password.

Đi đến resources/views/layouts/app.blade.php. Taị đây chúng ta sẽ tìm nav partial. Thay đổi dòng có login link (ngay dưới phần điều khiện để kiểm tra xem người dùng đã log out chưa) với:

@if (Auth::guest())
<li><a href=“{{ url(‘/login/magiclink’) }}”>Login</a></li>
<li><a href=“{{ url(‘/register’) }}”>Register</a></li>
[...]

Khi người dùng cố gắng truy cập một route được bảo vệ mà chưa login, họ cần được đưa đến custom login view mới thay vì view thông thường. Hành vi này được xác định trong authenticate middleware. Nên chúng ta sẽ thay đổi chính chỗ đó:

app/Http/Middleware/Authenticate.php

{
[...]
public function handle($request, Closure $next, $guard = null)
{
    if (Auth::guard($guard)->guest()) {
        if ($request->ajax() || $request->wantsJson()) {
            return response(‘Unauthorized.’, 401);
        } else {
            return redirect()->guest(‘login/magiclink’);
        }
    }
    return $next($request);
}
[...]

Hãy để ý, trong else block chúng ta đã chuyển địa điểm chuyển hướng đến login/magiclink thay vì login thông thường.

Tạo Magic Login Controller, View, và Routes

Bước tiếp theo, chúng ta sẽ tạo MagicLoginController bên trong thư mục Auth:

php artisan make:controller Auth\\MagicLoginController

Rồi sau đó thực hiện định tuyết để hiển thị custom login page của chúng ta:

app/Http/routes.php

[…]
Route::get(‘/login/magiclink’, ‘Auth\MagicLoginController@show’);

Hãy cập nhật MagicLoginController để kèm theo show action:

app/Http/Controllers/Auth/MagicLoginController.php

class MagicLoginController extends Controller
{
[…]
public function show()
{
return view(‘auth.magic.login’);
}
[…]
}

Để có login view, chúng ta sẽ mượn tạm login view thường và bỏ trường password đi. Chúng ta cũng sẽ thay đổi post URL của form đến \login\magiclink.

Hãy tạo một thư mục magic trong views/auth để giữ view mới này:

<span class=”token function”>mkdir</span> resources/views/auth/magic
<span class=”token function”>touch</span> resources/views/auth/magic/login.blade.php

Và cập nhật view vừa tạo thành:

resources/views/auth/magic/login.blade.php

@extends(‘layouts.app’)

@section(‘content’)
<div class=”container”>
<div class=”row”>
<div class=”col-md-8 col-md-offset-2″>
<div class=”panel panel-default”>
<div class=”panel-heading”>Login</div>
<div class=”panel-body”>
<form class=”form-horizontal” role=”form” method=”POST” action=”{{ url(‘/login/magiclink’) }}”>
{{ csrf_field() }}

<div class=”form-group{{ $errors->has(’email’) ? ‘ has-error’ : ” }}”>
<label for=”email” class=”col-md-4 control-label”>E-Mail Address</label>

<div class=”col-md-6″>
<input id=”email” type=”email” class=”form-control” name=”email” value=”{{ old(’email’) }}” required autofocus>

@if ($errors->has(’email’))
<span class=”help-block”>
<strong>{{ $errors->first(’email’) }}</strong>
</span>
@endif
</div>
</div>

<div class=”form-group”>
<div class=”col-md-6 col-md-offset-4″>
<div class=”checkbox”>
<label>
<input type=”checkbox” name=”remember”> Remember Me
</label>
</div>
</div>
</div>

<div class=”form-group”>
<div class=”col-md-8 col-md-offset-4″>
<button type=”submit” class=”btn btn-primary”>
Send magic link
</button>

<a href=”{{ url(‘/login’) }}” class=”btn btn-link”>Login with password instead</a>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@endsection

Chúng ta sẽ vẫn để lại tùy chọn login với password vì người dùng vẫn có thể thích dùng password login hơn. Vậy nếu người dùng click login từ nav, họ sẽ được đưa đến login view trông như sau:

Đã có Magic Logic Links Giết Password được rồi

Tạo Tokens và liên kết chúng với

Bước tiếp theo của chúng ta là tạo token và liên kết chúng với người dùng. Điều này xảy ra khi ai đó nhập email để đăng nhập.

Hãy bắt đầu với bước tạo route để xử lý posting action của form đăng nhập.

app/Http/routes.php

[…]
Route::post(‘/login/magiclink’, ‘Auth\MagicLoginController@sendToken’);

Sau đó, chúng ta sẽ thêm controller method tên sendToken trong MagicLoginController. Method này sẽ chứng thực địa chỉ email, liên kết token với user, gửi đi email đăng nhập và thông báo người dùng check email của mình:

app/Http/Controllers/Auth/MagicLoginController.php

class MagicLoginController extends Controller
{
[…]
/**
* Validate that the email has a valid format and exists in the users table
* in the email column
*/
public function sendToken(Request $request)
{
$this->validate($request, [
’email’ => ‘required|email|max:255|exists:users,email’
]);
//will add methods to send off a login email and a flash message later
}
[…]
}

Sau khi đã có địa chỉ email đạt chuẩn, chúng ta có thể gửi email đăng nhập đến người dùng. Nhưng trước khi email được gửi đi, chúng ta phải tạo trước token cho người dùng đang tìm cách đăng nhập này. Tôi không muốn phải để tất cả method trong MagicLoginController nên ta sẽ tạo model user-token dể xử lý chỉ một phần các method này.

php artisan make:model UserToken -m

Lệnh này sẽ giúp chúng ta có cả model và migration. Chúng ta vẫn phải tinh chỉnh migration một chút và thêm user_id và cột token. Mở file migration vừa tạo và thay đổi method up thành thế này:

database/migrations/{timestamp}_create_user_tokens_table.php

[…]
public function up()
{
Schema::create(‘user_tokens’, function (Blueprint $table) {
$table->increments(‘id’);
$table->integer(‘user_id’);
$table->string(‘token’);
$table->timestamps();
});
}
[…]

Sau đó chạy lệnh migrate Artisan:

php artisan migrate

Trong model UserToken, chúng ta cần thêm user_id và token dưới dạng thông số gán được (assignable attributes). Chúng ta cũng nên xác định mối quan hệ giữa model này với model User và ngược lại:

App/UserToken.php

[…]
class UserToken extends Model
{
protected $fillable = [‘user_id’, ‘token’];

/**
* A token belongs to a registered user.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function user()
{
return $this->belongsTo(User::class);
}
}

Sau đó trong App/User.php xác định rằng một User chỉ có thể có một token liên kết với chúng:

App/User.php

class User extends Model
{
[…]
/**
* A user has only one token.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function token()
{
return $this->hasOne(UserToken::class);
}
}

Đến đây, hãy tạo token. Trước hết, chúng ta cần phải lấy user object thông qua email trước khi tạo token. Tạo method trong User model với tên getUserByEmail để xử lý tính năng này:

App/User.php

class User extends Model
{
[…]
protected static function getUserByEmail($value)
{
$user = self::where(’email’, $value)->first();
return $user;
}
[…]
}

Chúng ta phải thêm namespace cho các class User và UserToken vào MagicLoginController của chúng ta để có thể call các method trong những class này từ controller.

app/Http/Controllers/Auth/MagicLoginController.php

[…]
use App\User;
use App\UserToken;
[…]
class MagicLoginController extends Controller
{
[…]
public function sendToken(Request $request)
{
//after validation
[…]
$user = User::getUserByEmail($request->get(’email’));

if (!user) {
return redirect(‘/login/magiclink’)->with(‘error’, ‘User not foud. PLease sign up’);
}

UserToken::create([
‘user_id’ => $user->id,
‘token’   => str_random(50)
]);
}
[…]
}

Trong khối code trên, chúng ta đang truy xuất user object dựa trên email được cung cấp. Trước khi đến bước này, các bạn cần nhớ là ta phải chứng thực sự tồn tại của địa chỉ email được cung cấp trong user table. Nhưng nếu có ai đó vượt qua được bước chứng thực và cung cấp email chưa có trong kho lưu trữ của chúng ta, ta sẽ báo họ phải đăng ký trước.

Khi đã có user object, ta sẽ tạo token cho họ.

Gửi Token qua email

Chúng ta đến đây đã có thể email Token vừa tạo đến người dùng dưới dạng URL. Trước hết, chúng ta sẽ phải yêu cầu Facade Mail trong model để hỗ trợ chức năng gửi email.

Tuy nhiên, trong phạm vi bài viết, chúng ta sẽ không gửi bất cứ email thật nào cả, mà chỉ để xác nhận là ứng dụng có thể gửi được email trong logs thôi. Để thực hiện, hãy tìm đến file .env của bạn, dưới phần mail chỉnh thành MAIL_DRIVER=log. Hơn nữa, chúng ta sẽ không tạo email view; chỉ tạo một email thuần từ class UserToken.

Hãy tạo thêm một method nữa trong model UserToken mang tên sendEmail để xử lý chức năng này. URL tổ hợp của token, email address và giá trị remember me sẽ được tạo trong method này.

app/UserToken.php

[…]
use Illuminate\Support\Facades\Mail;
[…]
class UserToken extends Model
{
[…]
public static function sendMail($request)
{
//grab user by the submitted email
$user = User::getUserByEmail($request->get(’email’));

if(!$user) {
return redirect(‘/login/magiclink’)->with(‘error’, ‘User not foud. PLease sign up’);
}

$url = url(‘/login/magiclink/’ . $user->token->token . ‘?’ . http_build_query([
‘remember’ => $request->get(‘remember’),
’email’ => $request->get(’email’),
]));

Mail::raw(
“<a href='{$url}’>{$url}</a>”,
function ($message) use ($user) {
$message->to($user->email)
->subject(‘Click the magic link to login’);
}
);
}
[…]
}

Khi tạo URL, chúng ta sẽ dùng hàm http_build_query của PHP để giúp một query từ array options được pass. Trong trường hợp của chúng ta là email, và giá trị remember me.

Đã đến lúc cập nhật MagicLoginController và call method sendEmail:

app/Http/Controllers/Auth/MagicLoginController.php

class MagicLoginController extends Controller
{
[…]
public function sendToken(Request $request)
{
$this->validate($request, [
’email’ => ‘required|email|max:255|exists:users,email’
]);
UserToken::storeToken($request);

UserToken::sendMail($request);

return back()->with(‘success’, ‘We\’ve sent you a magic link! The link expires in 5 minutes’);
}
[…]
}

Chúng ta cũng sẽ tích hợp một số trình tin nhắn đơn giản để gửi noti. Trong resources/views/layouts/app.blade.php, hãy thêm đoạn code này vào hay trên content của bạn vì flash message sẽ hiện trên bất kỳ nội dung nào khác:

resources/views/layouts/app.blade.php

[…]
<div class=”container”>
<div class=”row”>
<div class=”col-md-8 col-md-offset-2″>
@include (‘layouts.partials._notifications’)
</div>
</div>
</div>
@yield(‘content’)
[…]

Sau đó tạo notifications partial:

resources/views/layouts/partials/_notifications.blade.php

@if (session(‘success’))
<div class=”alert alert-success”>
{{ session(‘success’) }}
</div>
@endif

@if (session(‘error’))
<div class=”alert alert-danger”>
{{ session(‘error’) }}
</div>
@endif

Sau đó tạo notifications partial:

resources/views/layouts/partials/_notifications.blade.php

@if (session(‘success’))
<div class=”alert alert-success”>
{{ session(‘success’) }}
</div>
@endif

@if (session(‘error’))
<div class=”alert alert-danger”>
{{ session(‘error’) }}
</div>
@endif

Trong partial, chúng ta đã dùng helper session để giúp chúng với những màu noti khác nhay dựa trên các tình trạng session như success hoặc error.

Tại thời điểm này, chúng ta đã có thể gửi email. Bạn có thể thử trước bằng cách đăng nhập với một địa chỉ email hợp lệ, sau đó tìm đến file laravel.log. Chúng ta sẽ có thể thấy email có chứa URL ở cuối file log.

Kế tiếp, chúng ta muốn chứng thực token là đăng nhập người dùng. Chúng ta cũng không muốn những trường hợp mà token đã được gửi 3 ngày trước vẫn dùng được để đăng nhập.

Chứng thực Token Validation và Authentication

Giờ thì chúng ta đã có URL, hãy tạo route và controller action để xử lý chuỗi sự kiện xảy ra khi người dùng click vào URL từ email:

app/Http/routes.php

[…]
Route::get(‘/login/magiclink/{token}’, ‘Auth\MagicLoginController@auth

Hãy tạo action authenticate trong MagicLoginController. Chúng ta sẽ xác minh người dùng ngay trong method này. Chúng ta sẽ kéo token vào method authenticate thông qua Route Model Binding. Chúng ta sau đó sẽ grab người dùng từ token. Chú ý rằng chúng ta phải triệu tập Auth facade trong controller để có thể dùng method Auth:

app/Http/Controllers/Auth/MagicLoginController.php

[…]
use Auth;
[…]
class MagicLoginController extends Controller
{
[…]
public function authenticate(Request $request, UserToken $token)
{
Auth::login($token->user, $request->remember);
$token->delete();
return redirect(‘home’);
}
[…]
}

Sau đó, trong UserToken class, đặt route key name chúng ta muốn. Trong trường hợp này, chính là token:

App/UserToken.php

[…]
public function getRouteKeyName()
{
return ‘token’;
}
[…]

Và xong rồi. Người dùng có thể đăng nhập được. Lưu ý, sau khi đăng nhập người dùng, chúng ta phải xóa token vì ta không muốn quá tải table user_tokens với các token đã qua sử dụng.

Bước tiếp theo là kiểm tra độ xác thực của token. Với ứng dụng này, chúng ta sẽ thiết đặt magic link hết hạn sau 5 phút. Ta sẽ cần đến thư viện Carbon để theo dõi thời gian giữa lúc tạo token và thời gian hiện tại.

Trong model UserToken, chúng ta sẽ tạo hai method: isExpired và belongstoEmail để kiểm tra độ xác thực của token. Chú ý, bước chứng thực belongsToEmail chỉ là bước dự phòng để đảm bảo token thực sự thuộc về địa chỉ email đó:

App/UserToken.php

[…]
use Carbon\Carbon;
[…]
class UserToken extends Model
{
[…]
//Make sure that 5 minutes have not elapsed since the token was created
public function isExpired()
{
return $this->created_at->diffInMinutes(Carbon::now()) > 5;
}

//Make sure the token indeed belongs to the user with that email address
public function belongsToUser($email)
{
$user = User::getUserByEmail($email);

if(!$user || $user->token == null) {
//if no record was found or record found does not have a token
return false;
}

//if record found has a token that matches what was sent in the email
return ($this->token === $user->token->token);
}
[…]
}

Hãy call các method trên token instance trong method authenticate trong MagicLoginController:

app/Http/Controllers/Auth/MagicLoginController.php

class MagicLoginController extends Controller
{
[…]
public function authenticate(Request $request, UserToken $token)
{
if ($token->isExpired()) {
$token->delete();
return redirect(‘/login/magiclink’)->with(‘error’, ‘That magic link has expired.’);
}

if (!$token->belongsToUser($request->email)) {
$token->delete();
return redirect(‘/login/magiclink’)->with(‘error’, ‘Invalid magic link.’);
}

Auth::login($token->user, $request->get(‘remember’));
$token->delete();
return redirect(‘home’);

}
[…]
}

Lời kết

Như vậy, chúng ta đã thành công với lớp đăng nhập không password bên cạnh cách xác minh truyền thống. Nhiều người cho rằng cách này sẽ mất nhiều thời gian hơn cách đăng nhập bằng password thông thường, nhưng dùng password manager liệu có nhanh hơn chăng?

Tuy vậy, hệ thống không-password không phải ở đâu cũng hoạt động được, nếu bạn có session timeout periods ngắn hoặc đòi hỏi người dùng phải đăng nhập thường xuyên, cách này tỏ ra khá dùng dằng. Cũng thật may, có rất ít trang đi theo hướng này.

sitepoint