fix(auth): align console admin gates and oauth exchange

This commit is contained in:
Haitao Pan 2026-03-17 08:51:01 +08:00
parent 71b36a628d
commit d101676c13
13 changed files with 247 additions and 81 deletions

View File

@ -78,12 +78,9 @@ export default function LoginContent({
const googleAuthUrl = `${accountServiceBaseUrl}/api/auth/oauth/login/google`;
useEffect(() => {
const publicToken = searchParams.get("public_token");
const userId = searchParams.get("userId");
const email = searchParams.get("email");
const role = searchParams.get("role");
const exchangeCode = searchParams.get("exchange_code");
if (!publicToken || !userId || !email) {
if (!exchangeCode) {
return;
}
@ -101,10 +98,7 @@ export default function LoginContent({
"Content-Type": "application/json",
},
body: JSON.stringify({
publicToken,
userId,
email,
role: role || "user",
exchangeCode,
}),
});

View File

@ -3,20 +3,18 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
const WRITE_PERMISSIONS = ['admin.settings.write']
type ErrorPayload = {
error: string
}
function isAllowedRootEmail(email?: string): boolean {
return email?.trim().toLowerCase() === 'admin@svc.plus'
}
export async function POST(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
@ -25,12 +23,13 @@ export async function POST(request: NextRequest) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
if (!isAllowedRootEmail(user.email)) {
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
rootOnly: true,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const headers = new Headers({

View File

@ -3,20 +3,18 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
const READ_PERMISSIONS = ['admin.settings.read']
type ErrorPayload = {
error: string
}
function isAllowedRootEmail(email?: string): boolean {
return email?.trim().toLowerCase() === 'admin@svc.plus'
}
export async function GET(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
@ -25,12 +23,13 @@ export async function GET(request: NextRequest) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
if (!isAllowedRootEmail(user.email)) {
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: READ_PERMISSIONS,
rootOnly: true,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
try {

View File

@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin', 'operator']
const WRITE_PERMISSIONS = ['admin.users.pause.write']
type ErrorPayload = {
error: string
@ -35,8 +37,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const { userId: userIdParam } = await params

View File

@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin', 'operator']
const WRITE_PERMISSIONS = ['admin.users.renew_uuid.write']
type ErrorPayload = {
error: string
@ -35,8 +37,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const { userId: userIdParam } = await params

View File

@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin', 'operator']
const WRITE_PERMISSIONS = ['admin.users.resume.write']
type ErrorPayload = {
error: string
@ -35,8 +37,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const { userId: userIdParam } = await params

View File

@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
const WRITE_PERMISSIONS = ['admin.users.role.write']
type ErrorPayload = {
error: string
@ -35,8 +37,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const { userId: userIdParam } = await params
@ -78,8 +84,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const { userId: userIdParam } = await params

View File

@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin', 'operator']
const DELETE_PERMISSIONS = ['admin.users.delete.write']
type ErrorPayload = {
error: string
@ -35,8 +37,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: DELETE_PERMISSIONS,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const { userId: userIdParam } = await params

View File

@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
const WRITE_PERMISSIONS = ['admin.users.role.write']
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
@ -48,10 +50,6 @@ function normalizeGroups(value: unknown): string[] | null {
return Array.from(new Set(result))
}
function isAllowedRootEmail(email?: string): boolean {
return email?.trim().toLowerCase() === 'admin@svc.plus'
}
export async function POST(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
@ -60,12 +58,13 @@ export async function POST(request: NextRequest) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
if (!isAllowedRootEmail(user.email)) {
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
rootOnly: true,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
const body = (await request.json().catch(() => null)) as CreateUserBody | null

View File

@ -7,9 +7,9 @@ const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
export async function POST(request: NextRequest) {
try {
const payload = await request.json()
const { publicToken, userId, email, role } = payload
const { exchangeCode } = payload
if (!publicToken || !userId || !email) {
if (!exchangeCode || typeof exchangeCode !== 'string') {
return NextResponse.json({ success: false, error: 'invalid_request' }, { status: 400 })
}
@ -19,10 +19,7 @@ export async function POST(request: NextRequest) {
'Content-Type': 'application/json',
},
body: JSON.stringify({
public_token: publicToken,
user_id: userId,
email,
roles: role,
exchange_code: exchangeCode,
}),
cache: 'no-store',
})
@ -33,12 +30,20 @@ export async function POST(request: NextRequest) {
}
const data = await response.json()
const { access_token, expires_in } = data
const sessionToken = typeof data.token === 'string' && data.token.trim().length > 0
? data.token.trim()
: typeof data.access_token === 'string' && data.access_token.trim().length > 0
? data.access_token.trim()
: ''
if (!sessionToken) {
return NextResponse.json({ success: false, error: 'invalid_response' }, { status: 502 })
}
const result = NextResponse.json({ success: true })
// If backend returns expires_in (seconds), use it; otherwise derive from expiresAt if it exists
const maxAge = typeof expires_in === 'number' ? expires_in : deriveMaxAgeFromExpires(data.expiresAt)
applySessionCookie(result, access_token, maxAge)
const maxAge =
typeof data.expires_in === 'number' ? data.expires_in : deriveMaxAgeFromExpires(data.expiresAt)
applySessionCookie(result, sessionToken, maxAge)
return result
} catch (error) {

View File

@ -3,12 +3,14 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { applySessionCookie, deriveMaxAgeFromExpires } from '@lib/authGateway'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
const WRITE_PERMISSIONS = ['admin.settings.write']
const ROOT_BACKUP_COOKIE = 'xc_session_root'
const SANDBOX_EMAIL = 'sandbox@svc.plus'
@ -17,10 +19,6 @@ type ErrorPayload = {
error: string
}
function isAllowedRootEmail(email?: string): boolean {
return email?.trim().toLowerCase() === 'admin@svc.plus'
}
function secureCookies(): boolean {
if (process.env.NODE_ENV === 'production') {
return true
@ -37,12 +35,13 @@ export async function POST(request: NextRequest) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
if (!isAllowedRootEmail(user.email)) {
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
rootOnly: true,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
try {
@ -96,4 +95,3 @@ export async function POST(request: NextRequest) {
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
}
}

View File

@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
evaluateAccountAdminAccess,
isPlatformRootEmail,
} from "@server/account/adminAccess";
import type { AccountSessionUser } from "@server/account/session";
function buildUser(overrides: Partial<AccountSessionUser> = {}): AccountSessionUser {
return {
id: "user-1",
uuid: "user-1",
email: "user@example.com",
role: "user",
groups: [],
permissions: [],
...overrides,
};
}
describe("adminAccess", () => {
it("allows platform admin roles without an explicit permission", async () => {
const decision = await evaluateAccountAdminAccess(buildUser({ role: "admin" }), {
roles: ["admin", "operator"],
permissions: ["admin.users.list.read"],
});
expect(decision).toEqual({ allowed: true });
});
it("allows permission-scoped operators without requiring the admin role", async () => {
const decision = await evaluateAccountAdminAccess(buildUser({
role: "operator",
permissions: ["admin.users.pause.write"],
}), {
roles: ["admin", "operator"],
permissions: ["admin.users.pause.write"],
});
expect(decision).toEqual({ allowed: true });
});
it("enforces root-only routes after role and permission checks pass", async () => {
const decision = await evaluateAccountAdminAccess(buildUser({
role: "admin",
permissions: ["admin.settings.write"],
}), {
roles: ["admin"],
permissions: ["admin.settings.write"],
rootOnly: true,
});
expect(decision).toEqual({ allowed: false, reason: "root_only" });
});
it("recognizes the shared platform root email", () => {
expect(isPlatformRootEmail("admin@svc.plus")).toBe(true);
expect(isPlatformRootEmail("ADMIN@svc.plus")).toBe(true);
expect(isPlatformRootEmail("user@example.com")).toBe(false);
});
});

View File

@ -0,0 +1,77 @@
import type {
AccountSessionUser,
AccountUserRole,
} from "@server/account/session";
export const PLATFORM_ROOT_EMAIL = "admin@svc.plus";
type AccountAdminAccessRule = {
roles?: AccountUserRole[];
permissions?: string[];
rootOnly?: boolean;
};
type AccountAdminAccessDecision = {
allowed: boolean;
reason?: "forbidden" | "root_only";
};
export function isPlatformRootEmail(email?: string): boolean {
return email?.trim().toLowerCase() === PLATFORM_ROOT_EMAIL;
}
function hasRole(
user: AccountSessionUser,
roles: AccountUserRole[],
): boolean {
return roles.includes(user.role);
}
function hasPermission(
user: AccountSessionUser,
permissions: string[],
): boolean {
if (permissions.length === 0) {
return false;
}
const normalizedPermissions = new Set(
user.permissions.map((permission) => permission.trim()),
);
if (normalizedPermissions.has("*")) {
return true;
}
return permissions.every((permission) =>
normalizedPermissions.has(permission.trim()),
);
}
export async function evaluateAccountAdminAccess(
user: AccountSessionUser | null,
rule: AccountAdminAccessRule,
): Promise<AccountAdminAccessDecision> {
if (!user) {
return { allowed: false, reason: "forbidden" };
}
const roles = rule.roles ?? [];
const permissions = rule.permissions ?? [];
let allowed = false;
if (roles.length > 0 && permissions.length > 0) {
allowed = hasRole(user, roles) || hasPermission(user, permissions);
} else if (roles.length > 0) {
allowed = hasRole(user, roles);
} else if (permissions.length > 0) {
allowed = hasPermission(user, permissions);
}
if (!allowed) {
return { allowed: false, reason: "forbidden" };
}
if (rule.rootOnly && !isPlatformRootEmail(user.email)) {
return { allowed: false, reason: "root_only" };
}
return { allowed: true };
}