Settings resource; oauth2 app authorization model; UI cleanup

This commit is contained in:
garrettmills 2020-05-17 21:13:38 -05:00
parent d558f21375
commit 2b2e7d2ebe
No known key found for this signature in database
GPG Key ID: 6ACD58D6ADACFC6E
19 changed files with 393 additions and 2 deletions

21
TODO.text Normal file
View File

@ -0,0 +1,21 @@
- Tagline bug - cannot save with empty text
- Profile photos
- Allow uploading/changing
- Default photo
- Expose photo endpoint for public services
- App setup wizard
- SAML IAM handling
- LDAP IAM handling
- User registration
- Cobalt form JSON field type - Setting resource
- MFA recovery codes handling
- Forgot password handling
- Admin password reset mechanism -> flag users as needing PW resets
- Make this a general flow for pre-empting user logins
- Cobalt form - when multiselect make selection box taller
- Cobalt form - after action handlers
- e.g. after insert perform action
- e.g. after update perform action, &c.
- IAM manage user API scopes
- Eliminate LDAP group model, make LDAP server use standard auth group
- OAuth2 -> support refresh tokens

View File

@ -31,6 +31,7 @@ const FlitterUnits = {
* Custom units that modify or add functionality that needs to be made * Custom units that modify or add functionality that needs to be made
* available to the middleware-routing-controller stack. * available to the middleware-routing-controller stack.
*/ */
'Settings' : require('./app/unit/SettingsUnit'),
'Upload' : require('flitter-upload/UploadUnit'), 'Upload' : require('flitter-upload/UploadUnit'),
'Less' : require('flitter-less/LessUnit'), 'Less' : require('flitter-less/LessUnit'),
'LDAPServer' : require('./app/unit/LDAPServerUnit'), 'LDAPServer' : require('./app/unit/LDAPServerUnit'),

View File

@ -0,0 +1,17 @@
import { Component } from '../lib/vues6/vues6.js'
import { action_service } from './service/Action.service.js';
const template = `
<div></div>
`
export default class InvokeActionComponent extends Component {
static get selector() { return 'coreid-invoke-action' }
static get template() { return template }
static get props() { return ['action'] }
async vue_on_create() {
console.log('IAC', this)
await action_service.perform(this.action)
}
}

View File

@ -4,6 +4,7 @@ import MFASetupPage from './auth/MFASetup.component.js'
import MFAChallengePage from './auth/MFAChallenge.component.js' import MFAChallengePage from './auth/MFAChallenge.component.js'
import MFADisableComponent from './auth/MFADisable.component.js' import MFADisableComponent from './auth/MFADisable.component.js'
import PasswordResetComponent from './auth/PasswordReset.component.js' import PasswordResetComponent from './auth/PasswordReset.component.js'
import InvokeActionComponent from './InvokeAction.component.js'
const components = { const components = {
AuthLoginForm, AuthLoginForm,
@ -12,6 +13,7 @@ const components = {
MFAChallengePage, MFAChallengePage,
MFADisableComponent, MFADisableComponent,
PasswordResetComponent, PasswordResetComponent,
InvokeActionComponent,
} }
export { components } export { components }

View File

@ -64,6 +64,7 @@ export default class NavBarComponent extends Component {
async vue_on_create() { async vue_on_create() {
this.can.api_tokens = await session.check_permissions('v1:reflect:tokens:list') this.can.api_tokens = await session.check_permissions('v1:reflect:tokens:list')
this.$forceUpdate()
} }
toggle_sidebar() { toggle_sidebar() {

View File

@ -77,8 +77,9 @@ export default class SideBarComponent extends Component {
}, },
{ {
text: 'Settings', text: 'Settings',
action: 'redirect', action: 'list',
next: '/dash/settings', type: 'resource',
resource: 'Setting',
}, },
] ]

View File

@ -0,0 +1,52 @@
import CRUDBase from './CRUDBase.js'
class SettingResource extends CRUDBase {
endpoint = '/api/v1/settings'
required_fields = ['key', 'value']
permission_base = 'v1:settings'
item = 'Setting'
plural = 'Settings'
listing_definition = {
columns: [
{
name: 'Setting Key',
field: 'key',
},
{
name: 'Value',
field: 'value',
renderer: (v) => JSON.stringify(v),
},
],
actions: [
{
type: 'resource',
position: 'row',
action: 'update',
icon: 'fa fa-edit',
color: 'primary',
},
],
}
form_definition = {
fields: [
{
name: 'Setting Key',
field: 'key',
type: 'text',
readonly: true,
},
{
name: 'Value (JSON)',
field: 'value',
type: 'json',
},
],
}
}
const setting = new SettingResource()
export { setting }

View File

@ -23,6 +23,26 @@ class ActionService {
} else if ( action === 'list' ) { } else if ( action === 'list' ) {
return location_service.redirect(`/dash/c/listing/${resource}`, 0) return location_service.redirect(`/dash/c/listing/${resource}`, 0)
} }
} else if ( action === 'post' ) {
const inputs = []
if ( args.params ) {
for (const param in args.params) {
if ( !args.params.hasOwnProperty(param) ) continue
inputs.push(`<input type="hidden" name="${param}" value="${args.params[param]}"/>`)
}
}
const form_attrs = ['method="POST"']
if ( args.destination ) {
form_attrs.push(`action="${args.destination}"`)
}
$(`
<form ${form_attrs.join(' ')}>
${inputs.join('\n')}
</form>
`).appendTo('body').submit()
} else { } else {
throw new TypeError(`Unknown action type: ${action}`) throw new TypeError(`Unknown action type: ${action}`)
} }

View File

@ -0,0 +1,47 @@
const { Controller } = require('libflitter')
class SettingsController extends Controller {
static get services() {
return [...super.services, 'models']
}
async get_settings(req, res, next) {
const Setting = this.models.get('Setting')
const settings = await Setting.find()
const data = []
for ( const setting of settings ) {
data.push(await setting.to_api())
}
return res.api(data)
}
async get_setting(req, res, next) {
const Setting = this.models.get('Setting')
const setting = await Setting.findOne({ key: req.params.key })
if ( !setting )
return res.status(404)
.message('No setting exists with that key.')
.api()
return res.api(await setting.to_api())
}
async update_setting(req, res, next) {
const Setting = this.models.get('Setting')
const setting = await Setting.findOne({ key: req.params.key })
if ( !setting )
return res.status(404)
.message('No setting exists with that key.')
.api()
setting.set(req.body.value)
await setting.save()
return res.api()
}
}
module.exports = exports = SettingsController

View File

@ -7,6 +7,67 @@ const Oauth2Controller = require('flitter-auth/controllers/Oauth2')
* as you need. * as you need.
*/ */
class Oauth2 extends Oauth2Controller { class Oauth2 extends Oauth2Controller {
static get services() {
return [...super.services, 'Vue', 'configs', 'models']
}
async authorize_post(req, res, next) {
const client = await this._get_authorize_client({query: req.body})
if ( !client ) return this._uniform(res, 'Unable to authorize client application. The application config is invalid. Please check the client ID and redirect URI and try again.')
const StarshipClient = this.models.get('oauth:Client')
const starship_client = await StarshipClient.findOne({ active: true, uuid: client.clientID })
req.user.authorize(starship_client)
await req.user.save()
return super.authorize_post(req, res, next)
}
async authorize_get(req, res, next) {
const client = await this._get_authorize_client(req)
if ( !client ) return this._uniform(res, 'Unable to authorize client application. The application config is invalid. Please check the client ID and redirect URI and try again.')
const uri = new URL(req.query.redirect_uri)
const StarshipClient = this.models.get('oauth:Client')
const starship_client = await StarshipClient.findOne({ active: true, uuid: client.clientID })
if ( req.user.has_authorized(starship_client) ) {
return this.Vue.invoke_action(res, {
text: 'Grant Access',
action: 'post',
params: {
redirect_uri: uri.toString(),
client_id: client.clientID,
},
})
}
return res.page('public:message', {
...this.Vue.data({
message: `<h3 class="font-weight-light">Authorize ${client.name}?</h3>
<br>
${client.name} is requesting access to your ${this.configs.get('app.name')} account. Once you grant it, you may not be prompted for permission again.
<br><br><br>
<i><small>You will be redirected to: ${uri.host}</small></i>`,
actions: [
{
text: 'Deny',
action: 'redirect',
next: '/dash',
},
{
text: 'Grant Access',
action: 'post',
params: {
redirect_uri: uri.toString(),
client_id: client.clientID,
},
},
],
})
})
}
} }

View File

@ -0,0 +1,56 @@
const { Model } = require('flitter-orm')
class SettingModel extends Model {
static get services() {
return [...super.services, 'utility']
}
static get schema() {
return {
key: String,
value: String,
history: [String],
}
}
static async guarantee(key, value = '') {
if ( !(await this.findOne({ key })) ) {
const new_inst = new this({ key })
new_inst.set(value)
await new_inst.save()
}
}
static async get(key) {
const inst = await this.findOne({ key })
return inst.get()
}
static async set(key, value) {
const inst = await this.findOne({ key })
inst.set(value)
await inst.save()
}
get() {
return JSON.parse(this.value)
}
set(value) {
if ( Array.isArray(this.history) )
this.history.push(this.value)
this.value = JSON.stringify(value)
}
async to_api() {
return {
id: this.key,
key: this.key,
value: this.get(),
history: this.history || [],
}
}
}
module.exports = exports = SettingModel

View File

@ -0,0 +1,13 @@
const { Model } = require('flitter-orm')
class AppAuthorizationModel extends Model {
static get schema() {
return {
client_id: String,
authorize_date: { type: Date, default: () => new Date },
api_scopes: [String],
}
}
}
module.exports = exports = AppAuthorizationModel

View File

@ -4,6 +4,7 @@ const LDAP = require('ldapjs')
const ActiveScope = require('../scopes/ActiveScope') const ActiveScope = require('../scopes/ActiveScope')
const MFAToken = require('./MFAToken.model') const MFAToken = require('./MFAToken.model')
const PasswordReset = require('./PasswordReset.model') const PasswordReset = require('./PasswordReset.model')
const AppAuthorization = require('./AppAuthorization.model')
const AppPassword = require('./AppPassword.model') const AppPassword = require('./AppPassword.model')
const uuid = require('uuid/v4') const uuid = require('uuid/v4')
@ -29,12 +30,38 @@ class User extends AuthUser {
mfa_token: MFAToken, mfa_token: MFAToken,
password_resets: [PasswordReset], password_resets: [PasswordReset],
app_passwords: [AppPassword], app_passwords: [AppPassword],
app_authorizations: [AppAuthorization],
mfa_enabled: {type: Boolean, default: false}, mfa_enabled: {type: Boolean, default: false},
mfa_enable_date: Date, mfa_enable_date: Date,
create_date: {type: Date, default: () => new Date}, create_date: {type: Date, default: () => new Date},
}} }}
} }
has_authorized(client) {
return this.app_authorizations.some(x => x.client_id === client.id)
}
get_authorization(client) {
for ( const auth of this.app_authorizations ) {
if ( auth.client_id === client.id )
return auth
}
}
authorize(client) {
if ( !this.has_authorized(client) ) {
const client_rec = new AppAuthorization({
client_id: client.id,
api_scopes: client.api_scopes,
}, this)
this.app_authorizations.push(client_rec)
} else {
const client_rec = this.get_authorization(client)
client_rec.api_scopes = client.api_scopes
}
}
async to_api() { async to_api() {
return { return {
id: this.id, id: this.id,

View File

@ -0,0 +1,25 @@
const settings_routes = {
prefix: '/api/v1/settings',
middleware: ['auth:APIRoute'],
get: {
'/': [
['middleware::api:Permission', { check: 'v1:settings:list' }],
'controller::api:v1:Settings.get_settings',
],
'/:key': [
['middleware::api:Permission', { check: 'v1:settings:get' }],
'controller::api:v1:Settings.get_setting',
],
},
patch: {
'/:key': [
['middleware::api:Permission', { check: 'v1:settings:update' }],
'controller::api:v1:Settings.update_setting',
],
},
}
module.exports = exports = settings_routes

View File

@ -35,6 +35,12 @@ class VueService extends Service {
} }
} }
invoke_action(res, action) {
return res.page('public:action', {
...this.data({ action })
})
}
auth_message(res, {message, next_destination, ...args}) { auth_message(res, {message, next_destination, ...args}) {
const text = args.button_text || 'Continue' const text = args.button_text || 'Continue'
return res.page('public:message', { return res.page('public:message', {

24
app/unit/SettingsUnit.js Normal file
View File

@ -0,0 +1,24 @@
const { Unit } = require('libflitter')
class SettingsUnit extends Unit {
static get name() {
return 'settings'
}
static get services() {
return [...super.services, 'configs', 'models', 'output']
}
async go(app) {
const Setting = this.models.get('Setting')
const default_settings = this.configs.get('setting.settings')
for ( const key in default_settings ) {
if ( !default_settings.hasOwnProperty(key) ) continue
const default_value = default_settings[key]
this.output.debug(`Guarantee setting key "${key}" with default value "${default_value}".`)
await Setting.guarantee(key, default_value)
}
}
}
module.exports = exports = SettingsUnit

View File

@ -0,0 +1,7 @@
extends ../theme/public/base
block append style
link(rel='stylesheet' href='/style-asset/form.css')
block vue
coreid-invoke-action(v-bind:action="action")

View File

@ -6,6 +6,8 @@ block append style
block masthead block masthead
h1.font-weight-light #{_app && _app.name || 'Starship CoreID'} h1.font-weight-light #{_app && _app.name || 'Starship CoreID'}
p.lead Centralized, self-hosted, modern identity services. p.lead Centralized, self-hosted, modern identity services.
p.font-weight-light
a.btn.btn-sm.btn-outline-secondary(href='/dash') Dashboard
block append content block append content
section.py-5#about section.py-5#about

8
config/setting.config.js Normal file
View File

@ -0,0 +1,8 @@
const setting_config = {
settings: {
'auth.allow_registration': true,
'auth.default_roles': [ 'base_user' ],
}
}
module.exports = exports = setting_config