add login
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
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 './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;
|
||||
|
||||
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