add login

This commit is contained in:
maojindao55
2025-03-25 06:36:14 +08:00
parent 6e286682d3
commit 6c495ac631
7 changed files with 532 additions and 11 deletions

2
.gitignore vendored
View File

@@ -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

157
functions/api/login.ts Normal file
View File

@@ -0,0 +1,157 @@
interface Env {
bgkv: KVNamespace;
JWT_SECRET: string;
}
export const onRequestPost: PagesFunction<Env> = 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<string> {
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}`;
}

72
functions/api/sendCode.ts Normal file
View File

@@ -0,0 +1,72 @@
interface Env {
bgkv: KVNamespace;
}
export const onRequestPost: PagesFunction<Env> = 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',
},
}
);
}
};

View File

@@ -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);
};

View File

@@ -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<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(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 (
<div className="fixed inset-0 bg-gradient-to-br from-orange-50 via-orange-50/70 to-orange-100 flex items-center justify-center">
<div className="w-8 h-8 animate-spin rounded-full border-4 border-orange-500 border-t-transparent"></div>
</div>
);
}
function App() {
return (
<Layout>
<ChatUI />
</Layout>
)
}
<>
{isLoggedIn ? (
<div className="relative">
<ChatUI />
<button
onClick={handleLogout}
className="absolute top-4 right-4 px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors"
>
退
</button>
</div>
) : (
<PhoneLogin/>
)}
</>
);
};
export default App
export default App;

View File

@@ -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<PhoneLoginProps> = ({ 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 (
<div className="fixed inset-0 bg-white flex items-center justify-center">
<div className="w-full max-w-md p-8">
{/* Logo */}
<div className="flex items-center justify-center mb-4">
<span style={{fontFamily: 'Audiowide, system-ui', color: '#ff6600'}} className="text-3xl ml-2">botgroup.chat</span>
</div>
<div className="text-gray-500 mb-4 text-center">
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<div className="flex items-center border rounded-lg p-3 h-[46px] focus-within:border-[#ff6600]">
<span className="text-gray-400 mr-2">+86</span>
<Input
type="tel"
placeholder="请输入手机号"
value={phone}
onChange={(e) => 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"
/>
</div>
</div>
<div>
<div className="flex gap-3">
<Input
type="text"
placeholder="请输入验证码"
value={code}
onChange={(e) => 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"
/>
<Button
type="button"
onClick={handleSendCode}
disabled={countdown > 0 || isLoading}
className="bg-white text-[#ff6600] border border-[#ff6600] hover:bg-[#ff6600] hover:text-white rounded-lg px-6 h-[46px]"
>
{countdown > 0 ? `${countdown}秒后重试` : '发送验证码'}
</Button>
</div>
</div>
<Button
type="submit"
className="w-full bg-[#ff6600] hover:bg-[#e65c00] text-white rounded-lg py-3"
disabled={isLoading}
>
{isLoading ? (
<div className="w-4 h-4 mr-2 animate-spin rounded-full border-2 border-white border-t-transparent" />
) : '登录'}
</Button>
</form>
</div>
</div>
);
};
export default PhoneLogin;

4
wrangler.toml Normal file
View File

@@ -0,0 +1,4 @@
[[kv_namespaces]]
binding = "bgkv"
id = "cbd11575c3504e6bb043c1c250d2f7ed"
preview_id = "cbd11575c3504e6bb043c1c250d2f7ed" # 本地开发环境使用