diff --git a/packages/nocodb/src/lib/controllers/userController/index.ts b/packages/nocodb/src/lib/controllers/userController/index.ts new file mode 100644 index 0000000000..c4a5003430 --- /dev/null +++ b/packages/nocodb/src/lib/controllers/userController/index.ts @@ -0,0 +1 @@ +export * from './userApis'; diff --git a/packages/nocodb/src/lib/controllers/userController/initStrategies.ts b/packages/nocodb/src/lib/controllers/userController/initStrategies.ts new file mode 100644 index 0000000000..3b6b99c9aa --- /dev/null +++ b/packages/nocodb/src/lib/controllers/userController/initStrategies.ts @@ -0,0 +1,332 @@ +import { OrgUserRoles } from 'nocodb-sdk'; +import { promisify } from 'util'; +import { Strategy as CustomStrategy } from 'passport-custom'; +import passport from 'passport'; +import passportJWT from 'passport-jwt'; +import { Strategy as AuthTokenStrategy } from 'passport-auth-token'; +import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; + +const PassportLocalStrategy = require('passport-local').Strategy; +const ExtractJwt = passportJWT.ExtractJwt; +const JwtStrategy = passportJWT.Strategy; + +const jwtOptions = { + jwtFromRequest: ExtractJwt.fromHeader('xc-auth'), +}; + +import bcrypt from 'bcryptjs'; +import NocoCache from '../../cache/NocoCache'; +import { ApiToken, Plugin, Project, ProjectUser, User } from '../../models'; +import Noco from '../../Noco'; +import { CacheGetType, CacheScope } from '../../utils/globals'; +import { userService } from '../../services'; + +export function initStrategies(router): void { + passport.use( + 'authtoken', + new AuthTokenStrategy( + { headerFields: ['xc-token'], passReqToCallback: true }, + (req, token, done) => { + ApiToken.getByToken(token) + .then((apiToken) => { + if (!apiToken) { + return done({ msg: 'Invalid token' }); + } + + if (!apiToken.fk_user_id) return done(null, { roles: 'editor' }); + User.get(apiToken.fk_user_id) + .then((user) => { + user['is_api_token'] = true; + if (req.ncProjectId) { + ProjectUser.get(req.ncProjectId, user.id) + .then(async (projectUser) => { + user.roles = projectUser?.roles || user.roles; + user.roles = + user.roles === 'owner' ? 'owner,creator' : user.roles; + // + (user.roles ? `,${user.roles}` : ''); + // todo : cache + // await NocoCache.set(`${CacheScope.USER}:${key}`, user); + done(null, user); + }) + .catch((e) => done(e)); + } else { + return done(null, user); + } + }) + .catch((e) => { + console.log(e); + done({ msg: 'User not found' }); + }); + }) + .catch((e) => { + console.log(e); + done({ msg: 'Invalid token' }); + }); + } + ) + ); + + passport.serializeUser(function ( + { + id, + email, + email_verified, + roles: _roles, + provider, + firstname, + lastname, + isAuthorized, + isPublicBase, + token_version, + }, + done + ) { + const roles = (_roles || '') + .split(',') + .reduce((obj, role) => Object.assign(obj, { [role]: true }), {}); + if (roles.owner) { + roles.creator = true; + } + done(null, { + isAuthorized, + isPublicBase, + id, + email, + email_verified, + provider, + firstname, + lastname, + roles, + token_version, + }); + }); + + passport.deserializeUser(function (user, done) { + done(null, user); + }); + + passport.use( + new JwtStrategy( + { + secretOrKey: Noco.getConfig().auth.jwt.secret, + ...jwtOptions, + passReqToCallback: true, + ...Noco.getConfig().auth.jwt.options, + }, + async (req, jwtPayload, done) => { + // todo: improve this + if ( + req.ncProjectId && + jwtPayload.roles?.split(',').includes(OrgUserRoles.SUPER_ADMIN) + ) { + return User.getByEmail(jwtPayload?.email).then(async (user) => { + return done(null, { + ...user, + roles: `owner,creator,${OrgUserRoles.SUPER_ADMIN}`, + }); + }); + } + + const keyVals = [jwtPayload?.email]; + if (req.ncProjectId) { + keyVals.push(req.ncProjectId); + } + const key = keyVals.join('___'); + const cachedVal = await NocoCache.get( + `${CacheScope.USER}:${key}`, + CacheGetType.TYPE_OBJECT + ); + + if (cachedVal) { + if ( + !cachedVal.token_version || + !jwtPayload.token_version || + cachedVal.token_version !== jwtPayload.token_version + ) { + return done(new Error('Token Expired. Please login again.')); + } + return done(null, cachedVal); + } + + User.getByEmail(jwtPayload?.email) + .then(async (user) => { + if ( + !user.token_version || + !jwtPayload.token_version || + user.token_version !== jwtPayload.token_version + ) { + return done(new Error('Token Expired. Please login again.')); + } + if (req.ncProjectId) { + // this.xcMeta + // .metaGet(req.ncProjectId, null, 'nc_projects_users', { + // user_id: user?.id + // }) + + ProjectUser.get(req.ncProjectId, user.id) + .then(async (projectUser) => { + user.roles = projectUser?.roles || user.roles; + user.roles = + user.roles === 'owner' ? 'owner,creator' : user.roles; + // + (user.roles ? `,${user.roles}` : ''); + + await NocoCache.set(`${CacheScope.USER}:${key}`, user); + done(null, user); + }) + .catch((e) => done(e)); + } else { + // const roles = projectUser?.roles ? JSON.parse(projectUser.roles) : {guest: true}; + if (user) { + await NocoCache.set(`${CacheScope.USER}:${key}`, user); + return done(null, user); + } else { + return done(new Error('User not found')); + } + } + }) + .catch((err) => { + return done(err); + }); + } + ) + ); + + passport.use( + new PassportLocalStrategy( + { + usernameField: 'email', + session: false, + }, + async (email, password, done) => { + try { + const user = await User.getByEmail(email); + if (!user) { + return done({ msg: `Email ${email} is not registered!` }); + } + + if (!user.salt) { + return done({ + msg: `Please sign up with the invite token first or reset the password by clicking Forgot your password.`, + }); + } + + const hashedPassword = await promisify(bcrypt.hash)( + password, + user.salt + ); + if (user.password !== hashedPassword) { + return done({ msg: `Password not valid!` }); + } else { + return done(null, user); + } + } catch (e) { + done(e); + } + } + ) + ); + + passport.use( + 'baseView', + new CustomStrategy(async (req: any, callback) => { + let user; + if (req.headers['xc-shared-base-id']) { + // const cacheKey = `nc_shared_bases||${req.headers['xc-shared-base-id']}`; + + let sharedProject = null; + + if (!sharedProject) { + sharedProject = await Project.getByUuid( + req.headers['xc-shared-base-id'] + ); + } + user = { + roles: sharedProject?.roles, + }; + } + + callback(null, user); + }) + ); + + // mostly copied from older code + Plugin.getPluginByTitle('Google').then((googlePlugin) => { + if (googlePlugin && googlePlugin.input) { + const settings = JSON.parse(googlePlugin.input); + process.env.NC_GOOGLE_CLIENT_ID = settings.client_id; + process.env.NC_GOOGLE_CLIENT_SECRET = settings.client_secret; + } + + if ( + process.env.NC_GOOGLE_CLIENT_ID && + process.env.NC_GOOGLE_CLIENT_SECRET + ) { + const googleAuthParamsOrig = GoogleStrategy.prototype.authorizationParams; + GoogleStrategy.prototype.authorizationParams = (options: any) => { + const params = googleAuthParamsOrig.call(this, options); + + if (options.state) { + params.state = options.state; + } + + return params; + }; + + const clientConfig = { + clientID: process.env.NC_GOOGLE_CLIENT_ID, + clientSecret: process.env.NC_GOOGLE_CLIENT_SECRET, + // todo: update url + callbackURL: 'http://localhost:3000', + passReqToCallback: true, + }; + + const googleStrategy = new GoogleStrategy( + clientConfig, + async (req, _accessToken, _refreshToken, profile, done) => { + const email = profile.emails[0].value; + + User.getByEmail(email) + .then(async (user) => { + if (user) { + // if project id defined extract project level roles + if (req.ncProjectId) { + ProjectUser.get(req.ncProjectId, user.id) + .then(async (projectUser) => { + user.roles = projectUser?.roles || user.roles; + user.roles = + user.roles === 'owner' ? 'owner,creator' : user.roles; + // + (user.roles ? `,${user.roles}` : ''); + + done(null, user); + }) + .catch((e) => done(e)); + } else { + return done(null, user); + } + // if user not found create new user if allowed + // or return error + } else { + const salt = await promisify(bcrypt.genSalt)(10); + const user = await userService.registerNewUserIfAllowed({ + firstname: null, + lastname: null, + email_verification_token: null, + email: profile.emails[0].value, + password: '', + salt, + }); + return done(null, user); + } + }) + .catch((err) => { + return done(err); + }); + } + ); + + passport.use(googleStrategy); + } + }); + + router.use(passport.initialize()); +} diff --git a/packages/nocodb/src/lib/controllers/userController/ui/auth/emailVerify.ts b/packages/nocodb/src/lib/controllers/userController/ui/auth/emailVerify.ts new file mode 100644 index 0000000000..f7412b12ba --- /dev/null +++ b/packages/nocodb/src/lib/controllers/userController/ui/auth/emailVerify.ts @@ -0,0 +1,70 @@ +export default ` + + + NocoDB - Verify Email + + + + + + + +
+ + + + + + Email verified successfully! + + + {{errMsg}} + + + + + + + +
+ + + + + +`; diff --git a/packages/nocodb/src/lib/controllers/userController/ui/auth/resetPassword.ts b/packages/nocodb/src/lib/controllers/userController/ui/auth/resetPassword.ts new file mode 100644 index 0000000000..514fc6d739 --- /dev/null +++ b/packages/nocodb/src/lib/controllers/userController/ui/auth/resetPassword.ts @@ -0,0 +1,108 @@ +export default ` + + + NocoDB - Reset Password + + + + + + + +
+ + + + + + Password reset successful! + + + + + + +
+ + + + + +`; diff --git a/packages/nocodb/src/lib/controllers/userController/ui/emailTemplates/forgotPassword.ts b/packages/nocodb/src/lib/controllers/userController/ui/emailTemplates/forgotPassword.ts new file mode 100644 index 0000000000..afb2f5849a --- /dev/null +++ b/packages/nocodb/src/lib/controllers/userController/ui/emailTemplates/forgotPassword.ts @@ -0,0 +1,171 @@ +export default ` + + + + + Simple Transactional Email + + + + + + + + + + + + + +`; diff --git a/packages/nocodb/src/lib/controllers/userController/ui/emailTemplates/invite.ts b/packages/nocodb/src/lib/controllers/userController/ui/emailTemplates/invite.ts new file mode 100644 index 0000000000..fc81f9409e --- /dev/null +++ b/packages/nocodb/src/lib/controllers/userController/ui/emailTemplates/invite.ts @@ -0,0 +1,208 @@ +export default ` + + + + + Simple Transactional Email + + + + + + + + + + + + + +`; diff --git a/packages/nocodb/src/lib/controllers/userController/ui/emailTemplates/verify.ts b/packages/nocodb/src/lib/controllers/userController/ui/emailTemplates/verify.ts new file mode 100644 index 0000000000..11702cc659 --- /dev/null +++ b/packages/nocodb/src/lib/controllers/userController/ui/emailTemplates/verify.ts @@ -0,0 +1,207 @@ +export default ` + + + + + Simple Transactional Email + + + + + + + + + + + + + +`; diff --git a/packages/nocodb/src/lib/controllers/userController/userApis.ts b/packages/nocodb/src/lib/controllers/userController/userApis.ts new file mode 100644 index 0000000000..367eb4591b --- /dev/null +++ b/packages/nocodb/src/lib/controllers/userController/userApis.ts @@ -0,0 +1,462 @@ +import { Request, Response } from 'express'; +import { TableType, validatePassword } from 'nocodb-sdk'; +import { Tele } from 'nc-help'; + +const { isEmail } = require('validator'); +import * as ejs from 'ejs'; + +import bcrypt from 'bcryptjs'; +import { promisify } from 'util'; + +const { v4: uuidv4 } = require('uuid'); + +import passport from 'passport'; +import { getAjvValidatorMw } from '../../meta/api/helpers'; +import catchError, { NcError } from '../../meta/helpers/catchError'; +import extractProjectIdAndAuthenticate from '../../meta/helpers/extractProjectIdAndAuthenticate'; +import ncMetaAclMw from '../../meta/helpers/ncMetaAclMw'; +import NcPluginMgrv2 from '../../meta/helpers/NcPluginMgrv2'; +import { Audit, User } from '../../models'; +import Noco from '../../Noco'; +import { genJwt } from './helpers'; +import { userService } from '../../services'; + +export async function signup(req: Request, res: Response) { + const { + email: _email, + firstname, + lastname, + token, + ignore_subscribe, + } = req.body; + let { password } = 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) { + Tele.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 userService.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: + (req as any).ncSiteUrl + + `/email/verify/${user.email_verification_token}`, + }), + }); + } catch (e) { + console.log( + 'Warning : `mailSend` failed, Please configure emailClient configuration.' + ); + } + await promisify((req as any).login.bind(req))(user); + const refreshToken = userService.randomTokenString(); + await User.update(user.id, { + refresh_token: refreshToken, + email: user.email, + }); + + setTokenCookie(res, refreshToken); + + user = (req as any).user; + + await Audit.insert({ + op_type: 'AUTHENTICATION', + op_sub_type: 'SIGNUP', + user: user.email, + description: `signed up `, + ip: (req as any).clientIp, + }); + + res.json({ + token: genJwt(user, Noco.getConfig()), + } as any); +} + +async function 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 = userService.randomTokenString(); + + if (!user.token_version) { + user.token_version = userService.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; + } +} + +async function signin(req, res, next) { + passport.authenticate( + 'local', + { session: false }, + async (err, user, info): Promise => + await successfulSignIn({ + user, + err, + info, + req, + res, + auditDescription: 'signed in', + }) + )(req, res, next); +} + +async function googleSignin(req, res, next) { + 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); +} + +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); +} + +async function me(req, res): Promise { + res.json(req?.session?.passport?.user ?? {}); +} + +async function passwordChange(req: Request, res): Promise { + if (!(req as any).isAuthenticated()) { + NcError.forbidden('Not allowed'); + } + + await userService.passwordChange({ + user: req['user'], + req, + body: req.body, + }); + + res.json({ msg: 'Password updated successfully' }); +} + +async function passwordForgot(req: Request, res): Promise { + await userService.passwordForgot({ + siteUrl: (req as any).ncSiteUrl, + body: req.body, + req, + }); + + res.json({ msg: 'Please check your email to reset the password' }); +} + +async function tokenValidate(req, res): Promise { + await userService.tokenValidate({ + token: req.params.tokenId, + }); + res.json(true); +} + +async function passwordReset(req, res): Promise { + await userService.passwordReset({ + token: req.params.tokenId, + body: req.body, + req, + }); + + res.json({ msg: 'Password reset successful' }); +} + +async function emailVerification(req, res): Promise { + await userService.emailVerification({ + token: req.params.tokenId, + req, + }); + + res.json({ msg: 'Email verified successfully' }); +} + +async function refreshToken(req, res): Promise { + try { + if (!req?.cookies?.refresh_token) { + return res.status(400).json({ msg: 'Missing refresh token' }); + } + + const user = await User.getByRefreshToken(req.cookies.refresh_token); + + if (!user) { + return res.status(400).json({ msg: 'Invalid refresh token' }); + } + + const refreshToken = userService.randomTokenString(); + + await User.update(user.id, { + email: user.email, + refresh_token: refreshToken, + }); + + setTokenCookie(res, refreshToken); + + res.json({ + token: genJwt(user, Noco.getConfig()), + } as any); + } catch (e) { + return res.status(400).json({ msg: e.message }); + } +} + +async function renderPasswordReset(req, res): Promise { + try { + res.send( + ejs.render((await import('./ui/auth/resetPassword')).default, { + ncPublicUrl: process.env.NC_PUBLIC_URL || '', + token: JSON.stringify(req.params.tokenId), + baseUrl: `/`, + }) + ); + } catch (e) { + return res.status(400).json({ msg: e.message }); + } +} + +const mapRoutes = (router) => { + // todo: old api - /auth/signup?tool=1 + router.post( + '/auth/user/signup', + getAjvValidatorMw('swagger.json#/components/schemas/SignUpReq'), + catchError(signup) + ); + router.post( + '/auth/user/signin', + getAjvValidatorMw('swagger.json#/components/schemas/SignInReq'), + catchError(signin) + ); + router.get('/auth/user/me', extractProjectIdAndAuthenticate, catchError(me)); + router.post( + '/auth/password/forgot', + getAjvValidatorMw('swagger.json#/components/schemas/ForgotPasswordReq'), + catchError(passwordForgot) + ); + router.post('/auth/token/validate/:tokenId', catchError(tokenValidate)); + router.post( + '/auth/password/reset/:tokenId', + getAjvValidatorMw('swagger.json#/components/schemas/PasswordResetReq'), + catchError(passwordReset) + ); + router.post('/auth/email/validate/:tokenId', catchError(emailVerification)); + router.post( + '/user/password/change', + getAjvValidatorMw('swagger.json#/components/schemas/PasswordChangeReq'), + ncMetaAclMw(passwordChange, 'passwordChange') + ); + router.post('/auth/token/refresh', catchError(refreshToken)); + + /* Google auth apis */ + + router.post(`/auth/google/genTokenByCode`, catchError(googleSignin)); + + router.get('/auth/google', (req: any, res, next) => + passport.authenticate('google', { + scope: ['profile', 'email'], + state: req.query.state, + callbackURL: req.ncSiteUrl + Noco.getConfig().dashboardPath, + })(req, res, next) + ); + + // deprecated APIs + router.post( + '/api/v1/db/auth/user/signup', + getAjvValidatorMw('swagger.json#/components/schemas/SignUpReq'), + catchError(signup) + ); + router.post( + '/api/v1/db/auth/user/signin', + getAjvValidatorMw('swagger.json#/components/schemas/SignInReq'), + catchError(signin) + ); + router.get( + '/api/v1/db/auth/user/me', + extractProjectIdAndAuthenticate, + catchError(me) + ); + router.post( + '/api/v1/db/auth/password/forgot', + getAjvValidatorMw('swagger.json#/components/schemas/ForgotPasswordReq'), + catchError(passwordForgot) + ); + router.post( + '/api/v1/db/auth/token/validate/:tokenId', + catchError(tokenValidate) + ); + router.post( + '/api/v1/db/auth/password/reset/:tokenId', + getAjvValidatorMw('swagger.json#/components/schemas/PasswordResetReq'), + catchError(passwordReset) + ); + router.post( + '/api/v1/db/auth/email/validate/:tokenId', + catchError(emailVerification) + ); + router.post( + '/api/v1/db/auth/password/change', + getAjvValidatorMw('swagger.json#/components/schemas/PasswordChangeReq'), + ncMetaAclMw(passwordChange, 'passwordChange') + ); + router.post('/api/v1/db/auth/token/refresh', catchError(refreshToken)); + router.get( + '/api/v1/db/auth/password/reset/:tokenId', + catchError(renderPasswordReset) + ); + + // new API + router.post( + '/api/v1/auth/user/signup', + getAjvValidatorMw('swagger.json#/components/schemas/SignUpReq'), + catchError(signup) + ); + router.post( + '/api/v1/auth/user/signin', + getAjvValidatorMw('swagger.json#/components/schemas/SignInReq'), + catchError(signin) + ); + router.get( + '/api/v1/auth/user/me', + extractProjectIdAndAuthenticate, + catchError(me) + ); + router.post( + '/api/v1/auth/password/forgot', + getAjvValidatorMw('swagger.json#/components/schemas/ForgotPasswordReq'), + catchError(passwordForgot) + ); + router.post( + '/api/v1/auth/token/validate/:tokenId', + catchError(tokenValidate) + ); + router.post( + '/api/v1/auth/password/reset/:tokenId', + catchError(passwordReset) + ); + router.post( + '/api/v1/auth/email/validate/:tokenId', + catchError(emailVerification) + ); + router.post( + '/api/v1/auth/password/change', + getAjvValidatorMw('swagger.json#/components/schemas/PasswordChangeReq'), + ncMetaAclMw(passwordChange, 'passwordChange') + ); + router.post('/api/v1/auth/token/refresh', catchError(refreshToken)); + // respond with password reset page + router.get('/auth/password/reset/:tokenId', catchError(renderPasswordReset)); +}; +export { mapRoutes as userApis }; diff --git a/packages/nocodb/src/lib/services/index.ts b/packages/nocodb/src/lib/services/index.ts index dbbbdacc65..d0fe29bdcf 100644 --- a/packages/nocodb/src/lib/services/index.ts +++ b/packages/nocodb/src/lib/services/index.ts @@ -31,3 +31,4 @@ export * as bulkDataService from './dataService/bulkData'; export * as cacheService from './cacheService'; export * as auditService from './auditService'; export * as swaggerService from './swaggerService'; +export * as userService from './userService'; diff --git a/packages/nocodb/src/lib/services/userService/helpers.ts b/packages/nocodb/src/lib/services/userService/helpers.ts new file mode 100644 index 0000000000..4cfe0bd687 --- /dev/null +++ b/packages/nocodb/src/lib/services/userService/helpers.ts @@ -0,0 +1,23 @@ +import * as jwt from 'jsonwebtoken'; +import crypto from 'crypto'; +import { NcConfig } from '../../../interface/config' +import { User } from '../../models' + +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'); +} diff --git a/packages/nocodb/src/lib/services/userService/index.ts b/packages/nocodb/src/lib/services/userService/index.ts new file mode 100644 index 0000000000..73b9a9948c --- /dev/null +++ b/packages/nocodb/src/lib/services/userService/index.ts @@ -0,0 +1,301 @@ +import { Request, Response } from 'express'; +import { + PasswordChangeReqType, + PasswordForgotReqType, PasswordResetReqType, + SignUpReqType, + TableType, + UserType, + validatePassword, +} from 'nocodb-sdk' +import { OrgUserRoles } from 'nocodb-sdk'; +import { NC_APP_SETTINGS } from '../../../constants'; +import Store from '../../../models/Store'; +import { Tele } from 'nc-help'; +import catchError, { NcError } from '../../helpers/catchError'; + +const { isEmail } = require('validator'); +import * as ejs from 'ejs'; + +import bcrypt from 'bcryptjs'; +import { promisify } from 'util'; +import User from '../../../models/User'; + +const { v4: uuidv4 } = require('uuid'); +import Audit from '../../../models/Audit'; +import NcPluginMgrv2 from '../../helpers/NcPluginMgrv2'; + +import passport from 'passport'; +import extractProjectIdAndAuthenticate from '../../helpers/extractProjectIdAndAuthenticate'; +import ncMetaAclMw from '../../helpers/ncMetaAclMw'; +import { MetaTable } from '../../../utils/globals'; +import Noco from '../../../Noco'; +import { getAjvValidatorMw } from '../helpers'; +import { genJwt } from './helpers'; +import { randomTokenString } from '../../helpers/stringHelpers'; + +export async function registerNewUserIfAllowed({ + firstname, + lastname, + email, + salt, + password, + email_verification_token, + }: { + firstname; + lastname; + email: string; + salt: any; + password; + email_verification_token; +}) { + let roles: string = OrgUserRoles.CREATOR; + + if (await User.isFirst()) { + roles = `${OrgUserRoles.CREATOR},${OrgUserRoles.SUPER_ADMIN}`; + // todo: update in nc_store + // roles = 'owner,creator,editor' + Tele.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, + }); +} + + +export async function passwordChange(param: { + body: PasswordChangeReqType + user: UserType + req:any +}): Promise { + 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 +} + +export async function passwordForgot(param:{ + body: PasswordForgotReqType; + siteUrl: string; + req:any +}): Promise { + 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 +} + +export async function 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 +} + +export async function passwordReset(param:{ + body: PasswordResetReqType; + token: string; + // todo: exclude + req:any; +}): Promise { + 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 +} + +export async function 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 +} + +export * from './helpers' +export * from './initAdminFromEnv' + diff --git a/packages/nocodb/src/lib/services/userService/initAdminFromEnv.ts b/packages/nocodb/src/lib/services/userService/initAdminFromEnv.ts new file mode 100644 index 0000000000..8f9bfdce74 --- /dev/null +++ b/packages/nocodb/src/lib/services/userService/initAdminFromEnv.ts @@ -0,0 +1,283 @@ +import User from '../../../models/User'; +import { v4 as uuidv4 } from 'uuid'; +import { promisify } from 'util'; + +import bcrypt from 'bcryptjs'; +import Noco from '../../../Noco'; +import { CacheScope, MetaTable } from '../../../utils/globals'; +import ProjectUser from '../../../models/ProjectUser'; +import { validatePassword } from 'nocodb-sdk'; +import boxen from 'boxen'; +import NocoCache from '../../../cache/NocoCache'; +import { Tele } from 'nc-help'; + +const { isEmail } = require('validator'); +const rolesLevel = { owner: 0, creator: 1, editor: 2, commenter: 3, viewer: 4 }; + +export default async function initAdminFromEnv(_ncMeta = Noco.ncMeta) { + if (process.env.NC_ADMIN_EMAIL && process.env.NC_ADMIN_PASSWORD) { + if (!isEmail(process.env.NC_ADMIN_EMAIL?.trim())) { + console.log( + '\n', + boxen( + `Provided admin email '${process.env.NC_ADMIN_EMAIL}' is not valid`, + { + title: 'Invalid admin email', + padding: 1, + borderStyle: 'double', + titleAlignment: 'center', + borderColor: 'red', + } + ), + '\n' + ); + process.exit(1); + } + + const { valid, error, hint } = validatePassword( + process.env.NC_ADMIN_PASSWORD + ); + if (!valid) { + console.log( + '\n', + boxen(`${error}${hint ? `\n\n${hint}` : ''}`, { + title: 'Invalid admin password', + padding: 1, + borderStyle: 'double', + titleAlignment: 'center', + borderColor: 'red', + }), + '\n' + ); + process.exit(1); + } + + let ncMeta; + try { + ncMeta = await _ncMeta.startTransaction(); + const email = process.env.NC_ADMIN_EMAIL.toLowerCase().trim(); + + const salt = await promisify(bcrypt.genSalt)(10); + const password = await promisify(bcrypt.hash)( + process.env.NC_ADMIN_PASSWORD, + salt + ); + const email_verification_token = uuidv4(); + const roles = 'user,super'; + + // if super admin not present + if (await User.isFirst(ncMeta)) { + // roles = 'owner,creator,editor' + Tele.emit('evt', { + evt_type: 'project:invite', + count: 1, + }); + + await User.insert( + { + firstname: '', + lastname: '', + email, + salt, + password, + email_verification_token, + roles, + }, + ncMeta + ); + } else { + const salt = await promisify(bcrypt.genSalt)(10); + const password = await promisify(bcrypt.hash)( + process.env.NC_ADMIN_PASSWORD, + salt + ); + const email_verification_token = uuidv4(); + const superUser = await ncMeta.metaGet2(null, null, MetaTable.USERS, { + roles: 'user,super', + }); + + if (!superUser?.id) { + const existingUserWithNewEmail = await User.getByEmail(email, ncMeta); + if (existingUserWithNewEmail?.id) { + // clear cache + await NocoCache.delAll( + CacheScope.USER, + `${existingUserWithNewEmail.email}___*` + ); + await NocoCache.del( + `${CacheScope.USER}:${existingUserWithNewEmail.id}` + ); + await NocoCache.del( + `${CacheScope.USER}:${existingUserWithNewEmail.email}` + ); + + // Update email and password of super admin account + await User.update( + existingUserWithNewEmail.id, + { + salt, + email, + password, + email_verification_token, + token_version: null, + refresh_token: null, + roles, + }, + ncMeta + ); + } else { + Tele.emit('evt', { + evt_type: 'project:invite', + count: 1, + }); + + await User.insert( + { + firstname: '', + lastname: '', + email, + salt, + password, + email_verification_token, + roles, + }, + ncMeta + ); + } + } else if (email !== superUser.email) { + // update admin email and password and migrate projects + // if user already present and associated with some project + + // check user account already present with the new admin email + const existingUserWithNewEmail = await User.getByEmail(email, ncMeta); + + if (existingUserWithNewEmail?.id) { + // get all project access belongs to the existing account + // and migrate to the admin account + const existingUserProjects = await ncMeta.metaList2( + null, + null, + MetaTable.PROJECT_USERS, + { + condition: { fk_user_id: existingUserWithNewEmail.id }, + } + ); + + for (const existingUserProject of existingUserProjects) { + const userProject = await ProjectUser.get( + existingUserProject.project_id, + superUser.id, + ncMeta + ); + + // if admin user already have access to the project + // then update role based on the highest access level + if (userProject) { + if ( + rolesLevel[userProject.roles] > + rolesLevel[existingUserProject.roles] + ) { + await ProjectUser.update( + userProject.project_id, + superUser.id, + existingUserProject.roles, + ncMeta + ); + } + } else { + // if super doesn't have access then add the access + await ProjectUser.insert( + { + ...existingUserProject, + fk_user_id: superUser.id, + }, + ncMeta + ); + } + // delete the old project access entry from DB + await ProjectUser.delete( + existingUserProject.project_id, + existingUserProject.fk_user_id, + ncMeta + ); + } + + // delete existing user + await ncMeta.metaDelete( + null, + null, + MetaTable.USERS, + existingUserWithNewEmail.id + ); + + // clear cache + await NocoCache.delAll( + CacheScope.USER, + `${existingUserWithNewEmail.email}___*` + ); + await NocoCache.del( + `${CacheScope.USER}:${existingUserWithNewEmail.id}` + ); + await NocoCache.del( + `${CacheScope.USER}:${existingUserWithNewEmail.email}` + ); + + // Update email and password of super admin account + await User.update( + superUser.id, + { + salt, + email, + password, + email_verification_token, + token_version: null, + refresh_token: null, + }, + ncMeta + ); + } else { + // if email's are not different update the password and hash + await User.update( + superUser.id, + { + salt, + email, + password, + email_verification_token, + token_version: null, + refresh_token: null, + }, + ncMeta + ); + } + } else { + const newPasswordHash = await promisify(bcrypt.hash)( + process.env.NC_ADMIN_PASSWORD, + superUser.salt + ); + + if (newPasswordHash !== superUser.password) { + // if email's are same and passwords are different + // then update the password and token version + await User.update( + superUser.id, + { + salt, + password, + email_verification_token, + token_version: null, + refresh_token: null, + }, + ncMeta + ); + } + } + } + await ncMeta.commit(); + } catch (e) { + console.log('Error occurred while updating/creating admin user'); + console.log(e); + await ncMeta.rollback(e); + } + } +}