SAML; Dashboard

This commit is contained in:
garrettmills
2020-05-03 20:16:54 -05:00
parent e3ecfb0d37
commit c389e151b5
1778 changed files with 148410 additions and 82 deletions

View File

@@ -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)})
}
}

View File

@@ -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

View 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

View 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

View 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

View File

@@ -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.'
}),
})
}

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View 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