const { Controller } = require('libflitter') const zxcvbn = require('zxcvbn') const email_validator = require('email-validator') class AuthController extends Controller { static get services() { return [...super.services, 'models', 'auth', 'MFA', 'output', 'configs', 'utility', 'activity'] } async get_auth_user(req, res, next) { if ( req.user ) { return res.api({ authenticated: true, uid: req.user.uid, }) } return res.api({ authenticated: false }) } async get_traps(req, res, next) { const trap_config = this.configs.get('traps') const data = [{ name: req.T('auth.none'), trap: '', redirect_to: '/' }] for ( const name in trap_config.types ) { if ( !trap_config.types.hasOwnProperty(name) ) continue data.push({ name: name.replace(/_/g, ' ') .split(' ') .map(x => x.charAt(0).toUpperCase() + x.substr(1)) .join(' '), trap: name, redirect_to: trap_config.types[name].redirect_to }) } return res.api(data) } async registration(req, res, next) { const User = this.models.get('auth:User') const required_fields = ['first_name', 'last_name', 'uid', 'email'] const unique_fields = ['uid', 'email'] for ( const field of required_fields ) { if ( !req.body[field] ) return res.status(400) .message(`${req.T('api.missing_field')} ${field}`) .api() } if ( !req.body.uid.match(/^([A-Z]|[a-z]|[0-9]|_|-|\.)+$/) ) return res.status(400) .message(`${req.T('api.improper_field')} uid ${req.T('api.alphanum_underscores')}`) .api() if ( !email_validator.validate(req.body.email) ) return res.status(400) .message(`${req.T('api.improper_field')} email`) .api() for ( const field of unique_fields ) { const params = {} params[field] = req.body[field] const match_user = await User.findOne(params) if ( match_user ) return res.status(400) .message(`${req.T('auth.user_exists_with_field')} ${field}`) .api() } const user = new User({ first_name: req.body.first_name, last_name: req.body.last_name, uid: req.body.uid.toLowerCase(), email: req.body.email, trap: 'password_reset', // Force user to reset password }) const Setting = this.models.get('Setting') try { const default_roles = await Setting.get('auth.default_roles') if ( Array.isArray(default_roles) ) { for ( const role of default_roles ) user.promote(role) } } catch (e) { this.output.error('Unable to read default roles to promote registered user: ') this.output.error(e) } // If this is the first user, make them root if ( !(await User.findOne()) ) user.promote('root') await user.save() // Log in the user automatically await this.auth.get_provider().session(req, user) return res.api(await user.to_api()) } async attempt_mfa_recovery(req, res, next) { if ( !req.user.mfa_enabled || !Array.isArray(req.user.mfa_token.recovery_codes) || req.user.mfa_token.recovery_codes.length < 1 ) return res.status(400) .message(req.T('auth.no_mfa_or_recovery')) .api() if ( !req.body.code ) return res.status(400) .message(`${req.T('api.missing_field')} code`) .api() const success = await req.user.mfa_token.attempt_recovery(req.body.code) if ( !success ) return res.api({ success }) if ( req.trap.has_trap('mfa_challenge') ) await req.trap.end() let next_destination = req.session.auth.flow || this.configs.get('auth.default_login_route') delete req.session.auth.flow req.session.mfa_remember = true return res.api({ success: true, next_destination, remaining_codes: req.user.mfa_token.recovery_codes.filter(x => !x.used).length, }) } async validate_email(req, res, next) { let is_valid = !!req.body.email if ( is_valid ) { is_valid = email_validator.validate(req.body.email) } return res.api({ is_valid }) } async get_users(req, res, next) { const User = this.models.get('auth:User') const users = await User.find() const data = [] for ( const user of users ) { if ( !req.user.can(`auth:user:${user.id}:view`) && req.user.id !== user.id) continue data.push(await user.to_api()) } return res.api(data) } async get_groups(req, res, next) { const Group = this.models.get('auth:Group') const groups = await Group.find({active: true}) const data = [] for ( const group of groups ) { if ( !req.user.can(`auth:group:${group.id}:view`) ) continue data.push(await group.to_api()) } return res.api(data) } async get_roles(req, res, next) { const role_config = this.configs.get('auth.roles') const data = [] for ( const role_name in role_config ) { if ( !role_config.hasOwnProperty(role_name) ) continue data.push({ role: role_name, permissions: role_config[role_name], }) } return res.api(data) } async get_group(req, res, next) { const Group = this.models.get('auth:Group') const group = await Group.findById(req.params.id) if ( !group || !group.active ) return res.status(404) .message(req.T('api.group_not_found')) .api() if ( !req.user.can(`auth:group:${group.id}:view`) ) return res.status(401) .message('Insufficient permissions.') .api() return res.api(await group.to_api()) } async get_user(req, res, next) { if ( req.params.id === 'me' ) return res.api(await req.user.to_api()) const User = this.models.get('auth:User') const user = await User.findById(req.params.id) if ( !user ) return res.status(404) .message(req.T('api.user_not_found')) .api() if ( !req.user.can(`auth:user:${user.id}:view`) ) return res.status(401) .message(req.T('api.insufficient_permissions')) .api() return res.api(await user.to_api()) } async create_group(req, res, next) { if ( !req.user.can(`auth:group:create`) ) return res.status(401) .message(req.T('api.insufficient_permissions')) .api() if ( !req.body.name ) return res.status(400) .message(`${req.T('api.missing_field')} name`) .api() const Group = this.models.get('auth:Group') // Make sure the name is free const existing_group = await Group.findOne({ name: req.body.name }) if ( existing_group ) return res.status(400) .message(req.T('api.group_already_exists')) .api() const group = new Group({ name: req.body.name }) // Validate user ids const User = this.models.get('auth:User') if ( req.body.user_ids ) { const parsed = typeof req.body.user_ids === 'string' ? this.utility.infer(req.body.user_ids) : req.body.user_ids const user_ids = Array.isArray(parsed) ? parsed : [parsed] for ( const user_id of user_ids ) { const user = await User.findById(user_id) if ( !user ) return res.status(400) .message(`${req.T('common.invalid')} user_id.`) .api() } group.user_ids = user_ids } await group.save() return res.api(await group.to_api()) } async create_user(req, res, next) { if ( !req.user.can('auth:user:create') ) return res.status(401) .message(req.T('api.insufficient_permissions')) .api() const required_fields = ['uid', 'first_name', 'last_name', 'email', 'password'] for ( const field of required_fields ) { if ( !req.body[field] ) return res.status(400) .message(`${req.T('api.missing_field')} ${field}`) .api() } // Make sure uid & email are unique const User = this.models.get('auth:User') const unique_fields = ['uid', 'email'] for ( const field of unique_fields ) { const filter = {} filter[field] = req.body[field] const existing_user = await User.findOne(filter) if ( existing_user ) return res.status(400) .message(`${req.T('auth.user_exists_with_field')} ${field}`) .api() } // Verify password complexity const min_score = 3 const result = zxcvbn(req.body.password) if ( result.score < min_score ) return res.status(400) .message(req.T('auth.password_complexity_fail').replace('MIN_SCORE', min_score)) .api() const user = new User({ uid: req.body.uid.toLowerCase(), email: req.body.email, first_name: req.body.first_name, last_name: req.body.last_name, }) if ( req.body.tagline ) user.tagline = req.body.tagline if ( req.body.trap ) { if ( !req.trap.trap_exists(req.body.trap) ) return res.status(400) .message(req.T('auth.invalid_trap')) .api() user.trap = req.body.trap } await user.reset_password(req.body.password, 'create') await user.save() return res.api(await user.to_api()) } async update_group(req, res, next) { const Group = this.models.get('auth:Group') const User = this.models.get('auth:User') const group = await Group.findById(req.params.id) if ( !group ) return res.status(404) .message(req.T('api.group_not_found')) .api() if ( !req.user.can(`auth:group:${group.id}:update`) ) return res.status(401) .message(req.T('api.insufficient_permissions')) .api() if ( !req.body.name ) return res.status(400) .message(`${req.T('api.missing_field')} name`) .api() // Make sure the group name is unique const existing_group = await Group.findOne({ name: req.body.name }) if ( existing_group && existing_group.id !== group.id ) return res.status(400) .message(req.T('api.group_already_exists')) .api() // Validate user_ids if ( req.body.user_ids ) { const parsed = typeof req.body.user_ids === 'string' ? this.utility.infer(req.body.user_ids) : req.body.user_ids const user_ids = Array.isArray(parsed) ? parsed : [parsed] for ( const user_id of user_ids ) { const user = await User.findById(user_id) if ( !user ) return res.status(400) .message(`${req.T('common.invalid')} user_id.`) .api() } group.user_ids = user_ids } else { group.user_ids = [] } group.name = req.body.name await group.save() return res.api() } async update_user(req, res, next) { const User = this.models.get('auth:User') const user = await User.findById(req.params.id) if ( !user ) return res.status(404) .message(req.T('api.user_not_found')) .api() if ( !req.user.can(`auth:user:${user.id}:update`) ) return res.status(401) .message(req.T('api.insufficient_permissions')) .api() const required_fields = ['uid', 'first_name', 'last_name', 'email'] for ( const field of required_fields ) { if ( !req.body[field] ) return res.status(400) .message(`${req.T('api.missing_field')} ${field}`) .api() } // Make sure the uid/email are unique const unique_fields = ['uid', 'email'] for ( const field of unique_fields ) { const filter = {} filter[field] = req.body[field] const existing_user = await User.findOne(filter) if ( existing_user && existing_user.id !== user.id ) return res.status(400) .message(`${req.T('auth.user_exists_with_field')} ${field}`) .api() } // Verify password complexity if ( req.body.password ) { const min_score = 3 const result = zxcvbn(req.body.password) if (result.score < min_score) return res.status(400) .message(req.T('auth.password_complexity_fail').replace('MIN_SCORE', min_score)) .api() await user.reset_password(req.body.password, 'api') } user.first_name = req.body.first_name user.last_name = req.body.last_name user.uid = req.body.uid.toLowerCase() user.email = req.body.email if ( req.body.tagline ) user.tagline = req.body.tagline else user.tagline = '' if ( req.body.trap ) { if ( !req.trap.trap_exists(req.body.trap) ) return res.status(400) .message(req.T('auth.invalid_trap')) .api() user.trap = req.body.trap } else user.trap = '' await user.save() return res.api() } async delete_group(req, res, next) { const Group = this.models.get('auth:Group') const group = await Group.findById(req.params.id) if ( !group ) return res.status(404) .message(req.T('api.group_not_found')) .api() if ( !req.user.can(`auth:group:${group.id}:delete`) ) return res.status(401) .message(req.T('api.insufficient_permissions')) .api() group.active = false await group.save() return res.api() } async delete_user(req, res, next) { const User = this.models.get('auth:User') const user = await User.findById(req.params.id) if ( !user ) return res.status(404) .message(req.T('api.user_not_found')) .api() if ( !req.user.can(`auth:user:${user.id}:delete`) ) return res.status(401) .message(req.T('api.insufficient_permissions')) .api() // check if the user is an LDAP client. if so, delete the client const Client = this.models.get('ldap:Client') const matching_client = await Client.findOne({ user_id: user.id }) if ( matching_client ) { matching_client.active = false await matching_client.save() } user.active = false await user.kickout() await user.save() return res.api() } async validate_username(req, res, next) { let is_valid = true if ( !req.body.username ) is_valid = false if ( is_valid ) { const User = this.models.get('auth:User') const user = await User.findOne({uid: req.body.username.toLowerCase()}) if ( !user || !user.can_login ) is_valid = false } return res.api({ is_valid }) } async user_exists(req, res, next) { const User = this.models.get('auth:User') if ( !req.body.username && !req.body.email ) return res.status(400) .message(`${req.T('api.provide_one')} username, email`) .api() const data = {} if ( req.body.username ) { const existing_user = await User.findOne({ uid: req.body.username.toLowerCase(), }) data.username_taken = !!existing_user } if ( req.body.email ) { const existing_user = await User.findOne({ email: req.body.email, }) data.email_taken = !!existing_user } return res.api(data) } // TODO XSRF Token /* * Request Params: * - username * - password * - [create_session = false] */ async attempt(req, res, next) { const flitter = this.auth.get_provider('flitter') const errors = await flitter.validate_login(req.body) if ( errors && errors.length > 0 ) return res.status(400) .message(req.T('auth.unable_to_complete')) .api({ errors }) const [username, ...other_args] = await flitter.get_login_args(req.body) const login_args = [username.toLowerCase(), ...other_args] const user = await flitter.login.apply(flitter, login_args) if ( !user ) return res.status(200) .message(req.T('auth.invalid_un_or_pw')) .api({ message: req.T('auth.invalid_un_or_pw'), success: false, }) // Make sure the user can sign in. // Sign-in is NOT allowed for LDAP clients const Client = this.models.get('ldap:Client') const client = await Client.findOne({ user_id: user.id }) if ( client ) return res.status(200) .message(req.T('auth.invalid_un_or_pw')) .api({ message: req.T('auth.invalid_un_or_pw'), success: false, }) if ( req.body.create_session ) await flitter.session(req, user) let destination = this.configs.get('auth.default_login_route') if ( req.session.auth.flow ) { destination = req.session.auth.flow } if ( user.mfa_enabled && !req.session.mfa_remember ) { await req.trap.begin('mfa_challenge', { session_only: true }) } if ( req.session?.auth?.message ) delete req.session.auth.message // If we're doing a trust flow, check the grant if ( req.body.grant_code && req.trust.has_flow() ) { if ( req.trust.check_grant(req.body.grant_code) ) { req.trust.grant(req.trust.flow_scope()) // Trust re-verification is granted, // but the user might still need to verify MFA const next = req.trust.end() if ( req.trap.has_trap('mfa_challenge') ) { req.session.auth.flow = next } else { destination = next } } else { return res.status(401) .message(req.T('auth.unable_to_grant_trust')) .api() } } // Create a login tracking activity await this.activity.login(req) // If there are login messages, show those const LoginMessage = this.models.get('LoginMessage') const messages = await LoginMessage.for_user(user) if ( !req.trap.has_trap('mfa_challenge') && messages.length > 0 ) { await req.trap.begin('login_message', { session_only: true }) } return res.api({ success: true, session_created: !!req.body.create_session, next: destination, }) } async get_mfa_recovery(req, res, next) { if ( !req.user.mfa_enabled ) return res.status(400) .message(req.T('auth.no_mfa')) .api() const token = req.user.mfa_token if ( !Array.isArray(token.recovery_codes) || token.recovery_codes.length < 1 ) return res.api({ has_recovery: false }) return res.api({ has_recovery: true, generated: token.recovery_codes[0].generated, remaining_codes: token.recovery_codes.filter(x => !x.used).length, }) } async generate_mfa_recovery(req, res, next) { if ( !req.user.mfa_enabled ) return res.status(400) .message(req.T('auth.no_mfa')) .api() const token = req.user.mfa_token const codes = await token.generate_recovery() await req.user.save() await this.activity.mfa_recovery_created({ req }) return res.api({ codes, }) } async generate_mfa_key(req, res, next) { if ( req.user.mfa_enabled ) return res.status(400) .message(req.T('auth.already_has_mfa')) .api() const MFAToken = this.models.get('auth:MFAToken') const secret = await this.MFA.secret(req.user) req.user.mfa_token = new MFAToken({ secret: secret.base32, otpauth_url: secret.otpauth_url }, req.user) await req.user.save() return res.api({ success: true, secret: secret.base32, otpauth_url: secret.otpauth_url, qr_code: await this.MFA.qr_code(secret) }) } async attempt_mfa(req, res, next) { if ( !req.user.mfa_token ) return res.status(400) .message(req.T('auth.no_mfa')) .api() const code = req.body.verify_code const token = req.user.mfa_token const is_valid = token.verify(code) let next_destination = undefined if ( is_valid ) { if ( req.trap.has_trap('mfa_challenge') ) await req.trap.end() // If there are login messages, show those const LoginMessage = this.models.get('LoginMessage') const messages = await LoginMessage.for_user(req.user) if ( messages.length > 0 ) { await req.trap.begin('login_message', { session_only: true }) } next_destination = req.session.auth.flow || this.configs.get('auth.default_login_route') if ( messages.length < 1 ) delete req.session.auth.flow } req.session.mfa_remember = true return res.api({ success: true, verify_code: code, is_valid, next_destination, }) } async enable_mfa(req, res, next) { if ( !req.user.mfa_token ) return res.status(400) .message(req.T('auth.no_mfa')) .api() req.user.mfa_enabled = true req.user.mfa_enable_date = new Date req.user.save() await this.activity.mfa_enable({ req }) // invalidate existing tokens and other logins await req.user.logout(req) await req.user.kickout() return res.api({success: true, mfa_enabled: req.user.mfa_enabled}) } async disable_mfa(req, res, next) { if ( !req.user.mfa_enabled ) return res.status(400) .message(req.T('auth.no_mfa')) .api() req.user.mfa_enabled = false delete req.user.mfa_enable_date delete req.user.mfa_token req.user.app_passwords = [] await req.user.save() await this.activity.mfa_disable({ req }) // invalidate existing login tokens and logins await req.user.logout(req) await req.user.kickout() return res.api({success: true, mfa_enabled: req.user.mfa_enabled}) } async get_mfa_enable_date(req, res, next) { if ( !req.user.mfa_enabled ) return res.api({ mfa_enabled: false }) return res.api({ mfa_enabled: true, mfa_enable_date: req.user.mfa_enable_date }) } } module.exports = exports = AuthController