From 6c495ac631e7268e26fc648cc7081849ea89ad6c Mon Sep 17 00:00:00 2001 From: maojindao55 Date: Tue, 25 Mar 2025 06:36:14 +0800 Subject: [PATCH] add login --- .gitignore | 2 +- functions/api/login.ts | 157 +++++++++++++++++++++++++++++++++ functions/api/sendCode.ts | 72 ++++++++++++++++ functions/middleware/auth.ts | 60 +++++++++++++ src/App.tsx | 90 ++++++++++++++++--- src/components/PhoneLogin.tsx | 158 ++++++++++++++++++++++++++++++++++ wrangler.toml | 4 + 7 files changed, 532 insertions(+), 11 deletions(-) create mode 100644 functions/api/login.ts create mode 100644 functions/api/sendCode.ts create mode 100644 functions/middleware/auth.ts create mode 100644 src/components/PhoneLogin.tsx create mode 100644 wrangler.toml diff --git a/.gitignore b/.gitignore index 0ac13c4..6406656 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ yarn-error.log* lerna-debug.log* .pnpm-debug.log* package-lock.json - +.wrangler/ # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/functions/api/login.ts b/functions/api/login.ts new file mode 100644 index 0000000..d89b6c5 --- /dev/null +++ b/functions/api/login.ts @@ -0,0 +1,157 @@ +interface Env { + bgkv: KVNamespace; + JWT_SECRET: string; +} + +export const onRequestPost: PagesFunction = async (context) => { + try { + const { request, env } = context; + + // 获取请求体 + const body = await request.json(); + const { phone, code } = body; + + // 验证手机号格式 + if (!phone || !/^1[3-9]\d{9}$/.test(phone)) { + return new Response( + JSON.stringify({ + success: false, + message: '无效的手机号码' + }), + { + status: 400, + headers: { + 'Content-Type': 'application/json', + }, + } + ); + } + + // 验证码格式检查 + if (!code || !/^\d{6}$/.test(code)) { + return new Response( + JSON.stringify({ + success: false, + message: '验证码格式错误' + }), + { + status: 400, + headers: { + 'Content-Type': 'application/json', + }, + } + ); + } + + // 从 KV 中获取存储的验证码 + const storedCode = await env.bgkv.get(`sms:${phone}`); + + if (!storedCode || storedCode !== code) { + return new Response( + JSON.stringify({ + success: false, + message: '验证码错误或已过期' + }), + { + status: 400, + headers: { + 'Content-Type': 'application/json', + }, + } + ); + } + + // 验证成功,生成 JWT token,传入 env + const token = await generateToken(phone, env); + + // 删除验证码 + await env.bgkv.delete(`sms:${phone}`); + + return new Response( + JSON.stringify({ + success: true, + message: '登录成功', + data: { + token, + phone + } + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + } catch (error) { + console.error(error); + return new Response( + JSON.stringify({ + success: false, + message: '服务器错误' + }), + { + status: 500, + headers: { + 'Content-Type': 'application/json', + }, + } + ); + } +}; + +// 修改为 async 函数 +async function generateToken(phone: string, env: Env): Promise { + const header = { + alg: 'HS256', + typ: 'JWT' + }; + + const payload = { + phone, + exp: Math.floor(Date.now() / 1000) + (7 * 24 * 60 * 60), // 7天过期 + iat: Math.floor(Date.now() / 1000) + }; + + // Base64Url 编码 + const encodeBase64Url = (data: object) => { + return btoa(JSON.stringify(data)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + }; + + // 生成签名 + const generateSignature = async (input: string, secret: string) => { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + + const signature = await crypto.subtle.sign( + 'HMAC', + key, + encoder.encode(input) + ); + + return btoa(String.fromCharCode(...new Uint8Array(signature))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + }; + + const headerEncoded = encodeBase64Url(header); + const payloadEncoded = encodeBase64Url(payload); + + const signature = await generateSignature( + `${headerEncoded}.${payloadEncoded}`, + env.JWT_SECRET + ); + + return `${headerEncoded}.${payloadEncoded}.${signature}`; +} \ No newline at end of file diff --git a/functions/api/sendCode.ts b/functions/api/sendCode.ts new file mode 100644 index 0000000..c3990b1 --- /dev/null +++ b/functions/api/sendCode.ts @@ -0,0 +1,72 @@ +interface Env { + bgkv: KVNamespace; +} + +export const onRequestPost: PagesFunction = async (context) => { + try { + const { request, env } = context; + + // 获取请求体 + const body = await request.json(); + const { phone } = body; + + // 验证手机号格式 + if (!phone || !/^1[3-9]\d{9}$/.test(phone)) { + return new Response( + JSON.stringify({ + success: false, + message: '无效的手机号码' + }), + { + status: 400, + headers: { + 'Content-Type': 'application/json', + }, + } + ); + } + + // 生成6位随机验证码 + let verificationCode = Math.floor(100000 + Math.random() * 900000).toString(); + // 使用 CF_PAGES_ENVIRONMENT 判断环境 + // 值为 'production' 或 'preview' + if (env.CF_PAGES_ENVIRONMENT !== 'production') { + verificationCode = '123456'; + } + // 将验证码存储到 KV 中,设置5分钟过期 + await env.bgkv.put(`sms:${phone}`, verificationCode, { + expirationTtl: 300 // 5分钟过期 + }); + console.log(env.CF_PAGES_ENVIRONMENT, await env.bgkv.get(`sms:${phone}`)); + + return new Response( + JSON.stringify({ + success: true, + message: '验证码发送成功', + // 注意:实际生产环境不应该返回验证码 + code: verificationCode // 仅用于测试 + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + } catch (error) { + console.error(error); + return new Response( + JSON.stringify({ + success: false, + message: '服务器错误' + }), + { + status: 500, + headers: { + 'Content-Type': 'application/json', + }, + } + ); + } +}; diff --git a/functions/middleware/auth.ts b/functions/middleware/auth.ts new file mode 100644 index 0000000..05b2450 --- /dev/null +++ b/functions/middleware/auth.ts @@ -0,0 +1,60 @@ +interface Env { + JWT_SECRET: string; +} + +export async function verifyToken(token: string, env: Env) { + try { + const [headerB64, payloadB64, signature] = token.split('.'); + + // 验证签名 + const input = `${headerB64}.${payloadB64}`; + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(env.JWT_SECRET), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'] + ); + + const signatureBytes = Uint8Array.from(atob( + signature.replace(/-/g, '+').replace(/_/g, '/') + ), c => c.charCodeAt(0)); + + const isValid = await crypto.subtle.verify( + 'HMAC', + key, + signatureBytes, + encoder.encode(input) + ); + + if (!isValid) { + throw new Error('Invalid signature'); + } + + // 解码 payload + const payload = JSON.parse(atob( + payloadB64.replace(/-/g, '+').replace(/_/g, '/') + )); + + // 检查过期时间 + if (payload.exp < Math.floor(Date.now() / 1000)) { + throw new Error('Token expired'); + } + + return payload; + } catch (error) { + throw new Error('Invalid token'); + } +} + +// 中间件函数 +export const authMiddleware = async (request: Request, env: Env) => { + const authHeader = request.headers.get('Authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new Error('No token provided'); + } + + const token = authHeader.split(' ')[1]; + return await verifyToken(token, env); +}; \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 7154bbe..bfbf30e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,83 @@ -import ChatUI from './components/ChatUI' -import './App.css' -import Layout from './components/Layout' +import React, { useState, useEffect } from 'react'; +import ChatUI from './components/ChatUI'; +import PhoneLogin from './components/PhoneLogin'; +import './App.css'; +import Layout from './components/Layout'; + +const App: React.FC = () => { + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + // 在组件加载时检查登录状态 + useEffect(() => { + checkLoginStatus(); + }, []); + + // 检查登录状态 + const checkLoginStatus = async () => { + try { + // 从 localStorage 获取 token + const token = localStorage.getItem('token'); + if (!token) { + setIsLoggedIn(false); + setIsLoading(false); + return; + } + + // 验证 token 有效性 + const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/verify-token`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + setIsLoggedIn(true); + } else { + // token 无效,清除存储的 token + localStorage.removeItem('token'); + setIsLoggedIn(false); + } + } catch (error) { + console.error('验证登录状态失败:', error); + setIsLoggedIn(false); + } finally { + setIsLoading(false); + } + }; + + + // 处理登出 + const handleLogout = () => { + localStorage.removeItem('token'); + setIsLoggedIn(false); + }; + + if (isLoading) { + return ( +
+
+
+ ); + } -function App() { return ( - - - - ) -} + <> + {isLoggedIn ? ( +
+ + +
+ ) : ( + + )} + + ); +}; -export default App +export default App; diff --git a/src/components/PhoneLogin.tsx b/src/components/PhoneLogin.tsx new file mode 100644 index 0000000..61868b4 --- /dev/null +++ b/src/components/PhoneLogin.tsx @@ -0,0 +1,158 @@ +import React, { useState } from 'react'; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +interface PhoneLoginProps { + onLogin: (phone: string, code: string) => void; +} + +const PhoneLogin: React.FC = ({ onLogin }) => { + const [phone, setPhone] = useState(''); + const [code, setCode] = useState(''); + const [countdown, setCountdown] = useState(0); + const [isLoading, setIsLoading] = useState(false); + + // 发送验证码 + const handleSendCode = async () => { + if (!phone || !/^1[3-9]\d{9}$/.test(phone)) { + alert('请输入正确的手机号'); + return; + } + + //setIsLoading(true); + try { + const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/sendcode`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ phone }), + }); + + if (!response.ok) { + throw new Error('发送验证码失败'); + } + + // 开始倒计时 + setCountdown(60); + const timer = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + clearInterval(timer); + return 0; + } + return prev - 1; + }); + }, 1000); + + } catch (error) { + console.error('发送验证码失败:', error); + alert('发送验证码失败,请重试'); + } finally { + setIsLoading(false); + } + }; + + // 提交登录 + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!phone || !code) { + alert('请输入手机号和验证码'); + return; + } + + setIsLoading(true); + try { + const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ phone, code }), + }); + + const data = await response.json(); + console.log(data); + + if (!response.ok) { + throw new Error(data.message || '登录失败'); + } + + // 保存 token 到 localStorage + localStorage.setItem('token', data.data.token); + localStorage.setItem('phone', data.data.phone); + + } catch (error) { + console.error('登录失败:', error); + alert(error instanceof Error ? error.message : '登录失败,请重试'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+ {/* Logo */} +
+ botgroup.chat +
+ + +
+ 仅支持中国大陆手机号登录 +
+ +
+
+
+ +86 + setPhone(e.target.value)} + maxLength={11} + className="border-none focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 shadow-none p-0" + /> +
+
+ +
+
+ setCode(e.target.value)} + maxLength={6} + className="border rounded-lg p-3 h-[46px] focus:border-[#ff6600] focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 shadow-none" + /> + +
+
+ + +
+
+
+ ); +}; + +export default PhoneLogin; \ No newline at end of file diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..9d9d2a5 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,4 @@ +[[kv_namespaces]] +binding = "bgkv" +id = "cbd11575c3504e6bb043c1c250d2f7ed" +preview_id = "cbd11575c3504e6bb043c1c250d2f7ed" # 本地开发环境使用 \ No newline at end of file