diff --git a/TODO.text b/TODO.text new file mode 100644 index 0000000..889a994 --- /dev/null +++ b/TODO.text @@ -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 diff --git a/Units.flitter.js b/Units.flitter.js index 80ac02f..5633aae 100644 --- a/Units.flitter.js +++ b/Units.flitter.js @@ -31,6 +31,7 @@ const FlitterUnits = { * Custom units that modify or add functionality that needs to be made * available to the middleware-routing-controller stack. */ + 'Settings' : require('./app/unit/SettingsUnit'), 'Upload' : require('flitter-upload/UploadUnit'), 'Less' : require('flitter-less/LessUnit'), 'LDAPServer' : require('./app/unit/LDAPServerUnit'), diff --git a/app/assets/app/InvokeAction.component.js b/app/assets/app/InvokeAction.component.js new file mode 100644 index 0000000..0509fb8 --- /dev/null +++ b/app/assets/app/InvokeAction.component.js @@ -0,0 +1,17 @@ +import { Component } from '../lib/vues6/vues6.js' +import { action_service } from './service/Action.service.js'; + +const template = ` +
+` + +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) + } +} diff --git a/app/assets/app/components.js b/app/assets/app/components.js index 6198307..25cd76e 100644 --- a/app/assets/app/components.js +++ b/app/assets/app/components.js @@ -4,6 +4,7 @@ import MFASetupPage from './auth/MFASetup.component.js' import MFAChallengePage from './auth/MFAChallenge.component.js' import MFADisableComponent from './auth/MFADisable.component.js' import PasswordResetComponent from './auth/PasswordReset.component.js' +import InvokeActionComponent from './InvokeAction.component.js' const components = { AuthLoginForm, @@ -12,6 +13,7 @@ const components = { MFAChallengePage, MFADisableComponent, PasswordResetComponent, + InvokeActionComponent, } export { components } diff --git a/app/assets/app/dash/NavBar.component.js b/app/assets/app/dash/NavBar.component.js index 375e204..e5eb349 100644 --- a/app/assets/app/dash/NavBar.component.js +++ b/app/assets/app/dash/NavBar.component.js @@ -64,6 +64,7 @@ export default class NavBarComponent extends Component { async vue_on_create() { this.can.api_tokens = await session.check_permissions('v1:reflect:tokens:list') + this.$forceUpdate() } toggle_sidebar() { diff --git a/app/assets/app/dash/SideBar.component.js b/app/assets/app/dash/SideBar.component.js index 1713e19..04be211 100644 --- a/app/assets/app/dash/SideBar.component.js +++ b/app/assets/app/dash/SideBar.component.js @@ -77,8 +77,9 @@ export default class SideBarComponent extends Component { }, { text: 'Settings', - action: 'redirect', - next: '/dash/settings', + action: 'list', + type: 'resource', + resource: 'Setting', }, ] diff --git a/app/assets/app/resource/Setting.resource.js b/app/assets/app/resource/Setting.resource.js new file mode 100644 index 0000000..b0fe10f --- /dev/null +++ b/app/assets/app/resource/Setting.resource.js @@ -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 } diff --git a/app/assets/app/service/Action.service.js b/app/assets/app/service/Action.service.js index d829faa..0b95582 100644 --- a/app/assets/app/service/Action.service.js +++ b/app/assets/app/service/Action.service.js @@ -23,6 +23,26 @@ class ActionService { } else if ( action === 'list' ) { 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(``) + } + } + + const form_attrs = ['method="POST"'] + if ( args.destination ) { + form_attrs.push(`action="${args.destination}"`) + } + + $(` +
+ ${inputs.join('\n')} +
+ `).appendTo('body').submit() } else { throw new TypeError(`Unknown action type: ${action}`) } diff --git a/app/controllers/api/v1/Settings.controller.js b/app/controllers/api/v1/Settings.controller.js new file mode 100644 index 0000000..a46f018 --- /dev/null +++ b/app/controllers/api/v1/Settings.controller.js @@ -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 diff --git a/app/controllers/auth/Oauth2.controller.js b/app/controllers/auth/Oauth2.controller.js index 6203cbf..f018e3e 100644 --- a/app/controllers/auth/Oauth2.controller.js +++ b/app/controllers/auth/Oauth2.controller.js @@ -7,6 +7,67 @@ const Oauth2Controller = require('flitter-auth/controllers/Oauth2') * as you need. */ 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: `

Authorize ${client.name}?

+
+${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. +


+You will be redirected to: ${uri.host}`, + + actions: [ + { + text: 'Deny', + action: 'redirect', + next: '/dash', + }, + { + text: 'Grant Access', + action: 'post', + params: { + redirect_uri: uri.toString(), + client_id: client.clientID, + }, + }, + ], + }) + }) + } } diff --git a/app/models/Setting.model.js b/app/models/Setting.model.js new file mode 100644 index 0000000..6ee3403 --- /dev/null +++ b/app/models/Setting.model.js @@ -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 diff --git a/app/models/auth/AppAuthorization.model.js b/app/models/auth/AppAuthorization.model.js new file mode 100644 index 0000000..254a9b3 --- /dev/null +++ b/app/models/auth/AppAuthorization.model.js @@ -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 diff --git a/app/models/auth/User.model.js b/app/models/auth/User.model.js index 4419939..2284f10 100644 --- a/app/models/auth/User.model.js +++ b/app/models/auth/User.model.js @@ -4,6 +4,7 @@ const LDAP = require('ldapjs') const ActiveScope = require('../scopes/ActiveScope') const MFAToken = require('./MFAToken.model') const PasswordReset = require('./PasswordReset.model') +const AppAuthorization = require('./AppAuthorization.model') const AppPassword = require('./AppPassword.model') const uuid = require('uuid/v4') @@ -29,12 +30,38 @@ class User extends AuthUser { mfa_token: MFAToken, password_resets: [PasswordReset], app_passwords: [AppPassword], + app_authorizations: [AppAuthorization], mfa_enabled: {type: Boolean, default: false}, mfa_enable_date: 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() { return { id: this.id, diff --git a/app/routing/routers/api/v1/settings.routes.js b/app/routing/routers/api/v1/settings.routes.js new file mode 100644 index 0000000..c29a7b0 --- /dev/null +++ b/app/routing/routers/api/v1/settings.routes.js @@ -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 diff --git a/app/services/Vue.service.js b/app/services/Vue.service.js index d6824ac..6b420e5 100644 --- a/app/services/Vue.service.js +++ b/app/services/Vue.service.js @@ -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}) { const text = args.button_text || 'Continue' return res.page('public:message', { diff --git a/app/unit/SettingsUnit.js b/app/unit/SettingsUnit.js new file mode 100644 index 0000000..78ae658 --- /dev/null +++ b/app/unit/SettingsUnit.js @@ -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 diff --git a/app/views/public/action.pug b/app/views/public/action.pug new file mode 100644 index 0000000..e3f1c3c --- /dev/null +++ b/app/views/public/action.pug @@ -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") diff --git a/app/views/welcome.pug b/app/views/welcome.pug index e203581..7e327f7 100644 --- a/app/views/welcome.pug +++ b/app/views/welcome.pug @@ -6,6 +6,8 @@ block append style block masthead h1.font-weight-light #{_app && _app.name || 'Starship CoreID'} 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 section.py-5#about diff --git a/config/setting.config.js b/config/setting.config.js new file mode 100644 index 0000000..4a0992c --- /dev/null +++ b/config/setting.config.js @@ -0,0 +1,8 @@ +const setting_config = { + settings: { + 'auth.allow_registration': true, + 'auth.default_roles': [ 'base_user' ], + } +} + +module.exports = exports = setting_config