From efe638989d81e1231e19229a1d4db0ff9bed6e6f Mon Sep 17 00:00:00 2001 From: zhaolion Date: Sun, 4 Jan 2026 14:36:55 +0800 Subject: [PATCH 01/34] feat: add StreamLake channel type support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new channel type (57) for StreamLake integration with URL path handling that trims the /v1 prefix from request URLs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- constant/channel.go | 1 + relay/common/relay_utils.go | 5 + web/src/constants/channel.constants.js | 333 +++++++++++++------------ 3 files changed, 173 insertions(+), 166 deletions(-) diff --git a/constant/channel.go b/constant/channel.go index 023faea3ddc6..4cf0ebb028a9 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -54,6 +54,7 @@ const ( ChannelTypeDoubaoVideo = 54 ChannelTypeSora = 55 ChannelTypeReplicate = 56 + ChannelTypeStreamlake = 57 ChannelTypeDummy // this one is only for count, do not add any channel after this ) diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go index b662f905366d..35a1448f97c7 100644 --- a/relay/common/relay_utils.go +++ b/relay/common/relay_utils.go @@ -25,6 +25,11 @@ type HasImage interface { func GetFullRequestURL(baseURL string, requestURL string, channelType int) string { fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL) + if channelType == constant.ChannelTypeStreamlake { + fullRequestURL = fmt.Sprintf("%s%s", baseURL, strings.TrimPrefix(requestURL, "/v1")) + return fullRequestURL + } + if strings.HasPrefix(baseURL, "https://gateway.ai.cloudflare.com") { switch channelType { case constant.ChannelTypeOpenAI: diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js index 0d487958e9cf..56deec788bf4 100644 --- a/web/src/constants/channel.constants.js +++ b/web/src/constants/channel.constants.js @@ -18,172 +18,173 @@ For commercial licensing, please contact support@quantumnous.com */ export const CHANNEL_OPTIONS = [ - { value: 1, color: 'green', label: 'OpenAI' }, - { - value: 2, - color: 'light-blue', - label: 'Midjourney Proxy', - }, - { - value: 5, - color: 'blue', - label: 'Midjourney Proxy Plus', - }, - { - value: 36, - color: 'purple', - label: 'Suno API', - }, - { value: 4, color: 'grey', label: 'Ollama' }, - { - value: 14, - color: 'indigo', - label: 'Anthropic Claude', - }, - { - value: 33, - color: 'indigo', - label: 'AWS Claude', - }, - { value: 41, color: 'blue', label: 'Vertex AI' }, - { - value: 3, - color: 'teal', - label: 'Azure OpenAI', - }, - { - value: 34, - color: 'purple', - label: 'Cohere', - }, - { value: 39, color: 'grey', label: 'Cloudflare' }, - { value: 43, color: 'blue', label: 'DeepSeek' }, - { - value: 15, - color: 'blue', - label: '百度文心千帆', - }, - { - value: 46, - color: 'blue', - label: '百度文心千帆V2', - }, - { - value: 17, - color: 'orange', - label: '阿里通义千问', - }, - { - value: 18, - color: 'blue', - label: '讯飞星火认知', - }, - { - value: 16, - color: 'violet', - label: '智谱 ChatGLM(已经弃用,请使用智谱 GLM-4V)', - }, - { - value: 26, - color: 'purple', - label: '智谱 GLM-4V', - }, - { - value: 27, - color: 'blue', - label: 'Perplexity', - }, - { - value: 24, - color: 'orange', - label: 'Google Gemini', - }, - { - value: 11, - color: 'orange', - label: 'Google PaLM2', - }, - { - value: 47, - color: 'blue', - label: 'Xinference', - }, - { value: 25, color: 'green', label: 'Moonshot' }, - { value: 20, color: 'green', label: 'OpenRouter' }, - { value: 19, color: 'blue', label: '360 智脑' }, - { value: 23, color: 'teal', label: '腾讯混元' }, - { value: 31, color: 'green', label: '零一万物' }, - { value: 35, color: 'green', label: 'MiniMax' }, - { value: 37, color: 'teal', label: 'Dify' }, - { value: 38, color: 'blue', label: 'Jina' }, - { value: 40, color: 'purple', label: 'SiliconCloud' }, - { value: 42, color: 'blue', label: 'Mistral AI' }, - { value: 8, color: 'pink', label: '自定义渠道' }, - { - value: 22, - color: 'blue', - label: '知识库:FastGPT', - }, - { - value: 21, - color: 'purple', - label: '知识库:AI Proxy', - }, - { - value: 44, - color: 'purple', - label: '嵌入模型:MokaAI M3E', - }, - { - value: 45, - color: 'blue', - label: '字节火山方舟、豆包通用', - }, - { - value: 48, - color: 'blue', - label: 'xAI', - }, - { - value: 49, - color: 'blue', - label: 'Coze', - }, - { - value: 50, - color: 'green', - label: '可灵', - }, - { - value: 51, - color: 'blue', - label: '即梦', - }, - { - value: 52, - color: 'purple', - label: 'Vidu', - }, - { - value: 53, - color: 'blue', - label: 'SubModel', - }, - { - value: 54, - color: 'blue', - label: '豆包视频', - }, - { - value: 55, - color: 'green', - label: 'Sora', - }, - { - value: 56, - color: 'blue', - label: 'Replicate', - }, + {value: 1, color: 'green', label: 'OpenAI'}, + { + value: 2, + color: 'light-blue', + label: 'Midjourney Proxy', + }, + { + value: 5, + color: 'blue', + label: 'Midjourney Proxy Plus', + }, + { + value: 36, + color: 'purple', + label: 'Suno API', + }, + {value: 4, color: 'grey', label: 'Ollama'}, + { + value: 14, + color: 'indigo', + label: 'Anthropic Claude', + }, + { + value: 33, + color: 'indigo', + label: 'AWS Claude', + }, + {value: 41, color: 'blue', label: 'Vertex AI'}, + { + value: 3, + color: 'teal', + label: 'Azure OpenAI', + }, + { + value: 34, + color: 'purple', + label: 'Cohere', + }, + {value: 39, color: 'grey', label: 'Cloudflare'}, + {value: 43, color: 'blue', label: 'DeepSeek'}, + { + value: 15, + color: 'blue', + label: '百度文心千帆', + }, + { + value: 46, + color: 'blue', + label: '百度文心千帆V2', + }, + { + value: 17, + color: 'orange', + label: '阿里通义千问', + }, + { + value: 18, + color: 'blue', + label: '讯飞星火认知', + }, + { + value: 16, + color: 'violet', + label: '智谱 ChatGLM(已经弃用,请使用智谱 GLM-4V)', + }, + { + value: 26, + color: 'purple', + label: '智谱 GLM-4V', + }, + { + value: 27, + color: 'blue', + label: 'Perplexity', + }, + { + value: 24, + color: 'orange', + label: 'Google Gemini', + }, + { + value: 11, + color: 'orange', + label: 'Google PaLM2', + }, + { + value: 47, + color: 'blue', + label: 'Xinference', + }, + {value: 25, color: 'green', label: 'Moonshot'}, + {value: 20, color: 'green', label: 'OpenRouter'}, + {value: 19, color: 'blue', label: '360 智脑'}, + {value: 23, color: 'teal', label: '腾讯混元'}, + {value: 31, color: 'green', label: '零一万物'}, + {value: 35, color: 'green', label: 'MiniMax'}, + {value: 37, color: 'teal', label: 'Dify'}, + {value: 38, color: 'blue', label: 'Jina'}, + {value: 40, color: 'purple', label: 'SiliconCloud'}, + {value: 42, color: 'blue', label: 'Mistral AI'}, + {value: 8, color: 'pink', label: '自定义渠道'}, + { + value: 22, + color: 'blue', + label: '知识库:FastGPT', + }, + { + value: 21, + color: 'purple', + label: '知识库:AI Proxy', + }, + { + value: 44, + color: 'purple', + label: '嵌入模型:MokaAI M3E', + }, + { + value: 45, + color: 'blue', + label: '字节火山方舟、豆包通用', + }, + { + value: 48, + color: 'blue', + label: 'xAI', + }, + { + value: 49, + color: 'blue', + label: 'Coze', + }, + { + value: 50, + color: 'green', + label: '可灵', + }, + { + value: 51, + color: 'blue', + label: '即梦', + }, + { + value: 52, + color: 'purple', + label: 'Vidu', + }, + { + value: 53, + color: 'blue', + label: 'SubModel', + }, + { + value: 54, + color: 'blue', + label: '豆包视频', + }, + { + value: 55, + color: 'green', + label: 'Sora', + }, + { + value: 56, + color: 'blue', + label: 'Replicate', + }, + {value: 57, color: 'green', label: 'StreamLake'}, ]; export const MODEL_TABLE_PAGE_SIZE = 10; From 28d5ed0af369c4c1b484d2599c2789f04b7f4703 Mon Sep 17 00:00:00 2001 From: zhaolion Date: Sun, 4 Jan 2026 14:48:09 +0800 Subject: [PATCH 02/34] feat: add Docker image build to GHCR in release workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new docker job to build and push images to ghcr.io/zhaolion/newapi:{tag} when tags are pushed, running in parallel with existing release jobs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/release.yml | 61 +++++++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11fd6c7d1ae4..964f4206a98f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,7 @@ name: Release (Linux, macOS, Windows) permissions: contents: write + packages: write on: workflow_dispatch: @@ -22,6 +23,10 @@ jobs: uses: actions/checkout@v3 with: fetch-depth: 0 + - name: Determine Version + run: | + VERSION=$(git describe --tags) + echo "VERSION=$VERSION" >> $GITHUB_ENV - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -31,7 +36,7 @@ jobs: run: | cd web bun install - DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build + DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build cd .. - name: Set up Go uses: actions/setup-go@v3 @@ -40,13 +45,11 @@ jobs: - name: Build Backend (amd64) run: | go mod download - VERSION=$(git describe --tags) go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-$VERSION - name: Build Backend (arm64) run: | sudo apt-get update DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu - VERSION=$(git describe --tags) CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION - name: Release uses: softprops/action-gh-release@v2 @@ -65,6 +68,10 @@ jobs: uses: actions/checkout@v3 with: fetch-depth: 0 + - name: Determine Version + run: | + VERSION=$(git describe --tags) + echo "VERSION=$VERSION" >> $GITHUB_ENV - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -75,7 +82,7 @@ jobs: run: | cd web bun install - DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build + DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build cd .. - name: Set up Go uses: actions/setup-go@v3 @@ -84,7 +91,6 @@ jobs: - name: Build Backend run: | go mod download - VERSION=$(git describe --tags) go build -ldflags "-X 'new-api/common.Version=$VERSION'" -o new-api-macos-$VERSION - name: Release uses: softprops/action-gh-release@v2 @@ -105,6 +111,10 @@ jobs: uses: actions/checkout@v3 with: fetch-depth: 0 + - name: Determine Version + run: | + VERSION=$(git describe --tags) + echo "VERSION=$VERSION" >> $GITHUB_ENV - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -114,7 +124,7 @@ jobs: run: | cd web bun install - DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build + DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build cd .. - name: Set up Go uses: actions/setup-go@v3 @@ -123,7 +133,6 @@ jobs: - name: Build Backend run: | go mod download - VERSION=$(git describe --tags) go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION'" -o new-api-$VERSION.exe - name: Release uses: softprops/action-gh-release@v2 @@ -133,4 +142,42 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + docker: + name: Docker Image (GHCR) + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine Version + run: | + VERSION=$(git describe --tags) + echo "VERSION=$VERSION" >> $GITHUB_ENV + echo "$VERSION" > VERSION + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build & push to GHCR + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64 + push: true + tags: ghcr.io/zhaolion/newapi:${{ env.VERSION }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: false + sbom: false From f61c1c8ce7d91b8e05730f081fd958dc21ec9305 Mon Sep 17 00:00:00 2001 From: zhaolion Date: Sun, 4 Jan 2026 15:09:44 +0800 Subject: [PATCH 03/34] chore: remove unused GitHub workflow files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove deprecated workflow files that are no longer needed: - docker-image-alpha.yml (alpha Docker builds) - docker-image-arm64.yml (multi-arch Docker builds, now handled by release workflow) - electron-build.yml (Electron app builds) - sync-to-gitee.yml (Gitee release sync) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/docker-image-alpha.yml | 151 ----------------------- .github/workflows/docker-image-arm64.yml | 138 --------------------- .github/workflows/electron-build.yml | 141 --------------------- .github/workflows/sync-to-gitee.yml | 91 -------------- 4 files changed, 521 deletions(-) delete mode 100644 .github/workflows/docker-image-alpha.yml delete mode 100644 .github/workflows/docker-image-arm64.yml delete mode 100644 .github/workflows/electron-build.yml delete mode 100644 .github/workflows/sync-to-gitee.yml diff --git a/.github/workflows/docker-image-alpha.yml b/.github/workflows/docker-image-alpha.yml deleted file mode 100644 index 2a7d43ad53ff..000000000000 --- a/.github/workflows/docker-image-alpha.yml +++ /dev/null @@ -1,151 +0,0 @@ -name: Publish Docker image (alpha) - -on: - push: - branches: - - alpha - workflow_dispatch: - inputs: - name: - description: "reason" - required: false - -jobs: - build_single_arch: - name: Build & push (${{ matrix.arch }}) [native] - strategy: - fail-fast: false - matrix: - include: - - arch: amd64 - platform: linux/amd64 - runner: ubuntu-latest - - arch: arm64 - platform: linux/arm64 - runner: ubuntu-24.04-arm - runs-on: ${{ matrix.runner }} - permissions: - packages: write - contents: read - steps: - - name: Check out (shallow) - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Determine alpha version - id: version - run: | - VERSION="alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" - echo "$VERSION" > VERSION - echo "value=$VERSION" >> $GITHUB_OUTPUT - echo "VERSION=$VERSION" >> $GITHUB_ENV - echo "Publishing version: $VERSION for ${{ matrix.arch }}" - - - name: Normalize GHCR repository - run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (labels) - id: meta - uses: docker/metadata-action@v5 - with: - images: | - calciumion/new-api - ghcr.io/${{ env.GHCR_REPOSITORY }} - - - name: Build & push single-arch (to both registries) - uses: docker/build-push-action@v6 - with: - context: . - platforms: ${{ matrix.platform }} - push: true - tags: | - calciumion/new-api:alpha-${{ matrix.arch }} - calciumion/new-api:${{ steps.version.outputs.value }}-${{ matrix.arch }} - ghcr.io/${{ env.GHCR_REPOSITORY }}:alpha-${{ matrix.arch }} - ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ steps.version.outputs.value }}-${{ matrix.arch }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - provenance: false - sbom: false - - create_manifests: - name: Create multi-arch manifests (Docker Hub + GHCR) - needs: [build_single_arch] - runs-on: ubuntu-latest - permissions: - packages: write - contents: read - steps: - - name: Check out (shallow) - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Normalize GHCR repository - run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV - - - name: Determine alpha version - id: version - run: | - VERSION="alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" - echo "value=$VERSION" >> $GITHUB_OUTPUT - echo "VERSION=$VERSION" >> $GITHUB_ENV - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Create & push manifest (Docker Hub - alpha) - run: | - docker buildx imagetools create \ - -t calciumion/new-api:alpha \ - calciumion/new-api:alpha-amd64 \ - calciumion/new-api:alpha-arm64 - - - name: Create & push manifest (Docker Hub - versioned alpha) - run: | - docker buildx imagetools create \ - -t calciumion/new-api:${VERSION} \ - calciumion/new-api:${VERSION}-amd64 \ - calciumion/new-api:${VERSION}-arm64 - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Create & push manifest (GHCR - alpha) - run: | - docker buildx imagetools create \ - -t ghcr.io/${GHCR_REPOSITORY}:alpha \ - ghcr.io/${GHCR_REPOSITORY}:alpha-amd64 \ - ghcr.io/${GHCR_REPOSITORY}:alpha-arm64 - - - name: Create & push manifest (GHCR - versioned alpha) - run: | - docker buildx imagetools create \ - -t ghcr.io/${GHCR_REPOSITORY}:${VERSION} \ - ghcr.io/${GHCR_REPOSITORY}:${VERSION}-amd64 \ - ghcr.io/${GHCR_REPOSITORY}:${VERSION}-arm64 diff --git a/.github/workflows/docker-image-arm64.yml b/.github/workflows/docker-image-arm64.yml deleted file mode 100644 index 78517af0ee2d..000000000000 --- a/.github/workflows/docker-image-arm64.yml +++ /dev/null @@ -1,138 +0,0 @@ -name: Publish Docker image (Multi Registries, native amd64+arm64) - -on: - push: - tags: - - '*' - -jobs: - build_single_arch: - name: Build & push (${{ matrix.arch }}) [native] - strategy: - fail-fast: false - matrix: - include: - - arch: amd64 - platform: linux/amd64 - runner: ubuntu-latest - - arch: arm64 - platform: linux/arm64 - runner: ubuntu-24.04-arm - runs-on: ${{ matrix.runner }} - - permissions: - packages: write - contents: read - - steps: - - name: Check out (shallow) - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Resolve tag & write VERSION - run: | - git fetch --tags --force --depth=1 - TAG=${GITHUB_REF#refs/tags/} - echo "TAG=$TAG" >> $GITHUB_ENV - echo "$TAG" > VERSION - echo "Building tag: $TAG for ${{ matrix.arch }}" - - -# - name: Normalize GHCR repository -# run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - -# - name: Log in to GHCR -# uses: docker/login-action@v3 -# with: -# registry: ghcr.io -# username: ${{ github.actor }} -# password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (labels) - id: meta - uses: docker/metadata-action@v5 - with: - images: | - calciumion/new-api -# ghcr.io/${{ env.GHCR_REPOSITORY }} - - - name: Build & push single-arch (to both registries) - uses: docker/build-push-action@v6 - with: - context: . - platforms: ${{ matrix.platform }} - push: true - tags: | - calciumion/new-api:${{ env.TAG }}-${{ matrix.arch }} - calciumion/new-api:latest-${{ matrix.arch }} -# ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ env.TAG }}-${{ matrix.arch }} -# ghcr.io/${{ env.GHCR_REPOSITORY }}:latest-${{ matrix.arch }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - provenance: false - sbom: false - - create_manifests: - name: Create multi-arch manifests (Docker Hub) - needs: [build_single_arch] - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') - steps: - - name: Extract tag - run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV -# -# - name: Normalize GHCR repository -# run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Create & push manifest (Docker Hub - version) - run: | - docker buildx imagetools create \ - -t calciumion/new-api:${TAG} \ - calciumion/new-api:${TAG}-amd64 \ - calciumion/new-api:${TAG}-arm64 - - - name: Create & push manifest (Docker Hub - latest) - run: | - docker buildx imagetools create \ - -t calciumion/new-api:latest \ - calciumion/new-api:latest-amd64 \ - calciumion/new-api:latest-arm64 - - # ---- GHCR ---- -# - name: Log in to GHCR -# uses: docker/login-action@v3 -# with: -# registry: ghcr.io -# username: ${{ github.actor }} -# password: ${{ secrets.GITHUB_TOKEN }} - -# - name: Create & push manifest (GHCR - version) -# run: | -# docker buildx imagetools create \ -# -t ghcr.io/${GHCR_REPOSITORY}:${TAG} \ -# ghcr.io/${GHCR_REPOSITORY}:${TAG}-amd64 \ -# ghcr.io/${GHCR_REPOSITORY}:${TAG}-arm64 -# -# - name: Create & push manifest (GHCR - latest) -# run: | -# docker buildx imagetools create \ -# -t ghcr.io/${GHCR_REPOSITORY}:latest \ -# ghcr.io/${GHCR_REPOSITORY}:latest-amd64 \ -# ghcr.io/${GHCR_REPOSITORY}:latest-arm64 diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml deleted file mode 100644 index 20113e00fe6b..000000000000 --- a/.github/workflows/electron-build.yml +++ /dev/null @@ -1,141 +0,0 @@ -name: Build Electron App - -on: - push: - tags: - - '*' # Triggers on version tags like v1.0.0 - - '!*-*' # Ignore pre-release tags like v1.0.0-beta - - '!*-alpha*' # Ignore alpha tags like v1.0.0-alpha - workflow_dispatch: # Allows manual triggering - -jobs: - build: - strategy: - matrix: - # os: [macos-latest, windows-latest] - os: [windows-latest] - - runs-on: ${{ matrix.os }} - defaults: - run: - shell: bash - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: '>=1.25.1' - - - name: Build frontend - env: - CI: "" - NODE_OPTIONS: "--max-old-space-size=4096" - run: | - cd web - bun install - DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build - cd .. - - # - name: Build Go binary (macos/Linux) - # if: runner.os != 'Windows' - # run: | - # go mod download - # go build -ldflags "-s -w -X 'new-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api - - - name: Build Go binary (Windows) - if: runner.os == 'Windows' - run: | - go mod download - go build -ldflags "-s -w -X 'new-api/common.Version=$(git describe --tags)'" -o new-api.exe - - - name: Update Electron version - run: | - cd electron - VERSION=$(git describe --tags) - VERSION=${VERSION#v} # Remove 'v' prefix if present - # Convert to valid semver: take first 3 components and convert rest to prerelease format - # e.g., 0.9.3-patch.1 -> 0.9.3-patch.1 - if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(.*)$ ]]; then - MAJOR=${BASH_REMATCH[1]} - MINOR=${BASH_REMATCH[2]} - PATCH=${BASH_REMATCH[3]} - REST=${BASH_REMATCH[4]} - - VERSION="$MAJOR.$MINOR.$PATCH" - - # If there's extra content, append it without adding -dev - if [[ -n "$REST" ]]; then - VERSION="$VERSION$REST" - fi - fi - npm version $VERSION --no-git-tag-version --allow-same-version - - - name: Install Electron dependencies - run: | - cd electron - npm install - - # - name: Build Electron app (macOS) - # if: runner.os == 'macOS' - # run: | - # cd electron - # npm run build:mac - # env: - # CSC_IDENTITY_AUTO_DISCOVERY: false # Skip code signing - - - name: Build Electron app (Windows) - if: runner.os == 'Windows' - run: | - cd electron - npm run build:win - - # - name: Upload artifacts (macOS) - # if: runner.os == 'macOS' - # uses: actions/upload-artifact@v4 - # with: - # name: macos-build - # path: | - # electron/dist/*.dmg - # electron/dist/*.zip - - - name: Upload artifacts (Windows) - if: runner.os == 'Windows' - uses: actions/upload-artifact@v4 - with: - name: windows-build - path: | - electron/dist/*.exe - - release: - needs: build - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') - permissions: - contents: write - - steps: - - name: Download all artifacts - uses: actions/download-artifact@v4 - - - name: Upload to Release - uses: softprops/action-gh-release@v2 - with: - files: | - windows-build/* - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/sync-to-gitee.yml b/.github/workflows/sync-to-gitee.yml deleted file mode 100644 index 4f515a188dbe..000000000000 --- a/.github/workflows/sync-to-gitee.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Sync Release to Gitee - -permissions: - contents: read - -on: - workflow_dispatch: - inputs: - tag_name: - description: 'Release Tag to sync (e.g. v1.0.0)' - required: true - type: string - -# 配置你的 Gitee 仓库信息 -env: - GITEE_OWNER: 'QuantumNous' # 修改为你的 Gitee 用户名 - GITEE_REPO: 'new-api' # 修改为你的 Gitee 仓库名 - -jobs: - sync-to-gitee: - runs-on: sync - steps: - - name: Checkout - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Get Release Info - id: release_info - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG_NAME: ${{ github.event.inputs.tag_name }} - run: | - # 获取 release 信息 - RELEASE_INFO=$(gh release view "$TAG_NAME" --json name,body,tagName,targetCommitish) - - RELEASE_NAME=$(echo "$RELEASE_INFO" | jq -r '.name') - TARGET_COMMITISH=$(echo "$RELEASE_INFO" | jq -r '.targetCommitish') - - # 使用多行字符串输出 - { - echo "release_name=$RELEASE_NAME" - echo "target_commitish=$TARGET_COMMITISH" - echo "release_body<> $GITHUB_OUTPUT - - # 下载 release 的所有附件 - gh release download "$TAG_NAME" --dir ./release_assets || echo "No assets to download" - - # 列出下载的文件 - ls -la ./release_assets/ || echo "No assets directory" - - - name: Create Gitee Release - id: create_release - uses: nICEnnnnnnnLee/action-gitee-release@v2.0.0 - with: - gitee_action: create_release - gitee_owner: ${{ env.GITEE_OWNER }} - gitee_repo: ${{ env.GITEE_REPO }} - gitee_token: ${{ secrets.GITEE_TOKEN }} - gitee_tag_name: ${{ github.event.inputs.tag_name }} - gitee_release_name: ${{ steps.release_info.outputs.release_name }} - gitee_release_body: ${{ steps.release_info.outputs.release_body }} - gitee_target_commitish: ${{ steps.release_info.outputs.target_commitish }} - - - name: Upload Assets to Gitee - if: hashFiles('release_assets/*') != '' - uses: nICEnnnnnnnLee/action-gitee-release@v2.0.0 - with: - gitee_action: upload_asset - gitee_owner: ${{ env.GITEE_OWNER }} - gitee_repo: ${{ env.GITEE_REPO }} - gitee_token: ${{ secrets.GITEE_TOKEN }} - gitee_release_id: ${{ steps.create_release.outputs.release-id }} - gitee_upload_retry_times: 3 - gitee_files: | - release_assets/* - - - name: Cleanup - if: always() - run: | - rm -rf release_assets/ - - - name: Summary - if: success() - run: | - echo "✅ Successfully synced release ${{ github.event.inputs.tag_name }} to Gitee!" - echo "🔗 Gitee Release URL: https://gitee.com/${{ env.GITEE_OWNER }}/${{ env.GITEE_REPO }}/releases/tag/${{ github.event.inputs.tag_name }}" - From 12574430bc106b7b3a01a273511e53786985df29 Mon Sep 17 00:00:00 2001 From: zhaolion Date: Sun, 4 Jan 2026 15:14:17 +0800 Subject: [PATCH 04/34] fix: use dynamic repository name for GHCR Docker image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add repository name normalization to lowercase for GHCR compatibility - Replace hardcoded image path with dynamic $GITHUB_REPOSITORY - Add metadata extraction for proper image labels - Add latest tag alongside version tag 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/release.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 964f4206a98f..af05b711e875 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -160,6 +160,9 @@ jobs: echo "VERSION=$VERSION" >> $GITHUB_ENV echo "$VERSION" > VERSION + - name: Normalize GHCR repository + run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -170,13 +173,22 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ env.GHCR_REPOSITORY }} + - name: Build & push to GHCR uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64 push: true - tags: ghcr.io/zhaolion/newapi:${{ env.VERSION }} + tags: | + ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ env.VERSION }} + ghcr.io/${{ env.GHCR_REPOSITORY }}:latest + labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max provenance: false From 255564012ed5736854cbdc1c561d2206d3336d67 Mon Sep 17 00:00:00 2001 From: zhaolion Date: Sun, 4 Jan 2026 15:15:39 +0800 Subject: [PATCH 05/34] chore: remove macOS and Windows release jobs from workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove macOS and Windows build jobs to simplify the release workflow, keeping only Linux binary builds and Docker image publishing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/release.yml | 82 ----------------------------------- 1 file changed, 82 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af05b711e875..9bc74ef8bdf4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,88 +60,6 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - macos: - name: macOS Release - runs-on: macos-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - name: Determine Version - run: | - VERSION=$(git describe --tags) - echo "VERSION=$VERSION" >> $GITHUB_ENV - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - name: Build Frontend - env: - CI: "" - NODE_OPTIONS: "--max-old-space-size=4096" - run: | - cd web - bun install - DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build - cd .. - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version: '>=1.25.1' - - name: Build Backend - run: | - go mod download - go build -ldflags "-X 'new-api/common.Version=$VERSION'" -o new-api-macos-$VERSION - - name: Release - uses: softprops/action-gh-release@v2 - if: startsWith(github.ref, 'refs/tags/') - with: - files: new-api-macos-* - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - windows: - name: Windows Release - runs-on: windows-latest - defaults: - run: - shell: bash - steps: - - name: Checkout - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - name: Determine Version - run: | - VERSION=$(git describe --tags) - echo "VERSION=$VERSION" >> $GITHUB_ENV - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - name: Build Frontend - env: - CI: "" - run: | - cd web - bun install - DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build - cd .. - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version: '>=1.25.1' - - name: Build Backend - run: | - go mod download - go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION'" -o new-api-$VERSION.exe - - name: Release - uses: softprops/action-gh-release@v2 - if: startsWith(github.ref, 'refs/tags/') - with: - files: new-api-*.exe - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - docker: name: Docker Image (GHCR) runs-on: ubuntu-latest From d9d4404f75a1cfe567ae2788348cbf16ab462dab Mon Sep 17 00:00:00 2001 From: zhaolion Date: Fri, 9 Jan 2026 15:10:10 +0800 Subject: [PATCH 06/34] feat: enhance image processing for Qwen and Wan models in adaptor and image handling # Conflicts: # go.mod # go.sum # relay/channel/ali/dto.go # relay/channel/ali/image_wan.go --- controller/topup_stripe.go | 17 +++++++++++--- go.mod | 2 +- go.sum | 6 ++--- relay/channel/ali/adaptor.go | 26 ++++++++++++++++----- relay/channel/ali/dto.go | 27 ++++++++++++++++++++++ relay/channel/ali/image.go | 41 ++++++++++++++++++++++++++++++++++ relay/channel/ali/image_wan.go | 34 ++++++++++++++++++++++++++++ setting/payment_stripe.go | 11 +++++++++ 8 files changed, 150 insertions(+), 14 deletions(-) diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index 337ff8e738a1..77c20895aa2f 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -16,9 +16,9 @@ import ( "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" - "github.com/stripe/stripe-go/v81" - "github.com/stripe/stripe-go/v81/checkout/session" - "github.com/stripe/stripe-go/v81/webhook" + "github.com/stripe/stripe-go/v83" + "github.com/stripe/stripe-go/v83/checkout/session" + "github.com/stripe/stripe-go/v83/webhook" "github.com/thanhpk/randstr" ) @@ -241,6 +241,17 @@ func genStripeLink(referenceId string, customerId string, email string, amount i params.Customer = stripe.String(customerId) } + //if setting.StripeManagedPaymentsEnabled { + // // TODO:add Managed Payments parameters + // params.AddExtra("managed_payments", `{"enabled": true}`) + //} + + if setting.StripeAutoTaxEnabled { + params.AutomaticTax = &stripe.CheckoutSessionAutomaticTaxParams{ + Enabled: stripe.Bool(true), + } + } + result, err := session.New(params) if err != nil { return "", err diff --git a/go.mod b/go.mod index ff03c03d3baf..60296d0302d1 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( github.com/samber/lo v1.39.0 github.com/shirou/gopsutil v3.21.11+incompatible github.com/shopspring/decimal v1.4.0 - github.com/stripe/stripe-go/v81 v81.4.0 + github.com/stripe/stripe-go/v83 v83.2.1 github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 github.com/thanhpk/randstr v1.0.6 github.com/tidwall/gjson v1.18.0 diff --git a/go.sum b/go.sum index f43717973318..e87d20dc1114 100644 --- a/go.sum +++ b/go.sum @@ -238,8 +238,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/stripe/stripe-go/v81 v81.4.0 h1:AuD9XzdAvl193qUCSaLocf8H+nRopOouXhxqJUzCLbw= -github.com/stripe/stripe-go/v81 v81.4.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo= +github.com/stripe/stripe-go/v83 v83.2.1 h1:8WPhpMjr8VyMWKUsCMoVvlWxYazuL5edajKX/RulfbA= +github.com/stripe/stripe-go/v83 v83.2.1/go.mod h1:nRyDcLrJtwPPQUnKAFs9Bt1NnQvNhNiF6V19XHmPISE= github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I= github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 h1:XQdibLKagjdevRB6vAjVY4qbSr8rQ610YzTkWcxzxSI= github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300/go.mod h1:FNa/dfN95vAYCNFrIKRrlRo+MBLbwmR9Asa5f2ljmBI= @@ -288,7 +288,6 @@ golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSO golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= @@ -297,7 +296,6 @@ golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index adce01822033..a53c56b5bbba 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -45,7 +45,11 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { case constant.RelayModeRerank: fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.ChannelBaseUrl) case constant.RelayModeImagesGenerations: - fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.ChannelBaseUrl) + if isQWENImageModel(info.OriginModelName) { + fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl) + } else { + fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.ChannelBaseUrl) + } case constant.RelayModeImagesEdits: if isWanModel(info.OriginModelName) { fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image2image/image-synthesis", info.ChannelBaseUrl) @@ -108,11 +112,17 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { if info.RelayMode == constant.RelayModeImagesGenerations { - aliRequest, err := oaiImage2Ali(request) - if err != nil { - return nil, fmt.Errorf("convert image request failed: %w", err) + if isQWENImageModel(info.OriginModelName) { + if isWanModel(info.OriginModelName) { + return oaiImageGen2QwenImageGen(c, info, request) + } + } else { + aliRequest, err := oaiImage2Ali(request) + if err != nil { + return nil, fmt.Errorf("convert image request failed: %w", err) + } + return aliRequest, nil } - return aliRequest, nil } else if info.RelayMode == constant.RelayModeImagesEdits { if isWanModel(info.OriginModelName) { return oaiFormEdit2WanxImageEdit(c, info, request) @@ -169,7 +179,11 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom default: switch info.RelayMode { case constant.RelayModeImagesGenerations: - err, usage = aliImageHandler(c, resp, info) + if isQWENImageModel(info.OriginModelName) { + err, usage = aliQwenImageHandler(c, resp, info) + } else { + err, usage = aliImageHandler(c, resp, info) + } case constant.RelayModeImagesEdits: if isWanModel(info.OriginModelName) { err, usage = aliImageHandler(c, resp, info) diff --git a/relay/channel/ali/dto.go b/relay/channel/ali/dto.go index 26f14a6c03aa..0b00a4fe632d 100644 --- a/relay/channel/ali/dto.go +++ b/relay/channel/ali/dto.go @@ -125,6 +125,33 @@ type WanImageParameters struct { Strength float64 `json:"strength,omitempty"` // 修改幅度 0.0-1.0,默认0.5(部分模型支持) } +type QwenImageRequest struct { + Model string `json:"model"` + Input QwenImageInput `json:"input"` + Parameters QwenImageParameters `json:"parameters"` +} + +type QwenImageInput struct { + Messages []QwenImageInputMessage `json:"messages"` +} + +type QwenImageInputMessage struct { + Role string `json:"role"` + Content []QwenImageInputMessageContent `json:"content"` +} + +type QwenImageInputMessageContent struct { + Text string `json:"text"` +} + +type QwenImageParameters struct { + NegativePrompt string `json:"negative_prompt,omitempty"` // 可选:反向提示词,描述不希望在画面中看到的内容 + PromptExtend bool `json:"prompt_extend,omitempty"` + Watermark bool `json:"watermark,omitempty"` + Size string `json:"size,omitempty"` + N int `json:"n,omitempty"` // 此参数当前固定为1,设置其他值将导致报错。 +} + type AliRerankParameters struct { TopN *int `json:"top_n,omitempty"` ReturnDocuments *bool `json:"return_documents,omitempty"` diff --git a/relay/channel/ali/image.go b/relay/channel/ali/image.go index 0e3fe1ea0c83..40384f22b2e4 100644 --- a/relay/channel/ali/image.go +++ b/relay/channel/ali/image.go @@ -305,6 +305,47 @@ func aliImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rela return nil, &dto.Usage{} } +func aliQwenImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) { + var aliResponse AliResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil + } + + service.CloseResponseBodyGracefully(resp) + err = common.Unmarshal(responseBody, &aliResponse) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil + } + + if aliResponse.Message != "" { + logger.LogError(c, "ali_task_failed: "+aliResponse.Message) + return types.NewError(errors.New(aliResponse.Message), types.ErrorCodeBadResponse), nil + } + var fullTextResponse dto.ImageResponse + if len(aliResponse.Output.Choices) > 0 { + fullTextResponse = dto.ImageResponse{ + Created: info.StartTime.Unix(), + Data: []dto.ImageData{ + { + Url: aliResponse.Output.Choices[0]["message"].(map[string]any)["content"].([]any)[0].(map[string]any)["image"].(string), + B64Json: "", + }, + }, + } + } + + var mapResponse map[string]any + _ = common.Unmarshal(responseBody, &mapResponse) + fullTextResponse.Extra = mapResponse + jsonResponse, err := common.Marshal(fullTextResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + service.IOCopyBytesGracefully(c, resp, jsonResponse) + return nil, &dto.Usage{} +} + func aliImageEditHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) { var aliResponse AliResponse responseBody, err := io.ReadAll(resp.Body) diff --git a/relay/channel/ali/image_wan.go b/relay/channel/ali/image_wan.go index 4bd1a27016d6..0664291a1567 100644 --- a/relay/channel/ali/image_wan.go +++ b/relay/channel/ali/image_wan.go @@ -11,6 +11,36 @@ import ( "github.com/gin-gonic/gin" ) +func oaiImageGen2QwenImageGen(c *gin.Context, _ *relaycommon.RelayInfo, request dto.ImageRequest) (*QwenImageRequest, error) { + imageRequest := QwenImageRequest{ + Model: request.Model, + Input: QwenImageInput{ + Messages: []QwenImageInputMessage{ + { + Role: "user", + Content: []QwenImageInputMessageContent{ + { + Text: request.Prompt, + }, + }, + }, + }, + }, + Parameters: QwenImageParameters{ + NegativePrompt: "低分辨率,低画质,肢体畸形,手指畸形,画面过饱和,蜡像感,人脸无细节,过度光滑,画面具有AI感。构图混乱。文字模糊,扭曲。", + PromptExtend: true, + Watermark: false, + Size: request.Size, + }, + } + + if err := common.UnmarshalBodyReusable(c, &imageRequest); err != nil { + return nil, err + } + + return &imageRequest, nil +} + func oaiFormEdit2WanxImageEdit(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (*AliImageRequest, error) { var err error var imageRequest AliImageRequest @@ -37,3 +67,7 @@ func oaiFormEdit2WanxImageEdit(c *gin.Context, info *relaycommon.RelayInfo, requ func isWanModel(modelName string) bool { return strings.Contains(modelName, "wan") } + +func isQWENImageModel(modelName string) bool { + return strings.Contains(modelName, "qwen-image") +} diff --git a/setting/payment_stripe.go b/setting/payment_stripe.go index d97120c8523c..4011d1715d0c 100644 --- a/setting/payment_stripe.go +++ b/setting/payment_stripe.go @@ -1,8 +1,19 @@ package setting +import ( + "github.com/QuantumNous/new-api/common" +) + var StripeApiSecret = "" var StripeWebhookSecret = "" var StripePriceId = "" var StripeUnitPrice = 8.0 var StripeMinTopUp = 1 var StripePromotionCodesEnabled = false +var StripeManagedPaymentsEnabled = false +var StripeAutoTaxEnabled = false + +func init() { + StripeManagedPaymentsEnabled = common.GetEnvOrDefaultBool("STRIPE_MANAGED_PAYMENTS_ENABLED", true) + StripeAutoTaxEnabled = common.GetEnvOrDefaultBool("STRIPE_AUTO_TAX_ENABLED", true) +} From 07458b343880f676672a74a276138b866cf3dc0c Mon Sep 17 00:00:00 2001 From: zhaolion Date: Fri, 9 Jan 2026 15:35:12 +0800 Subject: [PATCH 07/34] fix: simplify image generation logic for Qwen model and enable managed payments in Stripe --- controller/topup_stripe.go | 7 +++---- relay/channel/ali/adaptor.go | 4 +--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index 77c20895aa2f..cd20aebbb03b 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -241,10 +241,9 @@ func genStripeLink(referenceId string, customerId string, email string, amount i params.Customer = stripe.String(customerId) } - //if setting.StripeManagedPaymentsEnabled { - // // TODO:add Managed Payments parameters - // params.AddExtra("managed_payments", `{"enabled": true}`) - //} + if setting.StripeManagedPaymentsEnabled { + params.AddExtra("managed_payments[enabled]", "true") + } if setting.StripeAutoTaxEnabled { params.AutomaticTax = &stripe.CheckoutSessionAutomaticTaxParams{ diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index a53c56b5bbba..9047f14246f6 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -113,9 +113,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { if info.RelayMode == constant.RelayModeImagesGenerations { if isQWENImageModel(info.OriginModelName) { - if isWanModel(info.OriginModelName) { - return oaiImageGen2QwenImageGen(c, info, request) - } + return oaiImageGen2QwenImageGen(c, info, request) } else { aliRequest, err := oaiImage2Ali(request) if err != nil { From baa20d22678ddea966a9ca3b5f0afe2812fe52fa Mon Sep 17 00:00:00 2001 From: zhaolion Date: Fri, 9 Jan 2026 15:59:26 +0800 Subject: [PATCH 08/34] feat: add logging for image generation requests in Wan model --- relay/channel/ali/image_wan.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/relay/channel/ali/image_wan.go b/relay/channel/ali/image_wan.go index 0664291a1567..59bcad4263fb 100644 --- a/relay/channel/ali/image_wan.go +++ b/relay/channel/ali/image_wan.go @@ -6,6 +6,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/gin-gonic/gin" @@ -38,6 +39,7 @@ func oaiImageGen2QwenImageGen(c *gin.Context, _ *relaycommon.RelayInfo, request return nil, err } + logger.LogInfo(c, fmt.Sprintf("oaiImageGen2QwenImageGen %s", request.Model)) return &imageRequest, nil } @@ -61,6 +63,7 @@ func oaiFormEdit2WanxImageEdit(c *gin.Context, info *relaycommon.RelayInfo, requ } imageRequest.Input = wanInput imageRequest.Parameters = wanParams + logger.LogInfo(c, fmt.Sprintf("oaiFormEdit2WanxImageEdit %s", request.Model)) return &imageRequest, nil } From f43decb4245d1e416b7c290b07e85432491acb4d Mon Sep 17 00:00:00 2001 From: zhaolion Date: Fri, 9 Jan 2026 16:00:10 +0800 Subject: [PATCH 09/34] feat: add preview feature metadata for managed payments in Stripe --- controller/topup_stripe.go | 1 + 1 file changed, 1 insertion(+) diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index cd20aebbb03b..86616ed516a2 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -243,6 +243,7 @@ func genStripeLink(referenceId string, customerId string, email string, amount i if setting.StripeManagedPaymentsEnabled { params.AddExtra("managed_payments[enabled]", "true") + params.AddMetadata("preview_feature", "true") } if setting.StripeAutoTaxEnabled { From 0d02c4261cb4d889010cd361ee4916df5e9f24da Mon Sep 17 00:00:00 2001 From: zhaolion Date: Fri, 9 Jan 2026 16:20:09 +0800 Subject: [PATCH 10/34] feat: add Stripe version header for managed payments preview in checkout session --- controller/topup_stripe.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index 86616ed516a2..375ed9bd20b1 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -243,7 +243,8 @@ func genStripeLink(referenceId string, customerId string, email string, amount i if setting.StripeManagedPaymentsEnabled { params.AddExtra("managed_payments[enabled]", "true") - params.AddMetadata("preview_feature", "true") + // see: https://docs.stripe.com/payments/managed-payments/set-up?mode=payment + params.Params.Headers.Set("Stripe-Version", "2025-10-29.preview; managed_payments_preview=v1") } if setting.StripeAutoTaxEnabled { From 6f686c5b693ecb8cb2fc0078c9d87e429415813c Mon Sep 17 00:00:00 2001 From: zhaolion Date: Fri, 9 Jan 2026 16:21:24 +0800 Subject: [PATCH 11/34] fix: enhance logging for image generation in Wan model to include request body --- relay/channel/ali/image_wan.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/channel/ali/image_wan.go b/relay/channel/ali/image_wan.go index 59bcad4263fb..b11b901c4530 100644 --- a/relay/channel/ali/image_wan.go +++ b/relay/channel/ali/image_wan.go @@ -39,7 +39,7 @@ func oaiImageGen2QwenImageGen(c *gin.Context, _ *relaycommon.RelayInfo, request return nil, err } - logger.LogInfo(c, fmt.Sprintf("oaiImageGen2QwenImageGen %s", request.Model)) + logger.LogInfo(c, fmt.Sprintf("oaiImageGen2QwenImageGen %s body: %v", request.Model, imageRequest)) return &imageRequest, nil } From 06857cc6d9ff3f65ddd01d8737b200fa35b3059d Mon Sep 17 00:00:00 2001 From: zhaolion Date: Fri, 9 Jan 2026 16:34:08 +0800 Subject: [PATCH 12/34] fix: update Stripe version header handling for managed payments --- controller/topup_stripe.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index 375ed9bd20b1..b0ef9096c79e 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -244,7 +244,9 @@ func genStripeLink(referenceId string, customerId string, email string, amount i if setting.StripeManagedPaymentsEnabled { params.AddExtra("managed_payments[enabled]", "true") // see: https://docs.stripe.com/payments/managed-payments/set-up?mode=payment - params.Params.Headers.Set("Stripe-Version", "2025-10-29.preview; managed_payments_preview=v1") + headers := make(http.Header) + headers.Set("Stripe-Version", "2025-10-29.preview; managed_payments_preview=v1") + params.Params.Headers = headers } if setting.StripeAutoTaxEnabled { From 8ecdbe2ce25538a6b6184e86b11ea10d7d695e94 Mon Sep 17 00:00:00 2001 From: zhaolion Date: Fri, 9 Jan 2026 16:37:03 +0800 Subject: [PATCH 13/34] fix: update Stripe version header handling for managed payments --- relay/channel/ali/adaptor.go | 4 +++- relay/channel/ali/image_wan.go | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index 9047f14246f6..42cc4c143e98 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -1,6 +1,7 @@ package ali import ( + "context" "errors" "fmt" "io" @@ -8,6 +9,7 @@ import ( "strings" "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/relay/channel" "github.com/QuantumNous/new-api/relay/channel/claude" "github.com/QuantumNous/new-api/relay/channel/openai" @@ -62,7 +64,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", info.ChannelBaseUrl) } } - + logger.LogInfo(context.Background(), fmt.Sprintf("aliAdaptor.GetRequestURL %s", fullRequestURL)) return fullRequestURL, nil } diff --git a/relay/channel/ali/image_wan.go b/relay/channel/ali/image_wan.go index b11b901c4530..8474e9e233e9 100644 --- a/relay/channel/ali/image_wan.go +++ b/relay/channel/ali/image_wan.go @@ -1,6 +1,7 @@ package ali import ( + "encoding/json" "fmt" "strings" @@ -39,7 +40,8 @@ func oaiImageGen2QwenImageGen(c *gin.Context, _ *relaycommon.RelayInfo, request return nil, err } - logger.LogInfo(c, fmt.Sprintf("oaiImageGen2QwenImageGen %s body: %v", request.Model, imageRequest)) + imageRequestBytes, _ := json.Marshal(imageRequest) + logger.LogInfo(c, fmt.Sprintf("oaiImageGen2QwenImageGen %s body: %v", request.Model, string(imageRequestBytes))) return &imageRequest, nil } From 67966491957358dca349b13747bdc89b2eeb620f Mon Sep 17 00:00:00 2001 From: zhaolion Date: Fri, 9 Jan 2026 16:38:10 +0800 Subject: [PATCH 14/34] fix: specify main branch for release workflow in YAML configuration --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9bc74ef8bdf4..ff8419b70436 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,8 @@ on: description: 'reason' required: false push: + branches: + - main tags: - '*' - '!*-alpha*' From ce2f5cbe1465be81883561269dba3ccc88c58800 Mon Sep 17 00:00:00 2001 From: zhaolion Date: Fri, 9 Jan 2026 16:57:15 +0800 Subject: [PATCH 15/34] fix: remove unnecessary error handling for image request unmarshalling --- relay/channel/ali/image_wan.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/relay/channel/ali/image_wan.go b/relay/channel/ali/image_wan.go index 8474e9e233e9..4bc117cd44be 100644 --- a/relay/channel/ali/image_wan.go +++ b/relay/channel/ali/image_wan.go @@ -36,10 +36,6 @@ func oaiImageGen2QwenImageGen(c *gin.Context, _ *relaycommon.RelayInfo, request }, } - if err := common.UnmarshalBodyReusable(c, &imageRequest); err != nil { - return nil, err - } - imageRequestBytes, _ := json.Marshal(imageRequest) logger.LogInfo(c, fmt.Sprintf("oaiImageGen2QwenImageGen %s body: %v", request.Model, string(imageRequestBytes))) return &imageRequest, nil From f1cbd21d8574fae77d410b377d7b925588ab70be Mon Sep 17 00:00:00 2001 From: zhaolion Date: Fri, 9 Jan 2026 17:06:43 +0800 Subject: [PATCH 16/34] fix: refine QWEN image model detection logic to exclude Wan models --- relay/channel/ali/image_wan.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/relay/channel/ali/image_wan.go b/relay/channel/ali/image_wan.go index 4bc117cd44be..e0ce25879e5b 100644 --- a/relay/channel/ali/image_wan.go +++ b/relay/channel/ali/image_wan.go @@ -70,5 +70,9 @@ func isWanModel(modelName string) bool { } func isQWENImageModel(modelName string) bool { - return strings.Contains(modelName, "qwen-image") + if isWanModel(modelName) { + return false + } + + return strings.Contains(modelName, "qwen") && strings.Contains(modelName, "image") } From 0e79577cbd880c6b21514433e50b9090731c446a Mon Sep 17 00:00:00 2001 From: zhaolion Date: Tue, 13 Jan 2026 11:44:45 +0800 Subject: [PATCH 17/34] fix: update Stripe managed payments configuration and disable auto-tax - Update Stripe-Version header to 2025-03-31.basil - Reorder managed_payments parameter after headers setup - Remove StripeAutoTaxEnabled feature and related code - Change STRIPE_MANAGED_PAYMENTS_ENABLED default to false Co-Authored-By: Claude --- controller/topup_stripe.go | 10 ++-------- setting/payment_stripe.go | 4 +--- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index b0ef9096c79e..efcb0a4468a3 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -242,17 +242,11 @@ func genStripeLink(referenceId string, customerId string, email string, amount i } if setting.StripeManagedPaymentsEnabled { - params.AddExtra("managed_payments[enabled]", "true") // see: https://docs.stripe.com/payments/managed-payments/set-up?mode=payment headers := make(http.Header) - headers.Set("Stripe-Version", "2025-10-29.preview; managed_payments_preview=v1") + headers.Set("Stripe-Version", "2025-03-31.basil; managed_payments_preview=v1") params.Params.Headers = headers - } - - if setting.StripeAutoTaxEnabled { - params.AutomaticTax = &stripe.CheckoutSessionAutomaticTaxParams{ - Enabled: stripe.Bool(true), - } + params.AddExtra("managed_payments[enabled]", "true") } result, err := session.New(params) diff --git a/setting/payment_stripe.go b/setting/payment_stripe.go index 4011d1715d0c..e16aee2b15b4 100644 --- a/setting/payment_stripe.go +++ b/setting/payment_stripe.go @@ -11,9 +11,7 @@ var StripeUnitPrice = 8.0 var StripeMinTopUp = 1 var StripePromotionCodesEnabled = false var StripeManagedPaymentsEnabled = false -var StripeAutoTaxEnabled = false func init() { - StripeManagedPaymentsEnabled = common.GetEnvOrDefaultBool("STRIPE_MANAGED_PAYMENTS_ENABLED", true) - StripeAutoTaxEnabled = common.GetEnvOrDefaultBool("STRIPE_AUTO_TAX_ENABLED", true) + StripeManagedPaymentsEnabled = common.GetEnvOrDefaultBool("STRIPE_MANAGED_PAYMENTS_ENABLED", false) } From 74967886813667af9ac6742d9b8076bfdcd57fb0 Mon Sep 17 00:00:00 2001 From: zhaolion Date: Tue, 13 Jan 2026 13:26:03 +0800 Subject: [PATCH 18/34] fix: enable Stripe managed payments by default in initialization --- setting/payment_stripe.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setting/payment_stripe.go b/setting/payment_stripe.go index e16aee2b15b4..c6d16d06366f 100644 --- a/setting/payment_stripe.go +++ b/setting/payment_stripe.go @@ -13,5 +13,5 @@ var StripePromotionCodesEnabled = false var StripeManagedPaymentsEnabled = false func init() { - StripeManagedPaymentsEnabled = common.GetEnvOrDefaultBool("STRIPE_MANAGED_PAYMENTS_ENABLED", false) + StripeManagedPaymentsEnabled = common.GetEnvOrDefaultBool("STRIPE_MANAGED_PAYMENTS_ENABLED", true) } From 41d2eff1a471d4fa82d0d92e5b39b9717b63931c Mon Sep 17 00:00:00 2001 From: zhaolion Date: Fri, 16 Jan 2026 20:57:45 +0800 Subject: [PATCH 19/34] feat: add debug log --- relay/channel/claude/adaptor.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go index b9b7447f2f39..08da6e367ba7 100644 --- a/relay/channel/claude/adaptor.go +++ b/relay/channel/claude/adaptor.go @@ -1,6 +1,7 @@ package claude import ( + "context" "errors" "fmt" "io" @@ -8,6 +9,7 @@ import ( "strings" "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/relay/channel" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/setting/model_setting" @@ -31,6 +33,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt } func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { + logger.LogInfo(context.Background(), fmt.Sprintf("ConvertClaudeRequest: %v", request.Model)) return request, nil } @@ -62,6 +65,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { if info.IsClaudeBetaQuery { baseURL = baseURL + "?beta=true" } + logger.LogInfo(context.Background(), fmt.Sprintf("GetRequestURL: %v", baseURL)) return baseURL, nil } From 7852f22aa698ae1bc30357fcec5948c54c86c444 Mon Sep 17 00:00:00 2001 From: zhaolion Date: Wed, 21 Jan 2026 15:23:23 +0800 Subject: [PATCH 20/34] feat: add separate test endpoint defaults for channel testing Add a dedicated test endpoint mapping that includes rerank endpoint for channel testing purposes, separate from the main endpoint defaults. Co-Authored-By: Claude --- common/endpoint_defaults.go | 15 +++++++++++++++ controller/channel-test.go | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/common/endpoint_defaults.go b/common/endpoint_defaults.go index c04c5f6d8e0e..8b82e47376fa 100644 --- a/common/endpoint_defaults.go +++ b/common/endpoint_defaults.go @@ -31,3 +31,18 @@ func GetDefaultEndpointInfo(et constant.EndpointType) (EndpointInfo, bool) { info, ok := defaultEndpointInfoMap[et] return info, ok } + +var defaultTestEndpointInfoMap = map[constant.EndpointType]EndpointInfo{ + constant.EndpointTypeOpenAI: {Path: "/v1/chat/completions", Method: "POST"}, + constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"}, + constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"}, + constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"}, + constant.EndpointTypeJinaRerank: {Path: "/v1/rerank", Method: "POST"}, + constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"}, + constant.EndpointTypeEmbeddings: {Path: "/v1/embeddings", Method: "POST"}, +} + +func GetTestDefaultEndpointInfo(et constant.EndpointType) (EndpointInfo, bool) { + info, ok := defaultTestEndpointInfoMap[et] + return info, ok +} diff --git a/controller/channel-test.go b/controller/channel-test.go index 171cca22b23d..f875fd8fc1d3 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -79,7 +79,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) // 如果指定了端点类型,使用指定的端点类型 if endpointType != "" { - if endpointInfo, ok := common.GetDefaultEndpointInfo(constant.EndpointType(endpointType)); ok { + if endpointInfo, ok := common.GetTestDefaultEndpointInfo(constant.EndpointType(endpointType)); ok { requestPath = endpointInfo.Path } } else { From 62ca8eab4d191785c923065b0be3dd2fef99c10d Mon Sep 17 00:00:00 2001 From: zhaolion Date: Wed, 21 Jan 2026 17:26:28 +0800 Subject: [PATCH 21/34] feat: add rerank model endpoint detection and disable Stripe managed payments by default - Add /v1/rerank endpoint detection for rerank models in channel testing - Change STRIPE_MANAGED_PAYMENTS_ENABLED default from true to false Co-Authored-By: Claude --- controller/channel-test.go | 5 ++++- setting/payment_stripe.go | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/controller/channel-test.go b/controller/channel-test.go index f875fd8fc1d3..eeac6d2da0c5 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -92,7 +92,10 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) channel.Type == constant.ChannelTypeMokaAI { // 其他 embedding 模型 requestPath = "/v1/embeddings" // 修改请求路径 } - + // Rerank 模型 + if strings.Contains(strings.ToLower(testModel), "rerank") { + requestPath = "/v1/rerank" + } // VolcEngine 图像生成模型 if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") { requestPath = "/v1/images/generations" diff --git a/setting/payment_stripe.go b/setting/payment_stripe.go index c6d16d06366f..e16aee2b15b4 100644 --- a/setting/payment_stripe.go +++ b/setting/payment_stripe.go @@ -13,5 +13,5 @@ var StripePromotionCodesEnabled = false var StripeManagedPaymentsEnabled = false func init() { - StripeManagedPaymentsEnabled = common.GetEnvOrDefaultBool("STRIPE_MANAGED_PAYMENTS_ENABLED", true) + StripeManagedPaymentsEnabled = common.GetEnvOrDefaultBool("STRIPE_MANAGED_PAYMENTS_ENABLED", false) } From 5af801ffa4d2a67bcca74459932680c6424c8c7c Mon Sep 17 00:00:00 2001 From: zhaolion Date: Wed, 21 Jan 2026 17:54:02 +0800 Subject: [PATCH 22/34] Merge pull request #3 from CherryInternal/fix/channel-test-response feat: enhance channel testing with extended model type detection --- controller/channel-test.go | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/controller/channel-test.go b/controller/channel-test.go index eeac6d2da0c5..80b7203bdbe2 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -77,6 +77,14 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) requestPath := "/v1/chat/completions" + // 需要排除 ImageEdit 模型 + if strings.Contains(strings.ToLower(testModel), "image") && strings.Contains(strings.ToLower(testModel), "edit") { + return testResult{ + localErr: errors.New("image edit model testing is not supported"), + newAPIError: nil, + } + } + // 如果指定了端点类型,使用指定的端点类型 if endpointType != "" { if endpointInfo, ok := common.GetTestDefaultEndpointInfo(constant.EndpointType(endpointType)); ok { @@ -84,6 +92,9 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) } } else { // 如果没有指定端点类型,使用原有的自动检测逻辑 + + modelName := strings.ToLower(testModel) + // 先判断是否为 Embedding 模型 if strings.Contains(strings.ToLower(testModel), "embedding") || strings.HasPrefix(testModel, "m3e") || // m3e 系列模型 @@ -91,14 +102,35 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) strings.Contains(testModel, "embed") || channel.Type == constant.ChannelTypeMokaAI { // 其他 embedding 模型 requestPath = "/v1/embeddings" // 修改请求路径 + endpointType = string(constant.EndpointTypeEmbeddings) } // Rerank 模型 - if strings.Contains(strings.ToLower(testModel), "rerank") { + if strings.Contains(modelName, "rerank") { requestPath = "/v1/rerank" + endpointType = string(constant.EndpointTypeJinaRerank) } // VolcEngine 图像生成模型 if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") { requestPath = "/v1/images/generations" + endpointType = string(constant.EndpointTypeImageGeneration) + } + // Image Generation 模型 + if strings.Contains(modelName, "image") && strings.Contains(modelName, "qwen") && !strings.Contains(modelName, "edit") { + requestPath = "/v1/images/generations" + endpointType = string(constant.EndpointTypeImageGeneration) + } + if strings.Contains(modelName, "image") && strings.Contains(modelName, "gpt") && !strings.Contains(modelName, "edit") { + requestPath = "/v1/images/generations" + endpointType = string(constant.EndpointTypeImageGeneration) + } + if strings.Contains(modelName, "kwai-kolors") && !strings.Contains(modelName, "edit") { + requestPath = "/v1/images/generations" + endpointType = string(constant.EndpointTypeImageGeneration) + } + // Response 模型 + if strings.Contains(modelName, "gpt-5.2") { + requestPath = "/v1/responses" + endpointType = string(constant.EndpointTypeOpenAIResponse) } } From f4471c83fd0c1e0a95939924ff0f7a94c16a4fa2 Mon Sep 17 00:00:00 2001 From: zhaolion Date: Wed, 21 Jan 2026 18:27:56 +0800 Subject: [PATCH 23/34] fix: exclude rerank models from embedding detection in channel test (#4) * feat: enhance channel testing with extended model type detection - Exclude image edit models from testing (not supported) - Add endpoint type tracking for embeddings and rerank models - Add image generation detection for qwen and gpt image models - Add response endpoint detection for gpt-5.2 models Co-Authored-By: Claude * feat: add kwai-kolors image generation model detection Add endpoint detection for kwai-kolors image generation models to use /v1/images/generations endpoint during channel testing. Co-Authored-By: Claude * fix: exclude rerank models from embedding detection in channel test Rerank models like BAAI/bge-reranker-v2-m3 contain "bge-" in their name and were incorrectly matched as embedding models. This fix ensures rerank models are properly routed to the /v1/rerank endpoint. Co-Authored-By: Claude --------- Co-authored-by: Claude --- controller/channel-test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/controller/channel-test.go b/controller/channel-test.go index 80b7203bdbe2..493af43b8069 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -478,9 +478,10 @@ func buildTestRequest(model string, endpointType string) dto.Request { // 自动检测逻辑(保持原有行为) // 先判断是否为 Embedding 模型 - if strings.Contains(strings.ToLower(model), "embedding") || - strings.HasPrefix(model, "m3e") || - strings.Contains(model, "bge-") { + modelName := strings.ToLower(model) + if !strings.Contains(modelName, "rerank") && (strings.Contains(modelName, "embedding") || + strings.HasPrefix(modelName, "m3e") || + strings.Contains(modelName, "bge-")) { // 返回 EmbeddingRequest return &dto.EmbeddingRequest{ Model: model, From 756ec879d2f7ce9d12e16b8cb5e7319234227bbe Mon Sep 17 00:00:00 2001 From: SuYao Date: Tue, 27 Jan 2026 15:51:35 +0800 Subject: [PATCH 24/34] feat: implement OAuth (#5) * feat: implement oauth * chore: modify image * fix: make error message consistent add avoid magic number * chore: clean unused env * chore: clean again * feat(oauth): oauth client * feat(ui): oauth test * feat(oauth): implement oauth clients * fix: clean local yaml --- .go-version | 1 + .golangci.yml | 33 + Dockerfile | 3 +- common/constants.go | 7 + common/init.go | 14 + controller/oauth_api.go | 273 ++++++ controller/oauth_api_test.go | 486 ++++++++++ controller/oauth_provider.go | 862 ++++++++++++++++++ controller/oauth_provider_test.go | 594 ++++++++++++ controller/task.go | 6 +- docker-compose.yml | 85 +- go.mod | 2 + go.sum | 4 + middleware/distributor.go | 2 +- middleware/oauth.go | 111 +++ middleware/oauth_test.go | 246 +++++ mise.toml | 2 + model/main.go | 2 + model/oauth_client.go | 104 +++ model/option.go | 24 + model/twofa.go | 9 + router/main.go | 2 + router/oauth-api.go | 41 + router/oauth-provider.go | 58 ++ service/hydra/interface.go | 42 + service/hydra/introspect_test.go | 98 ++ service/hydra/mock.go | 408 +++++++++ service/hydra/mock_test.go | 165 ++++ service/hydra/service.go | 176 ++++ web/src/App.jsx | 28 + web/src/components/layout/SiderBar.jsx | 7 + .../oauth-clients/OAuthClientsActions.jsx | 74 ++ .../oauth-clients/OAuthClientsColumnDefs.jsx | 242 +++++ .../oauth-clients/OAuthClientsFilters.jsx | 95 ++ .../table/oauth-clients/OAuthClientsTable.jsx | 93 ++ .../components/table/oauth-clients/index.jsx | 111 +++ .../modals/EditOAuthClientModal.jsx | 378 ++++++++ web/src/helpers/render.jsx | 3 + web/src/hooks/common/useSidebar.js | 1 + .../oauth-clients/useOAuthClientsData.jsx | 274 ++++++ web/src/i18n/locales/en.json | 72 +- web/src/i18n/locales/zh.json | 73 +- web/src/pages/OAuth/OAuthConsent.jsx | 387 ++++++++ web/src/pages/OAuth/OAuthLogin.jsx | 326 +++++++ web/src/pages/OAuth/index.jsx | 21 + web/src/pages/OAuthClients/index.jsx | 31 + 46 files changed, 6002 insertions(+), 74 deletions(-) create mode 100644 .go-version create mode 100644 .golangci.yml create mode 100644 controller/oauth_api.go create mode 100644 controller/oauth_api_test.go create mode 100644 controller/oauth_provider.go create mode 100644 controller/oauth_provider_test.go create mode 100644 middleware/oauth.go create mode 100644 middleware/oauth_test.go create mode 100644 mise.toml create mode 100644 model/oauth_client.go create mode 100644 router/oauth-api.go create mode 100644 router/oauth-provider.go create mode 100644 service/hydra/interface.go create mode 100644 service/hydra/introspect_test.go create mode 100644 service/hydra/mock.go create mode 100644 service/hydra/mock_test.go create mode 100644 service/hydra/service.go create mode 100644 web/src/components/table/oauth-clients/OAuthClientsActions.jsx create mode 100644 web/src/components/table/oauth-clients/OAuthClientsColumnDefs.jsx create mode 100644 web/src/components/table/oauth-clients/OAuthClientsFilters.jsx create mode 100644 web/src/components/table/oauth-clients/OAuthClientsTable.jsx create mode 100644 web/src/components/table/oauth-clients/index.jsx create mode 100644 web/src/components/table/oauth-clients/modals/EditOAuthClientModal.jsx create mode 100644 web/src/hooks/oauth-clients/useOAuthClientsData.jsx create mode 100644 web/src/pages/OAuth/OAuthConsent.jsx create mode 100644 web/src/pages/OAuth/OAuthLogin.jsx create mode 100644 web/src/pages/OAuth/index.jsx create mode 100644 web/src/pages/OAuthClients/index.jsx diff --git a/.go-version b/.go-version new file mode 100644 index 000000000000..7c819a961514 --- /dev/null +++ b/.go-version @@ -0,0 +1 @@ +1.25.1 \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 000000000000..29a92fd0369e --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,33 @@ +version: "2" + +run: + timeout: 5m + modules-download-mode: readonly + +linters: + default: none + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + - misspell + +formatters: + enable: + - gofmt + - goimports + +linters-settings: + misspell: + locale: US + +issues: + exclude-dirs: + - vendor + - web + exclude-rules: + - path: _test\.go + linters: + - errcheck diff --git a/Dockerfile b/Dockerfile index c7348add80fc..2610aa5cc3cc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,8 @@ RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$ FROM alpine -RUN apk upgrade --no-cache \ +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ + && apk upgrade --no-cache \ && apk add --no-cache ca-certificates tzdata \ && update-ca-certificates diff --git a/common/constants.go b/common/constants.go index 120c1e9d24d2..e36bbe28ab0d 100644 --- a/common/constants.go +++ b/common/constants.go @@ -96,6 +96,13 @@ var TurnstileSecretKey = "" var TelegramBotToken = "" var TelegramBotName = "" +// Hydra OAuth Provider configuration +var HydraEnabled = false +var HydraAdminURL = "" +var HydraTrustedClients = []string{} // Clients that get auto-consent (e.g., "new-api-web,new-api-admin") +var HydraLoginRememberFor int64 = 3600 // Login session remember duration in seconds (default: 1 hour) +var HydraConsentRememberFor int64 = 2592000 // Consent remember duration in seconds (default: 30 days) + var QuotaForNewUser = 0 var QuotaForInviter = 0 var QuotaForInvitee = 0 diff --git a/common/init.go b/common/init.go index 0ae7dcd68604..82278261a614 100644 --- a/common/init.go +++ b/common/init.go @@ -102,6 +102,20 @@ func InitEnv() { CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true) CriticalRateLimitNum = GetEnvOrDefault("CRITICAL_RATE_LIMIT", 20) CriticalRateLimitDuration = int64(GetEnvOrDefault("CRITICAL_RATE_LIMIT_DURATION", 20*60)) + + // Hydra OAuth Provider configuration + HydraEnabled = GetEnvOrDefaultBool("HYDRA_ENABLED", false) + HydraAdminURL = GetEnvOrDefaultString("HYDRA_ADMIN_URL", "") + if trustedClients := GetEnvOrDefaultString("HYDRA_TRUSTED_CLIENTS", ""); trustedClients != "" { + for _, c := range strings.Split(trustedClients, ",") { + if trimmed := strings.TrimSpace(c); trimmed != "" { + HydraTrustedClients = append(HydraTrustedClients, trimmed) + } + } + } + HydraLoginRememberFor = int64(GetEnvOrDefault("HYDRA_LOGIN_REMEMBER_FOR", 3600)) // Default: 1 hour + HydraConsentRememberFor = int64(GetEnvOrDefault("HYDRA_CONSENT_REMEMBER_FOR", 2592000)) // Default: 30 days + initConstantEnv() } diff --git a/controller/oauth_api.go b/controller/oauth_api.go new file mode 100644 index 000000000000..c26c23b9e127 --- /dev/null +++ b/controller/oauth_api.go @@ -0,0 +1,273 @@ +package controller + +import ( + "net/http" + "strconv" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" +) + +// getUserFromContext extracts and validates user ID from context. +// Returns (userId, true) if valid, (0, false) if unauthorized. +func getUserFromContext(c *gin.Context) (int, bool) { + userId := c.GetInt("id") + if userId == 0 { + return 0, false + } + return userId, true +} + +// abortUnauthorized sends a standardized unauthorized response +func abortUnauthorized(c *gin.Context) { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "unauthorized", + }) +} + +// OAuthGetUserInfo returns user information for OAuth clients +// Scope required: openid, profile +func OAuthGetUserInfo(c *gin.Context) { + userId, ok := getUserFromContext(c) + if !ok { + abortUnauthorized(c) + return + } + + user, err := model.GetUserById(userId, false) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "success": false, + "message": "user not found", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "id": user.Id, + "username": user.Username, + "display_name": user.DisplayName, + "email": user.Email, + "group": user.Group, + }, + }) +} + +// OAuthGetBalance returns user balance information for OAuth clients +// Scope required: balance:read +func OAuthGetBalance(c *gin.Context) { + userId, ok := getUserFromContext(c) + if !ok { + abortUnauthorized(c) + return + } + + user, err := model.GetUserById(userId, false) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "success": false, + "message": "user not found", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "quota": user.Quota, + "used_quota": user.UsedQuota, + }, + }) +} + +// OAuthGetUsage returns user usage statistics for OAuth clients +// Scope required: usage:read +func OAuthGetUsage(c *gin.Context) { + userId, ok := getUserFromContext(c) + if !ok { + abortUnauthorized(c) + return + } + + user, err := model.GetUserById(userId, false) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "success": false, + "message": "user not found", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "request_count": user.RequestCount, + "used_quota": user.UsedQuota, + "quota": user.Quota, + }, + }) +} + +// OAuthListTokens returns user's API tokens for OAuth clients +// Scope required: tokens:read +func OAuthListTokens(c *gin.Context) { + userId, ok := getUserFromContext(c) + if !ok { + abortUnauthorized(c) + return + } + + tokens, err := model.GetAllUserTokens(userId, 0, 100) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to get tokens", + }) + return + } + + // Hide sensitive key data + safeTokens := make([]gin.H, 0, len(tokens)) + for _, t := range tokens { + safeTokens = append(safeTokens, gin.H{ + "id": t.Id, + "name": t.Name, + "key": t.Key, + "status": t.Status, + "created_time": t.CreatedTime, + "expired_time": t.ExpiredTime, + "remain_quota": t.RemainQuota, + "unlimited_quota": t.UnlimitedQuota, + }) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": safeTokens, + }) +} + +// OAuthCreateToken creates a new API token for OAuth clients +// Scope required: tokens:write +func OAuthCreateToken(c *gin.Context) { + userId, ok := getUserFromContext(c) + if !ok { + abortUnauthorized(c) + return + } + + var req struct { + Name string `json:"name" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid request: " + err.Error(), + }) + return + } + + // Validate name length + if len(req.Name) > 30 { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "token name too long (max 30 characters)", + }) + return + } + + // Generate key + key, err := common.GenerateKey() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to generate token key", + }) + return + } + + // Create token with default settings + token := &model.Token{ + UserId: userId, + Name: req.Name, + Key: key, + CreatedTime: common.GetTimestamp(), + AccessedTime: common.GetTimestamp(), + ExpiredTime: -1, // Never expires + UnlimitedQuota: false, + } + + err = token.Insert() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to create token", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "id": token.Id, + "name": token.Name, + "key": token.Key, // Return key only on creation + }, + }) +} + +// OAuthDeleteToken deletes an API token for OAuth clients +// Scope required: tokens:write +func OAuthDeleteToken(c *gin.Context) { + userId, ok := getUserFromContext(c) + if !ok { + abortUnauthorized(c) + return + } + + tokenIdStr := c.Param("id") + if tokenIdStr == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "missing token id", + }) + return + } + + tokenId, err := strconv.Atoi(tokenIdStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid token id", + }) + return + } + + // Verify token belongs to user + token, err := model.GetTokenById(tokenId) + if err != nil || token.UserId != userId { + c.JSON(http.StatusNotFound, gin.H{ + "success": false, + "message": "token not found", + }) + return + } + + err = model.DeleteTokenById(tokenId, userId) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to delete token", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "token deleted", + }) +} diff --git a/controller/oauth_api_test.go b/controller/oauth_api_test.go new file mode 100644 index 000000000000..4b84c804fc5d --- /dev/null +++ b/controller/oauth_api_test.go @@ -0,0 +1,486 @@ +package controller + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service/hydra" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func setupTestDB(t *testing.T) func() { + // Create in-memory SQLite database for testing + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to connect to test database: %v", err) + } + + // Auto migrate necessary tables + err = db.AutoMigrate(&model.User{}, &model.Token{}) + if err != nil { + t.Fatalf("failed to migrate test database: %v", err) + } + + // Set the global DB + model.DB = db + + // Return cleanup function + return func() { + sqlDB, _ := db.DB() + if sqlDB != nil { + sqlDB.Close() + } + model.DB = nil + } +} + +func createTestUser(t *testing.T, id int, username string) *model.User { + user := &model.User{ + Id: id, + Username: username, + DisplayName: "Test User " + username, + Email: username + "@test.com", + Group: "default", + Quota: 100000, + UsedQuota: 5000, + RequestCount: 42, + Status: 1, + AffCode: fmt.Sprintf("aff_%s_%d", username, id), // Unique aff_code + } + err := model.DB.Create(user).Error + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + return user +} + +func setupOAuthAPITestRouter(mock *hydra.MockProvider) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + + // Apply OAuth middleware + oauthAPI := r.Group("/api/v1/oauth") + oauthAPI.Use(middleware.OAuthTokenAuth(mock)) + { + oauthAPI.GET("/userinfo", OAuthGetUserInfo) + oauthAPI.GET("/balance", OAuthGetBalance) + oauthAPI.GET("/usage", OAuthGetUsage) + } + + return r +} + +func TestOAuthGetUserInfo_Unauthorized(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + mock := hydra.NewMockProvider() + router := setupOAuthAPITestRouter(mock) + + req, _ := http.NewRequest("GET", "/api/v1/oauth/userinfo", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code) + } +} + +func TestOAuthGetUserInfo_Success(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + // Create test user with ID 123 + createTestUser(t, 123, "testuser") + + mock := hydra.NewMockProvider() + mock.SetIntrospectedToken("valid-token", true, "123", "openid profile", "test-client") + router := setupOAuthAPITestRouter(mock) + + req, _ := http.NewRequest("GET", "/api/v1/oauth/userinfo", nil) + req.Header.Set("Authorization", "Bearer valid-token") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + + if resp["success"] != true { + t.Errorf("Expected success=true, got %v", resp["success"]) + } + + data := resp["data"].(map[string]interface{}) + if data["username"] != "testuser" { + t.Errorf("Expected username 'testuser', got %v", data["username"]) + } +} + +func TestOAuthGetUserInfo_UserNotFound(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + // No user created - user 123 doesn't exist + mock := hydra.NewMockProvider() + mock.SetIntrospectedToken("valid-token", true, "123", "openid profile", "test-client") + router := setupOAuthAPITestRouter(mock) + + req, _ := http.NewRequest("GET", "/api/v1/oauth/userinfo", nil) + req.Header.Set("Authorization", "Bearer valid-token") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("Expected status %d, got %d", http.StatusNotFound, w.Code) + } +} + +func TestOAuthGetBalance_Unauthorized(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + mock := hydra.NewMockProvider() + router := setupOAuthAPITestRouter(mock) + + req, _ := http.NewRequest("GET", "/api/v1/oauth/balance", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code) + } +} + +func TestOAuthGetBalance_Success(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + createTestUser(t, 123, "testuser") + + mock := hydra.NewMockProvider() + mock.SetIntrospectedToken("valid-token", true, "123", "balance:read", "test-client") + router := setupOAuthAPITestRouter(mock) + + req, _ := http.NewRequest("GET", "/api/v1/oauth/balance", nil) + req.Header.Set("Authorization", "Bearer valid-token") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + + if resp["success"] != true { + t.Errorf("Expected success=true, got %v", resp["success"]) + } + + data := resp["data"].(map[string]interface{}) + // Quota should be 100000 as set in createTestUser + if data["quota"].(float64) != 100000 { + t.Errorf("Expected quota 100000, got %v", data["quota"]) + } + if data["used_quota"].(float64) != 5000 { + t.Errorf("Expected used_quota 5000, got %v", data["used_quota"]) + } +} + +func TestOAuthGetUsage_Unauthorized(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + mock := hydra.NewMockProvider() + router := setupOAuthAPITestRouter(mock) + + req, _ := http.NewRequest("GET", "/api/v1/oauth/usage", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code) + } +} + +func TestOAuthGetUsage_Success(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + createTestUser(t, 123, "testuser") + + mock := hydra.NewMockProvider() + mock.SetIntrospectedToken("valid-token", true, "123", "usage:read", "test-client") + router := setupOAuthAPITestRouter(mock) + + req, _ := http.NewRequest("GET", "/api/v1/oauth/usage", nil) + req.Header.Set("Authorization", "Bearer valid-token") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + + if resp["success"] != true { + t.Errorf("Expected success=true, got %v", resp["success"]) + } + + data := resp["data"].(map[string]interface{}) + if data["request_count"].(float64) != 42 { + t.Errorf("Expected request_count 42, got %v", data["request_count"]) + } +} + +func TestOAuthAPI_ScopeInContext(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + mock := hydra.NewMockProvider() + mock.SetIntrospectedToken("scoped-token", true, "456", "openid balance:read tokens:write", "third-party-app") + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(middleware.OAuthTokenAuth(mock)) + r.GET("/test-scope", func(c *gin.Context) { + scope := c.GetString("oauth_scope") + clientID := c.GetString("oauth_client_id") + c.JSON(200, gin.H{ + "scope": scope, + "client_id": clientID, + }) + }) + + req, _ := http.NewRequest("GET", "/test-scope", nil) + req.Header.Set("Authorization", "Bearer scoped-token") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + + if resp["scope"] != "openid balance:read tokens:write" { + t.Errorf("Expected full scope in context, got %v", resp["scope"]) + } + if resp["client_id"] != "third-party-app" { + t.Errorf("Expected client_id 'third-party-app', got %v", resp["client_id"]) + } +} + +func setupOAuthAPITestRouterWithTokens(mock *hydra.MockProvider) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + + // Apply OAuth middleware + oauthAPI := r.Group("/api/v1/oauth") + oauthAPI.Use(middleware.OAuthTokenAuth(mock)) + { + oauthAPI.GET("/userinfo", OAuthGetUserInfo) + oauthAPI.GET("/balance", OAuthGetBalance) + oauthAPI.GET("/usage", OAuthGetUsage) + oauthAPI.GET("/tokens", OAuthListTokens) + oauthAPI.POST("/tokens", OAuthCreateToken) + oauthAPI.DELETE("/tokens/:id", OAuthDeleteToken) + } + + return r +} + +func TestOAuthListTokens_Success(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + createTestUser(t, 123, "testuser") + + // Create some test tokens for the user + token1 := &model.Token{ + UserId: 123, + Name: "Test Token 1", + Key: "sk-test1", + Status: 1, + UnlimitedQuota: false, + RemainQuota: 1000, + } + token2 := &model.Token{ + UserId: 123, + Name: "Test Token 2", + Key: "sk-test2", + Status: 1, + UnlimitedQuota: true, + } + model.DB.Create(token1) + model.DB.Create(token2) + + mock := hydra.NewMockProvider() + mock.SetIntrospectedToken("valid-token", true, "123", "tokens:read", "test-client") + router := setupOAuthAPITestRouterWithTokens(mock) + + req, _ := http.NewRequest("GET", "/api/v1/oauth/tokens", nil) + req.Header.Set("Authorization", "Bearer valid-token") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + + if resp["success"] != true { + t.Errorf("Expected success=true, got %v", resp["success"]) + } + + tokens := resp["data"].([]interface{}) + if len(tokens) != 2 { + t.Errorf("Expected 2 tokens, got %d", len(tokens)) + } + + // Verify key is not exposed in list + firstToken := tokens[0].(map[string]interface{}) + if _, hasKey := firstToken["key"]; hasKey { + t.Error("Token key should not be exposed in list") + } +} + +func TestOAuthCreateToken_Success(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + createTestUser(t, 123, "testuser") + + mock := hydra.NewMockProvider() + mock.SetIntrospectedToken("valid-token", true, "123", "tokens:write", "test-client") + router := setupOAuthAPITestRouterWithTokens(mock) + + body := strings.NewReader(`{"name": "New API Token"}`) + req, _ := http.NewRequest("POST", "/api/v1/oauth/tokens", body) + req.Header.Set("Authorization", "Bearer valid-token") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, w.Code, w.Body.String()) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + + if resp["success"] != true { + t.Errorf("Expected success=true, got %v", resp["success"]) + } + + data := resp["data"].(map[string]interface{}) + if data["name"] != "New API Token" { + t.Errorf("Expected name 'New API Token', got %v", data["name"]) + } + // Key should be returned on creation + if data["key"] == nil || data["key"] == "" { + t.Error("Token key should be returned on creation") + } +} + +func TestOAuthCreateToken_InvalidRequest(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + createTestUser(t, 123, "testuser") + + mock := hydra.NewMockProvider() + mock.SetIntrospectedToken("valid-token", true, "123", "tokens:write", "test-client") + router := setupOAuthAPITestRouterWithTokens(mock) + + // Missing required name field + body := strings.NewReader(`{}`) + req, _ := http.NewRequest("POST", "/api/v1/oauth/tokens", body) + req.Header.Set("Authorization", "Bearer valid-token") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } +} + +func TestOAuthDeleteToken_Success(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + createTestUser(t, 123, "testuser") + + // Create a token to delete + token := &model.Token{ + UserId: 123, + Name: "Token to Delete", + Key: "sk-delete-me", + Status: 1, + } + model.DB.Create(token) + tokenId := token.Id + + mock := hydra.NewMockProvider() + mock.SetIntrospectedToken("valid-token", true, "123", "tokens:write", "test-client") + router := setupOAuthAPITestRouterWithTokens(mock) + + req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/v1/oauth/tokens/%d", tokenId), nil) + req.Header.Set("Authorization", "Bearer valid-token") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Should succeed + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, w.Code, w.Body.String()) + } +} + +func TestOAuthDeleteToken_NotOwned(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + createTestUser(t, 123, "testuser") + createTestUser(t, 456, "otheruser") + + // Create a token owned by another user + token := &model.Token{ + UserId: 456, + Name: "Other User's Token", + Key: "sk-other", + Status: 1, + } + model.DB.Create(token) + tokenId := token.Id + + mock := hydra.NewMockProvider() + mock.SetIntrospectedToken("valid-token", true, "123", "tokens:write", "test-client") + router := setupOAuthAPITestRouterWithTokens(mock) + + req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/v1/oauth/tokens/%d", tokenId), nil) + req.Header.Set("Authorization", "Bearer valid-token") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Should fail - can't delete other user's token + if w.Code != http.StatusNotFound { + t.Errorf("Expected status %d, got %d", http.StatusNotFound, w.Code) + } +} diff --git a/controller/oauth_provider.go b/controller/oauth_provider.go new file mode 100644 index 000000000000..af41ca93ebb0 --- /dev/null +++ b/controller/oauth_provider.go @@ -0,0 +1,862 @@ +package controller + +import ( + "fmt" + "net/http" + "slices" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service/hydra" + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// OAuthProviderController handles Hydra login/consent/logout flows +type OAuthProviderController struct { + hydra hydra.Provider +} + +// NewOAuthProviderController creates a new OAuth provider controller +func NewOAuthProviderController(hydraProvider hydra.Provider) *OAuthProviderController { + return &OAuthProviderController{ + hydra: hydraProvider, + } +} + +func setOAuthSession(c *gin.Context, user *model.User) error { + session := sessions.Default(c) + session.Set("id", user.Id) + session.Set("username", user.Username) + session.Set("role", user.Role) + session.Set("status", user.Status) + session.Set("group", user.Group) + return session.Save() +} + +// OAuthLoginRequest represents the login form submission +type OAuthLoginRequest struct { + Challenge string `json:"login_challenge" form:"login_challenge"` + Username string `json:"username" form:"username"` + Password string `json:"password" form:"password"` +} + +// OAuthLogin handles GET /oauth/login - displays login page or auto-accepts if session exists +func (ctrl *OAuthProviderController) OAuthLogin(c *gin.Context) { + challenge := c.Query("login_challenge") + if challenge == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "missing login_challenge", + }) + return + } + + // Get login request from Hydra + loginReq, err := ctrl.hydra.GetLoginRequest(c.Request.Context(), challenge) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid login challenge: " + err.Error(), + }) + return + } + + // If skip is true, the user has already authenticated with Hydra + // We can accept the login request immediately + if loginReq.GetSkip() { + redirect, err := ctrl.hydra.AcceptLogin(c.Request.Context(), challenge, loginReq.GetSubject(), false, 0) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to accept login: " + err.Error(), + }) + return + } + // Return JSON for frontend to handle redirect (avoid CORS issues with HTTP redirects) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "redirect_to": redirect.RedirectTo, + }, + }) + return + } + + // Check if user is already logged in via session + session := sessions.Default(c) + if userID := session.Get("id"); userID != nil { + subject := strconv.Itoa(userID.(int)) + redirect, err := ctrl.hydra.AcceptLogin(c.Request.Context(), challenge, subject, true, common.HydraLoginRememberFor) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to accept login: " + err.Error(), + }) + return + } + // Return JSON for frontend to handle redirect (avoid CORS issues with HTTP redirects) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "redirect_to": redirect.RedirectTo, + }, + }) + return + } + + // Return login page info for frontend to render + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "challenge": challenge, + "client_id": loginReq.Client.GetClientId(), + "client_name": loginReq.Client.GetClientName(), + "requested_scope": loginReq.GetRequestedScope(), + }, + }) +} + +// OAuthLoginSubmit handles POST /oauth/login - processes login form +func (ctrl *OAuthProviderController) OAuthLoginSubmit(c *gin.Context) { + var req OAuthLoginRequest + if err := c.ShouldBind(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid request: " + err.Error(), + }) + return + } + + if req.Challenge == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "missing challenge", + }) + return + } + + if req.Username == "" || req.Password == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "missing username or password", + }) + return + } + + // Check if password login is enabled + if !common.PasswordLoginEnabled { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "password login is disabled", + }) + return + } + + // Validate user credentials using existing model + user := model.User{ + Username: req.Username, + Password: req.Password, + } + if err := user.ValidateAndFill(); err != nil { + // Reject login with error + redirect, rejectErr := ctrl.hydra.RejectLogin(c.Request.Context(), req.Challenge, "access_denied", err.Error()) + if rejectErr != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to reject login: " + rejectErr.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + "redirect_to": redirect.RedirectTo, + }) + return + } + + // Check if 2FA is enabled + if model.IsTwoFAEnabled(user.Id) { + // Store pending state for 2FA + session := sessions.Default(c) + session.Set("oauth_pending_user_id", user.Id) + session.Set("oauth_pending_challenge", req.Challenge) + if err := session.Save(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to save session", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "require_2fa": true, + "challenge": req.Challenge, + }, + }) + return + } + + if err := setOAuthSession(c, &user); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to save session", + }) + return + } + + // Accept login + subject := strconv.Itoa(user.Id) + redirect, err := ctrl.hydra.AcceptLogin(c.Request.Context(), req.Challenge, subject, true, common.HydraLoginRememberFor) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to accept login: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "redirect_to": redirect.RedirectTo, + }, + }) +} + +// OAuthLogin2FA handles POST /oauth/login/2fa - processes 2FA verification for OAuth login +func (ctrl *OAuthProviderController) OAuthLogin2FA(c *gin.Context) { + var req struct { + Code string `json:"code" form:"code"` + } + if err := c.ShouldBind(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid request", + }) + return + } + + session := sessions.Default(c) + userID := session.Get("oauth_pending_user_id") + challenge := session.Get("oauth_pending_challenge") + + if userID == nil || challenge == nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "no pending 2FA verification", + }) + return + } + + // Verify 2FA code using existing logic + twoFA, err := model.GetTwoFAByUserId(userID.(int)) + if err != nil || twoFA == nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "2FA not configured", + }) + return + } + + // Check if locked + if twoFA.IsLocked() { + c.JSON(http.StatusTooManyRequests, gin.H{ + "success": false, + "message": "too many failed attempts, please try again later", + }) + return + } + + // Verify TOTP code + valid := common.ValidateTOTPCode(twoFA.Secret, req.Code) + if !valid { + // Try backup code + valid = model.UseBackupCode(userID.(int), req.Code) + } + + if !valid { + _ = twoFA.IncrementFailedAttempts() + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "invalid verification code", + }) + return + } + + // Clear pending state + session.Delete("oauth_pending_user_id") + session.Delete("oauth_pending_challenge") + + user, err := model.GetUserById(userID.(int), false) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to load user", + }) + return + } + + if err := setOAuthSession(c, user); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to save session", + }) + return + } + + // Accept login + subject := strconv.Itoa(userID.(int)) + redirect, err := ctrl.hydra.AcceptLogin(c.Request.Context(), challenge.(string), subject, true, common.HydraLoginRememberFor) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to accept login: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "redirect_to": redirect.RedirectTo, + }, + }) +} + +// OAuthConsent handles GET /oauth/consent - displays consent page +func (ctrl *OAuthProviderController) OAuthConsent(c *gin.Context) { + challenge := c.Query("consent_challenge") + if challenge == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "missing consent_challenge", + }) + return + } + + consentReq, err := ctrl.hydra.GetConsentRequest(c.Request.Context(), challenge) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid consent challenge: " + err.Error(), + }) + return + } + + session := sessions.Default(c) + subject := consentReq.GetSubject() + if subject == "" || session.Get("id") == nil || fmt.Sprint(session.Get("id")) != subject { + redirect, err := ctrl.hydra.RejectConsent(c.Request.Context(), challenge, "login_required", "user login required") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to reject consent: " + err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "redirect_to": redirect.RedirectTo, + }, + }) + return + } + + // If skip is true, the user has already given consent + if consentReq.GetSkip() { + redirect, err := ctrl.hydra.AcceptConsent( + c.Request.Context(), + challenge, + consentReq.GetRequestedScope(), + false, + 0, + nil, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to accept consent: " + err.Error(), + }) + return + } + // Return JSON for frontend to handle redirect (avoid CORS issues with HTTP redirects) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "redirect_to": redirect.RedirectTo, + }, + }) + return + } + + // Check if this is a trusted first-party client (auto-consent) + clientID := consentReq.Client.GetClientId() + if isTrustedOAuthClient(clientID) { + redirect, err := ctrl.hydra.AcceptConsent( + c.Request.Context(), + challenge, + consentReq.GetRequestedScope(), + true, + common.HydraConsentRememberFor, + nil, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to accept consent: " + err.Error(), + }) + return + } + // Return JSON for frontend to handle redirect (avoid CORS issues with HTTP redirects) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "redirect_to": redirect.RedirectTo, + }, + }) + return + } + + // Return consent page info for frontend + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "challenge": challenge, + "client_id": clientID, + "client_name": consentReq.Client.GetClientName(), + "requested_scope": consentReq.GetRequestedScope(), + "subject": consentReq.GetSubject(), + }, + }) +} + +// OAuthConsentRequest represents consent form submission +type OAuthConsentRequest struct { + Challenge string `json:"consent_challenge" form:"consent_challenge"` + GrantScope []string `json:"grant_scope" form:"grant_scope"` + Remember bool `json:"remember" form:"remember"` +} + +// OAuthConsentSubmit handles POST /oauth/consent - processes consent form +func (ctrl *OAuthProviderController) OAuthConsentSubmit(c *gin.Context) { + var req OAuthConsentRequest + if err := c.ShouldBind(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid request: " + err.Error(), + }) + return + } + + if req.Challenge == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "missing challenge", + }) + return + } + + consentReq, err := ctrl.hydra.GetConsentRequest(c.Request.Context(), req.Challenge) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid consent challenge: " + err.Error(), + }) + return + } + + session := sessions.Default(c) + subject := consentReq.GetSubject() + if subject == "" || session.Get("id") == nil || fmt.Sprint(session.Get("id")) != subject { + reject, err := ctrl.hydra.RejectConsent(c.Request.Context(), req.Challenge, "login_required", "user login required") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to reject consent: " + err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "redirect_to": reject.RedirectTo, + }, + }) + return + } + + var rememberFor int64 = 0 + if req.Remember { + rememberFor = common.HydraConsentRememberFor + } + + redirect, err := ctrl.hydra.AcceptConsent( + c.Request.Context(), + req.Challenge, + req.GrantScope, + req.Remember, + rememberFor, + nil, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to accept consent: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "redirect_to": redirect.RedirectTo, + }, + }) +} + +// OAuthConsentReject handles POST /oauth/consent/reject - rejects consent +func (ctrl *OAuthProviderController) OAuthConsentReject(c *gin.Context) { + var req struct { + Challenge string `json:"consent_challenge" form:"consent_challenge"` + } + if err := c.ShouldBind(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid request: " + err.Error(), + }) + return + } + + if req.Challenge == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "missing consent_challenge", + }) + return + } + + consentReq, err := ctrl.hydra.GetConsentRequest(c.Request.Context(), req.Challenge) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid consent challenge: " + err.Error(), + }) + return + } + + session := sessions.Default(c) + subject := consentReq.GetSubject() + if subject == "" || session.Get("id") == nil || fmt.Sprint(session.Get("id")) != subject { + reject, err := ctrl.hydra.RejectConsent(c.Request.Context(), req.Challenge, "login_required", "user login required") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to reject consent: " + err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "redirect_to": reject.RedirectTo, + }, + }) + return + } + + redirect, err := ctrl.hydra.RejectConsent(c.Request.Context(), req.Challenge, "access_denied", "user denied consent") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to reject consent: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "redirect_to": redirect.RedirectTo, + }, + }) +} + +// OAuthLogout handles GET /oauth/logout - displays logout confirmation +func (ctrl *OAuthProviderController) OAuthLogout(c *gin.Context) { + challenge := c.Query("logout_challenge") + if challenge == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "missing logout_challenge", + }) + return + } + + // Validate the logout challenge exists + _, err := ctrl.hydra.GetLogoutRequest(c.Request.Context(), challenge) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid logout challenge: " + err.Error(), + }) + return + } + + // Auto-accept logout for now + // Could show a confirmation page if needed + redirect, err := ctrl.hydra.AcceptLogout(c.Request.Context(), challenge) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to accept logout: " + err.Error(), + }) + return + } + + // Clear local session + session := sessions.Default(c) + session.Clear() + _ = session.Save() + + // Return JSON for frontend to handle redirect (avoid CORS issues with HTTP redirects) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "redirect_to": redirect.RedirectTo, + }, + }) +} + +// isTrustedOAuthClient checks if a client is a trusted first-party app +// Trusted clients get auto-consent without user interaction +// Configure via HydraTrustedClients setting (comma-separated client IDs) +func isTrustedOAuthClient(clientID string) bool { + return slices.Contains(common.HydraTrustedClients, clientID) +} + +// OAuthRegisterClientRequest represents the request to register an OAuth client +type OAuthRegisterClientRequest struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + ClientName string `json:"client_name"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` + RedirectURIs []string `json:"redirect_uris"` + Scope string `json:"scope"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` +} + +// OAuthRegisterClient handles POST /oauth/admin/clients - registers a new OAuth client (admin only) +func (ctrl *OAuthProviderController) OAuthRegisterClient(c *gin.Context) { + var req OAuthRegisterClientRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid request: " + err.Error(), + }) + return + } + + // Auto-generate client_id if not provided + if req.ClientID == "" { + req.ClientID = uuid.New().String() + } + + // Set defaults + if len(req.GrantTypes) == 0 { + req.GrantTypes = []string{"authorization_code", "refresh_token"} + } + if len(req.ResponseTypes) == 0 { + req.ResponseTypes = []string{"code"} + } + if req.TokenEndpointAuthMethod == "" { + req.TokenEndpointAuthMethod = "client_secret_post" + } + if req.ClientName == "" { + req.ClientName = req.ClientID + } + + // Determine client type based on token_endpoint_auth_method + clientType := model.OAuthClientTypeConfidential + if req.TokenEndpointAuthMethod == "none" { + clientType = model.OAuthClientTypePublic + req.ClientSecret = "" // Public clients don't have secrets + } else { + // Auto-generate client_secret for confidential clients if not provided + if req.ClientSecret == "" { + req.ClientSecret = uuid.New().String() + } + } + + // Get current user ID from context (set by AdminAuth middleware) + userID := c.GetInt("id") + + // Create client in Hydra + client, err := ctrl.hydra.CreateOAuth2Client( + c.Request.Context(), + req.ClientID, + req.ClientSecret, + req.ClientName, + req.GrantTypes, + req.ResponseTypes, + req.RedirectURIs, + req.Scope, + req.TokenEndpointAuthMethod, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to create client: " + err.Error(), + }) + return + } + + // Save client ownership to database + oauthClient := &model.OAuthClient{ + HydraClientID: req.ClientID, + UserID: userID, + ClientName: req.ClientName, + ClientType: clientType, + AllowedScopes: req.Scope, + RedirectURIs: strings.Join(req.RedirectURIs, ","), + } + if err := model.CreateOAuthClient(oauthClient); err != nil { + // Log the error but don't fail the request since client was created in Hydra + common.SysError("failed to save oauth client ownership: " + err.Error()) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": client, + }) +} + +// OAuthListClients handles GET /oauth/admin/clients - lists all OAuth clients (admin only) +func (ctrl *OAuthProviderController) OAuthListClients(c *gin.Context) { + clients, err := ctrl.hydra.ListOAuth2Clients(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to list clients: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": clients, + }) +} + +// OAuthDeleteClient handles DELETE /oauth/admin/clients/:id - deletes an OAuth client (admin only) +func (ctrl *OAuthProviderController) OAuthDeleteClient(c *gin.Context) { + clientID := c.Param("id") + if clientID == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "client_id is required", + }) + return + } + + // Delete from Hydra + if err := ctrl.hydra.DeleteOAuth2Client(c.Request.Context(), clientID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to delete client: " + err.Error(), + }) + return + } + + // Delete from our database (ignore error since Hydra deletion succeeded) + if err := model.DeleteOAuthClientByHydraID(clientID); err != nil { + common.SysError("failed to delete oauth client from database: " + err.Error()) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "client deleted", + }) +} + +// OAuthUpdateClientRequest represents the request to update an OAuth client +type OAuthUpdateClientRequest struct { + ClientName string `json:"client_name"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` + RedirectURIs []string `json:"redirect_uris"` + Scope string `json:"scope"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` +} + +// OAuthUpdateClient handles PUT /oauth/admin/clients/:id - updates an OAuth client (admin only) +func (ctrl *OAuthProviderController) OAuthUpdateClient(c *gin.Context) { + clientID := c.Param("id") + if clientID == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "client_id is required", + }) + return + } + + var req OAuthUpdateClientRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid request: " + err.Error(), + }) + return + } + + // Set defaults + if len(req.GrantTypes) == 0 { + req.GrantTypes = []string{"authorization_code", "refresh_token"} + } + if len(req.ResponseTypes) == 0 { + req.ResponseTypes = []string{"code"} + } + if req.TokenEndpointAuthMethod == "" { + req.TokenEndpointAuthMethod = "client_secret_post" + } + if req.ClientName == "" { + req.ClientName = clientID + } + + // Update client in Hydra + client, err := ctrl.hydra.UpdateOAuth2Client( + c.Request.Context(), + clientID, + req.ClientName, + req.GrantTypes, + req.ResponseTypes, + req.RedirectURIs, + req.Scope, + req.TokenEndpointAuthMethod, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to update client: " + err.Error(), + }) + return + } + + // Update in our database (ignore error since Hydra update succeeded) + if err := model.UpdateOAuthClientByHydraID(clientID, req.ClientName, req.Scope, strings.Join(req.RedirectURIs, ",")); err != nil { + common.SysError("failed to update oauth client in database: " + err.Error()) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": client, + }) +} diff --git a/controller/oauth_provider_test.go b/controller/oauth_provider_test.go new file mode 100644 index 000000000000..a25a45476a45 --- /dev/null +++ b/controller/oauth_provider_test.go @@ -0,0 +1,594 @@ +package controller + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/service/hydra" + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" +) + +func setupTestRouter(mock *hydra.MockProvider) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + + // Set up cookie session store for testing + store := cookie.NewStore([]byte("test-secret")) + r.Use(sessions.Sessions("session", store)) + + ctrl := NewOAuthProviderController(mock) + + // OAuth login routes + r.GET("/oauth/login", ctrl.OAuthLogin) + r.POST("/oauth/login", ctrl.OAuthLoginSubmit) + r.POST("/oauth/login/2fa", ctrl.OAuthLogin2FA) + + // OAuth consent routes + r.GET("/oauth/consent", ctrl.OAuthConsent) + r.POST("/oauth/consent", ctrl.OAuthConsentSubmit) + r.POST("/oauth/consent/reject", ctrl.OAuthConsentReject) + + // OAuth logout routes + r.GET("/oauth/logout", ctrl.OAuthLogout) + + // Test helper to set session cookie + r.GET("/_test/set-session", func(c *gin.Context) { + idParam := c.Query("id") + if idParam == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false}) + return + } + id, err := strconv.Atoi(idParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false}) + return + } + session := sessions.Default(c) + session.Set("id", id) + if err := session.Save(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) + }) + + return r +} + +func setSessionCookie(router *gin.Engine, userID string) string { + req, _ := http.NewRequest("GET", "/_test/set-session?id="+userID, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w.Header().Get("Set-Cookie") +} + +func TestOAuthLogin_MissingChallenge(t *testing.T) { + mock := hydra.NewMockProvider() + router := setupTestRouter(mock) + + req, _ := http.NewRequest("GET", "/oauth/login", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["success"] != false { + t.Error("Expected success=false") + } + if resp["message"] != "missing login_challenge" { + t.Errorf("Expected 'missing login_challenge', got %v", resp["message"]) + } +} + +func TestOAuthLogin_InvalidChallenge(t *testing.T) { + mock := hydra.NewMockProvider() + router := setupTestRouter(mock) + + req, _ := http.NewRequest("GET", "/oauth/login?login_challenge=invalid", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["success"] != false { + t.Error("Expected success=false") + } +} + +func TestOAuthLogin_SkipTrue(t *testing.T) { + mock := hydra.NewMockProvider() + mock.SetLoginRequest("skip-challenge", "test-client", "Test App", []string{"openid"}, true, "123") + router := setupTestRouter(mock) + + req, _ := http.NewRequest("GET", "/oauth/login?login_challenge=skip-challenge", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Should return redirect info when skip=true + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + // Check that login was accepted + if _, ok := mock.AcceptedLogins["skip-challenge"]; !ok { + t.Error("Login should have been accepted") + } +} + +func TestOAuthLogin_ShowLoginPage(t *testing.T) { + mock := hydra.NewMockProvider() + mock.SetLoginRequest("login-challenge", "test-client", "Test App", []string{"openid", "profile"}, false, "") + router := setupTestRouter(mock) + + req, _ := http.NewRequest("GET", "/oauth/login?login_challenge=login-challenge", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Should return login page info + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["success"] != true { + t.Error("Expected success=true") + } + + data := resp["data"].(map[string]interface{}) + if data["challenge"] != "login-challenge" { + t.Errorf("Expected challenge 'login-challenge', got %v", data["challenge"]) + } + if data["client_id"] != "test-client" { + t.Errorf("Expected client_id 'test-client', got %v", data["client_id"]) + } +} + +func TestOAuthLogin_HydraError(t *testing.T) { + mock := hydra.NewMockProvider() + mock.SetLoginRequest("error-challenge", "test-client", "Test App", []string{"openid"}, true, "123") + mock.AcceptLoginErr = http.ErrAbortHandler + router := setupTestRouter(mock) + + req, _ := http.NewRequest("GET", "/oauth/login?login_challenge=error-challenge", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("Expected status %d, got %d", http.StatusInternalServerError, w.Code) + } +} + +func TestOAuthLoginSubmit_MissingChallenge(t *testing.T) { + mock := hydra.NewMockProvider() + router := setupTestRouter(mock) + + form := url.Values{} + form.Set("username", "testuser") + form.Set("password", "testpass") + req, _ := http.NewRequest("POST", "/oauth/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["message"] != "missing challenge" { + t.Errorf("Expected 'missing challenge', got %v", resp["message"]) + } +} + +func TestOAuthLoginSubmit_MissingCredentials(t *testing.T) { + mock := hydra.NewMockProvider() + router := setupTestRouter(mock) + + form := url.Values{} + form.Set("challenge", "test-challenge") + req, _ := http.NewRequest("POST", "/oauth/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["message"] != "missing username or password" { + t.Errorf("Expected 'missing username or password', got %v", resp["message"]) + } +} + +func TestOAuthConsent_MissingChallenge(t *testing.T) { + mock := hydra.NewMockProvider() + router := setupTestRouter(mock) + + req, _ := http.NewRequest("GET", "/oauth/consent", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["message"] != "missing consent_challenge" { + t.Errorf("Expected 'missing consent_challenge', got %v", resp["message"]) + } +} + +func TestOAuthConsent_InvalidChallenge(t *testing.T) { + mock := hydra.NewMockProvider() + router := setupTestRouter(mock) + + req, _ := http.NewRequest("GET", "/oauth/consent?consent_challenge=invalid", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } +} + +func TestOAuthConsent_RequiresSession(t *testing.T) { + mock := hydra.NewMockProvider() + mock.SetConsentRequest("consent-no-session", "third-party-app", "Third Party", "123", []string{"openid"}, false) + router := setupTestRouter(mock) + + req, _ := http.NewRequest("GET", "/oauth/consent?consent_challenge=consent-no-session", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + if mock.RejectedConsents["consent-no-session"] != "login_required" { + t.Error("Consent should have been rejected with login_required") + } +} + +func TestOAuthConsent_SessionMismatch(t *testing.T) { + mock := hydra.NewMockProvider() + mock.SetConsentRequest("consent-mismatch", "third-party-app", "Third Party", "123", []string{"openid"}, false) + router := setupTestRouter(mock) + cookie := setSessionCookie(router, "456") + + req, _ := http.NewRequest("GET", "/oauth/consent?consent_challenge=consent-mismatch", nil) + req.Header.Set("Cookie", cookie) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + if mock.RejectedConsents["consent-mismatch"] != "login_required" { + t.Error("Consent should have been rejected with login_required") + } +} + +func TestOAuthConsent_SkipTrue(t *testing.T) { + mock := hydra.NewMockProvider() + mock.SetConsentRequest("skip-consent", "test-client", "Test App", "123", []string{"openid"}, true) + router := setupTestRouter(mock) + cookie := setSessionCookie(router, "123") + + req, _ := http.NewRequest("GET", "/oauth/consent?consent_challenge=skip-consent", nil) + req.Header.Set("Cookie", cookie) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Should return redirect info when skip=true + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + // Check that consent was accepted + if _, ok := mock.AcceptedConsents["skip-consent"]; !ok { + t.Error("Consent should have been accepted") + } +} + +func TestOAuthConsent_TrustedClient(t *testing.T) { + // Setup trusted clients for this test + oldTrustedClients := common.HydraTrustedClients + common.HydraTrustedClients = []string{"new-api-web", "new-api-admin"} + defer func() { common.HydraTrustedClients = oldTrustedClients }() + + mock := hydra.NewMockProvider() + // "new-api-web" is a trusted client + mock.SetConsentRequest("trusted-consent", "new-api-web", "Web App", "123", []string{"openid", "profile"}, false) + router := setupTestRouter(mock) + cookie := setSessionCookie(router, "123") + + req, _ := http.NewRequest("GET", "/oauth/consent?consent_challenge=trusted-consent", nil) + req.Header.Set("Cookie", cookie) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Should return redirect info for trusted client (auto-consent) + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + // Check that consent was accepted + if _, ok := mock.AcceptedConsents["trusted-consent"]; !ok { + t.Error("Consent should have been accepted for trusted client") + } +} + +func TestOAuthConsent_ShowConsentPage(t *testing.T) { + mock := hydra.NewMockProvider() + mock.SetConsentRequest("consent-challenge", "third-party-app", "Third Party", "123", []string{"openid", "profile", "email"}, false) + router := setupTestRouter(mock) + cookie := setSessionCookie(router, "123") + + req, _ := http.NewRequest("GET", "/oauth/consent?consent_challenge=consent-challenge", nil) + req.Header.Set("Cookie", cookie) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Should return consent page info for non-trusted clients + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["success"] != true { + t.Error("Expected success=true") + } + + data := resp["data"].(map[string]interface{}) + if data["challenge"] != "consent-challenge" { + t.Errorf("Expected challenge 'consent-challenge', got %v", data["challenge"]) + } + if data["client_id"] != "third-party-app" { + t.Errorf("Expected client_id 'third-party-app', got %v", data["client_id"]) + } +} + +func TestOAuthConsentSubmit_MissingChallenge(t *testing.T) { + mock := hydra.NewMockProvider() + router := setupTestRouter(mock) + + form := url.Values{} + form.Add("grant_scope", "openid") + req, _ := http.NewRequest("POST", "/oauth/consent", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["message"] != "missing challenge" { + t.Errorf("Expected 'missing challenge', got %v", resp["message"]) + } +} + +func TestOAuthConsentSubmit_Success(t *testing.T) { + mock := hydra.NewMockProvider() + mock.SetConsentRequest("consent-submit", "test-client", "Test App", "123", []string{"openid", "profile"}, false) + router := setupTestRouter(mock) + cookie := setSessionCookie(router, "123") + + form := url.Values{} + form.Set("consent_challenge", "consent-submit") + form.Add("grant_scope", "openid") + form.Add("grant_scope", "profile") + form.Set("remember", "true") + req, _ := http.NewRequest("POST", "/oauth/consent", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Cookie", cookie) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["success"] != true { + t.Error("Expected success=true") + } + if resp["redirect_to"] == nil { + t.Error("Expected redirect_to in response") + } + + // Check that consent was accepted with correct scopes + if scopes, ok := mock.AcceptedConsents["consent-submit"]; !ok { + t.Error("Consent should have been accepted") + } else if len(scopes) != 2 { + t.Errorf("Expected 2 scopes, got %d", len(scopes)) + } +} + +func TestOAuthConsentSubmit_RequiresSession(t *testing.T) { + mock := hydra.NewMockProvider() + mock.SetConsentRequest("consent-submit-no-session", "test-client", "Test App", "123", []string{"openid"}, false) + router := setupTestRouter(mock) + + form := url.Values{} + form.Set("consent_challenge", "consent-submit-no-session") + form.Add("grant_scope", "openid") + req, _ := http.NewRequest("POST", "/oauth/consent", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + if mock.RejectedConsents["consent-submit-no-session"] != "login_required" { + t.Error("Consent submit should have been rejected with login_required") + } + if _, ok := mock.AcceptedConsents["consent-submit-no-session"]; ok { + t.Error("Consent submit should not be accepted without session") + } +} + +func TestOAuthConsentReject_MissingChallenge(t *testing.T) { + mock := hydra.NewMockProvider() + router := setupTestRouter(mock) + + form := url.Values{} + req, _ := http.NewRequest("POST", "/oauth/consent/reject", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } +} + +func TestOAuthConsentReject_Success(t *testing.T) { + mock := hydra.NewMockProvider() + mock.SetConsentRequest("reject-consent", "test-client", "Test App", "123", []string{"openid"}, false) + router := setupTestRouter(mock) + cookie := setSessionCookie(router, "123") + + form := url.Values{} + form.Set("consent_challenge", "reject-consent") + req, _ := http.NewRequest("POST", "/oauth/consent/reject", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Cookie", cookie) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + // Check that consent was rejected + if _, ok := mock.RejectedConsents["reject-consent"]; !ok { + t.Error("Consent should have been rejected") + } +} + +func TestOAuthConsentReject_RequiresSession(t *testing.T) { + mock := hydra.NewMockProvider() + mock.SetConsentRequest("reject-no-session", "test-client", "Test App", "123", []string{"openid"}, false) + router := setupTestRouter(mock) + + form := url.Values{} + form.Set("consent_challenge", "reject-no-session") + req, _ := http.NewRequest("POST", "/oauth/consent/reject", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + if mock.RejectedConsents["reject-no-session"] != "login_required" { + t.Error("Consent reject should have been rejected with login_required") + } +} + +func TestOAuthLogout_MissingChallenge(t *testing.T) { + mock := hydra.NewMockProvider() + router := setupTestRouter(mock) + + req, _ := http.NewRequest("GET", "/oauth/logout", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["message"] != "missing logout_challenge" { + t.Errorf("Expected 'missing logout_challenge', got %v", resp["message"]) + } +} + +func TestOAuthLogout_InvalidChallenge(t *testing.T) { + mock := hydra.NewMockProvider() + router := setupTestRouter(mock) + + req, _ := http.NewRequest("GET", "/oauth/logout?logout_challenge=invalid", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } +} + +func TestOAuthLogout_Success(t *testing.T) { + mock := hydra.NewMockProvider() + mock.SetLogoutRequest("logout-challenge", "123", "session-456") + router := setupTestRouter(mock) + + req, _ := http.NewRequest("GET", "/oauth/logout?logout_challenge=logout-challenge", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Should return redirect info after accepting logout + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + // Check that logout was accepted + if _, ok := mock.AcceptedLogouts["logout-challenge"]; !ok { + t.Error("Logout should have been accepted") + } +} + +func TestIsTrustedOAuthClient(t *testing.T) { + // Setup trusted clients for this test + oldTrustedClients := common.HydraTrustedClients + common.HydraTrustedClients = []string{"new-api-web", "new-api-admin"} + defer func() { common.HydraTrustedClients = oldTrustedClients }() + + tests := []struct { + clientID string + expected bool + }{ + {"new-api-web", true}, + {"new-api-admin", true}, + {"third-party-app", false}, + {"unknown", false}, + {"", false}, + } + + for _, tt := range tests { + result := isTrustedOAuthClient(tt.clientID) + if result != tt.expected { + t.Errorf("isTrustedOAuthClient(%q) = %v, expected %v", tt.clientID, result, tt.expected) + } + } +} diff --git a/controller/task.go b/controller/task.go index c14d7e21d9ff..4fadacec339a 100644 --- a/controller/task.go +++ b/controller/task.go @@ -88,7 +88,7 @@ func UpdateSunoTaskAll(ctx context.Context, taskChannelM map[int][]string, taskM for channelId, taskIds := range taskChannelM { err := updateSunoTaskAll(ctx, channelId, taskIds, taskM) if err != nil { - logger.LogError(ctx, fmt.Sprintf("渠道 #%d 更新异步任务失败: %d", channelId, err.Error())) + logger.LogError(ctx, fmt.Sprintf("渠道 #%d 更新异步任务失败: %s", channelId, err.Error())) } } return nil @@ -125,7 +125,7 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas } if resp.StatusCode != http.StatusOK { logger.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode)) - return errors.New(fmt.Sprintf("Get Task status code: %d", resp.StatusCode)) + return fmt.Errorf("Get Task status code: %d", resp.StatusCode) } defer resp.Body.Close() responseBody, err := io.ReadAll(resp.Body) @@ -140,7 +140,7 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas return err } if !responseItems.IsSuccess() { - common.SysLog(fmt.Sprintf("渠道 #%d 未完成的任务有: %d, 成功获取到任务数: %d", channelId, len(taskIds), string(responseBody))) + common.SysLog(fmt.Sprintf("渠道 #%d 未完成的任务有: %d, 成功获取到任务数: %s", channelId, len(taskIds), string(responseBody))) return err } diff --git a/docker-compose.yml b/docker-compose.yml index a9d00967cf49..9ae1dbe8b704 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,84 +1,35 @@ -# New-API Docker Compose Configuration -# -# Quick Start: -# 1. docker-compose up -d -# 2. Access at http://localhost:3000 -# -# Using MySQL instead of PostgreSQL: -# 1. Comment out the postgres service and SQL_DSN line 15 -# 2. Uncomment the mysql service and SQL_DSN line 16 -# 3. Uncomment mysql in depends_on (line 28) -# 4. Uncomment mysql_data in volumes section (line 64) -# -# ⚠️ IMPORTANT: Change all default passwords before deploying to production! +# Dokploy Docker Compose Configuration +# Environment variables should be configured in Dokploy UI -version: '3.4' # For compatibility with older Docker versions +version: '3.4' services: new-api: - image: calciumion/new-api:latest + image: jeuneastre/new-api:test container_name: new-api restart: always - command: --log-dir /app/logs ports: - - "3000:3000" + - "31000:3000" + command: --log-dir /app/logs volumes: - ./data:/data - ./logs:/app/logs - environment: - - SQL_DSN=postgresql://root:123456@postgres:5432/new-api # ⚠️ IMPORTANT: Change the password in production! -# - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service, uncomment if using MySQL - - REDIS_CONN_STRING=redis://redis - - TZ=Asia/Shanghai - - ERROR_LOG_ENABLED=true # 是否启用错误日志记录 (Whether to enable error log recording) - - BATCH_UPDATE_ENABLED=true # 是否启用批量更新 (Whether to enable batch update) -# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 (Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions) -# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! (multi-node deployment, set this to a random string!!!!!!!) -# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed -# - GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX # Google Analytics 的测量 ID (Google Analytics Measurement ID) -# - UMAMI_WEBSITE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # Umami 网站 ID (Umami Website ID) -# - UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js # Umami 脚本 URL,默认为官方地址 (Umami Script URL, defaults to official URL) - depends_on: - - redis - - postgres -# - mysql # Uncomment if using MySQL + - hydra healthcheck: - test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"] + test: ["CMD-SHELL", "wget -q -O - http://localhost:31000/api/status | grep -o '\"success\":\\s*true' || exit 1"] interval: 30s timeout: 10s retries: 3 + env_file: + - .env - redis: - image: redis:latest - container_name: redis - restart: always - - postgres: - image: postgres:15 - container_name: postgres + hydra: + image: oryd/hydra:v2.2.0 + container_name: hydra restart: always - environment: - POSTGRES_USER: root - POSTGRES_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production! - POSTGRES_DB: new-api - volumes: - - pg_data:/var/lib/postgresql/data -# ports: -# - "5432:5432" # Uncomment if you need to access PostgreSQL from outside Docker - -# mysql: -# image: mysql:8.2 -# container_name: mysql -# restart: always -# environment: -# MYSQL_ROOT_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production! -# MYSQL_DATABASE: new-api -# volumes: -# - mysql_data:/var/lib/mysql -# ports: -# - "3306:3306" # Uncomment if you need to access MySQL from outside Docker - -volumes: - pg_data: -# mysql_data: + ports: + - "31001:4444" + entrypoint: ["sh", "-c", "hydra migrate sql -e --yes && hydra serve all --dev"] + env_file: + - .env diff --git a/go.mod b/go.mod index 60296d0302d1..577a39638b55 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( github.com/jinzhu/copier v0.4.0 github.com/joho/godotenv v1.5.1 github.com/mewkiz/flac v1.0.13 + github.com/ory/hydra-client-go/v2 v2.2.1 github.com/pkg/errors v0.9.1 github.com/pquerna/otp v1.5.0 github.com/samber/lo v1.39.0 @@ -111,6 +112,7 @@ require ( github.com/yusufpapurcu/wmi v1.2.3 // indirect golang.org/x/arch v0.21.0 // indirect golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect google.golang.org/protobuf v1.34.2 // indirect diff --git a/go.sum b/go.sum index e87d20dc1114..f25ef5c14f11 100644 --- a/go.sum +++ b/go.sum @@ -201,6 +201,8 @@ github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw= github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0= +github.com/ory/hydra-client-go/v2 v2.2.1 h1:m1821pIX6ybG/3oSAn2wtrbBKNwe9q5A8fLljYuLpBk= +github.com/ory/hydra-client-go/v2 v2.2.1/go.mod h1:K83R+iK40+5uF2uQ34yRUrf9izRvFsza9pG2Se5qMmk= github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo= github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtosvtEhg= github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= @@ -290,6 +292,8 @@ golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/middleware/distributor.go b/middleware/distributor.go index 5a9deb23cde8..b07ba1d8a300 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -157,7 +157,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { } midjourneyModel, mjErr, success := service.GetMjRequestModel(relayMode, &midjourneyRequest) if mjErr != nil { - return nil, false, fmt.Errorf(mjErr.Description) + return nil, false, errors.New(mjErr.Description) } if midjourneyModel == "" { if !success { diff --git a/middleware/oauth.go b/middleware/oauth.go new file mode 100644 index 000000000000..faf60832b7b2 --- /dev/null +++ b/middleware/oauth.go @@ -0,0 +1,111 @@ +package middleware + +import ( + "net/http" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/service/hydra" + "github.com/gin-gonic/gin" +) + +// OAuthTokenAuth validates OAuth Bearer Token +func OAuthTokenAuth(hydraProvider hydra.Provider) gin.HandlerFunc { + return func(c *gin.Context) { + // Extract Bearer Token + token := extractBearerToken(c) + if token == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "success": false, + "error": "missing bearer token", + }) + return + } + + result, err := hydraProvider.IntrospectToken(c.Request.Context(), token, "") + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "success": false, + "error": "token introspection failed", + }) + return + } + + // Check if token is active + if !result.GetActive() { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "success": false, + "error": "invalid or expired token", + }) + return + } + + // Extract user ID from subject + subject := result.GetSub() + userId, err := strconv.Atoi(subject) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "success": false, + "error": "invalid subject in token", + }) + return + } + + // Set context values + c.Set("id", userId) + c.Set("auth_method", "oauth") + c.Set("oauth_client_id", result.GetClientId()) + c.Set("oauth_scope", result.GetScope()) + + c.Next() + } +} + +// extractBearerToken extracts Bearer token from Authorization header +func extractBearerToken(c *gin.Context) string { + auth := c.GetHeader("Authorization") + if token, found := strings.CutPrefix(auth, "Bearer "); found { + return token + } + return "" +} + +// RequireScope checks if the OAuth token has all required scopes +func RequireScope(requiredScopes ...string) gin.HandlerFunc { + return func(c *gin.Context) { + // No scopes required, pass through + if len(requiredScopes) == 0 { + c.Next() + return + } + + // Get scope from context (set by OAuthTokenAuth middleware) + tokenScope := c.GetString("oauth_scope") + if tokenScope == "" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "success": false, + "error": "insufficient scope", + }) + return + } + + // Parse token scopes into a set + scopeSet := make(map[string]bool) + for s := range strings.SplitSeq(tokenScope, " ") { + scopeSet[s] = true + } + + // Check if all required scopes are present + for _, required := range requiredScopes { + if !scopeSet[required] { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "success": false, + "error": "insufficient scope: " + required + " required", + }) + return + } + } + + c.Next() + } +} diff --git a/middleware/oauth_test.go b/middleware/oauth_test.go new file mode 100644 index 000000000000..4abff9b5c180 --- /dev/null +++ b/middleware/oauth_test.go @@ -0,0 +1,246 @@ +package middleware + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/service/hydra" + "github.com/gin-gonic/gin" +) + +func setupOAuthTestRouter(mock *hydra.MockProvider) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(OAuthTokenAuth(mock)) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{ + "id": c.GetInt("id"), + "auth_method": c.GetString("auth_method"), + "oauth_client_id": c.GetString("oauth_client_id"), + "oauth_scope": c.GetString("oauth_scope"), + }) + }) + return r +} + +func TestOAuthTokenAuth_MissingToken(t *testing.T) { + mock := hydra.NewMockProvider() + router := setupOAuthTestRouter(mock) + + req, _ := http.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code) + } +} + +func TestOAuthTokenAuth_ValidToken(t *testing.T) { + mock := hydra.NewMockProvider() + mock.SetIntrospectedToken("valid-oauth-token", true, "123", "openid profile", "test-client") + router := setupOAuthTestRouter(mock) + + req, _ := http.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer valid-oauth-token") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + + if resp["auth_method"] != "oauth" { + t.Errorf("Expected auth_method 'oauth', got %v", resp["auth_method"]) + } + if resp["oauth_client_id"] != "test-client" { + t.Errorf("Expected oauth_client_id 'test-client', got %v", resp["oauth_client_id"]) + } +} + +func TestOAuthTokenAuth_InactiveToken(t *testing.T) { + mock := hydra.NewMockProvider() + mock.SetIntrospectedToken("expired-token", false, "", "", "") + router := setupOAuthTestRouter(mock) + + req, _ := http.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer expired-token") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code) + } +} + +func TestOAuthTokenAuth_UnknownToken(t *testing.T) { + mock := hydra.NewMockProvider() + router := setupOAuthTestRouter(mock) + + req, _ := http.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer unknown-token") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code) + } +} + +func TestOAuthTokenAuth_HydraError(t *testing.T) { + mock := hydra.NewMockProvider() + mock.IntrospectTokenErr = http.ErrAbortHandler + router := setupOAuthTestRouter(mock) + + req, _ := http.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer any-token") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code) + } +} + +func TestOAuthTokenAuth_ExtractScopes(t *testing.T) { + mock := hydra.NewMockProvider() + mock.SetIntrospectedToken("scoped-token", true, "456", "openid balance:read tokens:write", "third-party-app") + router := setupOAuthTestRouter(mock) + + req, _ := http.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer scoped-token") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + + if resp["oauth_scope"] != "openid balance:read tokens:write" { + t.Errorf("Expected full scope, got %v", resp["oauth_scope"]) + } +} + +// ===================== +// RequireScope Middleware Tests +// ===================== + +func setupScopeTestRouter(mock *hydra.MockProvider, requiredScopes ...string) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(OAuthTokenAuth(mock)) + r.Use(RequireScope(requiredScopes...)) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"success": true}) + }) + return r +} + +func TestRequireScope_HasRequiredScope(t *testing.T) { + mock := hydra.NewMockProvider() + mock.SetIntrospectedToken("token", true, "123", "openid balance:read", "client") + router := setupScopeTestRouter(mock, "balance:read") + + req, _ := http.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer token") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } +} + +func TestRequireScope_MissingRequiredScope(t *testing.T) { + mock := hydra.NewMockProvider() + mock.SetIntrospectedToken("token", true, "123", "openid profile", "client") + router := setupScopeTestRouter(mock, "balance:read") + + req, _ := http.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer token") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("Expected status %d, got %d", http.StatusForbidden, w.Code) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + + if resp["success"] != false { + t.Errorf("Expected success=false, got %v", resp["success"]) + } +} + +func TestRequireScope_MultipleScopes_AllPresent(t *testing.T) { + mock := hydra.NewMockProvider() + mock.SetIntrospectedToken("token", true, "123", "openid balance:read tokens:write", "client") + router := setupScopeTestRouter(mock, "balance:read", "tokens:write") + + req, _ := http.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer token") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } +} + +func TestRequireScope_MultipleScopes_OneMissing(t *testing.T) { + mock := hydra.NewMockProvider() + mock.SetIntrospectedToken("token", true, "123", "openid balance:read", "client") + router := setupScopeTestRouter(mock, "balance:read", "tokens:write") + + req, _ := http.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer token") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("Expected status %d, got %d", http.StatusForbidden, w.Code) + } +} + +func TestRequireScope_NoScopeInContext(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + // No OAuthTokenAuth middleware, so no scope in context + r.Use(RequireScope("balance:read")) + r.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"success": true}) + }) + + req, _ := http.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("Expected status %d, got %d", http.StatusForbidden, w.Code) + } +} + +func TestRequireScope_EmptyRequirement(t *testing.T) { + mock := hydra.NewMockProvider() + mock.SetIntrospectedToken("token", true, "123", "openid", "client") + router := setupScopeTestRouter(mock) // No scopes required + + req, _ := http.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer token") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Should pass when no scopes required + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 000000000000..748a0bc520c8 --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +golangci-lint = "latest" diff --git a/model/main.go b/model/main.go index 04842f13f5bc..d38675a195df 100644 --- a/model/main.go +++ b/model/main.go @@ -267,6 +267,7 @@ func migrateDB() error { &Setup{}, &TwoFA{}, &TwoFABackupCode{}, + &OAuthClient{}, ) if err != nil { return err @@ -300,6 +301,7 @@ func migrateDBFast() error { {&Setup{}, "Setup"}, {&TwoFA{}, "TwoFA"}, {&TwoFABackupCode{}, "TwoFABackupCode"}, + {&OAuthClient{}, "OAuthClient"}, } // 动态计算migration数量,确保errChan缓冲区足够大 errChan := make(chan error, len(migrations)) diff --git a/model/oauth_client.go b/model/oauth_client.go new file mode 100644 index 000000000000..4c1f1b61b89a --- /dev/null +++ b/model/oauth_client.go @@ -0,0 +1,104 @@ +package model + +import ( + "errors" + + "gorm.io/gorm" +) + +// OAuthClientType represents the type of OAuth client +type OAuthClientType string + +const ( + OAuthClientTypePublic OAuthClientType = "public" + OAuthClientTypeConfidential OAuthClientType = "confidential" +) + +// OAuthClient stores OAuth client ownership and metadata +// This allows tracking which user created which client and what scopes are allowed +type OAuthClient struct { + Id int `json:"id" gorm:"primaryKey"` + HydraClientID string `json:"hydra_client_id" gorm:"type:varchar(255);uniqueIndex;not null"` // client_id in Hydra + UserID int `json:"user_id" gorm:"index;not null"` // creator user ID + ClientName string `json:"client_name" gorm:"type:varchar(255)"` + ClientType OAuthClientType `json:"client_type" gorm:"type:varchar(50);default:'confidential'"` + AllowedScopes string `json:"allowed_scopes" gorm:"type:text"` // comma-separated allowed scopes + RedirectURIs string `json:"redirect_uris" gorm:"type:text"` // comma-separated redirect URIs + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` +} + +func (OAuthClient) TableName() string { + return "oauth_clients" +} + +// CreateOAuthClient creates a new OAuth client record +func CreateOAuthClient(client *OAuthClient) error { + return DB.Create(client).Error +} + +// GetOAuthClientByHydraID retrieves an OAuth client by its Hydra client ID +func GetOAuthClientByHydraID(hydraClientID string) (*OAuthClient, error) { + var client OAuthClient + err := DB.Where("hydra_client_id = ?", hydraClientID).First(&client).Error + if err != nil { + return nil, err + } + return &client, nil +} + +// GetOAuthClientsByUserID retrieves all OAuth clients created by a user +func GetOAuthClientsByUserID(userID int) ([]*OAuthClient, error) { + var clients []*OAuthClient + err := DB.Where("user_id = ?", userID).Order("id desc").Find(&clients).Error + return clients, err +} + +// GetAllOAuthClients retrieves all OAuth clients (admin use) +func GetAllOAuthClients(startIdx, num int) ([]*OAuthClient, error) { + var clients []*OAuthClient + err := DB.Order("id desc").Limit(num).Offset(startIdx).Find(&clients).Error + return clients, err +} + +// DeleteOAuthClientByHydraID deletes an OAuth client by its Hydra client ID +func DeleteOAuthClientByHydraID(hydraClientID string) error { + result := DB.Where("hydra_client_id = ?", hydraClientID).Delete(&OAuthClient{}) + if result.RowsAffected == 0 { + return errors.New("client not found") + } + return result.Error +} + +// DeleteOAuthClientByHydraIDAndUserID deletes an OAuth client only if it belongs to the user +func DeleteOAuthClientByHydraIDAndUserID(hydraClientID string, userID int) error { + result := DB.Where("hydra_client_id = ? AND user_id = ?", hydraClientID, userID).Delete(&OAuthClient{}) + if result.RowsAffected == 0 { + return errors.New("client not found or not owned by user") + } + return result.Error +} + +// IsOAuthClientOwner checks if a user owns an OAuth client +func IsOAuthClientOwner(hydraClientID string, userID int) (bool, error) { + var count int64 + err := DB.Model(&OAuthClient{}).Where("hydra_client_id = ? AND user_id = ?", hydraClientID, userID).Count(&count).Error + if err != nil { + return false, err + } + return count > 0, nil +} + +// UpdateOAuthClientByHydraID updates an OAuth client by its Hydra client ID +func UpdateOAuthClientByHydraID(hydraClientID, clientName, allowedScopes, redirectURIs string) error { + result := DB.Model(&OAuthClient{}).Where("hydra_client_id = ?", hydraClientID).Updates(map[string]interface{}{ + "client_name": clientName, + "allowed_scopes": allowedScopes, + "redirect_uris": redirectURIs, + }) + if result.RowsAffected == 0 { + return errors.New("client not found") + } + return result.Error +} diff --git a/model/option.go b/model/option.go index e9fd50d7f357..d66b68a105cc 100644 --- a/model/option.go +++ b/model/option.go @@ -448,6 +448,30 @@ func updateOptionMap(key string, value string) (err error) { setting.StreamCacheQueueLength, _ = strconv.Atoi(value) case "PayMethods": err = operation_setting.UpdatePayMethodsByJsonString(value) + case "HydraEnabled": + common.HydraEnabled = value == "true" + case "HydraAdminURL": + common.HydraAdminURL = value + case "HydraTrustedClients": + if value == "" { + common.HydraTrustedClients = []string{} + } else { + clients := strings.Split(value, ",") + common.HydraTrustedClients = make([]string, 0, len(clients)) + for _, c := range clients { + if trimmed := strings.TrimSpace(c); trimmed != "" { + common.HydraTrustedClients = append(common.HydraTrustedClients, trimmed) + } + } + } + case "HydraLoginRememberFor": + if v, e := strconv.ParseInt(value, 10, 64); e == nil { + common.HydraLoginRememberFor = v + } + case "HydraConsentRememberFor": + if v, e := strconv.ParseInt(value, 10, 64); e == nil { + common.HydraConsentRememberFor = v + } } return err } diff --git a/model/twofa.go b/model/twofa.go index e63c66629d7a..b130e0ef0237 100644 --- a/model/twofa.go +++ b/model/twofa.go @@ -296,6 +296,15 @@ func (t *TwoFA) ValidateBackupCodeAndUpdateUsage(code string) (bool, error) { return true, nil } +// UseBackupCode validates and uses a backup code, returning only the validity status +func UseBackupCode(userId int, code string) bool { + valid, err := ValidateBackupCode(userId, code) + if err != nil { + return false + } + return valid +} + // GetTwoFAStats 获取2FA统计信息(管理员使用) func GetTwoFAStats() (map[string]interface{}, error) { var totalUsers, enabledUsers int64 diff --git a/router/main.go b/router/main.go index 45b3080f281f..36980aaee5cb 100644 --- a/router/main.go +++ b/router/main.go @@ -17,6 +17,8 @@ func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) { SetDashboardRouter(router) SetRelayRouter(router) SetVideoRouter(router) + SetOAuthProviderRouter(router) + SetOAuthAPIRouter(router) frontendBaseUrl := os.Getenv("FRONTEND_BASE_URL") if common.IsMasterNode && frontendBaseUrl != "" { frontendBaseUrl = "" diff --git a/router/oauth-api.go b/router/oauth-api.go new file mode 100644 index 000000000000..5e072b83a119 --- /dev/null +++ b/router/oauth-api.go @@ -0,0 +1,41 @@ +package router + +import ( + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/controller" + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/service/hydra" + + "github.com/gin-gonic/gin" +) + +// SetOAuthAPIRouter sets up OAuth API routes for third-party applications +// These routes allow OAuth clients to access new-api resources using OAuth tokens +func SetOAuthAPIRouter(router *gin.Engine) { + if !common.HydraEnabled { + return + } + + // Initialize Hydra service for token introspection + hydraService := hydra.NewService(common.HydraAdminURL) + + // OAuth API routes (for third-party applications) + oauthAPI := router.Group("/api/v1/oauth") + oauthAPI.Use(middleware.GlobalAPIRateLimit()) + oauthAPI.Use(middleware.OAuthTokenAuth(hydraService)) + { + // User information (scope: openid or profile) + oauthAPI.GET("/userinfo", middleware.RequireScope("openid"), controller.OAuthGetUserInfo) + + // Balance (scope: balance:read) + oauthAPI.GET("/balance", middleware.RequireScope("balance:read"), controller.OAuthGetBalance) + + // Usage (scope: usage:read) + oauthAPI.GET("/usage", middleware.RequireScope("usage:read"), controller.OAuthGetUsage) + + // Token management + oauthAPI.GET("/tokens", middleware.RequireScope("tokens:read"), controller.OAuthListTokens) + oauthAPI.POST("/tokens", middleware.RequireScope("tokens:write"), controller.OAuthCreateToken) + oauthAPI.DELETE("/tokens/:id", middleware.RequireScope("tokens:write"), controller.OAuthDeleteToken) + } +} diff --git a/router/oauth-provider.go b/router/oauth-provider.go new file mode 100644 index 000000000000..ac7c9e327913 --- /dev/null +++ b/router/oauth-provider.go @@ -0,0 +1,58 @@ +package router + +import ( + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/controller" + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/service/hydra" + + "github.com/gin-gonic/gin" +) + +// SetOAuthProviderRouter sets up OAuth provider routes for Hydra login/consent/logout flows +// These routes are used when new-api acts as a Login/Consent Provider for Ory Hydra +func SetOAuthProviderRouter(router *gin.Engine) { + if !common.HydraEnabled { + return + } + + // Initialize Hydra service + hydraService := hydra.NewService(common.HydraAdminURL) + ctrl := controller.NewOAuthProviderController(hydraService) + + // OAuth provider API routes (for frontend to fetch data) + oauthAPI := router.Group("/api/oauth") + oauthAPI.Use(middleware.GlobalAPIRateLimit()) + { + // Login flow + // GET /api/oauth/login - Get login request info + oauthAPI.GET("/login", ctrl.OAuthLogin) + // POST /api/oauth/login - User submits login credentials + oauthAPI.POST("/login", middleware.CriticalRateLimit(), ctrl.OAuthLoginSubmit) + // POST /api/oauth/login/2fa - User submits 2FA code during OAuth login + oauthAPI.POST("/login/2fa", middleware.CriticalRateLimit(), ctrl.OAuthLogin2FA) + + // Consent flow + // GET /api/oauth/consent - Get consent request info + oauthAPI.GET("/consent", ctrl.OAuthConsent) + // POST /api/oauth/consent - User grants consent with selected scopes + oauthAPI.POST("/consent", ctrl.OAuthConsentSubmit) + // POST /api/oauth/consent/reject - User rejects consent + oauthAPI.POST("/consent/reject", ctrl.OAuthConsentReject) + + // Logout flow + // GET /api/oauth/logout - Handle logout + oauthAPI.GET("/logout", ctrl.OAuthLogout) + } + + // Admin client management routes (requires admin auth) + adminClients := router.Group("/api/oauth/admin/clients") + adminClients.Use(middleware.GlobalAPIRateLimit()) + adminClients.Use(middleware.AdminAuth()) + { + adminClients.GET("", ctrl.OAuthListClients) + adminClients.POST("", ctrl.OAuthRegisterClient) + adminClients.PUT("/:id", ctrl.OAuthUpdateClient) + adminClients.DELETE("/:id", ctrl.OAuthDeleteClient) + } +} diff --git a/service/hydra/interface.go b/service/hydra/interface.go new file mode 100644 index 000000000000..c9e47022116a --- /dev/null +++ b/service/hydra/interface.go @@ -0,0 +1,42 @@ +package hydra + +import ( + "context" + + client "github.com/ory/hydra-client-go/v2" +) + +// Provider defines the interface for Hydra operations +// This allows for easy mocking in tests +type Provider interface { + // Login flow + GetLoginRequest(ctx context.Context, challenge string) (*client.OAuth2LoginRequest, error) + AcceptLogin(ctx context.Context, challenge string, subject string, remember bool, rememberFor int64) (*client.OAuth2RedirectTo, error) + RejectLogin(ctx context.Context, challenge string, errorID string, errorDescription string) (*client.OAuth2RedirectTo, error) + + // Consent flow + GetConsentRequest(ctx context.Context, challenge string) (*client.OAuth2ConsentRequest, error) + AcceptConsent(ctx context.Context, challenge string, grantScope []string, remember bool, rememberFor int64, session *client.AcceptOAuth2ConsentRequestSession) (*client.OAuth2RedirectTo, error) + RejectConsent(ctx context.Context, challenge string, errorID string, errorDescription string) (*client.OAuth2RedirectTo, error) + + // Logout flow + GetLogoutRequest(ctx context.Context, challenge string) (*client.OAuth2LogoutRequest, error) + AcceptLogout(ctx context.Context, challenge string) (*client.OAuth2RedirectTo, error) + RejectLogout(ctx context.Context, challenge string) error + + // Token Introspection + IntrospectToken(ctx context.Context, token string, scope string) (*client.IntrospectedOAuth2Token, error) + + // Client Management (Admin) + CreateOAuth2Client(ctx context.Context, clientID, clientSecret, clientName string, + grantTypes, responseTypes, redirectURIs []string, + scope, tokenEndpointAuthMethod string) (*client.OAuth2Client, error) + UpdateOAuth2Client(ctx context.Context, clientID, clientName string, + grantTypes, responseTypes, redirectURIs []string, + scope, tokenEndpointAuthMethod string) (*client.OAuth2Client, error) + ListOAuth2Clients(ctx context.Context) ([]client.OAuth2Client, error) + DeleteOAuth2Client(ctx context.Context, clientID string) error +} + +// Ensure Service implements Provider +var _ Provider = (*Service)(nil) diff --git a/service/hydra/introspect_test.go b/service/hydra/introspect_test.go new file mode 100644 index 000000000000..62cb9903ed57 --- /dev/null +++ b/service/hydra/introspect_test.go @@ -0,0 +1,98 @@ +package hydra + +import ( + "context" + "errors" + "testing" +) + +func TestMockProvider_IntrospectToken_Active(t *testing.T) { + ctx := context.Background() + mock := NewMockProvider() + + // Setup: token is active + mock.SetIntrospectedToken("valid-token", true, "user-123", "openid profile", "test-client") + + result, err := mock.IntrospectToken(ctx, "valid-token", "") + if err != nil { + t.Fatalf("IntrospectToken failed: %v", err) + } + + if !result.GetActive() { + t.Error("Expected token to be active") + } + if result.GetSub() != "user-123" { + t.Errorf("Expected sub 'user-123', got '%s'", result.GetSub()) + } + if result.GetScope() != "openid profile" { + t.Errorf("Expected scope 'openid profile', got '%s'", result.GetScope()) + } + if result.GetClientId() != "test-client" { + t.Errorf("Expected client_id 'test-client', got '%s'", result.GetClientId()) + } +} + +func TestMockProvider_IntrospectToken_Inactive(t *testing.T) { + ctx := context.Background() + mock := NewMockProvider() + + // Setup: token is inactive (expired/revoked) + mock.SetIntrospectedToken("expired-token", false, "", "", "") + + result, err := mock.IntrospectToken(ctx, "expired-token", "") + if err != nil { + t.Fatalf("IntrospectToken failed: %v", err) + } + + if result.GetActive() { + t.Error("Expected token to be inactive") + } +} + +func TestMockProvider_IntrospectToken_NotFound(t *testing.T) { + ctx := context.Background() + mock := NewMockProvider() + + // Don't setup any token - should return inactive + result, err := mock.IntrospectToken(ctx, "unknown-token", "") + if err != nil { + t.Fatalf("IntrospectToken failed: %v", err) + } + + if result.GetActive() { + t.Error("Expected unknown token to be inactive") + } +} + +func TestMockProvider_IntrospectToken_Error(t *testing.T) { + ctx := context.Background() + mock := NewMockProvider() + + // Inject error + mock.IntrospectTokenErr = errors.New("hydra unavailable") + + _, err := mock.IntrospectToken(ctx, "any-token", "") + if err == nil { + t.Error("Expected error") + } +} + +func TestMockProvider_IntrospectToken_WithScope(t *testing.T) { + ctx := context.Background() + mock := NewMockProvider() + + mock.SetIntrospectedToken("scoped-token", true, "user-456", "openid balance:read tokens:write", "third-party-app") + + result, err := mock.IntrospectToken(ctx, "scoped-token", "balance:read") + if err != nil { + t.Fatalf("IntrospectToken failed: %v", err) + } + + if !result.GetActive() { + t.Error("Expected token to be active") + } + // Scope should contain the requested scope + if result.GetScope() != "openid balance:read tokens:write" { + t.Errorf("Expected full scope, got '%s'", result.GetScope()) + } +} diff --git a/service/hydra/mock.go b/service/hydra/mock.go new file mode 100644 index 000000000000..075ba9a15da4 --- /dev/null +++ b/service/hydra/mock.go @@ -0,0 +1,408 @@ +package hydra + +import ( + "context" + "fmt" + "sync" + + client "github.com/ory/hydra-client-go/v2" +) + +// MockProvider is a mock implementation of Provider for testing +type MockProvider struct { + mu sync.RWMutex + + // Storage for mock data + LoginRequests map[string]*client.OAuth2LoginRequest + ConsentRequests map[string]*client.OAuth2ConsentRequest + LogoutRequests map[string]*client.OAuth2LogoutRequest + IntrospectedTokens map[string]*client.IntrospectedOAuth2Token + OAuth2Clients map[string]*client.OAuth2Client // client_id -> client + + // Track accepted/rejected + AcceptedLogins map[string]string // challenge -> subject + AcceptedConsents map[string][]string // challenge -> granted scopes + AcceptedLogouts map[string]bool + RejectedLogins map[string]string // challenge -> error + RejectedConsents map[string]string + RejectedLogouts map[string]bool + + // Error injection + GetLoginRequestErr error + AcceptLoginErr error + RejectLoginErr error + GetConsentRequestErr error + AcceptConsentErr error + RejectConsentErr error + GetLogoutRequestErr error + AcceptLogoutErr error + RejectLogoutErr error + IntrospectTokenErr error + CreateOAuth2ClientErr error + UpdateOAuth2ClientErr error + ListOAuth2ClientsErr error + DeleteOAuth2ClientErr error + + // Default redirect URL + RedirectURL string +} + +// NewMockProvider creates a new mock provider +func NewMockProvider() *MockProvider { + return &MockProvider{ + LoginRequests: make(map[string]*client.OAuth2LoginRequest), + ConsentRequests: make(map[string]*client.OAuth2ConsentRequest), + LogoutRequests: make(map[string]*client.OAuth2LogoutRequest), + IntrospectedTokens: make(map[string]*client.IntrospectedOAuth2Token), + OAuth2Clients: make(map[string]*client.OAuth2Client), + AcceptedLogins: make(map[string]string), + AcceptedConsents: make(map[string][]string), + AcceptedLogouts: make(map[string]bool), + RejectedLogins: make(map[string]string), + RejectedConsents: make(map[string]string), + RejectedLogouts: make(map[string]bool), + RedirectURL: "https://example.com/callback", + } +} + +// SetLoginRequest sets a mock login request for testing +func (m *MockProvider) SetLoginRequest(challenge string, clientID, clientName string, requestedScope []string, skip bool, subject string) { + m.mu.Lock() + defer m.mu.Unlock() + + oauthClient := client.NewOAuth2Client() + oauthClient.SetClientId(clientID) + oauthClient.SetClientName(clientName) + + req := client.NewOAuth2LoginRequest(challenge, *oauthClient, "https://hydra/oauth2/auth", skip, subject) + req.SetRequestedScope(requestedScope) + m.LoginRequests[challenge] = req +} + +// SetConsentRequest sets a mock consent request for testing +func (m *MockProvider) SetConsentRequest(challenge string, clientID, clientName, subject string, requestedScope []string, skip bool) { + m.mu.Lock() + defer m.mu.Unlock() + + req := client.NewOAuth2ConsentRequest(challenge) + req.SetClient(*client.NewOAuth2Client()) + req.Client.SetClientId(clientID) + req.Client.SetClientName(clientName) + req.SetSubject(subject) + req.SetRequestedScope(requestedScope) + req.SetSkip(skip) + m.ConsentRequests[challenge] = req +} + +// SetLogoutRequest sets a mock logout request for testing +func (m *MockProvider) SetLogoutRequest(challenge string, subject, sessionID string) { + m.mu.Lock() + defer m.mu.Unlock() + + req := client.NewOAuth2LogoutRequest() + req.SetChallenge(challenge) + req.SetSubject(subject) + req.SetSid(sessionID) + m.LogoutRequests[challenge] = req +} + +// GetLoginRequest implements Provider +func (m *MockProvider) GetLoginRequest(ctx context.Context, challenge string) (*client.OAuth2LoginRequest, error) { + if m.GetLoginRequestErr != nil { + return nil, m.GetLoginRequestErr + } + + m.mu.RLock() + defer m.mu.RUnlock() + + req, ok := m.LoginRequests[challenge] + if !ok { + return nil, fmt.Errorf("login request not found: %s", challenge) + } + return req, nil +} + +// AcceptLogin implements Provider +func (m *MockProvider) AcceptLogin(ctx context.Context, challenge string, subject string, remember bool, rememberFor int64) (*client.OAuth2RedirectTo, error) { + if m.AcceptLoginErr != nil { + return nil, m.AcceptLoginErr + } + + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.LoginRequests[challenge]; !ok { + return nil, fmt.Errorf("login request not found: %s", challenge) + } + + m.AcceptedLogins[challenge] = subject + return &client.OAuth2RedirectTo{RedirectTo: m.RedirectURL}, nil +} + +// RejectLogin implements Provider +func (m *MockProvider) RejectLogin(ctx context.Context, challenge string, errorID string, errorDescription string) (*client.OAuth2RedirectTo, error) { + if m.RejectLoginErr != nil { + return nil, m.RejectLoginErr + } + + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.LoginRequests[challenge]; !ok { + return nil, fmt.Errorf("login request not found: %s", challenge) + } + + m.RejectedLogins[challenge] = errorID + return &client.OAuth2RedirectTo{RedirectTo: m.RedirectURL}, nil +} + +// GetConsentRequest implements Provider +func (m *MockProvider) GetConsentRequest(ctx context.Context, challenge string) (*client.OAuth2ConsentRequest, error) { + if m.GetConsentRequestErr != nil { + return nil, m.GetConsentRequestErr + } + + m.mu.RLock() + defer m.mu.RUnlock() + + req, ok := m.ConsentRequests[challenge] + if !ok { + return nil, fmt.Errorf("consent request not found: %s", challenge) + } + return req, nil +} + +// AcceptConsent implements Provider +func (m *MockProvider) AcceptConsent(ctx context.Context, challenge string, grantScope []string, remember bool, rememberFor int64, session *client.AcceptOAuth2ConsentRequestSession) (*client.OAuth2RedirectTo, error) { + if m.AcceptConsentErr != nil { + return nil, m.AcceptConsentErr + } + + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.ConsentRequests[challenge]; !ok { + return nil, fmt.Errorf("consent request not found: %s", challenge) + } + + m.AcceptedConsents[challenge] = grantScope + return &client.OAuth2RedirectTo{RedirectTo: m.RedirectURL}, nil +} + +// RejectConsent implements Provider +func (m *MockProvider) RejectConsent(ctx context.Context, challenge string, errorID string, errorDescription string) (*client.OAuth2RedirectTo, error) { + if m.RejectConsentErr != nil { + return nil, m.RejectConsentErr + } + + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.ConsentRequests[challenge]; !ok { + return nil, fmt.Errorf("consent request not found: %s", challenge) + } + + m.RejectedConsents[challenge] = errorID + return &client.OAuth2RedirectTo{RedirectTo: m.RedirectURL}, nil +} + +// GetLogoutRequest implements Provider +func (m *MockProvider) GetLogoutRequest(ctx context.Context, challenge string) (*client.OAuth2LogoutRequest, error) { + if m.GetLogoutRequestErr != nil { + return nil, m.GetLogoutRequestErr + } + + m.mu.RLock() + defer m.mu.RUnlock() + + req, ok := m.LogoutRequests[challenge] + if !ok { + return nil, fmt.Errorf("logout request not found: %s", challenge) + } + return req, nil +} + +// AcceptLogout implements Provider +func (m *MockProvider) AcceptLogout(ctx context.Context, challenge string) (*client.OAuth2RedirectTo, error) { + if m.AcceptLogoutErr != nil { + return nil, m.AcceptLogoutErr + } + + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.LogoutRequests[challenge]; !ok { + return nil, fmt.Errorf("logout request not found: %s", challenge) + } + + m.AcceptedLogouts[challenge] = true + return &client.OAuth2RedirectTo{RedirectTo: m.RedirectURL}, nil +} + +// RejectLogout implements Provider +func (m *MockProvider) RejectLogout(ctx context.Context, challenge string) error { + if m.RejectLogoutErr != nil { + return m.RejectLogoutErr + } + + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.LogoutRequests[challenge]; !ok { + return fmt.Errorf("logout request not found: %s", challenge) + } + + m.RejectedLogouts[challenge] = true + return nil +} + +// SetIntrospectedToken sets a mock introspection result for testing +func (m *MockProvider) SetIntrospectedToken(token string, active bool, subject string, scope string, clientID string) { + m.mu.Lock() + defer m.mu.Unlock() + + result := client.NewIntrospectedOAuth2Token(active) + if subject != "" { + result.SetSub(subject) + } + if scope != "" { + result.SetScope(scope) + } + if clientID != "" { + result.SetClientId(clientID) + } + m.IntrospectedTokens[token] = result +} + +// IntrospectToken implements Provider +func (m *MockProvider) IntrospectToken(ctx context.Context, token string, scope string) (*client.IntrospectedOAuth2Token, error) { + if m.IntrospectTokenErr != nil { + return nil, m.IntrospectTokenErr + } + + m.mu.RLock() + defer m.mu.RUnlock() + + result, ok := m.IntrospectedTokens[token] + if !ok { + // Unknown token returns inactive + return client.NewIntrospectedOAuth2Token(false), nil + } + return result, nil +} + +// CreateOAuth2Client creates a mock OAuth2 client +func (m *MockProvider) CreateOAuth2Client(ctx context.Context, clientID, clientSecret, clientName string, grantTypes, responseTypes, redirectURIs []string, scope, tokenEndpointAuthMethod string) (*client.OAuth2Client, error) { + if m.CreateOAuth2ClientErr != nil { + return nil, m.CreateOAuth2ClientErr + } + + m.mu.Lock() + defer m.mu.Unlock() + + c := client.NewOAuth2Client() + c.SetClientId(clientID) + c.SetClientSecret(clientSecret) + c.SetClientName(clientName) + c.SetGrantTypes(grantTypes) + c.SetResponseTypes(responseTypes) + c.SetRedirectUris(redirectURIs) + c.SetScope(scope) + c.SetTokenEndpointAuthMethod(tokenEndpointAuthMethod) + + m.OAuth2Clients[clientID] = c + return c, nil +} + +// UpdateOAuth2Client updates a mock OAuth2 client +func (m *MockProvider) UpdateOAuth2Client(ctx context.Context, clientID, clientName string, grantTypes, responseTypes, redirectURIs []string, scope, tokenEndpointAuthMethod string) (*client.OAuth2Client, error) { + if m.UpdateOAuth2ClientErr != nil { + return nil, m.UpdateOAuth2ClientErr + } + + m.mu.Lock() + defer m.mu.Unlock() + + c, exists := m.OAuth2Clients[clientID] + if !exists { + return nil, fmt.Errorf("client not found: %s", clientID) + } + + c.SetClientName(clientName) + c.SetGrantTypes(grantTypes) + c.SetResponseTypes(responseTypes) + c.SetRedirectUris(redirectURIs) + c.SetScope(scope) + c.SetTokenEndpointAuthMethod(tokenEndpointAuthMethod) + + m.OAuth2Clients[clientID] = c + return c, nil +} + +// ListOAuth2Clients lists all mock OAuth2 clients +func (m *MockProvider) ListOAuth2Clients(ctx context.Context) ([]client.OAuth2Client, error) { + if m.ListOAuth2ClientsErr != nil { + return nil, m.ListOAuth2ClientsErr + } + + m.mu.RLock() + defer m.mu.RUnlock() + + clients := make([]client.OAuth2Client, 0, len(m.OAuth2Clients)) + for _, c := range m.OAuth2Clients { + clients = append(clients, *c) + } + return clients, nil +} + +// DeleteOAuth2Client deletes a mock OAuth2 client +func (m *MockProvider) DeleteOAuth2Client(ctx context.Context, clientID string) error { + if m.DeleteOAuth2ClientErr != nil { + return m.DeleteOAuth2ClientErr + } + + m.mu.Lock() + defer m.mu.Unlock() + + if _, exists := m.OAuth2Clients[clientID]; !exists { + return fmt.Errorf("client not found: %s", clientID) + } + delete(m.OAuth2Clients, clientID) + return nil +} + +// Reset clears all mock data +func (m *MockProvider) Reset() { + m.mu.Lock() + defer m.mu.Unlock() + + m.LoginRequests = make(map[string]*client.OAuth2LoginRequest) + m.ConsentRequests = make(map[string]*client.OAuth2ConsentRequest) + m.LogoutRequests = make(map[string]*client.OAuth2LogoutRequest) + m.IntrospectedTokens = make(map[string]*client.IntrospectedOAuth2Token) + m.OAuth2Clients = make(map[string]*client.OAuth2Client) + m.AcceptedLogins = make(map[string]string) + m.AcceptedConsents = make(map[string][]string) + m.AcceptedLogouts = make(map[string]bool) + m.RejectedLogins = make(map[string]string) + m.RejectedConsents = make(map[string]string) + m.RejectedLogouts = make(map[string]bool) + + m.GetLoginRequestErr = nil + m.AcceptLoginErr = nil + m.RejectLoginErr = nil + m.GetConsentRequestErr = nil + m.AcceptConsentErr = nil + m.RejectConsentErr = nil + m.GetLogoutRequestErr = nil + m.AcceptLogoutErr = nil + m.RejectLogoutErr = nil + m.IntrospectTokenErr = nil + m.CreateOAuth2ClientErr = nil + m.UpdateOAuth2ClientErr = nil + m.ListOAuth2ClientsErr = nil + m.DeleteOAuth2ClientErr = nil +} + +// Ensure MockProvider implements Provider +var _ Provider = (*MockProvider)(nil) diff --git a/service/hydra/mock_test.go b/service/hydra/mock_test.go new file mode 100644 index 000000000000..5635eda65703 --- /dev/null +++ b/service/hydra/mock_test.go @@ -0,0 +1,165 @@ +package hydra + +import ( + "context" + "fmt" + "testing" +) + +func TestMockProvider_LoginFlow(t *testing.T) { + ctx := context.Background() + mock := NewMockProvider() + + challenge := "login-challenge-123" + mock.SetLoginRequest(challenge, "test-client", "Test App", []string{"openid", "profile"}, false, "") + + // Test GetLoginRequest + loginReq, err := mock.GetLoginRequest(ctx, challenge) + if err != nil { + t.Fatalf("GetLoginRequest failed: %v", err) + } + if loginReq.GetChallenge() != challenge { + t.Errorf("Expected challenge %s, got %s", challenge, loginReq.GetChallenge()) + } + if loginReq.Client.GetClientId() != "test-client" { + t.Errorf("Expected client_id test-client, got %s", loginReq.Client.GetClientId()) + } + + // Test AcceptLogin + redirect, err := mock.AcceptLogin(ctx, challenge, "user-123", true, 3600) + if err != nil { + t.Fatalf("AcceptLogin failed: %v", err) + } + if redirect.RedirectTo == "" { + t.Error("Expected redirect URL") + } + + // Verify login was accepted + if subject, ok := mock.AcceptedLogins[challenge]; !ok || subject != "user-123" { + t.Error("Login should be accepted with subject user-123") + } +} + +func TestMockProvider_ConsentFlow(t *testing.T) { + ctx := context.Background() + mock := NewMockProvider() + + challenge := "consent-challenge-456" + mock.SetConsentRequest(challenge, "test-client", "Test App", "user-123", []string{"openid", "profile", "email"}, false) + + // Test GetConsentRequest + consentReq, err := mock.GetConsentRequest(ctx, challenge) + if err != nil { + t.Fatalf("GetConsentRequest failed: %v", err) + } + if consentReq.GetSubject() != "user-123" { + t.Errorf("Expected subject user-123, got %s", consentReq.GetSubject()) + } + + // Test AcceptConsent + grantScope := []string{"openid", "profile"} + redirect, err := mock.AcceptConsent(ctx, challenge, grantScope, true, 3600, nil) + if err != nil { + t.Fatalf("AcceptConsent failed: %v", err) + } + if redirect.RedirectTo == "" { + t.Error("Expected redirect URL") + } + + // Verify consent was accepted + if scopes, ok := mock.AcceptedConsents[challenge]; !ok || len(scopes) != 2 { + t.Error("Consent should be accepted with granted scopes") + } +} + +func TestMockProvider_RejectFlow(t *testing.T) { + ctx := context.Background() + mock := NewMockProvider() + + challenge := "login-challenge-789" + mock.SetLoginRequest(challenge, "test-client", "Test App", []string{"openid"}, false, "") + + // Test RejectLogin + redirect, err := mock.RejectLogin(ctx, challenge, "access_denied", "User denied access") + if err != nil { + t.Fatalf("RejectLogin failed: %v", err) + } + if redirect.RedirectTo == "" { + t.Error("Expected redirect URL") + } + + // Verify login was rejected + if errorID, ok := mock.RejectedLogins[challenge]; !ok || errorID != "access_denied" { + t.Error("Login should be rejected with access_denied error") + } +} + +func TestMockProvider_SkipLogin(t *testing.T) { + ctx := context.Background() + mock := NewMockProvider() + + // Simulate skip=true (user already authenticated) + challenge := "login-challenge-skip" + mock.SetLoginRequest(challenge, "test-client", "Test App", []string{"openid"}, true, "existing-user-123") + + loginReq, err := mock.GetLoginRequest(ctx, challenge) + if err != nil { + t.Fatalf("GetLoginRequest failed: %v", err) + } + + if !loginReq.GetSkip() { + t.Error("Expected skip=true") + } + if loginReq.GetSubject() != "existing-user-123" { + t.Errorf("Expected subject existing-user-123, got %s", loginReq.GetSubject()) + } +} + +func TestMockProvider_LogoutFlow(t *testing.T) { + ctx := context.Background() + mock := NewMockProvider() + + challenge := "logout-challenge-123" + mock.SetLogoutRequest(challenge, "user-123", "session-456") + + // Test GetLogoutRequest + logoutReq, err := mock.GetLogoutRequest(ctx, challenge) + if err != nil { + t.Fatalf("GetLogoutRequest failed: %v", err) + } + if logoutReq.GetSubject() != "user-123" { + t.Errorf("Expected subject user-123, got %s", logoutReq.GetSubject()) + } + + // Test AcceptLogout + redirect, err := mock.AcceptLogout(ctx, challenge) + if err != nil { + t.Fatalf("AcceptLogout failed: %v", err) + } + if redirect.RedirectTo == "" { + t.Error("Expected redirect URL") + } +} + +func TestMockProvider_NotFound(t *testing.T) { + ctx := context.Background() + mock := NewMockProvider() + + // Try to get non-existent login request + _, err := mock.GetLoginRequest(ctx, "non-existent") + if err == nil { + t.Error("Expected error for non-existent challenge") + } +} + +func TestMockProvider_ErrorInjection(t *testing.T) { + ctx := context.Background() + mock := NewMockProvider() + + mock.GetLoginRequestErr = fmt.Errorf("simulated error") + + _, err := mock.GetLoginRequest(ctx, "any-challenge") + if err == nil { + t.Error("Expected injected error") + } +} diff --git a/service/hydra/service.go b/service/hydra/service.go new file mode 100644 index 000000000000..aed698c3d11a --- /dev/null +++ b/service/hydra/service.go @@ -0,0 +1,176 @@ +package hydra + +import ( + "context" + + client "github.com/ory/hydra-client-go/v2" +) + +// Service wraps Hydra client for login/consent/logout flows +type Service struct { + admin *client.APIClient +} + +// NewService creates a new Hydra service with the given admin URL +func NewService(adminURL string) *Service { + config := client.NewConfiguration() + config.Servers = []client.ServerConfiguration{ + {URL: adminURL}, + } + return &Service{ + admin: client.NewAPIClient(config), + } +} + +// GetLoginRequest fetches information about a login request +func (s *Service) GetLoginRequest(ctx context.Context, challenge string) (*client.OAuth2LoginRequest, error) { + req, _, err := s.admin.OAuth2API.GetOAuth2LoginRequest(ctx). + LoginChallenge(challenge). + Execute() + return req, err +} + +// AcceptLogin accepts a login request +func (s *Service) AcceptLogin(ctx context.Context, challenge string, subject string, remember bool, rememberFor int64) (*client.OAuth2RedirectTo, error) { + body := client.NewAcceptOAuth2LoginRequest(subject) + body.SetRemember(remember) + body.SetRememberFor(rememberFor) + + resp, _, err := s.admin.OAuth2API.AcceptOAuth2LoginRequest(ctx). + LoginChallenge(challenge). + AcceptOAuth2LoginRequest(*body). + Execute() + return resp, err +} + +// RejectLogin rejects a login request +func (s *Service) RejectLogin(ctx context.Context, challenge string, errorID string, errorDescription string) (*client.OAuth2RedirectTo, error) { + body := client.NewRejectOAuth2Request() + body.SetError(errorID) + body.SetErrorDescription(errorDescription) + + resp, _, err := s.admin.OAuth2API.RejectOAuth2LoginRequest(ctx). + LoginChallenge(challenge). + RejectOAuth2Request(*body). + Execute() + return resp, err +} + +// GetConsentRequest fetches information about a consent request +func (s *Service) GetConsentRequest(ctx context.Context, challenge string) (*client.OAuth2ConsentRequest, error) { + req, _, err := s.admin.OAuth2API.GetOAuth2ConsentRequest(ctx). + ConsentChallenge(challenge). + Execute() + return req, err +} + +// AcceptConsent accepts a consent request +func (s *Service) AcceptConsent(ctx context.Context, challenge string, grantScope []string, remember bool, rememberFor int64, session *client.AcceptOAuth2ConsentRequestSession) (*client.OAuth2RedirectTo, error) { + body := client.NewAcceptOAuth2ConsentRequest() + body.SetGrantScope(grantScope) + body.SetRemember(remember) + body.SetRememberFor(rememberFor) + if session != nil { + body.SetSession(*session) + } + + resp, _, err := s.admin.OAuth2API.AcceptOAuth2ConsentRequest(ctx). + ConsentChallenge(challenge). + AcceptOAuth2ConsentRequest(*body). + Execute() + return resp, err +} + +// RejectConsent rejects a consent request +func (s *Service) RejectConsent(ctx context.Context, challenge string, errorID string, errorDescription string) (*client.OAuth2RedirectTo, error) { + body := client.NewRejectOAuth2Request() + body.SetError(errorID) + body.SetErrorDescription(errorDescription) + + resp, _, err := s.admin.OAuth2API.RejectOAuth2ConsentRequest(ctx). + ConsentChallenge(challenge). + RejectOAuth2Request(*body). + Execute() + return resp, err +} + +// GetLogoutRequest fetches information about a logout request +func (s *Service) GetLogoutRequest(ctx context.Context, challenge string) (*client.OAuth2LogoutRequest, error) { + req, _, err := s.admin.OAuth2API.GetOAuth2LogoutRequest(ctx). + LogoutChallenge(challenge). + Execute() + return req, err +} + +// AcceptLogout accepts a logout request +func (s *Service) AcceptLogout(ctx context.Context, challenge string) (*client.OAuth2RedirectTo, error) { + resp, _, err := s.admin.OAuth2API.AcceptOAuth2LogoutRequest(ctx). + LogoutChallenge(challenge). + Execute() + return resp, err +} + +// RejectLogout rejects a logout request +func (s *Service) RejectLogout(ctx context.Context, challenge string) error { + _, err := s.admin.OAuth2API.RejectOAuth2LogoutRequest(ctx). + LogoutChallenge(challenge). + Execute() + return err +} + +// IntrospectToken validates a token and returns its metadata +func (s *Service) IntrospectToken(ctx context.Context, token string, scope string) (*client.IntrospectedOAuth2Token, error) { + req := s.admin.OAuth2API.IntrospectOAuth2Token(ctx).Token(token) + if scope != "" { + req = req.Scope(scope) + } + resp, _, err := req.Execute() + return resp, err +} + +// CreateOAuth2Client creates a new OAuth2 client in Hydra +func (s *Service) CreateOAuth2Client(ctx context.Context, clientID, clientSecret, clientName string, grantTypes, responseTypes, redirectURIs []string, scope, tokenEndpointAuthMethod string) (*client.OAuth2Client, error) { + body := client.NewOAuth2Client() + body.SetClientId(clientID) + body.SetClientSecret(clientSecret) + body.SetClientName(clientName) + body.SetGrantTypes(grantTypes) + body.SetResponseTypes(responseTypes) + body.SetRedirectUris(redirectURIs) + body.SetScope(scope) + body.SetTokenEndpointAuthMethod(tokenEndpointAuthMethod) + + resp, _, err := s.admin.OAuth2API.CreateOAuth2Client(ctx). + OAuth2Client(*body). + Execute() + return resp, err +} + +// UpdateOAuth2Client updates an existing OAuth2 client in Hydra +func (s *Service) UpdateOAuth2Client(ctx context.Context, clientID, clientName string, grantTypes, responseTypes, redirectURIs []string, scope, tokenEndpointAuthMethod string) (*client.OAuth2Client, error) { + body := client.NewOAuth2Client() + body.SetClientId(clientID) + body.SetClientName(clientName) + body.SetGrantTypes(grantTypes) + body.SetResponseTypes(responseTypes) + body.SetRedirectUris(redirectURIs) + body.SetScope(scope) + body.SetTokenEndpointAuthMethod(tokenEndpointAuthMethod) + + resp, _, err := s.admin.OAuth2API.SetOAuth2Client(ctx, clientID). + OAuth2Client(*body). + Execute() + return resp, err +} + +// ListOAuth2Clients lists all OAuth2 clients in Hydra +func (s *Service) ListOAuth2Clients(ctx context.Context) ([]client.OAuth2Client, error) { + resp, _, err := s.admin.OAuth2API.ListOAuth2Clients(ctx).Execute() + return resp, err +} + +// DeleteOAuth2Client deletes an OAuth2 client in Hydra +func (s *Service) DeleteOAuth2Client(ctx context.Context, clientID string) error { + _, err := s.admin.OAuth2API.DeleteOAuth2Client(ctx, clientID).Execute() + return err +} diff --git a/web/src/App.jsx b/web/src/App.jsx index 06e364897cf6..4ff0f8e8a40e 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -47,6 +47,8 @@ import OAuth2Callback from './components/auth/OAuth2Callback'; import PersonalSetting from './components/settings/PersonalSetting'; import Setup from './pages/Setup'; import SetupCheck from './components/layout/SetupCheck'; +import { OAuthLogin, OAuthConsent } from './pages/OAuth'; +import OAuthClients from './pages/OAuthClients'; const Home = lazy(() => import('./pages/Home')); const Dashboard = lazy(() => import('./pages/Dashboard')); @@ -208,6 +210,22 @@ function App() { } /> + } key={location.pathname}> + + + } + /> + } key={location.pathname}> + + + } + /> } /> + + } key={location.pathname}> + + + + } + /> {} }) => { @@ -169,6 +170,12 @@ const SiderBar = ({ onNavigate = () => {} }) => { to: '/user', className: isAdmin() ? '' : 'tableHiddle', }, + { + text: t('OAuth 客户端'), + itemKey: 'oauth-clients', + to: '/console/oauth-clients', + className: isAdmin() ? '' : 'tableHiddle', + }, { text: t('系统设置'), itemKey: 'setting', diff --git a/web/src/components/table/oauth-clients/OAuthClientsActions.jsx b/web/src/components/table/oauth-clients/OAuthClientsActions.jsx new file mode 100644 index 000000000000..3777e30898fd --- /dev/null +++ b/web/src/components/table/oauth-clients/OAuthClientsActions.jsx @@ -0,0 +1,74 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState } from 'react'; +import { Button, Modal } from '@douyinfe/semi-ui'; +import { showError } from '../../../helpers'; + +const OAuthClientsActions = ({ + selectedKeys, + setEditingClient, + setShowEdit, + batchDeleteClients, + t, +}) => { + // Handle delete selected clients with confirmation + const handleDeleteSelectedClients = () => { + if (selectedKeys.length === 0) { + showError(t('请至少选择一个客户端!')); + return; + } + Modal.confirm({ + title: t('确定要删除所选的 {{count}} 个客户端吗?', { count: selectedKeys.length }), + content: t('此操作不可逆'), + onOk: () => { + batchDeleteClients(); + }, + }); + }; + + return ( +
+ + + +
+ ); +}; + +export default OAuthClientsActions; diff --git a/web/src/components/table/oauth-clients/OAuthClientsColumnDefs.jsx b/web/src/components/table/oauth-clients/OAuthClientsColumnDefs.jsx new file mode 100644 index 000000000000..0f42e11ac89c --- /dev/null +++ b/web/src/components/table/oauth-clients/OAuthClientsColumnDefs.jsx @@ -0,0 +1,242 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Button, Space, Tag, Modal, Typography, Tooltip } from '@douyinfe/semi-ui'; +import { IconCopy } from '@douyinfe/semi-icons'; +import { timestamp2string } from '../../../helpers'; + +const { Text } = Typography; + +// Render timestamp +function renderTimestamp(timestamp) { + if (!timestamp) return '-'; + return <>{timestamp2string(timestamp)}; +} + +// Render client type +const renderClientType = (text, t) => { + if (text === 'public') { + return ( + + {t('公开')} + + ); + } + return ( + + {t('机密')} + + ); +}; + +// Render redirect URIs +const renderRedirectUris = (uris, t) => { + if (!uris || uris.length === 0) { + return -; + } + + const uriArray = Array.isArray(uris) ? uris : [uris]; + const displayUris = uriArray.slice(0, 1); + const extraCount = uriArray.length - displayUris.length; + + return ( + + {displayUris.map((uri, idx) => ( + + + + {uri} + + + + ))} + {extraCount > 0 && ( + + {'+' + extraCount} + + )} + + ); +}; + +// Render scopes +const renderScopes = (scopes, t) => { + if (!scopes || scopes.length === 0) { + return -; + } + + const scopeArray = Array.isArray(scopes) ? scopes : scopes.split(' '); + const displayScopes = scopeArray.slice(0, 2); + const extraCount = scopeArray.length - displayScopes.length; + + return ( + + {displayScopes.map((scope, idx) => ( + + {scope} + + ))} + {extraCount > 0 && ( + + {'+' + extraCount} + + )} + + ); +}; + +// Render client ID with copy button +const renderClientId = (text, copyText, t) => { + if (!text) return '-'; + + return ( + + + {text} + + + + + + ); +}; + +export const getOAuthClientsColumns = ({ + t, + copyText, + deleteClient, + setEditingClient, + setShowEdit, + refresh, +}) => { + return [ + { + title: t('客户端名称'), + dataIndex: 'client_name', + key: 'client_name', + }, + { + title: t('Client ID'), + dataIndex: 'client_id', + key: 'client_id', + render: (text) => renderClientId(text, copyText, t), + }, + { + title: t('类型'), + dataIndex: 'token_endpoint_auth_method', + key: 'token_endpoint_auth_method', + render: (text) => { + const isPublic = text === 'none'; + return renderClientType(isPublic ? 'public' : 'confidential', t); + }, + }, + { + title: t('Redirect URI'), + dataIndex: 'redirect_uris', + key: 'redirect_uris', + render: (uris) => renderRedirectUris(uris, t), + }, + { + title: t('允许的 Scope'), + dataIndex: 'scope', + key: 'scope', + render: (scopes) => renderScopes(scopes, t), + }, + { + title: t('创建时间'), + dataIndex: 'created_at', + key: 'created_at', + render: (text) => renderTimestamp(text), + }, + { + title: '', + dataIndex: 'operate', + fixed: 'right', + render: (text, record) => + renderOperations( + text, + record, + setEditingClient, + setShowEdit, + deleteClient, + refresh, + t + ), + }, + ]; +}; diff --git a/web/src/components/table/oauth-clients/OAuthClientsFilters.jsx b/web/src/components/table/oauth-clients/OAuthClientsFilters.jsx new file mode 100644 index 000000000000..dfa3c5e10576 --- /dev/null +++ b/web/src/components/table/oauth-clients/OAuthClientsFilters.jsx @@ -0,0 +1,95 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useRef } from 'react'; +import { Form, Button } from '@douyinfe/semi-ui'; +import { IconSearch } from '@douyinfe/semi-icons'; + +const OAuthClientsFilters = ({ + formInitValues, + setFormApi, + searchClients, + loading, + searching, + refresh, + t, +}) => { + const formApiRef = useRef(null); + + const handleReset = () => { + if (!formApiRef.current) return; + formApiRef.current.reset(); + setTimeout(() => { + refresh(); + }, 100); + }; + + return ( +
{ + setFormApi(api); + formApiRef.current = api; + }} + onSubmit={searchClients} + allowEmpty={true} + autoComplete='off' + layout='horizontal' + trigger='change' + stopValidateWithError={false} + className='w-full md:w-auto order-1 md:order-2' + > +
+
+ } + placeholder={t('搜索客户端名称或ID')} + showClear + pure + size='small' + /> +
+ +
+ + + +
+
+
+ ); +}; + +export default OAuthClientsFilters; diff --git a/web/src/components/table/oauth-clients/OAuthClientsTable.jsx b/web/src/components/table/oauth-clients/OAuthClientsTable.jsx new file mode 100644 index 000000000000..68a0a5136507 --- /dev/null +++ b/web/src/components/table/oauth-clients/OAuthClientsTable.jsx @@ -0,0 +1,93 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useMemo } from 'react'; +import { Empty } from '@douyinfe/semi-ui'; +import CardTable from '../../common/ui/CardTable'; +import { + IllustrationNoResult, + IllustrationNoResultDark, +} from '@douyinfe/semi-illustrations'; +import { getOAuthClientsColumns } from './OAuthClientsColumnDefs'; + +const OAuthClientsTable = (clientsData) => { + const { + clients, + loading, + activePage, + pageSize, + clientCount, + handlePageChange, + handlePageSizeChange, + rowSelection, + copyText, + deleteClient, + setEditingClient, + setShowEdit, + refresh, + t, + } = clientsData; + + // Get all columns + const columns = useMemo(() => { + return getOAuthClientsColumns({ + t, + copyText, + deleteClient, + setEditingClient, + setShowEdit, + refresh, + }); + }, [t, copyText, deleteClient, setEditingClient, setShowEdit, refresh]); + + return ( + } + darkModeImage={ + + } + description={t('暂无数据')} + style={{ padding: 30 }} + /> + } + className='rounded-xl overflow-hidden' + size='middle' + /> + ); +}; + +export default OAuthClientsTable; diff --git a/web/src/components/table/oauth-clients/index.jsx b/web/src/components/table/oauth-clients/index.jsx new file mode 100644 index 000000000000..5a46a9d97c93 --- /dev/null +++ b/web/src/components/table/oauth-clients/index.jsx @@ -0,0 +1,111 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import CardPro from '../../common/ui/CardPro'; +import OAuthClientsTable from './OAuthClientsTable'; +import OAuthClientsActions from './OAuthClientsActions'; +import OAuthClientsFilters from './OAuthClientsFilters'; +import EditOAuthClientModal from './modals/EditOAuthClientModal'; +import { useOAuthClientsData } from '../../../hooks/oauth-clients/useOAuthClientsData'; +import { useIsMobile } from '../../../hooks/common/useIsMobile'; +import { createCardProPagination } from '../../../helpers/utils'; + +function OAuthClientsPage() { + const clientsData = useOAuthClientsData(); + const isMobile = useIsMobile(); + + const { + // Edit state + showEdit, + editingClient, + closeEdit, + refresh, + + // Actions state + selectedKeys, + setEditingClient, + setShowEdit, + batchDeleteClients, + copyText, + + // Filters state + formInitValues, + setFormApi, + searchClients, + loading, + searching, + + // Translation + t, + } = clientsData; + + return ( + <> + + + + + +
+ +
+ + } + paginationArea={createCardProPagination({ + currentPage: clientsData.activePage, + pageSize: clientsData.pageSize, + total: clientsData.clientCount, + onPageChange: clientsData.handlePageChange, + onPageSizeChange: clientsData.handlePageSizeChange, + isMobile: isMobile, + t: clientsData.t, + })} + t={clientsData.t} + > + +
+ + ); +} + +export default OAuthClientsPage; diff --git a/web/src/components/table/oauth-clients/modals/EditOAuthClientModal.jsx b/web/src/components/table/oauth-clients/modals/EditOAuthClientModal.jsx new file mode 100644 index 000000000000..87f84b22f2cd --- /dev/null +++ b/web/src/components/table/oauth-clients/modals/EditOAuthClientModal.jsx @@ -0,0 +1,378 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect, useState, useRef } from 'react'; +import { + API, + showError, + showSuccess, +} from '../../../../helpers'; +import { useIsMobile } from '../../../../hooks/common/useIsMobile'; +import { + Button, + SideSheet, + Space, + Spin, + Typography, + Card, + Tag, + Avatar, + Form, + Col, + Row, + Modal, +} from '@douyinfe/semi-ui'; +import { + IconLink, + IconSave, + IconClose, + IconKey, + IconCopy, +} from '@douyinfe/semi-icons'; +import { useTranslation } from 'react-i18next'; + +const { Text, Title, Paragraph } = Typography; + +const AVAILABLE_SCOPES = [ + { value: 'openid', label: 'OpenID' }, + { value: 'profile', label: 'Profile' }, + { value: 'email', label: 'Email' }, + { value: 'offline_access', label: 'Offline Access' }, + { value: 'balance:read', label: 'Balance (Read)' }, + { value: 'usage:read', label: 'Usage (Read)' }, + { value: 'tokens:read', label: 'Tokens (Read)' }, + { value: 'tokens:write', label: 'Tokens (Write)' }, +]; + +const GRANT_TYPES = [ + { value: 'authorization_code', label: 'Authorization Code' }, + { value: 'refresh_token', label: 'Refresh Token' }, + { value: 'client_credentials', label: 'Client Credentials' }, +]; + +const EditOAuthClientModal = (props) => { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const isMobile = useIsMobile(); + const formApiRef = useRef(null); + const isEdit = props.editingClient?.client_id !== undefined; + const [newClientSecret, setNewClientSecret] = useState(null); + + const getInitValues = () => ({ + client_name: '', + redirect_uris: '', + scope: ['openid', 'profile', 'email', 'offline_access'], + grant_types: ['authorization_code', 'refresh_token'], + token_endpoint_auth_method: 'client_secret_basic', + }); + + const handleCancel = () => { + setNewClientSecret(null); + props.handleClose(); + }; + + const loadClient = async () => { + if (!props.editingClient?.client_id) return; + + setLoading(true); + try { + // For edit mode, we populate from the passed data since the API returns list only + const client = props.editingClient; + if (formApiRef.current) { + formApiRef.current.setValues({ + client_name: client.client_name || '', + redirect_uris: Array.isArray(client.redirect_uris) + ? client.redirect_uris.join('\n') + : client.redirect_uris || '', + scope: Array.isArray(client.scope) + ? client.scope + : (client.scope || '').split(' ').filter(Boolean), + grant_types: client.grant_types || ['authorization_code', 'refresh_token'], + token_endpoint_auth_method: client.token_endpoint_auth_method || 'client_secret_basic', + }); + } + } catch (error) { + showError(error.message); + } + setLoading(false); + }; + + useEffect(() => { + if (props.visiable) { + if (isEdit) { + loadClient(); + } else { + formApiRef.current?.setValues(getInitValues()); + } + } else { + formApiRef.current?.reset(); + setNewClientSecret(null); + } + }, [props.visiable, props.editingClient?.client_id]); + + const submit = async (values) => { + setLoading(true); + + // Parse redirect URIs from textarea (one per line) + const redirectUris = values.redirect_uris + .split('\n') + .map((uri) => uri.trim()) + .filter(Boolean); + + if (redirectUris.length === 0) { + showError(t('请至少输入一个 Redirect URI')); + setLoading(false); + return; + } + + const payload = { + client_name: values.client_name, + redirect_uris: redirectUris, + scope: values.scope.join(' '), + grant_types: values.grant_types, + token_endpoint_auth_method: values.token_endpoint_auth_method, + response_types: ['code'], + }; + + try { + if (isEdit) { + // Update existing client + const res = await API.put( + `/api/oauth/admin/clients/${props.editingClient.client_id}`, + payload + ); + const { success, message } = res.data; + if (success) { + showSuccess(t('客户端更新成功!')); + props.refresh(); + props.handleClose(); + } else { + showError(message); + } + } else { + // Create new client + const res = await API.post('/api/oauth/admin/clients', payload); + const { success, message, data } = res.data; + if (success) { + // Show the client_secret in a modal (only shown once) + if (data?.client_secret) { + setNewClientSecret(data.client_secret); + Modal.success({ + title: t('客户端创建成功!'), + content: ( +
+ + {t('请妥善保管以下 Client Secret,此信息仅显示一次:')} + +
+ + {data.client_secret} + +
+ + {t('关闭此窗口后将无法再次查看 Client Secret')} + +
+ ), + okText: t('我已保存'), + onOk: () => { + props.refresh(); + props.handleClose(); + }, + }); + } else { + showSuccess(t('客户端创建成功!')); + props.refresh(); + props.handleClose(); + } + } else { + showError(message); + } + } + } catch (error) { + showError(error.message || t('操作失败')); + } + + setLoading(false); + }; + + return ( + + {isEdit ? ( + + {t('更新')} + + ) : ( + + {t('新建')} + + )} + + {isEdit ? t('更新 OAuth 客户端') : t('创建 OAuth 客户端')} + + + } + bodyStyle={{ padding: '0' }} + visible={props.visiable} + width={isMobile ? '100%' : 600} + footer={ +
+ + + + +
+ } + closeIcon={null} + onCancel={() => handleCancel()} + > + +
(formApiRef.current = api)} + onSubmit={submit} + > + {({ values }) => ( +
+ {/* Basic Info */} + +
+ + + +
+ {t('基本信息')} +
+ {t('设置 OAuth 客户端的基本信息')} +
+
+
+ + + + + {isEdit && props.editingClient?.client_id && ( + + + {props.editingClient.client_id} + + + )} + +
+ + {/* OAuth Settings */} + +
+ + + +
+ {t('OAuth 设置')} +
+ {t('配置 OAuth 客户端的认证设置')} +
+
+
+ + + + + + + + + + + + + + +
+
+ )} +
+
+
+ ); +}; + +export default EditOAuthClientModal; diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 425abb318a06..36feec89b302 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -73,6 +73,7 @@ import { Settings, CircleUser, Package, + Link2, } from 'lucide-react'; // 获取侧边栏Lucide图标组件 @@ -116,6 +117,8 @@ export function getLucideIcon(key, selected = false) { return ; case 'setting': return ; + case 'oauth-clients': + return ; default: return ; } diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js index 76d74ac3433d..4caff9af1df4 100644 --- a/web/src/hooks/common/useSidebar.js +++ b/web/src/hooks/common/useSidebar.js @@ -63,6 +63,7 @@ export const useSidebar = () => { models: true, redemption: true, user: true, + 'oauth-clients': true, setting: true, }, }; diff --git a/web/src/hooks/oauth-clients/useOAuthClientsData.jsx b/web/src/hooks/oauth-clients/useOAuthClientsData.jsx new file mode 100644 index 000000000000..a9db165ae86b --- /dev/null +++ b/web/src/hooks/oauth-clients/useOAuthClientsData.jsx @@ -0,0 +1,274 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal } from '@douyinfe/semi-ui'; +import { API, copy, showError, showSuccess } from '../../helpers'; +import { ITEMS_PER_PAGE } from '../../constants'; + +export const useOAuthClientsData = () => { + const { t } = useTranslation(); + + // Basic state + const [clients, setClients] = useState([]); + const [loading, setLoading] = useState(true); + const [activePage, setActivePage] = useState(1); + const [clientCount, setClientCount] = useState(0); + const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); + const [searching, setSearching] = useState(false); + + // Selection state + const [selectedKeys, setSelectedKeys] = useState([]); + + // Edit state + const [showEdit, setShowEdit] = useState(false); + const [editingClient, setEditingClient] = useState({ + client_id: undefined, + }); + + // Form state + const [formApi, setFormApi] = useState(null); + const formInitValues = { + searchKeyword: '', + }; + + // Get form values helper function + const getFormValues = () => { + const formValues = formApi ? formApi.getValues() : {}; + return { + searchKeyword: formValues.searchKeyword || '', + }; + }; + + // Close edit modal + const closeEdit = () => { + setShowEdit(false); + setTimeout(() => { + setEditingClient({ + client_id: undefined, + }); + }, 500); + }; + + // Sync page data from API response + const syncPageData = (payload) => { + if (Array.isArray(payload)) { + setClients(payload); + setClientCount(payload.length); + } else { + setClients(payload.items || []); + setClientCount(payload.total || 0); + setActivePage(payload.page || 1); + setPageSize(payload.page_size || pageSize); + } + }; + + // Load clients function + const loadClients = async () => { + setLoading(true); + try { + const res = await API.get('/api/oauth/admin/clients'); + const { success, message, data } = res.data; + if (success) { + syncPageData(data || []); + } else { + showError(message); + } + } catch (error) { + showError(error.message || t('加载失败')); + } + setLoading(false); + }; + + // Refresh function + const refresh = async () => { + await loadClients(); + setSelectedKeys([]); + }; + + // Copy text function + const copyText = async (text) => { + if (await copy(text)) { + showSuccess(t('已复制到剪贴板!')); + } else { + Modal.error({ + title: t('无法复制到剪贴板,请手动复制'), + content: text, + size: 'large', + }); + } + }; + + // Create client function + const createClient = async (clientData) => { + setLoading(true); + try { + const res = await API.post('/api/oauth/admin/clients', clientData); + const { success, message, data } = res.data; + if (success) { + showSuccess(t('客户端创建成功!')); + await refresh(); + return data; + } else { + showError(message); + return null; + } + } catch (error) { + showError(error.message || t('创建失败')); + return null; + } finally { + setLoading(false); + } + }; + + // Delete client function + const deleteClient = async (clientId) => { + setLoading(true); + try { + const res = await API.delete(`/api/oauth/admin/clients/${clientId}`); + const { success, message } = res.data; + if (success) { + showSuccess(t('删除成功')); + await refresh(); + return true; + } else { + showError(message); + return false; + } + } catch (error) { + showError(error.message || t('删除失败')); + return false; + } finally { + setLoading(false); + } + }; + + // Search clients function + const searchClients = async () => { + const { searchKeyword } = getFormValues(); + if (searchKeyword === '') { + await loadClients(); + return; + } + setSearching(true); + // Filter clients locally for now + const filteredClients = clients.filter( + (client) => + client.client_name?.toLowerCase().includes(searchKeyword.toLowerCase()) || + client.client_id?.toLowerCase().includes(searchKeyword.toLowerCase()) + ); + setClients(filteredClients); + setClientCount(filteredClients.length); + setSearching(false); + }; + + // Page handlers + const handlePageChange = (page) => { + setActivePage(page); + }; + + const handlePageSizeChange = async (size) => { + setPageSize(size); + setActivePage(1); + }; + + // Row selection handlers + const rowSelection = { + onSelect: (record, selected) => {}, + onSelectAll: (selected, selectedRows) => {}, + onChange: (selectedRowKeys, selectedRows) => { + setSelectedKeys(selectedRows); + }, + }; + + // Batch delete clients + const batchDeleteClients = async () => { + if (selectedKeys.length === 0) { + showError(t('请先选择要删除的客户端!')); + return; + } + setLoading(true); + try { + let successCount = 0; + for (const client of selectedKeys) { + const res = await API.delete(`/api/oauth/admin/clients/${client.client_id}`); + if (res.data?.success) { + successCount++; + } + } + showSuccess(t('已删除 {{count}} 个客户端!', { count: successCount })); + await refresh(); + } catch (error) { + showError(error.message); + } finally { + setLoading(false); + } + }; + + // Initialize data + useEffect(() => { + loadClients().catch((reason) => { + showError(reason); + }); + }, []); + + return { + // Basic state + clients, + loading, + activePage, + clientCount, + pageSize, + searching, + + // Selection state + selectedKeys, + setSelectedKeys, + + // Edit state + showEdit, + setShowEdit, + editingClient, + setEditingClient, + closeEdit, + + // Form state + formApi, + setFormApi, + formInitValues, + getFormValues, + + // Functions + loadClients, + refresh, + copyText, + createClient, + deleteClient, + searchClients, + handlePageChange, + handlePageSizeChange, + rowSelection, + batchDeleteClients, + syncPageData, + + // Translation + t, + }; +}; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 7295914f4a7f..19290e8fecfc 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -48,7 +48,15 @@ "API渠道配置": "API channel configuration", "API端点": "API endpoints", "Authorization callback URL 填": "Fill in the Authorization callback URL", + "Authorization Code": "Authorization Code", "Authorization Endpoint": "Authorization Endpoint", + "Authorization Failed": "Authorization Failed", + "Authorization Successful": "Authorization Successful", + "Error": "Error", + "No authorization code received.": "No authorization code received.", + "OAuth Callback": "OAuth Callback", + "State": "State", + "You can now exchange this code for an access token.": "You can now exchange this code for an access token.", "auto分组调用链路": "auto group call chain", "Bark推送URL": "Bark Push URL", "Bark推送URL必须以http://或https://开头": "Bark push URL must start with http:// or https://", @@ -2113,6 +2121,68 @@ "统一的": "The Unified", "大模型接口网关": "LLM API Gateway", "正在跳转 GitHub...": "Redirecting to GitHub...", - "请求超时,请刷新页面后重新发起 GitHub 登录": "Request timed out, please refresh and restart GitHub login" + "请求超时,请刷新页面后重新发起 GitHub 登录": "Request timed out, please refresh and restart GitHub login", + "缺少 login_challenge 参数": "Missing login_challenge parameter", + "获取客户端信息失败": "Failed to fetch client information", + "错误": "Error", + "请输入您的验证器应用中的验证码": "Please enter the verification code from your authenticator app", + "请输入6位验证码": "Please enter 6-digit verification code", + "验证": "Verify", + "返回登录": "Back to Login", + "第三方应用": "Third-party Application", + "请求访问您的账户": "is requesting access to your account", + "登录后,该应用将获得以下权限:": "After login, this application will have access to:", + "缺少 consent_challenge 参数": "Missing consent_challenge parameter", + "获取授权信息失败": "Failed to fetch consent information", + "授权会话已过期,请重新发起授权": "Authorization session expired, please restart authorization", + "请求以下权限": "is requesting the following permissions", + "授权失败": "Authorization failed", + "授权失败,请重试": "Authorization failed, please try again", + "操作失败": "Operation failed", + "操作失败,请重试": "Operation failed, please try again", + "拒绝": "Deny", + "授权": "Authorize", + "授权后,该应用将获得上述所有权限": "By authorizing, you grant this application all the permissions listed above", + "授权完成": "Authorization Complete", + "请返回应用完成登录": "Please return to the application to complete login", + "已发起跳转,请返回应用完成登录": "Redirect initiated, please return to the application to complete login", + "如果未自动跳转,请点击继续": "If you are not redirected automatically, click Continue", + "OAuth 客户端": "OAuth Clients", + "客户端名称": "Client Name", + "类型": "Type", + "公开": "Public", + "机密": "Confidential", + "Redirect URI": "Redirect URI", + "允许的 Scope": "Allowed Scopes", + "添加客户端": "Add Client", + "搜索客户端名称或ID": "Search client name or ID", + "请至少选择一个客户端!": "Please select at least one client!", + "确定要删除所选的 {{count}} 个客户端吗?": "Are you sure you want to delete the {{count}} selected clients?", + "已删除 {{count}} 个客户端!": "Deleted {{count}} clients!", + "确定是否要删除此客户端?": "Are you sure you want to delete this client?", + "客户端创建成功!": "Client created successfully!", + "客户端更新成功!": "Client updated successfully!", + "更新 OAuth 客户端": "Update OAuth Client", + "创建 OAuth 客户端": "Create OAuth Client", + "请输入客户端名称": "Please enter client name", + "OAuth 设置": "OAuth Settings", + "配置 OAuth 客户端的认证设置": "Configure OAuth client authentication settings", + "请输入 Redirect URI,一行一个": "Enter Redirect URIs, one per line", + "支持多个 URI,每行一个": "Supports multiple URIs, one per line", + "请输入 Redirect URI": "Please enter Redirect URI", + "请至少输入一个 Redirect URI": "Please enter at least one Redirect URI", + "请选择允许的 Scope": "Please select allowed scopes", + "请至少选择一个 Scope": "Please select at least one scope", + "Grant Types": "Grant Types", + "请选择 Grant Types": "Please select grant types", + "请至少选择一个 Grant Type": "Please select at least one grant type", + "认证方式": "Authentication Method", + "请选择认证方式": "Please select authentication method", + "公开客户端(无密钥)": "Public Client (No Secret)", + "选择 \"公开客户端\" 将不会生成 Client Secret": "Selecting \"Public Client\" will not generate a Client Secret", + "设置 OAuth 客户端的基本信息": "Set basic information for OAuth client", + "请妥善保管以下 Client Secret,此信息仅显示一次:": "Please save the following Client Secret, it will only be shown once:", + "关闭此窗口后将无法再次查看 Client Secret": "You will not be able to view the Client Secret again after closing this window", + "我已保存": "I have saved it" } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 29c1c7f40ed4..4bfbe07d6e39 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -46,7 +46,13 @@ "API渠道配置": "API渠道配置", "API端点": "API端点", "Authorization callback URL 填": "Authorization callback URL 填", + "Authorization Code": "授权码", "Authorization Endpoint": "Authorization Endpoint", + "Authorization Failed": "授权失败", + "Authorization Successful": "授权成功", + "Error": "错误", + "State": "状态", + "You can now exchange this code for an access token.": "您现在可以使用此授权码换取访问令牌。", "auto分组调用链路": "auto分组调用链路", "Bark推送URL": "Bark推送URL", "Bark推送URL必须以http://或https://开头": "Bark推送URL必须以http://或https://开头", @@ -96,6 +102,8 @@ "Midjourney 任务记录": "Midjourney 任务记录", "MIT许可证": "MIT许可证", "New API项目仓库地址:": "New API项目仓库地址:", + "No authorization code received.": "未收到授权码。", + "OAuth Callback": "OAuth 回调", "OIDC": "OIDC", "OIDC ID": "OIDC ID", "Passkey": "Passkey", @@ -2075,6 +2083,69 @@ "Creem 介绍": "Creem 是一个简单的支付处理平台,支持固定金额产品销售,以及订阅销售。", "Creem Setting Tips": "Creem 只支持预设的固定金额产品,这产品以及价格需要提前在Creem网站内创建配置,所以不支持自定义动态金额充值。在Creem端配置产品的名字以及价格,获取Product Id 后填到下面的产品,在new-api为该产品设置充值额度,以及展示价格。", "正在跳转 GitHub...": "正在跳转 GitHub...", - "请求超时,请刷新页面后重新发起 GitHub 登录": "请求超时,请刷新页面后重新发起 GitHub 登录" + "请求超时,请刷新页面后重新发起 GitHub 登录": "请求超时,请刷新页面后重新发起 GitHub 登录", + "缺少 login_challenge 参数": "缺少 login_challenge 参数", + "获取客户端信息失败": "获取客户端信息失败", + "错误": "错误", + "请输入您的验证器应用中的验证码": "请输入您的验证器应用中的验证码", + "请输入6位验证码": "请输入6位验证码", + "验证": "验证", + "返回登录": "返回登录", + "第三方应用": "第三方应用", + "请求访问您的账户": "请求访问您的账户", + "登录后,该应用将获得以下权限:": "登录后,该应用将获得以下权限:", + "缺少 consent_challenge 参数": "缺少 consent_challenge 参数", + "获取授权信息失败": "获取授权信息失败", + "授权会话已过期,请重新发起授权": "授权会话已过期,请重新发起授权", + "请求以下权限": "请求以下权限", + "授权失败": "授权失败", + "授权失败,请重试": "授权失败,请重试", + "操作失败": "操作失败", + "操作失败,请重试": "操作失败,请重试", + "拒绝": "拒绝", + "授权": "授权", + "授权后,该应用将获得上述所有权限": "授权后,该应用将获得上述所有权限", + "授权完成": "授权完成", + "请返回应用完成登录": "请返回应用完成登录", + "已发起跳转,请返回应用完成登录": "已发起跳转,请返回应用完成登录", + "已发起跳转,请返回应用完成登录": "已发起跳转,请返回应用完成登录", + "如果未自动跳转,请点击继续": "如果未自动跳转,请点击继续", + "OAuth 客户端": "OAuth 客户端", + "客户端名称": "客户端名称", + "类型": "类型", + "公开": "公开", + "机密": "机密", + "Redirect URI": "Redirect URI", + "允许的 Scope": "允许的 Scope", + "添加客户端": "添加客户端", + "搜索客户端名称或ID": "搜索客户端名称或ID", + "请至少选择一个客户端!": "请至少选择一个客户端!", + "确定要删除所选的 {{count}} 个客户端吗?": "确定要删除所选的 {{count}} 个客户端吗?", + "已删除 {{count}} 个客户端!": "已删除 {{count}} 个客户端!", + "确定是否要删除此客户端?": "确定是否要删除此客户端?", + "客户端创建成功!": "客户端创建成功!", + "客户端更新成功!": "客户端更新成功!", + "更新 OAuth 客户端": "更新 OAuth 客户端", + "创建 OAuth 客户端": "创建 OAuth 客户端", + "请输入客户端名称": "请输入客户端名称", + "OAuth 设置": "OAuth 设置", + "配置 OAuth 客户端的认证设置": "配置 OAuth 客户端的认证设置", + "请输入 Redirect URI,一行一个": "请输入 Redirect URI,一行一个", + "支持多个 URI,每行一个": "支持多个 URI,每行一个", + "请输入 Redirect URI": "请输入 Redirect URI", + "请至少输入一个 Redirect URI": "请至少输入一个 Redirect URI", + "请选择允许的 Scope": "请选择允许的 Scope", + "请至少选择一个 Scope": "请至少选择一个 Scope", + "Grant Types": "Grant Types", + "请选择 Grant Types": "请选择 Grant Types", + "请至少选择一个 Grant Type": "请至少选择一个 Grant Type", + "认证方式": "认证方式", + "请选择认证方式": "请选择认证方式", + "公开客户端(无密钥)": "公开客户端(无密钥)", + "选择 \"公开客户端\" 将不会生成 Client Secret": "选择 \"公开客户端\" 将不会生成 Client Secret", + "设置 OAuth 客户端的基本信息": "设置 OAuth 客户端的基本信息", + "请妥善保管以下 Client Secret,此信息仅显示一次:": "请妥善保管以下 Client Secret,此信息仅显示一次:", + "关闭此窗口后将无法再次查看 Client Secret": "关闭此窗口后将无法再次查看 Client Secret", + "我已保存": "我已保存" } } diff --git a/web/src/pages/OAuth/OAuthConsent.jsx b/web/src/pages/OAuth/OAuthConsent.jsx new file mode 100644 index 000000000000..79609797b262 --- /dev/null +++ b/web/src/pages/OAuth/OAuthConsent.jsx @@ -0,0 +1,387 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Button, Card, Spin } from '@douyinfe/semi-ui'; +import { IconTickCircle, IconUser, IconMail, IconCoinMoneyStroked, IconHistogram, IconKey } from '@douyinfe/semi-icons'; +import Title from '@douyinfe/semi-ui/lib/es/typography/title'; +import Text from '@douyinfe/semi-ui/lib/es/typography/text'; +import { useTranslation } from 'react-i18next'; +import { API, getLogo, getSystemName, showError } from '../../helpers'; + +// Scope descriptions mapping with icons +const SCOPE_DESCRIPTIONS = { + openid: { + name: '身份验证', + desc: '验证您的身份', + nameEn: 'Identity', + descEn: 'Verify your identity', + icon: IconTickCircle, + color: 'text-green-500', + }, + profile: { + name: '基本信息', + desc: '访问您的用户名和头像', + nameEn: 'Profile', + descEn: 'Access your username and avatar', + icon: IconUser, + color: 'text-blue-500', + }, + email: { + name: '邮箱地址', + desc: '访问您的邮箱地址', + nameEn: 'Email', + descEn: 'Access your email address', + icon: IconMail, + color: 'text-purple-500', + }, + 'balance:read': { + name: '余额查看', + desc: '查看您的账户余额', + nameEn: 'Balance', + descEn: 'View your account balance', + icon: IconCoinMoneyStroked, + color: 'text-yellow-500', + }, + 'usage:read': { + name: '使用记录', + desc: '查看您的 API 使用记录', + nameEn: 'Usage', + descEn: 'View your API usage records', + icon: IconHistogram, + color: 'text-cyan-500', + }, + 'tokens:read': { + name: '令牌查看', + desc: '查看您的 API 令牌列表', + nameEn: 'Tokens (Read)', + descEn: 'View your API token list', + icon: IconKey, + color: 'text-orange-500', + }, + 'tokens:write': { + name: '令牌管理', + desc: '创建和删除 API 令牌', + nameEn: 'Tokens (Write)', + descEn: 'Create and delete API tokens', + icon: IconKey, + color: 'text-red-500', + }, +}; + +const OAuthConsent = () => { + const { t, i18n } = useTranslation(); + const [searchParams] = useSearchParams(); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + const [consentInfo, setConsentInfo] = useState(null); + const [redirectComplete, setRedirectComplete] = useState(false); + const [redirectTarget, setRedirectTarget] = useState(''); + + const logo = getLogo(); + const systemName = getSystemName(); + const challenge = searchParams.get('consent_challenge'); + const isEnglish = i18n.language === 'en'; + + // Check if URL is a custom URI scheme (not http/https) + const isCustomScheme = (url) => { + if (!url) return false; + try { + const parsed = new URL(url); + return !['http:', 'https:'].includes(parsed.protocol); + } catch { + return false; + } + }; + + // Handle redirect - for custom URI schemes, show completion message + const handleRedirect = (redirectTo) => { + setRedirectTarget(redirectTo || ''); + setRedirectComplete(true); + if (isCustomScheme(redirectTo)) { + window.location.href = redirectTo; + return; + } + const newWindow = window.open(redirectTo, '_blank', 'noopener,noreferrer'); + if (!newWindow) { + window.location.assign(redirectTo); + } + }; + + // Fetch consent info on mount + useEffect(() => { + if (!challenge) { + setError(t('缺少 consent_challenge 参数')); + setLoading(false); + return; + } + + const fetchConsentInfo = async () => { + try { + const res = await API.get(`/api/oauth/consent?consent_challenge=${challenge}`); + const { success, message, data } = res.data || {}; + + // Check if we need to redirect (already consented or not logged in) + if (data?.redirect_to) { + handleRedirect(data.redirect_to); + return; + } + + if (success) { + setConsentInfo(data); + } else { + setError(message || t('授权会话已过期,请重新发起授权')); + } + } catch (err) { + console.error('Failed to fetch consent info:', err); + const redirectTo = err?.response?.data?.data?.redirect_to; + if (redirectTo) { + handleRedirect(redirectTo); + return; + } + setError(t('获取授权信息失败')); + } finally { + setLoading(false); + } + }; + + fetchConsentInfo(); + }, [challenge, t]); + + // Handle consent approval + const handleApprove = async () => { + setSubmitting(true); + try { + const res = await API.post('/api/oauth/consent', { + consent_challenge: challenge, + grant_scope: consentInfo?.requested_scope || [], + remember: true, + }); + + const { success, message, data } = res.data || {}; + + if (data?.redirect_to) { + handleRedirect(data.redirect_to); + return; + } + + if (!success) { + showError(message || t('授权失败')); + } + } catch (err) { + console.error('Consent approval failed:', err); + showError(t('授权失败,请重试')); + } finally { + setSubmitting(false); + } + }; + + // Handle consent rejection + const handleReject = async () => { + setSubmitting(true); + try { + const res = await API.post('/api/oauth/consent/reject', { + consent_challenge: challenge, + }); + + const { success, message, data } = res.data || {}; + + if (data?.redirect_to) { + handleRedirect(data.redirect_to); + return; + } + + if (!success) { + showError(message || t('操作失败')); + } + } catch (err) { + console.error('Consent rejection failed:', err); + showError(t('操作失败,请重试')); + } finally { + setSubmitting(false); + } + }; + + // Get scope info + const getScopeInfo = (scope) => { + const info = SCOPE_DESCRIPTIONS[scope]; + if (info) { + return { + name: isEnglish ? info.nameEn : info.name, + desc: isEnglish ? info.descEn : info.desc, + Icon: info.icon, + color: info.color, + }; + } + return { + name: scope, + desc: scope, + Icon: IconTickCircle, + color: 'text-gray-500', + }; + }; + + // Render loading state + if (loading) { + return ( +
+ +
+ ); + } + + // Render error state + if (error) { + return ( +
+ +
+ + {t('错误')} + + {error} +
+
+
+ ); + } + + // Render redirect complete state + if (redirectComplete) { + return ( +
+
+
+ +
+
+ Logo + {systemName} +
+ + +
+ + + {t('授权完成')} + + + {t('已发起跳转,请返回应用完成登录')} + + {redirectTarget && ( + + )} +
+
+
+
+ ); + } + + // Render consent form + return ( +
+
+
+ +
+
+ Logo + {systemName} +
+ + +
+ + {consentInfo?.client_name || t('第三方应用')} + + + {t('请求以下权限')} + +
+ +
+ {/* Scope list */} +
+ {consentInfo?.requested_scope?.map((scope, index) => { + const { name, desc, Icon, color } = getScopeInfo(scope); + const isLast = index === consentInfo.requested_scope.length - 1; + + return ( +
+ +
+ + {name} + + + {desc} + +
+
+ ); + })} +
+ + {/* Action buttons */} +
+ + +
+ + {/* Notice */} + + {t('授权后,该应用将获得上述所有权限')} + +
+
+
+
+ ); +}; + +export default OAuthConsent; diff --git a/web/src/pages/OAuth/OAuthLogin.jsx b/web/src/pages/OAuth/OAuthLogin.jsx new file mode 100644 index 000000000000..489f1077c0ec --- /dev/null +++ b/web/src/pages/OAuth/OAuthLogin.jsx @@ -0,0 +1,326 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Button, Card, Form, Spin, Tag } from '@douyinfe/semi-ui'; +import { IconLock, IconMail } from '@douyinfe/semi-icons'; +import Title from '@douyinfe/semi-ui/lib/es/typography/title'; +import Text from '@douyinfe/semi-ui/lib/es/typography/text'; +import { useTranslation } from 'react-i18next'; +import { API, getLogo, getSystemName, showError } from '../../helpers'; + +// Scope descriptions mapping +const SCOPE_DESCRIPTIONS = { + openid: { name: '身份验证', desc: '验证您的身份', nameEn: 'Identity', descEn: 'Verify your identity' }, + profile: { name: '基本信息', desc: '访问您的用户名和头像', nameEn: 'Profile', descEn: 'Access your username and avatar' }, + email: { name: '邮箱地址', desc: '访问您的邮箱地址', nameEn: 'Email', descEn: 'Access your email address' }, + 'balance:read': { name: '余额查看', desc: '查看您的账户余额', nameEn: 'Balance', descEn: 'View your account balance' }, + 'usage:read': { name: '使用记录', desc: '查看您的 API 使用记录', nameEn: 'Usage', descEn: 'View your API usage records' }, + 'tokens:read': { name: '令牌查看', desc: '查看您的 API 令牌列表', nameEn: 'Tokens (Read)', descEn: 'View your API token list' }, + 'tokens:write': { name: '令牌管理', desc: '创建和删除 API 令牌', nameEn: 'Tokens (Write)', descEn: 'Create and delete API tokens' }, +}; + +const OAuthLogin = () => { + const { t, i18n } = useTranslation(); + const [searchParams] = useSearchParams(); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + const [clientInfo, setClientInfo] = useState(null); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [require2FA, setRequire2FA] = useState(false); + const [twoFACode, setTwoFACode] = useState(''); + + const logo = getLogo(); + const systemName = getSystemName(); + const challenge = searchParams.get('login_challenge'); + const isEnglish = i18n.language === 'en'; + + // Fetch client info on mount + useEffect(() => { + if (!challenge) { + setError(t('缺少 login_challenge 参数')); + setLoading(false); + return; + } + + const fetchClientInfo = async () => { + try { + const res = await API.get(`/api/oauth/login?login_challenge=${challenge}`); + const { success, message, data } = res.data; + + if (success) { + // Check if we need to redirect (already logged in) + if (data.redirect_to) { + window.location.href = data.redirect_to; + return; + } + setClientInfo(data); + } else { + setError(message || t('获取客户端信息失败')); + } + } catch (err) { + console.error('Failed to fetch client info:', err); + setError(t('获取客户端信息失败')); + } finally { + setLoading(false); + } + }; + + fetchClientInfo(); + }, [challenge, t]); + + // Handle login submission + const handleSubmit = async () => { + if (!username || !password) { + showError(t('请输入用户名和密码')); + return; + } + + setSubmitting(true); + try { + const res = await API.post('/api/oauth/login', { + login_challenge: challenge, + username, + password, + }); + + const { success, message, data } = res.data; + + if (success) { + if (data.require_2fa) { + setRequire2FA(true); + } else if (data.redirect_to) { + window.location.href = data.redirect_to; + } + } else { + showError(message || t('登录失败')); + } + } catch (err) { + console.error('Login failed:', err); + showError(t('登录失败,请重试')); + } finally { + setSubmitting(false); + } + }; + + // Handle 2FA submission + const handle2FASubmit = async () => { + if (!twoFACode) { + showError(t('请输入验证码')); + return; + } + + setSubmitting(true); + try { + const res = await API.post('/api/oauth/login/2fa', { + login_challenge: challenge, + code: twoFACode, + }); + + const { success, message, data } = res.data; + + if (success && data.redirect_to) { + window.location.href = data.redirect_to; + } else { + showError(message || t('验证失败')); + } + } catch (err) { + console.error('2FA verification failed:', err); + showError(t('验证失败,请重试')); + } finally { + setSubmitting(false); + } + }; + + // Get scope display name + const getScopeName = (scope) => { + const info = SCOPE_DESCRIPTIONS[scope]; + if (info) { + return isEnglish ? info.nameEn : info.name; + } + return scope; + }; + + // Render loading state + if (loading) { + return ( +
+ +
+ ); + } + + // Render error state + if (error) { + return ( +
+ +
+ + {t('错误')} + + {error} +
+
+
+ ); + } + + // Render 2FA form + if (require2FA) { + return ( +
+
+
+ +
+
+ Logo + {systemName} +
+ + +
+ + {t('两步验证')} + +
+
+ + {t('请输入您的验证器应用中的验证码')} + + +
+ + + + + + +
+
+
+
+ ); + } + + // Render login form + return ( +
+
+
+ +
+
+ Logo + {systemName} +
+ + +
+ + {clientInfo?.client_name || t('第三方应用')} + + + {t('请求访问您的账户')} + +
+ +
+
+ } + /> + + } + /> + + + + + {clientInfo?.requested_scope && clientInfo.requested_scope.length > 0 && ( +
+ + {t('登录后,该应用将获得以下权限:')} + +
+ {clientInfo.requested_scope.map((scope) => ( + + {getScopeName(scope)} + + ))} +
+
+ )} +
+
+
+
+ ); +}; + +export default OAuthLogin; diff --git a/web/src/pages/OAuth/index.jsx b/web/src/pages/OAuth/index.jsx new file mode 100644 index 000000000000..b435d1af6657 --- /dev/null +++ b/web/src/pages/OAuth/index.jsx @@ -0,0 +1,21 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +export { default as OAuthLogin } from './OAuthLogin'; +export { default as OAuthConsent } from './OAuthConsent'; diff --git a/web/src/pages/OAuthClients/index.jsx b/web/src/pages/OAuthClients/index.jsx new file mode 100644 index 000000000000..11064ba0b060 --- /dev/null +++ b/web/src/pages/OAuthClients/index.jsx @@ -0,0 +1,31 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import OAuthClientsTable from '../../components/table/oauth-clients'; + +const OAuthClients = () => { + return ( +
+ +
+ ); +}; + +export default OAuthClients; From 88afdb66215a84558f23f9e1a9ae08be3223184c Mon Sep 17 00:00:00 2001 From: SuYao Date: Wed, 28 Jan 2026 11:56:45 +0800 Subject: [PATCH 25/34] refactor: migrate to Dockerfile (#7) --- Dockerfile | 9 +++++++-- docker/entrypoint.sh | 9 +++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 docker/entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 2610aa5cc3cc..b0231f2b8689 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,8 @@ COPY . . COPY --from=builder /build/dist ./web/dist RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api +FROM oryd/hydra:v25.4.0 AS hydra + FROM alpine RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ @@ -33,6 +35,9 @@ RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && update-ca-certificates COPY --from=builder2 /build/new-api / -EXPOSE 3000 +COPY --from=hydra /usr/bin/hydra /usr/bin/hydra +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +EXPOSE 3000 4444 WORKDIR /data -ENTRYPOINT ["/new-api"] +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 000000000000..09f9fda0d6e2 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +if [ "${HYDRA_ENABLED:-true}" = "true" ]; then + /usr/bin/hydra migrate sql -e --yes + /usr/bin/hydra serve all --dev & +fi + +exec /new-api "$@" \ No newline at end of file From 963bb508bc50fcbc485d7ecdea910a23dd0c27b2 Mon Sep 17 00:00:00 2001 From: SuYao Date: Wed, 28 Jan 2026 16:47:10 +0800 Subject: [PATCH 26/34] feat(hydra): add new-api-reverse-proxy (#8) --- .env.example | 7 +++++++ Dockerfile | 2 +- common/constants.go | 1 + common/init.go | 1 + model/option.go | 2 ++ router/hydra-proxy.go | 43 +++++++++++++++++++++++++++++++++++++++++++ router/main.go | 1 + 7 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 router/hydra-proxy.go diff --git a/.env.example b/.env.example index 2ce9641b10d9..5da41b75eb52 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,13 @@ # 会话密钥 # SESSION_SECRET=random_string +# Hydra OAuth Provider +# HYDRA_ENABLED=true +# Hydra admin (internal) URL +# HYDRA_ADMIN_URL=http://127.0.0.1:4445 +# Hydra public URL (proxied by new-api) +# HYDRA_PUBLIC_URL=http://127.0.0.1:4444 + # 其他配置 # 生成默认token # GENERATE_DEFAULT_TOKEN=false diff --git a/Dockerfile b/Dockerfile index b0231f2b8689..3cace4fe8bfc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,6 +38,6 @@ COPY --from=builder2 /build/new-api / COPY --from=hydra /usr/bin/hydra /usr/bin/hydra COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh -EXPOSE 3000 4444 +EXPOSE 3000 WORKDIR /data ENTRYPOINT ["/entrypoint.sh"] diff --git a/common/constants.go b/common/constants.go index e36bbe28ab0d..050e3ab6a2ef 100644 --- a/common/constants.go +++ b/common/constants.go @@ -99,6 +99,7 @@ var TelegramBotName = "" // Hydra OAuth Provider configuration var HydraEnabled = false var HydraAdminURL = "" +var HydraPublicURL = "" var HydraTrustedClients = []string{} // Clients that get auto-consent (e.g., "new-api-web,new-api-admin") var HydraLoginRememberFor int64 = 3600 // Login session remember duration in seconds (default: 1 hour) var HydraConsentRememberFor int64 = 2592000 // Consent remember duration in seconds (default: 30 days) diff --git a/common/init.go b/common/init.go index 82278261a614..5fa5293cb057 100644 --- a/common/init.go +++ b/common/init.go @@ -106,6 +106,7 @@ func InitEnv() { // Hydra OAuth Provider configuration HydraEnabled = GetEnvOrDefaultBool("HYDRA_ENABLED", false) HydraAdminURL = GetEnvOrDefaultString("HYDRA_ADMIN_URL", "") + HydraPublicURL = GetEnvOrDefaultString("HYDRA_PUBLIC_URL", "http://127.0.0.1:4444") if trustedClients := GetEnvOrDefaultString("HYDRA_TRUSTED_CLIENTS", ""); trustedClients != "" { for _, c := range strings.Split(trustedClients, ",") { if trimmed := strings.TrimSpace(c); trimmed != "" { diff --git a/model/option.go b/model/option.go index d66b68a105cc..d612a0e0c5c6 100644 --- a/model/option.go +++ b/model/option.go @@ -452,6 +452,8 @@ func updateOptionMap(key string, value string) (err error) { common.HydraEnabled = value == "true" case "HydraAdminURL": common.HydraAdminURL = value + case "HydraPublicURL": + common.HydraPublicURL = value case "HydraTrustedClients": if value == "" { common.HydraTrustedClients = []string{} diff --git a/router/hydra-proxy.go b/router/hydra-proxy.go new file mode 100644 index 000000000000..6f40bd3f15aa --- /dev/null +++ b/router/hydra-proxy.go @@ -0,0 +1,43 @@ +package router + +import ( + "net/http" + "net/http/httputil" + "net/url" + "strings" + + "github.com/QuantumNous/new-api/common" + + "github.com/gin-gonic/gin" +) + +// SetHydraPublicProxyRouter proxies Hydra public endpoints through new-api. +func SetHydraPublicProxyRouter(router *gin.Engine) { + if !common.HydraEnabled { + return + } + + publicURL := strings.TrimSpace(common.HydraPublicURL) + if publicURL == "" { + return + } + + target, err := url.Parse(publicURL) + if err != nil || target.Scheme == "" || target.Host == "" { + common.SysLog("invalid HYDRA_PUBLIC_URL: " + publicURL) + return + } + + proxy := httputil.NewSingleHostReverseProxy(target) + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, proxyErr error) { + common.SysLog("hydra public proxy error: " + proxyErr.Error()) + http.Error(w, "bad gateway", http.StatusBadGateway) + } + + router.Any("/oauth2/*any", func(c *gin.Context) { + proxy.ServeHTTP(c.Writer, c.Request) + }) + router.Any("/.well-known/*any", func(c *gin.Context) { + proxy.ServeHTTP(c.Writer, c.Request) + }) +} diff --git a/router/main.go b/router/main.go index 36980aaee5cb..7c651cd87fd6 100644 --- a/router/main.go +++ b/router/main.go @@ -19,6 +19,7 @@ func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) { SetVideoRouter(router) SetOAuthProviderRouter(router) SetOAuthAPIRouter(router) + SetHydraPublicProxyRouter(router) frontendBaseUrl := os.Getenv("FRONTEND_BASE_URL") if common.IsMasterNode && frontendBaseUrl != "" { frontendBaseUrl = "" From d7f75fc0edaa2c91a69a76b7a84d512502e09eb1 Mon Sep 17 00:00:00 2001 From: zhaolion Date: Thu, 29 Jan 2026 10:53:00 +0800 Subject: [PATCH 27/34] feat: integrate Pyroscope for performance monitoring (#9) --- common/pyro.go | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 4 ++++ go.sum | 13 ++++++++++-- main.go | 5 +++++ 4 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 common/pyro.go diff --git a/common/pyro.go b/common/pyro.go new file mode 100644 index 000000000000..c779be61e044 --- /dev/null +++ b/common/pyro.go @@ -0,0 +1,55 @@ +package common + +import ( + "runtime" + + "github.com/grafana/pyroscope-go" +) + +func StartPyroScope() error { + pyroscopeUrl := GetEnvOrDefaultString("PYROSCOPE_URL", "") + if pyroscopeUrl == "" { + return nil + } + + pyroscopeAppName := GetEnvOrDefaultString("PYROSCOPE_APP_NAME", "new-api") + pyroscopeBasicAuthUser := GetEnvOrDefaultString("PYROSCOPE_BASIC_AUTH_USER", "") + pyroscopeBasicAuthPassword := GetEnvOrDefaultString("PYROSCOPE_BASIC_AUTH_PASSWORD", "") + pyroscopeHostname := GetEnvOrDefaultString("HOSTNAME", "new-api") + + mutexRate := GetEnvOrDefault("PYROSCOPE_MUTEX_RATE", 5) + blockRate := GetEnvOrDefault("PYROSCOPE_BLOCK_RATE", 5) + + runtime.SetMutexProfileFraction(mutexRate) + runtime.SetBlockProfileRate(blockRate) + + _, err := pyroscope.Start(pyroscope.Config{ + ApplicationName: pyroscopeAppName, + + ServerAddress: pyroscopeUrl, + BasicAuthUser: pyroscopeBasicAuthUser, + BasicAuthPassword: pyroscopeBasicAuthPassword, + + Logger: nil, + + Tags: map[string]string{"hostname": pyroscopeHostname}, + + ProfileTypes: []pyroscope.ProfileType{ + pyroscope.ProfileCPU, + pyroscope.ProfileAllocObjects, + pyroscope.ProfileAllocSpace, + pyroscope.ProfileInuseObjects, + pyroscope.ProfileInuseSpace, + + pyroscope.ProfileGoroutines, + pyroscope.ProfileMutexCount, + pyroscope.ProfileMutexDuration, + pyroscope.ProfileBlockCount, + pyroscope.ProfileBlockDuration, + }, + }) + if err != nil { + return err + } + return nil +} diff --git a/go.mod b/go.mod index ff03c03d3baf..2226f1e2c66e 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.0 + github.com/grafana/pyroscope-go v1.2.7 github.com/jfreymuth/oggvorbis v1.0.5 github.com/jinzhu/copier v0.4.0 github.com/joho/godotenv v1.5.1 @@ -82,6 +83,7 @@ require ( github.com/gorilla/context v1.1.1 // indirect github.com/gorilla/securecookie v1.1.1 // indirect github.com/gorilla/sessions v1.2.1 // indirect + github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/icza/bitio v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -91,6 +93,7 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.8 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -101,6 +104,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect diff --git a/go.sum b/go.sum index f43717973318..0b9429403628 100644 --- a/go.sum +++ b/go.sum @@ -131,6 +131,10 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac= +github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0= github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= @@ -159,12 +163,15 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= +github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= @@ -215,8 +222,9 @@ github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qq github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA= github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= @@ -226,6 +234,7 @@ github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+D github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= diff --git a/main.go b/main.go index 481d0a6002eb..8484257bf1d8 100644 --- a/main.go +++ b/main.go @@ -124,6 +124,11 @@ func main() { common.SysLog("pprof enabled") } + err = common.StartPyroScope() + if err != nil { + common.SysError(fmt.Sprintf("start pyroscope error : %v", err)) + } + // Initialize HTTP server server := gin.New() server.Use(gin.CustomRecovery(func(c *gin.Context, err any) { From 982382d9c9e37a1348f2e231ad91e2a960f9412e Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 29 Jan 2026 14:43:27 +0800 Subject: [PATCH 28/34] feat: implement proxy modification add fix some bug (#10) --- .github/workflows/release.yml | 19 ++- common/constants.go | 4 +- common/init.go | 2 +- controller/oauth_provider.go | 257 +++++++++++++++++++++++------ controller/user.go | 15 ++ main.go | 2 +- router/hydra-proxy.go | 123 +++++++++++++- service/hydra/interface.go | 3 + service/hydra/mock.go | 48 ++++-- service/hydra/service.go | 8 + web/src/pages/OAuth/OAuthLogin.jsx | 6 + 11 files changed, 407 insertions(+), 80 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff8419b70436..2d9997e19a69 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,7 @@ on: push: branches: - main + - dev tags: - '*' - '!*-alpha*' @@ -99,15 +100,27 @@ jobs: with: images: ghcr.io/${{ env.GHCR_REPOSITORY }} + - name: Determine Docker tags + id: docker-tags + run: | + if [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then + # dev branch: dev + dev-{version} + echo "TAGS=ghcr.io/${{ env.GHCR_REPOSITORY }}:dev,ghcr.io/${{ env.GHCR_REPOSITORY }}:dev-${{ env.VERSION }}" >> $GITHUB_OUTPUT + elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then + # main branch: latest + {version} + echo "TAGS=ghcr.io/${{ env.GHCR_REPOSITORY }}:latest,ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ env.VERSION }}" >> $GITHUB_OUTPUT + else + # git tag: {version} only + echo "TAGS=ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ env.VERSION }}" >> $GITHUB_OUTPUT + fi + - name: Build & push to GHCR uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64 push: true - tags: | - ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ env.VERSION }} - ghcr.io/${{ env.GHCR_REPOSITORY }}:latest + tags: ${{ steps.docker-tags.outputs.TAGS }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/common/constants.go b/common/constants.go index 050e3ab6a2ef..fb2fdcbdc572 100644 --- a/common/constants.go +++ b/common/constants.go @@ -100,8 +100,8 @@ var TelegramBotName = "" var HydraEnabled = false var HydraAdminURL = "" var HydraPublicURL = "" -var HydraTrustedClients = []string{} // Clients that get auto-consent (e.g., "new-api-web,new-api-admin") -var HydraLoginRememberFor int64 = 3600 // Login session remember duration in seconds (default: 1 hour) +var HydraTrustedClients = []string{} // Clients that get auto-consent (e.g., "new-api-web,new-api-admin") +var HydraLoginRememberFor int64 = 2592000 // Login session remember duration in seconds (default: 30 days, same as new-api session) var HydraConsentRememberFor int64 = 2592000 // Consent remember duration in seconds (default: 30 days) var QuotaForNewUser = 0 diff --git a/common/init.go b/common/init.go index 5fa5293cb057..4508f41ed675 100644 --- a/common/init.go +++ b/common/init.go @@ -114,7 +114,7 @@ func InitEnv() { } } } - HydraLoginRememberFor = int64(GetEnvOrDefault("HYDRA_LOGIN_REMEMBER_FOR", 3600)) // Default: 1 hour + HydraLoginRememberFor = int64(GetEnvOrDefault("HYDRA_LOGIN_REMEMBER_FOR", 2592000)) // Default: 30 days (same as new-api session) HydraConsentRememberFor = int64(GetEnvOrDefault("HYDRA_CONSENT_REMEMBER_FOR", 2592000)) // Default: 30 days initConstantEnv() diff --git a/controller/oauth_provider.go b/controller/oauth_provider.go index af41ca93ebb0..1544f4629855 100644 --- a/controller/oauth_provider.go +++ b/controller/oauth_provider.go @@ -3,6 +3,7 @@ package controller import ( "fmt" "net/http" + "net/url" "slices" "strconv" "strings" @@ -15,6 +16,68 @@ import ( "github.com/google/uuid" ) +// rewriteOAuthRedirect rewrites Hydra internal redirect URLs to use the request's host/scheme. +// Only rewrites URLs that point to Hydra's internal paths (oauth2/*), not client redirect_uris. +func rewriteOAuthRedirect(c *gin.Context, redirectURL string) string { + if redirectURL == "" { + return redirectURL + } + + parsed, err := url.Parse(redirectURL) + if err != nil { + return redirectURL + } + + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return redirectURL + } + + // Only rewrite Hydra internal OAuth paths, not client redirect_uris + if !isHydraInternalPath(parsed.Path) { + return redirectURL + } + + // Get request scheme + scheme := "http" + if proto := c.Request.Header.Get("X-Forwarded-Proto"); proto != "" { + scheme = strings.ToLower(strings.TrimSpace(proto)) + } else if c.Request.TLS != nil { + scheme = "https" + } + + // Rewrite host and scheme + parsed.Host = c.Request.Host + parsed.Scheme = scheme + + return parsed.String() +} + +// isHydraInternalPath checks if the path is a Hydra/new-api internal OAuth path +// Only matches specific known internal paths, not client redirect_uris +func isHydraInternalPath(path string) bool { + // Exact internal paths that need rewriting + internalPaths := []string{ + // Hydra public endpoints + "/oauth2/auth", + "/oauth2/token", + "/oauth2/revoke", + "/oauth2/sessions", + "/oauth2/fallbacks/login", + "/oauth2/fallbacks/consent", + "/oauth2/fallbacks/logout", + // new-api OAuth pages + "/oauth/login", + "/oauth/consent", + "/oauth/logout", + } + for _, p := range internalPaths { + if path == p || strings.HasPrefix(path, p+"?") || strings.HasPrefix(path, p+"/") { + return true + } + } + return false +} + // OAuthProviderController handles Hydra login/consent/logout flows type OAuthProviderController struct { hydra hydra.Provider @@ -58,6 +121,7 @@ func (ctrl *OAuthProviderController) OAuthLogin(c *gin.Context) { // Get login request from Hydra loginReq, err := ctrl.hydra.GetLoginRequest(c.Request.Context(), challenge) if err != nil { + common.SysError("OAuth login: failed to get login request: " + err.Error()) c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": "invalid login challenge: " + err.Error(), @@ -65,47 +129,75 @@ func (ctrl *OAuthProviderController) OAuthLogin(c *gin.Context) { return } - // If skip is true, the user has already authenticated with Hydra - // We can accept the login request immediately + // Check if user is already logged in via new-api session + session := sessions.Default(c) + userID := session.Get("id") + + // If skip is true, Hydra thinks the user is already authenticated + // But we need to verify new-api session is also valid AND matches Hydra's subject if loginReq.GetSkip() { - redirect, err := ctrl.hydra.AcceptLogin(c.Request.Context(), challenge, loginReq.GetSubject(), false, 0) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "success": false, - "message": "failed to accept login: " + err.Error(), - }) - return + if userID != nil { + sessionUserID, ok := userID.(int) + if !ok { + // Invalid session data, clear and show login page + session.Clear() + _ = session.Save() + } else { + sessionSubject := strconv.Itoa(sessionUserID) + // Verify Hydra's subject matches new-api session to prevent identity confusion + if loginReq.GetSubject() == sessionSubject { + // Both Hydra and new-api agree on the same user, accept immediately + redirect, err := ctrl.hydra.AcceptLogin(c.Request.Context(), challenge, sessionSubject, false, 0) + if err != nil { + common.SysError("OAuth login: failed to accept login (skip): " + err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to accept login", + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "redirect_to": rewriteOAuthRedirect(c, redirect.RedirectTo), + }, + }) + return + } + // Subject mismatch: Hydra and new-api have different users + // This could happen if logout didn't properly revoke Hydra sessions + // Don't skip, show login page to re-authenticate + } } - // Return JSON for frontend to handle redirect (avoid CORS issues with HTTP redirects) - c.JSON(http.StatusOK, gin.H{ - "success": true, - "data": gin.H{ - "redirect_to": redirect.RedirectTo, - }, - }) - return } // Check if user is already logged in via session - session := sessions.Default(c) - if userID := session.Get("id"); userID != nil { - subject := strconv.Itoa(userID.(int)) - redirect, err := ctrl.hydra.AcceptLogin(c.Request.Context(), challenge, subject, true, common.HydraLoginRememberFor) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "success": false, - "message": "failed to accept login: " + err.Error(), + if userID != nil { + sessionUserID, ok := userID.(int) + if !ok { + // Invalid session data, clear and continue to login page + session.Clear() + _ = session.Save() + } else { + subject := strconv.Itoa(sessionUserID) + redirect, err := ctrl.hydra.AcceptLogin(c.Request.Context(), challenge, subject, true, common.HydraLoginRememberFor) + if err != nil { + common.SysError("OAuth login: failed to accept login (session): " + err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "failed to accept login", + }) + return + } + // Return JSON for frontend to handle redirect (avoid CORS issues with HTTP redirects) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "redirect_to": rewriteOAuthRedirect(c, redirect.RedirectTo), + }, }) return } - // Return JSON for frontend to handle redirect (avoid CORS issues with HTTP redirects) - c.JSON(http.StatusOK, gin.H{ - "success": true, - "data": gin.H{ - "redirect_to": redirect.RedirectTo, - }, - }) - return } // Return login page info for frontend to render @@ -162,9 +254,11 @@ func (ctrl *OAuthProviderController) OAuthLoginSubmit(c *gin.Context) { Password: req.Password, } if err := user.ValidateAndFill(); err != nil { + common.SysLog("OAuth login: user validation failed for " + req.Username + ": " + err.Error()) // Reject login with error redirect, rejectErr := ctrl.hydra.RejectLogin(c.Request.Context(), req.Challenge, "access_denied", err.Error()) if rejectErr != nil { + common.SysError("OAuth login: failed to reject login: " + rejectErr.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": "failed to reject login: " + rejectErr.Error(), @@ -174,7 +268,7 @@ func (ctrl *OAuthProviderController) OAuthLoginSubmit(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": false, "message": err.Error(), - "redirect_to": redirect.RedirectTo, + "redirect_to": rewriteOAuthRedirect(c, redirect.RedirectTo), }) return } @@ -186,6 +280,7 @@ func (ctrl *OAuthProviderController) OAuthLoginSubmit(c *gin.Context) { session.Set("oauth_pending_user_id", user.Id) session.Set("oauth_pending_challenge", req.Challenge) if err := session.Save(); err != nil { + common.SysError("OAuth login: failed to save 2FA pending session: " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": "failed to save session", @@ -204,6 +299,7 @@ func (ctrl *OAuthProviderController) OAuthLoginSubmit(c *gin.Context) { } if err := setOAuthSession(c, &user); err != nil { + common.SysError("OAuth login: failed to save session: " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": "failed to save session", @@ -215,6 +311,7 @@ func (ctrl *OAuthProviderController) OAuthLoginSubmit(c *gin.Context) { subject := strconv.Itoa(user.Id) redirect, err := ctrl.hydra.AcceptLogin(c.Request.Context(), req.Challenge, subject, true, common.HydraLoginRememberFor) if err != nil { + common.SysError("OAuth login: failed to accept login: " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": "failed to accept login: " + err.Error(), @@ -225,7 +322,15 @@ func (ctrl *OAuthProviderController) OAuthLoginSubmit(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ - "redirect_to": redirect.RedirectTo, + "redirect_to": rewriteOAuthRedirect(c, redirect.RedirectTo), + "user": gin.H{ + "id": user.Id, + "username": user.Username, + "display_name": user.DisplayName, + "role": user.Role, + "status": user.Status, + "group": user.Group, + }, }, }) } @@ -244,10 +349,10 @@ func (ctrl *OAuthProviderController) OAuthLogin2FA(c *gin.Context) { } session := sessions.Default(c) - userID := session.Get("oauth_pending_user_id") - challenge := session.Get("oauth_pending_challenge") + userIDVal := session.Get("oauth_pending_user_id") + challengeVal := session.Get("oauth_pending_challenge") - if userID == nil || challenge == nil { + if userIDVal == nil || challengeVal == nil { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": "no pending 2FA verification", @@ -255,9 +360,28 @@ func (ctrl *OAuthProviderController) OAuthLogin2FA(c *gin.Context) { return } + // Safe type assertions + userID, ok := userIDVal.(int) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid session data", + }) + return + } + challenge, ok := challengeVal.(string) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid session data", + }) + return + } + // Verify 2FA code using existing logic - twoFA, err := model.GetTwoFAByUserId(userID.(int)) + twoFA, err := model.GetTwoFAByUserId(userID) if err != nil || twoFA == nil { + common.SysError(fmt.Sprintf("OAuth 2FA: failed to get 2FA config for user %d: %v", userID, err)) c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": "2FA not configured", @@ -278,7 +402,7 @@ func (ctrl *OAuthProviderController) OAuthLogin2FA(c *gin.Context) { valid := common.ValidateTOTPCode(twoFA.Secret, req.Code) if !valid { // Try backup code - valid = model.UseBackupCode(userID.(int), req.Code) + valid = model.UseBackupCode(userID, req.Code) } if !valid { @@ -294,8 +418,9 @@ func (ctrl *OAuthProviderController) OAuthLogin2FA(c *gin.Context) { session.Delete("oauth_pending_user_id") session.Delete("oauth_pending_challenge") - user, err := model.GetUserById(userID.(int), false) + user, err := model.GetUserById(userID, false) if err != nil { + common.SysError(fmt.Sprintf("OAuth 2FA: failed to load user %d: %s", userID, err.Error())) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": "failed to load user", @@ -304,6 +429,7 @@ func (ctrl *OAuthProviderController) OAuthLogin2FA(c *gin.Context) { } if err := setOAuthSession(c, user); err != nil { + common.SysError("OAuth 2FA: failed to save session: " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": "failed to save session", @@ -312,12 +438,13 @@ func (ctrl *OAuthProviderController) OAuthLogin2FA(c *gin.Context) { } // Accept login - subject := strconv.Itoa(userID.(int)) - redirect, err := ctrl.hydra.AcceptLogin(c.Request.Context(), challenge.(string), subject, true, common.HydraLoginRememberFor) + subject := strconv.Itoa(userID) + redirect, err := ctrl.hydra.AcceptLogin(c.Request.Context(), challenge, subject, true, common.HydraLoginRememberFor) if err != nil { + common.SysError("OAuth 2FA: failed to accept login: " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, - "message": "failed to accept login: " + err.Error(), + "message": "failed to accept login", }) return } @@ -325,7 +452,15 @@ func (ctrl *OAuthProviderController) OAuthLogin2FA(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ - "redirect_to": redirect.RedirectTo, + "redirect_to": rewriteOAuthRedirect(c, redirect.RedirectTo), + "user": gin.H{ + "id": user.Id, + "username": user.Username, + "display_name": user.DisplayName, + "role": user.Role, + "status": user.Status, + "group": user.Group, + }, }, }) } @@ -343,6 +478,7 @@ func (ctrl *OAuthProviderController) OAuthConsent(c *gin.Context) { consentReq, err := ctrl.hydra.GetConsentRequest(c.Request.Context(), challenge) if err != nil { + common.SysError("OAuth consent: failed to get consent request: " + err.Error()) c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": "invalid consent challenge: " + err.Error(), @@ -355,6 +491,7 @@ func (ctrl *OAuthProviderController) OAuthConsent(c *gin.Context) { if subject == "" || session.Get("id") == nil || fmt.Sprint(session.Get("id")) != subject { redirect, err := ctrl.hydra.RejectConsent(c.Request.Context(), challenge, "login_required", "user login required") if err != nil { + common.SysError("OAuth consent: failed to reject consent (no session): " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": "failed to reject consent: " + err.Error(), @@ -364,7 +501,7 @@ func (ctrl *OAuthProviderController) OAuthConsent(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ - "redirect_to": redirect.RedirectTo, + "redirect_to": rewriteOAuthRedirect(c, redirect.RedirectTo), }, }) return @@ -381,6 +518,7 @@ func (ctrl *OAuthProviderController) OAuthConsent(c *gin.Context) { nil, ) if err != nil { + common.SysError("OAuth consent: failed to accept consent (skip): " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": "failed to accept consent: " + err.Error(), @@ -391,7 +529,7 @@ func (ctrl *OAuthProviderController) OAuthConsent(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ - "redirect_to": redirect.RedirectTo, + "redirect_to": rewriteOAuthRedirect(c, redirect.RedirectTo), }, }) return @@ -409,6 +547,7 @@ func (ctrl *OAuthProviderController) OAuthConsent(c *gin.Context) { nil, ) if err != nil { + common.SysError("OAuth consent: failed to accept consent (trusted client): " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": "failed to accept consent: " + err.Error(), @@ -419,7 +558,7 @@ func (ctrl *OAuthProviderController) OAuthConsent(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ - "redirect_to": redirect.RedirectTo, + "redirect_to": rewriteOAuthRedirect(c, redirect.RedirectTo), }, }) return @@ -466,6 +605,7 @@ func (ctrl *OAuthProviderController) OAuthConsentSubmit(c *gin.Context) { consentReq, err := ctrl.hydra.GetConsentRequest(c.Request.Context(), req.Challenge) if err != nil { + common.SysError("OAuth consent submit: failed to get consent request: " + err.Error()) c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": "invalid consent challenge: " + err.Error(), @@ -478,6 +618,7 @@ func (ctrl *OAuthProviderController) OAuthConsentSubmit(c *gin.Context) { if subject == "" || session.Get("id") == nil || fmt.Sprint(session.Get("id")) != subject { reject, err := ctrl.hydra.RejectConsent(c.Request.Context(), req.Challenge, "login_required", "user login required") if err != nil { + common.SysError("OAuth consent submit: failed to reject consent (no session): " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": "failed to reject consent: " + err.Error(), @@ -487,7 +628,7 @@ func (ctrl *OAuthProviderController) OAuthConsentSubmit(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ - "redirect_to": reject.RedirectTo, + "redirect_to": rewriteOAuthRedirect(c, reject.RedirectTo), }, }) return @@ -507,6 +648,7 @@ func (ctrl *OAuthProviderController) OAuthConsentSubmit(c *gin.Context) { nil, ) if err != nil { + common.SysError("OAuth consent submit: failed to accept consent: " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": "failed to accept consent: " + err.Error(), @@ -517,7 +659,7 @@ func (ctrl *OAuthProviderController) OAuthConsentSubmit(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ - "redirect_to": redirect.RedirectTo, + "redirect_to": rewriteOAuthRedirect(c, redirect.RedirectTo), }, }) } @@ -545,6 +687,7 @@ func (ctrl *OAuthProviderController) OAuthConsentReject(c *gin.Context) { consentReq, err := ctrl.hydra.GetConsentRequest(c.Request.Context(), req.Challenge) if err != nil { + common.SysError("OAuth consent reject: failed to get consent request: " + err.Error()) c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": "invalid consent challenge: " + err.Error(), @@ -557,6 +700,7 @@ func (ctrl *OAuthProviderController) OAuthConsentReject(c *gin.Context) { if subject == "" || session.Get("id") == nil || fmt.Sprint(session.Get("id")) != subject { reject, err := ctrl.hydra.RejectConsent(c.Request.Context(), req.Challenge, "login_required", "user login required") if err != nil { + common.SysError("OAuth consent reject: failed to reject consent (no session): " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": "failed to reject consent: " + err.Error(), @@ -566,7 +710,7 @@ func (ctrl *OAuthProviderController) OAuthConsentReject(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ - "redirect_to": reject.RedirectTo, + "redirect_to": rewriteOAuthRedirect(c, reject.RedirectTo), }, }) return @@ -574,6 +718,7 @@ func (ctrl *OAuthProviderController) OAuthConsentReject(c *gin.Context) { redirect, err := ctrl.hydra.RejectConsent(c.Request.Context(), req.Challenge, "access_denied", "user denied consent") if err != nil { + common.SysError("OAuth consent reject: failed to reject consent: " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": "failed to reject consent: " + err.Error(), @@ -584,7 +729,7 @@ func (ctrl *OAuthProviderController) OAuthConsentReject(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ - "redirect_to": redirect.RedirectTo, + "redirect_to": rewriteOAuthRedirect(c, redirect.RedirectTo), }, }) } @@ -603,6 +748,7 @@ func (ctrl *OAuthProviderController) OAuthLogout(c *gin.Context) { // Validate the logout challenge exists _, err := ctrl.hydra.GetLogoutRequest(c.Request.Context(), challenge) if err != nil { + common.SysError("OAuth logout: failed to get logout request: " + err.Error()) c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": "invalid logout challenge: " + err.Error(), @@ -614,6 +760,7 @@ func (ctrl *OAuthProviderController) OAuthLogout(c *gin.Context) { // Could show a confirmation page if needed redirect, err := ctrl.hydra.AcceptLogout(c.Request.Context(), challenge) if err != nil { + common.SysError("OAuth logout: failed to accept logout: " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": "failed to accept logout: " + err.Error(), @@ -630,7 +777,7 @@ func (ctrl *OAuthProviderController) OAuthLogout(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ - "redirect_to": redirect.RedirectTo, + "redirect_to": rewriteOAuthRedirect(c, redirect.RedirectTo), }, }) } @@ -712,6 +859,7 @@ func (ctrl *OAuthProviderController) OAuthRegisterClient(c *gin.Context) { req.TokenEndpointAuthMethod, ) if err != nil { + common.SysError("OAuth register client: failed to create client in Hydra: " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": "failed to create client: " + err.Error(), @@ -743,6 +891,7 @@ func (ctrl *OAuthProviderController) OAuthRegisterClient(c *gin.Context) { func (ctrl *OAuthProviderController) OAuthListClients(c *gin.Context) { clients, err := ctrl.hydra.ListOAuth2Clients(c.Request.Context()) if err != nil { + common.SysError("OAuth list clients: failed to list clients: " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": "failed to list clients: " + err.Error(), @@ -769,6 +918,7 @@ func (ctrl *OAuthProviderController) OAuthDeleteClient(c *gin.Context) { // Delete from Hydra if err := ctrl.hydra.DeleteOAuth2Client(c.Request.Context(), clientID); err != nil { + common.SysError("OAuth delete client: failed to delete client " + clientID + ": " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": "failed to delete client: " + err.Error(), @@ -843,6 +993,7 @@ func (ctrl *OAuthProviderController) OAuthUpdateClient(c *gin.Context) { req.TokenEndpointAuthMethod, ) if err != nil { + common.SysError("OAuth update client: failed to update client " + clientID + ": " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": "failed to update client: " + err.Error(), diff --git a/controller/user.go b/controller/user.go index eda4f7f12e63..f2a017b36c2f 100644 --- a/controller/user.go +++ b/controller/user.go @@ -1,6 +1,7 @@ package controller import ( + "context" "encoding/json" "fmt" "net/http" @@ -14,6 +15,7 @@ import ( "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/service/hydra" "github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/constant" @@ -127,6 +129,11 @@ func setupLogin(user *model.User, c *gin.Context) { func Logout(c *gin.Context) { session := sessions.Default(c) + + // Get user ID before clearing session (for Hydra logout) + userID := session.Get("id") + + // Clear new-api session session.Clear() err := session.Save() if err != nil { @@ -136,6 +143,14 @@ func Logout(c *gin.Context) { }) return } + + // Also revoke Hydra login sessions if Hydra is enabled + if common.HydraEnabled && common.HydraAdminURL != "" && userID != nil { + hydraService := hydra.NewService(common.HydraAdminURL) + subject := strconv.Itoa(userID.(int)) + _ = hydraService.RevokeLoginSessions(context.Background(), subject) + } + c.JSON(http.StatusOK, gin.H{ "message": "", "success": true, diff --git a/main.go b/main.go index 481d0a6002eb..c64311ef0948 100644 --- a/main.go +++ b/main.go @@ -146,7 +146,7 @@ func main() { MaxAge: 2592000, // 30 days HttpOnly: true, Secure: false, - SameSite: http.SameSiteStrictMode, + SameSite: http.SameSiteLaxMode, // Lax allows OAuth redirect flows while preventing CSRF }) server.Use(sessions.Sessions("session", store)) diff --git a/router/hydra-proxy.go b/router/hydra-proxy.go index 6f40bd3f15aa..7726bdf220c2 100644 --- a/router/hydra-proxy.go +++ b/router/hydra-proxy.go @@ -1,6 +1,7 @@ package router import ( + "fmt" "net/http" "net/http/httputil" "net/url" @@ -11,6 +12,23 @@ import ( "github.com/gin-gonic/gin" ) +// Hydra paths -> target paths (for redirect rewriting) +var oauthPathMapping = map[string]string{ + // Hydra fallback paths -> new-api OAuth paths + "/oauth2/fallbacks/login": "/oauth/login", + "/oauth2/fallbacks/consent": "/oauth/consent", + "/oauth2/fallbacks/logout": "/oauth/logout", + // Configured OAuth paths (keep same path, just rewrite host) + "/oauth/login": "/oauth/login", + "/oauth/consent": "/oauth/consent", + "/oauth/logout": "/oauth/logout", + // Hydra internal paths (keep same path, just rewrite host) + "/oauth2/auth": "/oauth2/auth", + "/oauth2/token": "/oauth2/token", + "/oauth2/revoke": "/oauth2/revoke", + "/oauth2/sessions": "/oauth2/sessions", +} + // SetHydraPublicProxyRouter proxies Hydra public endpoints through new-api. func SetHydraPublicProxyRouter(router *gin.Engine) { if !common.HydraEnabled { @@ -28,16 +46,109 @@ func SetHydraPublicProxyRouter(router *gin.Engine) { return } - proxy := httputil.NewSingleHostReverseProxy(target) - proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, proxyErr error) { - common.SysLog("hydra public proxy error: " + proxyErr.Error()) - http.Error(w, "bad gateway", http.StatusBadGateway) - } - router.Any("/oauth2/*any", func(c *gin.Context) { + proxy := createHydraProxy(target, c.Request) proxy.ServeHTTP(c.Writer, c.Request) }) router.Any("/.well-known/*any", func(c *gin.Context) { + proxy := createHydraProxy(target, c.Request) proxy.ServeHTTP(c.Writer, c.Request) }) } + +// createHydraProxy creates a reverse proxy with automatic URL rewriting for OAuth redirects. +func createHydraProxy(target *url.URL, originalReq *http.Request) *httputil.ReverseProxy { + requestHost := originalReq.Host + requestScheme := getRequestScheme(originalReq) + + proxy := httputil.NewSingleHostReverseProxy(target) + defaultDirector := proxy.Director + proxy.Director = func(req *http.Request) { + defaultDirector(req) + if requestHost != "" { + req.Header.Set("X-Forwarded-Host", requestHost) + } + if requestScheme != "" { + req.Header.Set("X-Forwarded-Proto", requestScheme) + } + } + + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, proxyErr error) { + common.SysLog(fmt.Sprintf("hydra proxy error: %s %s -> %v", r.Method, r.URL.String(), proxyErr)) + http.Error(w, "bad gateway", http.StatusBadGateway) + } + + proxy.ModifyResponse = func(resp *http.Response) error { + return rewriteOAuthRedirect(resp, requestHost, requestScheme) + } + + return proxy +} + +func getRequestScheme(req *http.Request) string { + if proto := req.Header.Get("X-Forwarded-Proto"); proto != "" { + return strings.ToLower(strings.TrimSpace(proto)) + } + if req.TLS != nil { + return "https" + } + return "http" +} + +// rewriteOAuthRedirect rewrites OAuth redirect URLs to use the request's host/scheme. +// Also maps Hydra fallback paths to new-api OAuth paths. +func rewriteOAuthRedirect(resp *http.Response, requestHost, requestScheme string) error { + if resp.StatusCode < 300 || resp.StatusCode >= 400 { + return nil + } + + location := resp.Header.Get("Location") + if location == "" { + return nil + } + + locURL, err := url.Parse(location) + if err != nil { + return nil + } + + // Check if this is an OAuth path that needs rewriting + newPath := mapOAuthPath(locURL.Path) + if newPath == "" { + return nil + } + + oldLocation := location + + // Rewrite path (e.g., /oauth2/fallbacks/login -> /oauth/login) + locURL.Path = newPath + strings.TrimPrefix(locURL.Path, extractBasePath(locURL.Path)) + + // Rewrite host and scheme to match the original request + locURL.Host = requestHost + locURL.Scheme = requestScheme + + resp.Header.Set("Location", locURL.String()) + common.SysLog(fmt.Sprintf("hydra rewrite: %s -> %s", oldLocation, locURL.String())) + + return nil +} + +// mapOAuthPath returns the new-api OAuth path for a given path, or empty string if not an OAuth path. +func mapOAuthPath(path string) string { + for prefix, newPath := range oauthPathMapping { + if strings.HasPrefix(path, prefix) { + return newPath + } + } + return "" +} + +// extractBasePath extracts the base OAuth path from a full path. +func extractBasePath(path string) string { + for prefix := range oauthPathMapping { + if strings.HasPrefix(path, prefix) { + return prefix + } + } + return "" +} diff --git a/service/hydra/interface.go b/service/hydra/interface.go index c9e47022116a..94e8126de675 100644 --- a/service/hydra/interface.go +++ b/service/hydra/interface.go @@ -24,6 +24,9 @@ type Provider interface { AcceptLogout(ctx context.Context, challenge string) (*client.OAuth2RedirectTo, error) RejectLogout(ctx context.Context, challenge string) error + // Session Management + RevokeLoginSessions(ctx context.Context, subject string) error + // Token Introspection IntrospectToken(ctx context.Context, token string, scope string) (*client.IntrospectedOAuth2Token, error) diff --git a/service/hydra/mock.go b/service/hydra/mock.go index 075ba9a15da4..eec7fbee1c37 100644 --- a/service/hydra/mock.go +++ b/service/hydra/mock.go @@ -28,20 +28,24 @@ type MockProvider struct { RejectedLogouts map[string]bool // Error injection - GetLoginRequestErr error - AcceptLoginErr error - RejectLoginErr error - GetConsentRequestErr error - AcceptConsentErr error - RejectConsentErr error - GetLogoutRequestErr error - AcceptLogoutErr error - RejectLogoutErr error - IntrospectTokenErr error - CreateOAuth2ClientErr error - UpdateOAuth2ClientErr error - ListOAuth2ClientsErr error - DeleteOAuth2ClientErr error + GetLoginRequestErr error + AcceptLoginErr error + RejectLoginErr error + GetConsentRequestErr error + AcceptConsentErr error + RejectConsentErr error + GetLogoutRequestErr error + AcceptLogoutErr error + RejectLogoutErr error + RevokeLoginSessionsErr error + IntrospectTokenErr error + CreateOAuth2ClientErr error + UpdateOAuth2ClientErr error + ListOAuth2ClientsErr error + DeleteOAuth2ClientErr error + + // Track revoked sessions + RevokedSessions map[string]bool // Default redirect URL RedirectURL string @@ -61,6 +65,7 @@ func NewMockProvider() *MockProvider { RejectedLogins: make(map[string]string), RejectedConsents: make(map[string]string), RejectedLogouts: make(map[string]bool), + RevokedSessions: make(map[string]bool), RedirectURL: "https://example.com/callback", } } @@ -256,6 +261,19 @@ func (m *MockProvider) RejectLogout(ctx context.Context, challenge string) error return nil } +// RevokeLoginSessions implements Provider +func (m *MockProvider) RevokeLoginSessions(ctx context.Context, subject string) error { + if m.RevokeLoginSessionsErr != nil { + return m.RevokeLoginSessionsErr + } + + m.mu.Lock() + defer m.mu.Unlock() + + m.RevokedSessions[subject] = true + return nil +} + // SetIntrospectedToken sets a mock introspection result for testing func (m *MockProvider) SetIntrospectedToken(token string, active bool, subject string, scope string, clientID string) { m.mu.Lock() @@ -387,6 +405,7 @@ func (m *MockProvider) Reset() { m.RejectedLogins = make(map[string]string) m.RejectedConsents = make(map[string]string) m.RejectedLogouts = make(map[string]bool) + m.RevokedSessions = make(map[string]bool) m.GetLoginRequestErr = nil m.AcceptLoginErr = nil @@ -397,6 +416,7 @@ func (m *MockProvider) Reset() { m.GetLogoutRequestErr = nil m.AcceptLogoutErr = nil m.RejectLogoutErr = nil + m.RevokeLoginSessionsErr = nil m.IntrospectTokenErr = nil m.CreateOAuth2ClientErr = nil m.UpdateOAuth2ClientErr = nil diff --git a/service/hydra/service.go b/service/hydra/service.go index aed698c3d11a..dd3b262f943b 100644 --- a/service/hydra/service.go +++ b/service/hydra/service.go @@ -118,6 +118,14 @@ func (s *Service) RejectLogout(ctx context.Context, challenge string) error { return err } +// RevokeLoginSessions revokes all login sessions for a subject (user ID) +func (s *Service) RevokeLoginSessions(ctx context.Context, subject string) error { + _, err := s.admin.OAuth2API.RevokeOAuth2LoginSessions(ctx). + Subject(subject). + Execute() + return err +} + // IntrospectToken validates a token and returns its metadata func (s *Service) IntrospectToken(ctx context.Context, token string, scope string) (*client.IntrospectedOAuth2Token, error) { req := s.admin.OAuth2API.IntrospectOAuth2Token(ctx).Token(token) diff --git a/web/src/pages/OAuth/OAuthLogin.jsx b/web/src/pages/OAuth/OAuthLogin.jsx index 489f1077c0ec..e5e575384b45 100644 --- a/web/src/pages/OAuth/OAuthLogin.jsx +++ b/web/src/pages/OAuth/OAuthLogin.jsx @@ -109,6 +109,9 @@ const OAuthLogin = () => { if (data.require_2fa) { setRequire2FA(true); } else if (data.redirect_to) { + if (data.user) { + localStorage.setItem('user', JSON.stringify(data.user)); + } window.location.href = data.redirect_to; } } else { @@ -139,6 +142,9 @@ const OAuthLogin = () => { const { success, message, data } = res.data; if (success && data.redirect_to) { + if (data.user) { + localStorage.setItem('user', JSON.stringify(data.user)); + } window.location.href = data.redirect_to; } else { showError(message || t('验证失败')); From 7abac3762de3157bab647f1e0c0de3d70287e650 Mon Sep 17 00:00:00 2001 From: suyao Date: Thu, 29 Jan 2026 16:31:08 +0800 Subject: [PATCH 29/34] feat(oauth): optimize registration flow for OAuth users - Add registration link on OAuth login page with login_challenge param - Handle login_challenge in RegisterForm to redirect back to OAuth flow - Auto-create default token on consent when tokens:write scope granted Co-Authored-By: Claude Opus 4.5 --- controller/oauth_provider.go | 25 ++++++++++++++++++++++++ web/src/components/auth/RegisterForm.jsx | 17 +++++++++++----- web/src/pages/OAuth/OAuthLogin.jsx | 14 ++++++++++++- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/controller/oauth_provider.go b/controller/oauth_provider.go index 1544f4629855..c925121ed6c3 100644 --- a/controller/oauth_provider.go +++ b/controller/oauth_provider.go @@ -639,6 +639,31 @@ func (ctrl *OAuthProviderController) OAuthConsentSubmit(c *gin.Context) { rememberFor = common.HydraConsentRememberFor } + // Auto-create default token if scope includes tokens:write and user has no tokens + if slices.Contains(req.GrantScope, "tokens:write") { + userId, _ := strconv.Atoi(subject) + tokens, _ := model.GetAllUserTokens(userId, 0, 1) + if len(tokens) == 0 { + key, err := common.GenerateKey() + if err == nil { + token := model.Token{ + UserId: userId, + Name: "默认令牌", + Key: key, + CreatedTime: common.GetTimestamp(), + AccessedTime: common.GetTimestamp(), + ExpiredTime: -1, + UnlimitedQuota: true, + } + if insertErr := token.Insert(); insertErr == nil { + common.SysLog(fmt.Sprintf("OAuth consent: auto-created default token for user %d", userId)) + } else { + common.SysError(fmt.Sprintf("OAuth consent: failed to auto-create default token for user %d: %s", userId, insertErr.Error())) + } + } + } + } + redirect, err := ctrl.hydra.AcceptConsent( c.Request.Context(), req.Challenge, diff --git a/web/src/components/auth/RegisterForm.jsx b/web/src/components/auth/RegisterForm.jsx index 29eca627ee6b..7e3405ad6f4f 100644 --- a/web/src/components/auth/RegisterForm.jsx +++ b/web/src/components/auth/RegisterForm.jsx @@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import React, { useContext, useEffect, useRef, useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { API, getLogo, @@ -55,6 +55,8 @@ import { useTranslation } from 'react-i18next'; const RegisterForm = () => { let navigate = useNavigate(); const { t } = useTranslation(); + const [searchParams] = useSearchParams(); + const loginChallenge = searchParams.get('login_challenge'); const [inputs, setInputs] = useState({ username: '', password: '', @@ -204,8 +206,13 @@ const RegisterForm = () => { ); const { success, message } = res.data; if (success) { - navigate('/login'); - showSuccess('注册成功!'); + if (loginChallenge) { + navigate(`/oauth/login?login_challenge=${loginChallenge}`); + showSuccess(t('注册成功!请登录以继续')); + } else { + navigate('/login'); + showSuccess(t('注册成功!')); + } } else { showError(message); } @@ -440,7 +447,7 @@ const RegisterForm = () => { {t('已有账户?')}{' '} {t('登录')} @@ -618,7 +625,7 @@ const RegisterForm = () => { {t('已有账户?')}{' '} {t('登录')} diff --git a/web/src/pages/OAuth/OAuthLogin.jsx b/web/src/pages/OAuth/OAuthLogin.jsx index e5e575384b45..e3c6ed9f3948 100644 --- a/web/src/pages/OAuth/OAuthLogin.jsx +++ b/web/src/pages/OAuth/OAuthLogin.jsx @@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import React, { useEffect, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { Link, useSearchParams } from 'react-router-dom'; import { Button, Card, Form, Spin, Tag } from '@douyinfe/semi-ui'; import { IconLock, IconMail } from '@douyinfe/semi-icons'; import Title from '@douyinfe/semi-ui/lib/es/typography/title'; @@ -322,6 +322,18 @@ const OAuthLogin = () => {
)} + +
+ + {t('没有账户?')}{' '} + + {t('注册')} + + +
From 9602ad97adb2a370db4de07793314c0fcd04137b Mon Sep 17 00:00:00 2001 From: suyao Date: Thu, 29 Jan 2026 16:51:25 +0800 Subject: [PATCH 30/34] fix(oauth): validate tokens:write scope against requested scope Ensure tokens:write is both requested by client AND granted by user before auto-creating default token, preventing scope escalation attacks. Co-Authored-By: Claude Opus 4.5 --- controller/oauth_provider.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/controller/oauth_provider.go b/controller/oauth_provider.go index c925121ed6c3..ff941154e4ec 100644 --- a/controller/oauth_provider.go +++ b/controller/oauth_provider.go @@ -640,7 +640,9 @@ func (ctrl *OAuthProviderController) OAuthConsentSubmit(c *gin.Context) { } // Auto-create default token if scope includes tokens:write and user has no tokens - if slices.Contains(req.GrantScope, "tokens:write") { + // Only create if tokens:write is both requested by client AND granted by user + requestedScope := consentReq.GetRequestedScope() + if slices.Contains(requestedScope, "tokens:write") && slices.Contains(req.GrantScope, "tokens:write") { userId, _ := strconv.Atoi(subject) tokens, _ := model.GetAllUserTokens(userId, 0, 1) if len(tokens) == 0 { From aa7f558838dbdf91b70a071700c5a25b9c4e6a1c Mon Sep 17 00:00:00 2001 From: suyao Date: Fri, 30 Jan 2026 16:36:55 +0800 Subject: [PATCH 31/34] feat(oauth): redirect to register page first in OAuth flow - Change Hydra proxy to redirect /oauth/login to /register - Make AuthRedirect OAuth-aware to not redirect when login_challenge present - Add OAuth session check in RegisterForm to auto-continue for logged-in users This improves the OAuth UX by showing registration first for new users, while existing users can click to login or are auto-redirected if already logged in. Co-Authored-By: Claude Opus 4.5 --- router/hydra-proxy.go | 7 ++++--- web/src/components/auth/RegisterForm.jsx | 24 +++++++++++++++++++++++- web/src/helpers/auth.jsx | 7 +++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/router/hydra-proxy.go b/router/hydra-proxy.go index 7726bdf220c2..43b6a791ab7e 100644 --- a/router/hydra-proxy.go +++ b/router/hydra-proxy.go @@ -15,11 +15,12 @@ import ( // Hydra paths -> target paths (for redirect rewriting) var oauthPathMapping = map[string]string{ // Hydra fallback paths -> new-api OAuth paths - "/oauth2/fallbacks/login": "/oauth/login", + // Redirect to register first for new users; existing users can click to login + "/oauth2/fallbacks/login": "/register", "/oauth2/fallbacks/consent": "/oauth/consent", "/oauth2/fallbacks/logout": "/oauth/logout", - // Configured OAuth paths (keep same path, just rewrite host) - "/oauth/login": "/oauth/login", + // Configured OAuth paths - redirect login to register page + "/oauth/login": "/register", "/oauth/consent": "/oauth/consent", "/oauth/logout": "/oauth/logout", // Hydra internal paths (keep same path, just rewrite host) diff --git a/web/src/components/auth/RegisterForm.jsx b/web/src/components/auth/RegisterForm.jsx index 7e3405ad6f4f..138276629f1e 100644 --- a/web/src/components/auth/RegisterForm.jsx +++ b/web/src/components/auth/RegisterForm.jsx @@ -114,12 +114,34 @@ const RegisterForm = () => { setTurnstileEnabled(true); setTurnstileSiteKey(status.turnstile_site_key); } - + // 从 status 获取用户协议和隐私政策的启用状态 setHasUserAgreement(status.user_agreement_enabled || false); setHasPrivacyPolicy(status.privacy_policy_enabled || false); }, [status]); + // Check if user is already logged in during OAuth flow + useEffect(() => { + if (!loginChallenge) return; + + const checkOAuthSession = async () => { + try { + const res = await API.get(`/api/oauth/login?login_challenge=${loginChallenge}`); + const { success, data } = res.data; + + if (success && data?.redirect_to) { + // User is already logged in, redirect to continue OAuth flow + window.location.href = data.redirect_to; + } + } catch (err) { + // Ignore errors, just show registration form + console.error('OAuth session check failed:', err); + } + }; + + checkOAuthSession(); + }, [loginChallenge]); + useEffect(() => { let countdownInterval = null; if (disableButton && countdown > 0) { diff --git a/web/src/helpers/auth.jsx b/web/src/helpers/auth.jsx index d841afed7842..d4476a11cd49 100644 --- a/web/src/helpers/auth.jsx +++ b/web/src/helpers/auth.jsx @@ -35,6 +35,13 @@ export function authHeader() { export const AuthRedirect = ({ children }) => { const user = localStorage.getItem('user'); + // Don't redirect if in OAuth flow (login_challenge present) + const searchParams = new URLSearchParams(window.location.search); + const loginChallenge = searchParams.get('login_challenge'); + if (loginChallenge) { + return children; + } + if (user) { return ; } From 7b872232239b164f468fd06bd1aa0ef02ed6bedc Mon Sep 17 00:00:00 2001 From: zhaolion Date: Mon, 2 Feb 2026 17:49:48 +0800 Subject: [PATCH 32/34] refactor(panic): enhance panic recovery with detailed logging - Add comprehensive request context (method, path, query, client IP, request ID) - Include request body with truncation for large payloads - Add full stack trace for debugging - Use common.GetRequestBody() helper for consistent body retrieval --- main.go | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index cfcca19d01f6..1be96ecb9667 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "os" + "runtime/debug" "strconv" "strings" "time" @@ -132,7 +133,40 @@ func main() { // Initialize HTTP server server := gin.New() server.Use(gin.CustomRecovery(func(c *gin.Context, err any) { - common.SysLog(fmt.Sprintf("panic detected: %v", err)) + // 获取请求信息 + method := c.Request.Method + path := c.Request.URL.Path + query := c.Request.URL.RawQuery + clientIP := c.ClientIP() + requestId := c.GetString("X-Request-Id") + + // 获取 request body(如果存在缓存,key 定义在 common.KeyRequestBody) + var bodyStr string + + if bodyBytes, err := common.GetRequestBody(c); err == nil && bodyBytes != nil { + bodyStr = string(bodyBytes) + if len(bodyStr) > 1024 { + bodyStr = bodyStr[:1024] + "...(truncated)" + } + } + + // 获取堆栈信息 + stack := string(debug.Stack()) + + // 打印详细日志 + common.SysError(fmt.Sprintf( + "panic detected:\n"+ + " Request ID: %s\n"+ + " Method: %s\n"+ + " Path: %s\n"+ + " Query: %s\n"+ + " Client IP: %s\n"+ + " Error: %v\n"+ + " Body: %s\n"+ + " Stack:\n%s", + requestId, method, path, query, clientIP, err, bodyStr, stack, + )) + c.JSON(http.StatusInternalServerError, gin.H{ "error": gin.H{ "message": fmt.Sprintf("Panic detected, error: %v. Please submit a issue here: https://github.com/Calcium-Ion/new-api", err), From 9bfadf4a425bf407f766edaa23e94baebe517880 Mon Sep 17 00:00:00 2001 From: zhaolion Date: Mon, 2 Feb 2026 17:52:29 +0800 Subject: [PATCH 33/34] fix(usage): handle nil usage in Claude response to prevent errors --- relay/channel/claude/relay-claude.go | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 69c22db8bfec..c406d4302953 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -733,19 +733,24 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud if claudeError := claudeResponse.GetClaudeError(); claudeError != nil && claudeError.Type != "" { return types.WithClaudeError(*claudeError, http.StatusInternalServerError) } + if claudeInfo.Usage == nil { + claudeInfo.Usage = &dto.Usage{} + } if requestMode == RequestModeCompletion { completionTokens := service.CountTextToken(claudeResponse.Completion, info.OriginModelName) claudeInfo.Usage.PromptTokens = info.PromptTokens claudeInfo.Usage.CompletionTokens = completionTokens claudeInfo.Usage.TotalTokens = info.PromptTokens + completionTokens } else { - claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens - claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens - claudeInfo.Usage.TotalTokens = claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens - claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens - claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens - claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Usage.GetCacheCreation5mTokens() - claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Usage.GetCacheCreation1hTokens() + if claudeResponse.Usage != nil { + claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens + claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens + claudeInfo.Usage.TotalTokens = claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens + claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens + claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens + claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Usage.GetCacheCreation5mTokens() + claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Usage.GetCacheCreation1hTokens() + } } var responseData []byte switch info.RelayFormat { From 2fad3ebd0cffd7e2101e2fc51d3fe0266819f10b Mon Sep 17 00:00:00 2001 From: zhaolion Date: Tue, 3 Feb 2026 14:25:06 +0800 Subject: [PATCH 34/34] feat(pricing): add price field to pricing API response Include operation_setting.Price in the GetPricing endpoint response to expose pricing configuration to clients. --- controller/pricing.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/controller/pricing.go b/controller/pricing.go index dd3f7edca37c..d59585b87087 100644 --- a/controller/pricing.go +++ b/controller/pricing.go @@ -3,6 +3,7 @@ package controller import ( "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/gin-gonic/gin" @@ -46,6 +47,7 @@ func GetPricing(c *gin.Context) { "usable_group": usableGroup, "supported_endpoint": model.GetSupportedEndpointMap(), "auto_groups": service.GetUserAutoGroup(group), + "price": operation_setting.Price, }) }