From c048087d29fdb6f0d8566a7411e0ad7f34734cb1 Mon Sep 17 00:00:00 2001 From: Pranav C Date: Mon, 10 Apr 2023 11:23:20 +0530 Subject: [PATCH] feat: user apis Signed-off-by: Pranav C --- .../nocodb-nest/src/modules/users/helpers.ts | 33 ++ .../src/modules/users/ui/auth/emailVerify.ts | 70 +++ .../modules/users/ui/auth/resetPassword.ts | 108 ++++ .../users/ui/emailTemplates/forgotPassword.ts | 171 ++++++ .../modules/users/ui/emailTemplates/invite.ts | 208 ++++++++ .../modules/users/ui/emailTemplates/verify.ts | 207 ++++++++ .../src/modules/users/users.controller.ts | 247 ++++++++- .../src/modules/users/users.service.ts | 490 +++++++++++++++++- .../src/lib/controllers/user/user.ctl.ts | 2 +- 9 files changed, 1524 insertions(+), 12 deletions(-) create mode 100644 packages/nocodb-nest/src/modules/users/helpers.ts create mode 100644 packages/nocodb-nest/src/modules/users/ui/auth/emailVerify.ts create mode 100644 packages/nocodb-nest/src/modules/users/ui/auth/resetPassword.ts create mode 100644 packages/nocodb-nest/src/modules/users/ui/emailTemplates/forgotPassword.ts create mode 100644 packages/nocodb-nest/src/modules/users/ui/emailTemplates/invite.ts create mode 100644 packages/nocodb-nest/src/modules/users/ui/emailTemplates/verify.ts diff --git a/packages/nocodb-nest/src/modules/users/helpers.ts b/packages/nocodb-nest/src/modules/users/helpers.ts new file mode 100644 index 0000000000..664a3f6ffe --- /dev/null +++ b/packages/nocodb-nest/src/modules/users/helpers.ts @@ -0,0 +1,33 @@ +import crypto from 'crypto'; +import * as jwt from 'jsonwebtoken'; +import type User from '../../models/User'; +import type { NcConfig } from '../../../interface/config'; +import type { Response } from 'express'; + +export function genJwt(user: User, config: NcConfig) { + return jwt.sign( + { + email: user.email, + firstname: user.firstname, + lastname: user.lastname, + id: user.id, + roles: user.roles, + token_version: user.token_version, + }, + config.auth.jwt.secret, + config.auth.jwt.options + ); +} + +export function randomTokenString(): string { + return crypto.randomBytes(40).toString('hex'); +} + +export function setTokenCookie(res: Response, token): void { + // create http only cookie with refresh token that expires in 7 days + const cookieOptions = { + httpOnly: true, + expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }; + res.cookie('refresh_token', token, cookieOptions); +} diff --git a/packages/nocodb-nest/src/modules/users/ui/auth/emailVerify.ts b/packages/nocodb-nest/src/modules/users/ui/auth/emailVerify.ts new file mode 100644 index 0000000000..f7412b12ba --- /dev/null +++ b/packages/nocodb-nest/src/modules/users/ui/auth/emailVerify.ts @@ -0,0 +1,70 @@ +export default ` + + + NocoDB - Verify Email + + + + + + + +
+ + + + + + Email verified successfully! + + + {{errMsg}} + + + + + + + +
+ + + + + +`; diff --git a/packages/nocodb-nest/src/modules/users/ui/auth/resetPassword.ts b/packages/nocodb-nest/src/modules/users/ui/auth/resetPassword.ts new file mode 100644 index 0000000000..514fc6d739 --- /dev/null +++ b/packages/nocodb-nest/src/modules/users/ui/auth/resetPassword.ts @@ -0,0 +1,108 @@ +export default ` + + + NocoDB - Reset Password + + + + + + + +
+ + + + + + Password reset successful! + + + + + + +
+ + + + + +`; diff --git a/packages/nocodb-nest/src/modules/users/ui/emailTemplates/forgotPassword.ts b/packages/nocodb-nest/src/modules/users/ui/emailTemplates/forgotPassword.ts new file mode 100644 index 0000000000..afb2f5849a --- /dev/null +++ b/packages/nocodb-nest/src/modules/users/ui/emailTemplates/forgotPassword.ts @@ -0,0 +1,171 @@ +export default ` + + + + + Simple Transactional Email + + + + + + + + + + + + + +`; diff --git a/packages/nocodb-nest/src/modules/users/ui/emailTemplates/invite.ts b/packages/nocodb-nest/src/modules/users/ui/emailTemplates/invite.ts new file mode 100644 index 0000000000..fc81f9409e --- /dev/null +++ b/packages/nocodb-nest/src/modules/users/ui/emailTemplates/invite.ts @@ -0,0 +1,208 @@ +export default ` + + + + + Simple Transactional Email + + + + + + + + + + + + + +`; diff --git a/packages/nocodb-nest/src/modules/users/ui/emailTemplates/verify.ts b/packages/nocodb-nest/src/modules/users/ui/emailTemplates/verify.ts new file mode 100644 index 0000000000..11702cc659 --- /dev/null +++ b/packages/nocodb-nest/src/modules/users/ui/emailTemplates/verify.ts @@ -0,0 +1,207 @@ +export default ` + + + + + Simple Transactional Email + + + + + + + + + + + + + +`; diff --git a/packages/nocodb-nest/src/modules/users/users.controller.ts b/packages/nocodb-nest/src/modules/users/users.controller.ts index ab5d035438..4028fd94ba 100644 --- a/packages/nocodb-nest/src/modules/users/users.controller.ts +++ b/packages/nocodb-nest/src/modules/users/users.controller.ts @@ -1,10 +1,249 @@ -import { Controller, Get, Request, UseGuards } from '@nestjs/common' -import { AuthGuard } from '@nestjs/passport' -import { UsersService } from './users.service' +import { + Body, + Controller, + Get, + Param, + Post, + Request, + Response, + UseGuards, +} from '@nestjs/common'; +import { promisify } from 'util'; +import { NcError } from '../../helpers/catchError'; +import { Acl } from '../../middlewares/extract-project-id/extract-project-id.middleware'; +import Noco from '../../Noco'; +import extractRolesObj from '../../utils/extractRolesObj'; +import { genJwt, randomTokenString, setTokenCookie } from './helpers'; +import { UsersService } from './users.service'; +import * as ejs from 'ejs'; +import { Audit, User } from 'src/models'; +import { AuthGuard } from '@nestjs/passport'; @Controller() export class UsersController { - constructor(private readonly usersService: UsersService) { + constructor(private readonly usersService: UsersService) {} + + @Post([ + '/auth/user/signup', + '/api/v1/db/auth/user/signup', + '/api/v1/auth/user/signup', + ]) + async signup(@Request() req: any, @Request() res: any): Promise { + return await this.usersService.signup({ + body: req.body, + req, + res, + }); + } + + @Post([ + '/auth/token/refresh', + '/api/v1/db/auth/token/refresh', + '/api/v1/auth/token/refresh', + ]) + async refreshToken(@Request() req: any, @Request() res: any): Promise { + return await this.usersService.refreshToken({ + body: req.body, + req, + res, + }); + } + + async successfulSignIn({ user, err, info, req, res, auditDescription }) { + try { + if (!user || !user.email) { + if (err) { + return res.status(400).send(err); + } + if (info) { + return res.status(400).send(info); + } + return res.status(400).send({ msg: 'Your signin has failed' }); + } + + await promisify((req as any).login.bind(req))(user); + + const refreshToken = randomTokenString(); + + if (!user.token_version) { + user.token_version = randomTokenString(); + } + + await User.update(user.id, { + refresh_token: refreshToken, + email: user.email, + token_version: user.token_version, + }); + setTokenCookie(res, refreshToken); + + await Audit.insert({ + op_type: 'AUTHENTICATION', + op_sub_type: 'SIGNIN', + user: user.email, + ip: req.clientIp, + description: auditDescription, + }); + + res.json({ + token: genJwt(user, Noco.getConfig()), + } as any); + } catch (e) { + console.log(e); + throw e; + } } + @Post([ + '/auth/user/signin', + '/api/v1/db/auth/user/signin', + '/api/v1/auth/user/signin', + ]) + @UseGuards(AuthGuard('local')) + async signin(@Request() req) { + return this.usersService.login(req.user); + } + + @Post(`/auth/google/genTokenByCode`) + async googleSignin(req, res, next) { + // todo + /* passport.authenticate( + 'google', + { + session: false, + callbackURL: req.ncSiteUrl + Noco.getConfig().dashboardPath, + }, + async (err, user, info): Promise => + await successfulSignIn({ + user, + err, + info, + req, + res, + auditDescription: 'signed in using Google Auth', + }) + )(req, res, next);*/ + } + + @Get('/auth/google') + googleAuthenticate() { + /* passport.authenticate('google', { + scope: ['profile', 'email'], + state: req.query.state, + callbackURL: req.ncSiteUrl + Noco.getConfig().dashboardPath, + })(req, res, next)*/ + } + + @Get(['/auth/user/me', '/api/v1/db/auth/user/me', '/api/v1/auth/user/me']) + @UseGuards(AuthGuard('jwt')) + async me(@Request() req) { + const user = { + ...req.user, + roles: extractRolesObj(req.user.roles), + }; + return user; + } + + @Post([ + '/user/password/change', + '/api/v1/db/auth/password/change', + '/api/v1/auth/password/change', + ]) + @Acl('passwordChange') + async passwordChange(@Request() req: any, @Body() body: any): Promise { + if (!(req as any).isAuthenticated()) { + NcError.forbidden('Not allowed'); + } + + await this.usersService.passwordChange({ + user: req['user'], + req, + body: req.body, + }); + + return { msg: 'Password has been updated successfully' }; + } + + @Post([ + '/auth/password/forgot', + '/api/v1/db/auth/password/forgot', + '/api/v1/auth/password/forgot', + ]) + async passwordForgot(@Request() req: any, @Body() body: any): Promise { + await this.usersService.passwordForgot({ + siteUrl: (req as any).ncSiteUrl, + body: req.body, + req, + }); + + return { msg: 'Please check your email to reset the password' }; + } + + @Post([ + '/auth/token/validate/:tokenId', + '/api/v1/db/auth/token/validate/:tokenId', + '/api/v1/auth/token/validate/:tokenId', + ]) + async tokenValidate(@Param('tokenId') tokenId: string): Promise { + await this.usersService.tokenValidate({ + token: tokenId, + }); + return { msg: 'Token has been validated successfully' }; + } + + @Post([ + '/auth/password/reset/:tokenId', + '/api/v1/db/auth/password/reset/:tokenId', + '/api/v1/auth/password/reset/:tokenId', + ]) + async passwordReset( + @Request() req: any, + @Param('tokenId') tokenId: string, + @Body() body: any, + ): Promise { + await this.usersService.passwordReset({ + token: tokenId, + body: body, + req, + }); + + return { msg: 'Password has been reset successfully' }; + } + + @Post([ + '/api/v1/db/auth/email/validate/:tokenId', + '/api/v1/auth/email/validate/:tokenId', + ]) + async emailVerification( + @Request() req: any, + @Param('tokenId') tokenId: string, + ): Promise { + await this.usersService.emailVerification({ + token: tokenId, + req, + }); + + return { msg: 'Email has been verified successfully' }; + } + + @Get([ + '/api/v1/db/auth/password/reset/:tokenId', + '/auth/password/reset/:tokenId', + ]) + async renderPasswordReset( + @Request() req: any, + @Response() res: any, + @Param('tokenId') tokenId: string, + ): Promise { + try { + res.send( + ejs.render((await import('./ui/auth/resetPassword')).default, { + ncPublicUrl: process.env.NC_PUBLIC_URL || '', + token: JSON.stringify(tokenId), + baseUrl: `/`, + }), + ); + } catch (e) { + return res.status(400).json({ msg: e.message }); + } + } } diff --git a/packages/nocodb-nest/src/modules/users/users.service.ts b/packages/nocodb-nest/src/modules/users/users.service.ts index 89c8a9f350..105fe42a5e 100644 --- a/packages/nocodb-nest/src/modules/users/users.service.ts +++ b/packages/nocodb-nest/src/modules/users/users.service.ts @@ -1,21 +1,497 @@ import { Injectable } from '@nestjs/common'; -import { MetaService, MetaTable } from '../../meta/meta.service' +import { JwtService } from '@nestjs/jwt'; +import { + OrgUserRoles, + PasswordChangeReqType, + PasswordForgotReqType, + PasswordResetReqType, + SignUpReqType, + UserType, + validatePassword, +} from 'nocodb-sdk'; +import { promisify } from 'util'; +import { NC_APP_SETTINGS } from '../../constants'; +import { validatePayload } from '../../helpers'; +import { NcError } from '../../helpers/catchError'; +import NcPluginMgrv2 from '../../helpers/NcPluginMgrv2'; +import { randomTokenString } from '../../helpers/stringHelpers'; +import { MetaService, MetaTable } from '../../meta/meta.service'; +import { Audit, Store, User } from '../../models'; +import { v4 as uuidv4 } from 'uuid'; +import { isEmail } from 'validator'; +import { T } from 'nc-help'; +import * as ejs from 'ejs'; +import bcrypt from 'bcryptjs'; +import Noco from '../../Noco'; +import { genJwt, setTokenCookie } from './helpers'; @Injectable() export class UsersService { + constructor( + private metaService: MetaService, + private jwtService: JwtService, + ) {} - constructor(private metaService: MetaService) { + async findOne(email: string) { + const user = await this.metaService.metaGet(null, null, MetaTable.USERS, { + email, + }); + + return user; } - async findOne(email: string) { - const user = await this.metaService.metaGet(null, null, MetaTable.USERS, { email }); + async insert(param: { + token_version: string; + firstname: any; + password: any; + salt: any; + email_verification_token: any; + roles: string; + email: string; + lastname: any; + }) { + return this.metaService.metaInsert2(null, null, MetaTable.USERS, param); + } + async registerNewUserIfAllowed({ + firstname, + lastname, + email, + salt, + password, + email_verification_token, + }: { + firstname; + lastname; + email: string; + salt: any; + password; + email_verification_token; + }) { + let roles: string = OrgUserRoles.CREATOR; - return user; + if (await User.isFirst()) { + roles = `${OrgUserRoles.CREATOR},${OrgUserRoles.SUPER_ADMIN}`; + // todo: update in nc_store + // roles = 'owner,creator,editor' + T.emit('evt', { + evt_type: 'project:invite', + count: 1, + }); + } else { + let settings: { invite_only_signup?: boolean } = {}; + try { + settings = JSON.parse((await Store.get(NC_APP_SETTINGS))?.value); + } catch {} + + if (settings?.invite_only_signup) { + NcError.badRequest('Not allowed to signup, contact super admin.'); + } else { + roles = OrgUserRoles.VIEWER; + } + } + + const token_version = randomTokenString(); + + return await User.insert({ + firstname, + lastname, + email, + salt, + password, + email_verification_token, + roles, + token_version, + }); + } + + async passwordChange(param: { + body: PasswordChangeReqType; + user: UserType; + req: any; + }): Promise { + validatePayload( + 'swagger.json#/components/schemas/PasswordChangeReq', + param.body, + ); + + const { currentPassword, newPassword } = param.body; + + if (!currentPassword || !newPassword) { + return NcError.badRequest('Missing new/old password'); + } + + // validate password and throw error if password is satisfying the conditions + const { valid, error } = validatePassword(newPassword); + + if (!valid) { + NcError.badRequest(`Password : ${error}`); + } + + const user = await User.getByEmail(param.user.email); + + const hashedPassword = await promisify(bcrypt.hash)( + currentPassword, + user.salt, + ); + + if (hashedPassword !== user.password) { + return NcError.badRequest('Current password is wrong'); + } + + const salt = await promisify(bcrypt.genSalt)(10); + const password = await promisify(bcrypt.hash)(newPassword, salt); + + await User.update(user.id, { + salt, + password, + email: user.email, + token_version: null, + }); + + await Audit.insert({ + op_type: 'AUTHENTICATION', + op_sub_type: 'PASSWORD_CHANGE', + user: user.email, + description: `changed password `, + ip: param.req?.clientIp, + }); + + return true; + } + + async passwordForgot(param: { + body: PasswordForgotReqType; + siteUrl: string; + req: any; + }): Promise { + validatePayload( + 'swagger.json#/components/schemas/PasswordForgotReq', + param.body, + ); + + const _email = param.body.email; + + if (!_email) { + NcError.badRequest('Please enter your email address.'); + } + + const email = _email.toLowerCase(); + const user = await User.getByEmail(email); + + if (user) { + const token = uuidv4(); + await User.update(user.id, { + email: user.email, + reset_password_token: token, + reset_password_expires: new Date(Date.now() + 60 * 60 * 1000), + token_version: null, + }); + try { + const template = (await import('./ui/emailTemplates/forgotPassword')) + .default; + await NcPluginMgrv2.emailAdapter().then((adapter) => + adapter.mailSend({ + to: user.email, + subject: 'Password Reset Link', + text: `Visit following link to update your password : ${param.siteUrl}/auth/password/reset/${token}.`, + html: ejs.render(template, { + resetLink: param.siteUrl + `/auth/password/reset/${token}`, + }), + }), + ); + } catch (e) { + console.log(e); + return NcError.badRequest( + 'Email Plugin is not found. Please contact administrators to configure it in App Store first.', + ); + } + + await Audit.insert({ + op_type: 'AUTHENTICATION', + op_sub_type: 'PASSWORD_FORGOT', + user: user.email, + description: `requested for password reset `, + ip: param.req?.clientIp, + }); + } else { + return NcError.badRequest('Your email has not been registered.'); + } + + return true; + } + + async tokenValidate(param: { token: string }): Promise { + const token = param.token; + + const user = await Noco.ncMeta.metaGet(null, null, MetaTable.USERS, { + reset_password_token: token, + }); + + if (!user || !user.email) { + NcError.badRequest('Invalid reset url'); + } + if (new Date(user.reset_password_expires) < new Date()) { + NcError.badRequest('Password reset url expired'); + } + + return true; + } + + async passwordReset(param: { + body: PasswordResetReqType; + token: string; + // todo: exclude + req: any; + }): Promise { + validatePayload( + 'swagger.json#/components/schemas/PasswordResetReq', + param.body, + ); + + const { token, body, req } = param; + + const user = await Noco.ncMeta.metaGet(null, null, MetaTable.USERS, { + reset_password_token: token, + }); + + if (!user) { + NcError.badRequest('Invalid reset url'); + } + if (user.reset_password_expires < new Date()) { + NcError.badRequest('Password reset url expired'); + } + if (user.provider && user.provider !== 'local') { + NcError.badRequest('Email registered via social account'); + } + + // validate password and throw error if password is satisfying the conditions + const { valid, error } = validatePassword(body.password); + if (!valid) { + NcError.badRequest(`Password : ${error}`); + } + + const salt = await promisify(bcrypt.genSalt)(10); + const password = await promisify(bcrypt.hash)(body.password, salt); + + await User.update(user.id, { + salt, + password, + email: user.email, + reset_password_expires: null, + reset_password_token: '', + token_version: null, + }); + + await Audit.insert({ + op_type: 'AUTHENTICATION', + op_sub_type: 'PASSWORD_RESET', + user: user.email, + description: `did reset password `, + ip: req.clientIp, + }); + + return true; + } + + async emailVerification(param: { + token: string; + // todo: exclude + req: any; + }): Promise { + const { token, req } = param; + + const user = await Noco.ncMeta.metaGet(null, null, MetaTable.USERS, { + email_verification_token: token, + }); + + if (!user) { + NcError.badRequest('Invalid verification url'); + } + + await User.update(user.id, { + email: user.email, + email_verification_token: '', + email_verified: true, + }); + + await Audit.insert({ + op_type: 'AUTHENTICATION', + op_sub_type: 'EMAIL_VERIFICATION', + user: user.email, + description: `verified email `, + ip: req.clientIp, + }); + + return true; + } + + async refreshToken(param: { + body: SignUpReqType; + req: any; + res: any; + }): Promise { + try { + if (!param.req?.cookies?.refresh_token) { + NcError.badRequest(`Missing refresh token`); + } + + const user = await User.getByRefreshToken( + param.req.cookies.refresh_token, + ); + + if (!user) { + NcError.badRequest(`Invalid refresh token`); + } + + const refreshToken = randomTokenString(); + + await User.update(user.id, { + email: user.email, + refresh_token: refreshToken, + }); + + setTokenCookie(param.res, refreshToken); + + return { + token: genJwt(user, Noco.getConfig()), + } as any; + } catch (e) { + NcError.badRequest(e.message); + } + } + + async signup(param: { + body: SignUpReqType; + req: any; + res: any; + }): Promise { + validatePayload('swagger.json#/components/schemas/SignUpReq', param.body); + + const { + email: _email, + firstname, + lastname, + token, + ignore_subscribe, + } = param.req.body; + + let { password } = param.req.body; + + // validate password and throw error if password is satisfying the conditions + const { valid, error } = validatePassword(password); + if (!valid) { + NcError.badRequest(`Password : ${error}`); + } + + if (!isEmail(_email)) { + NcError.badRequest(`Invalid email`); + } + + const email = _email.toLowerCase(); + + let user = await User.getByEmail(email); + + if (user) { + if (token) { + if (token !== user.invite_token) { + NcError.badRequest(`Invalid invite url`); + } else if (user.invite_token_expires < new Date()) { + NcError.badRequest( + 'Expired invite url, Please contact super admin to get a new invite url', + ); + } + } else { + // todo : opening up signup for timebeing + // return next(new Error(`Email '${email}' already registered`)); + } + } + + const salt = await promisify(bcrypt.genSalt)(10); + password = await promisify(bcrypt.hash)(password, salt); + const email_verification_token = uuidv4(); + + if (!ignore_subscribe) { + T.emit('evt_subscribe', email); + } + + if (user) { + if (token) { + await User.update(user.id, { + firstname, + lastname, + salt, + password, + email_verification_token, + invite_token: null, + invite_token_expires: null, + email: user.email, + }); + } else { + NcError.badRequest('User already exist'); + } + } else { + await this.registerNewUserIfAllowed({ + firstname, + lastname, + email, + salt, + password, + email_verification_token, + }); + } + user = await User.getByEmail(email); + + try { + const template = (await import('./ui/emailTemplates/verify')).default; + await ( + await NcPluginMgrv2.emailAdapter() + ).mailSend({ + to: email, + subject: 'Verify email', + html: ejs.render(template, { + verifyLink: + (param.req as any).ncSiteUrl + + `/email/verify/${user.email_verification_token}`, + }), + }); + } catch (e) { + console.log( + 'Warning : `mailSend` failed, Please configure emailClient configuration.', + ); + } + await promisify((param.req as any).login.bind(param.req))(user); + + const refreshToken = randomTokenString(); + + await User.update(user.id, { + refresh_token: refreshToken, + email: user.email, + }); + + setTokenCookie(param.res, refreshToken); + + user = (param.req as any).user; + + await Audit.insert({ + op_type: 'AUTHENTICATION', + op_sub_type: 'SIGNUP', + user: user.email, + description: `signed up `, + ip: (param.req as any).clientIp, + }); + return { + token: genJwt(user, Noco.getConfig()), + } as any; } - async insert(param: { token_version: string; firstname: any; password: any; salt: any; email_verification_token: any; roles: string; email: string; lastname: any }) { - return this.metaService.metaInsert2(null, null, MetaTable.USERS, param) + async login(user: any) { + delete user.password; + delete user.salt; + const payload = user; + return { + token: this.jwtService.sign(payload), + }; } } diff --git a/packages/nocodb/src/lib/controllers/user/user.ctl.ts b/packages/nocodb/src/lib/controllers/user/user.ctl.ts index 6b4fee6bc9..583e20a8f6 100644 --- a/packages/nocodb/src/lib/controllers/user/user.ctl.ts +++ b/packages/nocodb/src/lib/controllers/user/user.ctl.ts @@ -8,7 +8,7 @@ import ncMetaAclMw from '../../meta/helpers/ncMetaAclMw'; import { Audit, User } from '../../models'; import Noco from '../../Noco'; import { userService } from '../../services'; -import { setTokenCookie } from '../../services/user/helpers'; +import { setTokenCookie } from '../../services/user'; import type { Request } from 'express'; export async function signup(req: Request, res): Promise {