feat(project): init

This commit is contained in:
2026-02-03 18:05:47 +08:00
commit f37a119eff
188 changed files with 29246 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
.list {
position: relative;
margin-bottom: var(--mantine-spacing-md);
}
.indicator {
/* background-color: var(--mantine-color-white); */
/* border-radius: var(--mantine-radius-md); */
border-bottom: 2px solid var(--mantine-color-blue-6);
top: 2px;
/* box-shadow: var(--mantine-shadow-sm); */
@mixin dark {
/* background-color: var(--mantine-color-dark-6); */
border-color: var(--mantine-color-blue-4);
}
}
.tab {
z-index: 1;
font-weight: 500;
transition: color 100ms ease;
color: var(--mantine-color-gray-7);
&[data-active] {
color: var(--mantine-color-black);
}
@mixin dark {
color: var(--mantine-color-dark-1);
&[data-active] {
color: var(--mantine-color-white);
}
}
}

View File

@@ -0,0 +1,61 @@
import httpFetch from "@/api/fetch"
import { ResultData } from "@/api/typing"
import { Login } from "./types"
// 获取验证码
export const getVerifyCode = (params: { phone: string }) => {
return httpFetch<ResultData<{}>>({
url: `/api/v1/front/login/get_verify_code`,
method: "POST",
body: JSON.stringify(params),
})
}
// 账户登录
export const passwdLogin = (params: Login.ReqPasswdLogin) => {
return httpFetch<ResultData<Login.ResLogin>>({
url: `/api/v1/front/login/passwd`,
method: "POST",
body: JSON.stringify(params),
isLogin: true,
})
}
// 验证码登录
export const verificationLogin = (params: Login.ReqVerificationLogin) => {
return httpFetch<ResultData<Login.ResLogin>>({
url: `/api/v1/front/login/verification`,
method: "POST",
body: JSON.stringify(params),
isLogin: true,
})
}
// 飞书登录
export const fetchFeishuLogin = (params: Login.ReqFeishuLogin) => {
return httpFetch<ResultData<Login.ResLogin>>({
url: `/api/v1/front/login/feishu`,
method: "POST",
body: JSON.stringify(params),
isLogin: true,
})
}
// 刷新token
export const refreshKey = (params: { token: string }) => {
return httpFetch<ResultData<Login.ResRefreshKey>>({
url: `/api/v1/front/key/refresh`,
method: "POST",
body: JSON.stringify(params),
isRefresh: true,
})
}
// 删除cookie
export const deleteCookie = () => {
return httpFetch<ResultData<string>>({
url: `/api/session`,
method: "DELETE",
isDeleteCookie: true,
})
}

View File

@@ -0,0 +1,65 @@
export namespace Login {
export interface ReqPasswdLogin {
account?: string
phone?: string
password: string
tenant: string
platform: string
app: string
fingerprint: string
}
export interface ReqVerificationLogin {
phone: string
verify_code: string
tenant: string
platform: string
app: string
fingerprint: string
}
export interface ReqFeishuLogin {
code: string
redirect_uri: string
tenant: string
platform: string
app: string
app_id: string
app_secret: string
fingerprint: string
}
export interface RbacDataItem {
app: string
cat: string
platform: string
tenant: string
permission: {
[x: string]: {
[y: string]: [boolean, any, string]
}
}
}
export interface ResLogin {
access_token: string
cats: Array<{
cat: string
cat_id: number
}>
is_super: boolean
tenant: string
user_info: {
[x: string]: string
}
rbac: {
data: RbacDataItem[]
}
refresh_token: string
}
export interface ResRefreshKey {
access: string
refresh: string
}
}

View File

@@ -0,0 +1,102 @@
import { useCallback, useEffect } from "react"
import { DigitalIcon } from "../resource/icons/digital"
import { useLoginStore } from "@/components/login/store"
import { useRouter } from "next/navigation"
import { APP, PLATFORM, TENANT } from "@/components/login/libs/common"
import { APP_ID, APP_SECRECT } from "./util"
import { fetchFeishuLogin } from "../api"
const HeaderIcon = DigitalIcon
const FeishuAutoLogin = ({ useUserStore }: { useUserStore: any }) => {
const fingerprint = useLoginStore.getState().fingerprint
const list = window.location.href.split("/")
const http_header =
window.location.protocol.toLowerCase() === "https:" ? "https" : "http"
const redirect_url =
http_header + "://" + list[2] + `${process.env.NEXT_PUBLIC_BASE_PATH}/login`
const { setUserInfo, refreshToken } = useUserStore()
const router = useRouter()
const autoLogin = useCallback(
(
fingerprint: any,
redirect_url: any,
router: any,
setUserInfo: (arg0: any, arg1: any, arg2: any) => void
) => {
if (!window.h5sdk || !window.tt) {
console.error("Feishu H5 SDK is not available")
setTimeout(() => {
autoLogin(fingerprint, redirect_url, router, setUserInfo)
}, 500)
return
}
// 确保每次进到这个页面是未登录,然后触发自动登录逻辑
window.h5sdk.ready(async () => {
console.log("Feishu H5 SDK is ready, requesting auth code")
window.tt.requestAuthCode({
appId: APP_ID,
success: async (res: any) => {
try {
const params = {
code: res.code,
redirect_uri: redirect_url,
tenant: TENANT,
platform: PLATFORM,
app: APP,
fingerprint,
app_id: APP_ID,
app_secret: APP_SECRECT,
}
const response = await fetchFeishuLogin(params)
const info = response.message
setUserInfo(info.tenant, info.user_info, info.rbac)
refreshToken({
access_token: info.access_token || "",
refresh_token: info.refresh_token || "",
})
window.location.href = "/"
} catch (err) {
console.log(err)
}
},
fail: (err: any) => {
console.error("Failed to get auth code from Feishu:", err)
alert(JSON.stringify(err))
},
})
})
},
[refreshToken]
)
const isFeishu = useCallback(() => !!(window.h5sdk && window.tt), [])
useEffect(() => {
console.log("Feishu auto login effect triggered")
autoLogin(fingerprint, redirect_url, router, setUserInfo)
}, [fingerprint, redirect_url, router, setUserInfo, autoLogin, isFeishu])
return (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
gap: 20,
}}>
<HeaderIcon />
<span>...</span>
</div>
)
}
export default FeishuAutoLogin

View File

@@ -0,0 +1,93 @@
import { useCallback, useEffect, useState } from "react"
import { useSearchParams } from "next/navigation"
import { Flex } from "@mantine/core"
import { useLoginStore } from "@/components/login/store"
import { APP_ID, APP_SECRECT, qr_load, qr_login } from "./util"
import {
APP,
PLATFORM,
TENANT,
getQueryVariable,
} from "@/components/login/libs/common"
import { fetchFeishuLogin } from "../api"
const QrLogin = ({ useUserStore }: { useUserStore: any }) => {
const fingerprint = useLoginStore.getState().fingerprint
const list = window.location.href.split("/")
const http_header =
window.location.protocol.toLowerCase() === "https:" ? "https" : "http"
const redirect_url =
http_header + "://" + list[2] + `${process.env.NEXT_PUBLIC_BASE_PATH}/login`
const searchParams = useSearchParams()
const renderCode = useCallback(async () => {
await qr_load()
qr_login(
APP_ID,
redirect_url,
getQueryVariable("app_id") || searchParams.get("app_id") || ""
)
}, [redirect_url, searchParams])
const [times, setTimes] = useState(0)
const callbackUrl = useCallback(() => {
times !== 2 && setTimes(times + 1)
}, [times])
const { setUserInfo, refreshToken } = useUserStore()
const feishuLogin = useCallback(async () => {
try {
const params = {
code: getQueryVariable("code") || "",
redirect_uri: redirect_url,
tenant: TENANT,
platform: PLATFORM,
app: APP,
fingerprint,
app_id: APP_ID,
app_secret: APP_SECRECT,
}
const res = await fetchFeishuLogin(params)
const info = res.message
setUserInfo(info.tenant, info.user_info, info.rbac)
refreshToken({
access_token: info.access_token || "",
refresh_token: info.refresh_token || "",
})
window.location.href = "/"
} catch (err) {
console.log(err)
}
}, [fingerprint, redirect_url, refreshToken, setUserInfo])
useEffect(() => {
if (times === 1) {
renderCode()
if (getQueryVariable("code")) {
fingerprint && feishuLogin()
}
}
}, [feishuLogin, fingerprint, renderCode, times])
useEffect(() => {
callbackUrl()
}, [callbackUrl])
return (
<Flex align="center" justify="center" w={"100%"} h={"100%"}>
<div
id={"login_container"}
style={{
width: "200px",
height: "210px",
display: "flex",
justifyContent: "center",
scale: 0.7,
}}></div>
</Flex>
)
}
export default QrLogin

8
components/login/feishu/type.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
interface Window {
tt: any
h5sdk: any
}
interface window {
[key: string]: any
}

View File

@@ -0,0 +1,53 @@
// 飞书脚本
export function qr_load() {
return new Promise<void>((resolve, reject) => {
const script = document.createElement("script")
script.type = "text/javascript"
script.src =
"https://sf3-cn.feishucdn.com/obj/feishu-static/lark/passport/qrcode/LarkSSOSDKWebQRCode-1.0.2.js"
script.onerror = reject
script.onload = () => {
resolve()
}
document.head.appendChild(script)
})
}
export async function qr_login(
appId: string,
redirect_url: string,
state: string
) {
const gotoUrl = `https://passport.feishu.cn/suite/passport/oauth/authorize?client_id=${appId}&redirect_uri=${encodeURIComponent(
redirect_url
)}&response_type=code&state=${encodeURIComponent(state)}`
const QRLogin = (<any>window).QRLogin
const QRLoginObj = QRLogin({
// eslint-disable-next-line prettier/prettier
id: "login_container",
goto: gotoUrl,
style: `width: 270px;height: 270px;border: 0;border-radius:12px;background-color: #E4F2FF;
background-image:
radial-gradient(at 47% 33%, hsl(212.37, 72%, 59%) 0, transparent 59%),
radial-gradient(at 82% 65%, hsl(198.00, 100%, 50%) 0, transparent 55%);`,
})
const handleMessage = function (event: any) {
const origin = event.origin
if (
QRLoginObj.matchOrigin(origin) &&
window.location.href.indexOf("login") > -1
) {
const loginTmpCode = event.data
window.location.href = `${gotoUrl}&tmp_code=${loginTmpCode}`
}
}
if (typeof window.addEventListener !== "undefined") {
window.addEventListener("message", handleMessage, false)
} else if (typeof (<any>window).attachEvent !== "undefined") {
// eslint-disable-next-line prettier/prettier
;(<any>window).attachEvent("onmessage", handleMessage)
}
}
export const APP_ID = "cli_a9a902d6c3b95ceb"
export const APP_SECRECT = "zVbsYlJa3nzK5DwabshHFgcWQdnp4HLu"

505
components/login/index.tsx Normal file
View File

@@ -0,0 +1,505 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import {
Box,
TextInput,
PasswordInput,
Paper,
Text,
Group,
Button,
LoadingOverlay,
Stack,
Modal,
Flex,
BackgroundImage,
Tabs,
FloatingIndicator,
Input,
useComputedColorScheme,
} from "@mantine/core"
import { useForm } from "@mantine/form"
import { useDisclosure } from "@mantine/hooks"
import { DigitalIcon } from "./resource/icons/digital"
import tabClasses from "./Tab.module.css"
import {
getQueryVariable,
validatePhoneNumber,
} from "@/components/login/libs/common"
import { getVerifyCode, passwdLogin, verificationLogin } from "./api"
import QrLogin from "./feishu/qr-login"
import { APP, PLATFORM, TENANT } from "./libs/common"
import FingerprintJS from "@fingerprintjs/fingerprintjs"
import { useLoginStore } from "./store"
// import { useSearchParams } from "next/navigation"
import Feishu from "./feishu/auto-login"
import LightBgImage from "./resource/images/light-login-bg.png"
import DarkBgImage from "./resource/images/dark-login-bg.png"
import LogoDigImage from "./resource/images/logo-dig.png"
import loginBg2Light from "./resource/images/login-bg2-light.png"
import loginBg2Dark from "./resource/images/login-bg2-dark.png"
const HeaderIcon = DigitalIcon
const headerTitle = "管理系统平台"
export default function LoginPage({ useUserStore }: { useUserStore: any }) {
const colorScheme = useComputedColorScheme()
// const params = useSearchParams().get("auto")
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isClient, setIsClient] = useState(false)
useEffect(() => {
setIsClient(true)
}, [])
// 创建 Mantine Form 实例
const accountForm = useForm({
// 初始值
initialValues: {
username: "", // 账户名
password: "", // 密码
},
// 验证规则 - 基于 rules 对象
validate: {
username: (value) => {
if (!value) {
return "账户名不能为空"
}
if (value.length < 1) {
return "账户名至少1个字符"
}
// if (value.length > 20) {
// return "账户名不能超过20个字符"
// }
// if (!/^[a-zA-Z0-9_]+$/.test(value)) {
// return "账户名只能包含字母、数字和下划线"
// }
return null
},
password: (value) => {
if (!value) {
return "密码不能为空"
}
// if (value.length < 6) {
// return "密码至少6个字符"
// }
// if (value.length > 30) {
// return "密码不能超过30个字符"
// }
// if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value)) {
// return "密码需包含大小写字母和数字"
// }
return null
},
},
// 验证触发时机
validateInputOnChange: true, // 输入时验证
validateInputOnBlur: true, // 失焦时验证
clearInputErrorOnChange: true, // 输入时清除错误
})
const phoneForm = useForm({
// 初始值
initialValues: {
phone: "", // 手机号
code: "", // 验证码
},
// 验证规则 - 基于 rules 对象
validate: {
phone: (value) => {
if (!value) {
return "手机号不能为空"
}
if (value.length !== 11) {
return "手机号必须为11位"
}
return null
},
code: (value) => {
if (!value) {
return "验证码不能为空"
}
if (value.length !== 6) {
return "验证码必须为6位"
}
return null
},
},
// 验证触发时机
validateInputOnChange: true, // 输入时验证
validateInputOnBlur: true, // 失焦时验证
clearInputErrorOnChange: true, // 输入时清除错误
})
const [opened, { open, close }] = useDisclosure(false)
const [activeTab, setActiveTab] = useState<string>("account")
// const [fingerprint, setFingerprint] = useState("")
const { setFingerprint } = useLoginStore()
const fingerprint = useLoginStore.getState().fingerprint
const fn = useCallback(async () => {
const fpPromise = await FingerprintJS.load()
fpPromise.get().then((result) => {
const visitorId = result.visitorId
setFingerprint(visitorId)
})
}, [setFingerprint])
useEffect(() => {
fn()
}, [fn])
useEffect(() => {
if (getQueryVariable("code")) {
setActiveTab("feishu")
}
}, [])
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null)
const [controlsRefs, setControlsRefs] = useState<
Record<string, HTMLButtonElement | null>
>({})
const setControlRef = (val: string) => (node: HTMLButtonElement) => {
controlsRefs[val] = node
setControlsRefs(controlsRefs)
}
const { setUserInfo, refreshToken } = useUserStore()
// 表单提交处理
const handleAccountSubmit = accountForm.onSubmit(
async (values) => {
// 验证成功时的处理
setLoading(true)
setError(null)
try {
let isAdmin = values.username === "admin"
let phone = values.username.startsWith("+86")
? values.username
: `+86${values.username}`
const params = {
account: isAdmin ? values.username : phone,
password: values.password,
tenant: TENANT,
platform: PLATFORM,
app: APP,
fingerprint,
}
try {
const res = await passwdLogin(params)
const info = res.message
setUserInfo(info.tenant, info.user_info, info.rbac)
refreshToken({
access_token: info.access_token || "",
refresh_token: info.refresh_token || "",
})
window.location.href = "/"
} catch (error) {
if (error) {
const message =
error instanceof Error ? error.message : "登录失败,请重试"
setError(message)
open() // 显示错误弹窗
return
}
}
} catch (err) {
const message = err instanceof Error ? err.message : "登录失败,请重试"
setError(message)
open() // 显示错误弹窗
} finally {
setLoading(false)
}
},
(errors) => {
console.log("验证错误:", errors)
}
)
const [codeTime, setCodeTime] = useState<number>(0)
const handleCountDown = () => {
setCodeTime(60)
let clean = setInterval(() => {
setCodeTime((time) => {
if (time === 0) {
clearInterval(clean)
return 0
}
return time - 1
})
}, 1000)
}
const handleGetCode = async () => {
if (!validatePhoneNumber(phoneForm.values.phone)) {
setError("请输入正确的手机号")
open() // 显示错误弹窗
return
} else {
try {
handleCountDown()
getVerifyCode({
phone: phoneForm.values.phone.startsWith("+86")
? phoneForm.values.phone
: `+86${phoneForm.values.phone}`,
}).then((res) => {
if (res?.code === 200) {
setError("验证码已发送")
open() // 显示错误弹窗
} else {
typeof res?.message === "string" &&
setError(res?.message || "验证码发送失败")
open() // 显示错误弹窗
}
})
} catch (error) {
const message =
error instanceof Error ? error.message : "获取验证码失败,请重试"
setError(message)
open() // 显示错误弹窗
}
}
}
// 手机号登录表单提交处理
const handlePhoneSubmit = phoneForm.onSubmit(
async (values) => {
// 验证成功时的处理
setLoading(true)
setError(null)
try {
const params = {
phone: values.phone.startsWith("+86")
? values.phone
: `+86${values.phone}`,
verify_code: values.code,
tenant: TENANT,
platform: PLATFORM,
app: APP,
fingerprint,
}
const res = await verificationLogin(params)
const info = res.message
setUserInfo(info.tenant, info.user_info, info.rbac)
refreshToken({
access_token: info.access_token || "",
refresh_token: info.refresh_token || "",
})
window.location.href = "/"
} catch (err) {
const message = err instanceof Error ? err.message : "登录失败,请重试"
setError(message)
open() // 显示错误弹窗
} finally {
setLoading(false)
}
},
(errors) => {
console.log("验证错误:", errors)
}
)
const bgImage = useMemo(
() => (colorScheme !== "dark" ? LightBgImage : DarkBgImage),
[colorScheme]
)
const stackBg2Image = useMemo(
() => (colorScheme === "dark" ? loginBg2Dark : loginBg2Light),
[colorScheme]
)
const isFeishu = useMemo(() => {
if (typeof window !== "undefined") return !!(window.h5sdk && window.tt)
else return false
}, [])
return (
<>
<Modal opened={opened} onClose={close} title="消息提示">
<Flex gap={12} align="center">
<Text size="sm">{error}</Text>
</Flex>
<Group mt="md">
<Button variant="outline" size="sm" onClick={close}>
</Button>
</Group>
</Modal>
<Stack align="center" justify="center" h={"100vh"} w="100%">
{isClient && (
<BackgroundImage
src={bgImage.src}
w="100%"
h="100%"
style={{ position: "absolute", top: 0, left: 0, zIndex: -1 }}
/>
)}
<Paper
withBorder
shadow="md"
radius="md"
h={520}
maw={"100%"}
style={{ borderColor: "rgba(255, 255, 255, 0.5)" }}>
<Flex w="100%" h={"100%"}>
<Stack
w={600}
p={32}
visibleFrom="md"
style={{ position: "relative" }}>
<Box
w="100%"
h="100%"
style={{ position: "absolute", top: 0, left: 0 }}>
{isClient && (
<BackgroundImage src={stackBg2Image.src} w="100%" h="100%" />
)}
</Box>
<Flex
justify="center"
align="center"
h="100%"
style={{ zIndex: 11 }}>
<BackgroundImage
src={LogoDigImage.src}
radius="xs"
w={432}
h={310}
visibleFrom="md"></BackgroundImage>
</Flex>
</Stack>
<Stack h={"100%"} w={380} p={"54px 32px"} gap={0}>
<Flex mb={"2rem"} gap={8} justify="center" align={"center"}>
<HeaderIcon />
<Text size="xxl">{headerTitle}</Text>
</Flex>
<Tabs
variant="none"
value={activeTab}
onChange={(value) => setActiveTab(value || "")}>
<Tabs.List ref={setRootRef} className={tabClasses.list}>
<Tabs.Tab
value="account"
ref={setControlRef("account")}
className={tabClasses.tab}>
</Tabs.Tab>
<Tabs.Tab
value="phone"
ref={setControlRef("phone")}
className={""}>
</Tabs.Tab>
<Tabs.Tab
value="feishu"
ref={setControlRef("feishu")}
className={tabClasses.tab}>
</Tabs.Tab>
<FloatingIndicator
target={activeTab ? controlsRefs[activeTab] : null}
parent={rootRef}
className={tabClasses.indicator}
/>
</Tabs.List>
<Tabs.Panel value="account">
<form onSubmit={handleAccountSubmit}>
<LoadingOverlay visible={loading} />
{/* 账户名输入 */}
<TextInput
withAsterisk
label="账户名"
placeholder="请输入账户名"
{...accountForm.getInputProps("username")}
/>
{/* 密码输入 */}
<PasswordInput
withAsterisk
label="密码"
placeholder="请输入密码"
mt="md"
{...accountForm.getInputProps("password")}
/>
{/* 登录按钮 */}
<Button type="submit" fullWidth mt="xl" disabled={loading}>
{loading ? "登录中..." : "登录"}
</Button>
</form>
</Tabs.Panel>
<Tabs.Panel value="phone">
<form onSubmit={handlePhoneSubmit}>
<LoadingOverlay visible={loading} />
{/* 手机号输入 */}
<TextInput
withAsterisk
label="手机号"
placeholder="请输入手机号"
{...phoneForm.getInputProps("phone")}
/>
{/* 验证码输入 */}
<Input.Wrapper
label="验证码"
withAsterisk
mt="md"
error={phoneForm.errors.code}>
<Flex justify={"space-between"} align={"end"}>
<Input
placeholder="请输入验证码"
{...phoneForm.getInputProps("code")}
/>
<Button
type="button"
disabled={
!validatePhoneNumber(phoneForm.values.phone) ||
!!codeTime
}
onClick={handleGetCode}>
{codeTime !== 0
? `${codeTime}秒后获取`
: "获取验证码"}
</Button>
</Flex>
</Input.Wrapper>
{/* 登录按钮 */}
<Button type="submit" fullWidth mt="xl" disabled={loading}>
{loading ? "登录中..." : "登录"}
</Button>
</form>
</Tabs.Panel>
<Tabs.Panel value="feishu">
{isClient && <QrLogin useUserStore={useUserStore}></QrLogin>}
</Tabs.Panel>
</Tabs>
</Stack>
</Flex>
</Paper>
</Stack>
{isFeishu && (
<Stack
h={"100vh"}
w={"100vw"}
bg="white"
style={{ position: "absolute", top: 0, zIndex: 99999 }}>
<Feishu useUserStore={useUserStore} />
</Stack>
)}
</>
)
}

View File

@@ -0,0 +1,21 @@
export const validatePhoneNumber = (str: string) => {
const reg = /^[1][3,4,5,6,7,8,9][0-9]{9}$/
return reg.test(str)
}
// 获取url中的某个参数值
export const getQueryVariable = (variable: string) => {
const query = window.location.search.substring(1)
const vars = query.split("&")
for (let i = 0; i < vars.length; i++) {
const pair = vars[i].split("=")
if (pair[0] === variable) {
return pair[1]
}
}
return false
}
export const APP = "basis"
export const PLATFORM = "basis"
export const TENANT = "cowarobot"

View File

@@ -0,0 +1,93 @@
import { ResultData } from "@/api/typing"
import redis from "@/components/login/libs/redis"
import { encrypt, generateKeyFromEnv } from "@/components/login/libs/session"
import { cookies } from "next/headers"
import { Login } from "@/components/login/api/types"
export const handleLoginData = async (props: {
clientIP: string
fingerprint: string
data: ResultData<Login.ResLogin>
}) => {
const { clientIP, fingerprint, data } = props
const sessionKey = {
clientIP,
fingerprint: fingerprint,
}
const cookieStore = await cookies()
const oldSession = cookieStore.get("session")?.value || ""
if (oldSession) {
redis.del(JSON.parse(oldSession) as any)
}
const key = await generateKeyFromEnv(process.env.ENCRYPTION_KEY || "")
const encryptedSessionData = await encrypt(JSON.stringify(sessionKey), key)
// save redis
await redis.set(
encryptedSessionData,
JSON.stringify({
clientIP,
fingerprint: fingerprint,
user_name: data?.message?.user_info?.user_name,
access_token: data.message.access_token,
refresh_token: data.message.refresh_token,
}),
"EX",
172800
)
// set cookie
cookieStore.set("session", JSON.stringify(encryptedSessionData), {
httpOnly: true,
secure: process.env.NEXT_PUBLIC_COOKIE_ENV === "production",
maxAge: 60 * 60 * 24 * 30, // 30天
path: "/",
})
return {
...data,
message: {
...data.message,
access_token: "",
refresh_token: "",
},
}
}
export const handleRefreshToken = async (props: {
data: ResultData<Login.ResRefreshKey>
userData: any
}) => {
const { userData, data } = props
const { redisKey, ...rest } = userData
await redis.set(
JSON.parse(redisKey),
JSON.stringify({
...rest,
access_token: data?.message?.access,
refresh_token: data?.message?.refresh,
}),
"EX",
172800
)
return {
...data,
message: {
...data.message,
access_token: "",
refresh_token: "",
},
}
}
export const handleDeleteCookie = async () => {
const cookieStore = await cookies()
const sessionData = cookieStore.get("session")?.value || ""
// 删cookie和redis中用户
if (sessionData) {
await redis.del(JSON.parse(sessionData) as any)
cookieStore.delete("session")
}
}

View File

@@ -0,0 +1,23 @@
import Redis from "ioredis"
const redisConfig = {
port: process.env.NEXT_PUBLIC_REDIS_PORT
? Number(process.env.NEXT_PUBLIC_REDIS_PORT)
: 6379, // Redis port
host: process.env.NEXT_PUBLIC_REDIS_URL || "", // Redis host
username: process.env.NEXT_PUBLIC_REDIS_USERNAME || "", // needs Redis >= 6
password: process.env.NEXT_PUBLIC_REDIS_PASSWORD || "",
db: process.env.NEXT_PUBLIC_REDIS_DB
? Number(process.env.NEXT_PUBLIC_REDIS_DB)
: 0, // Defaults to 0
}
const redis = new Redis({
port: redisConfig.port, // Redis port
host: redisConfig.host, // Redis host
username: redisConfig.username, // needs Redis >= 6
password: redisConfig.password,
db: redisConfig.db, // Defaults to 0
})
export default redis

View File

@@ -0,0 +1,156 @@
"use server"
import { Base64 } from "js-base64"
import { headers } from "next/headers"
import redis from "./redis"
// 生成加密密钥
export async function generateKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey(
{
name: "AES-CBC",
length: 256,
},
true,
["encrypt", "decrypt"]
)
}
export async function generateKeyFromEnv(
envPassword: string
): Promise<CryptoKey> {
const encoder = new TextEncoder()
const password = encoder.encode(envPassword)
const keyMaterial = await crypto.subtle.importKey(
"raw",
password,
{
name: "PBKDF2",
},
false,
["deriveKey"]
)
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: new Uint8Array(16), // 随机盐值
iterations: 100000, // 迭代次数
hash: "SHA-256",
},
keyMaterial,
{
name: "AES-CBC",
length: 256,
},
true,
["encrypt", "decrypt"]
)
}
// 将ArrayBuffer转换为Uint8Array
function arrayBufferToUint8Array(buffer: ArrayBuffer) {
return new Uint8Array(buffer)
}
// 加密函数
export async function encrypt(data: any, key: CryptoKey): Promise<string> {
const encodedData = new TextEncoder().encode(JSON.stringify(data))
const iv = crypto.getRandomValues(new Uint8Array(16)) // 16 bytes for AES block size
const encrypted = await crypto.subtle.encrypt(
{
name: "AES-CBC",
iv: iv,
},
key,
encodedData
)
// 将Uint8Array转换为Base64字符串以便存储和传输
const encryptedBase64 = Base64.fromUint8Array(
arrayBufferToUint8Array(encrypted)
)
const ivBase64 = Base64.fromUint8Array(new Uint8Array(iv)) // 使用js-base64进行编码
// 将IV和加密数据拼接在一起以便解密时使用
return `${ivBase64}::${encryptedBase64}`
}
// 解密函数
export async function decrypt(
encryptedData: string,
key: CryptoKey
): Promise<any> {
// 将加密数据从Base64字符串转换回Uint8Array
const [ivBase64, encryptedBase64] = encryptedData.split("::")
const ivArray = Buffer.from(ivBase64, "base64")
const encrypted = Buffer.from(encryptedBase64, "base64")
// 使用AES-CBC算法进行解密
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-CBC",
iv: ivArray,
},
key,
encrypted
)
// 将ArrayBuffer转换为字符串
return new TextDecoder().decode(decrypted)
}
// 校验用户信息
export async function genAccessToken(req: any) {
try {
// 校验用户信息
// const fingerprint = req.headers.get("Fingerprint")
const sessionData = req.cookies.get("session")?.value
// 无session 直接重定向到登录页面
if (!sessionData) {
return "客户端请求中,未包含用户标识"
}
let redisKey = JSON.parse(sessionData)
const key = await generateKeyFromEnv(process.env.ENCRYPTION_KEY || "")
const decryptedSessionData = JSON.parse(await decrypt(redisKey, key))
const fingerprint = JSON.parse(decryptedSessionData)?.fingerprint
// 获取用户信息
let userData: any = await redis.get(redisKey)
userData = JSON.parse(userData)
// redis中无用户信息 直接重定向到登录页面
if (!userData) {
return "未获取到用户信息"
}
const header = headers()
let clientIP =
(await header).get("x-forwarded-for")?.split(",")[0] ||
req?.socket?.remoteAddress
// ip v6地址
if (clientIP.startsWith("::")) {
clientIP = clientIP.slice(7)
}
// 登录ip与用户请求ip不匹配
if (
clientIP !== userData?.clientIP ||
fingerprint !== userData?.fingerprint
) {
return `客户端信息与缓存用户信息不一致 \n 客户端: ${clientIP}, ${fingerprint} | redis存储: ${userData?.clientIP}, ${userData?.fingerprint}`
}
return {
...userData,
redisKey: sessionData,
}
} catch (err: any) {
return `获取用户信息报错, 报错原因:${err?.message}`
}
}

View File

@@ -0,0 +1,84 @@
export const DigitalIcon = () => {
return (
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="6.4" fill="#1F65FF" />
<path
d="M18.8274 10.2358C17.1727 9.51456 15.2092 9.56087 13.526 10.5327C11.533 11.6833 10.4672 13.8231 10.5906 15.9739C9.43935 15.5275 8.1034 15.5877 6.95081 16.2531C6.41165 16.5644 5.96713 16.9766 5.62734 17.4522C4.91523 13.1111 6.89492 8.58834 10.9301 6.25862C14.0914 4.43344 17.7876 4.37384 20.8768 5.77385C21.2809 5.98109 21.6159 6.32477 21.8599 6.74734C22.5348 7.91635 22.1343 9.41116 20.9653 10.0861C20.2877 10.4772 19.5008 10.5072 18.8275 10.2356L18.8274 10.2358Z"
fill="white"
/>
<path
d="M27.1744 17.0864C26.7514 20.333 24.8747 23.3531 21.8203 25.1165C17.7851 27.4463 12.8784 26.8994 9.47497 24.1121C10.0567 24.0556 10.636 23.8768 11.1751 23.5655C12.3277 22.9 13.0478 21.7732 13.2368 20.5529C15.0378 21.7352 17.4238 21.8821 19.4168 20.7314C21.0984 19.7605 22.12 18.0854 22.324 16.2936C22.4243 15.5728 22.8441 14.9041 23.5231 14.5121C24.6921 13.8372 26.1869 14.2377 26.8619 15.4067C27.1674 15.9358 27.2528 16.4847 27.1744 17.0864Z"
fill="white"
/>
<path
d="M7.71583 17.3525C8.60142 16.8412 9.64534 16.852 10.4907 17.2896C11.1047 17.6075 11.6765 17.627 12.181 17.5259C12.3868 17.4776 12.5799 17.4028 12.7535 17.3026C13.3325 16.9683 13.7899 16.4562 13.9352 15.5682C13.9365 15.5428 13.9373 15.5167 13.9394 15.4914C14.0025 14.7188 14.4317 13.9883 15.1537 13.5713C16.3227 12.8966 17.817 13.2976 18.4919 14.4665C19.1666 15.6354 18.7668 17.1301 17.5981 17.805C16.8759 18.2218 16.0287 18.2283 15.328 17.8966C15.3057 17.8861 15.2842 17.8738 15.2621 17.8625C14.4195 17.5433 13.7469 17.6831 13.1674 18.0176C12.9954 18.1169 12.8343 18.2445 12.6905 18.3967C12.4263 18.6954 12.2051 19.0654 12.1036 19.5384C12.0736 19.6801 12.0535 19.8289 12.0464 19.9841C12.0437 20.0434 12.0389 20.1025 12.0325 20.1613C11.9371 21.0467 11.4348 21.8764 10.6045 22.3558L10.6032 22.3555L10.6037 22.3563C9.22241 23.1534 7.45586 22.6807 6.65814 21.2998C6.5878 21.178 6.53153 21.0511 6.48071 20.9242C6.43658 20.8141 6.39903 20.7031 6.36924 20.5905C6.2986 20.323 6.26795 20.0511 6.27515 19.781C6.27878 19.6429 6.28994 19.5056 6.31305 19.3701C6.34384 19.19 6.39063 19.013 6.4546 18.8418C6.61633 18.4088 6.88218 18.0125 7.24107 17.6943C7.31295 17.6306 7.38849 17.5698 7.46774 17.5127C7.5468 17.4557 7.62961 17.4023 7.71583 17.3525Z"
fill="url(#paint0_linear_3567_2455)"
/>
<mask
id="mask0_3567_2455"
maskUnits="userSpaceOnUse"
x="5"
y="4"
width="23"
height="23">
<path
d="M27.1711 17.0865C26.7481 20.3332 24.8714 23.3532 21.817 25.1167C17.7818 27.4464 12.8751 26.8995 9.47166 24.1122C10.0534 24.0557 10.6327 23.8769 11.1718 23.5656C12.3244 22.9002 13.0445 21.7733 13.2335 20.553C15.0344 21.7353 17.4205 21.8822 19.4135 20.7315C21.0951 19.7606 22.1167 18.0855 22.3207 16.2937C22.421 15.573 22.8408 14.9042 23.5198 14.5122C24.6888 13.8373 26.1836 14.2378 26.8586 15.4068C27.164 15.9359 27.2778 16.5301 27.1711 17.0865Z"
fill="white"
/>
<path
d="M18.8267 10.2362C17.1721 9.51488 15.2085 9.56119 13.5253 10.533C11.5324 11.6836 10.4665 13.8234 10.5899 15.9743C9.43868 15.5278 8.10273 15.588 6.95014 16.2534C6.41098 16.5647 5.96646 16.977 5.62667 17.4525C4.91456 13.1114 6.89424 8.58866 10.9294 6.25894C14.0907 4.43376 17.7869 4.37416 20.8761 5.77417L20.86 5.79569C21.2641 6.00294 21.6152 6.32509 21.8592 6.74766C22.5341 7.91667 22.1336 9.41148 20.9646 10.0864C20.2871 10.4776 19.5001 10.5075 18.8268 10.236L18.8267 10.2362Z"
fill="white"
/>
</mask>
<g mask="url(#mask0_3567_2455)">
<path
d="M13.235 20.5391C15.2733 22.042 20.2924 22.2388 22.0858 17.4017C21.7921 19.8898 20.1903 22.5922 17.7697 23.9897C15.0334 25.5695 11.9266 25.5141 9.46684 24.1086C11.4352 23.8134 12.813 22.6325 13.235 20.5391Z"
fill="url(#paint1_radial_3567_2455)"
fillOpacity="0.45"
/>
<path
d="M17.871 9.91452C14.523 9.11737 10.5125 11.4597 10.593 15.9775C8.61077 15.3494 6.8336 15.9721 5.6281 17.4562C5.72509 14.6902 7.29061 11.9514 9.93637 10.4239C12.2419 9.09279 15.6358 9.07102 17.871 9.91452Z"
fill="url(#paint2_radial_3567_2455)"
fillOpacity="0.45"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_3567_2455"
x1="5.9905"
y1="21.684"
x2="19.3253"
y2="13.9852"
gradientUnits="userSpaceOnUse">
<stop stopColor="#FF6C6C" />
<stop offset="1" stopColor="#FFF1CE" />
</linearGradient>
<radialGradient
id="paint1_radial_3567_2455"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(6.51419 24.5026) rotate(-22.4902) scale(16.7226 15.7844)">
<stop stopColor="white" stopOpacity="0" />
<stop offset="0.704134" stopColor="#2288FF" />
</radialGradient>
<radialGradient
id="paint2_radial_3567_2455"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(1.29495 15.4944) rotate(-22.703) scale(16.8219 15.438)">
<stop stopColor="white" stopOpacity="0" />
<stop offset="0.704134" stopColor="#2288FF" />
</radialGradient>
</defs>
</svg>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 846 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

26
components/login/store.ts Normal file
View File

@@ -0,0 +1,26 @@
import { create } from "zustand"
import { persist } from "zustand/middleware"
interface LoginState {
fingerprint: string
setFingerprint: (fingerprint: string) => void
}
const initialLoginState = {
fingerprint: "",
}
export const useLoginStore = create(
persist<LoginState>(
(set) => ({
fingerprint: initialLoginState.fingerprint,
setFingerprint: (fingerprint) =>
set((state) => {
return { ...state, fingerprint }
}),
}),
{
name: "login-store",
}
)
)