From 7b225eb954269a2cf6dfece8317fb4b5fb83f1b3 Mon Sep 17 00:00:00 2001 From: Joey Zhong Date: Tue, 7 Apr 2026 12:07:05 +0100 Subject: [PATCH] feat: add google auth --- .../@buildingai/constants/src/shared/auth.ts | 2 + .../db/src/entities/user.entity.ts | 6 + .../@buildingai/db/src/seeds/data/menu.json | 12 ++ packages/@buildingai/utils/src/is.ts | 6 + .../web/services/src/console/user.ts | 31 +++ .../auth/services/google-oauth.service.ts | 182 ++++++++++++++++++ .../src/modules/ai/agents/agents.module.ts | 2 +- .../api/src/modules/ai/chat/ai-chat.module.ts | 4 +- packages/api/src/modules/app.module.ts | 6 +- packages/api/src/modules/auth/auth.module.ts | 6 + .../auth/controller/web/auth.controller.ts | 63 ++++-- .../api/src/modules/channel/channel.module.ts | 8 +- .../console/google-config.controller.ts | 38 ++++ .../modules/channel/dto/google-config.dto.ts | 15 ++ .../channel/services/google-config.service.ts | 74 +++++++ .../src/modules/extension/extension.module.ts | 4 +- .../api/src/modules/system/system.module.ts | 2 +- .../api/src/modules/upload/upload.module.ts | 4 +- .../src/modules/user/services/user.service.ts | 35 ++++ packages/api/src/modules/user/user.module.ts | 2 +- packages/client/src/layouts/console/index.tsx | 5 + .../pages/console/channel/google/index.tsx | 173 +++++++++++++++++ .../console/system/login-config/index.tsx | 5 + .../pages/login/_components/login-form.tsx | 13 ++ .../client/src/pages/login/oauth-callback.tsx | 11 +- packages/client/vite.config.ts | 14 +- 26 files changed, 686 insertions(+), 37 deletions(-) create mode 100644 packages/api/src/common/modules/auth/services/google-oauth.service.ts create mode 100644 packages/api/src/modules/channel/controller/console/google-config.controller.ts create mode 100644 packages/api/src/modules/channel/dto/google-config.dto.ts create mode 100644 packages/api/src/modules/channel/services/google-config.service.ts create mode 100644 packages/client/src/pages/console/channel/google/index.tsx diff --git a/packages/@buildingai/constants/src/shared/auth.ts b/packages/@buildingai/constants/src/shared/auth.ts index 3ec7103c7..5a55aa235 100644 --- a/packages/@buildingai/constants/src/shared/auth.ts +++ b/packages/@buildingai/constants/src/shared/auth.ts @@ -13,5 +13,7 @@ export const LOGIN_TYPE = { PHONE: 2, /** 微信登录 */ WECHAT: 3, + /** Google登录 */ + GOOGLE: 5, } as const; export type LoginType = (typeof LOGIN_TYPE)[keyof typeof LOGIN_TYPE]; diff --git a/packages/@buildingai/db/src/entities/user.entity.ts b/packages/@buildingai/db/src/entities/user.entity.ts index 36fc676c6..27da8fb0c 100644 --- a/packages/@buildingai/db/src/entities/user.entity.ts +++ b/packages/@buildingai/db/src/entities/user.entity.ts @@ -37,6 +37,12 @@ export class User extends SoftDeleteBaseEntity { @Column({ nullable: true, unique: true, comment: "用户unionid" }) unionid: string; + /** + * Google OAuth openid + */ + @Column({ nullable: true, comment: "Google OAuth openid" }) + googleOpenid: string; + @Column({ nullable: true }) userNo: string; /** diff --git a/packages/@buildingai/db/src/seeds/data/menu.json b/packages/@buildingai/db/src/seeds/data/menu.json index 9d85f4580..96de2c652 100644 --- a/packages/@buildingai/db/src/seeds/data/menu.json +++ b/packages/@buildingai/db/src/seeds/data/menu.json @@ -386,6 +386,18 @@ "isHidden": 0, "type": 2, "sourceType": 1 + }, + { + "name": "Google登录", + "code": "channel-google", + "path": "google", + "icon": "google", + "component": "/console/channel/google/index", + "permissionCode": "google-config:get-config", + "sort": 1, + "isHidden": 0, + "type": 2, + "sourceType": 1 } ] }, diff --git a/packages/@buildingai/utils/src/is.ts b/packages/@buildingai/utils/src/is.ts index 85db84824..18e1e1787 100644 --- a/packages/@buildingai/utils/src/is.ts +++ b/packages/@buildingai/utils/src/is.ts @@ -110,3 +110,9 @@ export function isAsyncGenerator( ): value is AsyncGenerator { return value != null && typeof value === "object" && Symbol.asyncIterator in value; } + +export function getFrontendBaseUrl() { + return isDevelopment() + ? (process.env.CLIENT_URL || "http://localhost:4091").replace(/\/$/, "") + : (process.env.APP_DOMAIN || "http://localhost:4090").replace(/\/$/, ""); +} diff --git a/packages/@buildingai/web/services/src/console/user.ts b/packages/@buildingai/web/services/src/console/user.ts index 149cd98cb..e65a76f17 100644 --- a/packages/@buildingai/web/services/src/console/user.ts +++ b/packages/@buildingai/web/services/src/console/user.ts @@ -369,6 +369,37 @@ export function useUpdateWxOaConfigMutation( }); } +/** Google登录配置(接口返回) */ +export type GoogleConfigResponse = { + clientId: string; + clientSecret: string; + enabled: boolean; +}; + +/** 更新Google登录配置 DTO */ +export type UpdateGoogleConfigDto = { + clientId?: string; + clientSecret?: string; + enabled?: boolean; +}; + +export function useGoogleConfigQuery(options?: QueryOptionsUtil) { + return useQuery({ + queryKey: ["channel", "google-config"], + queryFn: () => consoleHttpClient.get("/google-config"), + ...options, + }); +} + +export function useUpdateGoogleConfigMutation( + options?: MutationOptionsUtil<{ success: boolean }, UpdateGoogleConfigDto>, +) { + return useMutation<{ success: boolean }, Error, UpdateGoogleConfigDto>({ + mutationFn: (dto) => consoleHttpClient.patch<{ success: boolean }>("/google-config", dto), + ...options, + }); +} + // User subscription types export type UserSubscriptionLevel = { id: string; diff --git a/packages/api/src/common/modules/auth/services/google-oauth.service.ts b/packages/api/src/common/modules/auth/services/google-oauth.service.ts new file mode 100644 index 000000000..631367e63 --- /dev/null +++ b/packages/api/src/common/modules/auth/services/google-oauth.service.ts @@ -0,0 +1,182 @@ +import { BooleanNumber, UserTerminal, UserTerminalType } from "@buildingai/constants"; +import { checkUserLoginPlayground, LoginUserPlayground } from "@buildingai/db"; +import { HttpErrorFactory } from "@buildingai/errors"; +import { Injectable } from "@nestjs/common"; +import { getFrontendBaseUrl } from "@buildingai/utils"; +import { GoogleConfigService } from "../../../../modules/channel/services/google-config.service"; +import { UserService } from "../../../../modules/user/services/user.service"; +import { UserTokenService } from "./user-token.service"; + +/** + * Google OAuth 2.0 Service + * + * Implements the OAuth 2.0 authorization code flow for Google authentication: + * 1. Build authorization URL + * 2. Exchange authorization code for access token + * 3. Fetch user info from Google + * 4. Find or create user and return app token + */ +@Injectable() +export class GoogleOAuthService { + private readonly TOKEN_URL = "https://oauth2.googleapis.com/token"; + private readonly USER_INFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"; + private readonly AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; + private readonly SCOPES = "openid email profile"; + + constructor( + private readonly configService: GoogleConfigService, + private readonly userService: UserService, + private readonly userTokenService: UserTokenService, + ) { } + + /** + * Step 1: Build Google OAuth authorization URL + * + * @param state Random state string for CSRF protection + * @returns Authorization URL to redirect user to Google + * @throws Error if Google OAuth is not properly configured + */ + async getAuthorizationUrl(state: string): Promise { + const config = await this.configService.getConfig(); + + if (!config.clientId || !config.clientSecret) { + throw HttpErrorFactory.unauthorized("Google OAuth is not configured. Please configure clientId and clientSecret."); + } + + const redirectUri = `${getFrontendBaseUrl()}/api/auth/google-callback`; + const params = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: redirectUri, + response_type: "code", + scope: this.SCOPES, + state, + }); + + return `${this.AUTH_URL}?${params}`; + } + + /** + * Step 2: Exchange authorization code for access token + * + * @param code Authorization code from Google callback + * @param redirectUri Redirect URI used in the authorization request + * @returns Access token response + * @throws Error if token exchange fails + */ + private async exchangeCodeForToken( + code: string, + redirectUri: string, + ): Promise<{ access_token: string }> { + const config = await this.configService.getConfig(); + + const response = await fetch(this.TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + code, + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: redirectUri, + grant_type: "authorization_code", + }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw HttpErrorFactory.unauthorized(`Google token exchange failed: ${response.status} - ${errorText}`); + } + + return response.json() as Promise<{ access_token: string }>; + } + + /** + * Step 3: Fetch user info from Google + * + * @param accessToken Google access token + * @returns Google user info + * @throws Error if user info fetch fails + */ + private async fetchUserInfo(accessToken: string): Promise<{ + id: string; + email?: string; + name?: string; + picture?: string; + }> { + const response = await fetch(this.USER_INFO_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw HttpErrorFactory.unauthorized(`Google user info fetch failed: ${response.status} - ${errorText}`); + } + + return response.json() as Promise<{ id: string; email?: string; name?: string; picture?: string }>; + } + + /** + * Step 4: Handle callback - find or create user, return token + * + * @param code Authorization code from Google callback + * @param redirectUri Redirect URI used in the authorization request + * @param terminal Login terminal type + * @param ipAddress Client IP address (optional) + * @param userAgent Client user agent (optional) + * @returns Token result with user info + */ + async handleCallback( + code: string, + state: string, + redirectUri: string, + terminal: UserTerminalType = UserTerminal.PC, + ipAddress?: string, + userAgent?: string, + ) { + // Validate state parameter for CSRF protection + if (!state || state.trim() === "") { + throw HttpErrorFactory.unauthorized("Invalid state parameter: CSRF validation failed"); + } + + // Exchange code for access token + const { access_token } = await this.exchangeCodeForToken(code, redirectUri); + + // Fetch user info from Google + const userInfo = await this.fetchUserInfo(access_token); + const googleOpenid = userInfo.id; + + // Find or create user by Google OpenID + let user = await this.userService.findByGoogleOpenid(googleOpenid); + + if (!user) { + user = await this.userService.createByGoogle({ + googleOpenid, + email: userInfo.email, + nickname: userInfo.name, + avatar: userInfo.picture, + }); + } + + // Build login playground payload + const payload: LoginUserPlayground = checkUserLoginPlayground({ + id: user.id, + username: user.username, + isRoot: user.isRoot ?? BooleanNumber.NO, + terminal, + }); + + // Create app token + const tokenResult = await this.userTokenService.createToken( + user.id, + payload, + terminal, + ipAddress, + userAgent, + ); + + return { + token: tokenResult.token, + expiresAt: tokenResult.expiresAt, + user, + }; + } +} diff --git a/packages/api/src/modules/ai/agents/agents.module.ts b/packages/api/src/modules/ai/agents/agents.module.ts index ae0ac0912..33d00f799 100644 --- a/packages/api/src/modules/ai/agents/agents.module.ts +++ b/packages/api/src/modules/ai/agents/agents.module.ts @@ -78,7 +78,7 @@ import { AgentsService } from "./services/agents.service"; forwardRef(() => AiDatasetsModule), AiMemoryModule, ConfigModule, - UserModule, + forwardRef(() => UserModule), ], controllers: [ AgentsConsoleController, diff --git a/packages/api/src/modules/ai/chat/ai-chat.module.ts b/packages/api/src/modules/ai/chat/ai-chat.module.ts index 340ee0216..7c9f3d56d 100644 --- a/packages/api/src/modules/ai/chat/ai-chat.module.ts +++ b/packages/api/src/modules/ai/chat/ai-chat.module.ts @@ -19,7 +19,7 @@ import { Secret, UserSubscription, } from "@buildingai/db/entities"; -import { Module } from "@nestjs/common"; +import { forwardRef, Module } from "@nestjs/common"; import { UserModule } from "../../user/user.module"; import { AiMcpServerService } from "../mcp/services/ai-mcp-server.service"; @@ -51,7 +51,7 @@ import { ChatConfigService } from "./services/chat-config.service"; @Module({ imports: [ AiMemoryModule, - UserModule, + forwardRef(() => UserModule), TypeOrmModule.forFeature([ AiModel, AiProvider, diff --git a/packages/api/src/modules/app.module.ts b/packages/api/src/modules/app.module.ts index 89e89253e..b505fbff6 100644 --- a/packages/api/src/modules/app.module.ts +++ b/packages/api/src/modules/app.module.ts @@ -30,7 +30,7 @@ import { ExtensionCoreModule } from "@modules/extension/extension.module"; import { HealthModule } from "@modules/health/health.module"; import { MembershipModule } from "@modules/membership/membership.module"; import { NotificationModule } from "@modules/notification/notification.module"; -import { DynamicModule, Module } from "@nestjs/common"; +import { DynamicModule, forwardRef, Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; import { APP_GUARD } from "@nestjs/core"; import { ServeStaticModule } from "@nestjs/serve-static"; @@ -97,7 +97,7 @@ export class AppModule { DatabaseModule, GuardsModule, BillingModule, - AuthModule, + forwardRef(() => AuthModule), CDKModule, // ChannelModule, AiModule, @@ -118,7 +118,7 @@ export class AppModule { UploadModule, AnalyseModule, SecretModule, - UserModule, + forwardRef(() => UserModule), CloudStorageModule, ScheduleModule, SmsModule, diff --git a/packages/api/src/modules/auth/auth.module.ts b/packages/api/src/modules/auth/auth.module.ts index 84fbf8d19..13e527753 100644 --- a/packages/api/src/modules/auth/auth.module.ts +++ b/packages/api/src/modules/auth/auth.module.ts @@ -14,14 +14,17 @@ import { import { AuthService } from "@common/modules/auth/services/auth.service"; import { ExtensionFeatureService } from "@common/modules/auth/services/extension-feature.service"; import { ExtensionFeatureScanService } from "@common/modules/auth/services/extension-feature-scan.service"; +import { GoogleOAuthService } from "@common/modules/auth/services/google-oauth.service"; import { RolePermissionService } from "@common/modules/auth/services/role-permission.service"; import { UserTokenService } from "@common/modules/auth/services/user-token.service"; import { SmsModule } from "@common/modules/sms/sms.module"; import { WechatOaService } from "@common/modules/wechat/services/wechatoa.service"; import { ChannelModule } from "@modules/channel/channel.module"; +import { UserModule } from "@modules/user/user.module"; import { Module } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { JwtModule } from "@nestjs/jwt"; +import { forwardRef } from "@nestjs/common"; import type { StringValue } from "ms"; import { AuthWebController } from "./controller/web/auth.controller"; @@ -47,6 +50,7 @@ import { AuthWebController } from "./controller/web/auth.controller"; DepartmentUserIndex, ]), ChannelModule, + forwardRef(() => UserModule), JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], @@ -63,6 +67,7 @@ import { AuthWebController } from "./controller/web/auth.controller"; AuthService, ExtensionFeatureScanService, ExtensionFeatureService, + GoogleOAuthService, RolePermissionService, UserTokenService, WechatOaService, @@ -71,6 +76,7 @@ import { AuthWebController } from "./controller/web/auth.controller"; AuthService, ExtensionFeatureScanService, ExtensionFeatureService, + GoogleOAuthService, JwtModule, RolePermissionService, UserTokenService, diff --git a/packages/api/src/modules/auth/controller/web/auth.controller.ts b/packages/api/src/modules/auth/controller/web/auth.controller.ts index 879c25dcb..112424a75 100644 --- a/packages/api/src/modules/auth/controller/web/auth.controller.ts +++ b/packages/api/src/modules/auth/controller/web/auth.controller.ts @@ -10,18 +10,20 @@ import { Playground } from "@buildingai/decorators/playground.decorator"; import { Public } from "@buildingai/decorators/public.decorator"; import { DictService } from "@buildingai/dict"; import { HttpErrorFactory } from "@buildingai/errors"; -import { isDevelopment } from "@buildingai/utils"; +import { getFrontendBaseUrl } from "@buildingai/utils"; import { WebController } from "@common/decorators"; import { ChangePasswordDto } from "@common/modules/auth/dto/change-password.dto"; import { LoginDto } from "@common/modules/auth/dto/login.dto"; import { RegisterDto } from "@common/modules/auth/dto/register.dto"; import { SendSmsCodeDto, SmsLoginDto } from "@common/modules/auth/dto/sms.dto"; import { AuthService } from "@common/modules/auth/services/auth.service"; +import { GoogleOAuthService } from "@common/modules/auth/services/google-oauth.service"; import { SmsService } from "@common/modules/sms/services/sms.service"; import { WechatOaService } from "@common/modules/wechat/services/wechatoa.service"; import { type LoginSettingsConfig } from "@modules/user/dto/login-settings.dto"; import { Body, Get, Headers, Param, Post, Query, Req, Res } from "@nestjs/common"; import type { Request, Response } from "express"; +import crypto from "node:crypto"; const OAUTH_SESSION_PREFIX = "oauth:session:"; @@ -38,6 +40,7 @@ export class AuthWebController extends BaseController { private smsService: SmsService, private dictService: DictService, private cacheService: CacheService, + private googleOAuthService: GoogleOAuthService, ) { super(); } @@ -338,22 +341,6 @@ export class AuthWebController extends BaseController { return { token: data.token, user: data.user }; } - private getFrontendBaseUrl() { - return isDevelopment() - ? "http://localhost:4091" - : (process.env.APP_DOMAIN || "http://localhost:4090").replace(/\/$/, ""); - } - - private redirectOAuthCallback(res: Response, error?: string, redirectState?: string) { - const baseUrl = this.getFrontendBaseUrl(); - if (error) { - const params = new URLSearchParams({ error }); - if (redirectState) params.set("redirect", redirectState); - return res.redirect(`${baseUrl}/login?${params.toString()}`); - } - return res.redirect(`${baseUrl}/login`); - } - private async getLoginSettings() { return await this.dictService.get( "login_settings", @@ -439,4 +426,46 @@ export class AuthWebController extends BaseController { res.setHeader("Content-Type", "text/html; charset=utf-8"); return res.send(html); } + + /** + * Redirect user to Google consent screen + * + * @param res Express response object + * @returns Redirects to Google OAuth authorization URL + */ + @Public() + @Get("google") + async googleAuth(@Res() res: Response) { + const state = crypto.randomUUID(); + const url = await this.googleOAuthService.getAuthorizationUrl(state); + return res.redirect(url); + } + + /** + * Google OAuth callback - Google redirects here with code + * + * @param code Authorization code from Google + * @param state State parameter for CSRF validation + * @param error Error parameter if user denied consent + * @param res Express response object + * @returns Redirects to frontend with token + */ + @Public() + @Get("google-callback") + async googleCallback( + @Query("code") code: string, + @Query("state") state: string, + @Query("error") error: string, + @Res() res: Response, + ) { + const redirectUri = `${getFrontendBaseUrl()}/api/auth/google-callback`; + + if (error || !code) { + return res.redirect(`${getFrontendBaseUrl()}/login?error=${error || "missing_code"}`); + } + + const { token } = await this.googleOAuthService.handleCallback(code, state, redirectUri); + console.log("Google OAuth callback - token:", token); + return res.redirect(`${getFrontendBaseUrl()}/login/oauth-callback?provider=google&token=${token}`); + } } diff --git a/packages/api/src/modules/channel/channel.module.ts b/packages/api/src/modules/channel/channel.module.ts index 26dee6677..d66cdd41a 100644 --- a/packages/api/src/modules/channel/channel.module.ts +++ b/packages/api/src/modules/channel/channel.module.ts @@ -2,14 +2,16 @@ import { TypeOrmModule } from "@buildingai/db/@nestjs/typeorm"; import { Dict } from "@buildingai/db/entities"; import { DictCacheService } from "@buildingai/dict"; import { DictService } from "@buildingai/dict"; +import { GoogleConfigConsoleController } from "@modules/channel/controller/console/google-config.controller"; +import { GoogleConfigService } from "@modules/channel/services/google-config.service"; import { WxOaConfigConsoleController } from "@modules/channel/controller/console/wxoaconfig.controller"; import { WxOaConfigService } from "@modules/channel/services/wxoaconfig.service"; import { Module } from "@nestjs/common"; @Module({ imports: [TypeOrmModule.forFeature([Dict])], - controllers: [WxOaConfigConsoleController], - providers: [WxOaConfigService, DictService, DictCacheService], - exports: [WxOaConfigService], + controllers: [WxOaConfigConsoleController, GoogleConfigConsoleController], + providers: [WxOaConfigService, GoogleConfigService, DictService, DictCacheService], + exports: [WxOaConfigService, GoogleConfigService], }) export class ChannelModule {} diff --git a/packages/api/src/modules/channel/controller/console/google-config.controller.ts b/packages/api/src/modules/channel/controller/console/google-config.controller.ts new file mode 100644 index 000000000..eb5d869fb --- /dev/null +++ b/packages/api/src/modules/channel/controller/console/google-config.controller.ts @@ -0,0 +1,38 @@ +import { BaseController } from "@buildingai/base"; +import { Permissions } from "@common/decorators"; +import { ConsoleController } from "@common/decorators/controller.decorator"; +import { UpdateGoogleConfigDto } from "@modules/channel/dto/google-config.dto"; +import { GoogleConfigService } from "@modules/channel/services/google-config.service"; +import { Body, Get, Patch } from "@nestjs/common"; + +@ConsoleController("google-config", "Google登录配置") +export class GoogleConfigConsoleController extends BaseController { + constructor(private readonly googleConfigService: GoogleConfigService) { + super(); + } + /** + * 获取Google登录配置 + * @returns Google登录配置 + */ + @Get() + @Permissions({ + code: "google-config:get-config", + name: "获取Google登录配置", + }) + getConfig() { + return this.googleConfigService.getConfig(); + } + /** + * 更新Google登录配置 + * @param updateGoogleConfigDto 更新配置DTO + * @returns 更新后的配置 + */ + @Patch() + @Permissions({ + code: "google-config:update-config", + name: "更新Google登录配置", + }) + update(@Body() updateGoogleConfigDto: UpdateGoogleConfigDto) { + return this.googleConfigService.updateConfig(updateGoogleConfigDto); + } +} diff --git a/packages/api/src/modules/channel/dto/google-config.dto.ts b/packages/api/src/modules/channel/dto/google-config.dto.ts new file mode 100644 index 000000000..36af27487 --- /dev/null +++ b/packages/api/src/modules/channel/dto/google-config.dto.ts @@ -0,0 +1,15 @@ +import { IsString, IsOptional, IsBoolean } from 'class-validator'; + +export class UpdateGoogleConfigDto { + @IsString() + @IsOptional() + clientId?: string; + + @IsString() + @IsOptional() + clientSecret?: string; + + @IsBoolean() + @IsOptional() + enabled?: boolean; +} diff --git a/packages/api/src/modules/channel/services/google-config.service.ts b/packages/api/src/modules/channel/services/google-config.service.ts new file mode 100644 index 000000000..64c977115 --- /dev/null +++ b/packages/api/src/modules/channel/services/google-config.service.ts @@ -0,0 +1,74 @@ +import { BaseService } from "@buildingai/base"; +import { InjectRepository } from "@buildingai/db/@nestjs/typeorm"; +import { Dict } from "@buildingai/db/entities"; +import { Repository } from "@buildingai/db/typeorm"; +import { DictService } from "@buildingai/dict"; +import { UpdateGoogleConfigDto } from "@modules/channel/dto/google-config.dto"; +import { Injectable } from "@nestjs/common"; +import { EventEmitter2 } from "@nestjs/event-emitter"; + +export const GOOGLE_EVENTS = { + REFRESH: "google.access_token.refresh", +} as const; + +export type GoogleConfig = { + clientId: string; + clientSecret: string; + enabled: boolean; +}; + +@Injectable() +export class GoogleConfigService extends BaseService { + private readonly GROUP = "googleConfig"; + + constructor( + private readonly dictService: DictService, + @InjectRepository(Dict) repository: Repository, + private readonly eventEmitter: EventEmitter2, + ) { + super(repository); + } + + async getConfig(): Promise { + const defaultConfig: GoogleConfig = { + clientId: "", + clientSecret: "", + enabled: false, + }; + + try { + const items = await this.dictService.findAll({ + where: { group: this.GROUP }, + }); + + if (items.length === 0) { + return defaultConfig; + } + + return { + clientId: items.find((i) => i.key === "clientId")?.value || "", + clientSecret: items.find((i) => i.key === "clientSecret")?.value || "", + enabled: items.find((i) => i.key === "enabled")?.value === "true", + }; + } catch (error) { + this.logger.error(`获取 ${this.GROUP} 配置失败: ${error.message}`); + return defaultConfig; + } + } + + async updateConfig(dto: UpdateGoogleConfigDto): Promise { + try { + const entries = Object.entries(dto).filter(([, v]) => v !== undefined); + for (const [key, value] of entries) { + await this.dictService.set(key, String(value), { + group: this.GROUP, + description: `配置 - ${key}`, + }); + } + this.eventEmitter.emit(GOOGLE_EVENTS.REFRESH); + } catch (error) { + this.logger.error(`更新 ${this.GROUP} 配置失败: ${error.message}`); + throw error; + } + } +} diff --git a/packages/api/src/modules/extension/extension.module.ts b/packages/api/src/modules/extension/extension.module.ts index b23dc5970..7b0773515 100644 --- a/packages/api/src/modules/extension/extension.module.ts +++ b/packages/api/src/modules/extension/extension.module.ts @@ -10,7 +10,7 @@ import { Extension } from "@buildingai/db/entities"; import { DataSource } from "@buildingai/db/typeorm"; import { TerminalLogger } from "@buildingai/logger"; import { ExtensionFeatureScanService } from "@common/modules/auth/services/extension-feature-scan.service"; -import { DynamicModule, Logger, Module, OnModuleInit } from "@nestjs/common"; +import { DynamicModule, forwardRef, Logger, Module, OnModuleInit } from "@nestjs/common"; import { ModuleRef } from "@nestjs/core"; import { AuthModule } from "../auth/auth.module"; @@ -51,7 +51,7 @@ export class ExtensionCoreModule implements OnModuleInit { return { module: ExtensionCoreModule, imports: [ - AuthModule, + forwardRef(() => AuthModule), Pm2Module, UploadModule, TypeOrmModule.forFeature([Extension]), diff --git a/packages/api/src/modules/system/system.module.ts b/packages/api/src/modules/system/system.module.ts index 80644ab97..701fc67d5 100644 --- a/packages/api/src/modules/system/system.module.ts +++ b/packages/api/src/modules/system/system.module.ts @@ -30,7 +30,7 @@ import { WebsiteService } from "./services/website.service"; */ @Module({ imports: [ - AuthModule, + forwardRef(() => AuthModule), CloudStorageModule, TypeOrmModule.forFeature([ Dict, diff --git a/packages/api/src/modules/upload/upload.module.ts b/packages/api/src/modules/upload/upload.module.ts index ae1e67fd9..8e6ad44a6 100644 --- a/packages/api/src/modules/upload/upload.module.ts +++ b/packages/api/src/modules/upload/upload.module.ts @@ -4,7 +4,7 @@ import { TypeOrmModule } from "@buildingai/db/@nestjs/typeorm"; import { File } from "@buildingai/db/entities"; import { SystemModule } from "@modules/system/system.module"; import { UserModule } from "@modules/user/user.module"; -import { Module } from "@nestjs/common"; +import { forwardRef, Module } from "@nestjs/common"; import { MulterModule } from "@nestjs/platform-express"; import { memoryStorage } from "multer"; @@ -26,7 +26,7 @@ import { UploadService } from "./services/upload.service"; storage: memoryStorage(), }), RedisModule, - UserModule, + forwardRef(() => UserModule), ], controllers: [UploadController], providers: [UploadService], diff --git a/packages/api/src/modules/user/services/user.service.ts b/packages/api/src/modules/user/services/user.service.ts index 57f340f8c..389b20861 100644 --- a/packages/api/src/modules/user/services/user.service.ts +++ b/packages/api/src/modules/user/services/user.service.ts @@ -868,6 +868,41 @@ export class UserService extends BaseService { return result; } + /** + * 根据 Google OpenID 查找用户 + * + * @param googleOpenid Google OpenID + * @returns 用户或 null + */ + async findByGoogleOpenid(googleOpenid: string): Promise { + return this.userRepository.findOne({ where: { googleOpenid } }); + } + + /** + * 通过 Google 创建用户 + * + * @param data 用户数据 + * @returns 创建的用户 + */ + async createByGoogle(data: { + googleOpenid: string; + email?: string; + nickname?: string; + avatar?: string; + }): Promise { + const user = this.userRepository.create({ + googleOpenid: data.googleOpenid, + email: data.email, + nickname: data.nickname || data.email?.split("@")[0] || "Google User", + username: `google_${data.googleOpenid.slice(0, 8)}`, + avatar: data.avatar, + password: "GOOGLE_AUTH_CREDENTIALS", + source: UserCreateSource.GOOGLE, + status: 1, + }); + return await this.userRepository.save(user); + } + /** * 获取用户当前最高会员等级ID * diff --git a/packages/api/src/modules/user/user.module.ts b/packages/api/src/modules/user/user.module.ts index 7fbcbf577..ce53166b7 100644 --- a/packages/api/src/modules/user/user.module.ts +++ b/packages/api/src/modules/user/user.module.ts @@ -43,7 +43,7 @@ import { UserCapacityService } from "./services/user-capacity.service"; Department, DepartmentUserIndex, ]), - AuthModule, + forwardRef(() => AuthModule), SmsModule, MenuModule, RoleModule, diff --git a/packages/client/src/layouts/console/index.tsx b/packages/client/src/layouts/console/index.tsx index 0454e228f..312eff076 100644 --- a/packages/client/src/layouts/console/index.tsx +++ b/packages/client/src/layouts/console/index.tsx @@ -18,6 +18,7 @@ import DatasetsIndexPage from "@/pages/console/ai/datasets/list"; import AiMcpIndexPage from "@/pages/console/ai/mcp"; import AiProviderIndexPage from "@/pages/console/ai/provider"; import AiSecretIndexPage from "@/pages/console/ai/secret"; +import ChannelGoogleIndexPage from "@/pages/console/channel/google"; import ChannelWechatOaIndexPage from "@/pages/console/channel/wechat-oa"; import ChatConfigIndexPage from "@/pages/console/chat/config"; import ChatRecordIndexPage from "@/pages/console/chat/record"; @@ -201,6 +202,10 @@ function ConsoleRoutes() { path: "wechat-oa", element: , }, + { + path: "google", + element: , + }, ], }, { diff --git a/packages/client/src/pages/console/channel/google/index.tsx b/packages/client/src/pages/console/channel/google/index.tsx new file mode 100644 index 000000000..5551ffc4b --- /dev/null +++ b/packages/client/src/pages/console/channel/google/index.tsx @@ -0,0 +1,173 @@ +import { + useUpdateGoogleConfigMutation, + useGoogleConfigQuery, + type GoogleConfigResponse, +} from "@buildingai/services/console"; +import { PermissionGuard } from "@buildingai/ui/components/auth/permission-guard"; +import { Alert, AlertTitle } from "@buildingai/ui/components/ui/alert"; +import { Button } from "@buildingai/ui/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@buildingai/ui/components/ui/card"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@buildingai/ui/components/ui/field"; +import { Input } from "@buildingai/ui/components/ui/input"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@buildingai/ui/components/ui/input-group"; +import { Copy, ShieldCheck } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { toast } from "sonner"; + +import { PageContainer } from "@/layouts/console/_components/page-container"; + +const GOOGLE_CALLBACK_URL = `${window.location.origin}/api/auth/google-callback`; + +const GoogleIndexPage = () => { + const { data, isLoading } = useGoogleConfigQuery(); + const config = data as GoogleConfigResponse | undefined; + const updateMutation = useUpdateGoogleConfigMutation({ + onSuccess: () => toast.success("保存成功"), + onError: (e) => toast.error(`保存失败: ${e.message}`), + }); + + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [enabled, setEnabled] = useState(false); + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text).then(() => toast.success("已复制")); + }; + + useEffect(() => { + if (!config) return; + setClientId(config.clientId ?? ""); + setClientSecret(config.clientSecret ?? ""); + setEnabled(config.enabled ?? false); + }, [config]); + + const handleSave = () => { + if (!clientId.trim()) { + toast.error("请填写 Client ID"); + return; + } + if (!clientSecret.trim()) { + toast.error("请填写 Client Secret"); + return; + } + updateMutation.mutate({ + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + enabled, + }); + }; + + return ( + +
+
+

Google登录配置

+ + + +
+ + + + +
请先前往 Google Cloud Console 申请 OAuth 2.0 客户端ID
+
+ + 前往 Google Cloud Console + + +
+
+
+ + +
+ + + Google OAuth 配置 + + 登录 Google Cloud Console,点击 APIs & Services > Credentials,创建 OAuth 2.0 Client ID + + + + + 回调地址 + + 登录 Google Cloud Console,在 OAuth 2.0 Client ID 的已授权重定向 URI 中添加以下地址 + + + + + copyToClipboard(GOOGLE_CALLBACK_URL)} + > + + + + + + + + * Client ID + + setClientId(e.target.value)} + placeholder="粘贴 Google Client ID" + disabled={isLoading} + /> + + + + * Client Secret + + setClientSecret(e.target.value)} + placeholder="粘贴 Google Client Secret" + disabled={isLoading} + /> + + + +
+
+
+
+ ); +}; + +export default GoogleIndexPage; diff --git a/packages/client/src/pages/console/system/login-config/index.tsx b/packages/client/src/pages/console/system/login-config/index.tsx index 9a2f26b07..843e36908 100644 --- a/packages/client/src/pages/console/system/login-config/index.tsx +++ b/packages/client/src/pages/console/system/login-config/index.tsx @@ -23,6 +23,7 @@ const LOGIN_TYPE_OPTIONS: { value: LoginType; label: string }[] = [ { value: LOGIN_TYPE.ACCOUNT as LoginType, label: "账号" }, { value: LOGIN_TYPE.WECHAT as LoginType, label: "微信" }, { value: LOGIN_TYPE.PHONE as LoginType, label: "手机号" }, + { value: LOGIN_TYPE.GOOGLE as LoginType, label: "Google" }, ]; const defaultConfig = { @@ -190,6 +191,10 @@ const SystemLoginConfigIndexPage = () => { 渠道 - 微信公众号配置 {" "} + 中设置,Google 登录凭证请在{" "} + + 渠道 - Google登录配置 + {" "} 中设置 diff --git a/packages/client/src/pages/login/_components/login-form.tsx b/packages/client/src/pages/login/_components/login-form.tsx index 9a90e9cbc..b887c0dca 100644 --- a/packages/client/src/pages/login/_components/login-form.tsx +++ b/packages/client/src/pages/login/_components/login-form.tsx @@ -171,6 +171,7 @@ export function LoginForm({ className, ...props }: React.ComponentProps<"div">) loginSettings?.allowedLoginMethods?.includes(LOGIN_TYPE.ACCOUNT) ?? true; const allowPhoneLogin = loginSettings?.allowedLoginMethods?.includes(LOGIN_TYPE.PHONE) ?? false; const allowWechatLogin = loginSettings?.allowedLoginMethods?.includes(LOGIN_TYPE.WECHAT) ?? true; + const allowGoogleLogin = loginSettings?.allowedLoginMethods?.includes(LOGIN_TYPE.GOOGLE) ?? true; const allowAccountRegister = loginSettings?.allowedRegisterMethods?.includes(LOGIN_TYPE.ACCOUNT) ?? true; const allowPhoneRegister = @@ -609,6 +610,18 @@ export function LoginForm({ className, ...props }: React.ComponentProps<"div">) )} )} + {allowGoogleLogin && ( + + + + )} {allowWechatLogin && canUseAccountInput && ( 或使用账号登录 diff --git a/packages/client/src/pages/login/oauth-callback.tsx b/packages/client/src/pages/login/oauth-callback.tsx index 21297f019..9d3a9dda5 100644 --- a/packages/client/src/pages/login/oauth-callback.tsx +++ b/packages/client/src/pages/login/oauth-callback.tsx @@ -11,9 +11,18 @@ const OAuthCallbackPage = () => { const [error, setError] = useState(null); const code = useMemo(() => searchParams.get("code"), [searchParams]); + const provider = useMemo(() => searchParams.get("provider"), [searchParams]); + const token = useMemo(() => searchParams.get("token"), [searchParams]); const redirect = useMemo(() => searchParams.get("redirect") || "/", [searchParams]); useEffect(() => { + if (provider === "google" && token) { + setToken(token); + window.history.replaceState(null, "", window.location.pathname); + navigate(redirect, { replace: true }); + return; + } + if (!code) { setError("missing_code"); return; @@ -35,7 +44,7 @@ const OAuthCallbackPage = () => { return () => { cancelled = true; }; - }, [code, redirect, setToken, navigate]); + }, [code, provider, token, redirect, setToken, navigate]); if (error) { return ( diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts index 1b0b72d83..129ac7f08 100644 --- a/packages/client/vite.config.ts +++ b/packages/client/vite.config.ts @@ -32,14 +32,20 @@ export default defineConfig({ strictPort: true, hmr: host ? { - protocol: "ws", - host, - port: 1421, - } + protocol: "ws", + host, + port: 1421, + } : undefined, watch: { ignored: ["**/src-tauri/**"], }, + proxy: { + "/api": { + target: "http://localhost:4090", + changeOrigin: true, + }, + }, }, build: { sourcemap: false,