SAML; Dashboard
This commit is contained in:
@@ -28,11 +28,7 @@ class Home extends Controller {
|
||||
}
|
||||
|
||||
async tmpl(req, res) {
|
||||
return this.Vue.auth_message(res, {
|
||||
message: 'This is a test message. Hello, baby girl; I love you very very much!.',
|
||||
next_destination: '/auth/login',
|
||||
button_text: 'Continue',
|
||||
})
|
||||
return res.page('tmpl', {...this.Vue.data(), ...this.Vue.session(req)})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,12 +54,34 @@ class AuthController extends Controller {
|
||||
destination = req.session.auth.flow
|
||||
}
|
||||
|
||||
// TODO remember-device feature
|
||||
if ( user.mfa_enabled && !req.session.mfa_remember ) {
|
||||
req.session.auth.in_dmz = true
|
||||
destination = '/auth/mfa/challenge'
|
||||
}
|
||||
|
||||
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.session.auth.in_dmz ) {
|
||||
req.session.auth.flow = next
|
||||
} else {
|
||||
destination = next
|
||||
}
|
||||
} else {
|
||||
return res.status(401)
|
||||
.message(`Unable to grant trust. Grant token is invalid.`)
|
||||
.api()
|
||||
}
|
||||
}
|
||||
|
||||
return res.api({
|
||||
success: true,
|
||||
session_created: !!req.body.create_session,
|
||||
@@ -124,15 +146,43 @@ class AuthController extends Controller {
|
||||
.api()
|
||||
|
||||
req.user.mfa_enabled = true
|
||||
req.user.mfa_enable_date = new Date
|
||||
req.user.save()
|
||||
|
||||
// TODO invalidate existing tokens and other logins
|
||||
// invalidate existing tokens and other logins
|
||||
const flitter = await this.auth.get_provider('flitter')
|
||||
await flitter.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('The user does not have MFA enabled.')
|
||||
.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()
|
||||
|
||||
// invalidate existing login tokens and logins
|
||||
const flitter = await this.auth.get_provider('flitter')
|
||||
await flitter.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
|
||||
|
||||
45
app/controllers/api/v1/Message.controller.js
Normal file
45
app/controllers/api/v1/Message.controller.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const { Controller } = require('libflitter')
|
||||
|
||||
class MessageController extends Controller {
|
||||
static get services() {
|
||||
return [...super.services, 'models']
|
||||
}
|
||||
|
||||
async get_banners(req, res, next) {
|
||||
const Message = this.models.get('Message')
|
||||
const messages = await Message.for_user(req.user)
|
||||
|
||||
return res.api(messages.map(x => {
|
||||
return {
|
||||
message: x.message,
|
||||
expires: x.expires,
|
||||
dismissed: x.dismissed,
|
||||
type: x.display_type,
|
||||
id: x.id,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async read_banner(req, res, next) {
|
||||
const banner_id = req.params.banner_id
|
||||
if ( !banner_id )
|
||||
return res.status(400)
|
||||
.message('Missing required parameter: banner_id')
|
||||
.api()
|
||||
|
||||
const Message = this.models.get('Message')
|
||||
const message = await Message.findById(banner_id)
|
||||
if ( !message )
|
||||
return res.status(404)
|
||||
.message('Banner message not found with that ID.')
|
||||
.api()
|
||||
|
||||
if ( message.user_id !== req.user.id )
|
||||
return res.status(401).api()
|
||||
|
||||
await message.dismiss()
|
||||
return res.api()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = exports = MessageController
|
||||
98
app/controllers/api/v1/Password.controller.js
Normal file
98
app/controllers/api/v1/Password.controller.js
Normal file
@@ -0,0 +1,98 @@
|
||||
const { Controller } = require('libflitter')
|
||||
const zxcvbn = require('zxcvbn')
|
||||
|
||||
class PasswordController extends Controller {
|
||||
static get services() {
|
||||
return [...super.services, 'auth']
|
||||
}
|
||||
|
||||
async get_resets(req, res, next) {
|
||||
return res.api(req.user.password_resets.map(x => {
|
||||
return {
|
||||
reset_on: x.reset_on,
|
||||
reason: x.reason,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async get_app_passwords(req, res, next) {
|
||||
return res.api(req.user.app_passwords.map(x => {
|
||||
return {
|
||||
created: x.created,
|
||||
expires: x.expires,
|
||||
active: x.active,
|
||||
name: x.name ?? '(unnamed)',
|
||||
uuid: x.uuid,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async create_app_password(req, res, next) {
|
||||
if ( !req.body.name )
|
||||
return res.status(400)
|
||||
.message('Missing required field: name')
|
||||
.api()
|
||||
|
||||
const { password, record } = await req.user.app_password(req.body.name)
|
||||
await req.user.save()
|
||||
|
||||
return res.api({
|
||||
password,
|
||||
name: req.body.name,
|
||||
uuid: record.uuid,
|
||||
})
|
||||
}
|
||||
|
||||
async delete_app_password(req, res, next) {
|
||||
if ( !req.params.uuid )
|
||||
return res.status(400)
|
||||
.message('Missing required parameter: uuid')
|
||||
.api()
|
||||
|
||||
const match = req.user.app_passwords.filter(x => x.uuid === req.params.uuid)[0]
|
||||
if ( !match )
|
||||
return res.status(400)
|
||||
.message('App password not found with that UUID.')
|
||||
.api()
|
||||
|
||||
req.user.app_passwords = req.user.app_passwords.filter(x => x.uuid !== req.params.uuid)
|
||||
await req.user.save()
|
||||
return res.api()
|
||||
}
|
||||
|
||||
async reset_password(req, res, next) {
|
||||
if ( !req.body.password )
|
||||
return res.status(400)
|
||||
.message('Missing required field: password')
|
||||
.api()
|
||||
|
||||
// Verify password complexity
|
||||
const min_score = 3
|
||||
const result = zxcvbn(req.body.password)
|
||||
if ( result.score < min_score )
|
||||
return res.status(400)
|
||||
.message(`Password does not meet the minimum complexity score of ${min_score}.`)
|
||||
.api()
|
||||
|
||||
// Make sure it's not a re-do
|
||||
for ( const old_pw of req.user.password_resets ) {
|
||||
if ( await old_pw.check(req.body.password) ) {
|
||||
return res.status(400)
|
||||
.message(`This password is a duplicate of one of your previous passwords.`)
|
||||
.api()
|
||||
}
|
||||
}
|
||||
|
||||
// Create the password reset
|
||||
const reset = await req.user.reset_password(req.body.password)
|
||||
await req.user.save()
|
||||
|
||||
// invalidate existing tokens and other logins
|
||||
const flitter = await this.auth.get_provider('flitter')
|
||||
await flitter.logout(req)
|
||||
await req.user.kickout()
|
||||
return res.api()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = exports = PasswordController
|
||||
77
app/controllers/api/v1/Profile.controller.js
Normal file
77
app/controllers/api/v1/Profile.controller.js
Normal file
@@ -0,0 +1,77 @@
|
||||
const { Controller } = require('libflitter')
|
||||
const Validator = require('email-validator')
|
||||
|
||||
class ProfileController extends Controller {
|
||||
static get services() {
|
||||
return [...super.services, 'models']
|
||||
}
|
||||
|
||||
async fetch(req, res, next) {
|
||||
const User = this.models.get('auth:User')
|
||||
|
||||
let user
|
||||
if ( req.params.user_id === 'me' ) user = req.user
|
||||
else { // if not me, verify that user can view profile
|
||||
if ( !req.user.can(`profile:view:${req.params.user_id}`) )
|
||||
return res.status(401).api()
|
||||
|
||||
user = await User.findById(req.params.user_id)
|
||||
}
|
||||
|
||||
return res.api({
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
email: user.email,
|
||||
uid: user.uid,
|
||||
tagline: user.tagline,
|
||||
user_id: user.id,
|
||||
})
|
||||
}
|
||||
|
||||
async update(req, res, next) {
|
||||
const User = this.models.get('auth:User')
|
||||
|
||||
let user
|
||||
if ( req.params.user_id === 'me' ) user = req.user
|
||||
else { // If not me, verify that user can modify profile
|
||||
if ( !req.user.can(`profile:update:${req.params.user_id}`) )
|
||||
return res.status(401).api()
|
||||
|
||||
user = await User.findById(req.params.user_id)
|
||||
}
|
||||
|
||||
if ( !user )
|
||||
return res.status(404)
|
||||
.message('No user found with the specified ID.')
|
||||
.api()
|
||||
|
||||
// Make sure the required fields are provided
|
||||
const required_fields = ['first_name', 'last_name', 'email']
|
||||
for ( const field of required_fields ) {
|
||||
if ( !req.body[field]?.trim() )
|
||||
return res.status(400)
|
||||
.message(`Required field "${field}" is missing or invalid.`)
|
||||
.api()
|
||||
}
|
||||
|
||||
// Validate the e-mail
|
||||
if ( !Validator.validate(req.body.email) )
|
||||
return res.status(400)
|
||||
.message(`"email" field must be a valid e-mail address.`)
|
||||
.api()
|
||||
|
||||
// Update the user's profile
|
||||
user.first_name = req.body.first_name
|
||||
user.last_name = req.body.last_name
|
||||
user.email = req.body.email
|
||||
if ( req.body.tagline ) user.tagline = req.body.tagline
|
||||
else delete user.tagline
|
||||
|
||||
// Save the record
|
||||
await user.save()
|
||||
return res.api()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = exports = ProfileController
|
||||
@@ -13,7 +13,7 @@ class Forms extends FormController {
|
||||
async login_provider_get(req, res, next) {
|
||||
return res.page('auth:login', {
|
||||
...this.Vue.data({
|
||||
login_message: 'Please sign-in to continue.'
|
||||
login_message: req.session?.auth?.message || 'Please sign-in to continue.'
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ const { Controller } = require('libflitter')
|
||||
|
||||
class MFAController extends Controller {
|
||||
static get services() {
|
||||
return [...super.services, 'Vue', 'configs']
|
||||
return [...super.services, 'Vue', 'configs', 'models']
|
||||
}
|
||||
|
||||
async setup(req, res, next) {
|
||||
@@ -39,6 +39,21 @@ class MFAController extends Controller {
|
||||
...this.Vue.data()
|
||||
})
|
||||
}
|
||||
|
||||
async get_disable(req, res, next) {
|
||||
return this.Vue.confirm(res, {
|
||||
message: `You are about to disable multi-factor authentication for your account. This process will require you to re-authenticate to continue. <br><br> Proceed?`,
|
||||
yes: '/auth/mfa/disable/process',
|
||||
no: '/dash/profile',
|
||||
})
|
||||
}
|
||||
|
||||
async do_disable(req, res, next) {
|
||||
return res.page('auth:mfa:disable', {
|
||||
...this.Vue.data(),
|
||||
...this.Vue.session(req),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = exports = MFAController
|
||||
|
||||
17
app/controllers/auth/Password.controller.js
Normal file
17
app/controllers/auth/Password.controller.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const { Controller } = require('libflitter')
|
||||
|
||||
class PasswordController extends Controller {
|
||||
static get services() {
|
||||
return [...super.services, 'Vue']
|
||||
}
|
||||
|
||||
async get_reset(req, res, next) {
|
||||
return res.page('auth:password:reset', {
|
||||
...this.Vue.data(),
|
||||
...this.Vue.session(req),
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = exports = PasswordController
|
||||
49
app/controllers/auth/Trust.controller.js
Normal file
49
app/controllers/auth/Trust.controller.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const { Controller } = require('libflitter')
|
||||
|
||||
class TrustController extends Controller {
|
||||
static get services() {
|
||||
return [...super.services, 'Vue']
|
||||
}
|
||||
|
||||
/*
|
||||
* Prompts the user to re-authenticate.
|
||||
* If successful, a trust token will be issued for the specified scope.
|
||||
*
|
||||
* Requires req.session.trust_flow = { scope: String, next: String }
|
||||
*/
|
||||
async get_issue(req, res, next) {
|
||||
if ( !req.trust.has_flow() )
|
||||
return res.status(400).message('Missing trust flow data.').send()
|
||||
|
||||
// Check if the session already has a token for this scope
|
||||
const has_scope = req.trust.has(req.trust.flow_scope())
|
||||
|
||||
// If so, redirect them to the destination
|
||||
if ( has_scope ) {
|
||||
return res.redirect(req.trust.end())
|
||||
}
|
||||
|
||||
// Otherwise, show the trust prompt for re-authorization
|
||||
const token = req.trust.start()
|
||||
return res.page('auth:trust:grant', {
|
||||
...this.Vue.data({
|
||||
grant_code: token,
|
||||
login_message: 'Please re-authenticate to continue.',
|
||||
}),
|
||||
...this.Vue.session(req)
|
||||
})
|
||||
}
|
||||
|
||||
/*async get_continue(req, res, next) {
|
||||
if ( !req.trust.has_flow() )
|
||||
return res.status(400).message('Missing trust flow data.')
|
||||
|
||||
if ( !req.trust.in_progress() )
|
||||
return res.status(401).message('No flow in progress. Please try again.')
|
||||
|
||||
req.trust.grant(req.trust.flow_scope())
|
||||
return res.redirect(req.trust.end())
|
||||
}*/
|
||||
}
|
||||
|
||||
module.exports = exports = TrustController
|
||||
35
app/controllers/dash/Groups.controller.js
Normal file
35
app/controllers/dash/Groups.controller.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const { Controller } = require('libflitter')
|
||||
|
||||
class GroupsController extends Controller {
|
||||
static get services() {
|
||||
return [...super.services, 'cobalt', 'models']
|
||||
}
|
||||
|
||||
async get_listing(req, res, next) {
|
||||
const Group = this.models.get('ldap:Group')
|
||||
const groups = await Group.find()
|
||||
const formatted = groups.map(x => {
|
||||
return {
|
||||
name: x.name,
|
||||
count: x.user_ids.length,
|
||||
}
|
||||
})
|
||||
|
||||
return this.cobalt.listing(req, res, {
|
||||
title: 'LDAP Groups', // TODO generalize this for SAML/OAuth2
|
||||
columns: [
|
||||
{
|
||||
name: 'Group Name',
|
||||
field: 'name',
|
||||
},
|
||||
{
|
||||
name: '# Users',
|
||||
field: 'count',
|
||||
},
|
||||
],
|
||||
data: formatted,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = exports = GroupsController
|
||||
16
app/controllers/dash/Profile.controller.js
Normal file
16
app/controllers/dash/Profile.controller.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const { Controller } = require('libflitter')
|
||||
|
||||
class ProfileController extends Controller {
|
||||
static get services() {
|
||||
return [...super.services, 'Vue']
|
||||
}
|
||||
|
||||
async get_page(req, res, next) {
|
||||
return res.page('dash:profile:main', {
|
||||
...this.Vue.data(),
|
||||
...this.Vue.session(req)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = exports = ProfileController
|
||||
46
app/controllers/dash/SAML.controller.js
Normal file
46
app/controllers/dash/SAML.controller.js
Normal file
@@ -0,0 +1,46 @@
|
||||
const { Controller } = require('libflitter')
|
||||
|
||||
class SAMLController extends Controller {
|
||||
static get services() {
|
||||
return [...super.services, 'cobalt', 'models']
|
||||
}
|
||||
|
||||
async get_sp_listing(req, res, next) {
|
||||
const ServiceProvider = this.models.get('saml:ServiceProvider')
|
||||
const service_providers = await ServiceProvider.find()
|
||||
const formatted = service_providers.map(x => {
|
||||
return {
|
||||
name: x.name,
|
||||
entity_id: x.entity_id,
|
||||
acs_url: x.acs_url,
|
||||
has_slo: !!x.slo_url,
|
||||
}
|
||||
})
|
||||
|
||||
return this.cobalt.listing(req, res, {
|
||||
title: 'SAML Service Providers',
|
||||
columns: [
|
||||
{
|
||||
name: 'Provider Name',
|
||||
field: 'name',
|
||||
},
|
||||
{
|
||||
name: 'Entity ID',
|
||||
field: 'entity_id',
|
||||
},
|
||||
{
|
||||
name: 'Has SLO?',
|
||||
field: 'has_slo',
|
||||
renderer: 'boolean',
|
||||
},
|
||||
{
|
||||
name: 'ACS URL',
|
||||
field: 'acs_url',
|
||||
},
|
||||
],
|
||||
data: formatted,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = exports = SAMLController
|
||||
47
app/controllers/dash/Users.controller.js
Normal file
47
app/controllers/dash/Users.controller.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const { Controller } = require('libflitter')
|
||||
|
||||
class UsersController extends Controller {
|
||||
static get services() {
|
||||
return [...super.services, 'models', 'cobalt']
|
||||
}
|
||||
|
||||
async get_listing(req, res, next) {
|
||||
// Columns: Username, First, Last, E-Mail
|
||||
const User = this.models.get('auth:User')
|
||||
const users = await User.find()
|
||||
const formatted = users.map(x => {
|
||||
return {
|
||||
username: x.uid,
|
||||
first: x.first_name,
|
||||
last: x.last_name,
|
||||
email: x.email,
|
||||
}
|
||||
})
|
||||
|
||||
return this.cobalt.listing(req, res, {
|
||||
title: 'Users',
|
||||
columns: [
|
||||
{
|
||||
name: 'Username',
|
||||
field: 'username',
|
||||
},
|
||||
{
|
||||
name: 'First Name',
|
||||
field: 'first',
|
||||
},
|
||||
{
|
||||
name: 'Last Name',
|
||||
field: 'last',
|
||||
},
|
||||
{
|
||||
name: 'E-Mail Address',
|
||||
field: 'email',
|
||||
},
|
||||
],
|
||||
data: formatted,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = exports = UsersController
|
||||
80
app/controllers/saml/SAML.controller.js
Normal file
80
app/controllers/saml/SAML.controller.js
Normal file
@@ -0,0 +1,80 @@
|
||||
const { Controller } = require('libflitter')
|
||||
const FlitterProfileMapper = require('../../classes/saml/FlitterProfileMapper')
|
||||
const samlp = require('samlp')
|
||||
|
||||
class SAMLController extends Controller {
|
||||
static get services() {
|
||||
return [...super.services, 'saml', 'output', 'Vue', 'configs']
|
||||
}
|
||||
|
||||
async get_metadata(req, res, next) {
|
||||
return samlp.metadata({
|
||||
issuer: this.saml.config().provider_name,
|
||||
cert: await this.saml.public_cert(),
|
||||
logoutEndpointPaths: {
|
||||
redirect: '/saml/logout',
|
||||
},
|
||||
redirectEndpointPath: '/saml/sso',
|
||||
postEndpointPath: '/saml/sso',
|
||||
})(req, res, next)
|
||||
}
|
||||
|
||||
// TODO some sort of first-logon flow
|
||||
// TODO Also, customize logon continue message
|
||||
async get_sso(req, res, next) {
|
||||
const index = await req.saml.participants.issue({ service_provider: req.saml_request.service_provider })
|
||||
|
||||
return samlp.auth({
|
||||
issuer: this.saml.config().provider_name,
|
||||
cert: await this.saml.public_cert(),
|
||||
key: await this.saml.private_key(),
|
||||
getPostURL: (wtrealm, wreply, req, callback) => {
|
||||
this.output.debug(`SAML Redirect URL: ${req.saml_request.service_provider.acs_url}`)
|
||||
return callback(null, req.saml_request.service_provider.acs_url) // fetch this from registered SAML app
|
||||
},
|
||||
profileMapper: user => new FlitterProfileMapper(user),
|
||||
destination: req.saml_request.service_provider.acs_url,
|
||||
sessionIndex: index,
|
||||
})(req, res, next)
|
||||
}
|
||||
|
||||
async post_logout(req, res, next) {
|
||||
return res.api({
|
||||
saml: req.saml_request,
|
||||
body: req.body,
|
||||
query: req.query,
|
||||
})
|
||||
}
|
||||
|
||||
async get_logout(req, res, next) {
|
||||
return samlp.logout({
|
||||
deflate: true,
|
||||
issuer: this.saml.config().provider_name,
|
||||
cert: await this.saml.public_cert(),
|
||||
key: await this.saml.private_key(),
|
||||
protocolBinding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
|
||||
clearIdPSession: done => {
|
||||
this.output.info(`Clearing IdP session for user: ${req.user.uid}`)
|
||||
req.saml.participants.clear().then(async () => {
|
||||
if ( this.saml.config().slo.end_coreid_session ) {
|
||||
await req.user.get_provider().logout(req)
|
||||
|
||||
// show logout page
|
||||
return this.Vue.auth_message(res, {
|
||||
message: `You have been successfully logged out from ${this.configs.get('app.name')}.`,
|
||||
next_destination: '/',
|
||||
})
|
||||
} else {
|
||||
return this.Vue.auth_message(res, {
|
||||
message: `You have been successfully logged out.`,
|
||||
next_destination: '/',
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
sessionParticipants: await req.saml.participants.wrapper(),
|
||||
})(req, res, next)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = exports = SAMLController
|
||||
Reference in New Issue
Block a user