Implement OAuth2 server, link oauth:Client and auth::Oauth2Client, implement permission checks

This commit is contained in:
garrettmills 2020-05-16 23:55:08 -05:00
parent 6f621f5891
commit d558f21375
No known key found for this signature in database
GPG Key ID: 6ACD58D6ADACFC6E
51 changed files with 2808 additions and 159 deletions

0
a.out Normal file
View File

View File

@ -40,7 +40,6 @@ export default class MFADisableComponent extends Component {
vue_on_create() {
this.app_name = session.get('app.name')
console.log({session})
}
async back_click() {

View File

@ -5,52 +5,111 @@ import { resource_service } from '../service/Resource.service.js'
import { action_service } from '../service/Action.service.js'
const template = `
<div class="card col-12 col-sm-10 offset-sm-1 col-md-8 offset-md-2 col-xl-6 offset-xl-3">
<h3 class="card-title mb-4 mt-4">
<button class="btn-lg btn" @click="back"><i class="fa fa-arrow-left"></i></button> {{ title }}
</h3>
<form v-on:submit.prevent="do_nothing" v-if="is_ready">
<div class="form-group" v-for="field of definition.fields">
<span v-if="field.type.startsWith('select')">
<label :for="uuid+field.field">{{ field.name }}</label>
<select
:id="uuid+field.field"
class="form-control"
v-model="data[field.field]"
:required="field.required"
:readonly="mode === 'view'"
:multiple="!!field.type.endsWith('.multiple')"
>
<option v-for="option of field.options" :value="option.value">{{ typeof option.display === 'function' ? option.display(option) : option.display }}</option>
</select>
<small class="form-text" style="color: darkred;" v-if="field.error">{{ field.error }}</small>
</span>
<span v-if="field.type === 'text'">
<label :for="uuid+field.field">{{ field.name }}</label>
<input
type="text"
class="form-control"
:id="uuid+field.field"
v-model="data[field.field]"
:required="field.required"
:placeholder="field.placeholder"
:readonly="mode === 'view'"
ref="input"
>
<small class="form-text" style="color: darkred;" v-if="field.error">{{ field.error }}</small>
</span>
<div class="card col-12 col-sm-10 offset-sm-1 col-md-8 offset-md-2 col-xl-6 offset-xl-3 mb-5">
<span v-if="!can_access">
<div class="row m-5">
<div class="col-12 text-center">
<h5>{{ access_msg }}</h5>
</div>
</div>
</form>
<div class="col-12 text-right mb-4 mr-0 mt-2">
<span style="color: darkred;" class="font-italic mr-3" v-if="error_message">{{ error_message }}</span>
<span class="font-italic mr-3 text-muted" v-if="other_message">{{ other_message }}</span>
<button
class="btn btn-primary"
type="button"
v-if="mode !== 'view'"
@click="save_click"
>Save</button>
</div>
</span>
<span v-if="can_access">
<h3 class="card-title mb-4 mt-4">
<button class="btn-lg btn" @click="back"><i class="fa fa-arrow-left"></i></button> {{ title }}
</h3>
<div class="row" v-if="!is_ready">
<div class="col-12 text-center pad-top mb-5">
<h4>Loading...</h4>
</div>
</div>
<form v-on:submit.prevent="do_nothing" v-if="is_ready">
<div class="form-group" v-for="field of definition.fields">
<span
v-if="field.type === 'display' && (Array.isArray(field.hidden) ? !field.hidden.includes(mode) : !field.hidden) && (typeof field.if !== 'function' || field.if(data))"
v-html="typeof field.display === 'function' ? field.display(data) : field.display"
></span>
<span v-if="field.type.startsWith('select') && (Array.isArray(field.hidden) ? !field.hidden.includes(mode) : !field.hidden) && (typeof field.if !== 'function' || field.if(data))">
<label :for="uuid+field.field">{{ field.name }}</label>
<select
:id="uuid+field.field"
class="form-control"
v-model="data[field.field]"
:required="Array.isArray(field.required) ? field.required.includes(mode) : field.required"
:readonly="mode === 'view' || (Array.isArray(field.readonly) ? field.readonly.includes(mode) : field.readonly)"
:multiple="!!field.type.endsWith('.multiple')"
ref="input"
>
<option
v-for="option of field.options"
:value="option.value"
:selected="data[field.field] && data[field.field].includes(option.value)"
>{{ typeof option.display === 'function' ? option.display(option) : option.display }}</option>
</select>
<small class="form-text" style="color: darkred;" v-if="field.error">{{ field.error }}</small>
</span>
<span v-if="field.type === 'text' && (Array.isArray(field.hidden) ? !field.hidden.includes(mode) : !field.hidden) && (typeof field.if !== 'function' || field.if(data))">
<label :for="uuid+field.field">{{ field.name }}</label>
<input
type="text"
class="form-control"
:id="uuid+field.field"
v-model="data[field.field]"
:required="Array.isArray(field.required) ? field.required.includes(mode) : field.required"
:placeholder="field.placeholder"
:readonly="mode === 'view' || (Array.isArray(field.readonly) ? field.readonly.includes(mode) : field.readonly)"
ref="input"
>
<small class="form-text" style="color: darkred;" v-if="field.error">{{ field.error }}</small>
</span>
<span v-if="field.type === 'textarea' && (Array.isArray(field.hidden) ? !field.hidden.includes(mode) : !field.hidden) && (typeof field.if !== 'function' || field.if(data))">
<label :for="uuid+field.field">{{ field.name }}</label>
<textarea
class="form-control"
:id="uuid+field.field"
v-model="data[field.field]"
:required="Array.isArray(field.required) ? field.required.includes(mode) : field.required"
:placeholder="field.placeholder"
:readonly="mode === 'view' || (Array.isArray(field.readonly) ? field.readonly.includes(mode) : field.readonly)"
ref="input"
></textarea>
<small class="form-text" style="color: darkred;" v-if="field.error">{{ field.error }}</small>
</span>
<span v-if="field.type === 'password' && (Array.isArray(field.hidden) ? !field.hidden.includes(mode) : !field.hidden) && (typeof field.if !== 'function' || field.if(data))">
<label :for="uuid+field.field">{{ field.name }}</label>
<input
type="password"
class="form-control"
:id="uuid+field.field"
v-model="data[field.field]"
:required="Array.isArray(field.required) ? field.required.includes(mode) : field.required"
:placeholder="field.placeholder"
:readonly="mode === 'view' || (Array.isArray(field.readonly) ? field.readonly.includes(mode) : field.readonly)"
ref="input"
>
<input
type="password"
class="form-control"
:id="uuid+field.field+'-confirm'"
v-model="data[field.field+'-confirm']"
:required="Array.isArray(field.required) ? field.required.includes(mode) : field.required"
:placeholder="'Confirm ' + field.name"
:readonly="mode === 'view' || (Array.isArray(field.readonly) ? field.readonly.includes(mode) : field.readonly)"
>
<small class="form-text" style="color: darkred;" v-if="field.error">{{ field.error }}</small>
</span>
</div>
</form>
<div class="col-12 text-right mb-4 mr-0 mt-2">
<span style="color: darkred;" class="font-italic mr-3" v-if="error_message">{{ error_message }}</span>
<span class="font-italic mr-3 text-muted" v-if="other_message">{{ other_message }}</span>
<button
class="btn btn-primary"
type="button"
v-if="mode !== 'view'"
@click="save_click"
>Save</button>
</div>
</span>
</div>
`
@ -80,6 +139,9 @@ export default class FormComponent extends Component {
error_message = ''
other_message = ''
access_msg = ''
can_access = false
is_ready = false
mode = ''
id = ''
@ -99,49 +161,62 @@ export default class FormComponent extends Component {
this.mode = this.initial_mode
this.id = this.form_id
this.resource_class = await resource_service.get(this.resource)
if ( await this.resource_class.can(this.mode) ) {
this.can_access = true
this.access_msg = true
} else {
this.can_access = false
this.access_msg = 'Sorry, you do not have permission to ' + this.mode + ' this resource.'
return
}
} else {
this.reset()
}
this.uuid = utility.uuid()
await this.load()
await this.init()
await this.load()
}
async init() {
this.definition = this.resource_class.form_definition
for ( const field of this.definition.fields ) {
if ( field.type.startsWith('select.dynamic') ) {
field._options = field._options || field.options
const rsc = await resource_service.get(field._options.resource)
const other_params = field._options.other_params || {}
field.options = (await rsc.list()).map(item => {
field.options = (await rsc.list(other_params)).map(item => {
return {
display: typeof field._options.display === 'function' ? field._options.display(item) : item[field._options.display || 'display'],
value: typeof field._options.value === 'function' ? field._options.value(item) : item[field._options.value || 'display'],
}
})
}
}
}
if ( field.type.endsWith('.multiple') ) {
async load() {
if (this.mode !== 'insert') {
this.data = await this.resource_class.get(this.id)
}
for ( const field of this.definition.fields ) {
if ( field.type.endsWith('.multiple') && !this.data[field.field] ) {
this.data[field.field] = []
}
}
this.title = title_map[this.mode] + ' ' + this.resource_class.item
this.is_ready = true
this.$nextTick(() => {
if ( this.mode !== 'view' ) this.$refs.input[0].focus()
})
}
async load() {
this.definition = this.resource_class.form_definition
if (this.mode !== 'insert') {
this.data = await this.resource_class.get(this.id)
}
this.title = title_map[this.mode] + ' ' + this.resource_class.item
}
async on_create() {
this.id = this.data.id
this.mode = 'update'
@ -171,9 +246,12 @@ export default class FormComponent extends Component {
validate() {
let valid = true
for ( const field of this.definition.fields ) {
if ( field.required && (!(field.field in this.data) || !this.data[field.field]) ) {
if ( (Array.isArray(field.required) ? field.required.includes(this.mode) : field.required) && (!(field.field in this.data) || !this.data[field.field]) ) {
field.error = 'This field is required.'
valid = false
} else if ( field.type === 'password' && this.data[field.field] !== this.data[field.field + '-confirm'] ) {
field.error = field.name + ' confirmation does not match.'
valid = false
} else {
field.error = ''
}

View File

@ -1,49 +1,62 @@
import { Component } from '../../lib/vues6/vues6.js'
import { action_service } from '../service/Action.service.js'
import { message_service } from '../service/Message.service.js'
import { resource_service } from '../service/Resource.service.js'
const template = `
<div>
<div class="row mb-4">
<div class="col-10"><h3>{{ resource_class.plural }}</h3></div>
<div class="col-2 text-right" v-if="definition.actions">
<button
:class="['btn', 'btn-'+(action.color || 'secondary'), 'btn-sm']"
type="button"
v-for="action of definition.actions"
@click="perform($event, action)"
v-if="action.position === 'main'"
>{{ action.text }}</button>
<span v-if="!can_access">
<div class="row m-5">
<div class="col-12 text-center">
<h4 class="pad-top">{{ access_msg }}</h4>
</div>
</div>
</div>
<table class="table">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col" v-for="col of definition.columns">{{ col.name }}</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) of data">
<th scope="row">{{ index + 1 }}</th>
<td v-for="col of definition.columns">
<span v-if="typeof col.renderer === 'function'">{{ col.renderer(row[col.field]) }}</span>
<span v-if="col.renderer === 'boolean'">{{ row[col.field] ? 'Yes' : 'No' }}</span>
<span v-if="col.renderer !== 'boolean' && typeof col.renderer !== 'function'">{{ col.field in row ? row[col.field] : '-' }}</span>
</td>
<td>
<button
type="button"
:class="['mr-2', 'btn', 'btn-sm', 'btn-'+(action.color || 'secondary')]"
v-for="action of definition.actions"
v-if="action.position === 'row'"
@click="perform($event, action, row)"
><i :class="action.icon" v-if="action.icon"></i> {{ action.text }}</button>
</td>
</tr>
</tbody>
</table>
</span>
<span v-if="can_access">
<div class="row mb-4">
<div class="col-10"><h3>{{ resource_class.plural }}</h3></div>
<div class="col-2 text-right" v-if="definition.actions">
<button
:class="['btn', 'btn-'+(action.color || 'secondary'), 'btn-sm']"
type="button"
v-for="action of definition.actions"
@click="perform($event, action)"
v-if="action.position === 'main'"
>{{ action.text }}</button>
</div>
</div>
<div class="row mb-4" v-if="definition.display">
<div class="col-12" v-html="definition.display"></div>
</div>
<table class="table">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col" v-for="col of definition.columns">{{ col.name }}</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) of data">
<th scope="row">{{ index + 1 }}</th>
<td v-for="col of definition.columns">
<span v-if="typeof col.renderer === 'function'">{{ col.renderer(row[col.field]) }}</span>
<span v-if="col.renderer === 'boolean'">{{ row[col.field] ? 'Yes' : 'No' }}</span>
<span v-if="col.renderer !== 'boolean' && typeof col.renderer !== 'function'">{{ col.field in row ? row[col.field] : '-' }}</span>
</td>
<td>
<button
type="button"
:class="['mr-2', 'btn', 'btn-sm', 'btn-'+(action.color || 'secondary')]"
v-for="action of definition.actions"
v-if="action.position === 'row'"
@click="perform($event, action, row)"
><i :class="action.icon" v-if="action.icon"></i> {{ action.text }}</button>
</td>
</tr>
</tbody>
</table>
</span>
</div>
`
@ -56,17 +69,23 @@ export default class ListingComponent extends Component {
data = []
resource_class = {}
access_msg = ''
can_access = false
async vue_on_create() {
// Load the resource
const resource_mod = await import(`../resource/${this.resource}.resource.js`)
if ( !resource_mod )
throw new Error('Unable to load Cobalt listing resource.')
this.resource_class = await resource_service.get(this.resource)
const rsc_name = this.resource.toLowerCase().replace(/\//g, '_')
if ( !resource_mod[rsc_name] )
throw new Error('Unable to extract resource object from module.')
// Make sure we have permission
if ( !(await this.resource_class.can('list')) ) {
this.access_msg = 'Sorry, you do not have permission to view this resource.'
this.can_access = false
return
} else {
this.access_msg = ''
this.can_access = true
}
this.resource_class = resource_mod[rsc_name]
await this.load()
}

View File

@ -37,6 +37,7 @@ const template = `
>
<h6 class="dropdown-header">Hello, {{ first_name }}.</h6>
<a href="/dash/profile" class="dropdown-item">My Profile</a>
<a href="/dash/c/listing/reflect/Token" v-if="can.api_tokens" class="dropdown-item">API Tokens</a>
<div class="dropdown-divider"></div>
<a href="/auth/logout" class="dropdown-item">Sign-Out of {{ app_name }}</a>
</div>
@ -51,6 +52,8 @@ export default class NavBarComponent extends Component {
static get template() { return template }
static get props() { return [] }
can = {}
constructor() {
super()
this.toggle_event = event_bus.event('sidebar.toggle')
@ -59,6 +62,10 @@ export default class NavBarComponent extends Component {
this.app_name = session.get('app.name')
}
async vue_on_create() {
this.can.api_tokens = await session.check_permissions('v1:reflect:tokens:list')
}
toggle_sidebar() {
this.toggle_event.fire()
}

View File

@ -1,6 +1,8 @@
import { Component } from '../../lib/vues6/vues6.js'
import { event_bus } from '../service/EventBus.service.js'
import { action_service } from '../service/Action.service.js'
import { resource_service } from '../service/Resource.service.js'
import { session } from '../service/Session.service.js'
const template = `
<div class="bg-light border-right coreid-sidebar-wrapper" id="sidebar-wrapper" v-bind:class="{ collapsed: isCollapsed }">
@ -23,7 +25,9 @@ export default class SideBarComponent extends Component {
static get props() { return ['app_name'] }
static get template() { return template }
actions = [
actions = []
possible_actions = [
{
text: 'Profile',
action: 'redirect',
@ -31,23 +35,45 @@ export default class SideBarComponent extends Component {
},
{
text: 'Users',
action: 'redirect',
next: '/dash/users',
action: 'list',
type: 'resource',
resource: 'auth/User',
},
{
text: 'Groups',
action: 'redirect',
next: '/dash/c/listing/ldap/Group',
action: 'list',
type: 'resource',
resource: 'auth/Group',
},
{
text: 'Applications',
action: 'list',
type: 'resource',
resource: 'App',
},
{
text: 'IAM Policy',
action: 'list',
type: 'resource',
resource: 'iam/Policy',
},
{
text: 'LDAP Clients',
action: 'redirect',
next: '/dash/c/listing/ldap/Client',
action: 'list',
type: 'resource',
resource: 'ldap/Client',
},
{
text: 'OAuth2 Clients',
action: 'list',
type: 'resource',
resource: 'oauth/Client',
},
{
text: 'SAML Service Providers',
action: 'redirect',
next: '/dash/c/listing/saml/Provider',
action: 'list',
type: 'resource',
resource: 'saml/Provider',
},
{
text: 'Settings',
@ -63,6 +89,32 @@ export default class SideBarComponent extends Component {
})
}
async vue_on_create() {
const new_actions = []
const perm_lookups = []
for ( const action of this.possible_actions ) {
if ( action.resource ) {
action.rsc = await resource_service.get(action.resource)
perm_lookups.push(`${action.rsc.permission_base}:list`)
}
}
const perms = await session.check_permissions(...perm_lookups)
for ( const action of this.possible_actions ) {
if ( action.resource ) {
if ( perms[`${action.rsc.permission_base}:list`] ) {
new_actions.push(action)
}
} else {
new_actions.push(action)
}
}
this.actions = new_actions
}
isCollapsed = false
toggle() {

View File

@ -175,8 +175,6 @@ export default class EditProfileComponent extends Component {
this.form_message = 'Saving...'
save()
}
console.log('profile form', this)
}
get_submit_data() {

View File

@ -80,7 +80,6 @@ export default class AppPasswordFormComponent extends Component {
vue_on_create() {
this.uuid = utility.uuid()
console.log({auth_api})
}
async on_name_change(event) {

View File

@ -0,0 +1,112 @@
import CRUDBase from './CRUDBase.js'
import { session } from '../service/Session.service.js'
class AppResource extends CRUDBase {
endpoint = '/api/v1/applications'
required_fields = ['name', 'identifier']
permission_base = 'v1:applications'
item = 'Application'
plural = 'Applications'
listing_definition = {
display: `
An application is anything that can authenticate users against ${session.get('app.name')}. Applications can have any number of associated LDAP clients, SAML service providers, and OAuth2 clients.
`,
columns: [
{
name: 'Name',
field: 'name',
},
{
name: 'Identifier',
field: 'identifier',
},
{
name: 'Description',
field: 'description',
},
],
actions: [
{
type: 'resource',
position: 'main',
action: 'insert',
text: 'Create New',
color: 'success',
},
{
type: 'resource',
position: 'row',
action: 'update',
icon: 'fa fa-edit',
color: 'primary',
},
{
type: 'resource',
position: 'row',
action: 'delete',
icon: 'fa fa-times',
color: 'danger',
confirm: true,
},
],
}
form_definition = {
fields: [
{
name: 'Name',
field: 'name',
placeholder: 'Awesome App',
required: true,
type: 'text',
},
{
name: 'Identifier',
field: 'identifier',
placeholder: 'awesome_app',
required: true,
type: 'text',
},
{
name: 'Description',
field: 'description',
type: 'textarea',
},
{
name: 'Associated LDAP Clients',
field: 'ldap_client_ids',
type: 'select.dynamic.multiple',
options: {
resource: 'ldap/Client',
display: 'name',
value: 'id',
},
},
{
name: 'Associated OAuth2 Clients',
field: 'oauth_client_ids',
type: 'select.dynamic.multiple',
options: {
resource: 'oauth/Client',
display: 'name',
value: 'id',
},
},
{
name: 'Associated SAML Service Providers',
field: 'saml_service_provider_ids',
type: 'select.dynamic.multiple',
options: {
resource: 'saml/Provider',
display: 'name',
value: 'id',
},
},
],
}
}
const app = new AppResource()
export { app }

View File

@ -1,8 +1,10 @@
import APIParseError from './APIParseError.js'
import { session } from '../service/Session.service.js'
export default class CRUDBase {
endpoint = '/api/v1'
required_fields = []
permission_base = ''
listing_definition = {}
form_definition = {}
@ -10,14 +12,18 @@ export default class CRUDBase {
item = ''
plural = ''
async list() {
const results = await axios.get(this._endpoint())
async can(action) {
return session.check_permissions(`${this.permission_base}:${action}`)
}
async list(other_params = {}) {
const results = await axios.get(this._endpoint(), { params: other_params })
if ( results && results.data && Array.isArray(results.data.data) ) return results.data.data
else throw new APIParseError()
}
async get(id) {
const results = await axios.get(this._endpoint(id))
async get(id, other_params = {}) {
const results = await axios.get(this._endpoint(id), { params: other_params })
if ( results && results.data && results.data.data ) return results.data.data
else throw new APIParseError()
}
@ -40,8 +46,8 @@ export default class CRUDBase {
await axios.patch(this._endpoint(id), properties)
}
async delete(id) {
await axios.delete(this._endpoint(id))
async delete(id, other_params = {}) {
await axios.delete(this._endpoint(id), { params: other_params })
}
_endpoint(sub = '/') {

View File

@ -0,0 +1,77 @@
import CRUDBase from '../CRUDBase.js'
import { session } from '../../service/Session.service.js'
class GroupResource extends CRUDBase {
endpoint = '/api/v1/auth/groups'
required_fields = ['name']
permission_base = 'v1:auth:groups'
item = 'Group'
plural = 'Groups'
listing_definition = {
display: `
In ${session.get('app.name')}, groups are simply a tool for organizing users and assigning permissions and access in bulk. After creating and assigning users to a group, you can manage permissions for that group, and its policies will be applied to all users in that group.
`,
columns: [
{
name: 'Name',
field: 'name',
},
{
name: '# of Users',
field: 'user_ids',
renderer: (user_ids) => Array.isArray(user_ids) ? user_ids.length : 0,
},
],
actions: [
{
type: 'resource',
position: 'main',
action: 'insert',
text: 'Create New',
color: 'success',
},
{
type: 'resource',
position: 'row',
action: 'update',
icon: 'fa fa-edit',
color: 'primary',
},
{
type: 'resource',
position: 'row',
action: 'delete',
icon: 'fa fa-times',
color: 'danger',
confirm: true,
},
],
}
form_definition = {
fields: [
{
name: 'Name',
field: 'name',
placeholder: 'Some Cool Users',
required: true,
type: 'text',
},
{
name: 'Users',
field: 'user_ids',
type: 'select.dynamic.multiple',
options: {
resource: 'auth/User',
display: (user) => `${user.last_name}, ${user.first_name} (${user.uid})`,
value: 'id',
},
},
],
}
}
const auth_group = new GroupResource()
export { auth_group }

View File

@ -3,6 +3,7 @@ import CRUDBase from '../CRUDBase.js'
class RoleResource extends CRUDBase {
endpoint = '/api/v1/auth/roles'
required_fields = ['role', 'permissions']
permission_base = 'v1:auth:roles'
item = 'Role'
plural = 'Roles'

View File

@ -1,11 +1,106 @@
import CRUDBase from '../CRUDBase.js'
import { session } from '../../service/Session.service.js'
class UserResource extends CRUDBase {
endpoint = '/api/v1/auth/users'
required_fields = ['uid', 'first_name', 'last_name', 'email', 'password']
required_fields = ['uid', 'first_name', 'last_name', 'email']
permission_base = 'v1:auth:users'
item = 'User'
plural = 'Users'
listing_definition = {
display: `
Users can be assigned permissions and, if granted, can manage their ${session.get('app.name')} accounts from the Profile page, as well as login to the external applications they've been given access to.
`,
columns: [
{
name: 'UID',
field: 'uid',
},
{
name: 'Last Name',
field: 'last_name',
},
{
name: 'First Name',
field: 'first_name',
},
{
name: 'E-Mail',
field: 'email',
},
],
actions: [
{
type: 'resource',
position: 'main',
action: 'insert',
text: 'Create New',
color: 'success',
},
{
type: 'resource',
position: 'row',
action: 'update',
icon: 'fa fa-edit',
color: 'primary',
},
{
type: 'resource',
position: 'row',
action: 'delete',
icon: 'fa fa-times',
color: 'danger',
confirm: true,
},
],
}
form_definition = {
fields: [
{
name: 'First Name',
field: 'first_name',
placeholder: 'John',
required: true,
type: 'text',
},
{
name: 'Last Name',
field: 'last_name',
placeholder: 'Doe',
required: true,
type: 'text',
},
{
name: 'Username',
field: 'uid',
placeholder: 'john.doe',
required: true,
type: 'text',
},
{
name: 'E-Mail',
field: 'email',
placeholder: 'john@contoso.com',
required: true,
type: 'text',
},
{
name: 'Tagline',
field: 'tagline',
type: 'text',
},
{
name: 'Password',
field: 'password',
type: 'password',
placeholder: 'Password',
required: ['insert'],
},
],
}
}
const auth_user = new UserResource()

View File

@ -0,0 +1,140 @@
import CRUDBase from '../CRUDBase.js'
import { session } from '../../service/Session.service.js'
class PolicyResource extends CRUDBase {
endpoint = '/api/v1/iam/policy'
required_fields = ['entity_id', 'entity_type', 'target_id', 'target_type', 'access_type']
permission_base = 'v1:iam:policy'
item = 'IAM Policy'
plural = 'IAM Policies'
listing_definition = {
display: `
Identity & Access Management (IAM) policies give you fine grained control over which ${session.get('app.name')} users and groups are allowed to access which applications.
<br><br>
An IAM policy has three parts. First, is the subject. The subject is who the policy applies to and is either a user or a group. The second part is the access type. This is either an allowance or a denial. That is, the policy either grants a subject access to a resource, or explicitly denies them access. The final part of the policy is the target. This is the application that the subject is being granted or denied access to.
<br><br>
Note that IAM policies can be overlapping. So, ${session.get('app.name')}'s policy engine follows a few basic rules when deciding what policies take precedence:
<br><br>
<ol>
<li>User policy takes precedence over group policy.</li>
<li>Denials take precedence over approvals.</li>
<li>Denials by default.</li>
</ol>
This means, for example, that if a user's group is allowed access, but a user is denied access, the user will be denied access. Likewise, if there are two policies for a subject, one granting them access and one denying them access, the denial will take precedence.
`,
columns: [
{
name: 'Subject',
field: 'entity_display',
},
{
name: 'Access Type',
field: 'access_type',
renderer: access_type => access_type === 'deny' ? '...is denied access to...' : '...is granted access to...',
},
{
name: 'Target',
field: 'target_display',
},
],
actions: [
{
type: 'resource',
position: 'main',
action: 'insert',
text: 'Create New',
color: 'success',
},
{
type: 'resource',
position: 'row',
action: 'update',
icon: 'fa fa-edit',
color: 'primary',
},
{
type: 'resource',
position: 'row',
action: 'delete',
icon: 'fa fa-times',
color: 'danger',
confirm: true,
},
],
}
form_definition = {
fields: [
{
name: 'Subject Type',
field: 'entity_type',
required: true,
type: 'select',
options: [
{ display: 'User', value: 'user' },
{ display: 'Group', value: 'group' },
],
},
{
name: 'Subject',
field: 'entity_id',
required: true,
type: 'select.dynamic',
options: {
resource: 'auth/User',
display: user => `User: ${user.last_name}, ${user.first_name} (${user.uid})`,
value: 'id',
},
if: (form_data) => form_data.entity_type === 'user',
},
{
name: 'Subject',
field: 'entity_id',
required: true,
type: 'select.dynamic',
options: {
resource: 'auth/Group',
display: group => `Group: ${group.name} (${group.user_ids.length} users)`,
value: 'id',
},
if: (form_data) => form_data.entity_type === 'group',
},
{
name: 'Access Type',
field: 'access_type',
required: true,
type: 'select',
options: [
{ display: '...is granted access to...', value: 'allow' },
{ display: '...is denied access to...', value: 'deny' },
],
},
{
name: 'Target Type',
field: 'target_type',
required: true,
type: 'select',
options: [
{ display: 'Application', value: 'application' },
],
},
{
name: 'Target',
field: 'target_id',
required: true,
type: 'select.dynamic',
options: {
resource: 'App',
display: 'name',
value: 'id',
},
if: (form_data) => form_data.target_type === 'application'
},
],
}
}
const iam_policy = new PolicyResource()
export { iam_policy }

View File

@ -1,13 +1,20 @@
import CRUDBase from '../CRUDBase.js'
import { session } from '../../service/Session.service.js'
class ClientResource extends CRUDBase {
endpoint = '/api/v1/ldap/clients'
required_fields = ['name', 'uid', 'password']
permission_base = 'v1:ldap:clients'
item = 'LDAP Client'
plural = 'LDAP Clients'
listing_definition = {
display: `
LDAP Clients are special user accounts that external applications can use to bind to ${session.get('app.name')}'s built-in LDAP server to allow these applications to authenticate users.
<br><br>
These special accounts are permitted to bind to the LDAP server, but are not allowed to sign-in to ${session.get('app.name')}.
`,
columns: [
{
name: 'Client Name',
@ -33,6 +40,14 @@ class ClientResource extends CRUDBase {
icon: 'fa fa-edit',
color: 'primary',
},
{
type: 'resource',
position: 'row',
action: 'delete',
icon: 'fa fa-times',
color: 'danger',
confirm: true,
},
],
}

View File

@ -3,6 +3,7 @@ import CRUDBase from '../CRUDBase.js'
class GroupResource extends CRUDBase {
endpoint = '/api/v1/ldap/groups'
required_fields = ['name', 'role']
permission_base = 'v1:ldap:groups'
item = 'LDAP Group'
plural = 'LDAP Groups'

View File

@ -0,0 +1,107 @@
import CRUDBase from '../CRUDBase.js'
import { session } from '../../service/Session.service.js';
class ClientResource extends CRUDBase {
endpoint = '/api/v1/oauth/clients'
required_fields = ['name', 'redirect_url', 'api_scopes']
permission_base = 'v1:oauth:clients'
item = 'OAuth2 Client'
plural = 'OAuth2 Clients'
listing_definition = {
display: `
OAuth2 clients are applications that support authentication over the OAuth2 protocol. This allows you to add a "Sign-In with XXX" button for ${session.get('app.name')} to the application in question. To do this, you need to create an OAuth2 client for that application, and provide the name, redirect URL, and API scopes.
<br><br>
You must select the API scopes to grant this OAuth2 client. This defines what ${session.get('app.name')} endpoints the application is allowed to access. For most applications, granting the <code>v1:api:users:get</code> and <code>v1:api:groups:get</code> API scopes should be sufficient.
<br><br>
This method can also be used to access the API for other purposes. Hence, the expansive API scopes. ${session.get('app.name')} uses Flitter-Auth's built-in OAuth2 server under the hood, so you can find details on how to configure the OAuth2 clients <a href="https://flitter.garrettmills.dev/tutorial-flitter-auth-oauth2-server.html" target="_blank">here.</a>
`,
columns: [
{
name: 'Client Name',
field: 'name',
},
{
name: '# of Scopes',
field: 'api_scopes',
renderer: (api_scopes) => api_scopes.length,
},
{
name: 'Redirect URL',
field: 'redirect_url',
},
],
actions: [
{
type: 'resource',
position: 'main',
action: 'insert',
text: 'Create New',
color: 'success',
},
{
type: 'resource',
position: 'row',
action: 'update',
icon: 'fa fa-edit',
color: 'primary',
},
{
type: 'resource',
position: 'row',
action: 'delete',
icon: 'fa fa-times',
color: 'danger',
confirm: true,
},
],
}
form_definition = {
fields: [
{
name: 'Client Name',
field: 'name',
placeholder: 'Awesome External App',
required: true,
type: 'text',
},
{
name: 'Redirect URL',
field: 'redirect_url',
placeholder: 'https://awesome.app/oauth2/callback',
required: true,
type: 'text',
},
{
name: 'API Scopes',
field: 'api_scopes',
type: 'select.dynamic.multiple',
options: {
resource: 'reflect/Scope',
display: 'scope',
value: 'scope',
},
required: true,
},
{
name: 'Client ID',
field: 'uuid',
type: 'text',
readonly: true,
hidden: ['insert'],
},
{
name: 'Client Secret',
field: 'secret',
type: 'text',
readonly: true,
hidden: ['insert'],
},
],
}
}
const oauth_client = new ClientResource()
export { oauth_client }

View File

@ -0,0 +1,13 @@
import CRUDBase from '../CRUDBase.js'
class ScopeResource extends CRUDBase {
endpoint = '/api/v1/reflect/scopes'
required_fields = ['scope']
permission_base = 'v1:reflect:scopes'
item = 'API Scope'
plural = 'API Scopes'
}
const reflect_scope = new ScopeResource()
export { reflect_scope }

View File

@ -0,0 +1,89 @@
import CRUDBase from '../CRUDBase.js'
class TokenResource extends CRUDBase {
endpoint = '/api/v1/reflect/tokens'
required_fields = ['client_id']
permission_base = 'v1:reflect:tokens'
item = 'API Token'
plural = 'API Tokens'
listing_definition = {
display: `
This allows you to create bearer tokens manually to allow for easier testing of the API. Notably, this is meant as a measure for testing and development, not for long term use.
<br><br>
If you have an application that needs to regularly interact with the API, set it up as an <a href="/dash/c/listing/oauth/Client">OAuth2 Client</a>. Manually-created tokens expire 7 days after their creation.
`,
columns: [
{
name: 'Token',
field: 'token',
},
{
name: 'Client',
field: 'client_display',
},
{
name: 'Expires',
field: 'expires',
},
],
actions: [
{
type: 'resource',
position: 'main',
action: 'insert',
text: 'Create New',
color: 'success',
},
{
type: 'resource',
position: 'row',
action: 'update',
icon: 'fa fa-edit',
color: 'primary',
},
{
type: 'resource',
position: 'row',
action: 'delete',
icon: 'fa fa-times',
color: 'danger',
confirm: true,
},
],
}
form_definition = {
fields: [
{
name: 'Client',
field: 'client_id',
required: true,
type: 'select.dynamic',
options: {
resource: 'oauth/Client',
display: 'name',
value: 'uuid',
},
},
{
name: 'Bearer Token',
field: 'token',
type: 'text',
readonly: true,
hidden: ['insert'],
},
{
name: 'Expires',
field: 'expires',
type: 'text',
readonly: true,
hidden: ['insert'],
},
],
}
}
const reflect_token = new TokenResource()
export { reflect_token }

View File

@ -1,13 +1,18 @@
import CRUDBase from '../CRUDBase.js'
import { session } from '../../service/Session.service.js'
class ProviderResource extends CRUDBase {
endpoint = '/api/v1/saml/providers'
required_fields = ['name', 'acs_url', 'entity_id']
permission_base = 'v1:saml:providers'
item = 'SAML Service Provider'
plural = 'SAML Service Providers'
listing_definition = {
display: `SAML Service Providers are applications that support external authentication to a SAML Identity Provider. In this case, ${session.get('app.name')} is the identity provider, so these external applications can authenticate against it.
<br><br>
To do this, you need to know the SAML service provider's entity ID, assertion consumer service URL, and single-logout URL (if supported).`,
columns: [
{
name: 'Provider Name',
@ -42,6 +47,14 @@ class ProviderResource extends CRUDBase {
icon: 'fa fa-edit',
color: 'primary',
},
{
type: 'resource',
position: 'row',
action: 'delete',
icon: 'fa fa-times',
color: 'danger',
confirm: true,
},
],
}

View File

@ -26,6 +26,12 @@ class Session {
parent[parts.reverse()[0]] = value
}
async check_permissions(...permissions) {
const result = await axios.post('/api/v1/reflect/check_permissions', { permissions })
if ( permissions.length === 1 ) return result.data.data[permissions[0]]
return result.data.data
}
}
const session = new Session()

View File

@ -0,0 +1,272 @@
const { Controller } = require('libflitter')
class AppController extends Controller {
static get services() {
return [...super.services, 'models', 'utility']
}
async get_applications(req, res, next) {
const Application = this.models.get('Application')
const applications = await Application.find({ active: true })
const data = []
for ( const app of applications ) {
if ( req.user.can(`app:${app.id}:view`) ) {
data.push(await app.to_api())
}
}
return res.api(data)
}
async get_application(req, res, next) {
const Application = this.models.get('Application')
const application = await Application.findById(req.params.id)
if ( !application || !application.active )
return res.status(404)
.message('Application not found with that ID.')
.api()
if ( !req.user.can(`app:${application.id}:view`) )
return res.status(401)
.message('Insufficient permissions.')
.api()
return res.api(await application.to_api())
}
async create_application(req, res, next) {
const Application = this.models.get('Application')
if ( !req.user.can('app:create') )
return res.status(401)
.message('Insufficient permissions.')
.api()
const required_fields = ['name', 'identifier']
for ( const field of required_fields ) {
if ( !req.body[field] )
return res.status(400)
.message(`Missing required field: ${field}`)
.api()
}
// Make sure the identifier is properly formatted
if ( !(new RegExp('^[a-zA-Z0-9_]*$')).test(req.body.identifier) )
return res.status(400)
.message('Improperly formatted field: identifier (alphanumeric with underscores only)')
.api()
// Make sure the identifier is unique
const existing_app = await Application.findOne({ identifier: req.body.identifier })
if ( existing_app )
return res.status(400)
.message('An Application with that identifier already exists.')
.api()
const application = new Application({
name: req.body.name,
identifier: req.body.identifier,
description: req.body.description,
})
// Verify LDAP client IDs
const LDAPClient = this.models.get('ldap:Client')
if ( req.body.ldap_client_ids ) {
const parsed = typeof req.body.ldap_client_ids === 'string' ? this.utility.infer(req.body.ldap_client_ids) : req.body.ldap_client_ids
const ldap_client_ids = Array.isArray(parsed) ? parsed : [parsed]
for ( const id of ldap_client_ids ) {
const client = await LDAPClient.findById(id)
if ( !client || !client.active || !req.user.can(`ldap:client:${client.id}:view`) )
return res.status(400)
.message(`Invalid ldap_client_id: ${id}`)
.api()
const other_assoc_app = await Application.findOne({ ldap_client_ids: client.id })
if ( other_assoc_app )
return res.status(400)
.message(`The LDAP client ${client.name} is already associated with an existing application (${other_assoc_app.name}).`)
.api()
}
application.ldap_client_ids = ldap_client_ids
}
// Verify OAuth client IDs
const OAuthClient = this.models.get('oauth:Client')
if ( req.body.oauth_client_ids ) {
const parsed = typeof req.body.oauth_client_ids === 'string' ? this.utility.infer(req.body.oauth_client_ids) : req.body.oauth_client_ids
const oauth_client_ids = Array.isArray(parsed) ? parsed : [parsed]
for ( const id of oauth_client_ids ) {
const client = await OAuthClient.findById(id)
if ( !client || !client.active || !req.user.can(`oauth:client:${client.id}:view`) )
return res.status(400)
.message(`Invalid oauth_client_id: ${id}`)
.api()
const other_assoc_app = await Application.findOne({ oauth_client_ids: client.id })
if ( other_assoc_app )
return res.status(400)
.message(`The OAuth2 client ${client.name} is already associated with an existing application (${other_assoc_app.name}).`)
.api()
}
application.oauth_client_ids = oauth_client_ids
}
// Verify SAML service provider IDs
const ServiceProvider = this.models.get('saml:ServiceProvider')
if ( req.body.saml_service_provider_ids ) {
const parsed = typeof req.body.saml_service_provider_ids === 'string' ? this.utility.infer(req.body.saml_service_provider_ids) : req.body.saml_service_provider_ids
const saml_service_provider_ids = Array.isArray(parsed) ? parsed : [parsed]
for ( const id of saml_service_provider_ids ) {
const provider = await ServiceProvider.findById(id)
if ( !provider || !provider.active || !req.user.can(`saml:provider:${provider.id}:view`) )
return res.status(400)
.message(`Invalid saml_service_provider_id: ${id}`)
.api()
const other_assoc_app = await Application.findOne({ saml_service_provider_ids: provider.id })
if ( other_assoc_app )
return res.status(400)
.message(`The SAML service provider ${provider.name} is already associated with an existing application (${other_assoc_app.name}).`)
.api()
}
application.saml_service_provider_ids = saml_service_provider_ids
}
await application.save()
return res.api(await application.to_api())
}
async update_application(req, res, next) {
const Application = this.models.get('Application')
const application = await Application.findById(req.params.id)
if ( !application || !application.active )
return res.status(404)
.message('Application not found with that ID.')
.api()
if ( !req.user.can(`app:${application.id}:update`) )
return res.status(401)
.message('Insufficient permissions.')
.api()
const required_fields = ['name', 'identifier']
for ( const field of required_fields ) {
if ( !req.body[field] )
return res.status(400)
.message(`Missing required field: ${field}`)
.api()
}
// Make sure the identifier is properly formatted
if ( !(new RegExp('^[a-zA-Z0-9_]*$')).test(req.body.identifier) )
return res.status(400)
.message('Improperly formatted field: identifier (alphanumeric with underscores only)')
.api()
// Make sure the identifier is unique
const existing_app = await Application.findOne({ identifier: req.body.identifier })
if ( existing_app && existing_app.id !== application.id )
return res.status(400)
.message('An Application with that identifier already exists.')
.api()
// Verify LDAP client IDs
const LDAPClient = this.models.get('ldap:Client')
if ( req.body.ldap_client_ids ) {
const parsed = typeof req.body.ldap_client_ids === 'string' ? this.utility.infer(req.body.ldap_client_ids) : req.body.ldap_client_ids
const ldap_client_ids = Array.isArray(parsed) ? parsed : [parsed]
for ( const id of ldap_client_ids ) {
const client = await LDAPClient.findById(id)
if ( !client || !client.active || !req.user.can(`ldap:client:${client.id}:view`) )
return res.status(400)
.message(`Invalid ldap_client_id: ${id}`)
.api()
const other_assoc_app = await Application.findOne({ ldap_client_ids: client.id })
if ( other_assoc_app && other_assoc_app.id !== application.id )
return res.status(400)
.message(`The LDAP client ${client.name} is already associated with an existing application (${other_assoc_app.name}).`)
.api()
}
application.ldap_client_ids = ldap_client_ids
} else application.ldap_client_ids = []
// Verify OAuth client IDs
const OAuthClient = this.models.get('oauth:Client')
if ( req.body.oauth_client_ids ) {
const parsed = typeof req.body.oauth_client_ids === 'string' ? this.utility.infer(req.body.oauth_client_ids) : req.body.oauth_client_ids
const oauth_client_ids = Array.isArray(parsed) ? parsed : [parsed]
for ( const id of oauth_client_ids ) {
const client = await OAuthClient.findById(id)
if ( !client || !client.active || !req.user.can(`oauth:client:${client.id}:view`) )
return res.status(400)
.message(`Invalid oauth_client_id: ${id}`)
.api()
const other_assoc_app = await Application.findOne({ oauth_client_ids: client.id })
if ( other_assoc_app && other_assoc_app.id !== application.id )
return res.status(400)
.message(`The OAuth2 client ${client.name} is already associated with an existing application (${other_assoc_app.name}).`)
.api()
}
application.oauth_client_ids = oauth_client_ids
} else application.oauth_client_ids = []
// Verify SAML service provider IDs
const ServiceProvider = this.models.get('saml:ServiceProvider')
if ( req.body.saml_service_provider_ids ) {
const parsed = typeof req.body.saml_service_provider_ids === 'string' ? this.utility.infer(req.body.saml_service_provider_ids) : req.body.saml_service_provider_ids
const saml_service_provider_ids = Array.isArray(parsed) ? parsed : [parsed]
for ( const id of saml_service_provider_ids ) {
const provider = await ServiceProvider.findById(id)
if ( !provider || !provider.active || !req.user.can(`saml:provider:${provider.id}:view`) )
return res.status(400)
.message(`Invalid saml_service_provider_id: ${id}`)
.api()
const other_assoc_app = await Application.findOne({ saml_service_provider_ids: provider.id })
if ( other_assoc_app && other_assoc_app.id !== application.id )
return res.status(400)
.message(`The SAML service provider ${provider.name} is already associated with an existing application (${other_assoc_app.name}).`)
.api()
}
application.saml_service_provider_ids = saml_service_provider_ids
} else application.saml_service_provider_ids = []
application.name = req.body.name
application.identifier = req.body.identifier
application.description = req.body.description
await application.save()
return res.api(await application.to_api())
}
async delete_application(req, res, next) {
const Application = this.models.get('Application')
const application = await Application.findById(req.params.id)
if ( !application || !application.active )
return res.status(404)
.message('Application not found with that ID.')
.api()
if ( !req.user.can(`app:${application.id}:delete`) )
return res.status(401)
.message('Insufficient permissions.')
.api()
application.active = false
await application.save()
return res.api()
}
}
module.exports = exports = AppController

View File

@ -1,8 +1,9 @@
const { Controller } = require('libflitter')
const zxcvbn = require('zxcvbn')
class AuthController extends Controller {
static get services() {
return [...super.services, 'models', 'auth', 'MFA', 'output', 'configs']
return [...super.services, 'models', 'auth', 'MFA', 'output', 'configs', 'utility']
}
async get_users(req, res, next) {
@ -18,6 +19,20 @@ class AuthController extends Controller {
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 = []
@ -32,6 +47,291 @@ class AuthController extends Controller {
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('Group not found with that ID.')
.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('User not found with that ID.')
.api()
if ( !req.user.can(`auth:user:${user.id}:view`) )
return res.status(401)
.message('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('Insufficient permissions.')
.api()
if ( !req.body.name )
return res.status(400)
.message('Missing required 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('A group with that name 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('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('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(`Missing required 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(`A user already exists with that ${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(`Password does not meet the minimum complexity score of ${min_score}.`)
.api()
const user = new User({
uid: req.body.uid,
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
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('Group not found with that ID.')
.api()
if ( !req.user.can(`auth:group:${group.id}:update`) )
return res.status(401)
.message('Insufficient permissions.')
.api()
if ( !req.body.name )
return res.status(400)
.message('Missing required 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('A group with that name 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('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('User not found with that ID.')
.api()
if ( !req.user.can(`auth:user:${user.id}:update`) )
return res.status(401)
.message('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(`Missing required 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(`A user already exists with that ${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(`Password does not meet the minimum complexity score of ${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
user.email = req.body.email
if ( req.body.tagline )
user.tagline = req.body.tagline
else
user.tagline = ''
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('Group not found with that ID.')
.api()
if ( !req.user.can(`auth:group:${group.id}:delete`) )
return res.status(401)
.message('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('User not found with that ID.')
.api()
if ( !req.user.can(`auth:user:${user.id}:delete`) )
return res.status(401)
.message('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
@ -73,6 +373,18 @@ class AuthController extends Controller {
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(`Invalid username or password.`)
.api({
message: `Invalid username or password.`,
success: false,
})
if ( req.body.create_session )
await flitter.session(req, user)

View File

@ -0,0 +1,236 @@
const { Controller } = require('libflitter')
class IAMController extends Controller {
static get services() {
return [...super.services, 'models']
}
async check_entity_access(req, res, next) {
const Policy = this.models.get('iam:Policy')
if ( !req.body.entity_id && !req.body.target_id )
return res.status(400)
.message('Missing one or more required fields: entity_id, target_id')
.api()
return res.api(await Policy.check_entity_access(req.body.entity_id, req.body.target_id))
}
async check_user_access(req, res, next) {
const User = this.models.get('auth:User')
const Policy = this.models.get('iam:Policy')
if ( !req.body.target_id )
return res.status(400)
.message('Missing required field: target_id')
.api()
let user = req.user
if ( req.body.user_id && req.body.user_id !== 'me' )
user = await User.findById(req.body.user_id)
if ( !user )
return res.status(404)
.message('User not found with that ID.')
.api()
if ( !req.user.can(`auth:user:${user.id}:view`) )
return res.status(401)
.message('Insufficient permissions.')
.api()
return res.api(await Policy.check_user_access(user, req.body.target_id))
}
async get_policies(req, res, next) {
const Policy = this.models.get('iam:Policy')
const policies = await Policy.find({ active: true })
const data = []
for ( const policy of policies ) {
if ( req.user.can(`iam:policy:${policy.id}:view`) ) {
data.push(await policy.to_api())
}
}
return res.api(data)
}
async get_policy(req, res, next) {
const Policy = this.models.get('iam:Policy')
const policy = await Policy.findById(req.params.id)
if ( !policy )
return res.status(404)
.message('Policy not found with that ID.')
.api()
if ( !req.user.can(`iam:policy:${policy.id}:view`) )
return res.status(401)
.message('Insufficient permissions.')
.api()
return res.api(await policy.to_api())
}
async create_policy(req, res, next) {
const Policy = this.models.get('iam:Policy')
const required_fields = ['entity_type', 'entity_id', 'access_type', 'target_type', 'target_id']
for ( const field of required_fields ) {
if ( !req.body[field] )
return res.status(400)
.message(`Missing required field: ${field}`)
.api()
}
if ( !['user', 'group'].includes(req.body.entity_type) )
return res.status(400)
.message('Invalid entity_type. Must be one of: user, group.')
.api()
// Make sure the entity_id is valid
if ( req.body.entity_type === 'user' ) {
const User = this.models.get('auth:User')
const user = await User.findById(req.body.entity_id)
if ( !user || !req.user.can(`auth:user:${user.id}:view`) )
return res.status(400)
.message('Invalid entity_id.')
.api()
} else if ( req.body.entity_type === 'group' ) {
const Group = this.models.get('auth:Group')
const group = await Group.findById(req.body.entity_id)
if ( !group || !group.active || !req.user.can(`auth:group:${group.id}:view`) )
return res.status(400)
.message('Invalid entity_id.')
.api()
}
if ( !['allow', 'deny'].includes(req.body.access_type) )
return res.status(400)
.message('Invalid access_type. Must be one of: allow, deny.')
.api()
if ( !['application'].includes(req.body.target_type) )
return res.status(400)
.message('Invalid target_type. Must be one of: application.')
.api()
// Make sure the target_id is valid
if ( req.body.target_type === 'application' ) {
const Application = this.models.get('Application')
const app = await Application.findById(req.body.target_id)
if ( !app || !app.active || !req.user.can(`app:${app.id}:view`) )
return res.status(400)
.message('Invalid target_id.')
.api()
}
const policy = new Policy({
entity_type: req.body.entity_type,
entity_id: req.body.entity_id,
access_type: req.body.access_type,
target_type: req.body.target_type,
target_id: req.body.target_id,
})
await policy.save()
req.user.allow(`iam:policy:${policy.id}`)
await req.user.save()
return res.api(await policy.to_api())
}
async update_policy(req, res, next) {
const Policy = this.models.get('iam:Policy')
const policy = await Policy.findById(req.params.id)
if ( !policy || !policy.active )
return res.status(404)
.message('Policy not found with that ID.')
.api()
if ( !req.user.can(`iam:policy:${policy.id}:update`) )
return res.status(401)
.message('Insufficient permissions.')
.api()
const required_fields = ['entity_type', 'entity_id', 'access_type', 'target_type', 'target_id']
for ( const field of required_fields ) {
if ( !req.body[field] )
return res.status(400)
.message(`Missing required field: ${field}`)
.api()
}
if ( !['user', 'group'].includes(req.body.entity_type) )
return res.status(400)
.message('Invalid entity_type. Must be one of: user, group.')
.api()
// Make sure the entity_id is valid
if ( req.body.entity_type === 'user' ) {
const User = this.models.get('auth:User')
const user = await User.findById(req.body.entity_id)
if ( !user || !req.user.can(`auth:user:${user.id}:view`) )
return res.status(400)
.message('Invalid entity_id.')
.api()
} else if ( req.body.entity_type === 'group' ) {
const Group = this.models.get('auth:Group')
const group = await Group.findById(req.body.entity_id)
if ( !group || !group.active || !req.user.can(`auth:group:${group.id}:view`) )
return res.status(400)
.message('Invalid entity_id.')
.api()
}
if ( !['allow', 'deny'].includes(req.body.access_type) )
return res.status(400)
.message('Invalid access_type. Must be one of: allow, deny.')
.api()
if ( !['application'].includes(req.body.target_type) )
return res.status(400)
.message('Invalid target_type. Must be one of: application.')
.api()
// Make sure the target_id is valid
if ( req.body.target_type === 'application' ) {
const Application = this.models.get('Application')
const app = await Application.findById(req.body.target_id)
if ( !app || !app.active || !req.user.can(`app:${app.id}:view`) )
return res.status(400)
.message('Invalid target_id.')
.api()
}
policy.entity_type = req.body.entity_type
policy.entity_id = req.body.entity_id
policy.access_type = req.body.access_type
policy.target_type = req.body.target_type
policy.target_id = req.body.target_id
await policy.save()
return res.api()
}
async delete_policy(req, res, next) {
const Policy = this.models.get('iam:Policy')
const policy = await Policy.findById(req.params.id)
if ( !policy || !policy.active )
return res.status(404)
.message('Policy not found with that ID.')
.api()
if ( !req.user.can(`iam:policy:${policy.id}:delete`) )
return res.status(401)
.message('Insufficient permissions.')
.api()
policy.active = false
await policy.save()
return res.api()
}
}
module.exports = exports = IAMController

View File

@ -0,0 +1,136 @@
const { Controller } = require('libflitter')
const is_absolute_url = require('is-absolute-url')
class OAuthController extends Controller {
static get services() {
return [...super.services, 'models']
}
async get_clients(req, res, next) {
const Client = this.models.get('oauth:Client')
const clients = await Client.find({ active: true })
const data = []
for ( const client of clients ) {
if ( req.user.can(`oauth:client:${client.id}:view`) ) {
data.push(await client.to_api())
}
}
return res.api(data)
}
async get_client(req, res, next) {
const Client = this.models.get('oauth:Client')
const client = await Client.findById(req.params.id)
if ( !client || !client.active )
return res.status(404)
.message('Client not found with that ID.')
.api()
if ( !req.user.can(`oauth:client:${client.id}:view`) )
return res.status(401)
.message('Insufficient permissions.')
.api()
return res.api(await client.to_api())
}
async create_client(req, res, next) {
if ( !req.user.can('oauth:client:create') )
return res.status(401)
.message('Insufficient permissions.')
.api()
const required_fields = ['name', 'api_scopes', 'redirect_url']
for ( const field of required_fields ) {
if ( !req.body[field] )
return res.status(400)
.message(`Missing required field: ${field}`)
.api()
}
if ( !Array.isArray(req.body.api_scopes) ) {
return res.status(400)
.message(`Improperly formatted field: api_scopes (should be array)`)
.api()
}
if ( !is_absolute_url(req.body.redirect_url) )
return res.status(400)
.message(`Improperly formatted field: redirect_url (should be absolute URL)`)
.api()
const Client = this.models.get('oauth:Client')
const client = new Client({
name: req.body.name,
api_scopes: req.body.api_scopes,
redirect_url: req.body.redirect_url,
})
await client.save()
return res.api(await client.to_api())
}
async update_client(req, res, next) {
const Client = this.models.get('oauth:Client')
const client = await Client.findById(req.params.id)
if ( !client || !client.active )
return res.status(404)
.message('Client not found with that ID.')
.api()
if ( !req.user.can(`oauth:client:${client.id}:update`) )
return res.status(401)
.message('Insufficient permissions.')
.api()
const required_fields = ['name', 'api_scopes', 'redirect_url']
for ( const field of required_fields ) {
if ( !req.body[field] )
return res.status(400)
.message(`Missing required field: ${field}`)
.api()
}
if ( !Array.isArray(req.body.api_scopes) )
return res.status(400)
.message(`Improperly formatted field: api_scopes (should be array)`)
.api()
if ( !is_absolute_url(req.body.redirect_url) )
return res.status(400)
.message(`Improperly formatted field: redirect_url (should be absolute URL)`)
.api()
client.name = req.body.name
client.api_scopes = req.body.api_scopes
client.redirect_url = req.body.redirect_url
await client.save()
return res.api()
}
async delete_client(req, res, next) {
const Client = this.models.get('oauth:Client')
const client = await Client.findById(req.params.id)
if ( !client || !client.active )
return res.status(404)
.message('Client not found with that ID.')
.api()
if ( !req.user.can(`oauth:client:${client.id}:delete`) )
return res.status(401)
.message('Insufficient permissions.')
.api()
client.active = false
await client.save()
return res.api()
}
}
module.exports = exports = OAuthController

View File

@ -0,0 +1,184 @@
const { Controller } = require('libflitter')
const uuid = require('uuid/v4')
class ReflectController extends Controller {
static get services() {
return [...super.services, 'routers', 'models']
}
async get_tokens(req, res, next) {
const Oauth2BearerToken = this.models.get('auth::Oauth2BearerToken')
const tokens = await Oauth2BearerToken.find({
expires: { $gt: new Date },
userID: req.user.id,
})
const Client = this.models.get('oauth:Client')
const data = []
for ( const token of tokens ) {
const client = await Client.findOne({ uuid: token.clientID })
let client_display = client && client.active ? client.name : '(Non-existent Client)'
data.push({
id: token.id,
token: token.accessToken,
client_id: token.clientID,
client_display,
expires: token.expires,
user_id: token.userID,
})
}
return res.api(data)
}
async get_token(req, res, next) {
const Oauth2BearerToken = this.models.get('auth::Oauth2BearerToken')
const token = await Oauth2BearerToken.findById(req.params.id)
if ( !token || token.userID !== req.user.id || token.expires <= new Date )
return res.status(404)
.message('Token not found with that ID, or expired.')
.api()
return res.api({
id: token.id,
token: token.accessToken,
client_id: token.clientID,
expires: token.expires,
user_id: token.userID,
})
}
async create_token(req, res, next) {
const Oauth2BearerToken = this.models.get('auth::Oauth2BearerToken')
if ( !req.body.client_id )
return res.status(400)
.message('Missing required field: client_id')
.api()
const Client = this.models.get('oauth:Client')
const client = await Client.findOne({uuid: req.body.client_id})
if ( !client || !client.active )
return res.status(400)
.message('Invalid client_id.')
.api()
if ( !req.user.can(`oauth:client:${client.id}:view`) )
return res.status(401)
.message('Insufficient permissions.')
.api()
const expires = new Date()
expires.setDate(expires.getDate() + 7)
const token = new Oauth2BearerToken({
accessToken: String(uuid()).replace(/-/g, ''),
clientID: client.uuid,
expires,
userID: req.user.id,
})
await token.save()
return res.api({
id: token.id,
token: token.accessToken,
client_id: token.clientID,
expires: token.expires,
user_id: token.userID,
})
}
async update_token(req, res, next) {
const Oauth2BearerToken = this.models.get('auth::Oauth2BearerToken')
const token = await Oauth2BearerToken.findById(req.params.id)
if ( !token || token.userID !== req.user.id || token.expires <= new Date )
return res.status(404)
.message('Token not found with that ID, or expired.')
.api()
if ( !req.body.client_id )
return res.status(400)
.message('Missing required field: client_id')
.api()
const Client = this.models.get('oauth:Client')
const client = await Client.findOne({uuid: req.body.client_id})
if ( !client || !client.active || !req.user.can(`oauth:client:${client.id}:view`) )
return res.status(400)
.message('Invalid client_id.')
.api()
token.client_id = client.uuid
await token.save()
return res.api()
}
async delete_token(req, res, next) {
const Oauth2BearerToken = this.models.get('auth::Oauth2BearerToken')
const token = await Oauth2BearerToken.findById(req.params.id)
if ( !token || token.userID !== req.user.id || token.expires <= new Date )
return res.status(404)
.message('Token not found with that ID, or expired.')
.api()
await token.delete()
return res.api()
}
async get_scopes(req, res, next) {
const routers = this.routers.canonical_items
const scopes = []
for ( const prefix in routers ) {
if ( !routers.hasOwnProperty(prefix) ) continue
const router = routers[prefix].schema
const supported_verbs = ['get', 'post', 'put', 'delete', 'copy', 'patch']
for ( const verb of supported_verbs ) {
if ( typeof router[verb] === 'object' ) {
const defs = router[verb]
for ( const def of Object.values(defs) ) {
if ( Array.isArray(def) ) {
for ( const layer of def ) {
if ( Array.isArray(layer) && layer.length > 1 && layer[0] === 'middleware::api:Permission' ) {
if ( typeof layer[1] === 'object' && layer[1].check ) {
scopes.push(layer[1].check)
}
}
}
}
}
}
}
}
scopes.sort()
return res.api(scopes.map(x => {
return { scope: x }
}))
}
async check_permissions(req, res, next) {
if ( !req.body.permissions )
return res.status(400)
.message('Missing permissions to check.')
.api()
const parsed = typeof req.body.permissions === 'string' ? this.utility.infer(req.body.permissions) : req.body.permissions
const permissions = Array.isArray(parsed) ? parsed : [parsed]
const returns = {}
for ( const permission of permissions ) {
returns[permission] = req.user.can(permission)
}
return res.api(returns)
}
}
module.exports = exports = ReflectController

View File

@ -4,7 +4,7 @@ const samlp = require('samlp')
class SAMLController extends Controller {
static get services() {
return [...super.services, 'saml', 'output', 'Vue', 'configs']
return [...super.services, 'saml', 'output', 'Vue', 'configs', 'models']
}
async get_metadata(req, res, next) {
@ -20,10 +20,24 @@ class SAMLController extends Controller {
}
// 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 })
// Apply the appropriate IAM policy if this SAML SP is associated with an App
// If the SAML service provider has no associated application, just allow it
// TODO test this
const associated_app = await req.saml_request.service_provider.application()
if ( associated_app ) {
const Policy = this.models.get('iam:Policy')
const can_access = await Policy.check_user_access(req.user, associated_app.id)
if ( !can_access ) {
return this.Vue.auth_message(res, {
message: `Sorry, you don't have permission to access this application. Please ask your administrator to grant you access to ${associated_app.name}.`,
next_destination: '/dash',
})
}
}
return samlp.auth({
issuer: this.saml.config().provider_name,
cert: await this.saml.public_cert(),

View File

@ -105,7 +105,6 @@ class GroupsController extends LDAPController {
async get_resource_from_dn(dn) {
const cn = this.get_cn_from_dn(dn)
console.log({cn, dn})
if ( cn ) {
return this.Group.findOne({name: cn, ldap_visible: true})
}

View File

@ -0,0 +1,29 @@
const { Model } = require('flitter-orm')
class ApplicationModel extends Model {
static get schema() {
return {
name: String,
identifier: String,
description: String,
active: { type: Boolean, default: true },
saml_service_provider_ids: [String],
ldap_client_ids: [String],
oauth_client_ids: [String],
}
}
async to_api() {
return {
id: this.id,
name: this.name,
identifier: this.identifier,
description: this.description,
saml_service_provider_ids: this.saml_service_provider_ids,
ldap_client_ids: this.ldap_client_ids,
oauth_client_ids: this.oauth_client_ids,
}
}
}
module.exports = exports = ApplicationModel

View File

@ -0,0 +1,35 @@
const { Model } = require('flitter-orm')
// For organizational purposes only.
class GroupModel extends Model {
static get services() {
return [...super.services, 'models']
}
static get schema() {
return {
name: String,
user_ids: [String],
active: { type: Boolean, default: true },
}
}
identifier() {
return this.name.toLowerCase().replace(/\s/g, '_')
}
async users() {
const User = this.models.get('auth:User')
return await User.find({ _id: { $in: this.user_ids.map(x => this.constructor.to_object_id(x)) } })
}
async to_api() {
return {
id: this.id,
name: this.name,
user_ids: this.user_ids,
}
}
}
module.exports = exports = GroupModel

View File

@ -31,6 +31,7 @@ class User extends AuthUser {
app_passwords: [AppPassword],
mfa_enabled: {type: Boolean, default: false},
mfa_enable_date: Date,
create_date: {type: Date, default: () => new Date},
}}
}
@ -42,6 +43,7 @@ class User extends AuthUser {
last_name: this.last_name,
email: this.email,
tagline: this.tagline,
group_ids: (await this.groups()).map(x => x.id),
}
}
@ -112,6 +114,11 @@ class User extends AuthUser {
return { password: gen, record: pw }
}
async groups() {
const Group = this.models.get('auth:Group')
return Group.find({ active: true, user_ids: this.id })
}
async ldap_groups() {
const Group = this.models.get('ldap:Group')
return await Group.find({

View File

@ -0,0 +1,127 @@
const { Model } = require('flitter-orm')
// TODO - remove specific :create checks; auto-grant permissions on create
class PolicyModel extends Model {
static get services() {
return [...super.services, 'models']
}
static get schema() {
return {
entity_type: String, // user | group
entity_id: String,
access_type: String, // allow | deny
target_type: { type: String, default: 'application' }, // application
target_id: String,
active: { type: Boolean, default: true },
}
}
static async check_allow(entity_id, target_id) {
const policies = await this.find({
entity_id,
target_id,
access_type: 'allow',
active: true,
})
return policies.length > 0
}
static async check_deny(entity_id, target_id) {
const policies = await this.find({
entity_id,
target_id,
access_type: 'deny',
active: true,
})
return policies.length === 0
}
static async check_entity_access(entity_id, target_id) {
return (await this.check_allow(entity_id, target_id)) && !(await this.check_deny(entity_id, target_id))
}
static async check_user_access(user, target_id) {
const groups = await user.groups()
const group_ids = groups.map(x => x.id)
const user_approvals = await this.find({
entity_id: user.id,
target_id,
approval_type: 'allow',
active: true,
})
const user_denials = await this.find({
entity_id: user.id,
target_id,
approval_type: 'deny',
active: true,
})
const group_approvals = await this.find({
entity_id: { $in: group_ids },
target_id,
approval_type: 'allow',
active: true,
})
const group_denials = await this.find({
entity_id: { $in: group_ids },
target_id,
approval_type: 'deny',
active: true,
})
// IF user has explicit denial, deny
if ( user_denials.length > 0 ) return false
// ELSE IF user has explicit approval, approve
if ( user_approvals.length > 0 ) return true
// ELSE IF group has denial, deny
if ( group_denials.length > 0 ) return false
// ELSE IF group has approval, approve
if ( group_approvals.length > 0 ) return true
// ELSE deny
return false
}
async to_api() {
let entity_display = ''
if ( this.entity_type === 'user' ) {
const User = this.models.get('auth:User')
const user = await User.findById(this.entity_id)
entity_display = `User: ${user.last_name}, ${user.first_name} (${user.uid})`
} else if ( this.entity_type === 'group' ) {
const Group = this.models.get('auth:Group')
const group = await Group.findById(this.entity_id)
entity_display = `Group: ${group.name} (${group.user_ids.length} users)`
}
let target_display = ''
if ( this.target_type === 'application' ) {
const Application = this.models.get('Application')
const app = await Application.findById(this.target_id)
target_display = `Application: ${app.name}`
}
return {
id: this.id,
entity_display,
entity_type: this.entity_type,
entity_id: this.entity_id,
access_type: this.access_type,
target_display,
target_type: this.target_type,
target_id: this.target_id,
}
}
}
module.exports = exports = PolicyModel

View File

@ -35,15 +35,23 @@ class ClientModel extends Model {
return client
}
async invoke() {
this.last_invocation = new Date
}
async user() {
const User = this.models.get('auth:User')
return User.findById(this.user_id)
}
async application() {
const Application = this.models.get('Application')
return Application.findOne({ active: true, ldap_client_ids: this.id })
}
async to_api() {
const User = this.models.get('auth:User')
const user = await User.findById(this.user_id)
const role_permissions = user.roles.map(x => this.configs.get('auth.roles')[x])
return {

View File

@ -0,0 +1,95 @@
const { Model } = require('flitter-orm')
const uuid = require('uuid/v4')
/*
* OAuth2 Client Model
* ---------------------------------------------------
* Represents a single OAuth2 client. This class contains logic
* to create/update/delete the associated Flitter-Auth Oauth2Client
* instance.
*/
class ClientModel extends Model {
static get services() {
return [...super.services, 'models']
}
static get schema() {
return {
name: String,
uuid: {type: String, default: uuid},
secret: {type: String, default: uuid},
active: {type: Boolean, default: true},
api_scopes: [String],
redirect_url: String,
}
}
can(scope) {
return this.api_scopes.includes()
}
async application() {
const Application = this.models.get('Application')
return Application.findOne({ active: true, oauth_client_ids: this.id })
}
async update_auth_client() {
const Oauth2Client = this.models.get('auth::Oauth2Client')
let client = await Oauth2Client.findOne({ clientID: this.uuid })
// There's an associated client, but we're not active, so delete the assoc
if ( client && !this.active ) {
await client.delete()
return
}
if ( !client ) {
client = new Oauth2Client({
grants: ['authorization_code'],
})
}
client.clientID = this.uuid
client.clientSecret = this.secret
client.name = this.name
client.redirectUris = [this.redirect_url]
await client.save()
}
async save() {
await super.save()
// Save the associated flitter-auth-compatible client.
await this.update_auth_client()
}
async to_api() {
return {
id: this.id,
name: this.name,
uuid: this.uuid,
secret: this.secret,
api_scopes: this.api_scopes,
redirect_url: this.redirect_url,
}
}
// See flitter-auth/User
_array_allow_permission(array_of_permissions, permission) {
const permission_parts = permission.split(':')
for ( let i = permission_parts.length; i > 0; i-- ) {
const permission_string = permission_parts.slice(0, i).join(':')
if ( array_of_permissions.includes(permission_string) ) return true
}
return false
}
// See flitter-auth/User
can(scope){
return this._array_allow_permission(this.api_scopes, scope)
}
}
module.exports = exports = ClientModel

View File

@ -1,6 +1,10 @@
const { Model } = require('flitter-orm')
class ServiceProviderModel extends Model {
static get services() {
return [...super.services, 'models']
}
static get schema() {
return {
name: String,
@ -11,6 +15,11 @@ class ServiceProviderModel extends Model {
}
}
async application() {
const Application = this.models.get('Application')
return Application.findOne({ active: true, saml_service_provider_ids: this.id })
}
to_api() {
return {
id: this.id,

View File

@ -2,6 +2,16 @@ const { Middleware } = require('libflitter')
class PermissionMiddleware extends Middleware {
async test(req, res, next, { check }) {
// If the request was authorized using an OAuth2 bearer token,
// make sure the associated client has permission to access this endpoint.
if ( req?.oauth?.client ) {
if ( !req.oauth.client.can(check) )
return res.status(401)
.message('Insufficient permissions (OAuth2 Client).')
.api()
}
// Make sure the user has permission
if ( !req.user.can(check) )
return res.status(401)
.message('Insufficient permissions.')

View File

@ -0,0 +1,60 @@
const { Middleware } = require('libflitter')
class APIRouteMiddleware extends Middleware {
static get services() {
return [...super.services, 'models']
}
async test(req, res, next, { allow_token = true, allow_user = true }) {
// First, check if there is a user in the session.
if ( allow_user && req.is_auth ) {
return next()
} else if ( allow_token ) {
return req.app.oauth2.authorise()(req, res, async e => {
if ( e ) return next(e)
// Look up the OAuth2 client an inject it into the route
if ( req.user && req.user.id ) {
const User = this.models.get('auth:User')
const user = await User.findById(req.user.id)
if ( !user )
return res.status(401)
.message('The user this token is associated with no longer exists.')
.api()
req.user = user
req.is_auth = true
// Look up the token and the associated client
const Oauth2BearerToken = this.models.get('auth::Oauth2BearerToken')
const Client = this.models.get('oauth:Client')
// e.g. "Bearer XYZ".split(' ')[1] -> "XYZ"
const bearer = req.headers.authorization.split(' ')[1]
const token = await Oauth2BearerToken.findOne({ accessToken: bearer })
if ( !token )
return res.status(401)
.message('Unable to lookup OAuth2 token.')
.api()
const client = await Client.findOne({uuid: token.clientID})
if ( !client )
return res.status(401)
.message('This OAuth2 client is no longer authorized.')
.api()
req.oauth.token = token
req.oauth.client = client
} else
return res.status(401)
.message('Unable to lookup user associated with that token.')
.api()
next()
})
}
return res.status(401).api()
}
}
module.exports = exports = APIRouteMiddleware

View File

@ -0,0 +1,41 @@
const app_routes = {
prefix: '/api/v1/applications',
middleware: [
'auth:APIRoute',
],
get: {
'/': [
['middleware::api:Permission', { check: 'v1:applications:list' }],
'controller::api:v1:App.get_applications',
],
'/:id': [
['middleware::api:Permission', { check: 'v1:applications:get' }],
'controller::api:v1:App.get_application',
],
},
post: {
'/': [
['middleware::api:Permission', { check: 'v1:applications:create' }],
'controller::api:v1:App.create_application',
],
},
patch: {
'/:id': [
['middleware::api:Permission', { check: 'v1:applications:update' }],
'controller::api:v1:App.update_application',
],
},
delete: {
'/:id': [
['middleware::api:Permission', { check: 'v1:applications:delete' }],
'controller::api:v1:App.delete_application',
],
},
}
module.exports = exports = app_routes

View File

@ -9,30 +9,100 @@ const auth_routes = {
'/mfa/enable/date': ['middleware::auth:UserOnly', 'controller::api:v1:Auth.get_mfa_enable_date'],
'/roles': [
'middleware::auth:APIRoute',
['middleware::api:Permission', { check: 'v1:auth:roles:list' }],
'controller::api:v1:Auth.get_roles',
],
'/users': [
'middleware::auth:APIRoute',
['middleware::api:Permission', { check: 'v1:auth:users:list' }],
'controller::api:v1:Auth.get_users',
],
'/groups': [
'middleware::auth:APIRoute',
['middleware::api:Permission', { check: 'v1:auth:groups:list' }],
'controller::api:v1:Auth.get_groups',
],
'/users/:id': [
'middleware::auth:APIRoute',
['middleware::api:Permission', { check: 'v1:auth:users:get' }],
'controller::api:v1:Auth.get_user',
],
'/groups/:id': [
'middleware::auth:APIRoute',
['middleware::api:Permission', { check: 'v1:auth:groups:get' }],
'controller::api:v1:Auth.get_group',
],
},
post: {
'/validate/username': ['controller::api:v1:Auth.validate_username'],
'/attempt': [ 'controller::api:v1:Auth.attempt' ],
'/mfa/generate': ['middleware::auth:UserOnly', 'controller::api:v1:Auth.generate_mfa_key'],
'/mfa/attempt': ['middleware::auth:DMZOnly', 'controller::api:v1:Auth.attempt_mfa'],
'/validate/username': [
'controller::api:v1:Auth.validate_username'
],
'/attempt': [
'controller::api:v1:Auth.attempt'
],
'/mfa/generate': [
'middleware::auth:UserOnly',
'controller::api:v1:Auth.generate_mfa_key'
],
'/mfa/attempt': [
'middleware::auth:DMZOnly',
'controller::api:v1:Auth.attempt_mfa'
],
'/mfa/enable': [
'middleware::auth:UserOnly',
['middleware::auth:RequireTrust', { scope: 'mfa.enable', deplete: true }],
'controller::api:v1:Auth.enable_mfa'
],
'/mfa/disable': [
'middleware::auth:UserOnly',
['middleware::auth:RequireTrust', { scope: 'mfa.disable', deplete: true }],
'controller::api:v1:Auth.disable_mfa',
],
'/groups': [
'middleware::auth:APIRoute',
['middleware::api:Permission', { check: 'v1:auth:groups:create' }],
'controller::api:v1:Auth.create_group',
],
'/users': [
'middleware::auth:APIRoute',
['middleware::api:Permission', { check: 'v1:auth:users:create' }],
'controller::api:v1:Auth.create_user',
],
},
patch: {
'/groups/:id': [
'middleware::auth:APIRoute',
['middleware::api:Permission', { check: 'v1:auth:groups:update' }],
'controller::api:v1:Auth.update_group',
],
'/users/:id': [
'middleware::auth:APIRoute',
['middleware::api:Permission', { check: 'v1:auth:users:update' }],
'controller::api:v1:Auth.update_user',
],
},
delete: {
'/groups/:id': [
'middleware::auth:APIRoute',
['middleware::api:Permission', { check: 'v1:auth:groups:delete' }],
'controller::api:v1:Auth.delete_group',
],
'/users/:id': [
'middleware::auth:APIRoute',
['middleware::api:Permission', { check: 'v1:auth:users:delete' }],
'controller::api:v1:Auth.delete_user',
],
},
}

View File

@ -0,0 +1,49 @@
const iam_routes = {
prefix: '/api/v1/iam',
middleware: [
'auth:APIRoute'
],
get: {
'/policy': [
['middleware::api:Permission', { check: 'v1:iam:policy:list' }],
'controller::api:v1:IAM.get_policies',
],
'/policy/:id': [
['middleware::api:Permission', { check: 'v1:iam:policy:get' }],
'controller::api:v1:IAM.get_policy',
],
},
post: {
'/policy': [
['middleware::api:Permission', { check: 'v1:iam:policy:create' }],
'controller::api:v1:IAM.create_policy',
],
'/check_entity_access': [
['middleware::api:Permission', { check: 'v1:iam:check_entity_access' }],
'controller::api:v1:IAM.check_entity_access',
],
'/check_user_access': [
['middleware::api:Permission', { check: 'v1:iam:check_user_access' }],
'controller::api:v1:IAM.check_user_access',
],
},
patch: {
'/policy/:id': [
['middleware::api:Permission', { check: 'v1:iam:policy:update' }],
'controller::api:v1:IAM.update_policy',
],
},
delete: {
'/policy/:id': [
['middleware::api:Permission', { check: 'v1:iam:policy:delete' }],
'controller::api:v1:IAM.delete_policy',
],
},
}
module.exports = exports = iam_routes

View File

@ -2,7 +2,7 @@ const ldap_routes = {
prefix: '/api/v1/ldap',
middleware: [
'auth:UserOnly',
'auth:APIRoute',
],
get: {

View File

@ -2,15 +2,21 @@ const message_routes = {
prefix: '/api/v1/message',
middleware: [
'auth:UserOnly',
'auth:APIRoute',
],
get: {
'/banners': ['controller::api:v1:Message.get_banners'],
'/banners': [
['middleware::api:Permission', { check: 'v1:message:banners:get' }],
'controller::api:v1:Message.get_banners',
],
},
post: {
'/banners/read/:banner_id': ['controller::api:v1:Message.read_banner'],
'/banners/read/:banner_id': [
['middleware::api:Permission', { check: 'v1:message:banners:update' }],
'controller::api:v1:Message.read_banner',
],
},
}

View File

@ -0,0 +1,41 @@
const oauth_routes = {
prefix: '/api/v1/oauth',
middleware: [
'auth:APIRoute',
],
get: {
'/clients': [
['middleware::api:Permission', { check: 'v1:oauth:clients:list' }],
'controller::api:v1:OAuth.get_clients',
],
'/clients/:id': [
['middleware::api:Permission', { check: 'v1:oauth:clients:get' }],
'controller::api:v1:OAuth.get_client',
],
},
post: {
'/clients': [
['middleware::api:Permission', { check: 'v1:oauth:clients:create' }],
'controller::api:v1:OAuth.create_client',
],
},
patch: {
'/clients/:id': [
['middleware::api:Permission', { check: 'v1:oauth:clients:update' }],
'controller::api:v1:OAuth.update_client',
],
},
delete: {
'/clients/:id': [
['middleware::api:Permission', { check: 'v1:oauth:clients:delete' }],
'controller::api:v1:OAuth.delete_client',
],
},
}
module.exports = exports = oauth_routes

View File

@ -2,16 +2,25 @@ const password_routes = {
prefix: '/api/v1/password',
middleware: [
'auth:UserOnly',
'auth:APIRoute',
],
get: {
'/resets': ['controller::api:v1:Password.get_resets'],
'/app_passwords': ['controller::api:v1:Password.get_app_passwords'],
'/resets': [
['middleware::api:Permission', { check: 'v1:password:resets:get' }],
'controller::api:v1:Password.get_resets',
],
'/app_passwords': [
['middleware::api:Permission', { check: 'v1:password:app_passwords:get' }],
'controller::api:v1:Password.get_app_passwords',
],
},
post: {
'/app_passwords': ['controller::api:v1:Password.create_app_password'],
'/app_passwords': [
['middleware::api:Permission', { check: 'v1:password:app_passwords:create' }],
'controller::api:v1:Password.create_app_password',
],
'/resets': [
['middleware::auth:RequireTrust', { scope: 'password.reset' }],
'controller::api:v1:Password.reset_password',
@ -19,7 +28,10 @@ const password_routes = {
},
delete: {
'/app_passwords/:uuid': ['controller::api:v1:Password.delete_app_password'],
'/app_passwords/:uuid': [
['middleware::api:Permission', { check: 'v1:password:app_passwords:delete' }],
'controller::api:v1:Password.delete_app_password',
],
}
}

View File

@ -2,17 +2,19 @@ const profile_routes = {
prefix: '/api/v1/profile',
middleware: [
'auth:UserOnly',
'auth:APIRoute',
],
get: {
'/:user_id': [ // user_id | 'me'
['middleware::api:Permission', { check: 'v1:profile:get' }],
'controller::api:v1:Profile.fetch',
],
},
patch: {
'/:user_id': [ // user_id | 'me'
['middleware::api:Permission', { check: 'v1:profile:update' }],
'controller::api:v1:Profile.update',
],
},

View File

@ -0,0 +1,50 @@
const reflect_routes = {
prefix: '/api/v1/reflect',
middleware: [
'auth:APIRoute'
],
get: {
'/scopes': [
['middleware::api:Permission', { check: 'v1:reflect:scopes' }],
'controller::api:v1:Reflect.get_scopes',
],
'/tokens': [
['middleware::api:Permission', { check: 'v1:reflect:tokens:list' }],
'controller::api:v1:Reflect.get_tokens',
],
'/tokens/:id': [
['middleware::api:Permission', { check: 'v1:reflect:tokens:get' }],
'controller::api:v1:Reflect.get_token',
],
},
post: {
'/tokens': [
['middleware::api:Permission', { check: 'v1:reflect:tokens:create'}],
'controller::api:v1:Reflect.create_token',
],
'/check_permissions': [
['middleware::api:Permission', { check: 'v1:reflect:check_permissions' }],
'controller::api:v1:Reflect.check_permissions',
],
},
patch: {
'/tokens/:id': [
['middleware::api:Permission', { check: 'v1:reflect:tokens:update' }],
'controller::api:v1:Reflect.update_token',
],
},
delete: {
'/tokens/:id': [
['middleware::api:Permission', { check: 'v1:reflect:tokens:delete' }],
'controller::api:v1:Reflect.delete_token',
],
},
}
module.exports = exports = reflect_routes

View File

@ -2,7 +2,7 @@ const saml_routes = {
prefix: '/api/v1/saml',
middleware: [
'auth:UserOnly',
'auth:APIRoute',
],
get: {

View File

@ -105,10 +105,12 @@ class LDAPServerUnit extends Unit {
async port_free() {
return new Promise((res, rej) => {
const server = net.createServer()
server.once('error', rej)
server.once('error', (e) => {
res(false)
})
server.once('listening', () => {
server.close()
res()
res(true)
})
server.listen(this.config.port)
})

View File

@ -10,7 +10,7 @@ const auth_config = {
servers: {
// OAuth2 authorization server
oauth2: {
enable: env('OAUTH2_SERVER_ENABLE', false),
enable: env('OAUTH2_SERVER_ENABLE', true),
// Grants that are available to clients. Supported types are authorization_code, password
grants: ['authorization_code'],
@ -20,7 +20,8 @@ const auth_config = {
// Get the token user's data
user: {
enable: env('OAUTH2_SERVER_ENABLE', true),
// enable: env('OAUTH2_SERVER_ENABLE', false),
enable: false,
// Fields to return to the endpoint
// The keys are the keys in the request. The values are the keys in the user.
@ -177,6 +178,20 @@ const auth_config = {
coreid_base: ['my:profile'],
saml_admin: ['v1:saml', 'saml'],
base_user: [
// Message Service
'v1:message:banners',
// Permission Checks
'v1:reflect:check_permissions',
// Profile
'v1:profile',
// Password API
'v1:password',
],
},
}

View File

@ -17,13 +17,14 @@
"license": "MIT",
"dependencies": {
"email-validator": "^2.0.4",
"flitter-auth": "^0.18.2",
"flitter-auth": "^0.19.0",
"flitter-cli": "^0.16.0",
"flitter-di": "^0.5.0",
"flitter-flap": "^0.5.2",
"flitter-forms": "^0.8.1",
"flitter-less": "^0.5.3",
"flitter-upload": "^0.8.0",
"is-absolute-url": "^3.0.3",
"ldapjs": "^1.0.2",
"libflitter": "^0.51.0",
"moment": "^2.24.0",

View File

@ -1779,10 +1779,10 @@ flat@^4.1.0:
dependencies:
is-buffer "~2.0.3"
flitter-auth@^0.18.2:
version "0.18.2"
resolved "https://registry.yarnpkg.com/flitter-auth/-/flitter-auth-0.18.2.tgz#25f6d3311168f85989ff78ebacdfe7a6ace13c3f"
integrity sha512-kJGHf0zOo8ICerVt8jgyDiaDrJ+Ob3KVh9wpwpDo6aI37U26bTYfSUrJdU6ge0rLwnTTbInXVpndEjd465bQAw==
flitter-auth@^0.19.0:
version "0.19.0"
resolved "https://registry.yarnpkg.com/flitter-auth/-/flitter-auth-0.19.0.tgz#fa6d0b44c8edfed45c1f0d7de65b9acb8d444c19"
integrity sha512-WoNkIGG981Zy3L0qqvml0rpxwNyfVAfAXjvZE6i6XnDJeLdsqHxCAVPllJlOhfJmuFPCr2TGXPl8WhAQaoG6Bw==
dependencies:
axios "^0.19.0"
bcrypt "^3.0.4"
@ -2299,6 +2299,11 @@ ipaddr.js@1.8.0:
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.8.0.tgz#eaa33d6ddd7ace8f7f6fe0c9ca0440e706738b1e"
integrity sha1-6qM9bd16zo9/b+DJygRA5wZzix4=
is-absolute-url@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-3.0.3.tgz#96c6a22b6a23929b11ea0afb1836c36ad4a5d698"
integrity sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q==
is-binary-path@~2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
@ -4909,12 +4914,7 @@ xmldom@=0.1.15:
resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.15.tgz#b3048062f1bdd52edc421424459f06dceeb2f94d"
integrity sha1-swSAYvG91S7cQhQkRZ8G3O6y+U0=
xmldom@=0.1.19:
version "0.1.19"
resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.19.tgz#631fc07776efd84118bf25171b37ed4d075a0abc"
integrity sha1-Yx/Ad3bv2EEYvyUXGzftTQdaCrw=
xmldom@auth0/xmldom#v0.1.19-auth0_1:
xmldom@=0.1.19, xmldom@auth0/xmldom#v0.1.19-auth0_1:
version "0.1.19"
resolved "https://codeload.github.com/auth0/xmldom/tar.gz/3376bc7beb5551bf68e12b0cc6b0e3669f77d392"