使用 Vue 3 與 .Net 8 實作 Google reCAPTCHA v2
使用 Vue 3 實作 Google reCAPTCHA(v2)
前言
近期因為工作需求和電子商務相關,所以來做個範例。
開發功能之前不免俗要來吐槽一下,
研究顯示,網頁機器人解 CAPTCHA 能力優於人類
本來 reCaptcha 是要擋機器人的,但最後都是拿來為難人類自己。
開發目標
前端使用 Vue 3.5 搭配後端使用 .Net 8 完成
先決條件
- Google 帳號:沒有就去辦一個唄
- Node.js:本次範例用
Node.js v22.11.0
- ASP.NET Core 8:微軟親兒子
Visual Studio 2022
裝下去就有了
註冊服務
先到 https://www.google.com/recaptcha/about/ 畫面
點選 「v3 Admin Console」
接著就會看到這註冊表單畫面
標籤
reCAPTCHA 類型
本次範例選擇 「驗證問題 (v2)」 > 「我不是機器人」核取方塊」,實際上可根據業務需求調整網域
因為是在本機上做開發,所以先填入localhost
和127.0.0.1
完成之後會產生一組 網站金鑰 與 密鑰
網站金鑰是給前端用的,密鑰是給後端在伺服器端驗證用的。
文件參考
渲染方式
雖然很多教學都直接給程式碼照抄,但建議還是先把文件看懂,
上方畫面中點選「進一步瞭解用戶端整合」可以看官方文件 。
本頁說明如何在網頁上顯示和自訂 reCAPTCHA v2 小工具。
如要顯示小工具,可以採取下列任一做法:
自動顯示小工具或
明確算繪小工具
如要瞭解如何自訂小工具,請參閱「設定」一節。舉例來說,您可以指定小工具的語言或主題。
請參閱「驗證使用者的回應」,確認使用者是否已成功回答人機驗證問題。
這個小工具就是平常會看到的核取方塊和那討人厭的九宮格
自動顯示和明確算繪差異在於載入方式
自動顯示當網頁完成載入時自動顯示
明確轉譯需要透過 JavaScript 程式碼手動觸發顯示
原生 HTML 範例
這邊寫一個原生 HTML 來示範手動渲染,
按下按鈕 會把一個以 recaptcha-container
為 ID 的 div 標籤換成核取方塊。
<html>
<head>
<title>reCAPTCHA V2 Demo</title>
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
</head>
<body>
<button id="showCaptcha">顯示驗證碼</button>
<div id="recaptcha-container"></div>
<script>
// 等待 API 完全載入
window.onload = function () {
// 取得按鈕元素
const button = document.getElementById("showCaptcha");
let isRendered = false;
// 監聽按鈕點擊事件
button.addEventListener("click", function () {
// 確保只渲染一次
if (!isRendered) {
try {
// 使用 grecaptcha.render() 來渲染驗證碼
grecaptcha.render("recaptcha-container", {
sitekey: "sitekey", // 替換成你的 site key
callback: function (response) {
// 驗證成功的回調函數
console.log("驗證成功:", response);
},
"error-callback": function () {
// 發生錯誤的回調函數
console.error("驗證發生錯誤");
},
"expired-callback": function () {
// 驗證過期的回調函數
console.log("驗證已過期");
isRendered = false; // 允許重新渲染
},
});
isRendered = true;
button.textContent = "驗證碼已顯示";
button.disabled = true;
} catch (error) {
console.error("渲染驗證碼時發生錯誤:", error);
}
}
});
};
</script>
</body>
</html>
建立前端專案
參考官方教學建立一個空專案
把樣式清掉方便開發
不用套件
參考上方原生 HTML 寫法換成 Vue3 的寫法,
並新建一個 component。
src\components\ReCaptchaV2.vue
<template>
<div>
<div ref="re-captcha-v2"></div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="token">Token:</p>
<p v-if="token">{{ token }}</p>
</div>
</template>
<script setup lang="ts">
import { ref, useTemplateRef, onMounted } from "vue";
// 介面定義 reCAPTCHA 提供的 JavaScript API
interface ReCaptchaV2 {
render(container: HTMLDivElement, options: ReCaptchaV2Parameters): number;
reset(widgetId?: number): void;
getResponse(widgetId?: number): string;
}
// 介面定義 grecaptcha.render 參數
interface ReCaptchaV2Parameters {
sitekey: string;
callback?: (response: string) => void;
"expired-callback"?: () => void;
"error-callback"?: () => void;
}
// ref 型別定義
const reCaptchaV2 = useTemplateRef<HTMLDivElement>("re-captcha-v2");
const error = ref("");
const token = ref("");
// 這裡要填入你從 Google reCAPTCHA 管理後台獲得的 site key
const siteKey = "****************************************";
let widgetId: number | null = null;
// 工具函數:取得 grecaptcha 實例
function getGrecaptcha() {
return (window as unknown as { grecaptcha: ReCaptchaV2 }).grecaptcha;
}
/**
* 載入 reCAPTCHA script
*/
async function loadReCaptcha() {
const script = document.createElement("script");
script.src = "https://www.google.com/recaptcha/api.js";
script.async = true;
script.defer = true;
document.head.appendChild(script);
}
/**
* 初始化 reCAPTCHA
*/
async function initialReCaptcha() {
const captcha = getGrecaptcha();
if (!captcha) {
error.value = "reCAPTCHA 尚未載入";
return;
}
try {
widgetId = captcha.render(reCaptchaV2.value!, {
sitekey: siteKey,
callback: (response: string) => {
console.log(response);
token.value = response;
},
"expired-callback": () => {
error.value = "reCAPTCHA 回應到期時執行,讓使用者必須重新驗證。";
console.log("error", error.value);
},
"error-callback": () => {
error.value = "reCAPTCHA 驗證發生錯誤。";
console.log("error", error.value);
},
});
console.log("widgetId", widgetId);
} catch {}
}
onMounted(async () => {
try {
await loadReCaptcha();
// 確保 grecaptcha 已完全載入
const captcha = getGrecaptcha();
if (captcha) {
initialReCaptcha();
}
} catch (e) {
console.error(e);
error.value = "reCAPTCHA 載入失敗";
// emit('error', error.value)
}
});
</script>
<style scoped>
.error {
color: red;
font-size: 14px;
margin-top: 8px;
}
</style>
使用套件
看到上面落落長的範例,是不是很想用套件了呢?
沒錯這種爛大街的需求肯定會有套件可以爽!
<template>
<vue-recaptcha
v-show="showRecaptcha"
sitekey="****************************************"
size="normal"
theme="light"
hl="tr"
:loading-timeout="loadingTimeout"
@verify="recaptchaVerified"
@expire="recaptchaExpired"
@fail="recaptchaFailed"
@error="recaptchaError"
ref="recaptchaRef"
>
</vue-recaptcha>
<p v-if="token">Token:</p>
<p v-if="token">{{ token }}</p>
</template>
<script setup lang="ts">
import { ref } from "vue";
import vueRecaptcha from "vue3-recaptcha2";
const showRecaptcha = ref(true);
const loadingTimeout = ref(30000);
const token = ref("");
const recaptchaRef = ref<InstanceType<typeof vueRecaptcha> | null>(null);
const recaptchaVerified = (response: string) => {
console.log(response);
token.value = response;
};
const recaptchaExpired = () => {
recaptchaRef.value?.reset();
};
const recaptchaFailed = () => {};
const recaptchaError = (reason: string) => {
console.log(reason);
};
</script>
前端的部分就先到這邊,接著是後端的部分。
建立後端專案
用 Visual Studio 開一個 Web API 新專案,
本次範例我以 RecaptchaV3
作為專案名稱。
開發目標是接收前端的 token 後,攜帶後端的密鑰一併發送請求給 Google 來判斷是否成功。
建立必要檔案
畢竟是框架還是得乖乖照規矩走,至於檔案怎麼擺可以自行調整。
appsettings.json
加入這段 存放前面拿到的 網站金鑰 和 密鑰。
"ReCaptchaSettings":
{
"SiteKey": "************************************************",
"SecretKey": "************************************************"
},
開一個 Models 資料夾
ReCaptchaSettings.cs
用來讀取 appsettings.json
namespace RecaptchaV3.Models
{
public class ReCaptchaSettings
{
public const string Position = "ReCaptchaSettings";
public string SiteKey { get; set; } = string.Empty;
public string SecretKey { get; set; } = string.Empty;
}
}
ReCaptchaRequest.cs
用來接收前端 token 的請求物件
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace RecaptchaV3.Models
{
public class ReCaptchaRequest
{
[Required]
[JsonPropertyName("reCaptchaResponse")]
public string ReCaptchaResponse { get; set; } = string.Empty;
}
}
ReCaptchaRequest.cs
用來接收 Google 驗證後的回應物件
using System.Text.Json.Serialization;
namespace RecaptchaV3.Models
{
public class ReCaptchaResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("challenge_ts")]
public string ChallengeTs { get; set; }
[JsonPropertyName("hostname")]
public string Hostname { get; set; }
[JsonPropertyName("error-codes")]
public string[] ErrorCodes { get; set; }
}
}
實作發送邏輯
開一個 Services 資料夾
ReCaptchaService.cs
實作發送請求
using Microsoft.Extensions.Options;
using RecaptchaV3.Models;
using System.Text.Json;
namespace RecaptchaV3.Services
{
public interface IReCaptchaService
{
Task<ReCaptchaResponse?> ValidateReCaptchaAsync(string reCaptchaResponse);
}
public class ReCaptchaService : IReCaptchaService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ReCaptchaSettings _settings;
private readonly ILogger<ReCaptchaService> _logger;
public ReCaptchaService(IHttpClientFactory httpClientFactory, IOptionsMonitor<ReCaptchaSettings> settings, ILogger<ReCaptchaService> logger)
{
_httpClientFactory = httpClientFactory;
_settings = settings.CurrentValue;
_logger = logger;
}
public async Task<ReCaptchaResponse?> ValidateReCaptchaAsync(string reCaptchaResponse)
{
try
{
var parameters = new Dictionary<string, string>
{
{"secret", _settings.SecretKey},
{"response", reCaptchaResponse}
};
using HttpClient httpClient = _httpClientFactory.CreateClient();
var content = new FormUrlEncodedContent(parameters);
var response = await httpClient.PostAsync("https://www.google.com/recaptcha/api/siteverify", content);
var jsonString = await response.Content.ReadAsStringAsync();
var recaptchaResponse = JsonSerializer.Deserialize<ReCaptchaResponse>(jsonString);
return recaptchaResponse;
}
catch (Exception ex)
{
_logger.LogError(ex, "reCAPTCHA 驗證過程發生錯誤");
return null;
}
}
}
}
加入發送端點
using Microsoft.AspNetCore.Mvc;
using RecaptchaV3.Models;
using RecaptchaV3.Services;
namespace RecaptchaV3.Controllers
{
[ApiController]
[Route("recaptcha")]
public class RecaptchaController : ControllerBase
{
private readonly IReCaptchaService _reCaptchaService;
private readonly IHttpClientFactory _httpClientFactory;
public RecaptchaController(IReCaptchaService reCaptchaService, IHttpClientFactory httpClientFactory)
{
_reCaptchaService = reCaptchaService;
_httpClientFactory = httpClientFactory;
}
[HttpPost("verify")]
public async Task<IActionResult> Post(ReCaptchaRequest request)
{
if (string.IsNullOrEmpty(request.ReCaptchaResponse)) { return BadRequest("reCAPTCHA response is required"); }
var ReCaptchaResponse = await _reCaptchaService.ValidateReCaptchaAsync(request.ReCaptchaResponse);
if (ReCaptchaResponse == null || !ReCaptchaResponse.Success) { return BadRequest("reCAPTCHA validation failed"); }
return Ok(new { success = true });
}
}
}
依賴注入服務
直接貼出完整的 Program.cs
,反白的部分要貼對地方。
幾個被反白的說明如下:
- 跨域資源存取(CORS):記得要設定不然前端會拉不到資料
- 注入 ReCaptchaSettings:在 ReCaptchaService 才能取得
appsettings.json
的設定值 - 注入 IHttpClientFactory:發送 Http 請求服務,最佳實踐可延伸閱讀以下文章
- IHttpClientFactory 搭配 .NET
- 在 ASP.NET Core 中使用 IHttpClientFactory 發出 HTTP 要求
using RecaptchaV3.Models;
using RecaptchaV3.Services;
namespace RecaptchaV3
{
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// 讀取允許列表
string[] corsOrigins = builder.Configuration.GetSection("AllowedHosts").Get<string>()!.Split(';');
// 加入具有預設原則的 CORS 和中介軟體
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(
policy =>
{
if (corsOrigins.Contains("*"))
{
policy.SetIsOriginAllowed(_ => true);
}
else
{
policy.WithOrigins(corsOrigins);
}
policy.AllowAnyHeader();
policy.AllowAnyMethod();
policy.AllowCredentials();
});
});
// 注入 ReCaptcha 必要設定與服務
builder.Services.Configure<ReCaptchaSettings>(builder.Configuration.GetSection(ReCaptchaSettings.Position));
builder.Services.AddScoped<IReCaptchaService, ReCaptchaService>();
// 注入 IHttpClientFactory
builder.Services.AddHttpClient();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
// 啟用預設 CORS 原則
app.UseCors();
app.UseAuthorization();
app.MapControllers();
app.Run();
}
}
}
功能串接
後端功能完成後接下來就是把前後端功能串起來,因此要將前面前端的程式做調整。
根據上面不用套件的版本來改,
加入 81-106 行的 verifyToken 實作與後端的 API 溝通,
並把 verifyToken 函式 放入 67 行的 callback 內,
當使用者驗證成功就會自動呼叫後端確認有效性。
src\components\ReCaptchaV2.vue
<template>
<div>
<div ref="re-captcha-v2"></div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="token">Token:</p>
<p v-if="token">{{ token }}</p>
</div>
</template>
<script setup lang="ts">
import { ref, useTemplateRef, onMounted } from 'vue'
// 介面定義 reCAPTCHA 提供的 JavaScript API
interface ReCaptchaV2 {
render(container: HTMLDivElement, options: ReCaptchaV2Parameters): number
reset(widgetId?: number): void
getResponse(widgetId?: number): string
}
// 介面定義 grecaptcha.render 參數
interface ReCaptchaV2Parameters {
sitekey: string
callback?: (response: string) => void
'expired-callback'?: () => void
'error-callback'?: () => void
}
// ref 型別定義
const reCaptchaV2 = useTemplateRef<HTMLDivElement>('re-captcha-v2')
const error = ref('')
const token = ref('')
// 這裡要填入你從 Google reCAPTCHA 管理後台獲得的 site key
const siteKey = '6LdCfo4qAAAAAEhXixXDxRABEuqJgae94Dqyfgbt'
let widgetId: number | null = null
// 工具函數:取得 grecaptcha 實例
function getGrecaptcha() {
return (window as unknown as { grecaptcha: ReCaptchaV2 }).grecaptcha
}
/**
* 載入 reCAPTCHA script
*/
async function loadReCaptcha() {
const script = document.createElement('script')
script.src = 'https://www.google.com/recaptcha/api.js'
script.async = true
script.defer = true
document.head.appendChild(script)
}
/**
* 初始化 reCAPTCHA
*/
async function initialReCaptcha() {
const captcha = getGrecaptcha()
if (!captcha) {
error.value = 'reCAPTCHA 尚未載入'
return
}
try {
widgetId = captcha.render(reCaptchaV2.value!, {
sitekey: siteKey,
callback: (response: string) => {
console.log(response)
token.value = response
verifyToken(token.value)
},
'expired-callback': () => {
error.value = 'reCAPTCHA 回應到期時執行,讓使用者必須重新驗證。'
console.log('error', error.value)
},
'error-callback': () => {
error.value = 'reCAPTCHA 驗證發生錯誤。'
console.log('error', error.value)
},
})
} catch {}
}
// 發送 token 到後端驗證
async function verifyToken(token: string) {
try {
const response = await fetch('https://localhost:7291/recaptcha/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ reCaptchaResponse: token }),
})
const result = await response.json()
console.log(result)
if (result.success) {
// 驗證成功,繼續你的邏輯
console.log('驗證成功')
alert('驗證成功')
} else {
// 驗證失敗
console.log('驗證失敗')
alert('驗證失敗')
}
} catch (error) {
console.error('驗證發生錯誤:', error)
}
}
onMounted(async () => {
try {
await loadReCaptcha()
// 確保 grecaptcha 已完全載入
const captcha = getGrecaptcha()
if (captcha) {
initialReCaptcha()
}
} catch (e) {
console.error(e)
error.value = 'reCAPTCHA 載入失敗'
// emit('error', error.value)
}
})
</script>
<style scoped>
.error {
color: red;
font-size: 14px;
margin-top: 8px;
}
</style>