diff --git a/README.md b/README.md index d5e6de9..068f17b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ ## TODO + + 使用 pure js 的包代替 canvas,加快编译速度 提取后刷新提取页套餐可用余量 diff --git a/src/actions/auth/auth.ts b/src/actions/auth.ts similarity index 55% rename from src/actions/auth/auth.ts rename to src/actions/auth.ts index 466beb7..6d14805 100644 --- a/src/actions/auth/auth.ts +++ b/src/actions/auth.ts @@ -1,19 +1,10 @@ 'use server' import {cookies} from 'next/headers' import {ApiResponse, UnauthorizedError} from '@/lib/api' -import {AuthContext} from '@/lib/auth' import {User} from '@/lib/models' -import {callByDevice, callByUser, callPublic, getUserToken} from '@/actions/base' -import {redirect} from 'next/navigation' -import {cache} from 'react' +import {callByDevice, callByUser} from '@/actions/base' -export interface LoginParams { - username: string - password: string - remember: boolean -} - -type LoginResp = { +type TokenResp = { access_token: string refresh_token: string expires_in: number @@ -21,9 +12,14 @@ type LoginResp = { scope?: string } -export async function login(props: LoginParams): Promise { +export async function login(props: { + username: string + password: string + remember: boolean +}): Promise { + // 尝试登录 - const result = await callByDevice('/api/auth/token', { + const result = await callByDevice('/api/auth/token', { ...props, grant_type: 'password', login_type: 'phone_code', @@ -44,14 +40,6 @@ export async function login(props: LoginParams): Promise { httpOnly: true, sameSite: 'strict', }) - // cookieStore.set('auth_info', JSON.stringify(data.auth), { - // httpOnly: true, - // sameSite: 'strict', - // }) - // cookieStore.set('auth_profile', JSON.stringify(data.profile), { - // httpOnly: true, - // sameSite: 'strict', - // }) return { success: true, @@ -91,19 +79,49 @@ export async function logout() { } export async function getProfile() { - try { - const token = await getUserToken() - const result = await callPublic('/api/user/get/token', {token}) + return await callByUser('/api/auth/introspect') +} - if (!result.success) { - throw new Error('获取用户信息失败') - } - return result.data +export async function refreshAuth() { + const cookie = await cookies() + + const userRefresh = cookie.get('auth_refresh')?.value + if (!userRefresh) { + throw UnauthorizedError } - catch (e) { - if (e === UnauthorizedError) { - return null - } - throw e + + // 请求刷新访问令牌 + const resp = await callByDevice(`/api/auth/token`, { + grant_type: 'refresh_token', + refresh_token: userRefresh, + }) + + // 处理请求 + if (!resp.success) { + cookie.delete('auth_refresh') + throw UnauthorizedError + } + + // 解析响应 + const data = resp.data + const nextAccessToken = data.access_token + const nextRefreshToken = data.refresh_token + const expiresIn = data.expires_in + + // 保存令牌到 cookies + cookie.set('auth_token', nextAccessToken, { + httpOnly: true, + sameSite: 'strict', + maxAge: Math.max(expiresIn, 0), + }) + cookie.set('auth_refresh', nextRefreshToken, { + httpOnly: true, + sameSite: 'strict', + }) + + // 返回新的访问令牌 + return { + access_token: nextAccessToken, + refresh_token: nextRefreshToken, } } diff --git a/src/actions/auth/identify.ts b/src/actions/auth/identify.ts deleted file mode 100644 index 52f7b2b..0000000 --- a/src/actions/auth/identify.ts +++ /dev/null @@ -1,22 +0,0 @@ -'use server' -import {callByUser, callPublic} from '@/actions/base' - -export async function Identify(props: { - type: number - name: string - iden_no: string -}) { - return await callByUser<{ - identified: boolean - target: string - }>('/api/user/identify', props) -} - -export async function IdentifyCallback(props: { - id: string -}) { - return await callPublic<{ - success: boolean - message: string - }>('/api/user/identify/callback', props) -} diff --git a/src/actions/base.ts b/src/actions/base.ts index d03bcfe..8b408d4 100644 --- a/src/actions/base.ts +++ b/src/actions/base.ts @@ -1,184 +1,13 @@ 'use server' -import {API_BASE_URL, CLIENT_ID, CLIENT_SECRET, ApiResponse, UnauthorizedError} from '@/lib/api' +import {API_BASE_URL, ApiResponse, CLIENT_ID, CLIENT_SECRET} from '@/lib/api' import {cookies, headers} from 'next/headers' -import {redirect} from 'next/navigation' import {cache} from 'react' +import {redirect} from 'next/navigation' // ====================== -// region device token +// public // ====================== -async function callByDevice( - endpoint: string, - data: unknown, -): Promise> { - return _callByDevice(endpoint, data ? JSON.stringify(data) : undefined) -} - -// 通用的API调用函数 -const _callByDevice = cache(async ( - endpoint: string, - data?: string, -): Promise> => { - try { - // 获取设备令牌 - if (!CLIENT_ID || !CLIENT_SECRET) { - throw new Error('缺少CLIENT_ID或CLIENT_SECRET环境变量') - } - const token = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64url') - - // 构造请求 - const url = `${API_BASE_URL}${endpoint}` - const request = { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Basic ${token}`, - }, - body: data, - } - - // 发送请求 - const response = await fetch(`${API_BASE_URL}${endpoint}`, request) - - // 检查响应状态 - return handleResponse(response) - } - catch (e) { - throw new Error('服务调用失败', {cause: e}) - } -}) - -// endregion - -// ====================== -// region user token -// ====================== - -async function getUserToken(refresh = false): Promise { - // 从 cookie 中获取用户令牌 - const cookie = await cookies() - const userToken = cookie.get('auth_token')?.value - const userRefresh = cookie.get('auth_refresh')?.value - - // 检查缓存的令牌是否可用 - if (!refresh && userToken) { - return userToken - } - - // 如果没有刷新令牌,抛出异常 - if (!userRefresh) { - throw UnauthorizedError - } - - // 请求刷新访问令牌 - const addr = `${API_BASE_URL}/api/auth/token` - const body = { - grant_type: 'refresh_token', - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - refresh_token: userRefresh, - } - - const response = await fetch(addr, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }) - - if (!response.ok) { - console.log('刷新令牌失败', `status=${response.status}`, await response.text()) - throw UnauthorizedError - } - - // 保存新的用户令牌到 cookie - const data = await response.json() - const nextAccessToken = data.access_token - const nextRefreshToken = data.refresh_token - const expiresIn = data.expires_in - - cookie.set('auth_token', nextAccessToken, { - httpOnly: true, - sameSite: 'strict', - maxAge: Math.max(expiresIn, 0), - }) - cookie.set('auth_refresh', nextRefreshToken, { - httpOnly: true, - sameSite: 'strict', - maxAge: 7 * 24 * 3600, // 7天 - }) - - return nextAccessToken -} - -// 使用用户令牌的API调用函数 -async function callByUser( - endpoint: string, - data?: unknown, -): Promise> { - return _callByUser(endpoint, data ? JSON.stringify(data) : undefined) -} - -const _callByUser = cache(async ( - endpoint: string, - data?: string, -): Promise> => { - const header = await headers() - try { - let token = await getUserToken() - - // 构造请求 - const request = { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - } as Record, - body: data, - } - - const userIp = header.get('x-forwarded-for') - if (userIp) { - request.headers['X-Forwarded-For'] = userIp - } - - // 发送请求 - let response = await fetch(`${API_BASE_URL}${endpoint}`, request) - if (response.status === 401) { - token = await getUserToken(true) - request.headers['Authorization'] = `Bearer ${token}` - response = await fetch(`${API_BASE_URL}${endpoint}`, request) - } - - if (response.status === 401) { - throw UnauthorizedError - } - - return handleResponse(response) - } - catch (e) { - if (e === UnauthorizedError) { - const referer = header.get('referer') - let redirectUrl = '/login' - if (referer) { - const url = new URL(referer) - redirectUrl = `/login?redirect=${encodeURIComponent(url.pathname)}` - } - return redirect(redirectUrl) - } - throw new Error('服务调用失败', {cause: e}) - } -}) - -// endregion - -// ====================== -// region no token -// ====================== - -// 不需要令牌的公共API调用函数 async function callPublic( endpoint: string, data?: unknown, @@ -190,33 +19,106 @@ const _callPublic = cache(async ( endpoint: string, data?: string, ): Promise> => { - try { - const url = `${API_BASE_URL}${endpoint}` - const request: RequestInit = { + return call(`${API_BASE_URL}${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: data, - } - - const response = await fetch(url, request) - return handleResponse(response) - } - catch (e) { - throw new Error('服务调用失败', {cause: e}) - } + }, + ) }) -// endregion +// ====================== +// device +// ====================== -// 统一响应解析 -async function handleResponse(response: Response): Promise> { +async function callByDevice( + endpoint: string, + data: unknown, +): Promise> { + return _callByDevice(endpoint, data ? JSON.stringify(data) : undefined) +} + +const _callByDevice = cache(async ( + endpoint: string, + data?: string, +): Promise> => { + + // 获取设备令牌 + if (!CLIENT_ID || !CLIENT_SECRET) { + return { + success: false, + status: 401, + message: '未配置 CLIENT_ID 或 CLIENT_SECRET', + } + } + const token = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64url') + + // 发起请求 + return call(`${API_BASE_URL}${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${token}`, + }, + body: data, + }) +}) + +// ====================== +// user +// ====================== + +async function callByUser( + endpoint: string, + data?: unknown, +): Promise> { + return postCall(_callByUser(endpoint, data ? JSON.stringify(data) : undefined)) +} + +const _callByUser = cache(async ( + endpoint: string, + data?: string, +): Promise> => { + const header = await headers() + + // 获取用户令牌 + const cookie = await cookies() + const token = cookie.get('auth_token')?.value + if (!token) { + return { + success: false, + status: 401, + message: '会话已失效', + } + } + + // 发起请求 + return await call(`${API_BASE_URL}${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + 'X-Forwarded-For': header.get('x-forwarded-for') || '[web]unknown', + 'User-Agent': header.get('user-agent') || '[web]unknown', + } as Record, + body: data, + }) +}) + + +// ====================== +// call +// ====================== + +async function call(url: string, request: RequestInit): Promise> { + const response = await fetch(url, request) const type = response.headers.get('Content-Type') ?? 'text/plain' if (type.indexOf('application/json') !== -1) { const json = await response.json() if (!response.ok) { - console.log('响应不成功', `status=${response.status}`, json) + console.log('后端请求失败', url, `status=${response.status}`, json) return { success: false, status: response.status, @@ -231,7 +133,7 @@ async function handleResponse(response: Response): Promise(response: Response): Promise(rawResp: Promise>) { + const header = await headers() + const pathname = header.get('x-pathname') || '/' + const resp = await rawResp + + // 重定向到登录页 + const match = [ + RegExp(`^/admin.*`), + ].some(item => item.test(pathname)) + + if (match && !resp.success && resp.status === 401) { + redirect(pathname === '/' ? '/login' : `/login?redirect=${pathname}`) + } + + return resp +} // 导出 export { - getUserToken, + callPublic, callByDevice, callByUser, - callPublic, } diff --git a/src/actions/user.ts b/src/actions/user.ts index f10a630..81cb59b 100644 --- a/src/actions/user.ts +++ b/src/actions/user.ts @@ -1,6 +1,6 @@ 'use server' -import {callByUser} from '@/actions/base' +import {callByUser, callPublic} from '@/actions/base' export async function RechargeByAlipay(props: { amount: number @@ -31,3 +31,23 @@ export async function RechargeByWechatConfirm(props: { }) { return callByUser('/api/user/recharge/confirm/wechat', props) } + +export async function Identify(props: { + type: number + name: string + iden_no: string +}) { + return await callByUser<{ + identified: boolean + target: string + }>('/api/user/identify', props) +} + +export async function IdentifyCallback(props: { + id: string +}) { + return await callPublic<{ + success: boolean + message: string + }>('/api/user/identify/callback', props) +} diff --git a/src/actions/auth/verify.ts b/src/actions/verify.ts similarity index 81% rename from src/actions/auth/verify.ts rename to src/actions/verify.ts index bbe456e..d6e53af 100644 --- a/src/actions/auth/verify.ts +++ b/src/actions/verify.ts @@ -1,16 +1,13 @@ 'use server' +import {ApiResponse} from '@/lib/api' +import {callByDevice} from '@/actions/base' import {cookies} from 'next/headers' import crypto from 'crypto' -import {ApiResponse} from '@/lib/api' -import { callByDevice } from '@/actions/base' - -export interface VerifyParams { +export async function sendSMS(props: { phone: string - captcha: string // 添加验证码字段 -} - -export default async function verify(props: VerifyParams): Promise { + captcha: string +}): Promise { try { // 人机验证 if (!props.captcha?.length) { @@ -20,7 +17,7 @@ export default async function verify(props: VerifyParams): Promise message: '请输入验证码', } } - const valid = await verifyCaptcha(props.captcha) + const valid = await checkCaptcha(props.captcha) if (!valid) { return { success: false, @@ -41,7 +38,7 @@ export default async function verify(props: VerifyParams): Promise } } -async function verifyCaptcha(userInput: string): Promise { +export async function checkCaptcha(userInput: string): Promise { const cookieStore = await cookies() const hash = cookieStore.get('captcha_hash')?.value const salt = cookieStore.get('captcha_salt')?.value diff --git a/src/app/(api)/identify/callback/page.tsx b/src/app/(api)/identify/callback/page.tsx index b5d933b..0848c91 100644 --- a/src/app/(api)/identify/callback/page.tsx +++ b/src/app/(api)/identify/callback/page.tsx @@ -1,7 +1,7 @@ 'use client' import {Suspense, useEffect, useState} from 'react' import {useSearchParams} from 'next/navigation' -import {IdentifyCallback} from '@/actions/auth/identify' +import {IdentifyCallback} from '@/actions/user' import {Card, CardContent} from '@/components/ui/card' import {CheckCircle, AlertCircle, Loader2} from 'lucide-react' diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 039a3c4..af93267 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -19,8 +19,8 @@ import {zodResolver} from '@hookform/resolvers/zod' import {useForm} from 'react-hook-form' import zod from 'zod' import Captcha from './captcha' -import verify from '@/actions/auth/verify' -import {login} from '@/actions/auth/auth' +import {login} from '@/actions/auth' +import {sendSMS} from '@/actions/verify' import {useRouter, useSearchParams} from 'next/navigation' import {toast} from 'sonner' import {ApiResponse} from '@/lib/api' @@ -83,7 +83,7 @@ export default function LoginPage(props: LoginPageProps) { // 发送验证码 let resp: ApiResponse try { - resp = await verify({ + resp = await sendSMS({ phone: username, captcha: captchaCode, }) diff --git a/src/app/admin/(dashboard)/page.tsx b/src/app/admin/(dashboard)/page.tsx index 692255d..e8f31ec 100644 --- a/src/app/admin/(dashboard)/page.tsx +++ b/src/app/admin/(dashboard)/page.tsx @@ -3,8 +3,6 @@ import Image from 'next/image' import banner from './_assets/banner.webp' import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card' import {Button} from '@/components/ui/button' -import {getProfile} from '@/actions/auth/auth' -import {redirect} from 'next/navigation' import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs' export type DashboardPageProps = {} @@ -65,7 +63,7 @@ export default async function DashboardPage(props: DashboardPageProps) { {/* 图表 */} -
+
动态 IP 套餐 diff --git a/src/app/admin/_client/navbar.tsx b/src/app/admin/_client/navbar.tsx index a30ebe1..b349e99 100644 --- a/src/app/admin/_client/navbar.tsx +++ b/src/app/admin/_client/navbar.tsx @@ -17,7 +17,7 @@ export default function Navbar(props: NavbarProps) { ) @@ -75,10 +76,10 @@ function NavTitle(props: { `transition-[opacity] duration-150 ease-in-out absolute mx-4`, `group-data-[expand=true]:delay-[50ms] group-data-[expand=true]:opacity-100 group-data-[expand=false]:opacity-0`, )}>{props.label} -
+ )}>

) } @@ -87,7 +88,6 @@ function NavItem(props: { href: string icon?: ReactNode label: string - expand: boolean }) { return ( { + return { + title: 'Create Next App', + description: 'Generated by create next app', + } } export default async function RootLayout({ @@ -21,7 +24,8 @@ export default async function RootLayout({ children: ReactNode }>) { - const user = await getProfile() + const result = await getProfile() + const user = result.success ? result.data : null return ( diff --git a/src/app/test/route.ts b/src/app/test/route.ts new file mode 100644 index 0000000..e3a6836 --- /dev/null +++ b/src/app/test/route.ts @@ -0,0 +1,14 @@ +import {NextRequest, NextResponse} from 'next/server' + +export async function GET(req: NextRequest) { + const headers: { + [key: string]: string + } = {} + req.headers.forEach((value, key) => { + headers[key] = value + }) + return NextResponse.json({ + headers: headers, + cookies: req.cookies.getAll(), + }) +} diff --git a/src/assets/logo-avatar.svg b/src/assets/logo-avatar.svg new file mode 100644 index 0000000..d3873bd --- /dev/null +++ b/src/assets/logo-avatar.svg @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/src/components/providers/StoreProvider.tsx b/src/components/providers/StoreProvider.tsx index d7d1ec1..cc01367 100644 --- a/src/components/providers/StoreProvider.tsx +++ b/src/components/providers/StoreProvider.tsx @@ -23,13 +23,13 @@ export default function StoreProvider(props: ProfileProviderProps) { const profile = useRef>(null) if (!profile.current) { - console.log('create profile store') + console.log('📦 create profile store') profile.current = createProfileStore(props.user) } const layout = useRef>(null) if (!layout.current) { - console.log('create layout store') + console.log('📦 create layout store') layout.current = createLayoutStore() } diff --git a/src/lib/api.ts b/src/lib/api.ts index 3df7ee3..db53f0f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,5 +1,3 @@ -// API工具函数 - // 定义后端服务URL和OAuth2配置 const API_BASE_URL = process.env.API_BASE_URL const CLIENT_ID = process.env.CLIENT_ID diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..9129997 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,38 @@ +import {NextRequest, NextResponse} from 'next/server' +import {refreshAuth} from '@/actions/auth' + +export const config = { + matcher: [ + '/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*(? item.test(request.nextUrl.pathname)) + + if (match) { + try { + const accessToken = request.cookies.get('auth_token') + const refreshToken = request.cookies.get('auth_refresh') + if (!accessToken && refreshToken) { + console.log('💡 refresh token') + const token = await refreshAuth() + request.cookies.set('auth_token', token.access_token) + request.cookies.set('auth_refresh', token.refresh_token) + } + } + catch (error) { + return NextResponse.redirect(`${request.nextUrl.origin}/login?redirect=${request.nextUrl.pathname}`) + } + } + + return NextResponse.next({request}) +} diff --git a/src/stores/profile.ts b/src/stores/profile.ts index 2e5d033..8ca289c 100644 --- a/src/stores/profile.ts +++ b/src/stores/profile.ts @@ -1,6 +1,6 @@ import {User} from '@/lib/models' import {createStore} from 'zustand/vanilla' -import {getProfile} from '@/actions/auth/auth' +import {getProfile} from '@/actions/auth' export type ProfileStore = ProfileState & ProfileActions @@ -18,7 +18,8 @@ export const createProfileStore = (init: User|null) => { profile: init, refreshProfile: async () => { const profile = await getProfile() - setState({profile}) + if (!profile.success) return + setState({profile: profile.data}) }, })) }