Skip to content
4 changes: 4 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,7 @@ OBB_WECHAT_APP_SECRET=
OBB_OPEN_WECHAT_APP_ID=
OBB_OPEN_WECHAT_APP_SECRET=
OBB_WECHAT_REDIRECT_URI=

OBB_GOOGLE_CLIENT_ID=
OBB_GOOGLE_CLIENT_SECRET=
OBB_GOOGLE_REDIRECT_URI=
12 changes: 10 additions & 2 deletions src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,24 @@ import { GroupsModule } from 'omniboxd/groups/groups.module';
import { PermissionsModule } from 'omniboxd/permissions/permissions.module';
import { WechatService } from 'omniboxd/auth/wechat.service';
import { WechatController } from 'omniboxd/auth/wechat.controller';
import { GoogleService } from 'omniboxd/auth/google.service';
import { GoogleController } from 'omniboxd/auth/google.controller';
import { APIKeyModule } from 'omniboxd/api-key/api-key.module';
import { APIKeyAuthGuard } from 'omniboxd/auth/api-key/api-key-auth.guard';
import { CookieAuthGuard } from 'omniboxd/auth/cookie/cookie-auth.guard';

@Module({
exports: [AuthService, WechatService],
controllers: [AuthController, InternalAuthController, WechatController],
exports: [AuthService, WechatService, GoogleService],
controllers: [
AuthController,
InternalAuthController,
WechatController,
GoogleController,
],
providers: [
AuthService,
WechatService,
GoogleService,
JwtStrategy,
LocalStrategy,
{
Expand Down
43 changes: 43 additions & 0 deletions src/auth/google.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Request } from 'express';
import { AuthService } from 'omniboxd/auth/auth.service';
import { GoogleService } from 'omniboxd/auth/google.service';
import { Req, Get, Body, Controller, Post } from '@nestjs/common';
import { Public } from 'omniboxd/auth/decorators/public.auth.decorator';
import { SocialController } from 'omniboxd/auth/social.controller';
import { UserId } from 'omniboxd/decorators/user-id.decorator';

@Controller('api/v1/google')
export class GoogleController extends SocialController {
constructor(
private readonly googleService: GoogleService,
protected readonly authService: AuthService,
) {
super(authService);
}

@Public()
@Get('auth-url')
getAuthUrl() {
return this.googleService.getGoogleAuthUrl();
}

@Public()
@Post('callback')
async handleCallback(
@Req() req: Request,
@Body() body: { code: string; state: string },
) {
const userId = this.findUserId(req.headers.authorization);

return await this.googleService.handleCallback(
body.code,
body.state,
userId,
);
}

@Post('unbind')
unbind(@UserId() userId: string) {
return this.googleService.unbind(userId);
}
}
202 changes: 202 additions & 0 deletions src/auth/google.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { DataSource } from 'typeorm';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { SocialService } from 'omniboxd/auth/social.service';
import { UserService } from 'omniboxd/user/user.service';
import { NamespacesService } from 'omniboxd/namespaces/namespaces.service';
import {
Logger,
Injectable,
BadRequestException,
UnauthorizedException,
} from '@nestjs/common';

interface GoogleTokenResponse {
access_token: string;
expires_in: number;
refresh_token?: string;
scope: string;
token_type: string;
id_token?: string;
}

interface GoogleUserInfo {
sub: string;
name?: string;
given_name?: string;
family_name?: string;
picture?: string;
email?: string;
email_verified?: boolean;
locale?: string;
hd?: string;
}

@Injectable()
export class GoogleService extends SocialService {
private readonly logger = new Logger(GoogleService.name);

private readonly clientId: string;
private readonly clientSecret: string;
private readonly redirectUri: string;

constructor(
private readonly configService: ConfigService,
private readonly jwtService: JwtService,
protected readonly userService: UserService,
private readonly namespaceService: NamespacesService,
private readonly dataSource: DataSource,
) {
super(userService);
this.clientId = this.configService.get<string>('OBB_GOOGLE_CLIENT_ID', '');
this.clientSecret = this.configService.get<string>(
'OBB_GOOGLE_CLIENT_SECRET',
'',
);
this.redirectUri = this.configService.get<string>(
'OBB_GOOGLE_REDIRECT_URI',
'',
);
}

getGoogleAuthUrl(): string {
const state = this.setState('google');
this.cleanExpiresState();

const params = new URLSearchParams({
client_id: this.clientId,
redirect_uri: this.redirectUri,
response_type: 'code',
scope: 'openid email profile',
state: state,
});
return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
}

async handleCallback(
code: string,
state: string,
userId: string,
): Promise<any> {
const stateInfo = this.getState(state);
if (!stateInfo) {
throw new UnauthorizedException('Invalid state identifier');
}

const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: this.clientId,
client_secret: this.clientSecret,
code: code,
grant_type: 'authorization_code',
redirect_uri: this.redirectUri,
}),
});

if (!tokenResponse.ok) {
throw new UnauthorizedException('Failed to get Google access token');
}

const tokenData: GoogleTokenResponse = await tokenResponse.json();

if (!tokenData.id_token) {
throw new BadRequestException('Invalid token response from Google');
}

const userInfoResponse = await fetch(
'https://www.googleapis.com/oauth2/v3/userinfo',
{
headers: { Authorization: `Bearer ${tokenData.access_token}` },
},
);

if (!userInfoResponse.ok) {
throw new UnauthorizedException('Failed to get Google user info');
}

const userData: GoogleUserInfo = await userInfoResponse.json();

if (!userData.sub || !userData.email) {
throw new BadRequestException('Invalid user data from Google');
}

if (userId) {
const wechatUser = await this.userService.findByLoginId(userData.sub);
if (wechatUser && wechatUser.id !== userId) {
throw new BadRequestException(
'This google account is already bound to another user',
);
}
const existingUser = await this.userService.bindingExistUser({
userId,
loginType: 'google',
loginId: userData.sub,
});
const returnValue = {
id: existingUser.id,
access_token: this.jwtService.sign({
sub: existingUser.id,
}),
};
stateInfo.userInfo = returnValue;
return returnValue;
}

const existingUser = await this.userService.findByLoginId(userData.sub);
if (existingUser) {
const returnValue = {
id: existingUser.id,
access_token: this.jwtService.sign({
sub: existingUser.id,
}),
};
stateInfo.userInfo = returnValue;
return returnValue;
}
return await this.dataSource.transaction(async (manager) => {
let nickname = userData.name;
if (!nickname) {
nickname = userData.given_name;
}
if (!nickname && userData.email) {
nickname = userData.email.split('@')[0];
}
if (!nickname) {
nickname = userData.sub;
}
const username = await this.getValidUsername(nickname, manager);
this.logger.debug({ nickname, username });
const googleUser = await this.userService.createUserBinding(
{
username,
loginType: 'google',
loginId: userData.sub,
},
manager,
);

await this.namespaceService.createAndJoinNamespace(
googleUser.id,
`${googleUser.username}'s Namespace`,
manager,
);

const returnValue = {
id: googleUser.id,
access_token: this.jwtService.sign({
sub: googleUser.id,
}),
};
stateInfo.userInfo = returnValue;
return returnValue;
});
}

async unbind(userId: string) {
await this.userService.unbindByLoginType(userId, 'google');
}
}
23 changes: 23 additions & 0 deletions src/auth/social.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Controller } from '@nestjs/common';
import { AuthService } from 'omniboxd/auth/auth.service';

@Controller()
export class SocialController {
constructor(protected readonly authService: AuthService) {}

protected findUserId(authorization: string | undefined) {
let userId: string = '';

if (authorization) {
const headerToken = authorization.replace('Bearer ', '');
if (headerToken) {
const payload = this.authService.jwtVerify(headerToken);
if (payload && payload.sub) {
userId = payload.sub;
}
}
}

return userId;
}
}
85 changes: 85 additions & 0 deletions src/auth/social.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { EntityManager } from 'typeorm';
import generateId from 'omniboxd/utils/generate-id';
import { UserService } from 'omniboxd/user/user.service';
import { WechatCheckResponseDto } from 'omniboxd/auth/dto/wechat-login.dto';
import { Injectable, InternalServerErrorException } from '@nestjs/common';

@Injectable()
export class SocialService {
private readonly minUsernameLength = 2;
private readonly maxUsernameLength = 32;
private readonly states = new Map<
string,
{
type: string;
createdAt: number;
expiresIn: number;
userInfo?: WechatCheckResponseDto['user'];
}
>();

constructor(protected readonly userService: UserService) {}

protected cleanExpiresState() {
const now = Date.now();
for (const [state, info] of this.states.entries()) {
if (now - info.createdAt > info.expiresIn) {
this.states.delete(state);
}
}
}

protected setState(type: string) {
const state = generateId();
this.states.set(state, {
type,
createdAt: Date.now(),
expiresIn: 5 * 60 * 1000, // Expires in 5 minutes
});
return state;
}

protected getState(state: string) {
return this.states.get(state);
}

private generateSuffix(): string {
return (
'_' +
generateId(4, 'useandomTPXpxJACKVERYMINDBUSHWOLFGQZbfghjklqvwyzrict')
);
}

protected async getValidUsername(
nickname: string,
manager: EntityManager,
): Promise<string> {
let username = nickname;

if (username.length > this.maxUsernameLength) {
username = nickname.slice(0, this.maxUsernameLength);
}
if (username.length >= this.minUsernameLength) {
const user = await this.userService.findByUsername(username, manager);
if (!user) {
return username;
}
}

username = nickname.slice(0, this.maxUsernameLength - 5);
for (let i = 0; i < 5; i++) {
const suffix = this.generateSuffix();
const user = await this.userService.findByUsername(
username + suffix,
manager,
);
if (!user) {
return username + suffix;
}
}

throw new InternalServerErrorException(
'Unable to generate a valid username',
);
}
}
Loading