diff --git a/server/src/authentication/dto/email-signin.dto.ts b/server/src/authentication/dto/email-signin.dto.ts new file mode 100644 index 0000000000..4528428bef --- /dev/null +++ b/server/src/authentication/dto/email-signin.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' +import { + IsNotEmpty, + IsString, + Length, + IsOptional, + Matches, + IsEmail, +} from 'class-validator' + +export class EmailSigninDto { + @ApiProperty({ + description: 'email', + }) + @IsString() + @IsNotEmpty() + @IsEmail() + email: string + + @ApiProperty({}) + @IsNotEmpty() + @Length(6, 6) + code: string + + @ApiProperty({ + description: 'username', + example: 'laf-user', + }) + @IsOptional() + @IsString() + @Length(3, 64) + username: string + + @ApiProperty({ + description: 'password, 8-64 characters', + example: 'laf-user-password', + }) + @IsOptional() + @IsString() + @Length(8, 64) + password: string + + @ApiPropertyOptional({ + description: 'invite code', + example: 'iLeMi7x', + }) + @IsOptional() + @IsString() + @Matches(/^\S+$/, { message: 'invalid characters' }) + inviteCode: string +} diff --git a/server/src/authentication/dto/passwd-reset.dto.ts b/server/src/authentication/dto/passwd-reset.dto.ts index 3890265f48..c19e7e7cb3 100644 --- a/server/src/authentication/dto/passwd-reset.dto.ts +++ b/server/src/authentication/dto/passwd-reset.dto.ts @@ -1,6 +1,12 @@ import { ApiProperty } from '@nestjs/swagger' -import { IsEnum, IsNotEmpty, IsString, Length, Matches } from 'class-validator' -import { SmsVerifyCodeType } from '../entities/sms-verify-code' +import { + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + Length, + Matches, +} from 'class-validator' export class PasswdResetDto { @ApiProperty({ @@ -17,9 +23,18 @@ export class PasswdResetDto { example: '13805718888', }) @IsString() + @IsOptional() @Matches(/^1[3-9]\d{9}$/) phone: string + @ApiProperty({ + description: 'email', + }) + @IsString() + @IsEmail() + @IsOptional() + email: string + @ApiProperty({ description: 'verify code', example: '032456', @@ -27,11 +42,4 @@ export class PasswdResetDto { @IsString() @Length(6, 6) code: string - - @ApiProperty({ - description: 'type', - example: 'ResetPassword', - }) - @IsEnum(SmsVerifyCodeType) - type: SmsVerifyCodeType } diff --git a/server/src/authentication/dto/passwd-signup.dto.ts b/server/src/authentication/dto/passwd-signup.dto.ts index 679131c68b..6550bbd3cb 100644 --- a/server/src/authentication/dto/passwd-signup.dto.ts +++ b/server/src/authentication/dto/passwd-signup.dto.ts @@ -1,13 +1,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' import { - IsEnum, + IsEmail, IsNotEmpty, IsOptional, IsString, Length, Matches, } from 'class-validator' -import { SmsVerifyCodeType } from '../entities/sms-verify-code' export class PasswdSignupDto { @ApiProperty({ @@ -40,21 +39,21 @@ export class PasswdSignupDto { phone: string @ApiPropertyOptional({ - description: 'verify code', - example: '032456', + description: 'email', }) @IsOptional() @IsString() - @Length(6, 6) - code: string + @IsEmail() + email: string @ApiPropertyOptional({ - description: 'type', - example: 'Signup', + description: 'verify code', + example: '032456', }) @IsOptional() - @IsEnum(SmsVerifyCodeType) - type: SmsVerifyCodeType + @IsString() + @Length(6, 6) + code: string @ApiPropertyOptional({ description: 'invite code', diff --git a/server/src/authentication/email/email.controller.ts b/server/src/authentication/email/email.controller.ts index 82ccff021d..76a786c58b 100644 --- a/server/src/authentication/email/email.controller.ts +++ b/server/src/authentication/email/email.controller.ts @@ -5,13 +5,22 @@ import { ResponseUtil } from 'src/utils/response' import { SendEmailCodeDto } from '../dto/send-email-code.dto' import { EmailService } from './email.service' import { GetClientIPFromRequest } from 'src/utils/getter' +import { UserService } from 'src/user/user.service' +import { EmailSigninDto } from '../dto/email-signin.dto' +import { EmailVerifyCodeType } from '../entities/email-verify-code' +import { AuthenticationService } from '../authentication.service' +import { AuthBindingType, AuthProviderBinding } from '../entities/types' @ApiTags('Authentication') @Controller('auth') export class EmailController { private readonly logger = new Logger(EmailController.name) - constructor(private readonly emailService: EmailService) {} + constructor( + private readonly emailService: EmailService, + private readonly authService: AuthenticationService, + private readonly userService: UserService, + ) {} /** * send email code @@ -30,4 +39,50 @@ export class EmailController { } return ResponseUtil.ok('success') } + + /** + * Signin by email and verify code + */ + @ApiOperation({ summary: 'Signin by email and verify code' }) + @ApiResponse({ type: ResponseUtil }) + @Post('email/signin') + async signin(@Body() dto: EmailSigninDto) { + const { email, code } = dto + // check if code valid + const err = await this.emailService.validateCode( + email, + code, + EmailVerifyCodeType.Signin, + ) + if (err) return ResponseUtil.error(err) + + // check if user exists + const user = await this.userService.findOneByEmail(email) + if (user) { + const token = this.emailService.signin(user) + return ResponseUtil.ok(token) + } + + // user not exist + const provider = await this.authService.getEmailProvider() + if (provider.register === false) { + return ResponseUtil.error('user not exists') + } + + // check if username and password is needed + let signupWithUsername = false + const bind = provider.bind as any as AuthProviderBinding + if (bind.username === AuthBindingType.Required) { + const { username, password } = dto + signupWithUsername = true + if (!username || !password) { + return ResponseUtil.error('username and password is required') + } + } + + // user not exist, signup and signin + const newUser = await this.emailService.signup(dto, signupWithUsername) + const data = this.emailService.signin(newUser) + return ResponseUtil.ok(data) + } } diff --git a/server/src/authentication/email/email.service.ts b/server/src/authentication/email/email.service.ts index 4e30765131..e52b3074fb 100644 --- a/server/src/authentication/email/email.service.ts +++ b/server/src/authentication/email/email.service.ts @@ -10,13 +10,45 @@ import { LIMIT_CODE_PER_IP_PER_DAY, MILLISECONDS_PER_DAY, MILLISECONDS_PER_MINUTE, + TASK_LOCK_INIT_TIME, } from 'src/constants' import { isEmail } from 'class-validator' import { Injectable } from '@nestjs/common' +import { ObjectId } from 'mongodb' +import { User } from 'src/user/entities/user' +import { + InviteCode, + InviteCodeState, + InviteRelation, +} from '../entities/invite-code' +import { Setting, SettingKey } from 'src/setting/entities/setting' +import { + AccountChargeOrder, + AccountChargePhase, + Currency, + PaymentChannelType, +} from 'src/account/entities/account-charge-order' +import { AccountTransaction } from 'src/account/entities/account-transaction' +import { Account } from 'src/account/entities/account' +import { UserProfile } from 'src/user/entities/user-profile' +import { + UserPassword, + UserPasswordState, +} from 'src/user/entities/user-password' +import { hashPassword } from 'src/utils/crypto' +import { EmailSigninDto } from '../dto/email-signin.dto' +import { AuthenticationService } from '../authentication.service' +import { UserService } from 'src/user/user.service' +import { AccountService } from 'src/account/account.service' @Injectable() export class EmailService { - constructor(private readonly mailerService: MailerService) {} + constructor( + private readonly mailerService: MailerService, + private readonly authService: AuthenticationService, + private readonly userService: UserService, + private readonly accountService: AccountService, + ) {} private readonly db = SystemDatabase.db async sendCode(email: string, type: EmailVerifyCodeType, ip: string) { @@ -121,4 +153,164 @@ export class EmailService { return null } + + /** + * Signup a user by email + * @param dto + * @returns + */ + async signup(dto: EmailSigninDto, withUsername = false) { + const { email, username, password, inviteCode } = dto + + const client = SystemDatabase.client + const session = client.startSession() + + try { + session.startTransaction() + const randomUsername = new ObjectId() + // create user + const user = await this.db.collection('User').insertOne( + { + email, + username: username || randomUsername.toString(), + createdAt: new Date(), + updatedAt: new Date(), + }, + { session }, + ) + + // create invite relation and add invite profit + if (inviteCode) { + const inviteCodeInfo = await this.db + .collection('InviteCode') + .findOne({ + code: inviteCode, + state: InviteCodeState.Enabled, + }) + + if (inviteCodeInfo) { + const account = await this.accountService.findOne(inviteCodeInfo.uid) + // get invitation Profit Amount + let amount = 0 + const inviteProfit = await this.db + .collection('Setting') + .findOne({ + key: SettingKey.InvitationProfit, + public: true, + }) + if (inviteProfit) { + amount = parseFloat(inviteProfit.value) + } + // add invite-code charge order + await this.db + .collection('AccountChargeOrder') + .insertOne( + { + accountId: account._id, + amount: amount, + currency: Currency.CNY, + phase: AccountChargePhase.Paid, + channel: PaymentChannelType.InviteCode, + createdBy: new ObjectId(inviteCodeInfo.uid), + lockedAt: TASK_LOCK_INIT_TIME, + createdAt: new Date(), + updatedAt: new Date(), + }, + { session }, + ) + // update account balance + const accountAfterUpdate = await this.db + .collection('Account') + .findOneAndUpdate( + { _id: account._id }, + { + $inc: { balance: amount }, + $set: { updatedAt: new Date() }, + }, + { session, returnDocument: 'after' }, + ) + + // add transaction record + const transaction = await this.db + .collection('AccountTransaction') + .insertOne( + { + accountId: account._id, + amount: amount, + balance: accountAfterUpdate.value.balance, + message: 'Invitation profit', + createdAt: new Date(), + }, + { session }, + ) + + await this.db.collection('InviteRelation').insertOne( + { + uid: user.insertedId, + invitedBy: inviteCodeInfo.uid, + codeId: inviteCodeInfo._id, + createdAt: new Date(), + transactionId: transaction.insertedId, + }, + { session }, + ) + } + } + + // create profile + await this.db.collection('UserProfile').insertOne( + { + uid: user.insertedId, + name: username, + createdAt: new Date(), + updatedAt: new Date(), + }, + { session }, + ) + + if (withUsername) { + // create password + await this.db.collection('UserPassword').insertOne( + { + uid: user.insertedId, + password: hashPassword(password), + state: UserPasswordState.Active, + createdAt: new Date(), + updatedAt: new Date(), + }, + { session }, + ) + } + + await session.commitTransaction() + return await this.userService.findOneById(user.insertedId) + } catch (err) { + await session.abortTransaction() + throw err + } finally { + await session.endSession() + } + } + + /** + * signin a user, return token and if bind password + * @param user user + * @returns token and if bind password + */ + signin(user: User) { + const token = this.authService.getAccessTokenByUser(user) + return token + } + + // check if current user has bind password + async ifBindPassword(user: User) { + const count = await this.db + .collection('UserPassword') + .countDocuments({ + uid: user._id, + state: UserPasswordState.Active, + }) + + return count > 0 + } } diff --git a/server/src/authentication/entities/email-verify-code.ts b/server/src/authentication/entities/email-verify-code.ts index d8d78866a5..c83baae6e4 100644 --- a/server/src/authentication/entities/email-verify-code.ts +++ b/server/src/authentication/entities/email-verify-code.ts @@ -1,6 +1,9 @@ import { ObjectId } from 'mongodb' export enum EmailVerifyCodeType { + Signin = 'Signin', + Signup = 'Signup', + ResetPassword = 'ResetPassword', Bind = 'bind', Unbind = 'Unbind', } diff --git a/server/src/authentication/user-passwd/user-password.controller.ts b/server/src/authentication/user-passwd/user-password.controller.ts index e07c5291a4..3c87aeb9a9 100644 --- a/server/src/authentication/user-passwd/user-password.controller.ts +++ b/server/src/authentication/user-passwd/user-password.controller.ts @@ -10,6 +10,9 @@ import { AuthBindingType, AuthProviderBinding } from '../entities/types' import { SmsService } from '../phone/sms.service' import { PasswdResetDto } from '../dto/passwd-reset.dto' import { PasswdCheckDto } from '../dto/passwd-check.dto' +import { EmailService } from '../email/email.service' +import { EmailVerifyCodeType } from '../entities/email-verify-code' +import { SmsVerifyCodeType } from '../entities/sms-verify-code' @ApiTags('Authentication') @Controller('auth') @@ -20,6 +23,7 @@ export class UserPasswordController { private readonly passwdService: UserPasswordService, private readonly authService: AuthenticationService, private readonly smsService: SmsService, + private readonly emailService: EmailService, ) {} /** @@ -29,7 +33,7 @@ export class UserPasswordController { @ApiResponse({ type: ResponseUtil }) @Post('passwd/signup') async signup(@Body() dto: PasswdSignupDto) { - const { username, password, phone, inviteCode } = dto + const { username, password, phone, email, inviteCode, code } = dto // check if user exists const doc = await this.userService.findOneByUsername(username) if (doc) { @@ -45,13 +49,33 @@ export class UserPasswordController { // valid phone code if needed const bind = provider.bind as any as AuthProviderBinding if (bind.phone === AuthBindingType.Required) { - const { phone, code, type } = dto // valid phone has been binded const user = await this.userService.findOneByPhone(phone) if (user) { return ResponseUtil.error('phone has been binded') } - const err = await this.smsService.validateCode(phone, code, type) + const err = await this.smsService.validateCode( + phone, + code, + SmsVerifyCodeType.Signup, + ) + if (err) { + return ResponseUtil.error(err) + } + } + + // valid email code if needed + if (bind.email === AuthBindingType.Required) { + // valid email has been binded + const user = await this.userService.findOneByEmail(email) + if (user) { + return ResponseUtil.error('email has been binded') + } + const err = await this.emailService.validateCode( + email, + code, + EmailVerifyCodeType.Signup, + ) if (err) { return ResponseUtil.error(err) } @@ -62,6 +86,7 @@ export class UserPasswordController { username, password, phone, + email, inviteCode, ) @@ -113,14 +138,37 @@ export class UserPasswordController { @Post('passwd/reset') async reset(@Body() dto: PasswdResetDto) { // valid phone code - const { phone, code, type } = dto - const err = await this.smsService.validateCode(phone, code, type) - if (err) { - return ResponseUtil.error(err) + const { phone, code, email } = dto + if (!phone && !email) { + return ResponseUtil.error('phone or email should be provided') + } + + if (phone) { + const err = await this.smsService.validateCode( + phone, + code, + SmsVerifyCodeType.ResetPassword, + ) + if (err) { + return ResponseUtil.error(err) + } + } + + if (email) { + const err = await this.emailService.validateCode( + email, + code, + EmailVerifyCodeType.ResetPassword, + ) + if (err) { + return ResponseUtil.error(err) + } } - // find user by phone - const user = await this.userService.findOneByPhone(phone) + // find user by phone or email + const user = phone + ? await this.userService.findOneByPhone(phone) + : await this.userService.findOneByEmail(email) if (!user) { return ResponseUtil.error('user not found') } diff --git a/server/src/authentication/user-passwd/user-password.service.ts b/server/src/authentication/user-passwd/user-password.service.ts index af4ac931ec..0dad903da9 100644 --- a/server/src/authentication/user-passwd/user-password.service.ts +++ b/server/src/authentication/user-passwd/user-password.service.ts @@ -43,6 +43,7 @@ export class UserPasswordService { username: string, password: string, phone: string, + email: string, inviteCode: string, ) { const client = SystemDatabase.client @@ -55,7 +56,7 @@ export class UserPasswordService { { username, phone, - email: null, + email, createdAt: new Date(), updatedAt: new Date(), }, diff --git a/server/src/function/entities/cloud-function.ts b/server/src/function/entities/cloud-function.ts index ee00976c58..b48806ca60 100644 --- a/server/src/function/entities/cloud-function.ts +++ b/server/src/function/entities/cloud-function.ts @@ -1,11 +1,23 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' import { ObjectId } from 'mongodb' -export type CloudFunctionSource = { +export class CloudFunctionSource { + @ApiProperty() code: string + + @ApiProperty() compiled: string + + @ApiPropertyOptional() uri?: string + + @ApiProperty() version: number + + @ApiPropertyOptional() hash?: string + + @ApiPropertyOptional() lang?: string } @@ -19,15 +31,36 @@ export enum HttpMethod { } export class CloudFunction { + @ApiProperty({ type: String }) _id?: ObjectId + + @ApiProperty() appid: string + + @ApiProperty() name: string + + @ApiProperty({ type: CloudFunctionSource }) source: CloudFunctionSource + + @ApiProperty() desc: string + + @ApiProperty({ type: String, isArray: true }) tags: string[] + + @ApiProperty({ enum: HttpMethod, isArray: true }) methods: HttpMethod[] + + @ApiPropertyOptional() params?: any + + @ApiProperty() createdAt: Date + + @ApiProperty() updatedAt: Date + + @ApiProperty({ type: String }) createdBy: ObjectId }