mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-12 02:06:37 +08:00
去除默认OIDC
This commit is contained in:
@@ -44,3 +44,4 @@ uploads/*
|
|||||||
|
|
||||||
s3_cache
|
s3_cache
|
||||||
/frontend/.next/
|
/frontend/.next/
|
||||||
|
/data/
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ Refreshing/ # 项目根目录(模块名: github.com/lin
|
|||||||
├── config.example.yaml # 配置模板(需提交)
|
├── config.example.yaml # 配置模板(需提交)
|
||||||
├── Makefile # 常用命令(swagger/tidy/license)
|
├── Makefile # 常用命令(swagger/tidy/license)
|
||||||
├── Dockerfile # 后端容器镜像构建
|
├── Dockerfile # 后端容器镜像构建
|
||||||
|
├── docker-compose.yml # 本地依赖服务(PostgreSQL / Redis / ClickHouse)
|
||||||
├── .editorconfig # 编辑器格式规范
|
├── .editorconfig # 编辑器格式规范
|
||||||
├── .gitignore
|
├── .gitignore
|
||||||
├── docs/ # Swagger 自动生成文档(不要手动编辑)
|
├── docs/ # Swagger 自动生成文档(不要手动编辑)
|
||||||
|
|||||||
@@ -98,12 +98,18 @@ cd refreshing
|
|||||||
cp config.example.yaml config.yaml
|
cp config.example.yaml config.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit `config.yaml` to configure your database, Redis, and at least one auth source (OIDC or password-based).
|
Edit `config.yaml` to configure your database and Redis. OIDC auth sources are configured at runtime in the admin settings page.
|
||||||
|
|
||||||
### 3. Initialize Database
|
### 3. Initialize Database
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create the database
|
# Start local dependencies (PostgreSQL + Redis)
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Optional: also start ClickHouse
|
||||||
|
docker compose --profile clickhouse up -d
|
||||||
|
|
||||||
|
# If you use an external PostgreSQL instance instead of Docker, create the database manually
|
||||||
createdb -h <host> -p 5432 -U postgres refreshing
|
createdb -h <host> -p 5432 -U postgres refreshing
|
||||||
|
|
||||||
# Database schema is auto-migrated on first startup
|
# Database schema is auto-migrated on first startup
|
||||||
@@ -159,7 +165,6 @@ Key configuration options (see `config.example.yaml` for the full reference):
|
|||||||
| `database.database` | Database name | `refreshing` |
|
| `database.database` | Database name | `refreshing` |
|
||||||
| `redis.host` | Redis host | `127.0.0.1` |
|
| `redis.host` | Redis host | `127.0.0.1` |
|
||||||
| `storage.endpoint` | S3-compatible endpoint | `s3.amazonaws.com` |
|
| `storage.endpoint` | S3-compatible endpoint | `s3.amazonaws.com` |
|
||||||
| `oauth2.client_id` | Default OIDC client ID | `your_client_id` |
|
|
||||||
|
|
||||||
## 🔧 Development Guide
|
## 🔧 Development Guide
|
||||||
|
|
||||||
|
|||||||
+8
-3
@@ -98,12 +98,18 @@ cd refreshing
|
|||||||
cp config.example.yaml config.yaml
|
cp config.example.yaml config.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
编辑 `config.yaml`,配置数据库、Redis,以及至少一个认证源(OIDC 或密码登录)。
|
编辑 `config.yaml`,配置数据库和 Redis。OIDC 认证源统一在管理后台的系统设置页面运行时配置。
|
||||||
|
|
||||||
### 3. 初始化数据库
|
### 3. 初始化数据库
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 创建数据库
|
# 启动本地依赖服务(PostgreSQL + Redis)
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 可选:同时启动 ClickHouse
|
||||||
|
docker compose --profile clickhouse up -d
|
||||||
|
|
||||||
|
# 如果使用外部 PostgreSQL,而不是 Docker 内置服务,则手动创建数据库
|
||||||
createdb -h <主机> -p 5432 -U postgres refreshing
|
createdb -h <主机> -p 5432 -U postgres refreshing
|
||||||
|
|
||||||
# 数据库表结构在首次启动时自动迁移,无需手动执行
|
# 数据库表结构在首次启动时自动迁移,无需手动执行
|
||||||
@@ -159,7 +165,6 @@ pnpm dev
|
|||||||
| `database.database` | 数据库名称 | `refreshing` |
|
| `database.database` | 数据库名称 | `refreshing` |
|
||||||
| `redis.host` | Redis 主机 | `127.0.0.1` |
|
| `redis.host` | Redis 主机 | `127.0.0.1` |
|
||||||
| `storage.endpoint` | S3 兼容存储端点 | `s3.amazonaws.com` |
|
| `storage.endpoint` | S3 兼容存储端点 | `s3.amazonaws.com` |
|
||||||
| `oauth2.client_id` | 默认 OIDC 客户端 ID | `your_client_id` |
|
|
||||||
|
|
||||||
## 🔧 开发指南
|
## 🔧 开发指南
|
||||||
|
|
||||||
|
|||||||
+1
-12
@@ -18,17 +18,6 @@ app:
|
|||||||
api_prefix: "/api"
|
api_prefix: "/api"
|
||||||
frontend_url: "http://localhost:3000"
|
frontend_url: "http://localhost:3000"
|
||||||
|
|
||||||
# ─── Default OAuth2 / OIDC Provider (optional) ──────────────────────────────────
|
|
||||||
# You can configure additional OIDC providers at runtime via the admin panel.
|
|
||||||
oauth2:
|
|
||||||
client_id: "<OAUTH2_CLIENT_ID>"
|
|
||||||
client_secret: "<OAUTH2_CLIENT_SECRET>"
|
|
||||||
redirect_uri: "<OAUTH2_REDIRECT_URI>"
|
|
||||||
issuer: "" # OIDC Issuer URL for auto-discovery (recommended)
|
|
||||||
authorization_endpoint: "" # Leave empty if issuer is set
|
|
||||||
token_endpoint: ""
|
|
||||||
user_endpoint: ""
|
|
||||||
|
|
||||||
# ─── PostgreSQL ─────────────────────────────────────────────────────────────────
|
# ─── PostgreSQL ─────────────────────────────────────────────────────────────────
|
||||||
# Supports Standalone and Primary-Replica (read/write split) modes.
|
# Supports Standalone and Primary-Replica (read/write split) modes.
|
||||||
database:
|
database:
|
||||||
@@ -36,7 +25,7 @@ database:
|
|||||||
host: "127.0.0.1"
|
host: "127.0.0.1"
|
||||||
port: 5432
|
port: 5432
|
||||||
username: "postgres"
|
username: "postgres"
|
||||||
password: ""
|
password: "postgres"
|
||||||
database: "refreshing"
|
database: "refreshing"
|
||||||
max_idle_conn: 16
|
max_idle_conn: 16
|
||||||
max_open_conn: 128
|
max_open_conn: 128
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
container_name: refreshing-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-refreshing}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
|
TZ: ${TZ:-Asia/Shanghai}
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
|
volumes:
|
||||||
|
- ./data/postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-refreshing}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
#
|
||||||
|
# redis:
|
||||||
|
# image: redis:7-alpine
|
||||||
|
# container_name: refreshing-redis
|
||||||
|
# restart: unless-stopped
|
||||||
|
# command: ["redis-server", "--appendonly", "yes"]
|
||||||
|
# ports:
|
||||||
|
# - "${REDIS_PORT:-6379}:6379"
|
||||||
|
# volumes:
|
||||||
|
# - ./data/redis_data:/data
|
||||||
|
# healthcheck:
|
||||||
|
# test: ["CMD", "redis-cli", "ping"]
|
||||||
|
# interval: 10s
|
||||||
|
# timeout: 5s
|
||||||
|
# retries: 5
|
||||||
|
# start_period: 5s
|
||||||
|
#
|
||||||
|
# clickhouse:
|
||||||
|
# image: clickhouse/clickhouse-server:25.3-alpine
|
||||||
|
# container_name: refreshing-clickhouse
|
||||||
|
# restart: unless-stopped
|
||||||
|
# profiles:
|
||||||
|
# - clickhouse
|
||||||
|
# environment:
|
||||||
|
# CLICKHOUSE_DB: ${CLICKHOUSE_DB:-refreshing}
|
||||||
|
# CLICKHOUSE_USER: ${CLICKHOUSE_USER:-default}
|
||||||
|
# CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-}
|
||||||
|
# CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
||||||
|
# TZ: ${TZ:-Asia/Shanghai}
|
||||||
|
# ports:
|
||||||
|
# - "${CLICKHOUSE_HTTP_PORT:-8123}:8123"
|
||||||
|
# - "${CLICKHOUSE_NATIVE_PORT:-9000}:9000"
|
||||||
|
# volumes:
|
||||||
|
# - clickhouse_data:/var/lib/clickhouse
|
||||||
|
# - clickhouse_logs:/var/log/clickhouse-server
|
||||||
|
# healthcheck:
|
||||||
|
# test: ["CMD", "clickhouse-client", "--query", "SELECT 1"]
|
||||||
|
# interval: 10s
|
||||||
|
# timeout: 5s
|
||||||
|
# retries: 5
|
||||||
|
# start_period: 15s
|
||||||
+2
-2
@@ -1485,7 +1485,7 @@ const docTemplate = `{
|
|||||||
},
|
},
|
||||||
"/api/v1/oauth/login": {
|
"/api/v1/oauth/login": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用默认认证源。",
|
"description": "根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用第一个启用的认证源。",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -1496,7 +1496,7 @@ const docTemplate = `{
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "认证源名称,为空使用默认源",
|
"description": "认证源名称,为空使用第一个启用的认证源",
|
||||||
"name": "source",
|
"name": "source",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1478,7 +1478,7 @@
|
|||||||
},
|
},
|
||||||
"/api/v1/oauth/login": {
|
"/api/v1/oauth/login": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用默认认证源。",
|
"description": "根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用第一个启用的认证源。",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -1489,7 +1489,7 @@
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "认证源名称,为空使用默认源",
|
"description": "认证源名称,为空使用第一个启用的认证源",
|
||||||
"name": "source",
|
"name": "source",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1436,9 +1436,9 @@ paths:
|
|||||||
- oauth
|
- oauth
|
||||||
/api/v1/oauth/login:
|
/api/v1/oauth/login:
|
||||||
get:
|
get:
|
||||||
description: 根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用默认认证源。
|
description: 根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用第一个启用的认证源。
|
||||||
parameters:
|
parameters:
|
||||||
- description: 认证源名称,为空使用默认源
|
- description: 认证源名称,为空使用第一个启用的认证源
|
||||||
in: query
|
in: query
|
||||||
name: source
|
name: source
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
@@ -237,31 +237,28 @@ export function LoginForm() {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-3">
|
{authSources.length > 0 ? (
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
authSources.map((source) => (
|
||||||
<ShieldCheck className="size-4" />
|
<div className="space-y-3">
|
||||||
第三方认证源
|
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||||
</div>
|
<ShieldCheck className="size-4" />
|
||||||
<div className="grid gap-2">
|
第三方认证源
|
||||||
{authSources.length > 0 ? (
|
|
||||||
authSources.map((source) => (
|
|
||||||
<Button
|
|
||||||
key={source.id}
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="justify-start"
|
|
||||||
onClick={() => void handleOAuthLogin(source.name)}
|
|
||||||
>
|
|
||||||
{source.display_name || source.name} 登录
|
|
||||||
</Button>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="rounded-lg border border-dashed border-border/60 px-3 py-4 text-sm text-muted-foreground">
|
|
||||||
暂无可用认证源
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="grid gap-2">
|
||||||
</div>
|
<Button
|
||||||
</div>
|
key={source.id}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="justify-start"
|
||||||
|
onClick={() => void handleOAuthLogin(source.name)}
|
||||||
|
>
|
||||||
|
{source.display_name || source.name} 登录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : null}
|
||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,9 +2,24 @@
|
|||||||
|
|
||||||
import {useEffect, useMemo, useState} from "react"
|
import {useEffect, useMemo, useState} from "react"
|
||||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||||
import {Fingerprint, Globe, Loader2, Lock, Pencil, Plus, Settings, Trash2, UserPlus} from "lucide-react"
|
import {
|
||||||
|
CalendarClock,
|
||||||
|
Fingerprint,
|
||||||
|
Globe,
|
||||||
|
Info,
|
||||||
|
Loader2,
|
||||||
|
Lock,
|
||||||
|
Monitor,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Server,
|
||||||
|
Settings,
|
||||||
|
Trash2,
|
||||||
|
UserPlus
|
||||||
|
} from "lucide-react"
|
||||||
import {useRouter} from "next/navigation"
|
import {useRouter} from "next/navigation"
|
||||||
import {motion} from "motion/react"
|
import {motion} from "motion/react"
|
||||||
|
import packageJson from "../../../package.json"
|
||||||
|
|
||||||
import {Button} from "@/components/ui/button"
|
import {Button} from "@/components/ui/button"
|
||||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||||
@@ -12,7 +27,7 @@ import {Switch} from "@/components/ui/switch"
|
|||||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||||
import {useAuth} from "@/components/providers/auth-provider"
|
import {useAuth} from "@/components/providers/auth-provider"
|
||||||
import {AuthSourceModal} from "@/components/common/settings/auth-source-modal"
|
import {AuthSourceModal} from "@/components/common/settings/auth-source-modal"
|
||||||
import {AdminService} from "@/lib/services"
|
import {AdminService, apiConfig} from "@/lib/services"
|
||||||
import type {AuthSource, SystemConfig} from "@/lib/services/admin"
|
import type {AuthSource, SystemConfig} from "@/lib/services/admin"
|
||||||
import {toast} from "sonner"
|
import {toast} from "sonner"
|
||||||
|
|
||||||
@@ -52,12 +67,33 @@ function systemConfigMap(configs: SystemConfig[]) {
|
|||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4 border-b border-dashed py-2 last:border-b-0">
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
<span className="text-right text-xs font-medium text-foreground break-all">{value || "-"}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBooleanConfig(config?: SystemConfig) {
|
||||||
|
if (!config) return "未配置"
|
||||||
|
return config.value === "true" ? "启用" : "禁用"
|
||||||
|
}
|
||||||
|
|
||||||
export function SecurityMain() {
|
export function SecurityMain() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false)
|
const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false)
|
||||||
const [selectedSource, setSelectedSource] = useState<AuthSource | null>(null)
|
const [selectedSource, setSelectedSource] = useState<AuthSource | null>(null)
|
||||||
|
const [runtimeInfo, setRuntimeInfo] = useState({
|
||||||
|
language: "-",
|
||||||
|
platform: "-",
|
||||||
|
timezone: "-",
|
||||||
|
viewport: "-",
|
||||||
|
userAgent: "-",
|
||||||
|
})
|
||||||
|
|
||||||
const systemConfigsQuery = useQuery({
|
const systemConfigsQuery = useQuery({
|
||||||
queryKey: ["admin", "system-configs"],
|
queryKey: ["admin", "system-configs"],
|
||||||
@@ -83,10 +119,14 @@ export function SecurityMain() {
|
|||||||
}, [user, loading, router])
|
}, [user, loading, router])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.is_admin) {
|
setRuntimeInfo({
|
||||||
void systemConfigsQuery.refetch()
|
language: navigator.language || "-",
|
||||||
}
|
platform: navigator.platform || "-",
|
||||||
}, [systemConfigsQuery, user])
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "-",
|
||||||
|
viewport: `${window.innerWidth} x ${window.innerHeight}`,
|
||||||
|
userAgent: navigator.userAgent || "-",
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const updateConfigMutation = useMutation({
|
const updateConfigMutation = useMutation({
|
||||||
mutationFn: async ({ key, value }: { key: SecurityKey; value: boolean }) => {
|
mutationFn: async ({ key, value }: { key: SecurityKey; value: boolean }) => {
|
||||||
@@ -341,7 +381,93 @@ export function SecurityMain() {
|
|||||||
<TabsContent value="operation" />
|
<TabsContent value="operation" />
|
||||||
<TabsContent value="system" />
|
<TabsContent value="system" />
|
||||||
<TabsContent value="other" />
|
<TabsContent value="other" />
|
||||||
<TabsContent value="info" />
|
<TabsContent value="info" className="pt-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<Card className="border border-dashed shadow-sm">
|
||||||
|
<CardHeader className="border-b border-dashed pb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 rounded-lg bg-muted text-muted-foreground">
|
||||||
|
<Info className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base font-semibold">应用信息</CardTitle>
|
||||||
|
<CardDescription className="text-xs">当前前端应用的版本与构建信息</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<InfoRow label="应用名称" value={packageJson.name} />
|
||||||
|
<InfoRow label="版本号" value={packageJson.version} />
|
||||||
|
<InfoRow label="构建时间" value={packageJson.buildDate} />
|
||||||
|
<InfoRow label="Next.js" value={packageJson.dependencies.next} />
|
||||||
|
<InfoRow label="React" value={packageJson.dependencies.react} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border border-dashed shadow-sm">
|
||||||
|
<CardHeader className="border-b border-dashed pb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 rounded-lg bg-muted text-muted-foreground">
|
||||||
|
<Server className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base font-semibold">服务连接</CardTitle>
|
||||||
|
<CardDescription className="text-xs">前端 API 客户端的基础连接参数</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<InfoRow label="API Base URL" value={apiConfig.baseURL || "同源"} />
|
||||||
|
<InfoRow label="请求超时" value={`${apiConfig.timeout}ms`} />
|
||||||
|
<InfoRow label="携带凭证" value={apiConfig.withCredentials ? "是" : "否"} />
|
||||||
|
<InfoRow label="系统配置项" value={`${systemConfigsQuery.data?.length ?? 0} 项`} />
|
||||||
|
<InfoRow label="认证源数量" value={`${authSourcesQuery.data?.length ?? 0} 个`} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border border-dashed shadow-sm">
|
||||||
|
<CardHeader className="border-b border-dashed pb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 rounded-lg bg-muted text-muted-foreground">
|
||||||
|
<Monitor className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base font-semibold">运行环境</CardTitle>
|
||||||
|
<CardDescription className="text-xs">当前浏览器会话的本地运行信息</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<InfoRow label="语言" value={runtimeInfo.language} />
|
||||||
|
<InfoRow label="平台" value={runtimeInfo.platform} />
|
||||||
|
<InfoRow label="时区" value={runtimeInfo.timezone} />
|
||||||
|
<InfoRow label="视口" value={runtimeInfo.viewport} />
|
||||||
|
<InfoRow label="User Agent" value={runtimeInfo.userAgent} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border border-dashed shadow-sm">
|
||||||
|
<CardHeader className="border-b border-dashed pb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 rounded-lg bg-muted text-muted-foreground">
|
||||||
|
<CalendarClock className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base font-semibold">安全配置概览</CardTitle>
|
||||||
|
<CardDescription className="text-xs">当前系统登录与注册开关状态</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<InfoRow label="密码登录" value={formatBooleanConfig(configs.password_login_enabled)} />
|
||||||
|
<InfoRow label="开放注册" value={formatBooleanConfig(configs.registration_enabled)} />
|
||||||
|
<InfoRow label="密码注册" value={formatBooleanConfig(configs.password_register_enabled)} />
|
||||||
|
<InfoRow label="OIDC 登录" value={formatBooleanConfig(configs.oidc_login_enabled)} />
|
||||||
|
<InfoRow label="配置加载状态" value={systemConfigsQuery.isFetching ? "刷新中" : "已加载"} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<AuthSourceModal
|
<AuthSourceModal
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2025 linux.do
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package oauth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/coreos/go-oidc/v3/oidc"
|
|
||||||
"github.com/linux-do/credit/internal/config"
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
oauthConf *oauth2.Config
|
|
||||||
oidcVerifier *oidc.IDTokenVerifier
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
cfg := config.Config.OAuth2
|
|
||||||
|
|
||||||
if cfg.Issuer != "" {
|
|
||||||
ctx := context.Background()
|
|
||||||
// Clean the issuer URL (trim /.well-known/openid-configuration if configured by mistake)
|
|
||||||
issuer := strings.TrimSuffix(strings.TrimSpace(cfg.Issuer), "/")
|
|
||||||
issuer = strings.TrimSuffix(issuer, "/.well-known/openid-configuration")
|
|
||||||
issuer = strings.TrimSuffix(issuer, "/.well-known/oauth-authorization-server")
|
|
||||||
|
|
||||||
provider, err := oidc.NewProvider(ctx, issuer)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[OAuth] 初始化 OIDC Provider 失败: %v,将仅使用 OAuth2", err)
|
|
||||||
} else {
|
|
||||||
oidcVerifier = provider.Verifier(&oidc.Config{
|
|
||||||
ClientID: cfg.ClientID,
|
|
||||||
})
|
|
||||||
log.Printf("[OAuth] OIDC Provider 初始化成功: %s", issuer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化 OAuth2 配置
|
|
||||||
scopes := []string{"profile", "email"}
|
|
||||||
if oidcVerifier != nil {
|
|
||||||
// 启用 OIDC 时添加 openid scope
|
|
||||||
scopes = append([]string{oidc.ScopeOpenID}, scopes...)
|
|
||||||
}
|
|
||||||
|
|
||||||
oauthConf = &oauth2.Config{
|
|
||||||
ClientID: cfg.ClientID,
|
|
||||||
ClientSecret: cfg.ClientSecret,
|
|
||||||
RedirectURL: cfg.RedirectURI,
|
|
||||||
Scopes: scopes,
|
|
||||||
Endpoint: oauth2.Endpoint{
|
|
||||||
AuthURL: cfg.AuthorizationEndpoint,
|
|
||||||
TokenURL: cfg.TokenEndpoint,
|
|
||||||
AuthStyle: oauth2.AuthStyleAutoDetect,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2025 linux.do
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package oauth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
|
|
||||||
"github.com/gin-contrib/sessions"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/linux-do/credit/internal/common"
|
|
||||||
"github.com/linux-do/credit/internal/config"
|
|
||||||
"github.com/linux-do/credit/internal/db"
|
|
||||||
"github.com/linux-do/credit/internal/model"
|
|
||||||
"github.com/linux-do/credit/internal/otel_trace"
|
|
||||||
"go.opentelemetry.io/otel/codes"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetUserIDFromSession(s sessions.Session) uint64 {
|
|
||||||
userID, ok := s.Get(UserIDKey).(uint64)
|
|
||||||
if !ok {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return userID
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetUserIDFromContext(c *gin.Context) uint64 {
|
|
||||||
session := sessions.Default(c)
|
|
||||||
return GetUserIDFromSession(session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// doOAuth 执行 OAuth2/OIDC 流程
|
|
||||||
func doOAuth(ctx context.Context, code string, nonce string) (*model.User, error) {
|
|
||||||
ctx, span := otel_trace.Start(ctx, "OAuth")
|
|
||||||
defer span.End()
|
|
||||||
|
|
||||||
var userInfo model.OAuthUserInfo
|
|
||||||
|
|
||||||
// 使用授权码换取 Token
|
|
||||||
token, err := oauthConf.Exchange(ctx, code)
|
|
||||||
if err != nil {
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if oidcVerifier != nil {
|
|
||||||
if rawIDToken, ok := token.Extra("id_token").(string); ok {
|
|
||||||
idToken, verifyErr := oidcVerifier.Verify(ctx, rawIDToken)
|
|
||||||
if verifyErr != nil {
|
|
||||||
err := fmt.Errorf("%s: %w", IDTokenVerifyFailed, verifyErr)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if nonce != "" && idToken.Nonce != nonce {
|
|
||||||
span.SetStatus(codes.Error, NonceMismatch)
|
|
||||||
return nil, errors.New(NonceMismatch)
|
|
||||||
}
|
|
||||||
if claimsErr := idToken.Claims(&userInfo); claimsErr != nil {
|
|
||||||
span.SetStatus(codes.Error, claimsErr.Error())
|
|
||||||
return nil, claimsErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if userInfo.GetID() == 0 {
|
|
||||||
client := oauthConf.Client(ctx, token)
|
|
||||||
resp, httpErr := client.Get(config.Config.OAuth2.UserEndpoint)
|
|
||||||
if httpErr != nil {
|
|
||||||
span.SetStatus(codes.Error, httpErr.Error())
|
|
||||||
return nil, httpErr
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
responseData, readErr := io.ReadAll(resp.Body)
|
|
||||||
if readErr != nil {
|
|
||||||
span.SetStatus(codes.Error, readErr.Error())
|
|
||||||
return nil, readErr
|
|
||||||
}
|
|
||||||
if unmarshalErr := json.Unmarshal(responseData, &userInfo); unmarshalErr != nil {
|
|
||||||
span.SetStatus(codes.Error, unmarshalErr.Error())
|
|
||||||
return nil, unmarshalErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !userInfo.Active {
|
|
||||||
err := errors.New(common.BannedAccount)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var user model.User
|
|
||||||
err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var holder model.User
|
|
||||||
if conflictErr := tx.Where("username = ? AND id != ?", userInfo.Username, userInfo.GetID()).First(&holder).Error; conflictErr == nil {
|
|
||||||
// 存在冲突 -> 将占用者改名并注销
|
|
||||||
newParams := map[string]interface{}{
|
|
||||||
"username": fmt.Sprintf("%s已注销: %s", holder.Username, uuid.NewString()),
|
|
||||||
"is_active": false,
|
|
||||||
}
|
|
||||||
if updateErr := tx.Model(&holder).Updates(newParams).Error; updateErr != nil {
|
|
||||||
return updateErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据 ID 处理当前用户的 更新 或 创建
|
|
||||||
if queryErr := tx.Where("id = ?", userInfo.GetID()).First(&user).Error; queryErr == nil {
|
|
||||||
// 用户已存在 -> 更新信息
|
|
||||||
if activeErr := user.CheckActive(); activeErr != nil {
|
|
||||||
return activeErr
|
|
||||||
}
|
|
||||||
user.UpdateFromOAuthInfo(&userInfo)
|
|
||||||
if saveErr := tx.Save(&user).Error; saveErr != nil {
|
|
||||||
return saveErr
|
|
||||||
}
|
|
||||||
} else if errors.Is(queryErr, gorm.ErrRecordNotFound) {
|
|
||||||
// 用户不存在 -> 创建新用户
|
|
||||||
user = model.User{}
|
|
||||||
if createErr := user.CreateUser(tx, &userInfo); createErr != nil {
|
|
||||||
return createErr
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return queryErr
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &user, nil
|
|
||||||
}
|
|
||||||
@@ -27,6 +27,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -134,6 +135,17 @@ var (
|
|||||||
testJWKS jose.JSONWebKeySet
|
testJWKS jose.JSONWebKeySet
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testIssuerURL = "https://connect.linux.do"
|
||||||
|
testAuthURL = "https://connect.linux.do/oauth2/authorize"
|
||||||
|
testTokenURL = "https://connect.linux.do/oauth2/token"
|
||||||
|
testJWKSURL = "https://connect.linux.do/oauth2/keys"
|
||||||
|
testClientID = "test_client_id"
|
||||||
|
testClientSecret = "test_client_secret"
|
||||||
|
testSourceName = "linuxdo"
|
||||||
|
testSourceDisplay = "LINUX DO"
|
||||||
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
var err error
|
var err error
|
||||||
testRSAPrivateKey, err = rsa.GenerateKey(rand.Reader, 2048)
|
testRSAPrivateKey, err = rsa.GenerateKey(rand.Reader, 2048)
|
||||||
@@ -151,7 +163,50 @@ func init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func seedTestAuthSource(t *testing.T, dbConn *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
if err := dbConn.Create(&model.AuthSource{
|
||||||
|
ID: 100,
|
||||||
|
Name: testSourceName,
|
||||||
|
Type: model.AuthSourceTypeOIDC,
|
||||||
|
DisplayName: testSourceDisplay,
|
||||||
|
IsActive: true,
|
||||||
|
ClientID: testClientID,
|
||||||
|
ClientSecret: testClientSecret,
|
||||||
|
OpenIDDiscoveryURL: testIssuerURL,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("failed to seed auth source: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func oidcDiscoveryResponse() *http.Response {
|
||||||
|
body := fmt.Sprintf(`{
|
||||||
|
"issuer": %q,
|
||||||
|
"authorization_endpoint": %q,
|
||||||
|
"token_endpoint": %q,
|
||||||
|
"jwks_uri": %q,
|
||||||
|
"response_types_supported": ["code"],
|
||||||
|
"subject_types_supported": ["public"],
|
||||||
|
"id_token_signing_alg_values_supported": ["RS256"]
|
||||||
|
}`, testIssuerURL, testAuthURL, testTokenURL, testJWKSURL)
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Header: make(http.Header),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jwksResponse() *http.Response {
|
||||||
|
jwksJSON, _ := json.Marshal(testJWKS)
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: io.NopCloser(bytes.NewReader(jwksJSON)),
|
||||||
|
Header: make(http.Header),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type mockClaims struct {
|
type mockClaims struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
Issuer string `json:"iss"`
|
Issuer string `json:"iss"`
|
||||||
Subject string `json:"sub"`
|
Subject string `json:"sub"`
|
||||||
Audience string `json:"aud"`
|
Audience string `json:"aud"`
|
||||||
@@ -169,7 +224,9 @@ func generateMockIDToken(issuer, sub, aud, nonce, username, email, name string)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
id, _ := strconv.ParseUint(sub, 10, 64)
|
||||||
claims := mockClaims{
|
claims := mockClaims{
|
||||||
|
ID: id,
|
||||||
Issuer: issuer,
|
Issuer: issuer,
|
||||||
Subject: sub,
|
Subject: sub,
|
||||||
Audience: aud,
|
Audience: aud,
|
||||||
@@ -236,19 +293,6 @@ func setupTestRouter(dbConn *gorm.DB, mockRedis *mockRedisClient, mockClient *ht
|
|||||||
db.SetDB(dbConn)
|
db.SetDB(dbConn)
|
||||||
db.Redis = mockRedis
|
db.Redis = mockRedis
|
||||||
|
|
||||||
// Setup oauth vars
|
|
||||||
oauthConf = &oauth2.Config{
|
|
||||||
ClientID: config.Config.OAuth2.ClientID,
|
|
||||||
ClientSecret: config.Config.OAuth2.ClientSecret,
|
|
||||||
RedirectURL: config.Config.OAuth2.RedirectURI,
|
|
||||||
Scopes: []string{"profile", "email"},
|
|
||||||
Endpoint: oauth2.Endpoint{
|
|
||||||
AuthURL: config.Config.OAuth2.AuthorizationEndpoint,
|
|
||||||
TokenURL: config.Config.OAuth2.TokenEndpoint,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
oidcVerifier = nil
|
|
||||||
|
|
||||||
api := r.Group("/api/v1")
|
api := r.Group("/api/v1")
|
||||||
{
|
{
|
||||||
api.GET("/oauth/sources", GetLoginSources)
|
api.GET("/oauth/sources", GetLoginSources)
|
||||||
@@ -288,12 +332,7 @@ func initializeTestConfig() {
|
|||||||
config.Config.App.SessionCookieName = "test_session_id"
|
config.Config.App.SessionCookieName = "test_session_id"
|
||||||
config.Config.App.SessionSecret = "test_session_secret"
|
config.Config.App.SessionSecret = "test_session_secret"
|
||||||
config.Config.App.APIPrefix = "/api"
|
config.Config.App.APIPrefix = "/api"
|
||||||
config.Config.OAuth2.ClientID = "test_client_id"
|
config.Config.App.FrontendURL = "http://localhost:3000"
|
||||||
config.Config.OAuth2.ClientSecret = "test_client_secret"
|
|
||||||
config.Config.OAuth2.RedirectURI = "http://localhost:3000/callback"
|
|
||||||
config.Config.OAuth2.AuthorizationEndpoint = "https://connect.linux.do/oauth2/authorize"
|
|
||||||
config.Config.OAuth2.TokenEndpoint = "https://connect.linux.do/oauth2/token"
|
|
||||||
config.Config.OAuth2.UserEndpoint = "https://connect.linux.do/api/user"
|
|
||||||
config.Config.OpenAPIRisk.Enabled = false
|
config.Config.OpenAPIRisk.Enabled = false
|
||||||
config.Config.OpenAPIRisk.BaseURL = ""
|
config.Config.OpenAPIRisk.BaseURL = ""
|
||||||
config.Config.OpenAPIRisk.BlockRiskLevels = []string{}
|
config.Config.OpenAPIRisk.BlockRiskLevels = []string{}
|
||||||
@@ -352,13 +391,12 @@ func TestGetLoginSources(t *testing.T) {
|
|||||||
t.Fatalf("failed to unmarshal response: %v", err)
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(resp.Data) != 2 {
|
if len(resp.Data) != 1 {
|
||||||
t.Fatalf("expected 2 active sources, got %d", len(resp.Data))
|
t.Fatalf("expected 1 active source, got %d", len(resp.Data))
|
||||||
}
|
}
|
||||||
|
|
||||||
names := []string{resp.Data[0].Name, resp.Data[1].Name}
|
if resp.Data[0].Name != "github" {
|
||||||
if !strings.Contains(strings.Join(names, ","), "default") || !strings.Contains(strings.Join(names, ","), "github") {
|
t.Errorf("expected github source, got %s", resp.Data[0].Name)
|
||||||
t.Errorf("expected default and github sources, got %v", names)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test disabling OIDC
|
// Test disabling OIDC
|
||||||
@@ -379,10 +417,14 @@ func TestGetLoginURL(t *testing.T) {
|
|||||||
initializeTestConfig()
|
initializeTestConfig()
|
||||||
dbConn := setupTestDB(t)
|
dbConn := setupTestDB(t)
|
||||||
mockRedis := newMockRedisClient()
|
mockRedis := newMockRedisClient()
|
||||||
|
seedTestAuthSource(t, dbConn)
|
||||||
|
|
||||||
httpMock := &http.Client{
|
httpMock := &http.Client{
|
||||||
Transport: &mockRoundTripper{
|
Transport: &mockRoundTripper{
|
||||||
roundTripFunc: func(req *http.Request) (*http.Response, error) {
|
roundTripFunc: func(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.Method == http.MethodGet && strings.Contains(req.URL.String(), "/.well-known/openid-configuration") {
|
||||||
|
return oidcDiscoveryResponse(), nil
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("unexpected request")
|
return nil, fmt.Errorf("unexpected request")
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -404,7 +446,7 @@ func TestGetLoginURL(t *testing.T) {
|
|||||||
t.Fatalf("failed to unmarshal response: %v", err)
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(resp.Data.AuthorizeURL, config.Config.OAuth2.AuthorizationEndpoint) {
|
if !strings.Contains(resp.Data.AuthorizeURL, testAuthURL) {
|
||||||
t.Errorf("invalid authorize URL: %s", resp.Data.AuthorizeURL)
|
t.Errorf("invalid authorize URL: %s", resp.Data.AuthorizeURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +466,7 @@ func TestGetLoginURL(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to decode state payload: %v", err)
|
t.Fatalf("failed to decode state payload: %v", err)
|
||||||
}
|
}
|
||||||
if payload.SourceName != "default" || payload.Purpose != OAuthPurposeLogin {
|
if payload.SourceName != testSourceName || payload.Purpose != OAuthPurposeLogin {
|
||||||
t.Errorf("unexpected payload: %+v", payload)
|
t.Errorf("unexpected payload: %+v", payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,23 +567,23 @@ func TestCallbackLoginAndUserInfo(t *testing.T) {
|
|||||||
initializeTestConfig()
|
initializeTestConfig()
|
||||||
dbConn := setupTestDB(t)
|
dbConn := setupTestDB(t)
|
||||||
mockRedis := newMockRedisClient()
|
mockRedis := newMockRedisClient()
|
||||||
|
seedTestAuthSource(t, dbConn)
|
||||||
|
state := uuid.NewString()
|
||||||
|
|
||||||
// 1. Mock the outgoing HTTP client for token exchange and user info fetching
|
// 1. Mock the outgoing HTTP client for token exchange and user info fetching
|
||||||
httpMock := &http.Client{
|
httpMock := &http.Client{
|
||||||
Transport: &mockRoundTripper{
|
Transport: &mockRoundTripper{
|
||||||
roundTripFunc: func(req *http.Request) (*http.Response, error) {
|
roundTripFunc: func(req *http.Request) (*http.Response, error) {
|
||||||
// Handle Token Exchange
|
if req.Method == http.MethodGet && strings.Contains(req.URL.String(), "/.well-known/openid-configuration") {
|
||||||
if req.Method == http.MethodPost && req.URL.String() == config.Config.OAuth2.TokenEndpoint {
|
return oidcDiscoveryResponse(), nil
|
||||||
body := `{"access_token":"mock_access_token","token_type":"Bearer","expires_in":3600}`
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Body: io.NopCloser(strings.NewReader(body)),
|
|
||||||
Header: make(http.Header),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
// Handle User Info fetch
|
if req.Method == http.MethodGet && req.URL.String() == testJWKSURL {
|
||||||
if req.Method == http.MethodGet && req.URL.String() == config.Config.OAuth2.UserEndpoint {
|
return jwksResponse(), nil
|
||||||
body := `{"id":88888,"sub":"oauth_user_88888","username":"test_oauth_user","email":"oauth@linux.do","name":"Oauth Test User","active":true,"trust_level":3}`
|
}
|
||||||
|
// Handle Token Exchange
|
||||||
|
if req.Method == http.MethodPost && req.URL.String() == testTokenURL {
|
||||||
|
idToken := generateMockIDToken(testIssuerURL, "88888", testClientID, state, "test_oauth_user", "oauth@linux.do", "Oauth Test User")
|
||||||
|
body := fmt.Sprintf(`{"access_token":"mock_access_token","token_type":"Bearer","expires_in":3600,"id_token":"%s"}`, idToken)
|
||||||
return &http.Response{
|
return &http.Response{
|
||||||
StatusCode: http.StatusOK,
|
StatusCode: http.StatusOK,
|
||||||
Body: io.NopCloser(strings.NewReader(body)),
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
@@ -556,9 +598,8 @@ func TestCallbackLoginAndUserInfo(t *testing.T) {
|
|||||||
router := setupTestRouter(dbConn, mockRedis, httpMock)
|
router := setupTestRouter(dbConn, mockRedis, httpMock)
|
||||||
|
|
||||||
// 2. Setup state in Redis
|
// 2. Setup state in Redis
|
||||||
state := uuid.NewString()
|
|
||||||
payloadValue, _ := encodeOAuthStatePayload(oauthStatePayload{
|
payloadValue, _ := encodeOAuthStatePayload(oauthStatePayload{
|
||||||
SourceName: "default",
|
SourceName: testSourceName,
|
||||||
Purpose: OAuthPurposeLogin,
|
Purpose: OAuthPurposeLogin,
|
||||||
})
|
})
|
||||||
mockRedis.Set(context.Background(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration)
|
mockRedis.Set(context.Background(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration)
|
||||||
@@ -582,13 +623,13 @@ func TestCallbackLoginAndUserInfo(t *testing.T) {
|
|||||||
t.Errorf("expected logged_in status, got %s", callbackResp.Data.Status)
|
t.Errorf("expected logged_in status, got %s", callbackResp.Data.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if callbackResp.Data.User.Username != "test_oauth_user" || callbackResp.Data.User.ID != 1 {
|
if callbackResp.Data.User.Username != "test_oauth_user" || callbackResp.Data.User.ID != 88888 {
|
||||||
t.Errorf("unexpected user returned: %+v", callbackResp.Data.User)
|
t.Errorf("unexpected user returned: %+v", callbackResp.Data.User)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify user is created in database
|
// Verify user is created in database
|
||||||
var user model.User
|
var user model.User
|
||||||
if err := dbConn.First(&user, "id = ?", 1).Error; err != nil {
|
if err := dbConn.First(&user, "id = ?", 88888).Error; err != nil {
|
||||||
t.Fatalf("user was not created in DB: %v", err)
|
t.Fatalf("user was not created in DB: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,15 +660,15 @@ func TestCallbackLoginAndUserInfo(t *testing.T) {
|
|||||||
httpMock2 := &http.Client{
|
httpMock2 := &http.Client{
|
||||||
Transport: &mockRoundTripper{
|
Transport: &mockRoundTripper{
|
||||||
roundTripFunc: func(req *http.Request) (*http.Response, error) {
|
roundTripFunc: func(req *http.Request) (*http.Response, error) {
|
||||||
if req.Method == http.MethodPost && req.URL.String() == config.Config.OAuth2.TokenEndpoint {
|
if req.Method == http.MethodGet && strings.Contains(req.URL.String(), "/.well-known/openid-configuration") {
|
||||||
body := `{"access_token":"mock_access_token","token_type":"Bearer","expires_in":3600}`
|
return oidcDiscoveryResponse(), nil
|
||||||
return &http.Response{
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Body: io.NopCloser(strings.NewReader(body)),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
if req.Method == http.MethodGet && req.URL.String() == config.Config.OAuth2.UserEndpoint {
|
if req.Method == http.MethodGet && req.URL.String() == testJWKSURL {
|
||||||
body := `{"id":99999,"sub":"oauth_user_99999","username":"test_oauth_user","email":"another@linux.do","name":"Another User","active":true,"trust_level":1}`
|
return jwksResponse(), nil
|
||||||
|
}
|
||||||
|
if req.Method == http.MethodPost && req.URL.String() == testTokenURL {
|
||||||
|
idToken := generateMockIDToken(testIssuerURL, "99999", testClientID, state2, "test_oauth_user", "another@linux.do", "Another User")
|
||||||
|
body := fmt.Sprintf(`{"access_token":"mock_access_token","token_type":"Bearer","expires_in":3600,"id_token":"%s"}`, idToken)
|
||||||
return &http.Response{
|
return &http.Response{
|
||||||
StatusCode: http.StatusOK,
|
StatusCode: http.StatusOK,
|
||||||
Body: io.NopCloser(strings.NewReader(body)),
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ package oauth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -53,33 +51,30 @@ type CallbackRequest struct {
|
|||||||
Code string `json:"code" binding:"required"`
|
Code string `json:"code" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultAuthSource() *model.AuthSource {
|
func GetUserIDFromSession(s sessions.Session) uint64 {
|
||||||
if config.Config.OAuth2.ClientID == "" || config.Config.OAuth2.RedirectURI == "" {
|
userID, ok := s.Get(UserIDKey).(uint64)
|
||||||
return nil
|
if !ok {
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
source := &model.AuthSource{
|
return userID
|
||||||
Name: "default",
|
}
|
||||||
Type: model.AuthSourceTypeOIDC,
|
|
||||||
DisplayName: "默认认证源",
|
func GetUserIDFromContext(c *gin.Context) uint64 {
|
||||||
IsActive: true,
|
session := sessions.Default(c)
|
||||||
ClientID: config.Config.OAuth2.ClientID,
|
return GetUserIDFromSession(session)
|
||||||
ClientSecret: config.Config.OAuth2.ClientSecret,
|
|
||||||
OpenIDDiscoveryURL: config.Config.OAuth2.Issuer,
|
|
||||||
}
|
|
||||||
if source.DisplayName == "" {
|
|
||||||
source.DisplayName = "默认认证源"
|
|
||||||
}
|
|
||||||
return source
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveAuthSource(sourceName string) (*model.AuthSource, error) {
|
func resolveAuthSource(sourceName string) (*model.AuthSource, error) {
|
||||||
name := strings.TrimSpace(strings.ToLower(sourceName))
|
name := strings.TrimSpace(strings.ToLower(sourceName))
|
||||||
if name == "" || name == "default" {
|
if name == "" {
|
||||||
source := defaultAuthSource()
|
sources, err := model.GetActiveAuthSources()
|
||||||
if source == nil {
|
if err != nil {
|
||||||
return nil, errors.New("默认认证源未配置")
|
return nil, err
|
||||||
}
|
}
|
||||||
return source, nil
|
if len(sources) == 0 {
|
||||||
|
return nil, errors.New("未配置可用认证源")
|
||||||
|
}
|
||||||
|
return &sources[0], nil
|
||||||
}
|
}
|
||||||
return model.GetAuthSourceByName(name)
|
return model.GetAuthSourceByName(name)
|
||||||
}
|
}
|
||||||
@@ -90,24 +85,11 @@ func activeLoginSources() []AuthSourceView {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
sources := make([]AuthSourceView, 0, 4)
|
|
||||||
if source := defaultAuthSource(); source != nil {
|
|
||||||
source.Sanitize()
|
|
||||||
sources = append(sources, AuthSourceView{
|
|
||||||
ID: source.ID,
|
|
||||||
Name: source.Name,
|
|
||||||
Type: source.Type,
|
|
||||||
DisplayName: source.DisplayName,
|
|
||||||
IsActive: source.IsActive,
|
|
||||||
IconURL: source.IconURL,
|
|
||||||
ClientSecretConfigured: source.ClientSecretConfigured,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
dbSources, err := model.GetActiveAuthSources()
|
dbSources, err := model.GetActiveAuthSources()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return sources
|
return nil
|
||||||
}
|
}
|
||||||
|
sources := make([]AuthSourceView, 0, len(dbSources))
|
||||||
for _, source := range dbSources {
|
for _, source := range dbSources {
|
||||||
sources = append(sources, AuthSourceView{
|
sources = append(sources, AuthSourceView{
|
||||||
ID: source.ID,
|
ID: source.ID,
|
||||||
@@ -126,9 +108,6 @@ func frontendLoginRedirectURL() string {
|
|||||||
if config.Config.App.FrontendURL != "" {
|
if config.Config.App.FrontendURL != "" {
|
||||||
return strings.TrimRight(config.Config.App.FrontendURL, "/") + "/login"
|
return strings.TrimRight(config.Config.App.FrontendURL, "/") + "/login"
|
||||||
}
|
}
|
||||||
if config.Config.OAuth2.RedirectURI != "" {
|
|
||||||
return config.Config.OAuth2.RedirectURI
|
|
||||||
}
|
|
||||||
return "/login"
|
return "/login"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,24 +116,6 @@ func buildOAuthConfig(ctx context.Context, source *model.AuthSource, redirectURL
|
|||||||
return nil, nil, errors.New("认证源不能为空")
|
return nil, nil, errors.New("认证源不能为空")
|
||||||
}
|
}
|
||||||
|
|
||||||
if source.Name == "default" {
|
|
||||||
scopes := []string{"profile", "email"}
|
|
||||||
if oidcVerifier != nil {
|
|
||||||
scopes = append([]string{oidc.ScopeOpenID}, scopes...)
|
|
||||||
}
|
|
||||||
return &oauth2.Config{
|
|
||||||
ClientID: config.Config.OAuth2.ClientID,
|
|
||||||
ClientSecret: config.Config.OAuth2.ClientSecret,
|
|
||||||
RedirectURL: redirectURL,
|
|
||||||
Scopes: scopes,
|
|
||||||
Endpoint: oauth2.Endpoint{
|
|
||||||
AuthURL: config.Config.OAuth2.AuthorizationEndpoint,
|
|
||||||
TokenURL: config.Config.OAuth2.TokenEndpoint,
|
|
||||||
AuthStyle: oauth2.AuthStyleAutoDetect,
|
|
||||||
},
|
|
||||||
}, oidcVerifier, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if source.OpenIDDiscoveryURL == "" {
|
if source.OpenIDDiscoveryURL == "" {
|
||||||
return nil, nil, errors.New("OIDC 认证源必须配置 Discovery URL")
|
return nil, nil, errors.New("OIDC 认证源必须配置 Discovery URL")
|
||||||
}
|
}
|
||||||
@@ -260,29 +221,6 @@ func buildOAuthUserInfo(ctx context.Context, source *model.AuthSource, code stri
|
|||||||
userInfo.Name = userInfo.Username
|
userInfo.Name = userInfo.Username
|
||||||
}
|
}
|
||||||
|
|
||||||
if userInfo.Username == "" {
|
|
||||||
client := authConfig.Client(ctx, token)
|
|
||||||
userEndpoint := config.Config.OAuth2.UserEndpoint
|
|
||||||
if source.Name != "default" {
|
|
||||||
userEndpoint = ""
|
|
||||||
}
|
|
||||||
if userEndpoint != "" {
|
|
||||||
resp, httpErr := client.Get(userEndpoint)
|
|
||||||
if httpErr != nil {
|
|
||||||
return nil, httpErr
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
responseData, readErr := io.ReadAll(resp.Body)
|
|
||||||
if readErr != nil {
|
|
||||||
return nil, readErr
|
|
||||||
}
|
|
||||||
if unmarshalErr := json.Unmarshal(responseData, userInfo); unmarshalErr != nil {
|
|
||||||
return nil, unmarshalErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return userInfo, nil
|
return userInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,10 +274,10 @@ func GetLoginSources(c *gin.Context) {
|
|||||||
|
|
||||||
// GetLoginURL 获取登录授权地址
|
// GetLoginURL 获取登录授权地址
|
||||||
// @Summary 获取登录授权地址
|
// @Summary 获取登录授权地址
|
||||||
// @Description 根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用默认认证源。
|
// @Description 根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用第一个启用的认证源。
|
||||||
// @Tags oauth
|
// @Tags oauth
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param source query string false "认证源名称,为空使用默认源"
|
// @Param source query string false "认证源名称,为空使用第一个启用的认证源"
|
||||||
// @Success 200 {object} util.ResponseAny{data=oauth.OAuthAuthorizeResponse} "授权 URL"
|
// @Success 200 {object} util.ResponseAny{data=oauth.OAuthAuthorizeResponse} "授权 URL"
|
||||||
// @Failure 400 {object} util.ResponseAny "认证源不存在或未配置"
|
// @Failure 400 {object} util.ResponseAny "认证源不存在或未配置"
|
||||||
// @Failure 500 {object} util.ResponseAny "Redis 异常或构造 URL 失败"
|
// @Failure 500 {object} util.ResponseAny "Redis 异常或构造 URL 失败"
|
||||||
@@ -522,16 +460,8 @@ func Callback(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, util.Err(uniqueErr.Error()))
|
c.JSON(http.StatusInternalServerError, util.Err(uniqueErr.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user = model.User{
|
userInfo.Username = username
|
||||||
Username: username,
|
if err := user.CreateUser(db.DB(ctx), userInfo); err != nil {
|
||||||
Nickname: userInfo.Name,
|
|
||||||
AvatarUrl: userInfo.AvatarUrl,
|
|
||||||
TrustLevel: userInfo.TrustLevel,
|
|
||||||
SignKey: util.GenerateUniqueIDSimple(),
|
|
||||||
IsActive: true,
|
|
||||||
LastLoginAt: time.Now(),
|
|
||||||
}
|
|
||||||
if err := db.DB(ctx).Create(&user).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,6 @@ func (h *CleanupUnusedUploadsHandler) Execute(ctx context.Context, payload []byt
|
|||||||
}
|
}
|
||||||
|
|
||||||
msg := fmt.Sprintf("共处理 %d 个文件,成功删除 %d 个", totalProcessed, totalDeleted)
|
msg := fmt.Sprintf("共处理 %d 个文件,成功删除 %d 个", totalProcessed, totalDeleted)
|
||||||
task.AppendLog(ctx, msg)
|
task.AppendLog(ctx, "%s", msg)
|
||||||
return &task.TaskResult{Message: msg}, nil
|
return &task.TaskResult{Message: msg}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import "time"
|
|||||||
|
|
||||||
type configModel struct {
|
type configModel struct {
|
||||||
App appConfig `mapstructure:"app"`
|
App appConfig `mapstructure:"app"`
|
||||||
OAuth2 OAuth2Config `mapstructure:"oauth2"`
|
|
||||||
Database databaseConfig `mapstructure:"database"`
|
Database databaseConfig `mapstructure:"database"`
|
||||||
Redis redisConfig `mapstructure:"redis"`
|
Redis redisConfig `mapstructure:"redis"`
|
||||||
Log logConfig `mapstructure:"log"`
|
Log logConfig `mapstructure:"log"`
|
||||||
@@ -54,17 +53,6 @@ func (a *appConfig) IsProduction() bool {
|
|||||||
return a.Env == "production"
|
return a.Env == "production"
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth2Config OAuth2/OIDC认证配置
|
|
||||||
type OAuth2Config struct {
|
|
||||||
ClientID string `mapstructure:"client_id"`
|
|
||||||
ClientSecret string `mapstructure:"client_secret"`
|
|
||||||
RedirectURI string `mapstructure:"redirect_uri"`
|
|
||||||
Issuer string `mapstructure:"issuer"`
|
|
||||||
AuthorizationEndpoint string `mapstructure:"authorization_endpoint"`
|
|
||||||
TokenEndpoint string `mapstructure:"token_endpoint"`
|
|
||||||
UserEndpoint string `mapstructure:"user_endpoint"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// databaseConfig 数据库配置
|
// databaseConfig 数据库配置
|
||||||
type databaseConfig struct {
|
type databaseConfig struct {
|
||||||
Enabled bool `mapstructure:"enabled"`
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ func ListExternalAccountsByUserID(userID uint64) ([]ExternalAccountView, error)
|
|||||||
if account.AuthSourceID == 0 {
|
if account.AuthSourceID == 0 {
|
||||||
name = "default"
|
name = "default"
|
||||||
sourceType = "oidc"
|
sourceType = "oidc"
|
||||||
label = "默认认证源"
|
label = "历史认证源"
|
||||||
} else {
|
} else {
|
||||||
source, err := GetAuthSourceByID(account.AuthSourceID)
|
source, err := GetAuthSourceByID(account.AuthSourceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user