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

參考:

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *