Settings resource; oauth2 app authorization model; UI cleanup
This commit is contained in:
parent
d558f21375
commit
2b2e7d2ebe
21
TODO.text
Normal file
21
TODO.text
Normal 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
|
@ -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'),
|
||||||
|
17
app/assets/app/InvokeAction.component.js
Normal file
17
app/assets/app/InvokeAction.component.js
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@ -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 }
|
||||||
|
@ -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() {
|
||||||
|
@ -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',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
52
app/assets/app/resource/Setting.resource.js
Normal file
52
app/assets/app/resource/Setting.resource.js
Normal 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 }
|
@ -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}`)
|
||||||
}
|
}
|
||||||
|
47
app/controllers/api/v1/Settings.controller.js
Normal file
47
app/controllers/api/v1/Settings.controller.js
Normal 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
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
56
app/models/Setting.model.js
Normal file
56
app/models/Setting.model.js
Normal 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
|
13
app/models/auth/AppAuthorization.model.js
Normal file
13
app/models/auth/AppAuthorization.model.js
Normal 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
|
@ -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,
|
||||||
|
25
app/routing/routers/api/v1/settings.routes.js
Normal file
25
app/routing/routers/api/v1/settings.routes.js
Normal 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
|
@ -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
24
app/unit/SettingsUnit.js
Normal 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
|
7
app/views/public/action.pug
Normal file
7
app/views/public/action.pug
Normal 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")
|
@ -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
8
config/setting.config.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
const setting_config = {
|
||||||
|
settings: {
|
||||||
|
'auth.allow_registration': true,
|
||||||
|
'auth.default_roles': [ 'base_user' ],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = exports = setting_config
|
Loading…
Reference in New Issue
Block a user