Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/@buildingai/constants/src/shared/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
6 changes: 6 additions & 0 deletions packages/@buildingai/db/src/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
12 changes: 12 additions & 0 deletions packages/@buildingai/db/src/seeds/data/menu.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
},
Expand Down
6 changes: 6 additions & 0 deletions packages/@buildingai/utils/src/is.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,9 @@ export function isAsyncGenerator<T, TReturn, TNext>(
): value is AsyncGenerator<T, TReturn, TNext> {
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(/\/$/, "");
}
31 changes: 31 additions & 0 deletions packages/@buildingai/web/services/src/console/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GoogleConfigResponse>) {
return useQuery<GoogleConfigResponse>({
queryKey: ["channel", "google-config"],
queryFn: () => consoleHttpClient.get<GoogleConfigResponse>("/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;
Expand Down
182 changes: 182 additions & 0 deletions packages/api/src/common/modules/auth/services/google-oauth.service.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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,
};
}
}
2 changes: 1 addition & 1 deletion packages/api/src/modules/ai/agents/agents.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ import { AgentsService } from "./services/agents.service";
forwardRef(() => AiDatasetsModule),
AiMemoryModule,
ConfigModule,
UserModule,
forwardRef(() => UserModule),
],
controllers: [
AgentsConsoleController,
Expand Down
4 changes: 2 additions & 2 deletions packages/api/src/modules/ai/chat/ai-chat.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -51,7 +51,7 @@ import { ChatConfigService } from "./services/chat-config.service";
@Module({
imports: [
AiMemoryModule,
UserModule,
forwardRef(() => UserModule),
TypeOrmModule.forFeature([
AiModel,
AiProvider,
Expand Down
6 changes: 3 additions & 3 deletions packages/api/src/modules/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -97,7 +97,7 @@ export class AppModule {
DatabaseModule,
GuardsModule,
BillingModule,
AuthModule,
forwardRef(() => AuthModule),
CDKModule, //
ChannelModule,
AiModule,
Expand All @@ -118,7 +118,7 @@ export class AppModule {
UploadModule,
AnalyseModule,
SecretModule,
UserModule,
forwardRef(() => UserModule),
CloudStorageModule,
ScheduleModule,
SmsModule,
Expand Down
6 changes: 6 additions & 0 deletions packages/api/src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -47,6 +50,7 @@ import { AuthWebController } from "./controller/web/auth.controller";
DepartmentUserIndex,
]),
ChannelModule,
forwardRef(() => UserModule),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
Expand All @@ -63,6 +67,7 @@ import { AuthWebController } from "./controller/web/auth.controller";
AuthService,
ExtensionFeatureScanService,
ExtensionFeatureService,
GoogleOAuthService,
RolePermissionService,
UserTokenService,
WechatOaService,
Expand All @@ -71,6 +76,7 @@ import { AuthWebController } from "./controller/web/auth.controller";
AuthService,
ExtensionFeatureScanService,
ExtensionFeatureService,
GoogleOAuthService,
JwtModule,
RolePermissionService,
UserTokenService,
Expand Down
Loading