Skip to content

Commit

Permalink
feat: setting to force oauth2/oidc login & refactor (#1131)
Browse files Browse the repository at this point in the history
* feat: setting to force oauth2/oidc login & refactor

* feat: setting to force oauth2/oidc login
  • Loading branch information
kpodp0ra authored Dec 10, 2024
1 parent 168129f commit 6067b25
Show file tree
Hide file tree
Showing 16 changed files with 417 additions and 338 deletions.
2 changes: 2 additions & 0 deletions apps/nestjs-backend/src/configs/env.validation.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,6 @@ export const envValidationSchema = Joi.object({
'The `BACKEND_GITHUB_CLIENT_SECRET` is required when `SOCIAL_AUTH_PROVIDERS` includes `github`',
}),
}),

PASSWORD_LOGIN_DISABLED: Joi.string().equal('true').optional(),
});
91 changes: 8 additions & 83 deletions apps/nestjs-backend/src/features/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,62 +1,25 @@
import { Body, Controller, Get, HttpCode, Patch, Post, Req, Res, UseGuards } from '@nestjs/common';
import { Controller, Get, HttpCode, Post, Req, Res } from '@nestjs/common';
import type { IUserMeVo } from '@teable/openapi';
import {
IAddPasswordRo,
IChangePasswordRo,
IResetPasswordRo,
ISendResetPasswordEmailRo,
ISignup,
addPasswordRoSchema,
changePasswordRoSchema,
resetPasswordRoSchema,
sendResetPasswordEmailRoSchema,
signupSchema,
} from '@teable/openapi';
import { Response, Request } from 'express';
import { Response } from 'express';
import { AUTH_SESSION_COOKIE_NAME } from '../../const';
import { ZodValidationPipe } from '../../zod.validation.pipe';
import { AuthService } from './auth.service';
import { Public } from './decorators/public.decorator';
import { TokenAccess } from './decorators/token.decorator';
import { LocalAuthGuard } from './guard/local-auth.guard';
import { pickUserMe } from './utils';
import { SessionService } from './session/session.service';

@Controller('api/auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}

@Public()
@UseGuards(LocalAuthGuard)
@HttpCode(200)
@Post('signin')
async signin(@Req() req: Express.Request): Promise<IUserMeVo> {
return req.user as IUserMeVo;
}
constructor(
private readonly authService: AuthService,
private readonly sessionService: SessionService
) {}

@Post('signout')
@HttpCode(200)
async signout(@Req() req: Express.Request, @Res({ passthrough: true }) res: Response) {
await this.authService.signout(req);
await this.sessionService.signout(req);
res.clearCookie(AUTH_SESSION_COOKIE_NAME);
}

@Public()
@Post('signup')
async signup(
@Body(new ZodValidationPipe(signupSchema)) body: ISignup,
@Res({ passthrough: true }) res: Response,
@Req() req: Express.Request
): Promise<IUserMeVo> {
const user = pickUserMe(
await this.authService.signup(body.email, body.password, body.defaultSpaceName, body.refMeta)
);
// set cookie, passport login
await new Promise<void>((resolve, reject) => {
req.login(user, (err) => (err ? reject(err) : resolve()));
});
return user;
}

@Get('/user/me')
async me(@Req() request: Express.Request) {
return request.user;
Expand All @@ -67,42 +30,4 @@ export class AuthController {
async user(@Req() request: Express.Request) {
return this.authService.getUserInfo(request.user as IUserMeVo);
}

@Patch('/change-password')
async changePassword(
@Body(new ZodValidationPipe(changePasswordRoSchema)) changePasswordRo: IChangePasswordRo,
@Req() req: Request,
@Res({ passthrough: true }) res: Response
) {
await this.authService.changePassword(changePasswordRo);
await this.authService.signout(req);
res.clearCookie(AUTH_SESSION_COOKIE_NAME);
}

@Post('/send-reset-password-email')
@Public()
async sendResetPasswordEmail(
@Body(new ZodValidationPipe(sendResetPasswordEmailRoSchema)) body: ISendResetPasswordEmailRo
) {
return this.authService.sendResetPasswordEmail(body.email);
}

@Post('/reset-password')
@Public()
async resetPassword(
@Res({ passthrough: true }) res: Response,
@Body(new ZodValidationPipe(resetPasswordRoSchema)) body: IResetPasswordRo
) {
await this.authService.resetPassword(body.code, body.password);
res.clearCookie(AUTH_SESSION_COOKIE_NAME);
}

@Post('/add-password')
async addPassword(
@Res({ passthrough: true }) res: Response,
@Body(new ZodValidationPipe(addPasswordRoSchema)) body: IAddPasswordRo
) {
await this.authService.addPassword(body.password);
res.clearCookie(AUTH_SESSION_COOKIE_NAME);
}
}
7 changes: 5 additions & 2 deletions apps/nestjs-backend/src/features/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { Module } from '@nestjs/common';
import { ConditionalModule } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { AccessTokenModule } from '../access-token/access-token.module';
import { UserModule } from '../user/user.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { AuthGuard } from './guard/auth.guard';
import { LocalAuthModule } from './local-auth/local-auth.module';
import { PermissionModule } from './permission.module';
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';

@Module({
Expand All @@ -20,12 +21,14 @@ import { SessionStrategy } from './strategies/session.strategy';
PassportModule.register({ session: true }),
SessionModule,
AccessTokenModule,
ConditionalModule.registerWhen(LocalAuthModule, (env) => {
return Boolean(env.PASSWORD_LOGIN_DISABLED !== 'true');
}),
SocialModule,
PermissionModule,
],
providers: [
AuthService,
LocalStrategy,
SessionStrategy,
AuthGuard,
SessionSerializer,
Expand Down
205 changes: 4 additions & 201 deletions apps/nestjs-backend/src/features/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,214 +1,17 @@
import { BadRequestException, HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { generateUserId, getRandomString } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import type { IChangePasswordRo, IRefMeta, IUserInfoVo, IUserMeVo } from '@teable/openapi';
import * as bcrypt from 'bcrypt';
import { isEmpty, omit, pick } from 'lodash';
import { Injectable } from '@nestjs/common';
import type { IUserInfoVo, IUserMeVo } from '@teable/openapi';
import { omit, pick } from 'lodash';
import { ClsService } from 'nestjs-cls';
import { CacheService } from '../../cache/cache.service';
import { AuthConfig, type IAuthConfig } from '../../configs/auth.config';
import { MailConfig, type IMailConfig } from '../../configs/mail.config';
import { EventEmitterService } from '../../event-emitter/event-emitter.service';
import { Events } from '../../event-emitter/events';
import { UserSignUpEvent } from '../../event-emitter/events/user/user.event';
import type { IClsStore } from '../../types/cls';
import { second } from '../../utils/second';
import { MailSenderService } from '../mail-sender/mail-sender.service';
import { UserService } from '../user/user.service';
import { PermissionService } from './permission.service';
import { SessionStoreService } from './session/session-store.service';

@Injectable()
export class AuthService {
constructor(
private readonly prismaService: PrismaService,
private readonly userService: UserService,
private readonly cls: ClsService<IClsStore>,
private readonly sessionStoreService: SessionStoreService,
private readonly mailSenderService: MailSenderService,
private readonly cacheService: CacheService,
private readonly permissionService: PermissionService,
private readonly eventEmitterService: EventEmitterService,
@AuthConfig() private readonly authConfig: IAuthConfig,
@MailConfig() private readonly mailConfig: IMailConfig
private readonly permissionService: PermissionService
) {}

private async encodePassword(password: string) {
const salt = await bcrypt.genSalt(10);
const hashPassword = await bcrypt.hash(password, salt);
return { salt, hashPassword };
}

private async comparePassword(
password: string,
hashPassword: string | null,
salt: string | null
) {
const _hashPassword = await bcrypt.hash(password || '', salt || '');
return _hashPassword === hashPassword;
}

private async getUserByIdOrThrow(userId: string) {
const user = await this.userService.getUserById(userId);
if (!user) {
throw new BadRequestException('User not found');
}
return user;
}

async validateUserByEmail(email: string, pass: string) {
const user = await this.userService.getUserByEmail(email);
if (!user || (user.accounts.length === 0 && user.password == null)) {
throw new BadRequestException(`${email} not registered`);
}

if (!user.password) {
throw new BadRequestException('Password is not set');
}

if (user.isSystem) {
throw new BadRequestException('User is system user');
}

const { password, salt, ...result } = user;
return (await this.comparePassword(pass, password, salt)) ? { ...result, password } : null;
}

async signup(email: string, password: string, defaultSpaceName?: string, refMeta?: IRefMeta) {
const user = await this.userService.getUserByEmail(email);
if (user && (user.password !== null || user.accounts.length > 0)) {
throw new HttpException(`User ${email} is already registered`, HttpStatus.BAD_REQUEST);
}
if (user && user.isSystem) {
throw new HttpException(`User ${email} is system user`, HttpStatus.BAD_REQUEST);
}
const { salt, hashPassword } = await this.encodePassword(password);
const res = await this.prismaService.$tx(async () => {
if (user) {
return await this.prismaService.user.update({
where: { id: user.id, deletedTime: null },
data: {
salt,
password: hashPassword,
lastSignTime: new Date().toISOString(),
refMeta: refMeta ? JSON.stringify(refMeta) : undefined,
},
});
}
return await this.userService.createUserWithSettingCheck(
{
id: generateUserId(),
name: email.split('@')[0],
email,
salt,
password: hashPassword,
lastSignTime: new Date().toISOString(),
refMeta: isEmpty(refMeta) ? undefined : JSON.stringify(refMeta),
},
undefined,
defaultSpaceName
);
});
this.eventEmitterService.emitAsync(Events.USER_SIGNUP, new UserSignUpEvent(res.id));
return res;
}

async signout(req: Express.Request) {
await new Promise<void>((resolve, reject) => {
req.session.destroy(function (err) {
// cannot access session here
if (err) {
reject(err);
return;
}
resolve();
});
});
}

async changePassword({ password, newPassword }: IChangePasswordRo) {
const userId = this.cls.get('user.id');
const user = await this.getUserByIdOrThrow(userId);

const { password: currentHashPassword, salt } = user;
if (!(await this.comparePassword(password, currentHashPassword, salt))) {
throw new BadRequestException('Password is incorrect');
}
const { salt: newSalt, hashPassword: newHashPassword } = await this.encodePassword(newPassword);
await this.prismaService.txClient().user.update({
where: { id: userId, deletedTime: null },
data: {
password: newHashPassword,
salt: newSalt,
},
});
// clear session
await this.sessionStoreService.clearByUserId(userId);
}

async sendResetPasswordEmail(email: string) {
const user = await this.userService.getUserByEmail(email);
if (!user || (user.accounts.length === 0 && user.password == null)) {
throw new BadRequestException(`${email} not registered`);
}

const resetPasswordCode = getRandomString(30);

const url = `${this.mailConfig.origin}/auth/reset-password?code=${resetPasswordCode}`;
const resetPasswordEmailOptions = this.mailSenderService.resetPasswordEmailOptions({
name: user.name,
email: user.email,
resetPasswordUrl: url,
});
await this.mailSenderService.sendMail({
to: user.email,
...resetPasswordEmailOptions,
});
await this.cacheService.set(
`reset-password-email:${resetPasswordCode}`,
{ userId: user.id },
second(this.authConfig.resetPasswordEmailExpiresIn)
);
}

async resetPassword(code: string, newPassword: string) {
const resetPasswordEmail = await this.cacheService.get(`reset-password-email:${code}`);
if (!resetPasswordEmail) {
throw new BadRequestException('Token is invalid');
}
const { userId } = resetPasswordEmail;
const { salt, hashPassword } = await this.encodePassword(newPassword);
await this.prismaService.txClient().user.update({
where: { id: userId, deletedTime: null },
data: {
password: hashPassword,
salt,
},
});
await this.cacheService.del(`reset-password-email:${code}`);
// clear session
await this.sessionStoreService.clearByUserId(userId);
}

async addPassword(newPassword: string) {
const userId = this.cls.get('user.id');
const user = await this.getUserByIdOrThrow(userId);

if (user.password) {
throw new BadRequestException('Password is already set');
}
const { salt, hashPassword } = await this.encodePassword(newPassword);
await this.prismaService.txClient().user.update({
where: { id: userId, deletedTime: null, password: null },
data: {
password: hashPassword,
salt,
},
});
// clear session
await this.sessionStoreService.clearByUserId(userId);
}

async getUserInfo(user: IUserMeVo): Promise<IUserInfoVo> {
const res = pick(user, ['id', 'email', 'avatar', 'name']);
const accessTokenId = this.cls.get('accessTokenId');
Expand Down
Loading

0 comments on commit 6067b25

Please sign in to comment.