Laravel 認證與授權:用 Starter Kit 十分鐘搞定會員系統
本篇是「PHP/Laravel 完全指南」系列的第 6 / 15 篇。你可以從系列總覽開始閱讀,也可以直接接著看本文。
每一個有使用者的應用程式,都逃不過這兩件事:認證(Authentication)和授權(Authorization)。認證是「你是誰」,授權是「你能做什麼」。聽起來簡單,但自己從零刻一套會員系統——處理密碼雜湊、Session 管理、忘記密碼信件、Email 驗證、CSRF 防護——光想就頭痛。更何況,這些東西稍有閃失就是資安漏洞。
好消息是,Laravel 根本不讓你自己造這個輪子。Laravel 12 推出了全新的官方 Starter Kit,取代了舊版的 Breeze 和 Jetstream。一行指令就幫你搞定註冊、登入、密碼重設、Email 驗證的完整流程,連前端 UI 都幫你生好了。你可以選擇 React、Vue、Livewire(推出時的三種官方選項,2026 年 2 月再補上 Svelte)作為前端堆疊——我們選 Livewire,因為整本書都在 PHP 生態系裡。
在「揪好買」團購平台裡,我們有兩種角色:開團主和跟團者。開團主可以建立團購、設定截止時間、管理訂單;跟團者只能瀏覽和加入。這一章,我們要用 Starter Kit 搞定會員系統,再用 Policy 把「只有開團主能建立團購」這條規則寫得乾淨俐落。
認證 vs 授權:先搞清楚這兩件事
這兩個詞經常被混用,但它們是完全不同的概念:
| 認證(Authentication) | 授權(Authorization) | |
|---|---|---|
| 問題 | 你是誰? | 你能做什麼? |
| 時機 | 登入時 | 每次操作時 |
| 失敗結果 | 401 Unauthorized | 403 Forbidden |
| Laravel 工具 | Guard、Session、Token | Gate、Policy |
| 類比 | 門口刷員工證 | 你的員工證能進哪些房間 |
認證通常只需要做一次(用套件),授權則貫穿整個應用程式(用你的業務邏輯)。這一章前半講認證,後半講授權。
跨框架對照
| 概念 | Laravel | Express (Node.js) | Django (Python) |
|---|---|---|---|
| 認證套件 | Starter Kit | Passport.js | django.contrib.auth |
| Session | 內建 | express-session | 內建 |
| 密碼雜湊 | Hash::make() | bcrypt | make_password() |
| 授權 | Gate / Policy | 自己寫 middleware | Permissions / Decorators |
| Token 認證 | Sanctum | jsonwebtoken | DRF TokenAuth |
Laravel 12 Starter Kit:十分鐘擁有完整會員系統
什麼是 Starter Kit?
Laravel 12 的 Starter Kit 是官方維護的應用程式啟動模板。它直接把認證相關的 Controller、View、Route 全部放進你的專案裡——不是 Composer 套件,而是真正的程式碼。你可以看到每一行、改每一處。
歷史脈絡: Laravel 11 以前用的是 Breeze(輕量)和 Jetstream(重量級,含 Team 管理)。Laravel 12 把它們統一成了新的 Starter Kit 系列,推出時提供 React、Vue、Livewire 三種前端選項,2026 年 2 月再加入官方 Svelte + Inertia kit,目前共四種。如果你看到舊教學提到 Breeze,概念是一樣的,只是安裝方式不同。
安裝 Livewire Starter Kit
如果你在第二章建專案時選了 “No starter kit”,現在可以重新建一個:
laravel new jiu-hao-mai
在互動式選單中選擇:
┌ Would you like to install a starter kit? ──────┐
│ › Livewire │
└──────────────────────────────────────────────────┘
或者,如果你想在現有專案上操作,最簡單的做法是用 laravel new 建一個新專案,再把認證相關的檔案複製過來。
安裝完你得到了什麼?
Starter Kit 幫你生成的東西:
app/
└── Livewire/Auth/ # 認證邏輯放在 Livewire 元件,而非傳統 Controller
├── Login.php
├── Register.php
├── ResetPassword.php
└── VerifyEmail.php
resources/views/
├── livewire/auth/ # 認證頁面模板
│ ├── login.blade.php
│ ├── register.blade.php
│ └── ...
├── components/layouts/
│ └── app.blade.php # 應用程式 Layout
routes/
└── auth.php # 認證路由(Volt::route 風格)
注意: Laravel 12 Livewire Starter Kit 不會產生傳統的
app/Http/Controllers/Auth/目錄。認證邏輯改用 Livewire 元件(app/Livewire/Auth/)處理,不再是 LoginController、RegisterController 等傳統 Controller。
所有程式碼都在你的專案裡——不是躲在 vendor/ 裡的黑盒子。
註冊、登入、忘記密碼:開箱即用
安裝完 Starter Kit 後,這些路由就自動可用了:
| 路由 | 功能 |
|---|---|
GET /register | 註冊頁面 |
POST /register | 處理註冊 |
GET /login | 登入頁面 |
POST /login | 處理登入 |
POST /logout | 登出 |
GET /forgot-password | 忘記密碼頁面 |
POST /forgot-password | 寄送重設密碼信 |
GET /reset-password/{token} | 重設密碼頁面 |
POST /reset-password | 處理密碼重設 |
php artisan serve
# 打開 http://localhost:8000/register
你會看到一個設計好的註冊頁面。填入資料、送出,帳號就建好了。登入、登出、忘記密碼——全部能用。
在 Controller 裡取得當前使用者
use Illuminate\Http\Request;
class DashboardController extends Controller
{
public function index(Request $request)
{
// 方法一:從 Request 物件取
$user = $request->user();
// 方法二:用 Auth Facade
$user = auth()->user();
// 方法三:取得使用者 ID
$userId = auth()->id();
// 檢查是否已登入
if (auth()->check()) {
// 已登入
}
return view('dashboard', compact('user'));
}
}
保護路由(要求登入才能存取)
// routes/web.php
// 方法一:單一路由
Route::get('/dashboard', [DashboardController::class, 'index'])
->middleware('auth');
// 方法二:路由群組(常用)
Route::middleware('auth')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']);
Route::get('/my-groups', [GroupBuyController::class, 'myGroups']);
Route::post('/group-buys', [GroupBuyController::class, 'store']);
});
// 方法三:只給「未登入」使用者(如登入頁)
Route::get('/login', [LoginController::class, 'show'])
->middleware('guest');
auth middleware 的邏輯很簡單:使用者沒登入 → 重新導向到 /login。就這樣。
密碼雜湊
Laravel 自動用 bcrypt 雜湊密碼,你永遠不會在資料庫裡看到明文密碼:
use Illuminate\Support\Facades\Hash;
// 建立雜湊(註冊時用)
$hashed = Hash::make('my-password');
// $2y$12$eUxcJq1...(60 字元雜湊字串)
// 驗證密碼(登入時用)
if (Hash::check('my-password', $hashed)) {
// 密碼正確
}
重要: 永遠不要自己寫登入驗證邏輯。Starter Kit 已經幫你處理好了密碼雜湊、防暴力破解(rate limiting)、CSRF 防護等所有安全細節。
Email 驗證:確保使用者是真人
Email 驗證是「註冊後寄一封確認信,使用者點連結才算驗證完成」的功能。
啟用 Email 驗證
讓 User Model 實作 MustVerifyEmail 介面:
// app/Models/User.php
use Illuminate\Contracts\Auth\MustVerifyEmail;
class User extends Authenticatable implements MustVerifyEmail
{
// ...
}
就這一行,Laravel 會自動:
- 在註冊後寄出驗證信
- 提供
/verify-email頁面 - 驗證連結有簽名保護(防偽造)
限制未驗證使用者
Route::middleware(['auth', 'verified'])->group(function () {
// 這裡的路由只有驗證過 email 的使用者才能進
Route::post('/group-buys', [GroupBuyController::class, 'store']);
});
開發環境的 Email
開發階段不用真的寄信。.env 預設用 log driver:
MAIL_MAILER=log
所有「寄出」的信件都會記錄在 storage/logs/laravel.log 裡,你可以從 log 裡找到驗證連結。
Gate 與 Policy:誰可以做什麼事
認證搞定了(你是誰),現在來處理授權(你能做什麼)。
Gate:簡單的授權檢查
Gate 是最基本的授權方式——定義一個 closure,回傳 true 或 false:
// app/Providers/AppServiceProvider.php 的 boot() 裡
use Illuminate\Support\Facades\Gate;
use App\Models\GroupBuy;
use App\Models\User;
public function boot(): void
{
Gate::define('create-group-buy', function (User $user) {
return $user->is_organizer;
});
Gate::define('update-group-buy', function (User $user, GroupBuy $groupBuy) {
return $user->id === $groupBuy->user_id;
});
}
使用方式:
// 在 Controller 裡
if (Gate::allows('create-group-buy')) {
// 可以建立團購
}
if (Gate::denies('update-group-buy', $groupBuy)) {
abort(403);
}
// 更簡潔:authorize(失敗自動丟 403)
Gate::authorize('create-group-buy');
// 在 Blade 裡
@can('create-group-buy')
<a href="/group-buys/create">+ 我要開團</a>
@endcan
@cannot('update-group-buy', $groupBuy)
<p>你沒有權限編輯這個團購</p>
@endcannot
Policy:更有組織的授權
Gate 適合一兩條簡單規則。當授權邏輯變多,Policy 把同一個 Model 的授權規則集中在一個 class 裡:
php artisan make:policy GroupBuyPolicy --model=GroupBuy
<?php
namespace App\Policies;
use App\Models\GroupBuy;
use App\Models\User;
class GroupBuyPolicy
{
/**
* 誰可以看列表?所有人。
*/
public function viewAny(?User $user): bool
{
return true; // ?User 表示未登入也可以
}
/**
* 誰可以看單一團購?所有人。
*/
public function view(?User $user, GroupBuy $groupBuy): bool
{
return true;
}
/**
* 誰可以建立團購?已驗證 email 的使用者。
*/
public function create(User $user): bool
{
return $user->hasVerifiedEmail();
}
/**
* 誰可以更新團購?只有開團者本人。
*/
public function update(User $user, GroupBuy $groupBuy): bool
{
return $user->id === $groupBuy->user_id;
}
/**
* 誰可以刪除團購?
* 只有開團者本人,而且團購還沒有人加入。
*/
public function delete(User $user, GroupBuy $groupBuy): bool
{
return $user->id === $groupBuy->user_id
&& $groupBuy->participants()->count() === 0;
}
}
在 Controller 裡使用 Policy
class GroupBuyController extends Controller
{
public function index()
{
// viewAny 不需要特定 model 實例
$this->authorize('viewAny', GroupBuy::class);
return view('group-buys.index');
}
public function create()
{
$this->authorize('create', GroupBuy::class);
return view('group-buys.create');
}
public function store(Request $request)
{
$this->authorize('create', GroupBuy::class);
// 建立團購...
}
public function edit(GroupBuy $groupBuy)
{
$this->authorize('update', $groupBuy);
return view('group-buys.edit', compact('groupBuy'));
}
public function destroy(GroupBuy $groupBuy)
{
$this->authorize('delete', $groupBuy);
$groupBuy->delete();
return redirect('/group-buys')->with('success', '團購已刪除');
}
}
$this->authorize() 會自動找到 GroupBuyPolicy(靠命名慣例),檢查對應的方法。失敗就丟 403 Forbidden。
Policy 的自動發現: Laravel 會自動把
GroupBuyModel 對應到GroupBuyPolicy。你不需要手動註冊——命名慣例搞定一切。
在 Blade 裡使用 Policy
@can('create', App\Models\GroupBuy::class)
<a href="/group-buys/create" class="btn-primary">+ 我要開團</a>
@endcan
@can('update', $groupBuy)
<a href="/group-buys/{{ $groupBuy->id }}/edit">編輯</a>
@endcan
@can('delete', $groupBuy)
<form method="POST" action="/group-buys/{{ $groupBuy->id }}">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600">刪除</button>
</form>
@endcan
Role-based 權限設計模式
揪好買需要區分「開團主」和「跟團者」。最簡單的做法是在 users 表加一個 role 欄位:
方案一:簡單的 role 欄位
php artisan make:migration add_role_to_users_table
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('role')->default('member'); // member, organizer, admin
});
}
在 User Model 加上 helper 方法:
// app/Models/User.php
// 用 Enum 定義角色(型別安全)
enum UserRole: string
{
case Member = 'member';
case Organizer = 'organizer';
case Admin = 'admin';
}
class User extends Authenticatable implements MustVerifyEmail
{
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'role' => UserRole::class,
];
}
public function isOrganizer(): bool
{
return $this->role === UserRole::Organizer
|| $this->role === UserRole::Admin;
}
public function isAdmin(): bool
{
return $this->role === UserRole::Admin;
}
}
然後在 Policy 裡使用:
public function create(User $user): bool
{
return $user->isOrganizer() && $user->hasVerifiedEmail();
}
方案二:Spatie Permission 套件(多角色/多權限)
如果需要更複雜的權限系統(一個使用者可以有多個角色、角色可以有多個權限),推薦用 spatie/laravel-permission:
composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate
// 指派角色
$user->assignRole('organizer');
// 檢查角色
$user->hasRole('organizer');
// 指派權限
$user->givePermissionTo('create group buys');
// 在 Policy 裡用
public function create(User $user): bool
{
return $user->can('create group buys');
}
揪好買用哪個? 我們的需求很簡單(member / organizer / admin 三種角色),方案一的
role欄位就夠了。除非你的專案有「一個使用者同時是多個角色」或「權限需要動態新增/移除」的需求,才需要 Spatie Permission。YAGNI——You Ain’t Gonna Need It。
Guard:多重認證系統
Guard 決定的是「用什麼方式認證使用者」。預設的 web guard 用 Session + Cookie,api guard 用 Token。
大多數應用只用一個 Guard,你不需要動它。只有在這些情況下才需要自訂 Guard:
- 前後台分離——Admin 和一般使用者用不同的 users 表
- API 認證——用 Sanctum token 而不是 Session
- 多租戶——不同租戶有不同的認證方式
// config/auth.php(通常不需要改)
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
// 安裝 Sanctum 後會多一個 sanctum guard
],
揪好買目前只需要 web guard,第十一章做 API 時才會用到 Sanctum。
實作:揪好買的開團主與跟團者
讓我們把認證和授權整合進揪好買。
Step 1:加入 role 欄位
php artisan make:migration add_role_to_users_table
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('role')->default('member');
});
}
php artisan migrate
Step 2:更新 User Model
在 app/Models/User.php 加入:
use App\Enums\UserRole;
// 在 $fillable 加入 'role'
protected $fillable = [
'name', 'email', 'password', 'role',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'role' => UserRole::class,
];
}
public function isOrganizer(): bool
{
return $this->role === UserRole::Organizer
|| $this->role === UserRole::Admin;
}
public function isAdmin(): bool
{
return $this->role === UserRole::Admin;
}
建立 Enum app/Enums/UserRole.php:
<?php
namespace App\Enums;
enum UserRole: string
{
case Member = 'member';
case Organizer = 'organizer';
case Admin = 'admin';
public function label(): string
{
return match ($this) {
self::Member => '一般會員',
self::Organizer => '開團主',
self::Admin => '管理員',
};
}
}
Step 3:建立 GroupBuyPolicy
php artisan make:policy GroupBuyPolicy --model=GroupBuy
把前面的 Policy 程式碼放進去(viewAny、view、create、update、delete)。
Step 4:保護路由
// routes/web.php
use App\Http\Controllers\GroupBuyController;
// 任何人都能看
Route::get('/group-buys', [GroupBuyController::class, 'index']);
Route::get('/group-buys/{groupBuy}', [GroupBuyController::class, 'show']);
// 需要登入 + Email 驗證
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/group-buys/create', [GroupBuyController::class, 'create']);
Route::post('/group-buys', [GroupBuyController::class, 'store']);
Route::get('/group-buys/{groupBuy}/edit', [GroupBuyController::class, 'edit']);
Route::put('/group-buys/{groupBuy}', [GroupBuyController::class, 'update']);
Route::delete('/group-buys/{groupBuy}', [GroupBuyController::class, 'destroy']);
});
Step 5:在 View 裡顯示角色相關 UI
{{-- 導覽列 --}}
<nav>
@auth
<span>{{ auth()->user()->name }}({{ auth()->user()->role->label() }})</span>
@can('create', App\Models\GroupBuy::class)
<a href="/group-buys/create">+ 我要開團</a>
@endcan
@endauth
</nav>
{{-- 團購詳情頁 --}}
@can('update', $groupBuy)
<a href="/group-buys/{{ $groupBuy->id }}/edit" class="btn">編輯團購</a>
@endcan
@can('delete', $groupBuy)
<form method="POST" action="/group-buys/{{ $groupBuy->id }}">
@csrf
@method('DELETE')
<button onclick="return confirm('確定要刪除?')" class="text-red-600">刪除</button>
</form>
@endcan
講真的,這一步最容易讓新手誤會:
@can只是把按鈕藏起來,它不是防線。 編輯按鈕看不到,不代表那個 endpoint 被擋住了。任何人打開 DevTools、或直接用 curl/Postman 對著PUT /group-buys/5送一發請求,照樣會打進你的 Controller。真正的防線在後端——所以每一個會改資料的動作(store / update / destroy)都得自己再authorize()一次。少了這層,就是教科書等級的 IDOR 越權漏洞:A 開的團,B 改網址裡的 id 就能刪。
所以 Step 4 在路由套 auth + verified 還不夠——那只擋「沒登入的人」,擋不了「登入了但不該動這筆資料的人」。把前面那個 GroupBuyController 的 authorize() 真的接上去,每個寫入動作都檢查一次:
class GroupBuyController extends Controller
{
public function store(Request $request)
{
$this->authorize('create', GroupBuy::class);
// 通過才建立團購...
}
public function update(Request $request, GroupBuy $groupBuy)
{
$this->authorize('update', $groupBuy);
// 通過才更新...
}
public function destroy(GroupBuy $groupBuy)
{
$this->authorize('delete', $groupBuy);
$groupBuy->delete();
return redirect('/group-buys')->with('success', '團購已刪除');
}
}
懶一點也可以走路由層,把檢查直接掛在路由上,效果一樣:Route::put('/group-buys/{groupBuy}', ...)->can('update', 'groupBuy');。重點不是用哪種寫法,是「Blade 藏按鈕」跟「後端擋請求」永遠要成對出現,缺一個都不算授權做完。
Step 6:Seeder 建立測試帳號
// database/seeders/DatabaseSeeder.php
public function run(): void
{
// 建立管理員
User::factory()->create([
'name' => 'Admin',
'email' => 'admin@jiuhaomai.tw',
'role' => 'admin',
]);
// 建立開團主
User::factory()->count(3)->create([
'role' => 'organizer',
]);
// 建立一般會員
User::factory()->count(10)->create([
'role' => 'member',
]);
// ...GroupBuy seeder
}
php artisan migrate:fresh --seed
現在你可以用 admin@jiuhaomai.tw 登入,看到所有管理功能;用一般會員登入,只能看和跟團。
小結:認證用套件,授權用 Policy
這一章我們走過了 Laravel 認證授權的完整流程:
認證(你是誰):
- Laravel 12 Starter Kit(Livewire 版)——一行安裝,開箱即用
- 註冊、登入、登出、忘記密碼、Email 驗證全自動
authmiddleware 保護路由,auth()->user()取得當前使用者- 密碼自動 bcrypt 雜湊,永遠不要自己處理密碼
授權(你能做什麼):
- Gate——簡單的一次性授權檢查
- Policy——依 Model 組織的授權類別,自動發現
$this->authorize()在 Controller 裡使用,@can在 Blade 裡使用- Role-based 權限——簡單需求用
role欄位 + Enum,複雜需求用 Spatie Permission
揪好買進度:
- ✅ User Model 加入
role欄位(member / organizer / admin) - ✅ UserRole Enum 定義角色和中文標籤
- ✅ GroupBuyPolicy 定義 CRUD 權限規則
- ✅ 路由保護(
auth+verifiedmiddleware) - ✅ Blade 裡用
@can顯示/隱藏操作按鈕
下一章,我們要讓開團主真正地建立團購——表單驗證與檔案上傳。你會學到 Laravel 強大的 Validation 規則系統、Form Request、以及用 Storage facade 處理商品圖片上傳。