Skip to content

Commit

Permalink
feat: integrate github login (#432)
Browse files Browse the repository at this point in the history
* feat: integrate gitHub login

* feat: dynamic social auth module

* feat: add github auth env validation
  • Loading branch information
boris-w committed Mar 13, 2024
1 parent a8e3442 commit 61044b1
Show file tree
Hide file tree
Showing 27 changed files with 504 additions and 77 deletions.
2 changes: 2 additions & 0 deletions apps/nestjs-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"@types/node-fetch": "2.6.11",
"@types/nodemailer": "6.4.14",
"@types/passport": "1.0.16",
"@types/passport-github2": "1.2.9",
"@types/passport-jwt": "4.0.1",
"@types/passport-local": "1.0.38",
"@types/pause": "0.1.3",
Expand Down Expand Up @@ -166,6 +167,7 @@
"nodemailer": "6.9.11",
"papaparse": "5.4.1",
"passport": "0.7.0",
"passport-github2": "0.1.12",
"passport-jwt": "4.0.1",
"passport-local": "1.0.0",
"pause": "0.1.0",
Expand Down
5 changes: 5 additions & 0 deletions apps/nestjs-backend/src/cache/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface ICacheStore {
[key: `auth:session-store:${string}`]: ISessionData;
[key: `auth:session-user:${string}`]: Record<string, number>;
[key: `auth:session-expire:${string}`]: boolean;
[key: `oauth2:${string}`]: IOauth2State;
}

export interface IAttachmentSignatureCache {
Expand All @@ -33,3 +34,7 @@ export interface IAttachmentPreviewCache {
url: string;
expiresIn: number;
}

export interface IOauth2State {
redirectUri?: string;
}
5 changes: 5 additions & 0 deletions apps/nestjs-backend/src/configs/auth.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export const authConfig = registerAs('auth', () => ({
iv: process.env.BACKEND_ACCESS_TOKEN_ENCRYPTION_IV ?? 'i0vKGXBWkzyAoGf4',
},
},
socialAuthProviders: process.env.SOCIAL_AUTH_PROVIDERS?.split(',') ?? [],
github: {
clientID: process.env.BACKEND_GITHUB_CLIENT_ID,
clientSecret: process.env.BACKEND_GITHUB_CLIENT_SECRET,
},
}));

export const AuthConfig = () => Inject(authConfig.KEY);
Expand Down
20 changes: 20 additions & 0 deletions apps/nestjs-backend/src/configs/env.validation.schema.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/naming-convention */
import Joi from 'joi';

export const envValidationSchema = Joi.object({
Expand Down Expand Up @@ -37,4 +38,23 @@ export const envValidationSchema = Joi.object({
.pattern(/^(redis:\/\/|rediss:\/\/)/)
.message('Cache `redis` the URI must start with the protocol `redis://` or `rediss://`'),
}),
// github auth
BACKEND_GITHUB_CLIENT_ID: Joi.when('SOCIAL_AUTH_PROVIDERS', {
is: Joi.string()
.regex(/(^|,)(github)(,|$)/)
.required(),
then: Joi.string().required().messages({
'any.required':
'The `BACKEND_GITHUB_CLIENT_ID` is required when `SOCIAL_AUTH_PROVIDERS` includes `github`',
}),
}),
BACKEND_GITHUB_CLIENT_SECRET: Joi.when('SOCIAL_AUTH_PROVIDERS', {
is: Joi.string()
.regex(/(^|,)(github)(,|$)/)
.required(),
then: Joi.string().required().messages({
'any.required':
'The `BACKEND_GITHUB_CLIENT_SECRET` is required when `SOCIAL_AUTH_PROVIDERS` includes `github`',
}),
}),
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Readable as ReadableStream } from 'node:stream';
import { BadRequestException } from '@nestjs/common';
import { UploadType } from '@teable/openapi';
import { storageConfig } from '../../../configs/storage';
Expand All @@ -16,7 +17,7 @@ export default abstract class StorageAdapter {
}
};

static readonly getDir = (type: UploadType) => {
static readonly getDir = (type: UploadType): string => {
switch (type) {
case UploadType.Table:
return 'table';
Expand Down Expand Up @@ -79,7 +80,7 @@ export default abstract class StorageAdapter {
path: string,
filePath: string,
metadata: Record<string, unknown>
): Promise<string>;
): Promise<{ hash: string; url: string }>;

/**
* uploadFile with file stream
Expand All @@ -91,7 +92,7 @@ export default abstract class StorageAdapter {
abstract uploadFile(
bucket: string,
path: string,
stream: Buffer,
stream: Buffer | ReadableStream,
metadata?: Record<string, unknown>
): Promise<string>;
): Promise<{ hash: string; url: string }>;
}
50 changes: 38 additions & 12 deletions apps/nestjs-backend/src/features/attachments/plugins/local.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { createReadStream, createWriteStream } from 'fs';
import { join, resolve, dirname } from 'path';
import { type Readable as ReadableStream } from 'node:stream';
import { join, resolve } from 'path';
import { BadRequestException, Injectable } from '@nestjs/common';
import { getRandomString } from '@teable/core';
import type { Request } from 'express';
import * as fse from 'fs-extra';
import sharp from 'sharp';
import { CacheService } from '../../../cache/cache.service';
import { IStorageConfig, StorageConfig } from '../../../configs/storage';
import { FileUtils } from '../../../utils';
import { Encryptor } from '../../../utils/encryptor';
import { getFullStorageUrl } from '../../../utils/full-storage-url';
import { second } from '../../../utils/second';
Expand Down Expand Up @@ -229,22 +231,46 @@ export class LocalStorage implements StorageAdapter {
filePath: string,
_metadata: Record<string, unknown>
) {
this.save(filePath, join(bucket, path));
return join(this.readPath, bucket, path);
const hash = await FileUtils.getHash(filePath);
await this.save(filePath, join(bucket, path));
return {
hash,
url: join(this.readPath, bucket, path),
};
}

async uploadFile(
bucket: string,
path: string,
stream: Buffer,
stream: Buffer | ReadableStream,
_metadata?: Record<string, unknown>
): Promise<string> {
const distPath = resolve(this.storageDir);
const newFilePath = resolve(distPath, join(bucket, path));

await fse.ensureDir(dirname(newFilePath));

await fse.writeFile(newFilePath, stream);
return join(this.readPath, bucket, path);
) {
const name = getRandomString(12);
const temPath = resolve(this.temporaryDir, name);
if (stream instanceof Buffer) {
await fse.writeFile(temPath, stream);
} else {
await new Promise<void>((resolve, reject) => {
const writer = createWriteStream(temPath);
stream.pipe(writer);
stream.on('end', function () {
writer.end();
writer.close();
resolve();
});
stream.on('error', (err) => {
writer.end();
writer.close();
this.deleteFile(path);
reject(err);
});
});
}
const hash = await FileUtils.getHash(temPath);
await this.save(temPath, join(bucket, path));
return {
hash,
url: join(this.readPath, bucket, path),
};
}
}
19 changes: 13 additions & 6 deletions apps/nestjs-backend/src/features/attachments/plugins/minio.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/naming-convention */
import type { Readable as ReadableStream } from 'node:stream';
import { join } from 'path';
import { BadRequestException, Injectable } from '@nestjs/common';
import { getRandomString } from '@teable/core';
Expand Down Expand Up @@ -101,17 +102,23 @@ export class MinioStorage implements StorageAdapter {
filePath: string,
metadata: Record<string, unknown>
) {
await this.minioClient.fPutObject(bucket, path, filePath, metadata);
return `/${bucket}/${path}`;
const { etag: hash } = await this.minioClient.fPutObject(bucket, path, filePath, metadata);
return {
hash,
url: `/${bucket}/${path}`,
};
}

async uploadFile(
bucket: string,
path: string,
stream: Buffer,
stream: Buffer | ReadableStream,
metadata?: Record<string, unknown>
): Promise<string> {
await this.minioClient.putObject(bucket, path, stream, metadata);
return `/${bucket}/${path}`;
) {
const { etag: hash } = await this.minioClient.putObject(bucket, path, stream, metadata);
return {
hash,
url: `/${bucket}/${path}`,
};
}
}
2 changes: 2 additions & 0 deletions apps/nestjs-backend/src/features/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { AuthGuard } from './guard/auth.guard';
import { SessionStoreService } from './session/session-store.service';
import { SessionModule } from './session/session.module';
import { SessionSerializer } from './session/session.serializer';
import { SocialModule } from './social/social.module';
import { AccessTokenStrategy } from './strategies/access-token.strategy';
import { LocalStrategy } from './strategies/local.strategy';
import { SessionStrategy } from './strategies/session.strategy';
Expand All @@ -19,6 +20,7 @@ import { SessionStrategy } from './strategies/session.strategy';
PassportModule.register({ session: true }),
SessionModule,
AccessTokenModule,
SocialModule,
],
providers: [
AuthService,
Expand Down
16 changes: 9 additions & 7 deletions apps/nestjs-backend/src/features/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,15 @@ export class AuthService {
throw new HttpException(`User ${email} is already registered`, HttpStatus.BAD_REQUEST);
}
const { salt, hashPassword } = await this.encodePassword(password);
return await this.userService.createUser({
id: generateUserId(),
name: email.split('@')[0],
email,
salt,
password: hashPassword,
lastSignTime: new Date().toISOString(),
return await this.prismaService.$tx(async () => {
return await this.userService.createUser({
id: generateUserId(),
name: email.split('@')[0],
email,
salt,
password: hashPassword,
lastSignTime: new Date().toISOString(),
});
});
}

Expand Down
5 changes: 5 additions & 0 deletions apps/nestjs-backend/src/features/auth/guard/github.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class GithubGuard extends AuthGuard('github') {}
39 changes: 39 additions & 0 deletions apps/nestjs-backend/src/features/auth/oauth/oauth.store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { getRandomString } from '@teable/core';
import type { Request } from 'express';
import { CacheService } from '../../../cache/cache.service';
import type { IOauth2State } from '../../../cache/types';
import { second } from '../../../utils/second';

@Injectable()
export class OauthStoreService {
key: string = 'oauth2:';

constructor(private readonly cacheService: CacheService) {}

async store(req: Request, callback: (err: unknown, stateId: string) => void) {
const random = getRandomString(16);
await this.cacheService.set(
`oauth2:${random}`,
{
redirectUri: req.query.redirect_uri as string,
},
second('12h')
);
callback(null, random);
}

async verify(
_req: unknown,
stateId: string,
callback: (err: unknown, ok: boolean, state: IOauth2State | string) => void
) {
const state = await this.cacheService.get(`oauth2:${stateId}`);
if (state) {
await this.cacheService.del(`oauth2:${stateId}`);
callback(null, true, state);
} else {
callback(null, false, 'Invalid authorization request state');
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common';
import { Response } from 'express';
import type { IOauth2State } from '../../../../cache/types';
import { Public } from '../../decorators/public.decorator';
import { GithubGuard } from '../../guard/github.guard';

@Controller('api/auth')
export class GithubController {
@Get('/github')
@Public()
@UseGuards(GithubGuard)
// eslint-disable-next-line @typescript-eslint/no-empty-function
async githubAuthenticate() {}

@Get('/github/callback')
@Public()
@UseGuards(GithubGuard)
async githubCallback(@Req() req: Express.Request, @Res({ passthrough: true }) res: Response) {
const user = req.user!;
// set cookie, passport login
await new Promise<void>((resolve, reject) => {
req.login(user, (err) => (err ? reject(err) : resolve()));
});
const redirectUri = (req.authInfo as { state: IOauth2State })?.state?.redirectUri;
return res.redirect(redirectUri || '/');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { UserModule } from '../../../user/user.module';
import { OauthStoreService } from '../../oauth/oauth.store';
import { GithubStrategy } from '../../strategies/github.strategy';
import { GithubController } from './github.controller';

@Module({
imports: [UserModule],
providers: [GithubStrategy, OauthStoreService],
exports: [],
controllers: [GithubController],
})
export class GithubModule {}
12 changes: 12 additions & 0 deletions apps/nestjs-backend/src/features/auth/social/social.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ConditionalModule } from '@nestjs/config';
import { GithubModule } from './github/github.module';

@Module({
imports: [
ConditionalModule.registerWhen(GithubModule, (env) => {
return Boolean(env.SOCIAL_AUTH_PROVIDERS?.split(',')?.includes('github'));
}),
],
})
export class SocialModule {}

0 comments on commit 61044b1

Please sign in to comment.