From b57d04f172611a2a6f0b54070ee48da7616568f4 Mon Sep 17 00:00:00 2001 From: shay7sev Date: Tue, 3 Feb 2026 17:33:52 +0800 Subject: [PATCH] feat(project): init --- .env.example | 6 ++ .gitignore | 7 ++ Dockerfile | 19 ++++ README.md | 19 ++++ next-env.d.ts | 5 + next.config.js | 40 +++++++ package.json | 36 +++++++ postcss.config.js | 7 ++ src/app/api/auth/login/route.ts | 88 +++++++++++++++ src/app/api/auth/logout/route.ts | 23 ++++ src/app/api/captcha/route.ts | 28 +++++ src/app/forgot-password/page.tsx | 10 ++ src/app/globals.css | 30 ++++++ src/app/layout.tsx | 16 +++ src/app/login/page.tsx | 25 +++++ src/app/logout/page.tsx | 16 +++ src/app/page.tsx | 6 ++ src/components/login-form.tsx | 177 +++++++++++++++++++++++++++++++ src/components/ui/button.tsx | 52 +++++++++ src/components/ui/card.tsx | 52 +++++++++ src/components/ui/checkbox.tsx | 27 +++++ src/components/ui/input.tsx | 25 +++++ src/components/ui/label.tsx | 22 ++++ src/lib/auth-code.ts | 36 +++++++ src/lib/captcha.ts | 74 +++++++++++++ src/lib/env.ts | 5 + src/lib/iam.ts | 77 ++++++++++++++ src/lib/redis.ts | 22 ++++ src/lib/utils.ts | 7 ++ src/middleware.ts | 18 ++++ tailwind.config.ts | 57 ++++++++++ tsconfig.json | 25 +++++ 32 files changed, 1057 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 next-env.d.ts create mode 100644 next.config.js create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/api/captcha/route.ts create mode 100644 src/app/forgot-password/page.tsx create mode 100644 src/app/globals.css create mode 100644 src/app/layout.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/app/logout/page.tsx create mode 100644 src/app/page.tsx create mode 100644 src/components/login-form.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/lib/auth-code.ts create mode 100644 src/lib/captcha.ts create mode 100644 src/lib/env.ts create mode 100644 src/lib/iam.ts create mode 100644 src/lib/redis.ts create mode 100644 src/lib/utils.ts create mode 100644 src/middleware.ts create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..66422f2 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# IAM_SERVICE_BASE_URL:iam-service 的基础地址(iam-front 通过服务端路由调用它) +IAM_SERVICE_BASE_URL=http://127.0.0.1:3000 + +# CAPTCHA_SECRET:验证码 cookie 的签名密钥(HMAC-SHA256)。 +# 作用:防止客户端篡改验证码 cookie;不是验证码本身,也不会对外返回。 +CAPTCHA_SECRET=please_replace_with_a_secure_random_string diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bdcdcce --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules +.next +.env +.DS_Store + +pnpm-lock.yaml + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e3b0d9c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json ./ +RUN npm install + +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=6020 +ENV HOSTNAME=0.0.0.0 +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6032e6e --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# iam-front + +统一认证前端(SSO 登录页),基于 Next.js 14(App Router)+ TypeScript + Tailwind CSS + shadcn/ui。 + +## 本地启动 + +1. 复制环境变量: + +```bash +cp .env.example .env +``` + +2. 安装依赖并启动: + +```bash +npm install +npm run dev +``` + diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..40c3d68 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..546fef7 --- /dev/null +++ b/next.config.js @@ -0,0 +1,40 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: "standalone", + async headers() { + const isDev = process.env.NODE_ENV === "development"; + const csp = [ + "default-src 'self'", + "base-uri 'self'", + "frame-ancestors 'none'", + "object-src 'none'", + "form-action 'self'", + "img-src 'self' data:", + isDev + ? "script-src 'self' 'unsafe-eval' 'unsafe-inline'" + : "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "connect-src 'self'", + ].join("; "); + + return [ + { + source: "/(.*)", + headers: [ + { key: "Content-Security-Policy", value: csp }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "no-referrer" }, + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + { key: "Permissions-Policy", value: "geolocation=(), microphone=(), camera=()" }, + ], + }, + ]; + }, +}; + +module.exports = nextConfig; + diff --git a/package.json b/package.json new file mode 100644 index 0000000..87e9382 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "iam-front", + "private": true, + "version": "0.1.0", + "scripts": { + "dev": "next dev -p 6020", + "build": "next build", + "start": "next start -p 6020", + "lint": "next lint" + }, + "dependencies": { + "@radix-ui/react-checkbox": "^1.3.0", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-slot": "^1.2.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "jose": "^5.9.6", + "lucide-react": "^0.468.0", + "next": "^14.2.25", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "redis": "^4.7.0", + "tailwind-merge": "^2.5.4" + }, + "devDependencies": { + "@types/node": "^20.17.16", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "autoprefixer": "^10.4.20", + "eslint": "^8.57.1", + "eslint-config-next": "^14.2.25", + "postcss": "^8.5.1", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.3" + } +} \ No newline at end of file diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2ce518b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,7 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..e8f760c --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { iamLoginCode } from "@/lib/iam"; +import { verifyCaptchaCookie } from "@/lib/captcha"; + +export const runtime = "nodejs"; + +type Body = { + clientId?: string; + tenantId?: string; + callback?: string; + email?: string; + password?: string; + captcha?: string; + rememberMe?: boolean; +}; + +function validateCallbackUrl(raw: string): URL { + const u = new URL(raw); + const isLocalhost = u.hostname === "localhost" || u.hostname === "127.0.0.1"; + const allowHttp = process.env.NODE_ENV !== "production" && isLocalhost; + if (u.protocol !== "https:" && !allowHttp) { + throw new Error("callback must be https"); + } + return u; +} + +export async function POST(req: NextRequest) { + const body = (await req.json()) as Body; + + const clientId = body.clientId?.trim() ?? ""; + const tenantId = body.tenantId?.trim() ?? ""; + const callback = body.callback?.trim() ?? ""; + const email = body.email?.trim() ?? ""; + const password = body.password ?? ""; + const captcha = body.captcha?.trim() ?? ""; + + if (!clientId || !tenantId || !callback || !email || !password) { + return NextResponse.json( + { message: "missing required fields" }, + { status: 400 }, + ); + } + + const captchaCookie = req.cookies.get("iam_captcha")?.value; + if (!verifyCaptchaCookie(captchaCookie, captcha)) { + return NextResponse.json({ message: "invalid captcha" }, { status: 400 }); + } + + let callbackUrl: URL; + try { + callbackUrl = validateCallbackUrl(callback); + } catch { + return NextResponse.json({ message: "invalid callback" }, { status: 400 }); + } + + const issued = await iamLoginCode({ + tenantId, + clientId, + redirectUri: callbackUrl.toString(), + email, + password, + }); + + const res = NextResponse.json( + { + redirectTo: issued.redirectTo, + expiresAt: issued.expiresAt, + }, + { status: 200, headers: { "Cache-Control": "no-store" } }, + ); + + if (body.rememberMe) { + res.cookies.set("iam_remember_email", email, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + path: "/", + maxAge: 30 * 24 * 60 * 60, + }); + } else { + res.cookies.delete("iam_remember_email"); + } + + res.cookies.delete("iam_captcha"); + + return res; +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..c044800 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server" + +import { mustGetEnv } from "@/lib/env" + +export const runtime = "nodejs" + +export async function POST(req: NextRequest) { + const auth = req.headers.get("authorization") ?? "" + if (auth.toLowerCase().startsWith("bearer ")) { + const base = mustGetEnv("IAM_SERVICE_BASE_URL").replace(/\/$/, "") + const url = `${base}/auth/logout` + await fetch(url, { + method: "POST", + headers: { Authorization: auth }, + cache: "no-store", + }).catch(() => {}) + } + + const res = NextResponse.json({ ok: true }, { headers: { "Cache-Control": "no-store" } }) + res.cookies.delete("iam_remember_email") + res.cookies.delete("iam_captcha") + return res +} diff --git a/src/app/api/captcha/route.ts b/src/app/api/captcha/route.ts new file mode 100644 index 0000000..6101037 --- /dev/null +++ b/src/app/api/captcha/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server" + +import { captchaSvg, generateCaptcha, signCaptchaCookie } from "@/lib/captcha" + +export const runtime = "nodejs" + +export async function GET() { + const payload = generateCaptcha(120) + const svg = captchaSvg(payload.code) + + const res = new NextResponse(svg, { + headers: { + "Content-Type": "image/svg+xml", + "Cache-Control": "no-store", + }, + }) + + res.cookies.set("iam_captcha", signCaptchaCookie(payload), { + httpOnly: true, + secure: true, + sameSite: "strict", + path: "/", + maxAge: 120, + }) + + return res +} + diff --git a/src/app/forgot-password/page.tsx b/src/app/forgot-password/page.tsx new file mode 100644 index 0000000..1050f1a --- /dev/null +++ b/src/app/forgot-password/page.tsx @@ -0,0 +1,10 @@ +export default function ForgotPasswordPage() { + return ( +
+
+ 请联系租户管理员或通过业务系统的找回流程重置密码。 +
+
+ ) +} + diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..4ec32f8 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,30 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0.75rem; +} + +body { + background: hsl(var(--background)); + color: hsl(var(--foreground)); +} + diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..c93c220 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next" +import "./globals.css" + +export const metadata: Metadata = { + title: "IAM SSO", + description: "Unified login for all services", +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..c55d105 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,25 @@ +import { cookies } from "next/headers"; +import dynamic from "next/dynamic"; + +const LoginFormNoSSR = dynamic(() => import("@/components/login-form"), { + ssr: false, +}); + +export default function LoginPage({ + searchParams, +}: { + searchParams: { tenantId?: string; callback?: string; clientId?: string }; +}) { + const rememberedEmail = cookies().get("iam_remember_email")?.value ?? ""; + + return ( +
+ +
+ ); +} diff --git a/src/app/logout/page.tsx b/src/app/logout/page.tsx new file mode 100644 index 0000000..356f0a2 --- /dev/null +++ b/src/app/logout/page.tsx @@ -0,0 +1,16 @@ +import Link from "next/link" + +export default function LogoutPage() { + return ( +
+
+
已退出登录
+
你可以关闭此页面,或重新登录。
+ + 返回登录页 + +
+
+ ) +} + diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..de4dfd8 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation" + +export default function Home() { + redirect("/login") +} + diff --git a/src/components/login-form.tsx b/src/components/login-form.tsx new file mode 100644 index 0000000..b8e21e3 --- /dev/null +++ b/src/components/login-form.tsx @@ -0,0 +1,177 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +const LoginForm = (props: { + clientId: string; + tenantId: string; + callback: string; + initialEmail: string; +}) => { + const [email, setEmail] = React.useState(props.initialEmail); + const [password, setPassword] = React.useState(""); + const [captcha, setCaptcha] = React.useState(""); + const [rememberMe, setRememberMe] = React.useState( + Boolean(props.initialEmail), + ); + const [submitting, setSubmitting] = React.useState(false); + const [error, setError] = React.useState(null); + const [captchaKey, setCaptchaKey] = React.useState(() => String(Date.now())); + + const clientId = props.clientId; + const tenantId = props.tenantId; + const callback = props.callback; + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setSubmitting(true); + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId, + tenantId, + callback, + email, + password, + captcha, + rememberMe, + }), + }); + const json = (await res.json()) as { + redirectTo?: string; + message?: string; + }; + if (!res.ok || !json.redirectTo) { + throw new Error(json.message || "登录失败"); + } + console.log(`json.redirectTo`, json.redirectTo); + window.location.href = json.redirectTo; + } catch (err) { + setError(err instanceof Error ? err.message : "登录失败"); + setCaptcha(""); + setCaptchaKey(String(Date.now())); + } finally { + setSubmitting(false); + } + } + + const missingParams = !clientId || !tenantId || !callback; + + return ( + + + 统一登录 + 使用 IAM 账号登录以访问业务系统 + +
+ + {missingParams ? ( +
+ 缺少 clientId、tenantId 或 callback 参数,无法继续登录 +
+ ) : null} + {error ? ( +
{error}
+ ) : null} + +
+ + setEmail(e.target.value)} + placeholder="user@example.com" + disabled={submitting} + required + /> +
+ +
+ + setPassword(e.target.value)} + disabled={submitting} + required + /> +
+ +
+ +
+ setCaptcha(e.target.value)} + disabled={submitting} + required + /> + +
+
+ +
+ + + 忘记密码 + +
+
+ + + +
+
+ ); +}; + +export default LoginForm; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..bbe24cc --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,52 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:opacity-90", + secondary: "bg-secondary text-secondary-foreground hover:opacity-90", + outline: "border border-input bg-background hover:bg-secondary", + ghost: "hover:bg-secondary", + destructive: "bg-destructive text-destructive-foreground hover:opacity-90", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + }, +) +Button.displayName = "Button" + +export { Button, buttonVariants } + diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..e39a1ab --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,52 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +) +Card.displayName = "Card" + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } + diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..10eb433 --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,27 @@ +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } + diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..ef93ef0 --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +export interface InputProps extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + }, +) +Input.displayName = "Input" + +export { Input } + diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..f3c8815 --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,22 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" + +import { cn } from "@/lib/utils" + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } + diff --git a/src/lib/auth-code.ts b/src/lib/auth-code.ts new file mode 100644 index 0000000..7f4daaa --- /dev/null +++ b/src/lib/auth-code.ts @@ -0,0 +1,36 @@ +import crypto from "crypto" +import { SignJWT } from "jose" + +import { mustGetEnv } from "@/lib/env" + +export type AccessTokenClaims = { + sub?: string + tenant_id?: string +} + +export function unsafeDecodeJwtPayload(token: string): T { + const parts = token.split(".") + if (parts.length < 2) throw new Error("Invalid JWT") + const payload = Buffer.from(parts[1], "base64url").toString("utf8") + return JSON.parse(payload) as T +} + +export async function issueAuthCode(params: { userId: string; tenantId: string }): Promise<{ + code: string + jti: string + exp: number +}> { + const secret = new TextEncoder().encode(mustGetEnv("AUTH_CODE_JWT_SECRET")) + const now = Math.floor(Date.now() / 1000) + const exp = now + 5 * 60 + const jti = crypto.randomUUID() + const code = await new SignJWT({ tenant_id: params.tenantId, jti }) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setIssuedAt(now) + .setExpirationTime(exp) + .setIssuer("iam-front") + .setSubject(params.userId) + .sign(secret) + + return { code, jti, exp } +} diff --git a/src/lib/captcha.ts b/src/lib/captcha.ts new file mode 100644 index 0000000..004b3e6 --- /dev/null +++ b/src/lib/captcha.ts @@ -0,0 +1,74 @@ +import crypto from "crypto" + +import { mustGetEnv } from "@/lib/env" + +export type CaptchaPayload = { + code: string + exp: number + nonce: string +} + +export function generateCaptcha(ttlSeconds: number): CaptchaPayload { + const code = String(crypto.randomInt(1000, 9999)) + const exp = Math.floor(Date.now() / 1000) + ttlSeconds + const nonce = crypto.randomBytes(12).toString("hex") + return { code, exp, nonce } +} + +export function signCaptchaCookie(payload: CaptchaPayload): string { + const data = `${payload.exp}.${payload.nonce}.${payload.code}` + const mac = crypto + .createHmac("sha256", mustGetEnv("CAPTCHA_SECRET")) + .update(data) + .digest("hex") + return `${payload.exp}.${payload.nonce}.${mac}` +} + +export function verifyCaptchaCookie(cookieValue: string | undefined, userInput: string): boolean { + if (!cookieValue) return false + const parts = cookieValue.split(".") + if (parts.length !== 3) return false + + const exp = Number(parts[0]) + const nonce = parts[1] + const mac = parts[2] + if (!Number.isFinite(exp)) return false + if (Math.floor(Date.now() / 1000) > exp) return false + + const code = userInput.trim() + if (!/^\d{4}$/.test(code)) return false + + const data = `${exp}.${nonce}.${code}` + const expected = crypto + .createHmac("sha256", mustGetEnv("CAPTCHA_SECRET")) + .update(data) + .digest("hex") + return crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expected)) +} + +export function captchaSvg(code: string): string { + const width = 120 + const height = 40 + const noise = Array.from({ length: 6 }).map(() => ({ + x1: crypto.randomInt(0, width), + y1: crypto.randomInt(0, height), + x2: crypto.randomInt(0, width), + y2: crypto.randomInt(0, height), + })) + + const lines = noise + .map( + (l) => + ``, + ) + .join("") + + return ` + + + ${lines} + + ${code} + +` +} diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..778af74 --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,5 @@ +export function mustGetEnv(name: string): string { + const v = process.env[name] + if (!v) throw new Error(`${name} is required`) + return v +} diff --git a/src/lib/iam.ts b/src/lib/iam.ts new file mode 100644 index 0000000..6f89f36 --- /dev/null +++ b/src/lib/iam.ts @@ -0,0 +1,77 @@ +import { mustGetEnv } from "@/lib/env"; + +type AppResponse = { + code: number; + message: string; + data: T; + trace_id?: string | null; + details?: unknown; +}; + +export type IamLoginResponse = { + access_token: string; + refresh_token: string; + token_type: string; + expires_in: number; +}; + +export type IamLoginCodeResponse = { + redirectTo: string; + expiresAt: number; +}; + +export async function iamLogin(params: { + tenantId: string; + email: string; + password: string; +}): Promise { + const base = mustGetEnv("IAM_SERVICE_BASE_URL").replace(/\/$/, ""); + const url = `${base}/api/v1/auth/login`; + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Tenant-ID": params.tenantId, + }, + body: JSON.stringify({ email: params.email, password: params.password }), + cache: "no-store", + }); + + const body = (await res.json()) as AppResponse; + if (!res.ok || body.code !== 0) { + throw new Error(body.message || "Login failed"); + } + return body.data; +} + +export async function iamLoginCode(params: { + tenantId: string; + clientId: string; + redirectUri: string; + email: string; + password: string; +}): Promise { + const base = mustGetEnv("IAM_SERVICE_BASE_URL").replace(/\/$/, ""); + const url = `${base}/api/v1/auth/login-code`; + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Tenant-ID": params.tenantId, + }, + body: JSON.stringify({ + clientId: params.clientId, + redirectUri: params.redirectUri, + email: params.email, + password: params.password, + }), + cache: "no-store", + }); + + const body = (await res.json()) as AppResponse; + + if (!res.ok || body.code !== 0) { + throw new Error(body.message || "Login failed"); + } + return body.data; +} diff --git a/src/lib/redis.ts b/src/lib/redis.ts new file mode 100644 index 0000000..b6346d2 --- /dev/null +++ b/src/lib/redis.ts @@ -0,0 +1,22 @@ +import { createClient } from "redis" + +import { mustGetEnv } from "@/lib/env" + +type AnyRedisClient = ReturnType + +let client: AnyRedisClient | null = null +let connecting: Promise | null = null + +export async function getRedis(): Promise { + if (client) return client + if (connecting) return connecting + + connecting = (async () => { + const c = createClient({ url: mustGetEnv("REDIS_URL") }) + await c.connect() + client = c + return c + })() + + return connecting +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..e8ed525 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,7 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..d0baf0a --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server" + +export function middleware(req: NextRequest) { + if (process.env.NODE_ENV === "production") { + const proto = req.headers.get("x-forwarded-proto") + if (proto && proto !== "https") { + const url = req.nextUrl.clone() + url.protocol = "https" + return NextResponse.redirect(url, 308) + } + } + return NextResponse.next() +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], +} + diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..fc85e23 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,57 @@ +import type { Config } from "tailwindcss" + +const config: Config = { + darkMode: ["class"], + content: ["./src/**/*.{ts,tsx}"], + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + }, + }, + plugins: [], +} + +export default config + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..48907b4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "es2022"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} +