Laravel & Vue3 客製化的API信件認證
Laravel 預設已有提供信件的認證機制,可以使用 Laravel 應用程式入門套件 或是查看電子郵件驗證,但它屬於 Blade 範本 的方式,若要使用 Vue3 的 api 架構則必需要自行客製程式。下面就大致解說一下整體流程,也順便記錄留存。
預期的作業流程
用戶註冊
填入資料按立即註冊後,會發出一封信到註冊信箱,並轉頁到輸入驗證碼的頁面。
轉頁到信箱認證
收到驗證碼
信箱驗證成功頁面
實作程式
建立記錄驗證信箱的資料庫
利用指令建立資料表 table: users_verify
PS W:\xampp\htdocs\twingo> php artisan make:migration create_users_verify_table
INFO Migration [W:\xampp\htdocs\twingo\database\migrations/2024_09_03_142735_create_users_verify_table.php] created successfully.
修改資料表 table: users_verify
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users_verify', function (Blueprint $table) {
$table->id();
$table->bigInteger('user_id');
$table->string('type');
$table->string('token');
$table->string('verify_code');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users_verify');
}
};
記得要執行 php artisan db:migrate fresh,資料庫才會更新。
建立 Model: UserVerify
php artisan make:model UserVerify
程式碼修改如下
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserVerify extends Model
{
use HasFactory;
/**
* The table associated with the model.
* @var string
*/
protected $table='users_verify';
/**
* The attributes that are mass assignable.
* @var array
*/
protected $fillable = [
'user_id',
'type',
'token',
'verify_code'
];
/**
* 此認證憑證屬於那個登入帳號
* @return void
*/
public function user():BelongsTo
{
return $this->belongsTo(User::class);
}
}
這裡是 定義關係的反向 BelongsTo 到 Model: User,可以查看 Laravel TEST 測試 。
修改 Model: User
/**
* 郵件認證的記錄
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function userVerify(): HasMany
{
return $this->hasMany(UserVerify::class);
}
這裡是 一對多設定 到 Model: UserVerify,可以查看 Laravel TEST 測試 。
設定路由
在 routes/web.php 設定 網頁 路由
/**
* 首頁
*/
Route::get('/', function () {
return view('appHome'); // Vue3的首頁
});
Route::controller(AuthController::class)->group( function() {
// 信件驗證
Route::get('/verify/email/{id}/{hash}','sendVerifyEmail')->name('email.verify');
});
/**
* 任何前端來的 request 都轉向到 appHome 的 Vue3 首頁
*/
Route::get('{any}', function () {
return view('appHome');
})->where('any', '.*');
這裡會轉到 AuthController.php 控制器中的函數(function):sendVerifyEmail,再由它轉到 vue3 router 去讓用戶輸入驗證碼。
// AuthController.php
public function sendVerifyEmail($id, $token): RedirectResponse
{
// 導向url: vue3-router(auth.js):/auth/email/verification/:id/:hash
return Redirect::to('/auth/email/verification/' . $id . '/' . $token);
}
在 vue3 router 中設定路由
export default [
{
path: '/auth/login',
name: 'login',
component: () => import('@/pages/auth/Login.vue')
},
{
path: '/auth/register',
name: 'register',
component: () => import('@/pages/auth/Register.vue')
},
{
path: '/auth/email/verification/:id/:hash',
name: 'user.verification',
component: () => import('@/pages/auth/Verification.vue')
}
]
在 routes/api.php 設定 api 路由
/**
* 公開可以使用的 api
*/
Route::controller(AuthController::class)->group(function () {
Route::post('/auth/register', 'register'); // 註冊
Route::post('/auth/login', 'login'); // 登入
Route::post('/auth/verifyEmail', 'verifyEmail'); // 驗證註冊信箱
Route::post('/auth/reSendVeifyEmail', 'reSendVeifyEmail'); // 重送驗證註冊信件
});
前端 Vue3 的頁面
Vue3 註冊頁面
<script setup>
import { ref } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useRouter } from 'vue-router';
import { useForm } from 'vee-validate';
import * as yup from 'yup';
import i18n from '@/plugins/vue-i18n';
import { AuthService } from '@/service';
const { t } = i18n.global;
const processing = ref(false);
const toast = useToast();
const router = useRouter();
const authService = new AuthService();
const mailVerify = import.meta.env.VITE_APP_MAIL_VERIFY;
// 設置表單驗證規則
const schema = yup.object({
name: yup.string().required(),
email: yup.string().required().email(),
password: yup.string().required().min(6),
c_password: yup.string().oneOf([yup.ref('password')],`${t('validate.field_not_same')}`).required()
});
// 啟用驗證規則
const { defineField, handleSubmit, resetForm, errors, values } = useForm({
validationSchema: schema
});
// 訂定表單欄位與驗證規則掛勾
const [name] = defineField('name');
const [email] = defineField('email');
const [password] = defineField('password');
const [c_password] = defineField('c_password');
// 表單送出
const onSubmit = handleSubmit( async (values) => {
console.log(values)
try {
processing.value = true;
const data = {
name: values.name,
email: values.email,
password: values.password,
c_password: values.c_password
}
const response = await authService.register(data);
// 在這裡處理登入成功的邏輯
console.log('註冊成功:', response.data);
// 檢查信箱是否需要驗證
if (mailVerify) {
router.push({name:'user-email-verification',params:{id:response.data.id,hash:response.data.token}});
} else {
const userInfo = {
user : response.data,
logined : response.success
};
localStorage.removeItem('userInfo');
localStorage.setItem('userInfo', JSON.stringify(userInfo));
router.push({name:'dashboard'});
}
setTimeout(() => {
toast.add({ severity: 'info', summary: t('toast.info'), detail: t('toast.register_success'), life: 3000 });
}, 500);
} catch (error) {
toast.add({ severity: 'error', summary: t('toast.error'), detail: t('toast.register_failed'), life: 3000 });
} finally {
processing.value = false;
}
});
</script>
<template>
<form novalidate @submit="onSubmit">
<div class="flex flex-column gap-3 mt-6">
<InputGroup>
<InputGroupAddon>
<i class="pi pi-user"></i>
</InputGroupAddon>
<InputText v-model="name" :placeholder="$t('label.name')" :class="{ 'p-invalid': errors.name}" />
</InputGroup>
<small class="p-error">{{ errors.name }}</small>
<InputGroup>
<InputGroupAddon>
<i class="pi pi-at"></i>
</InputGroupAddon>
<InputText v-model="email" :placeholder="$t('label.email')" :class="{ 'p-invalid': errors.email }" />
</InputGroup>
<small class="p-error">{{ errors.email }}</small>
<InputGroup>
<InputGroupAddon>
<i class="pi pi-key"></i>
</InputGroupAddon>
<Password v-model="password" :placeholder="$t('label.password')" inputClass="w-full" toggleMask />
</InputGroup>
<small class="p-error">{{ errors.password }}</small>
<InputGroup>
<InputGroupAddon>
<i class="pi pi-key"></i>
</InputGroupAddon>
<Password v-model="c_password" :placeholder="$t('label.password_again')" inputClass="w-full" toggleMask />
</InputGroup>
<small class="p-error">{{ errors.c_password }}</small>
<div>
<Button type="submit" :disabled="processing" class="w-full" :label="$t('label.sign_up')">
{{ processing ? $t('label.wait') : $t('label.sign_up') }}
</Button>
</div>
<div>
<Button class="w-full text-primary-500" text :label="$t('label.back_to_login')" @click="router.push('/auth/login')"></Button>
</div>
</div>
</form>
</template>
Vue3 信件認證碼頁面
<script setup>
import AppConfig from '@/layout/AppConfig.vue';
import { useToast } from 'primevue/usetoast';
import i18n from '@/plugins/vue-i18n';
import { useRouter, useRoute } from 'vue-router';
import { AuthService } from '@/service';
import { ref } from 'vue';
const { t } = i18n.global;
const toast = useToast();
const router = useRouter(); // 用來轉到下個頁面
const route = useRoute(); // 用來取得傳入的參數
const authService = new AuthService();
// 取得傳入的參數值
const id = route.params.id;
const hash = route.params.hash
console.log(id,hash);
const verifycode = ref(null);
const backgroundImage = ref('url(/demo/images/pages/accessDenied-bg.jpg)');
const backgroundStyle = ref({
background: backgroundImage.value
});
const focus = (event) => {
const regexNum = /^\d+$/;
const isValid = regexNum.test(event.key);
const nextElementInputRef = event.currentTarget.nextElementSibling.children[0];
isValid && nextElementInputRef.focus();
};
// 處理認證信件
const onVerifyCode = async () => {
const data = {
user_id: id,
type: 'email',
token: hash,
verifycode: verifycode.value
};
const res = await authService.verifyEmail(data);
router.push({name:'dashboard'});
setTimeout(() => {
toast.add({ severity: 'info', summary: t('toast.info'), detail: res.message, life: 3000 });
}, 500);
}
// 重送認證信件
const onSendAgain = async ()=> {
const data = {
user_id: id,
token: hash,
verifycode: verifycode.value
};
const res1 = await authService.reSendVeifyEmail(data);
setTimeout(() => {
toast.add({ severity: 'info', summary: t('toast.info'), detail: res1.message, life: 3000 });
}, 500);
}
</script>
<template>
<div class="h-screen flex w-full surface-ground">
<div class="flex flex-1 flex-column surface-ground align-items-center justify-content-center">
<div class="w-11 sm:w-30rem">
<div class="flex flex-column">
<div style="height: 56px; width: 56px" class="bg-primary-50 border-circle flex align-items-center justify-content-center">
<i class="pi pi-check-circle text-primary text-4xl"></i>
</div>
<div class="mt-4">
<h1 class="m-0 text-primary font-semibold text-4xl">電子信箱認證 (Authentication) ?</h1>
<span class="block text-700 mt-2">輸入電子郵件中的驗證碼(Verify Code)</span>
<span class="block text-700 mt-2">Verify your code from e-Mail</span>
</div>
</div>
<div class="flex gap-3 align-items-center mt-6 p-fluid">
<InputText inputId="val1" v-model="verifycode" inputClass="w-3rem text-center" :max="9"></InputText>
</div>
<div class="mt-3">
<Button class="w-full" label="VERIFY NOW" @click="onVerifyCode"></Button>
</div>
<div class="mt-3">
<Button class="w-full text-primary-500" text label="SEND AGAIN" @click="onSendAgain"></Button>
</div>
<div>
<Button class="w-full text-primary-500" text :label="$t('label.back_to_login')" @click="router.push('/auth/login')"></Button>
</div>
</div>
</div>
</div>
</template>
建立控制器 AuthController
<?php
namespace App\Http\Controllers\API;
use Illuminate\Http\Request;
use App\Http\Controllers\API\BaseController as BaseController;
use App\Models\User;
use App\Models\UserVerify;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Http\RedirectResponse;
class AuthController extends BaseController
{
/**
* api: /auth/register
* @param \Illuminate\Http\Request $request
* @return RedirectResponse|\Illuminate\Http\Response
*/
public function register(Request $request)
{
$result = [];
$validator = Validator::make($request->all(), [
'name' => 'required',
'email' => 'required|email',
'password' => 'required',
'c_password' => 'required|same:password',
]);
if ($validator->fails()) {
return $this->sendError('Validation Error.', $validator->errors());
}
$input = $request->all();
$input['password'] = bcrypt($input['password']);
$user = User::create($input);
if (env('APP_MAIL_VERIFY')) {
$userVerify = $this->generateVerifyEmail($user);
$result['id'] = $user->id;
$result['token'] = $userVerify->token;
return $this->sendResponse($result, '已註冊, 請輸入信件中的認證碼');
}
$result = $this->generateUserInfo($user);
return $this->sendResponse($result, '已註冊成功');
}
/**
* api: /auth/login
*
* @return \Illuminate\Http\Response
*/
public function login(Request $request): JsonResponse
{
if (Auth::attempt(['email' => $request->email, 'password' => $request->password])) {
$user = Auth::user();
$result = $this->generateUserInfo($user);
return $this->sendResponse($result, 'User login successfully.');
} else {
return $this->sendError('Unauthorised.', ['error' => 'Unauthorised']);
}
}
/**
* 註冊後發給用戶的信件, 信中連結回來的承接點, Route::get('/verify/email/{id}/{hash}','sendVerifyEmail')->name('email.verify');
* 再轉向到 vue 的 path: '/auth/verification/:id/:hash'
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\RedirectResponse
*/
public function sendVerifyEmail($id, $token): RedirectResponse
{
// 導向url: vue3-router(auth.js):/auth/email/verification/:id/:hash
return Redirect::to('/auth/email/verification/' . $id . '/' . $token);
}
/**
* api: /auth/verifyEmail
* 驗證註冊信箱
* @param Request $request
* @return JsonResponse
*/
public function verifyEmail(Request $request): JsonResponse
{
$result = [];
$input = $request->all();
$user = USER::find($input['user_id']);
if (!isset($user)) {
return $this->sendError('此帳號不存在');
}
if ($user->hasVerifiedEmail()) {
UserVerify::where('user_id', $input['user_id'])->delete(); // 刪除該帳號所有認證過的記錄
return $this->sendResponse($result, '此信箱已認證過');
}
$uv = UserVerify::where('user_id', $input['user_id'])
->where('type', $input['type'])
->where('token', $input['token'])
->first();
$verifyCode = $uv->verify_code;
if (isset($verifyCode) && $verifyCode == $input['verifycode']) {
$user = USER::find($input['user_id']);
if (isset($user)) {
$user->markEmailAsVerified();
$uv->delete();
}
return $this->sendResponse($result, '信箱驗證成功');
}
return $this->sendError('信箱驗證失敗', $result);
}
public function reSendVeifyEmail(Request $request)
{
$result = [];
$input = $request->all();
$user = USER::find($input['user_id']);
if (!isset($user)) {
return $this->sendError('此帳號不存在');
}
$userVerify = $this->generateVerifyEmail($user);
$result['id'] = $user->id;
$result['token'] = $userVerify->token;
return $this->sendResponse($result, '已重送信件, 請輸入信件中的認證碼');
}
/**
* 產生前端需要的帳號資訊
* @param \App\Models\User $user
* @return array
*/
protected function generateUserInfo(User $user)
{
if (!isset($user)) {
return [];
}
$result['id'] = $user->id;
$result['token'] = $user->createToken($user->email)->plainTextToken;
$result['name'] = $user->name;
return $result;
}
protected function generateVerifyEmail(User $user)
{
if (!isset($user)) {
return [];
}
// 送出驗證信件到用戶信箱
$token = Str::random(64);
$code = Str::random(9);
$uv = UserVerify::create([
'user_id' => $user->id,
'type' => 'email',
'token' => $token,
'verify_code' => $code
]);
Mail::send('email.EmailVerificationEmail', ['id' => $user->id, 'token' => $token, 'code' => $code], function ($message) use ($user) {
$message->to($user->email);
$message->subject(env('APP_NAME') . ' - 信件認證(Email Verification Mail)');
});
return $uv;
}
}
這樣就大致完成註冊信箱認證作業了。
實作的網址: https://twingo.polinwei.com/auth/register
發佈留言