feat: Add user allowlist and improve client creation validation

- Implement user allowlist feature for client applications
- Add input validation for client creation form
- Handle parsing of allowed users list
- Improve error handling and user feedback during client creation
- Update authorization process to check client enabled status and user permissions
This commit is contained in:
wood chen 2025-02-20 02:13:06 +08:00
parent 0dd6a3338f
commit bd6b2b747d
4 changed files with 137 additions and 48 deletions

View File

@ -10,8 +10,28 @@ export async function AddClientAction(formData: FormData) {
const logo = formData.get("logo") as string; const logo = formData.get("logo") as string;
const redirectUri = formData.get("redirectUri") as string; const redirectUri = formData.get("redirectUri") as string;
const description = formData.get("description") as string; const description = formData.get("description") as string;
const allowedUsersStr = formData.get("allowedUsers") as string;
const user = await getCurrentUser(); const user = await getCurrentUser();
if (!user?.id) {
return { success: false, error: "未登录" };
}
// 验证必填字段
if (!name || !home || !redirectUri) {
return { success: false, error: "请填写所有必填字段" };
}
// 解析允许的用户列表
let allowedUsers: string[] = [];
if (allowedUsersStr) {
try {
allowedUsers = JSON.parse(allowedUsersStr);
} catch (error) {
console.error("Error parsing allowedUsers:", error);
return { success: false, error: "允许用户列表格式错误" };
}
}
// Generate a unique client ID and secret // Generate a unique client ID and secret
let clientId = generateRandomKey(); let clientId = generateRandomKey();
@ -29,13 +49,14 @@ export async function AddClientAction(formData: FormData) {
description, description,
clientId, clientId,
clientSecret, clientSecret,
userId: user?.id || "", userId: user.id,
allowedUsers,
}); });
console.log("New client created:", newClient); console.log("New client created:", newClient);
return { success: true, client: newClient }; return { success: true, client: newClient };
} catch (error) { } catch (error) {
console.error("Error creating client:", error); console.error("Error creating client:", error);
return { success: false, error: "Failed to create client" }; return { success: false, error: "创建应用失败" };
} }
} }

View File

@ -12,14 +12,19 @@ export async function handleAuthorizeAction(
clientId: string, clientId: string,
scope: string, scope: string,
) { ) {
try {
// 检查客户端是否限制了允许的用户 // 检查客户端是否限制了允许的用户
const client = await prisma.client.findUnique({ const client = await prisma.client.findUnique({
where: { id: clientId }, where: { id: clientId },
select: { allowedUsers: true, name: true }, select: { allowedUsers: true, name: true, enabled: true },
}); });
if (!client) { if (!client) {
throw new Error("应用不存在"); return { error: "应用不存在" };
}
if (!client.enabled) {
return { error: "该应用已被禁用" };
} }
// 如果设置了允许的用户列表,检查当前用户是否在列表中 // 如果设置了允许的用户列表,检查当前用户是否在列表中
@ -30,15 +35,13 @@ export async function handleAuthorizeAction(
}); });
if (!user) { if (!user) {
console.error(`用户不存在: ${userId}`); return { error: "用户不存在" };
throw new Error("用户不存在");
} }
if (!client.allowedUsers.includes(user.username)) { if (!client.allowedUsers.includes(user.username)) {
console.error( return {
`用户 ${user.username} 不在应用 ${client.name} 的允许列表中。允许列表: ${client.allowedUsers.join(", ")}`, error: "您没有权限使用此应用",
); };
throw new Error("您没有权限使用此应用");
} }
} }
@ -60,5 +63,9 @@ export async function handleAuthorizeAction(
revalidatePath("/admin/clients"); revalidatePath("/admin/clients");
revalidatePath(`/admin/clients/${clientId}`); revalidatePath(`/admin/clients/${clientId}`);
return redirectUrl; return { redirectUrl };
} catch (error) {
console.error("授权处理失败:", error);
return { error: "授权处理失败,请稍后重试" };
}
} }

View File

@ -47,6 +47,7 @@ export function AuthorizationCard({
null, null,
); );
const [isAuthorizing, setIsAuthorizing] = useState(false); const [isAuthorizing, setIsAuthorizing] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter(); const router = useRouter();
const togglePermission = (id: string) => { const togglePermission = (id: string) => {
@ -56,16 +57,43 @@ export function AuthorizationCard({
const authorizingHandler = async () => { const authorizingHandler = async () => {
try { try {
setIsAuthorizing(true); setIsAuthorizing(true);
const url = await handleAuthorizeAction( setError(null);
const result = await handleAuthorizeAction(
oauthParams, oauthParams,
client.userId, client.userId,
client.id, client.id,
permissions[0].id, permissions[0].id,
); );
router.push(url);
} catch (error) { if (result.error) {
setError(result.error);
setIsAuthorizing(false); setIsAuthorizing(false);
// 这里可以添加错误提示 return;
}
if (result.redirectUrl) {
const url = await Promise.resolve(result.redirectUrl);
router.push(url);
}
} catch (error) {
setError("授权处理失败,请稍后重试");
setIsAuthorizing(false);
}
};
const handleCancel = () => {
// 从 URL 中获取 redirect_uri 参数
const params = new URLSearchParams(atob(oauthParams));
const redirectUri = params.get("redirect_uri");
if (redirectUri) {
// 添加错误参数返回
const redirectUrl = new URL(redirectUri);
redirectUrl.searchParams.set("error", "access_denied");
redirectUrl.searchParams.set("error_description", "用户取消了授权请求");
router.push(redirectUrl.toString());
} else {
// 如果没有 redirect_uri返回首页
router.push("/");
} }
}; };
@ -102,6 +130,13 @@ export function AuthorizationCard({
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{error && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-4 text-red-600">
<p className="text-sm">
<strong></strong> {error}
</p>
</div>
)}
<div className="space-y-3 rounded-lg bg-gray-100 p-4 dark:bg-gray-800"> <div className="space-y-3 rounded-lg bg-gray-100 p-4 dark:bg-gray-800">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
@ -143,6 +178,7 @@ export function AuthorizationCard({
<Button <Button
variant="outline" variant="outline"
className="min-w-[100px] transition-all duration-200 hover:bg-gray-100" className="min-w-[100px] transition-all duration-200 hover:bg-gray-100"
onClick={handleCancel}
disabled={isAuthorizing} disabled={isAuthorizing}
> >

View File

@ -30,6 +30,18 @@ export function AddClientButton() {
setIsLoading(true); setIsLoading(true);
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
const allowedUsers = formData.get("allowedUsers") as string;
if (allowedUsers) {
formData.set(
"allowedUsers",
JSON.stringify(
allowedUsers
.split(",")
.map((u) => u.trim())
.filter(Boolean),
),
);
}
const response = await AddClientAction(formData); const response = await AddClientAction(formData);
setIsLoading(false); setIsLoading(false);
@ -112,6 +124,19 @@ export function AddClientButton() {
disabled={isLoading} disabled={isLoading}
/> />
</div> </div>
<div className="grid gap-2">
<Label htmlFor="allowedUsers"></Label>
<Input
id="allowedUsers"
name="allowedUsers"
placeholder="用户名列表,用逗号分隔"
disabled={isLoading}
/>
<p className="text-sm text-muted-foreground">
Q58
</p>
</div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button