You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
CoreID/app/controllers/api/v1/Auth.controller.js

765 lines
24 KiB

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