add login
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,7 +7,7 @@ yarn-error.log*
|
|||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
.wrangler/
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
|||||||
157
functions/api/login.ts
Normal file
157
functions/api/login.ts
Normal 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
72
functions/api/sendCode.ts
Normal 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',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
60
functions/middleware/auth.ts
Normal file
60
functions/middleware/auth.ts
Normal 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);
|
||||||
|
};
|
||||||
90
src/App.tsx
90
src/App.tsx
@@ -1,13 +1,83 @@
|
|||||||
import ChatUI from './components/ChatUI'
|
import React, { useState, useEffect } from 'react';
|
||||||
import './App.css'
|
import ChatUI from './components/ChatUI';
|
||||||
import Layout from './components/Layout'
|
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 (
|
return (
|
||||||
<Layout>
|
<>
|
||||||
<ChatUI />
|
{isLoggedIn ? (
|
||||||
</Layout>
|
<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;
|
||||||
|
|||||||
158
src/components/PhoneLogin.tsx
Normal file
158
src/components/PhoneLogin.tsx
Normal 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
4
wrangler.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "bgkv"
|
||||||
|
id = "cbd11575c3504e6bb043c1c250d2f7ed"
|
||||||
|
preview_id = "cbd11575c3504e6bb043c1c250d2f7ed" # 本地开发环境使用
|
||||||
Reference in New Issue
Block a user