feat: add local access control and managed point clouds
This commit is contained in:
153
server/_core/auth.ts
Normal file
153
server/_core/auth.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { COOKIE_NAME, SESSION_TTL_MS } from "@shared/const";
|
||||
import { parse as parseCookieHeader } from "cookie";
|
||||
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from "crypto";
|
||||
import type { Request } from "express";
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import { promisify } from "util";
|
||||
import type { User } from "../../drizzle/schema";
|
||||
import * as db from "../db";
|
||||
import { ENV } from "./env";
|
||||
|
||||
const scrypt = promisify(scryptCallback);
|
||||
const PASSWORD_MIN_LENGTH = 8;
|
||||
|
||||
function sessionSecret() {
|
||||
if (!ENV.cookieSecret || ENV.cookieSecret.length < 32) {
|
||||
throw new Error("JWT_SECRET 必须配置且至少 32 个字符");
|
||||
}
|
||||
return new TextEncoder().encode(ENV.cookieSecret);
|
||||
}
|
||||
|
||||
export function validatePassword(password: string) {
|
||||
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||
throw new Error(`密码至少需要 ${PASSWORD_MIN_LENGTH} 位`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string) {
|
||||
validatePassword(password);
|
||||
const salt = randomBytes(16);
|
||||
const derived = await scrypt(password, salt, 64) as Buffer;
|
||||
return `scrypt$${salt.toString("base64url")}$${derived.toString("base64url")}`;
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, encoded: string | null) {
|
||||
if (!encoded) return false;
|
||||
const [scheme, saltValue, hashValue] = encoded.split("$");
|
||||
if (scheme !== "scrypt" || !saltValue || !hashValue) return false;
|
||||
try {
|
||||
const expected = Buffer.from(hashValue, "base64url");
|
||||
const actual = await scrypt(password, Buffer.from(saltValue, "base64url"), expected.length) as Buffer;
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSessionToken(user: Pick<User, "id" | "sessionVersion">) {
|
||||
return new SignJWT({ userId: user.id, sessionVersion: user.sessionVersion })
|
||||
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(Math.floor((Date.now() + SESSION_TTL_MS) / 1000))
|
||||
.sign(sessionSecret());
|
||||
}
|
||||
|
||||
function readSessionCookie(req: Request) {
|
||||
if (!req.headers.cookie) return undefined;
|
||||
return parseCookieHeader(req.headers.cookie)[COOKIE_NAME];
|
||||
}
|
||||
|
||||
export async function authenticateRequest(req: Request): Promise<User | null> {
|
||||
const token = readSessionCookie(req);
|
||||
if (!token) return null;
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, sessionSecret(), { algorithms: ["HS256"] });
|
||||
const userId = Number(payload.userId);
|
||||
const sessionVersion = Number(payload.sessionVersion);
|
||||
if (!Number.isInteger(userId) || !Number.isInteger(sessionVersion)) return null;
|
||||
const user = await db.getUserById(userId);
|
||||
if (!user || user.isActive !== 1 || user.sessionVersion !== sessionVersion) return null;
|
||||
return user;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type Attempt = { failures: number[] };
|
||||
const attempts = new Map<string, Attempt>();
|
||||
const RATE_WINDOW_MS = 15 * 60 * 1000;
|
||||
const RATE_MAX_FAILURES = 5;
|
||||
|
||||
function normalizedUsername(username: string) {
|
||||
return username.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function attemptKeys(ip: string, username: string) {
|
||||
return [`ip:${ip}`, `username:${normalizedUsername(username)}`];
|
||||
}
|
||||
|
||||
function recentFailures(key: string, now = Date.now()) {
|
||||
const record = attempts.get(key);
|
||||
if (!record) return [];
|
||||
record.failures = record.failures.filter(time => now - time < RATE_WINDOW_MS);
|
||||
if (record.failures.length === 0) attempts.delete(key);
|
||||
return record.failures;
|
||||
}
|
||||
|
||||
export function isLoginRateLimited(ip: string, username: string) {
|
||||
return attemptKeys(ip, username).some(key => recentFailures(key).length >= RATE_MAX_FAILURES);
|
||||
}
|
||||
|
||||
export function recordLoginFailure(ip: string, username: string) {
|
||||
const now = Date.now();
|
||||
for (const key of attemptKeys(ip, username)) {
|
||||
const failures = recentFailures(key, now);
|
||||
failures.push(now);
|
||||
attempts.set(key, { failures });
|
||||
}
|
||||
}
|
||||
|
||||
export function clearLoginFailures(ip: string, username: string) {
|
||||
for (const key of attemptKeys(ip, username)) attempts.delete(key);
|
||||
}
|
||||
|
||||
export function resetLoginRateLimitForTests() {
|
||||
attempts.clear();
|
||||
}
|
||||
|
||||
export async function ensureBootstrapAdmin() {
|
||||
if (await db.countActiveSuperAdmins() > 0) return;
|
||||
const username = ENV.bootstrapAdminUsername.trim();
|
||||
const password = ENV.bootstrapAdminPassword;
|
||||
if (!username || !password) {
|
||||
throw new Error("系统不存在有效超级管理员,请配置 BOOTSTRAP_ADMIN_USERNAME 和 BOOTSTRAP_ADMIN_PASSWORD");
|
||||
}
|
||||
validatePassword(password);
|
||||
const existing = await db.getUserByUsername(username);
|
||||
const passwordHash = await hashPassword(password);
|
||||
if (existing) {
|
||||
await db.updateUser(existing.id, {
|
||||
passwordHash,
|
||||
role: "super_admin",
|
||||
isActive: 1,
|
||||
mustChangePassword: 1,
|
||||
sessionVersion: existing.sessionVersion + 1,
|
||||
});
|
||||
} else {
|
||||
await db.createUser({
|
||||
username,
|
||||
passwordHash,
|
||||
name: "系统管理员",
|
||||
role: "super_admin",
|
||||
isActive: 1,
|
||||
mustChangePassword: 1,
|
||||
sessionVersion: 0,
|
||||
});
|
||||
}
|
||||
console.log(`[Auth] 已创建引导超级管理员账号:${username}(首次登录必须修改密码)`);
|
||||
}
|
||||
|
||||
export function publicUser(user: User) {
|
||||
const { passwordHash: _passwordHash, ...safe } = user;
|
||||
return safe;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
|
||||
import type { User } from "../../drizzle/schema";
|
||||
import { sdk } from "./sdk";
|
||||
import { authenticateRequest } from "./auth";
|
||||
|
||||
export type TrpcContext = {
|
||||
req: CreateExpressContextOptions["req"];
|
||||
@@ -14,7 +14,7 @@ export async function createContext(
|
||||
let user: User | null = null;
|
||||
|
||||
try {
|
||||
user = await sdk.authenticateRequest(opts.req);
|
||||
user = await authenticateRequest(opts.req);
|
||||
} catch (error) {
|
||||
// Authentication is optional for public procedures.
|
||||
user = null;
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import type { CookieOptions, Request } from "express";
|
||||
|
||||
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
|
||||
function isIpAddress(host: string) {
|
||||
// Basic IPv4 check and IPv6 presence detection.
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
|
||||
return host.includes(":");
|
||||
}
|
||||
|
||||
function isSecureRequest(req: Request) {
|
||||
if (req.protocol === "https") return true;
|
||||
|
||||
@@ -42,7 +34,7 @@ export function getSessionCookieOptions(
|
||||
return {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "none",
|
||||
sameSite: "lax",
|
||||
secure: isSecureRequest(req),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
export const ENV = {
|
||||
appId: process.env.VITE_APP_ID ?? "",
|
||||
cookieSecret: process.env.JWT_SECRET ?? "",
|
||||
databaseUrl: process.env.DATABASE_URL ?? "",
|
||||
oAuthServerUrl: process.env.OAUTH_SERVER_URL ?? "",
|
||||
authMode: process.env.AUTH_MODE ?? "oauth",
|
||||
localDevOpenId: process.env.LOCAL_DEV_OPEN_ID ?? "local-dev-user",
|
||||
localDevName: process.env.LOCAL_DEV_NAME ?? "Local Dev User",
|
||||
localDevEmail: process.env.LOCAL_DEV_EMAIL ?? "local-dev@example.com",
|
||||
ownerOpenId: process.env.OWNER_OPEN_ID ?? "",
|
||||
bootstrapAdminUsername: process.env.BOOTSTRAP_ADMIN_USERNAME ?? "",
|
||||
bootstrapAdminPassword: process.env.BOOTSTRAP_ADMIN_PASSWORD ?? "",
|
||||
isProduction: process.env.NODE_ENV === "production",
|
||||
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
|
||||
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
|
||||
|
||||
@@ -7,11 +7,11 @@ import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import mysql from "mysql2/promise";
|
||||
import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
||||
import { registerOAuthRoutes } from "./oauth";
|
||||
import { appRouter } from "../routers";
|
||||
import { createContext } from "./context";
|
||||
import { serveStatic, setupVite } from "./vite";
|
||||
import { fileRouter } from "../fileRoutes";
|
||||
import { ensureBootstrapAdmin } from "./auth";
|
||||
|
||||
const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -113,11 +113,8 @@ async function findAvailablePort(startPort: number = 3000): Promise<number> {
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
try {
|
||||
await runMigrations();
|
||||
} catch (e) {
|
||||
console.error("[Migration] Unexpected error, server will still start:", e);
|
||||
}
|
||||
await runMigrations();
|
||||
await ensureBootstrapAdmin();
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
// Configure body parser with larger size limit for file uploads
|
||||
@@ -128,8 +125,6 @@ async function startServer() {
|
||||
server.timeout = 0; // 禁用 Node http.Server 全局超时
|
||||
server.keepAliveTimeout = 0; // 长连接不超时
|
||||
server.headersTimeout = 0; // 读请求头不超时
|
||||
// OAuth callback under /api/oauth/callback
|
||||
registerOAuthRoutes(app);
|
||||
// 禁止代理(NPS 等)缓存 API 响应
|
||||
app.use('/api', (_req, res, next) => {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
||||
import type { Express, Request, Response } from "express";
|
||||
import * as db from "../db";
|
||||
import { getSessionCookieOptions } from "./cookies";
|
||||
import { sdk } from "./sdk";
|
||||
|
||||
function getQueryParam(req: Request, key: string): string | undefined {
|
||||
const value = req.query[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
export function registerOAuthRoutes(app: Express) {
|
||||
app.get("/api/oauth/callback", async (req: Request, res: Response) => {
|
||||
const code = getQueryParam(req, "code");
|
||||
const state = getQueryParam(req, "state");
|
||||
|
||||
if (!code || !state) {
|
||||
res.status(400).json({ error: "code and state are required" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenResponse = await sdk.exchangeCodeForToken(code, state);
|
||||
const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
|
||||
|
||||
if (!userInfo.openId) {
|
||||
res.status(400).json({ error: "openId missing from user info" });
|
||||
return;
|
||||
}
|
||||
|
||||
await db.upsertUser({
|
||||
openId: userInfo.openId,
|
||||
name: userInfo.name || null,
|
||||
email: userInfo.email ?? null,
|
||||
loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
|
||||
lastSignedIn: new Date(),
|
||||
});
|
||||
|
||||
const sessionToken = await sdk.createSessionToken(userInfo.openId, {
|
||||
name: userInfo.name || "",
|
||||
expiresInMs: ONE_YEAR_MS,
|
||||
});
|
||||
|
||||
const cookieOptions = getSessionCookieOptions(req);
|
||||
res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: ONE_YEAR_MS });
|
||||
|
||||
res.redirect(302, "/");
|
||||
} catch (error) {
|
||||
console.error("[OAuth] Callback failed", error);
|
||||
res.status(500).json({ error: "OAuth callback failed" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
import { AXIOS_TIMEOUT_MS, COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
||||
import { ForbiddenError } from "@shared/_core/errors";
|
||||
import axios, { type AxiosInstance } from "axios";
|
||||
import { parse as parseCookieHeader } from "cookie";
|
||||
import type { Request } from "express";
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import type { User } from "../../drizzle/schema";
|
||||
import * as db from "../db";
|
||||
import { ENV } from "./env";
|
||||
import type {
|
||||
ExchangeTokenRequest,
|
||||
ExchangeTokenResponse,
|
||||
GetUserInfoResponse,
|
||||
GetUserInfoWithJwtRequest,
|
||||
GetUserInfoWithJwtResponse,
|
||||
} from "./types/manusTypes";
|
||||
// Utility function
|
||||
const isNonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === "string" && value.length > 0;
|
||||
|
||||
export type SessionPayload = {
|
||||
openId: string;
|
||||
appId: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const EXCHANGE_TOKEN_PATH = `/webdev.v1.WebDevAuthPublicService/ExchangeToken`;
|
||||
const GET_USER_INFO_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfo`;
|
||||
const GET_USER_INFO_WITH_JWT_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfoWithJwt`;
|
||||
|
||||
class OAuthService {
|
||||
constructor(private client: ReturnType<typeof axios.create>) {
|
||||
const isLocalAuth = ENV.authMode === "local";
|
||||
console.log("[OAuth] Initialized with baseURL:", ENV.oAuthServerUrl);
|
||||
if (!ENV.oAuthServerUrl && !isLocalAuth) {
|
||||
console.error(
|
||||
"[OAuth] ERROR: OAUTH_SERVER_URL is not configured! Set OAUTH_SERVER_URL environment variable."
|
||||
);
|
||||
} else if (!ENV.oAuthServerUrl && isLocalAuth) {
|
||||
console.log("[OAuth] AUTH_MODE=local, OAuth server is not required.");
|
||||
}
|
||||
}
|
||||
|
||||
private decodeState(state: string): string {
|
||||
const redirectUri = atob(state);
|
||||
return redirectUri;
|
||||
}
|
||||
|
||||
async getTokenByCode(
|
||||
code: string,
|
||||
state: string
|
||||
): Promise<ExchangeTokenResponse> {
|
||||
const payload: ExchangeTokenRequest = {
|
||||
clientId: ENV.appId,
|
||||
grantType: "authorization_code",
|
||||
code,
|
||||
redirectUri: this.decodeState(state),
|
||||
};
|
||||
|
||||
const { data } = await this.client.post<ExchangeTokenResponse>(
|
||||
EXCHANGE_TOKEN_PATH,
|
||||
payload
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async getUserInfoByToken(
|
||||
token: ExchangeTokenResponse
|
||||
): Promise<GetUserInfoResponse> {
|
||||
const { data } = await this.client.post<GetUserInfoResponse>(
|
||||
GET_USER_INFO_PATH,
|
||||
{
|
||||
accessToken: token.accessToken,
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
const createOAuthHttpClient = (): AxiosInstance =>
|
||||
axios.create({
|
||||
baseURL: ENV.oAuthServerUrl,
|
||||
timeout: AXIOS_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
class SDKServer {
|
||||
private readonly client: AxiosInstance;
|
||||
private readonly oauthService: OAuthService;
|
||||
|
||||
constructor(client: AxiosInstance = createOAuthHttpClient()) {
|
||||
this.client = client;
|
||||
this.oauthService = new OAuthService(this.client);
|
||||
}
|
||||
|
||||
private isLocalAuthMode() {
|
||||
return ENV.authMode === "local";
|
||||
}
|
||||
|
||||
private deriveLoginMethod(
|
||||
platforms: unknown,
|
||||
fallback: string | null | undefined
|
||||
): string | null {
|
||||
if (fallback && fallback.length > 0) return fallback;
|
||||
if (!Array.isArray(platforms) || platforms.length === 0) return null;
|
||||
const set = new Set<string>(
|
||||
platforms.filter((p): p is string => typeof p === "string")
|
||||
);
|
||||
if (set.has("REGISTERED_PLATFORM_EMAIL")) return "email";
|
||||
if (set.has("REGISTERED_PLATFORM_GOOGLE")) return "google";
|
||||
if (set.has("REGISTERED_PLATFORM_APPLE")) return "apple";
|
||||
if (
|
||||
set.has("REGISTERED_PLATFORM_MICROSOFT") ||
|
||||
set.has("REGISTERED_PLATFORM_AZURE")
|
||||
)
|
||||
return "microsoft";
|
||||
if (set.has("REGISTERED_PLATFORM_GITHUB")) return "github";
|
||||
const first = Array.from(set)[0];
|
||||
return first ? first.toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange OAuth authorization code for access token
|
||||
* @example
|
||||
* const tokenResponse = await sdk.exchangeCodeForToken(code, state);
|
||||
*/
|
||||
async exchangeCodeForToken(
|
||||
code: string,
|
||||
state: string
|
||||
): Promise<ExchangeTokenResponse> {
|
||||
return this.oauthService.getTokenByCode(code, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user information using access token
|
||||
* @example
|
||||
* const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
|
||||
*/
|
||||
async getUserInfo(accessToken: string): Promise<GetUserInfoResponse> {
|
||||
const data = await this.oauthService.getUserInfoByToken({
|
||||
accessToken,
|
||||
} as ExchangeTokenResponse);
|
||||
const loginMethod = this.deriveLoginMethod(
|
||||
(data as any)?.platforms,
|
||||
(data as any)?.platform ?? data.platform ?? null
|
||||
);
|
||||
return {
|
||||
...(data as any),
|
||||
platform: loginMethod,
|
||||
loginMethod,
|
||||
} as GetUserInfoResponse;
|
||||
}
|
||||
|
||||
private parseCookies(cookieHeader: string | undefined) {
|
||||
if (!cookieHeader) {
|
||||
return new Map<string, string>();
|
||||
}
|
||||
|
||||
const parsed = parseCookieHeader(cookieHeader);
|
||||
return new Map(Object.entries(parsed));
|
||||
}
|
||||
|
||||
private getSessionSecret() {
|
||||
const secret = ENV.cookieSecret;
|
||||
return new TextEncoder().encode(secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session token for a Manus user openId
|
||||
* @example
|
||||
* const sessionToken = await sdk.createSessionToken(userInfo.openId);
|
||||
*/
|
||||
async createSessionToken(
|
||||
openId: string,
|
||||
options: { expiresInMs?: number; name?: string } = {}
|
||||
): Promise<string> {
|
||||
return this.signSession(
|
||||
{
|
||||
openId,
|
||||
appId: ENV.appId,
|
||||
name: options.name || "",
|
||||
},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
async signSession(
|
||||
payload: SessionPayload,
|
||||
options: { expiresInMs?: number } = {}
|
||||
): Promise<string> {
|
||||
const issuedAt = Date.now();
|
||||
const expiresInMs = options.expiresInMs ?? ONE_YEAR_MS;
|
||||
const expirationSeconds = Math.floor((issuedAt + expiresInMs) / 1000);
|
||||
const secretKey = this.getSessionSecret();
|
||||
|
||||
return new SignJWT({
|
||||
openId: payload.openId,
|
||||
appId: payload.appId,
|
||||
name: payload.name,
|
||||
})
|
||||
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
||||
.setExpirationTime(expirationSeconds)
|
||||
.sign(secretKey);
|
||||
}
|
||||
|
||||
async verifySession(
|
||||
cookieValue: string | undefined | null
|
||||
): Promise<{ openId: string; appId: string; name: string } | null> {
|
||||
if (!cookieValue) {
|
||||
console.warn("[Auth] Missing session cookie");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const secretKey = this.getSessionSecret();
|
||||
const { payload } = await jwtVerify(cookieValue, secretKey, {
|
||||
algorithms: ["HS256"],
|
||||
});
|
||||
const { openId, appId, name } = payload as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(openId) ||
|
||||
!isNonEmptyString(appId) ||
|
||||
!isNonEmptyString(name)
|
||||
) {
|
||||
console.warn("[Auth] Session payload missing required fields");
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
openId,
|
||||
appId,
|
||||
name,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("[Auth] Session verification failed", String(error));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserInfoWithJwt(
|
||||
jwtToken: string
|
||||
): Promise<GetUserInfoWithJwtResponse> {
|
||||
const payload: GetUserInfoWithJwtRequest = {
|
||||
jwtToken,
|
||||
projectId: ENV.appId,
|
||||
};
|
||||
|
||||
const { data } = await this.client.post<GetUserInfoWithJwtResponse>(
|
||||
GET_USER_INFO_WITH_JWT_PATH,
|
||||
payload
|
||||
);
|
||||
|
||||
const loginMethod = this.deriveLoginMethod(
|
||||
(data as any)?.platforms,
|
||||
(data as any)?.platform ?? data.platform ?? null
|
||||
);
|
||||
return {
|
||||
...(data as any),
|
||||
platform: loginMethod,
|
||||
loginMethod,
|
||||
} as GetUserInfoWithJwtResponse;
|
||||
}
|
||||
|
||||
async authenticateRequest(req: Request): Promise<User> {
|
||||
if (this.isLocalAuthMode()) {
|
||||
const signedInAt = new Date();
|
||||
const localOpenId = ENV.localDevOpenId;
|
||||
|
||||
try {
|
||||
await db.upsertUser({
|
||||
openId: localOpenId,
|
||||
name: ENV.localDevName,
|
||||
email: ENV.localDevEmail,
|
||||
loginMethod: "local",
|
||||
lastSignedIn: signedInAt,
|
||||
});
|
||||
const localUser = await db.getUserByOpenId(localOpenId);
|
||||
if (localUser) return localUser;
|
||||
} catch {
|
||||
// DB not ready yet (e.g. MySQL still starting up); fall through to
|
||||
// an in-memory guest user so the UI keeps working.
|
||||
console.warn("[Auth] DB unavailable in local mode, using fallback user");
|
||||
}
|
||||
|
||||
// Fallback: return a transient in-memory user so requests don't fail
|
||||
// while MySQL is warming up.
|
||||
return {
|
||||
id: 0,
|
||||
openId: localOpenId,
|
||||
name: ENV.localDevName ?? "Dev User",
|
||||
email: ENV.localDevEmail ?? null,
|
||||
loginMethod: "local",
|
||||
role: "admin" as const,
|
||||
createdAt: signedInAt,
|
||||
updatedAt: signedInAt,
|
||||
lastSignedIn: signedInAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Regular authentication flow
|
||||
const cookies = this.parseCookies(req.headers.cookie);
|
||||
const sessionCookie = cookies.get(COOKIE_NAME);
|
||||
const session = await this.verifySession(sessionCookie);
|
||||
|
||||
if (!session) {
|
||||
throw ForbiddenError("Invalid session cookie");
|
||||
}
|
||||
|
||||
const sessionUserId = session.openId;
|
||||
const signedInAt = new Date();
|
||||
let user = await db.getUserByOpenId(sessionUserId);
|
||||
|
||||
// If user not in DB, sync from OAuth server automatically
|
||||
if (!user) {
|
||||
try {
|
||||
const userInfo = await this.getUserInfoWithJwt(sessionCookie ?? "");
|
||||
await db.upsertUser({
|
||||
openId: userInfo.openId,
|
||||
name: userInfo.name || null,
|
||||
email: userInfo.email ?? null,
|
||||
loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
|
||||
lastSignedIn: signedInAt,
|
||||
});
|
||||
user = await db.getUserByOpenId(userInfo.openId);
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to sync user from OAuth:", error);
|
||||
throw ForbiddenError("Failed to sync user info");
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw ForbiddenError("User not found");
|
||||
}
|
||||
|
||||
await db.upsertUser({
|
||||
openId: user.openId,
|
||||
lastSignedIn: signedInAt,
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
export const sdk = new SDKServer();
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NOT_ADMIN_ERR_MSG, UNAUTHED_ERR_MSG } from '@shared/const';
|
||||
import { MUST_CHANGE_PASSWORD_ERR_MSG, NOT_ADMIN_ERR_MSG, UNAUTHED_ERR_MSG } from '@shared/const';
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import type { TrpcContext } from "./context";
|
||||
@@ -10,7 +10,7 @@ const t = initTRPC.context<TrpcContext>().create({
|
||||
export const router = t.router;
|
||||
export const publicProcedure = t.procedure;
|
||||
|
||||
const requireUser = t.middleware(async opts => {
|
||||
const requireSession = t.middleware(async opts => {
|
||||
const { ctx, next } = opts;
|
||||
|
||||
if (!ctx.user) {
|
||||
@@ -25,13 +25,34 @@ const requireUser = t.middleware(async opts => {
|
||||
});
|
||||
});
|
||||
|
||||
export const protectedProcedure = t.procedure.use(requireUser);
|
||||
export const sessionProcedure = t.procedure.use(requireSession);
|
||||
|
||||
const requireReadableUser = t.middleware(async opts => {
|
||||
if (!opts.ctx.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
|
||||
}
|
||||
if (opts.ctx.user.mustChangePassword === 1) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: MUST_CHANGE_PASSWORD_ERR_MSG });
|
||||
}
|
||||
if (opts.type === "mutation" && opts.ctx.user.role === "user") {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
|
||||
}
|
||||
return opts.next({ ctx: { ...opts.ctx, user: opts.ctx.user } });
|
||||
});
|
||||
|
||||
export const protectedProcedure = t.procedure.use(requireReadableUser);
|
||||
|
||||
export const adminProcedure = t.procedure.use(
|
||||
t.middleware(async opts => {
|
||||
const { ctx, next } = opts;
|
||||
|
||||
if (!ctx.user || ctx.user.role !== 'admin') {
|
||||
if (!ctx.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
|
||||
}
|
||||
if (ctx.user.mustChangePassword === 1) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: MUST_CHANGE_PASSWORD_ERR_MSG });
|
||||
}
|
||||
if (ctx.user.role !== 'admin' && ctx.user.role !== 'super_admin') {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
|
||||
}
|
||||
|
||||
@@ -43,3 +64,16 @@ export const adminProcedure = t.procedure.use(
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
export const superAdminProcedure = t.procedure.use(
|
||||
t.middleware(async opts => {
|
||||
if (!opts.ctx.user) throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
|
||||
if (opts.ctx.user.mustChangePassword === 1) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: MUST_CHANGE_PASSWORD_ERR_MSG });
|
||||
}
|
||||
if (opts.ctx.user.role !== "super_admin") {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
|
||||
}
|
||||
return opts.next({ ctx: { ...opts.ctx, user: opts.ctx.user } });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
// WebDev Auth TypeScript types
|
||||
// Auto-generated from protobuf definitions
|
||||
// Generated on: 2025-09-24T05:57:57.338Z
|
||||
|
||||
export interface AuthorizeRequest {
|
||||
redirectUri: string;
|
||||
projectId: string;
|
||||
state: string;
|
||||
responseType: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export interface AuthorizeResponse {
|
||||
redirectUrl: string;
|
||||
}
|
||||
|
||||
export interface ExchangeTokenRequest {
|
||||
grantType: string;
|
||||
code: string;
|
||||
refreshToken?: string;
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
export interface ExchangeTokenResponse {
|
||||
accessToken: string;
|
||||
tokenType: string;
|
||||
expiresIn: number;
|
||||
refreshToken?: string;
|
||||
scope: string;
|
||||
idToken: string;
|
||||
}
|
||||
|
||||
export interface GetUserInfoRequest {
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
export interface GetUserInfoResponse {
|
||||
openId: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
email?: string | null;
|
||||
platform?: string | null;
|
||||
loginMethod?: string | null;
|
||||
}
|
||||
|
||||
export interface CanAccessRequest {
|
||||
openId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export interface CanAccessResponse {
|
||||
canAccess: boolean;
|
||||
}
|
||||
|
||||
export interface GetUserInfoWithJwtRequest {
|
||||
jwtToken: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export interface GetUserInfoWithJwtResponse {
|
||||
openId: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
email?: string | null;
|
||||
platform?: string | null;
|
||||
loginMethod?: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user