Compare commits

..

79 Commits

Author SHA1 Message Date
775ac8b474 Make /oidc/jwks support ANY http verb
All checks were successful
continuous-integration/drone/push Build is passing
2024-04-12 23:26:02 -05:00
3d6908b7ec First steps for webauthn 2023-01-24 18:00:32 -06:00
0b8c4b87df Drone: only rollout on tag/promotion
All checks were successful
continuous-integration/drone Build is passing
2022-12-03 22:28:25 -06:00
1b12af0cd2 Drone: fix deploy dir
All checks were successful
continuous-integration/drone Build is passing
2022-12-02 10:34:23 -06:00
49be0887d0 Drone: rework ci pipeline
Some checks failed
continuous-integration/drone Build is failing
2022-12-02 10:24:35 -06:00
d63de520c9 Implement better radius support 2022-10-26 13:45:05 -05:00
0d24782691 Update Dockerfile to Node.js 16 2022-10-26 03:07:36 -05:00
35113ed81c Remove Vault support; fix OpenID Connect client delete issue 2022-10-26 02:59:43 -05:00
562ada3af5 Add kubernetes deployment specs 2022-06-25 20:55:06 -05:00
04ea16743d Include profile photo in user API data
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is failing
2022-01-27 22:45:11 -06:00
cf91063315 #9 - show app password use date in profile
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is failing
2021-12-14 16:40:15 -06:00
fd8a05446a Report OIDC errors to output
All checks were successful
continuous-integration/drone/push Build is passing
2021-12-03 02:45:02 -06:00
dae06aa577 Disable radius 2021-11-22 09:08:33 -06:00
ffbcf1b514 More RADIUS work 2021-11-22 09:08:22 -06:00
6e161dd383 Radius server - fail gracefully if port in use
All checks were successful
continuous-integration/drone/push Build is passing
2021-10-24 13:39:11 -05:00
64bc167d01 RADIUS - improve logging
All checks were successful
continuous-integration/drone/push Build is passing
2021-10-24 13:37:21 -05:00
bd69be7137 Implement RADIUS server!
All checks were successful
continuous-integration/drone/push Build is passing
2021-10-24 13:12:58 -05:00
f98f35f626
Update flitter-auth
All checks were successful
continuous-integration/drone/push Build is passing
2021-09-14 23:53:20 -05:00
cf1ea362cd
Update flitter-auth & fix state search params
All checks were successful
continuous-integration/drone/push Build is passing
2021-09-14 23:38:35 -05:00
7f338325b9
Update flitter-auth
All checks were successful
continuous-integration/drone/push Build is passing
2021-09-14 23:32:38 -05:00
a60af453ab Update 'app/controllers/auth/Oauth2.controller.js'
All checks were successful
continuous-integration/drone/push Build is passing
2021-09-15 04:25:03 +00:00
670b9b1299 Update 'app/controllers/auth/Oauth2.controller.js'
All checks were successful
continuous-integration/drone/push Build is passing
2021-09-15 04:16:01 +00:00
5f0d67d525 Update 'app/controllers/auth/Oauth2.controller.js'
All checks were successful
continuous-integration/drone/push Build is passing
2021-09-15 04:12:59 +00:00
1852be4ef0 Update 'app/controllers/auth/Oauth2.controller.js'
All checks were successful
continuous-integration/drone/push Build is passing
2021-09-15 04:01:48 +00:00
54258ecb8b
Update flitter-auth
All checks were successful
continuous-integration/drone/push Build is passing
2021-09-14 22:52:12 -05:00
1e80da9b80
Make User.to_api include combined name field
All checks were successful
continuous-integration/drone/push Build is passing
2021-09-14 20:43:42 -05:00
5420cf58bd
Add flat user endpoint
All checks were successful
continuous-integration/drone/push Build is passing
2021-09-14 20:42:45 -05:00
159fdb15e6
Update flitter-auth
All checks were successful
continuous-integration/drone/push Build is passing
2021-09-14 20:37:56 -05:00
6612eb7b10 Upload files to 'app/assets'
All checks were successful
continuous-integration/drone Build is passing
2021-09-06 17:15:41 +00:00
2fe1d499f6
Fix format of SAML middleware error throw
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is failing
2021-08-22 20:06:36 -05:00
cb783ea277
Revert: SAML request middleware - pass error, not string
All checks were successful
continuous-integration/drone/push Build is passing
2021-08-22 20:05:21 -05:00
2f2d38d12f
SAML request middleware - pass error, not string
All checks were successful
continuous-integration/drone/push Build is passing
2021-08-22 20:04:42 -05:00
62c818dc8d
Add ability to require e-mail verification
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is failing
2021-05-04 11:28:55 -05:00
f45e92af1e
Make coreid SPA
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-05-04 10:18:53 -05:00
ced3a15d00
Remove vault menu item
All checks were successful
continuous-integration/drone/push Build is passing
2021-05-04 09:27:33 -05:00
9729de47f8
Make registration carry flow all the way through
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-05-03 20:06:51 -05:00
5bc98e6568
Update libflitter
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-04-23 10:59:54 -05:00
de20dce735
Add public endpoint to get user photo
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-04-19 13:32:43 -05:00
13af63a364 Update 'app/views/welcome.pug'
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-04-17 02:40:42 +00:00
3730ddc2f2
Add basic logic for managing vaults
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-04-15 15:34:13 -05:00
5391c7c6d6
Check app ID on oidc auth
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-04-15 14:06:25 -05:00
ae85c3fd24
fix format of oidc authorization checks
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-04-15 14:03:14 -05:00
3301a48750
Track oidc authorizations by app, not client
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-04-15 13:50:48 -05:00
d1312fe627
Add logic to save OpenID connect grants
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-04-15 13:41:13 -05:00
bd6eaceaf3
Remove debugging
All checks were successful
continuous-integration/drone/push Build is passing
2021-04-15 13:16:19 -05:00
6b2257ae33
Fix recursive method call in sudo controller
All checks were successful
continuous-integration/drone/push Build is passing
2021-04-15 13:09:56 -05:00
636e1f8ab9
Fix recursive method call in sudo controller
All checks were successful
continuous-integration/drone/push Build is passing
2021-04-15 13:02:18 -05:00
627499537d
Look up IAM from sudo hosts
All checks were successful
continuous-integration/drone/push Build is passing
2021-04-15 13:00:13 -05:00
7e3f198c04
Debugging
All checks were successful
continuous-integration/drone/push Build is passing
2021-04-15 12:51:23 -05:00
b26519ea88
Make sudo access managed via IAM rather than group checkmark
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-04-15 10:56:11 -05:00
f2995899ec
Add ability to manage and grant IAM permissions as policy
All checks were successful
continuous-integration/drone/push Build is passing
2021-04-15 10:38:43 -05:00
5645e8fae1
Clean up IAM to allow relations w/o explicit definitions
All checks were successful
continuous-integration/drone/push Build is passing
2021-04-15 09:55:25 -05:00
a7ed5d09f1
Better IAM denial logging
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-16 15:24:21 -05:00
3a91417db3
Add default user to allow for default groups and IAM
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-03-15 17:45:18 -05:00
0844da594e
Show iam filter for machines
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-03-15 17:13:09 -05:00
64ad8931f3
Show iamTarget in relevant forms
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-03-15 16:55:50 -05:00
a9d7b1c047
Allow IAM policy to manage user access to machines & machine groups
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-15 16:28:42 -05:00
d6e4ea2e56
Add ability to manage computers and computer groups from web interface
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-15 16:10:23 -05:00
718414d924
Clean up sudo LDAP formatting
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-03-10 23:53:22 -06:00
943c30fa96
Add support for sudo
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-03-10 23:43:16 -06:00
3d2c4c0fec
Cast gidNumber to strings
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-03-10 23:21:33 -06:00
8b8c2e076f
User model - fix LDAP gidNumber
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-03-10 23:16:04 -06:00
0ee36dc429
User - resolve posix groups for all member groups
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-10 20:15:04 -06:00
48f5b3f71a
Make all groups appear in LDAP, get posix GIDs 2021-03-10 20:12:06 -06:00
ef819b0a2e
Groups - allow flagging group as su equivalent
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-10 20:06:43 -06:00
91fc8a65a2
Allow users to set login shell in profile
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-10 19:43:51 -06:00
2d31eaa148
LDAP - properly cast gidNumber to string
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-10 19:21:41 -06:00
82e25ccef0
LDAP - support posixGroups in group model
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-10 19:12:46 -06:00
53a1662f70
LDAP - specify user homeDirectory
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-10 18:37:32 -06:00
dbb8684f68
LDAP - set default loginShell
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-10 18:31:43 -06:00
6a4f82611b
LDAP - allow specifying certificate file
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-10 18:22:51 -06:00
e6a7070589
Remove duplicate uid/gid number LDAP attrs
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-10 16:26:59 -06:00
e6588b4f5b
LDAP - include gid number
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-03-10 16:02:18 -06:00
20e723f39f
LDAP - cast modifications to support posix logins
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-03-10 15:48:27 -06:00
a8729930e6
Update drone to use promotions for prod
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2020-12-08 22:21:54 -06:00
c725f14bf2
Update flitter jobs
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2020-12-04 20:49:37 -06:00
9b5216431d
Make jobs use built-in job logging
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2020-12-04 20:40:27 -06:00
1d5c00768c
Update flitter jobs
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2020-12-03 20:00:40 -06:00
7f1c9ec9a8
Update libflitter
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2020-10-29 08:52:13 -05:00
96 changed files with 5623 additions and 2234 deletions

View File

@ -1,88 +1,68 @@
---
kind: pipeline
name: default
type: kubernetes
name: build
metadata:
labels:
pod-security.kubernetes.io/audit: privileged
services:
- name: docker daemon
image: docker:dind
privileged: true
environment:
DOCKER_TLS_CERTDIR: ""
steps:
- name: release
image: plugins/gitea-release
settings:
api_key:
from_secret: gitea_api_key
base_url: https://code.garrettmills.dev
checksum: md5
title: ${DRONE_TAG}
- name: container build
image: docker:latest
privileged: true
commands:
- "while ! docker stats --no-stream; do sleep 1; done"
- "docker build -t $DOCKER_REGISTRY/starship/coreid ."
- "docker push $DOCKER_REGISTRY/starship/coreid"
environment:
DOCKER_HOST: tcp://localhost:2375
DOCKER_REGISTRY:
from_secret: DOCKER_REGISTRY
- name: environment substitution
image: rockylinux:9.0-minimal
commands:
- microdnf install -y gettext
- cd deploy && mkdir ../deploy-subst && bash -c 'for f in *.yaml; do envsubst < $f > ../deploy-subst/$f; done'
environment:
COREID_DOMAIN:
from_secret: COREID_DOMAIN
DOCKER_REGISTRY:
from_secret: DOCKER_REGISTRY
COREID_DATABASE_HOST:
from_secret: COREID_DATABASE_HOST
COREID_DATABASE_NAME:
from_secret: COREID_DATABASE_NAME
COREID_LDAP_BASE_DC:
from_secret: COREID_LDAP_BASE_DC
COREID_REDIS_HOST:
from_secret: COREID_REDIS_HOST
COREID_SMTP_HOST:
from_secret: COREID_SMTP_HOST
when:
event: tag
- name: deploy to production
image: appleboy/drone-ssh
settings:
host:
from_secret: deploy_ssh_host
username:
from_secret: deploy_ssh_user
key:
from_secret: deploy_ssh_key
port:
from_secret: deploy_ssh_port
script:
- cd /home/coreid/CoreID
- git checkout master
- git pull
- git checkout ${DRONE_TAG}
- git pull
- yarn install
when:
event:
- tag
- promote
- name: restart production services
image: appleboy/drone-ssh
settings:
host:
from_secret: deploy_ssh_host
username:
from_secret: deploy_ssh_admin_user
key:
from_secret: deploy_ssh_key
port:
from_secret: deploy_ssh_port
script:
- systemctl restart coreid-www
- systemctl restart coreid-jobs
when:
event:
- tag
- promote
- name: send success notifications
image: plugins/webhook
settings:
urls:
from_secret: notify_webhook_url
content_type: application/json
template: |
{
"title": "Drone-CI [Starship/CoreID]",
"message": "Build ${DRONE_BUILD_NUMBER} promoted to production.",
"priority": 4
}
when:
status: success
event:
- tag
- promote
- name: send error notifications
image: plugins/webhook
settings:
urls:
from_secret: notify_webhook_url
content_type: application/json
template: |
{
"title": "Drone-CI [Starship/CoreID]",
"message": "An error was encountered while promoting build ${DRONE_BUILD_NUMBER} to production.",
"priority": 6
}
when:
status: failure
event:
- tag
- promote
- name: k8s rollout
image: bitnami/kubectl
privileged: true
commands:
- cd deploy-subst && kubectl apply -f .
- kubectl rollout restart deployment/coreid-www -n starship
- kubectl rollout restart deployment/coreid-jobs -n starship
depends_on:
- container build
- environment substitution
when:
event:
- tag
- promote

3
.gitignore vendored
View File

@ -1,3 +1,5 @@
*.conf
# ---> Node
# Logs
logs
@ -150,3 +152,4 @@ tmp.uploads/*
!tmp.uploads/.gitkeep
uploads/*
!uploads/.gitkeep
ttls-pap.conf

16
Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM node:16
RUN mkdir /app
COPY package.json /app
COPY yarn.lock /app
RUN cd /app && yarn install
COPY . /app
RUN rm -rf /app/.env
RUN touch /app/.env
WORKDIR /app
CMD ["node", "index.js"]

View File

@ -44,6 +44,7 @@ const FlitterUnits = {
'LDAPController': require('./app/unit/LDAPControllerUnit'),
'LDAPRoutingUnit': require('./app/unit/LDAPRoutingUnit'),
'OpenIDConnect' : require('./app/unit/OpenIDConnectUnit'),
'Radius' : require('./app/unit/RadiusUnit'),
/*
* The Core Flitter Units

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -28,7 +28,7 @@ const template = `
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))">
<span v-if="field.type.startsWith('select') && (Array.isArray(field.hidden) ? !field.hidden.includes(mode) : !field.hidden) && (typeof field.if !== 'function' || field.if(data, field.options))">
<label :for="uuid+field.field">{{ field.name }}</label>
<select
:id="uuid+field.field"
@ -42,13 +42,13 @@ const template = `
<option
v-for="option of field.options"
:value="option.value"
:selected="data[field.field] && data[field.field].includes(option.value)"
:selected="data[field.field] && (data[field.field] === option.value || (Array.isArray(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>
<label :for="uuid+field.field" style="display: inline">{{ field.name }} <span v-if="field.help" :title="field.help"><img src="/assets/info-circle-solid.svg" height="18"></span></label>
<input
type="text"
class="form-control"

View File

@ -8,6 +8,8 @@ import AppSetupComponent from './dash/AppSetup.component.js'
import ListingComponent from './cobalt/Listing.component.js'
import FormComponent from './cobalt/Form.component.js'
import RootPageComponent from './dash/RootPage.component.js'
import OutletComponent from './dash/Outlet.component.js'
import { T } from './service/Translate.service.js'
@ -22,6 +24,8 @@ const dash_components = {
ListingComponent,
FormComponent,
RootPageComponent,
OutletComponent,
}
export { dash_components }

View File

@ -1,6 +1,7 @@
import { Component } from '../../lib/vues6/vues6.js'
import { event_bus } from '../service/EventBus.service.js'
import { session } from '../service/Session.service.js'
import { action_service } from '../service/Action.service.js'
const template = `
<nav class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
@ -35,9 +36,9 @@ const template = `
aria-labelledby="navbarDropdown"
>
<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>
<a href="/dash/c/listing/system/Announcement" v-if="can.messages" class="dropdown-item">System Announcements</a>
<a href="/dash/profile" class="dropdown-item" @click="navigate('dash.profile')" onclick="return false;">My Profile</a>
<a href="/dash/c/listing/reflect/Token" v-if="can.api_tokens" @click="cobalt('reflect/Token', 'list')" class="dropdown-item" onclick="return false;">API Tokens</a>
<a href="/dash/c/listing/system/Announcement" v-if="can.messages" @click="cobalt('system/Announcement', 'list')" class="dropdown-item" onclick="return false;">System Announcements</a>
<div class="dropdown-divider"></div>
<a href="/auth/logout" class="dropdown-item">Sign-Out of {{ app_name }}</a>
</div>
@ -71,4 +72,20 @@ export default class NavBarComponent extends Component {
toggle_sidebar() {
this.toggle_event.fire()
}
navigate(page) {
action_service.perform({
action: 'navigate',
page,
})
}
cobalt(resource, action, id = undefined) {
action_service.perform({
type: 'resource',
resource,
action,
id,
})
}
}

View File

@ -0,0 +1,45 @@
import { Component } from '../../lib/vues6/vues6.js'
import { event_bus } from '../service/EventBus.service.js'
const template = `
<coreid-root :page="page" :form_id="form_id" :resource="resource" :mode="mode" v-if="show"></coreid-root>
`
export default class OutletPageComponent extends Component {
static get selector() { return 'coreid-outlet' }
static get template() { return template }
static get props() { return ['initial_page', 'initial_form_id', 'initial_resource', 'initial_mode'] }
constructor() {
super()
this.navigate_event = event_bus.event('root.navigate')
this.show = true
console.log('navigate event', this.navigate_event)
}
async vue_on_create() {
this.page = this.initial_page
this.form_id = this.initial_form_id
this.resource = this.initial_resource
this.mode = this.initial_mode
this.navigate_event.subscribe((props = {}) => {
console.log('navigation event', props)
this.page = props.page
this.form_id = props.form_id
this.resource = props.resource
this.mode = props.mode
this.rerender()
})
this.$forceUpdate()
}
rerender() {
this.show = false
this.$forceUpdate()
requestAnimationFrame(() => {
this.show = true
this.$forceUpdate()
})
}
}

View File

@ -0,0 +1,32 @@
import { Component } from '../../lib/vues6/vues6.js'
const template = `
<span>
<coreid-profile-edit v-if="page === 'dash.profile'"></coreid-profile-edit>
<coreid-app-setup v-if="page === 'app.setup'"></coreid-app-setup>
<cobalt-form
v-if="page === 'cobalt.form' && form_id"
:resource="resource"
:form_id="form_id"
:initial_mode="mode"
></cobalt-form>
<cobalt-form
v-if="page === 'cobalt.form' && !form_id"
:resource="resource"
:initial_mode="mode"
></cobalt-form>
<cobalt-listing
v-if="page === 'cobalt.listing'"
:resource="resource"
></cobalt-listing>
</span>
`
export default class RootPageComponent extends Component {
static get selector() { return 'coreid-root' }
static get template() { return template }
static get props() { return ['page', 'form_id', 'resource', 'mode'] }
constructor() {
super()
}
}

View File

@ -33,8 +33,8 @@ export default class SideBarComponent extends Component {
this.possible_actions = [
{
text: 'Profile',
action: 'redirect',
next: '/dash/profile',
action: 'navigate',
page: 'dash.profile',
},
{
text: 'Users',
@ -60,6 +60,24 @@ export default class SideBarComponent extends Component {
type: 'resource',
resource: 'iam/Policy',
},
{
text: 'IAM Permissions',
action: 'list',
type: 'resource',
resource: 'iam/Permission',
},
{
text: 'Computers',
action: 'list',
type: 'resource',
resource: 'ldap/Machine',
},
{
text: 'Computer Groups',
action: 'list',
type: 'resource',
resource: 'ldap/MachineGroup',
},
{
text: 'LDAP Clients',
action: 'list',
@ -72,6 +90,12 @@ export default class SideBarComponent extends Component {
type: 'resource',
resource: 'oauth/Client',
},
{
text: 'RADIUS Clients',
action: 'list',
type: 'resource',
resource: 'radius/Client',
},
{
text: 'OpenID Connect Clients',
action: 'list',

View File

@ -8,7 +8,7 @@ import { utility } from '../../service/Utility.service.js'
import { profile_service } from '../../service/Profile.service.js'
const template = `
<div class="coreid-profile-container mb-5">
<div class="coreid-profile-container mb-5 offset-0 col-md-8 offset-md-2 col-xl-6 offset-xl-3">
<div class="card">
<div class="card-body">
<div class="row">
@ -77,6 +77,20 @@ const template = `
>
</div>
</div>
<div class="row">
<h4 style="margin-left: 15px">{{ t['profile.advanced_header'] }}</h4>
<div class="col-12 form-group">
<label for="coreid-profile-shell-input">{{ t['profile.advanced_shell'] }}</label>
<input
type="text"
class="form-control"
id="coreid-profile-shell-input"
v-model="profile_shell"
@keyup="on_key_up($event)"
placeholder="/bin/bash"
>
</div>
</div>
</li>
<li class="list-group-item text-right font-italic text-muted">
{{ form_message }}
@ -90,6 +104,11 @@ const template = `
@click="change_password"
>{{ t['password.change'] }}</button>
</li>
<li class="list-group-item">
<h4>{{ t['authn.authn'] }}</h4>
<p>{{ t['authn.desc'].replace(/APP_NAME/g, app_name) }}</p>
<button class="btn btn-success btn-sm" type="button">{{ t['authn.enable'] }}</button>
</li>
<li class="list-group-item" v-if="ready && !has_mfa && (!user_id || user_id === 'me')">
<h4>{{ t['mfa.mfa'] }}</h4>
<p>{{ t['profile.mfa_1'].replace(/APP_NAME/g, app_name) }}</p>
@ -117,6 +136,7 @@ const template = `
<div class="col-9">
{{ pw.name }}
<br><span class="text-muted font-italic">{{ t['profile.issued'] }} {{ pw.created }}</span>
<span class="text-muted font-italic">&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;{{ t['profile.accessed'] }} {{ pw.accessed || t['common.never'] }}</span>
</div>
<div class="col-3 my-auto">
<button
@ -202,6 +222,7 @@ export default class EditProfileComponent extends Component {
this.profile_last = ''
this.profile_email = ''
this.profile_tagline = ''
this.profile_shell = ''
this.last_reset = ''
this.mfa_enable_date = ''
@ -271,7 +292,14 @@ export default class EditProfileComponent extends Component {
'profile.app_key',
'profile.example_gateway_url',
'profile.save_notify',
'profile.test_notify'
'profile.test_notify',
'profile.advanced_header',
'profile.advanced_shell',
'profile.accessed',
'common.never',
'authn.authn',
'authn.desc',
'authn.enable',
)
this.app_name = session.get('app.name')
@ -292,6 +320,7 @@ export default class EditProfileComponent extends Component {
last_name: this.profile_last,
email: this.profile_email,
tagline: this.profile_tagline,
login_shell: this.profile_shell,
user_id: this.user_id || 'me',
}
}
@ -340,6 +369,7 @@ export default class EditProfileComponent extends Component {
this.profile_last = result.last_name
this.profile_email = result.email
this.profile_tagline = result.tagline
this.profile_shell = result.login_shell
const notify_config = await profile_service.get_notify(this.user_id || 'me')
if ( !notify_config || !notify_config.has_config ) {
@ -384,6 +414,7 @@ export default class EditProfileComponent extends Component {
this.app_passwords = app_pws.map(x => {
if ( x.expires ) x.expires = (new Date(x.expires)).toLocaleDateString()
if ( x.created ) x.created = (new Date(x.created)).toLocaleDateString()
if ( x.accessed ) x.accessed = (new Date(x.accessed)).toLocaleDateString()
return x
})
}

View File

@ -40,10 +40,10 @@ class AppResource extends CRUDBase {
},
{
position: 'main',
action: 'redirect',
action: 'navigate',
text: 'Setup Wizard',
color: 'success',
next: '/dash/app/setup',
page: 'app.setup',
},
{
type: 'resource',
@ -84,6 +84,14 @@ class AppResource extends CRUDBase {
field: 'description',
type: 'textarea',
},
{
name: 'IAM Target',
field: 'id',
type: 'text',
readonly: true,
hidden: ['insert'],
help: `(LDAP use) Allows restricting users to only those that can access this application. (filter: iamTarget)`,
},
{
name: 'Associated LDAP Clients',
field: 'ldap_client_ids',
@ -104,6 +112,16 @@ class AppResource extends CRUDBase {
value: 'id',
},
},
{
name: 'Associated RADIUS Clients',
field: 'radius_client_ids',
type: 'select.dynamic.multiple',
options: {
resource: 'radius/Client',
display: 'name',
value: 'id',
},
},
{
name: 'Associated OpenID Connect Clients',
field: 'openid_client_ids',

View File

@ -62,6 +62,15 @@ class GroupResource extends CRUDBase {
required: true,
type: 'text',
},
/*{
name: 'Superuser equivalent?',
field: 'grants_sudo',
type: 'select',
options: [
{display: 'Yes', value: true},
{display: 'No', value: false},
],
},*/
{
name: 'Users',
field: 'user_ids',

View File

@ -0,0 +1,87 @@
import CRUDBase from '../CRUDBase.js'
class PermissionResource extends CRUDBase {
constructor() {
super()
this.endpoint = '/api/v1/iam/permission'
this.required_fields = ['target_type', 'permission']
this.permission_base = 'v1:iam:permission'
this.item = 'IAM Permission'
this.plural = 'IAM Permissions'
this.listing_definition = {
display: `Permissions are custom actions that can be performed on a given IAM target by the subject.`,
columns: [
{
name: 'Target Type',
field: 'target_type',
renderer: type => type.split('_').map(x => `${x.charAt(0).toUpperCase()}${x.slice(1)}`).join(' '),
},
{
name: 'Permission',
field: 'permission',
},
],
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,
},
],
}
this.form_definition = {
fields: [
{
name: 'Target Type',
field: 'target_type',
required: true,
type: 'select',
options: [
{display: 'Application', value: 'application'},
{display: 'Api Scope', value: 'api_scope'},
{display: 'Machine', value: 'machine'},
{display: 'Machine Group', value: 'machine_group'},
],
},
{
name: 'Permission',
field: 'permission',
required: true,
type: 'text',
},
],
/*handlers: {
insert: {
action: 'back',
},
update: {
action: 'back',
},
},*/
}
}
}
const iam_permission = new PermissionResource()
export { iam_permission }

View File

@ -41,6 +41,11 @@ class PolicyResource extends CRUDBase {
name: 'Target',
field: 'target_display',
},
{
name: 'Permission',
field: 'permission',
renderer: permission => permission || '-',
},
],
actions: [
{
@ -122,6 +127,8 @@ class PolicyResource extends CRUDBase {
options: [
{display: 'Application', value: 'application'},
{display: 'API Scope', value: 'api_scope'},
{display: 'Computer', value: 'machine'},
{display: 'Computer Group', value: 'machine_group'},
],
},
{
@ -148,6 +155,94 @@ class PolicyResource extends CRUDBase {
},
if: (form_data) => form_data.target_type === 'api_scope'
},
{
name: 'Target',
field: 'target_id',
required: true,
type: 'select.dynamic',
options: {
resource: 'ldap/Machine',
display: machine => `${machine.name}${machine.host_name ? ' (' + machine.host_name + ')' : ''}`,
value: 'id',
},
if: (form_data) => form_data.target_type === 'machine'
},
{
name: 'Target',
field: 'target_id',
required: true,
type: 'select.dynamic',
options: {
resource: 'ldap/MachineGroup',
display: group => `${group.name} (${(group.machine_ids || []).length} computers)`,
value: 'id',
},
if: (form_data) => form_data.target_type === 'machine_group'
},
{
name: 'Permission',
field: 'permission',
required: false,
type: 'select.dynamic',
options: {
resource: 'iam/Permission',
display: 'permission',
value: 'permission',
other_params: {
target_type: 'application',
include_unset: true,
},
},
if: (form_data, opts) => form_data.target_type === 'application' && opts?.length
},
{
name: 'Permission',
field: 'permission',
required: false,
type: 'select.dynamic',
options: {
resource: 'iam/Permission',
display: 'permission',
value: 'permission',
other_params: {
target_type: 'api_scope',
include_unset: true,
},
},
if: (form_data, opts) => form_data.target_type === 'api_scope' && opts?.length
},
{
name: 'Permission',
field: 'permission',
required: false,
type: 'select.dynamic',
options: {
resource: 'iam/Permission',
display: 'permission',
value: 'permission',
other_params: {
target_type: 'machine',
include_unset: true,
},
},
if: (form_data, opts) => form_data.target_type === 'machine' && opts?.length
},
{
name: 'Permission',
field: 'permission',
required: false,
type: 'select.dynamic',
options: {
resource: 'iam/Permission',
display: 'permission',
value: 'permission',
other_params: {
target_type: 'machine_group',
include_unset: true,
},
},
if: (form_data, opts) => form_data.target_type === 'machine_group' && opts?.length
},
],
/*handlers: {
insert: {

View File

@ -0,0 +1,108 @@
import CRUDBase from '../CRUDBase.js'
class MachineResource extends CRUDBase {
constructor() {
super()
this.endpoint = '/api/v1/ldap/machines'
this.required_fields = ['name', 'description']
this.permission_base = 'v1:ldap:machines'
this.item = 'Computer'
this.plural = 'Computers'
this.listing_definition = {
columns: [
{
name: 'Machine Name',
field: 'name',
},
{
name: 'Host Name',
field: 'host_name',
},
{
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,
},
],
}
this.form_definition = {
// back_action: {
// text: 'Back',
// action: 'back',
// },
fields: [
{
name: 'Machine Name',
field: 'name',
placeholder: 'DNS01',
required: true,
type: 'text',
},
{
name: 'Description',
field: 'description',
required: true,
type: 'textarea',
},
{
name: 'Location',
field: 'location',
type: 'text',
placeholder: 'Server room 1',
},
{
name: 'Host Name (FQDN)',
field: 'host_name',
type: 'text',
placeholder: 'dns01.my.domain',
},
{
name: 'IAM Target',
field: 'id',
type: 'text',
readonly: true,
hidden: ['insert'],
help: `(LDAP use) Allows restricting users to only those that can access this computer. (filter: iamTarget)`,
},
{
name: 'IAM Filter',
field: 'iam_filter',
type: 'text',
readonly: true,
hidden: ['insert'],
help: `(LDAP use) Use this filter to restrict access to only users granted IAM access to this computer.`,
},
],
}
}
}
const ldap_machine = new MachineResource()
export { ldap_machine }

View File

@ -0,0 +1,98 @@
import CRUDBase from '../CRUDBase.js'
class MachineGroupResource extends CRUDBase {
constructor() {
super()
this.endpoint = '/api/v1/ldap/machine-groups'
this.required_fields = ['name']
this.permission_base = 'v1:ldap:machine_groups'
this.item = 'Computer Group'
this.plural = 'Computer Groups'
this.listing_definition = {
columns: [
{
name: 'Group Name',
field: 'name',
},
{
name: '# Computers',
field: 'machine_ids',
renderer: machine_ids => Array.isArray(machine_ids) ? machine_ids.length : 0,
},
{
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,
},
],
}
this.form_definition = {
// back_action: {
// text: 'Back',
// action: 'back',
// },
fields: [
{
name: 'Group Name',
field: 'name',
placeholder: 'DNS Servers',
required: true,
type: 'text',
},
{
name: 'Description',
field: 'description',
type: 'textarea',
},
{
name: 'IAM Target',
field: 'id',
type: 'text',
readonly: true,
hidden: ['insert'],
help: `(LDAP use) Allows restricting users to only those that can access this computer group. (filter: iamTarget)`,
},
{
name: 'Computers',
field: 'machine_ids',
type: 'select.dynamic.multiple',
options: {
resource: 'ldap/Machine',
display: machine => `${machine.name}${machine.host_name ? ' (' + machine.host_name + ')' : ''}`,
value: 'id',
},
},
],
}
}
}
const ldap_machinegroup = new MachineGroupResource()
export { ldap_machinegroup }

View File

@ -0,0 +1,71 @@
import CRUDBase from '../CRUDBase.js'
import { session } from '../../service/Session.service.js';
class ClientResource extends CRUDBase {
constructor() {
super()
this.endpoint = '/api/v1/radius/clients'
this.required_fields = ['name']
this.permission_base = 'v1:radius:clients'
this.item = 'RADIUS Client'
this.plural = 'RADIUS Clients'
this.listing_definition = {
display: ``,
columns: [
{
name: 'Client Name',
field: 'name',
},
],
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,
},
],
}
this.form_definition = {
fields: [
{
name: 'Client Name',
field: 'name',
placeholder: 'Awesome External App',
required: true,
type: 'text',
},
{
name: 'Client Secret',
field: 'secret',
type: 'text',
readonly: true,
hidden: ['insert'],
},
],
}
}
}
const radius_client = new ClientResource()
export { radius_client }

View File

@ -1,5 +1,11 @@
import { location_service } from './Location.service.js'
import { resource_service } from './Resource.service.js'
import { event_bus } from './EventBus.service.js'
const pageMap = {
'dash.profile': '/dash/profile',
'app.setup': '/dash/app/setup',
}
class ActionService {
async perform({ text = '', action, ...args }) {
@ -7,21 +13,44 @@ class ActionService {
if ( args.next ) {
return location_service.redirect(args.next, args.delay || 0)
}
} else if ( action === 'navigate' ) {
if ( args.page && pageMap[args.page] ) {
window.history.pushState('pageNavigate', `Open ${args.page}`, pageMap[args.page])
return event_bus.event('root.navigate').fire(args)
}
} else if ( action === 'back' ) {
return location_service.back()
} else if ( args.type === 'resource' ) {
const { resource } = args
if ( action === 'insert' ) {
return location_service.redirect(`/dash/c/form/${resource}`, 0)
window.history.pushState('cobaltForm', `Insert ${resource}`, `/dash/c/form/${resource}`)
return event_bus.event('root.navigate').fire({
page: 'cobalt.form',
resource,
mode: 'insert',
})
} else if ( action === 'update' ) {
const { id } = args
return location_service.redirect(`/dash/c/form/${resource}?id=${id}`, 0)
window.history.pushState('cobaltForm', `Edit ${resource}`, `/dash/c/form/${resource}?id=${id}`)
return event_bus.event('root.navigate').fire({
page: 'cobalt.form',
resource,
mode: 'update',
form_id: id,
})
} else if ( action === 'delete' ) {
const { id } = args
const rsc = await resource_service.get(resource)
await rsc.delete(id)
} else if ( action === 'list' ) {
return location_service.redirect(`/dash/c/listing/${resource}`, 0)
window.history.pushState('cobaltListing', `View ${resource}`, `/dash/c/listing/${resource}`)
return event_bus.event('root.navigate').fire({
page: 'cobalt.listing',
resource,
})
}
} else if ( action === 'post' ) {
const inputs = []

View File

@ -1,3 +1,5 @@
import {message_service} from './Message.service.js'
class ProfileService {
async get_profile(user_id = 'me') {
@ -10,8 +12,11 @@ class ProfileService {
if ( results && results.data && results.data.data ) return results.data.data
}
async update_profile({ user_id, first_name, last_name, email, tagline = undefined }) {
await axios.patch(`/api/v1/profile/${user_id}`, { first_name, last_name, email, tagline })
async update_profile({ user_id, first_name, last_name, email, login_shell = undefined, tagline = undefined }) {
const results = await axios.patch(`/api/v1/profile/${user_id}`, { first_name, last_name, email, tagline, login_shell })
if ( results && results.data && results.data.data && results.data.data.force_message_refresh ) {
await message_service._listener_tick()
}
}
async update_notify({ user_id = 'me', app_key, gateway_url }) {

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="info-circle" class="svg-inline--fa fa-info-circle fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 8C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12v24z"></path></svg>

After

Width:  |  Height:  |  Size: 641 B

View File

@ -0,0 +1,55 @@
const User = require('../../models/auth/User.model')
const Client = require('../../models/radius/Client.model')
const Application = require('../../models/Application.model')
const Policy = require('../../models/iam/Policy.model')
/**
* @implements IAuthentication from radius-server
*/
class CoreIDAuthentication {
async authenticate(username, password, packet) {
// We only allow client-specific secrets to authenticate
if ( !packet || !packet.secret ) {
return false;
}
// Try to look up the client
const client = await Client.findOne({
active: true,
secret: packet.secret,
})
if ( !client ) {
return false;
}
// Try to look up the associated application
const application = await Application.findOne({
radius_client_ids: client.id,
})
if ( !application ) {
return false;
}
// Try to look up the user
/** @var {User} */
const user = await User.findByLogin(username)
if ( !user ) {
return false;
}
// Validate the incoming credential
if ( !(await user.check_credential_string(password)) ) {
return false;
}
// Don't allow login if the user has a trap set
if ( user.trap ) {
return false;
}
// Check the IAM policy engine to make sure the user can access this resource
return Policy.check_user_access(user, application.id)
}
}
module.exports = exports = CoreIDAuthentication

View File

@ -0,0 +1,28 @@
import radius from 'radius'
import { RadiusServer } from '@coreid/radius-server'
import RadiusClient from '../../models/radius/Client.model.js'
import CoreIDUserPasswordPacketHandler from './CoreIDUserPasswordPacketHandler.mjs'
export default class CoreIDRadiusServer extends RadiusServer {
// constructor(options) {
// super(options)
// this.packetHandler.packetHandlers.pop()
// this.packetHandler.packetHandlers.push(new CoreIDUserPasswordPacketHandler(options.authentication, this.logger))
// console.log(this.packetHandler.packetHandlers)
// }
async decodeMessage(msg) {
const clients = await RadiusClient.find({ active: true })
for ( const client of clients ) {
try {
const packet = radius.decode({ packet: msg, secret: client.secret })
packet.secret = client.secret
return packet
} catch (e) {
console.error(e)
}
}
throw new Error('Unable to determine client to decode RADIUS packet: is the client active?')
}
}

View File

@ -0,0 +1,40 @@
import { UserPasswordPacketHandler } from '@coreid/radius-server/dist/radius/handler/UserPasswordPacketHandler.js'
export default class CoreIDUserPasswordPacketHandler extends UserPasswordPacketHandler {
async handlePacket(packet) {
console.log('coreid user password packet handler handlePacket', packet)
const username = packet.attributes['User-Name'];
let password = packet.attributes['User-Password'];
if (Buffer.isBuffer(password) && password.indexOf(0x00) > 0) {
// check if there is a 0x00 in it, and trim it from there
password = password.slice(0, password.indexOf(0x00));
}
if (!username || !password) {
// params missing, this handler cannot continue...
return {};
}
this.logger.debug('username', username, username.toString());
this.logger.debug('token', password, password.toString());
console.log('client', packet.__coreid_client)
const authenticated = await this.authentication.authenticate(
username.toString(),
password.toString()
);
if (authenticated) {
// success
return {
code: 'Access-Accept',
attributes: [['User-Name', username]],
};
}
// Failed
return {
code: 'Access-Reject',
};
}
}

View File

@ -105,7 +105,7 @@ class OpenIDController extends Controller {
const Client = this.models.get('openid:Client')
const client = await Client.findById(req.params.id)
if ( !client || !client.active )
if ( !client )
return res.status(404)
.message(req.T('api.client_not_found'))
.api()
@ -153,6 +153,12 @@ class OpenIDController extends Controller {
})
}
// If the user has already authorized this app, just redirect
if ( req.user.has_authorized({ id: application.id }) ) {
return res.redirect(`/openid/interaction/${uid.toLowerCase()}/grant`)
}
// Otherwise, prompt them for authorization
return res.page('public:message', {
...this.Vue.data({
message: `<h3 class="font-weight-light">Authorize ${application.name}?</h3>
@ -170,6 +176,11 @@ class OpenIDController extends Controller {
{
text: req.T('common.grant'),
action: 'redirect',
next: `/openid/grant-and-save/${application.id}/${uid.toLowerCase()}`,
},
{
text: req.T('common.grant_once'),
action: 'redirect',
next: `/openid/interaction/${uid.toLowerCase()}/grant`,
},
],
@ -177,6 +188,19 @@ class OpenIDController extends Controller {
})
}
async grant_and_save(req, res, next) {
if ( !req.user.has_authorized({ id: req.params.app_id }) ) {
req.user.authorize({
id: req.params.app_id,
api_scopes: ['openid-connect'],
})
await req.user.save()
}
return res.redirect(`/openid/interaction/${req.params.uid.toLowerCase()}/grant`)
}
async login(req, res, { uid, prompt, params, session }) {
return res.redirect(`/openid/interaction/${uid.toLowerCase()}/start-session`)
}

View File

@ -115,6 +115,28 @@ class AppController extends Controller {
application.oauth_client_ids = oauth_client_ids
}
// Verify RADIUS client IDs
const RadiusClient = this.models.get('radius:Client')
if ( req.body.radius_client_ids ) {
const parsed = typeof req.body.radius_client_ids === 'string' ? this.utility.infer(req.body.radius_client_ids) : req.body.radius_client_ids
const radius_client_ids = Array.isArray(parsed) ? parsed : [parsed]
for ( const id of radius_client_ids ) {
const client = await RadiusClient.findById(id)
if ( !client || !client.active || !req.user.can(`radius:client:${client.id}:view`) )
return res.status(400)
.message(`${req.T('api.invalid_radius_client_id')} ${id}`)
.api()
const other_assoc_app = await Application.findOne({ radius_client_ids: client.id })
if ( other_assoc_app )
return res.status(400) // TODO translate this
.message(`The RADIUS client ${client.name} is already associated with an existing application (${other_assoc_app.name}).`)
.api()
}
application.radius_client_ids = radius_client_ids
}
// Verify OpenID client IDs
const OpenIDClient = this.models.get('openid:Client')
if ( req.body.openid_client_ids ) {
@ -242,6 +264,28 @@ class AppController extends Controller {
application.oauth_client_ids = oauth_client_ids
} else application.oauth_client_ids = []
// Verify OAuth client IDs
const RadiusClient = this.models.get('radius:Client')
if ( req.body.radius_client_ids ) {
const parsed = typeof req.body.radius_client_ids === 'string' ? this.utility.infer(req.body.radius_client_ids) : req.body.radius_client_ids
const radius_client_ids = Array.isArray(parsed) ? parsed : [parsed]
for ( const id of radius_client_ids ) {
const client = await RadiusClient.findById(id)
if ( !client || !client.active || !req.user.can(`radius:client:${client.id}:view`) )
return res.status(400)
.message(`${req.T('api.invalid_radius_client_id')} ${id}`)
.api()
const other_assoc_app = await Application.findOne({ radius_client_ids: client.id })
if ( other_assoc_app && other_assoc_app.id !== application.id )
return res.status(400) // TODO translate this
.message(`The RADIUS client ${client.name} is already associated with an existing application (${other_assoc_app.name}).`)
.api()
}
application.radius_client_ids = radius_client_ids
} else application.radius_client_ids = []
// Verify OpenID client IDs
const OpenIDClient = this.models.get('openid:Client')
if ( req.body.openid_client_ids ) {

View File

@ -91,6 +91,7 @@ class AuthController extends Controller {
if ( !(await User.findOne()) ) user.promote('root')
await user.save()
await user.grant_defaults()
// Log in the user automatically
await this.auth.get_provider().session(req, user)
@ -219,6 +220,48 @@ class AuthController extends Controller {
return res.api(await user.to_api())
}
async get_user_flat(req, res, next) {
if ( req.params.id === 'me' )
return res.json(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(req.T('api.user_not_found'))
.api()
if ( !req.user.can(`auth:user:${user.id}:view`) )
return res.status(401)
.message(req.T('api.insufficient_permissions'))
.api()
return res.json(await user.to_api())
}
async get_user_photo(req, res, next) {
let user
if ( req.params.id === 'me' ) {
user = req.user
} else {
const User = this.models.get('auth:User')
user = await User.findOne({ uid: req.params.id })
}
if ( !user )
return res.status(404)
.message(req.T('api.user_not_found'))
.api()
const file = await user.photo()
if ( !file )
// The user does not have a profile. Send the default.
return res.sendFile(this.utility.path('app/assets/people.png'))
await file.send(res)
}
async create_group(req, res, next) {
if ( !req.user.can(`auth:group:create`) )
return res.status(401)
@ -239,7 +282,10 @@ class AuthController extends Controller {
.message(req.T('api.group_already_exists'))
.api()
const group = new Group({ name: req.body.name })
const group = new Group({
name: req.body.name,
grants_sudo: !!req.body.grants_sudo,
})
// Validate user ids
const User = this.models.get('auth:User')
@ -258,6 +304,7 @@ class AuthController extends Controller {
}
await group.save()
await group.get_gid_number()
return res.api(await group.to_api())
}
@ -317,6 +364,7 @@ class AuthController extends Controller {
await user.reset_password(req.body.password, 'create')
await user.save()
await user.grant_defaults()
return res.api(await user.to_api())
}
@ -365,7 +413,10 @@ class AuthController extends Controller {
}
group.name = req.body.name
group.grants_sudo = !!req.body.grants_sudo
await group.save()
await group.get_gid_number()
return res.api()
}

View File

@ -13,7 +13,7 @@ class IAMController extends Controller {
.message(`${req.T('api.missing_field', true)} entity_id, target_id`)
.api()
return res.api(await Policy.check_entity_access(req.body.entity_id, req.body.target_id))
return res.api(await Policy.check_entity_access(req.body.entity_id, req.body.target_id, req.body.permission || undefined))
}
async check_user_access(req, res, next) {
@ -39,7 +39,7 @@ class IAMController extends Controller {
.message(req.T('api.insufficient_permissions'))
.api()
return res.api(await Policy.check_user_access(user, req.body.target_id))
return res.api(await Policy.check_user_access(user, req.body.target_id, req.body.permission || undefined))
}
async get_policies(req, res, next) {
@ -56,6 +56,33 @@ class IAMController extends Controller {
return res.api(data)
}
async get_permissions(req, res, next) {
const Permission = this.models.get('iam:Permission')
const permissions = await Permission.find({
active: true,
...(req.query.target_type ? {
target_type: req.query.target_type,
} : {})
})
const data = []
for ( const perm of permissions ) {
if ( req.user.can(`iam:permission:${perm.target_type}:view`) ) {
data.push(await perm.to_api())
}
}
if ( req.query.include_unset ) {
data.reverse().push({
permission: '',
})
data.reverse()
}
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)
@ -73,6 +100,23 @@ class IAMController extends Controller {
return res.api(await policy.to_api())
}
async get_permission(req, res, next) {
const Permission = this.models.get('iam:Permission')
const permission = await Permission.findById(req.params.id)
if ( !permission )
return res.status(404)
.message(req.T('iam.permission_not_found'))
.api()
if ( !req.user.can(`iam:permission:${permission.target_type}:view`) )
return res.status(401)
.message(req.T('api.insufficient_permissions'))
.api()
return res.api(await permission.to_api())
}
async create_policy(req, res, next) {
const Policy = this.models.get('iam:Policy')
@ -108,12 +152,12 @@ class IAMController extends Controller {
if ( !['allow', 'deny'].includes(req.body.access_type) )
return res.status(400)
.message(`${req.T('common.invalid')} access_type. ${req.T('api:must_one')} allow, deny.`)
.message(`${req.T('common.invalid')} access_type. ${req.T('api.must_one')} allow, deny.`)
.api()
if ( !['application', 'api_scope'].includes(req.body.target_type) )
if ( !['application', 'api_scope', 'machine', 'machine_group'].includes(req.body.target_type) )
return res.status(400)
.message(`${req.T('common.invalid')} target_type. ${req.T('api:must_one')} application, api_scope.`)
.message(`${req.T('common.invalid')} target_type. ${req.T('api.must_one')} application, api_scope, machine, machine_group.`)
.api()
// Make sure the target_id is valid
@ -130,6 +174,20 @@ class IAMController extends Controller {
return res.status(400)
.message(`${req.T('common.invalid')} target_id.`)
.api()
} else if ( req.body.target_type === 'machine' ) {
const Machine = this.models.get('ldap:Machine')
const machine = await Machine.findById(req.body.target_id)
if ( !machine || !machine.active || !req.user.can(`ldap:machine:${machine.id}:view`) )
return res.status(400)
.message(`${req.T('common.invalid')} target_id.`)
.api()
} else if ( req.body.target_type === 'machine_group' ) {
const MachineGroup = this.models.get('ldap:MachineGroup')
const group = await MachineGroup.findById(req.body.target_id)
if ( !group || !group.active || !req.user.can(`ldap:machine_group:${group.id}:view`) )
return res.status(400)
.message(`${req.T('common.invalid')} target_id.`)
.api()
}
const policy = new Policy({
@ -140,12 +198,71 @@ class IAMController extends Controller {
target_id: req.body.target_id,
})
if ( req.body.permission ) {
// Validate the permission and set it, if it is valid
const Permission = this.models.get('iam:Permission')
const permission = await Permission.findOne({
active: true,
target_type: req.body.target_type,
permission: req.body.permission,
})
if ( permission ) {
policy.for_permission = true
policy.permission = req.body.permission
}
}
await policy.save()
req.user.allow(`iam:policy:${policy.id}`)
await req.user.save()
return res.api(await policy.to_api())
}
async create_permission(req, res, next) {
const Permission = this.models.get('iam:Permission')
const required_fields = ['target_type', 'permission']
for ( const field of required_fields ) {
if ( !req.body[field] )
return res.status(400)
.message(`${req.T('api.missing_field')} ${field}`)
.api()
}
const valid_target_types = ['application', 'api_scope', 'machine', 'machine_group']
if ( !valid_target_types.includes(req.body.target_type) ) {
return res.status(400)
.message(`${req.T('api.invalid_target_type')}`)
.api()
}
if ( !req.user.can(`iam:permission${req.body.target_type}:create`) ) {
return res.status(401).api()
}
// Make sure one doesn't already exist
const existing = await Permission.findOne({
active: true,
target_type: req.body.target_type,
permission: req.body.permission,
})
if ( existing ) {
return res.status(400)
.message(req.T('api.permission_already_exists'))
.api()
}
const perm = new Permission({
target_type: req.body.target_type,
permission: req.body.permission,
})
await perm.save()
return res.api(await perm.to_api())
}
async update_policy(req, res, next) {
const Policy = this.models.get('iam:Policy')
const policy = await Policy.findById(req.params.id)
@ -195,9 +312,9 @@ class IAMController extends Controller {
.message(`${req.T('common.invalid')} access_type. ${req.T('api.must_one')} allow, deny.`)
.api()
if ( !['application', 'api_scope'].includes(req.body.target_type) )
if ( !['application', 'api_scope', 'machine', 'machine_group'].includes(req.body.target_type) )
return res.status(400)
.message(`${req.T('common.invalid')} target_type. ${req.T('api.must_one')} application, api_scope.`)
.message(`${req.T('common.invalid')} target_type. ${req.T('api.must_one')} application, api_scope, machine, machine_group.`)
.api()
// Make sure the target_id is valid
@ -214,6 +331,20 @@ class IAMController extends Controller {
return res.status(400)
.message(`${req.T('common.invalid')} target_id.`)
.api()
} else if ( req.body.target_type === 'machine' ) {
const Machine = this.models.get('ldap:Machine')
const machine = await Machine.findById(req.body.target_id)
if ( !machine || !machine.active || !req.user.can(`ldap:machine:${machine.id}:view`) )
return res.status(400)
.message(`${req.T('common.invalid')} target_id.`)
.api()
} else if ( req.body.target_type === 'machine_group' ) {
const MachineGroup = this.models.get('ldap:MachineGroup')
const group = await MachineGroup.findById(req.body.target_id)
if ( !group || !group.active || !req.user.can(`ldap:machine_group:${group.id}:view`) )
return res.status(400)
.message(`${req.T('common.invalid')} target_id.`)
.api()
}
policy.entity_type = req.body.entity_type
@ -221,10 +352,69 @@ class IAMController extends Controller {
policy.access_type = req.body.access_type
policy.target_type = req.body.target_type
policy.target_id = req.body.target_id
if ( req.body.permission ) {
// Validate the permission and set it, if it is valid
const Permission = this.models.get('iam:Permission')
const permission = await Permission.findOne({
active: true,
target_type: req.body.target_type,
permission: req.body.permission,
})
if ( permission ) {
policy.for_permission = true
policy.permission = req.body.permission
} else {
policy.for_permission = false
policy.permission = undefined
}
} else {
policy.for_permission = false
policy.permission = undefined
}
await policy.save()
return res.api()
}
async update_permission(req, res, next) {
const Permission = this.models.get('iam:Permission')
const required_fields = ['target_type', 'permission']
for ( const field of required_fields ) {
if ( !req.body[field] )
return res.status(400)
.message(`${req.T('api.missing_field')} ${field}`)
.api()
}
const valid_target_types = ['application', 'api_scope', 'machine', 'machine_group']
if ( !valid_target_types.includes(req.body.target_type) ) {
return res.status(400)
.message(`${req.T('api.invalid_target_type')}`)
.api()
}
if ( !req.user.can(`iam:permission${req.body.target_type}:update`) ) {
return res.status(401).api()
}
// Make sure one doesn't already exist
const existing = await Permission.findById(req.params.id)
if ( !existing?.active ) {
return res.status(404)
.message(req.T('api.permission_not_found'))
.api()
}
existing.target_type = req.body.target_type
existing.permission = req.body.permission
await existing.save()
return res.api(await existing.to_api())
}
async delete_policy(req, res, next) {
const Policy = this.models.get('iam:Policy')
const policy = await Policy.findById(req.params.id)
@ -243,6 +433,27 @@ class IAMController extends Controller {
await policy.save()
return res.api()
}
async delete_permission(req, res, next) {
const Permission = this.models.get('iam:Permission')
const permission = await Permission.findById(req.params.id)
if ( !permission?.active ) {
return res.status(404)
.message(req.T('api.permission_not_found'))
.api()
}
if ( !req.user.can(`iam:permission:${permission.target_type}:delete`) ) {
return res.status(401)
.message(req.T('api.insufficient_permissions'))
.api()
}
permission.active = false
await permission.save()
return res.api()
}
}
module.exports = exports = IAMController

View File

@ -46,6 +46,32 @@ class LDAPController extends Controller {
return res.api(data)
}
async get_machines(req, res, next) {
const Machine = this.models.get('ldap:Machine')
const machines = await Machine.find({active: true})
const data = []
for ( const machine of machines ) {
if ( !req.user.can(`ldap:machine:${machine.id}:view`) ) continue
data.push(await machine.to_api())
}
return res.api(data)
}
async get_machine_groups(req, res, next) {
const MachineGroup = this.models.get('ldap:MachineGroup')
const groups = await MachineGroup.find({active: true})
const data = []
for ( const group of groups ) {
if ( !req.user.can(`ldap:machine_group:${group.id}:view`) ) continue
data.push(await group.to_api())
}
return res.api(data)
}
async get_client(req, res, next) {
const Client = this.models.get('ldap:Client')
const client = await Client.findById(req.params.id)
@ -80,6 +106,40 @@ class LDAPController extends Controller {
return res.api(await group.to_api())
}
async get_machine(req, res, next) {
const Machine = this.models.get('ldap:Machine')
const machine = await Machine.findById(req.params.id)
if ( !machine || !machine.active )
return res.status(404)
.message(req.T('api.machine_not_found'))
.api()
if ( !req.user.can(`ldap:machine:${machine.id}:view`) )
return res.status(401)
.message(req.T('api.insufficient_permissions'))
.api()
return res.api(await machine.to_api())
}
async get_machine_group(req, res, next) {
const MachineGroup = this.models.get('ldap:MachineGroup')
const group = await MachineGroup.findById(req.params.id)
if ( !group || !group.active )
return res.status(404)
.message(req.T('api.group_not_found'))
.api()
if ( !req.user.can(`ldap:machine_group:${group.id}:view`) )
return res.status(401)
.message(req.T('api.insufficient_permissions'))
.api()
return res.api(await group.to_api())
}
async create_client(req, res, next) {
if ( !req.user.can('ldap:client:create') )
return res.status(401)
@ -121,13 +181,89 @@ class LDAPController extends Controller {
return res.api(await client.to_api())
}
async create_group(req, res, next) {
console.log(req.body)
if ( !req.user.can(`ldap:group:create`) )
return res.status(401)
.message(req.T('api.insufficient_permissions'))
async create_machine(req, res, next) {
// validate inputs
const required_fields = ['name', 'description']
for ( const field of required_fields ) {
if ( !req.body[field] )
return res.status(400)
.message(`${req.T('api.missing_field')} ${field}`)
.api()
}
// Make sure the machine name is free
const Machine = this.models.get('ldap:Machine')
const existing_machine = await Machine.findOne({ name: req.body.name })
if ( existing_machine )
return res.status(400)
.message(req.T('api.machine_already_exists'))
.api()
const machine = new Machine({
name: req.body.name,
description: req.body.description,
host_name: req.body.host_name,
location: req.body.location,
})
if ( req.body.bind_password ) {
await machine.set_bind_password(req.body.bind_password)
}
if ( 'ldap_visible' in req.body ) {
machine.ldap_visible = !!req.body.ldap_visible
}
await machine.save()
return res.api(await machine.to_api())
}
async create_machine_group(req, res, next) {
// validate inputs
const required_fields = ['name']
for ( const field of required_fields ) {
if ( !req.body[field] )
return res.status(400)
.message(`${req.T('api.missing_field')} ${field}`)
.api()
}
// Make sure the machine name is free
const MachineGroup = this.models.get('ldap:MachineGroup')
const existing_group = await MachineGroup.findOne({ name: req.body.name })
if ( existing_group )
return res.status(400)
.message(req.T('api.group_already_exists'))
.api()
const group = new MachineGroup({
name: req.body.name,
description: req.body.description,
})
if ( 'ldap_visible' in req.body ) {
group.ldap_visible = !!req.body.ldap_visible
}
const Machine = this.models.get('ldap:Machine')
const machine_ids = Array.isArray(req.body.machine_ids) ? req.body.machine_ids : []
group.machine_ids = []
for ( const potential of machine_ids ) {
const machine = await Machine.findOne({
_id: Machine.to_object_id(potential),
active: true,
})
if ( machine ) {
group.machine_ids.push(potential)
}
}
await group.save()
return res.api(await group.to_api())
}
async create_group(req, res, next) {
// validate inputs
const required_fields = ['role', 'name']
for ( const field of required_fields ) {
@ -240,6 +376,106 @@ class LDAPController extends Controller {
return res.api()
}
async update_machine(req, res, next) {
const Machine = this.models.get('ldap:Machine')
const machine = await Machine.findById(req.params.id)
if ( !machine || !machine.active )
return res.status(404)
.message(req.T('api.machine_not_found'))
.api()
if ( !req.user.can(`ldap:machine:${machine.id}:update`) )
return res.status(401)
.message(req.T('api.insufficient_permissions'))
.api()
const required_fields = ['name', 'description']
for ( const field of required_fields ) {
if ( !req.body[field] )
return res.status(400)
.message(`${req.T('api.missing_field')} ${field}`)
.api()
}
// Make sure the machine name is free
const existing_machine = await Machine.findOne({ name: req.body.name })
if ( existing_machine && existing_machine.id !== machine.id )
return res.status(400)
.message(req.T('api.machine_already_exists'))
.api()
machine.name = req.body.name
machine.description = req.body.description
machine.host_name = req.body.host_name
machine.location = req.body.location
if ( req.body.bind_password ) {
await machine.set_bind_password(req.body.bind_password)
}
if ( 'ldap_visible' in req.body ) {
machine.ldap_visible = !!req.body.ldap_visible
}
await machine.save()
return res.api(await machine.to_api())
}
async update_machine_group(req, res, next) {
const MachineGroup = this.models.get('ldap:MachineGroup')
const group = await MachineGroup.findById(req.params.id)
if ( !group || !group.active )
return res.status(404)
.message(req.T('api.group_not_found'))
.api()
if ( !req.user.can(`ldap:machine_group:${group.id}:update`) )
return res.status(401)
.message(req.T('api.insufficient_permissions'))
.api()
const required_fields = ['name']
for ( const field of required_fields ) {
if ( !req.body[field] )
return res.status(400)
.message(`${req.T('api.missing_field')} ${field}`)
.api()
}
// Make sure the machine name is free
const existing_group = await MachineGroup.findOne({ name: req.body.name })
if ( existing_group && existing_group.id !== group.id )
return res.status(400)
.message(req.T('api.group_already_exists'))
.api()
group.name = req.body.name
group.description = req.body.description
if ( 'ldap_visible' in req.body ) {
group.ldap_visible = !!req.body.ldap_visible
}
const Machine = this.models.get('ldap:Machine')
const machine_ids = Array.isArray(req.body.machine_ids) ? req.body.machine_ids : []
group.machine_ids = []
for ( const potential of machine_ids ) {
const machine = await Machine.findOne({
_id: Machine.to_object_id(potential),
active: true,
})
if ( machine ) {
group.machine_ids.push(potential)
}
}
await group.save()
return res.api(await group.to_api())
}
async update_group(req, res, next) {
const User = await this.models.get('auth:User')
const Group = await this.models.get('ldap:Group')
@ -337,6 +573,44 @@ class LDAPController extends Controller {
await group.save()
return res.api()
}
async delete_machine(req, res, next) {
const Machine = this.models.get('ldap:Machine')
const machine = await Machine.findById(req.params.id)
if ( !machine || !machine.active )
return res.status(404)
.message(req.T('api.machine_not_found'))
.api()
if ( !req.user.can(`ldap:machine:${machine.id}:delete`) )
return res.status(401)
.message(req.T('api.insufficient_permissions'))
.api()
machine.active = false
await machine.save()
return res.api()
}
async delete_machine_group(req, res, next) {
const MachineGroup = this.models.get('ldap:MachineGroup')
const group = await MachineGroup.findById(req.params.id)
if ( !group || !group.active )
return res.status(404)
.message(req.T('api.group_not_found'))
.api()
if ( !req.user.can(`ldap:machine_group:${group.id}:delete`) )
return res.status(401)
.message(req.T('api.insufficient_permissions'))
.api()
group.active = false
await group.save()
return res.api()
}
}
module.exports = exports = LDAPController

View File

@ -20,6 +20,7 @@ class PasswordController extends Controller {
return {
created: x.created,
expires: x.expires,
accessed: x.accessed,
active: x.active,
name: x.name ?? req.T('common.unnamed'),
uuid: x.uuid,
@ -90,6 +91,10 @@ class PasswordController extends Controller {
await this.activity.password_reset({ req, ip: req.ip })
if ( req.trap.has_trap() && req.trap.get_trap() === 'password_reset' ) await req.trap.end()
if ( req.session.registrant_flow ) {
await req.trap.begin('registrant_flow', { session_only: true })
}
// invalidate existing tokens and other logins
await req.user.logout(req)
await req.user.kickout()

View File

@ -24,8 +24,9 @@ class ProfileController extends Controller {
last_name: user.last_name,
email: user.email,
uid: user.uid,
tagline: user.tagline,
tagline: user.tagline || '',
user_id: user.id,
login_shell: user.login_shell || '',
...(user.notify_config ? { notify_config: await user.notify_config.to_api() } : {})
})
}
@ -123,6 +124,8 @@ class ProfileController extends Controller {
async update(req, res, next) {
const User = this.models.get('auth:User')
const Message = this.models.get('Message')
const Setting = this.models.get('Setting')
let user
if ( req.params.user_id === 'me' ) user = req.user
@ -154,14 +157,22 @@ class ProfileController extends Controller {
.api()
// Update the user's profile
if ( user.email !== req.body.email && (await Setting.get('auth.require_email_verify')) ) {
await req.trap.begin('verify_email', { session_only: false })
await Message.create(req.user, 'Your e-mail address has changed, and a verification e-mail has been sent. You must complete this process to continue.')
}
user.first_name = req.body.first_name
user.last_name = req.body.last_name
user.email = req.body.email
user.tagline = req.body.tagline
user.login_shell = req.body.login_shell
// Save the record
await user.save()
return res.api()
return res.api({
force_message_refresh: true,
})
}
async update_photo(req, res, next) {

View File

@ -0,0 +1,195 @@
const { Controller } = require('libflitter')
class RadiusController extends Controller {
static get services() {
return [...super.services, 'models', 'output']
}
async attempt(req, res, next) {
const User = this.models.get('auth:User')
const Client = this.models.get('radius:Client')
this.output.debug('RADIUS attempt:')
this.output.debug(req.body)
if ( !req.body.username || !req.body.password ) {
this.output.error('RADIUS error: missing username or password')
return this.fail(res)
}
const parts = String(req.body.username).split('@')
parts.reverse()
const clientId = parts.shift()
parts.reverse()
const username = parts.join('@')
const password = String(req.body.password).replace(/\0/g, '')
this.output.debug(`clientId: ${clientId}, username: ${username}, password: ${password}`)
const user = await User.findOne({ uid: username, active: true })
if ( !user ) {
this.output.error(`RADIUS error: invalid username: ${username}`)
return this.fail(res)
}
const client = await Client.findById(clientId)
if ( !client || !client.active ) {
this.output.error(`RADIUS error: invalid client: ${clientId}`)
return this.fail(res)
}
// Check if the credentials are an app_password
const app_password_verified = Array.isArray(user.app_passwords)
&& user.app_passwords.length > 0
&& await user.check_app_password(password)
// Check if the user has MFA enabled.
// If so, split the incoming password to fetch the MFA code
// e.g. normalPassword:123456
if ( !app_password_verified && user.mfa_enabled ) {
const parts = password.split(':')
const mfa_code = parts.pop()
const actual_password = parts.join(':')
// Check the credentials
if ( !(await user.check_password(actual_password)) ) {
this.output.debug(`RADIUS error: user w/ MFA provided invalid credentials`)
return this.fail(res)
}
// Now, check the MFA code
if ( !user.mfa_token.verify(mfa_code) ) {
this.output.debug(`RADIUS error: user w/ MFA provided invalid MFA token`)
return this.fail(res)
}
// If not MFA, just check the credentials
} else if (!app_password_verified && !await user.check_password(password)) {
this.output.debug(`RADIUS error: user w/ simple auth provided invalid credentials`)
return this.fail(res)
}
// Check if the user has any login interrupt traps set
if ( user.trap ) {
this.output.error(`RADIUS error: user has trap: ${user.trap}`)
return this.fail(res)
}
// 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
const associated_app = await client.application()
if ( associated_app ) {
const Policy = this.models.get('iam:Policy')
const can_access = await Policy.check_user_access(user, associated_app.id)
if ( !can_access ) {
this.output.error(`RADIUS error: user denied IAM access`)
return this.fail(res)
}
}
this.output.info(`Authenticated RADIUS user: ${user.uid} to IAM ${associated_app.name}`)
return res.api({ success: true })
}
fail(res) {
return res.status(401).api({ success: false })
}
async get_clients(req, res, next) {
const Client = this.models.get('radius:Client')
const clients = await Client.find({ active: true })
const data = []
for ( const client of clients ) {
if ( req.user.can(`radius: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('radius:Client')
const client = await Client.findById(req.params.id)
if ( !client || !client.active )
return res.status(404)
.message(req.T('api.client_not_found'))
.api()
if ( !req.user.can(`radius:client:${client.id}:view`) )
return res.status(401)
.message(req.T('api.insufficient_permissions'))
.api()
return res.api(await client.to_api())
}
async create_client(req, res, next) {
if ( !req.user.can('radius:client:create') )
return res.status(401)
.message(req.T('api.insufficient_permissions'))
.api()
if ( !req.body.name )
return res.status(400)
.message(`${req.T('api.missing_field')} name`)
.api()
const Client = this.models.get('radius:Client')
const client = new Client({
name: req.body.name,
})
await client.save()
return res.api(await client.to_api())
}
async update_client(req, res, next) {
const Client = this.models.get('radius:Client')
const client = await Client.findById(req.params.id)
if ( !client || !client.active )
return res.status(404)
.message(req.T('api.client_not_found'))
.api()
if ( !req.user.can(`radius:client:${client.id}:update`) )
return res.status(401)
.message(req.T('api.insufficient_permissions'))
.api()
if ( !req.body.name )
return res.status(400)
.message(`${req.T('api.missing_field')} name`)
.api()
client.name = req.body.name
await client.save()
return res.api()
}
async delete_client(req, res, next) {
const Client = this.models.get('radius:Client')
const client = await Client.findById(req.params.id)
if ( !client || !client.active )
return res.status(404)
.message(req.T('api.client_not_found'))
.api()
if ( !req.user.can(`radius:client:${client.id}:delete`) )
return res.status(401)
.message(req.T('api.insufficient_permissions'))
.api()
client.active = false
await client.save()
return res.api()
}
}
module.exports = exports = RadiusController

View File

@ -7,15 +7,69 @@ const FormController = require('flitter-auth/controllers/Forms')
*/
class Forms extends FormController {
static get services() {
return [...super.services, 'Vue', 'models']
return [...super.services, 'Vue', 'models', 'jobs']
}
async registration_provider_get(req, res, next) {
if ( req.session.auth.flow ) {
req.session.registrant_flow = req.session.auth.flow
}
return res.page('auth:register', {
...this.Vue.data({})
})
}
async email_verify_keyaction(req, res, next) {
if ( !req.trap.has_trap('verify_email') ) return res.redirect(req.session.email_verify_flow || '/dash/profile')
req.user.email_verified = true
await req.user.save()
await req.trap.end()
const url = req.session.email_verify_flow || '/dash/profile'
return res.redirect(url)
}
async show_verify_email(req, res, next) {
if ( !req.trap.has_trap('verify_email') ) return res.redirect(req.session.email_verify_flow || '/dash/profile')
const verify_queue = this.jobs.queue('verifications')
await verify_queue.add('SendVerificationEmail', { user_id: req.user.id })
return res.page('public:message', {
...this.Vue.data({
message: req.T('auth.must_verify_email'),
actions: [
{
text: 'Send Verification E-Mail',
action: 'redirect',
next: '/auth/verify-email/sent',
},
],
})
})
}
async send_verify_email(req, res, next) {
if ( !req.trap.has_trap('verify_email') ) return res.redirect(req.session.email_verify_flow || '/dash/profile')
return res.page('public:message', {
...this.Vue.data({
message: req.T('auth.verify_email_sent'),
actions: [
{
text: 'Re-send Verification E-Mail',
action: 'redirect',
next: '/auth/verify-email/sent',
},
],
})
})
}
async finish_registration(req, res, next) {
if ( req.trap.has_trap() && req.trap.get_trap() === 'registrant_flow' ) await req.trap.end()
const dest = req.session.registrant_flow || '/dash/profile'
return res.redirect(dest)
}
async login_provider_get(req, res, next) {
const Setting = this.models.get('Setting')

View File

@ -23,13 +23,13 @@ class Oauth2 extends Oauth2Controller {
const Policy = this.models.get('iam:Policy')
const application = await Application.findOne({ oauth_client_ids: starship_client.id })
if ( !application ) {
this.output.warn('IAM Denial!')
this.output.warn(`IAM Denial: OAuth client not associated with an application: ${starship_client.id}`)
return this.Vue.auth_message(res, {
message: req.T('saml.no_access').replace('APP_NAME', application.name),
next_destination: '/dash',
})
} else if ( !(await Policy.check_user_access(req.user, application.id)) ) {
this.output.warn('IAM Denial!')
this.output.warn(`IAM Denial: User ${req.user.uid} not authorized to access application: ${application.id}`)
return this.Vue.auth_message(res, {
message: req.T('saml.no_access').replace('APP_NAME', application.name),
next_destination: '/dash',
@ -44,7 +44,7 @@ class Oauth2 extends Oauth2Controller {
async authorize_get(req, res, next) {
const client = await this._get_authorize_client(req)
if ( !client ) return this._uniform(res, req.T('auth.unable_to_authorize'))
const uri = new URL(req.query.redirect_uri)
const uri = new URL(Array.isArray(req.query.redirect_uri) ? req.query.redirect_uri[0] : req.query.redirect_uri)
const StarshipClient = this.models.get('oauth:Client')
const starship_client = await StarshipClient.findOne({ active: true, uuid: client.clientID })
@ -54,19 +54,25 @@ class Oauth2 extends Oauth2Controller {
const Policy = this.models.get('iam:Policy')
const application = await Application.findOne({ oauth_client_ids: starship_client.id })
if ( !application ) {
this.output.warn('IAM Denial!')
this.output.warn(`IAM Denial: OAuth client not associated with an application: ${starship_client.id}`)
return this.Vue.auth_message(res, {
message: req.T('saml.no_access').replace('APP_NAME', application.name),
next_destination: '/dash',
})
} else if ( !(await Policy.check_user_access(req.user, application.id)) ) {
this.output.warn('IAM Denial!')
this.output.warn(`IAM Denial: User ${req.user.uid} not authorized to access application: ${application.id}`)
return this.Vue.auth_message(res, {
message: req.T('saml.no_access').replace('APP_NAME', application.name),
next_destination: '/dash',
})
}
let state;
if ( state = (req.query.state || req.body.state) ) {
state = Array.isArray(state) ? state[0] : state
uri.searchParams.set('state', state)
}
if ( req.user.has_authorized(starship_client) ) {
return this.Vue.invoke_action(res, {
text: 'Grant Access',

View File

@ -12,7 +12,7 @@ class EMailJob extends Job {
const { data } = job
let { from = config.default_sender, to, subject, html = undefined, email_params = undefined } = data
this.output.info(`Sending mail to ${to}...`)
this.info(`Sending mail to ${to}...`)
if ( !html && email_params ) html = this.email(email_params)
@ -20,9 +20,11 @@ class EMailJob extends Job {
from, to, subject, html,
})
} catch (e) {
this.output.error(e)
this.error(e)
throw e
}
this.output.success(`Mail sent!`)
this.success(`Mail sent!`)
}
email({ header_text, body_paragraphs = [], button_text = '', button_link = '' }) {

View File

@ -12,7 +12,7 @@ class ForeignIPLoginAlertJob extends Job {
const user = await User.findById(user_id)
if ( !user ) throw new Error('Unable to find user with ID: '+user_id)
this.output.info('Sending foreign IP login alert to user.')
this.info('Sending foreign IP login alert to user ' + user.uid)
await this.jobs.queue('mailer').add('EMail', {
to: user.email,
@ -29,14 +29,19 @@ class ForeignIPLoginAlertJob extends Job {
}
})
this.info('Logged e-mail job')
if ( user.notify_config && user.notify_config.active ) {
await user.notify_config.log({
title: `${this.configs.get('app.name')}: Sign-In From New IP`,
message: `Someone signed into your account (${user.uid}) from the IP address ${ip}. If this was you, no further action is required.`,
})
this.info('Logged push notification job')
}
} catch (e) {
this.output.error(e)
this.error(e)
throw e
}
}
}

View File

@ -13,13 +13,17 @@ class PasswordResetJob extends Job {
const User = this.models.get('auth:User')
const user = await User.findById(user_id)
if (!user) {
this.output.error(`Unable to find user with ID: ${user_id}`)
this.error(`Unable to find user with ID: ${user_id}`)
throw new Error('Unable to find user with that ID.')
}
this.output.info(`Resetting password for user: ${user.uid}`)
this.info(`Resetting password for user: ${user.uid}`)
// Create an authenticated key-action
const key_action = await this.key_action(user)
this.info(`Created reset keyaction ${key_action.id} (key: ${key_action.key}, handler: ${key_action.handler})`)
await this.jobs.queue('mailer').add('EMail', {
to: user.email,
subject: 'Reset Your Password | ' + this.configs.get('app.name'),
@ -34,17 +38,22 @@ class PasswordResetJob extends Job {
}
})
this.info('Logged e-mail job.')
if ( user.notify_config && user.notify_config.active ) {
await user.notify_config.log({
title: `${this.configs.get('app.name')}: Password Reset Requested`,
message: `A password reset request was logged for your account (${user.uid}). If this was you, please check your e-mail for further instructions.`,
priority: 8,
})
this.info('Logged security push notification job')
}
this.output.success('Password reset logged.')
this.success('Password reset logged.')
} catch (e) {
this.output.error(e)
this.error(e)
throw e
}
}

View File

@ -14,7 +14,7 @@ class PasswordResetAlertJob extends Job {
const user = await User.findById(user_id)
if ( !user ) throw new Error('Unable to find user with ID: '+user_id)
this.output.info('Sending password reset alert to user.')
this.info('Sending password reset alert to user ' + user.uid)
await this.jobs.queue('mailer').add('EMail', {
to: user.email,
@ -28,15 +28,20 @@ class PasswordResetAlertJob extends Job {
},
})
this.info('Logged e-mail job')
if ( user.notify_config && user.notify_config.active ) {
await user.notify_config.log({
title: `${this.configs.get('app.name')}: Password Reset`,
message: `The password to your account (${user.uid}) was reset from the IP address ${ip}. If this was not you, please contact your system administrator.`,
priority: 8,
})
this.info('Logged push notification job')
}
} catch (e) {
this.output.error(e)
this.error(e)
throw e
}
}
}

View File

@ -14,14 +14,15 @@ class PopulateAnnouncementJob extends Job {
const announcement = await Announcement.findById(announcement_id)
if ( !announcement ) {
this.output.error(`Unable to find announcement with ID: ${announcement_id}`)
this.error(`Unable to find announcement with ID: ${announcement_id}`)
throw new Error('Unable to find announcement with that ID.')
}
await announcement.populate()
this.output.success('Populated announcements.')
this.success('Populated announcements.')
} catch (e) {
this.output.error(e)
this.error(e)
throw e
}
}
}

View File

@ -18,13 +18,15 @@ class PushNotifyJob extends Job {
const notify = user.notify_config
if ( !notify || !notify.active ) throw new Error('User does not have notifications configured.')
this.output.info(`Sending notification to ${user.uid}...`)
this.info(`Sending notification to ${user.uid}...`)
await notify.send({ title, message, priority })
} catch (e) {
this.output.error(e)
this.error(e)
throw e
}
this.output.success(`Notification sent!`)
this.success(`Notification sent!`)
}
}

View File

@ -0,0 +1,62 @@
const { Job } = require('flitter-jobs')
class SendVerificationEmailJob extends Job {
static get services() {
return [...super.services, 'models', 'jobs', 'output', 'configs']
}
async execute(job) {
const {data} = job
const {user_id} = data
try {
const User = this.models.get('auth:User')
const user = await User.findById(user_id)
if (!user) {
this.error(`Unable to find user with ID: ${user_id}`)
throw new Error('Unable to find user with that ID.')
}
this.info(`Sending verification email for user: ${user.uid}`)
// Create an authenticated key-action
const key_action = await this.key_action(user)
this.info(`Created verification keyaction ${key_action.id} (key: ${key_action.key}, handler: ${key_action.handler})`)
await this.jobs.queue('mailer').add('EMail', {
to: user.email,
subject: 'Confirm Your E-mail | ' + this.configs.get('app.name'),
email_params: {
header_text: 'Confirm Your E-mail',
body_paragraphs: [
'The e-mail address for your ' + this.configs.get('app.name') + ' was set or changed. Click the link below to verify this change.',
'If you didn\'t request this e-mail, please contact your system administrator.',
],
button_text: 'Confirm E-mail',
button_link: key_action.url(),
}
})
this.info('Logged e-mail job.')
} catch (e) {
this.error(e)
throw e
}
}
async key_action(user) {
const KeyAction = this.models.get('auth:KeyAction')
const ka_data = {
handler: 'controller::auth:Forms.email_verify_keyaction',
used: false,
user_id: user._id,
auto_login: true,
no_auto_logout: false,
}
return (new KeyAction(ka_data)).save()
}
}
module.exports = exports = SendVerificationEmailJob

View File

@ -1,4 +1,5 @@
const LDAPController = require('./LDAPController')
const LDAP = require('ldapjs')
class GroupsController extends LDAPController {
static get services() {

View File

@ -59,34 +59,8 @@ class LDAPController extends Injectable {
return next(new LDAP.InsufficientAccessRightsError())
}
// Check if the credentials are an app_password
const app_password_verified = Array.isArray(item.app_passwords)
&& item.app_passwords.length > 0
&& await item.check_app_password(req.credentials)
// Check if the user has MFA enabled.
// If so, split the incoming password to fetch the MFA code
// e.g. normalPassword:123456
if ( !app_password_verified && item.mfa_enabled ) {
const parts = req.credentials.split(':')
const mfa_code = parts.pop()
const actual_password = parts.join(':')
// Check the credentials
if ( !await item.check_password(actual_password) ) {
this.output.debug(`Bind failure: user w/ MFA provided invalid credentials`)
return next(new LDAP.InvalidCredentialsError('Invalid credentials. Make sure MFA code is included at the end of your password (e.g. password:123456)'))
}
// Now, check the MFA code
if ( !item.mfa_token.verify(mfa_code) ) {
this.output.debug(`Bind failure: user w/ MFA provided invalid MFA token`)
return next(new LDAP.InvalidCredentialsError('Invalid credentials. Verification of the MFA token failed.'))
}
// If not MFA, just check the credentials
} else if (!app_password_verified && !await item.check_password(req.credentials)) {
this.output.debug(`Bind failure: user w/ simple auth provided invalid credentials`)
// Check if the credentials are valid
if ( !(await item.check_credential_string(req.credentials)) ) {
return next(new LDAP.InvalidCredentialsError())
}

View File

@ -0,0 +1,146 @@
const LDAPController = require('./LDAPController')
const LDAP = require('ldapjs')
class SudoController extends LDAPController {
static get services() {
return [
...super.services,
'output',
'ldap_server',
'models',
'configs',
'auth'
]
}
constructor() {
super()
this.Group = this.models.get('auth:Group')
this.User = this.models.get('auth:User')
}
// TODO flitter-orm chunk query
// TODO generalize scoped search logic
async search_sudo(req, res, next) {
if ( !req.user.can('ldap:search:sudo') ) {
return next(new LDAP.InsufficientAccessRightsError())
}
const sudo_hosts = this.parse_sudo_hosts(req.filter)
const iam_targets = await this.get_targets_from_hosts(sudo_hosts)
if ( req.scope === 'base' ) {
// If scope is base, check if the base DN matches the filter.
// If so, return it. Else, return empty.
this.output.debug(`Running base DN search for sudo with DN: ${req.dn.format(this.configs.get('ldap:server.format'))}`)
const user = await this.get_resource_from_dn(req.dn)
// Make sure the user is ldap visible && match the filter
if ( user && user.ldap_visible && req.filter.matches(await user.to_sudo(iam_targets)) ) {
// If so, send the object
res.send({
dn: user.sudo_dn.format(this.configs.get('ldap:server.format')),
attributes: await user.to_sudo(iam_targets),
})
}
} else if ( req.scope === 'one' ) {
// If scope is one, find all entries that are the immediate
// subordinates of the base DN that match the filter.
this.output.debug(`Running one DN search for sudo with DN: ${req.dn.format(this.configs.get('ldap:server.format'))}`)
// Fetch the LDAP-visible users
const users = await this.Group.sudo_directory()
for ( const user of users ) {
// Make sure the user os of the appropriate scope
if ( req.dn.equals(user.sudo_dn) || user.sudo_dn.parent().equals(req.dn) ) {
// Check if the filter matches
if ( req.filter.matches(await user.to_sudo(iam_targets)) ) {
// If so, send the object
res.send({
dn: user.sudo_dn.format(this.configs.get('ldap:server.format')),
attributes: await user.to_sudo(iam_targets),
})
}
}
}
} else if ( req.scope === 'sub' ) {
// If scope is sub, find all entries that are subordinates
// of the base DN at any level and match the filter.
this.output.debug(`Running sub DN search for sudo with DN: ${req.dn.format(this.configs.get('ldap:server.format'))}`)
// Fetch the users as LDAP objects
const users = await this.Group.sudo_directory()
for ( const user of users ) {
// Make sure the user is of appropriate scope
if ( req.dn.equals(user.sudo_dn) || req.dn.parentOf(user.sudo_dn) ) {
// Check if filter matches
if ( req.filter.matches(await user.to_sudo(iam_targets)) ) {
// If so, send the object
res.send({
dn: user.sudo_dn.format(this.configs.get('ldap:server.format')),
attributes: await user.to_sudo(iam_targets),
})
}
}
}
} else {
this.output.error(`Attempted to perform LDAP search with invalid scope: ${req.scope}`)
return next(new LDAP.OtherError('Attempted to perform LDAP search with invalid scope.'))
}
res.end()
return next()
}
parse_sudo_hosts(filter, target_hosts = []) {
if ( Array.isArray(filter?.filters) ) {
for ( const sub_filter of filter.filters ) {
target_hosts = [...target_hosts, ...this.parse_sudo_hosts(sub_filter)]
}
} else if ( filter?.attribute ) {
if ( filter.attribute === 'sudohost' ) {
target_hosts.push(filter.value)
}
}
return target_hosts.filter(Boolean)
}
async get_targets_from_hosts(sudo_hosts) {
const Machine = this.models.get('ldap:Machine')
const machines = await Machine.find({
active: true,
ldap_visible: true,
host_name: {
$in: sudo_hosts.filter(x => x.toLowerCase() !== 'all' && x.indexOf('*') < 0),
}
})
return machines.map(x => x.id)
}
get_cn_from_dn(dn) {
try {
if ( typeof dn === 'string' ) dn = LDAP.parseDN(dn)
return dn.rdns[0].attrs.cn.value
} catch (e) { console.log('Error parsing CN from DN', e) }
}
async get_resource_from_dn(sudo_dn) {
const cn = this.get_cn_from_dn(sudo_dn)
if ( cn ) {
return this.User.findOne({uid: cn.substr(5), ldap_visible: true})
}
}
}
module.exports = exports = SudoController

View File

@ -0,0 +1,28 @@
const sudo_routes = {
prefix: false, // false | string
middleware: [
'Logger'
],
search: {
'ou=sudo': [
'ldap_middleware::BindUser',
'ldap_controller::Sudo.search_sudo',
],
},
bind: {},
add: {},
del: {},
modify: {},
compare: {},
}
module.exports = exports = sudo_routes

View File

@ -11,6 +11,7 @@ class ApplicationModel extends Model {
ldap_client_ids: [String],
oauth_client_ids: [String],
openid_client_ids: [String],
radius_client_ids: [String],
}
}
@ -24,6 +25,7 @@ class ApplicationModel extends Model {
ldap_client_ids: this.ldap_client_ids,
oauth_client_ids: this.oauth_client_ids,
openid_client_ids: this.openid_client_ids,
radius_client_ids: this.radius_client_ids || [],
}
}
}

View File

@ -7,6 +7,7 @@ class AppPasswordModel extends Model {
return {
hash: String,
created: { type: Date, default: () => new Date },
accessed: Date,
expires: Date,
active: { type: Boolean, default: true },
name: String,

View File

@ -11,6 +11,9 @@ class GroupModel extends Model {
return {
name: String,
user_ids: [String],
posix_user_id: String,
posix_group_id: Number,
grants_sudo: { type: Boolean, default: false },
active: { type: Boolean, default: true },
ldap_visible: { type: Boolean, default: true },
}
@ -29,18 +32,72 @@ class GroupModel extends Model {
return await User.find({ _id: { $in: this.user_ids.map(x => this.constructor.to_object_id(x)) } })
}
async get_gid_number() {
if ( !this.posix_group_id ) {
const Setting = this.models.get('Setting')
let last_uid = await Setting.get('ldap.last_alloc_uid')
if ( last_uid < 1 ) {
last_uid = this.configs.get('ldap:server.schema.start_uid')
}
this.posix_group_id = last_uid + 1
await Setting.set('ldap.last_alloc_uid', this.posix_group_id)
await this.save()
}
return this.posix_group_id
}
async to_ldap() {
const users = await this.users()
return {
cn: this.name,
dn: this.dn.format(this.configs.get('ldap:server.format')),
objectClass: 'groupOfNames',
objectClass: ['groupOfNames', 'posixGroup'],
gidNumber: String(await this.get_gid_number()),
member: users.map(x => x.dn.format(this.configs.get('ldap:server.format'))),
}
}
static async sudo_directory() {
const groups = await this.find({ ldap_visible: true, active: true, grants_sudo: true })
let users = []
for ( const group of groups ) {
users = [...users, ...(await group.users())]
}
return users.filter(u => u.uid !== 'root')
}
static async ldap_directory() {
return this.find({ ldap_visible: true, active: true })
const User = this.prototype.models.get('auth:User')
const groups = await this.find({ ldap_visible: true, active: true })
const posix_user_ids = groups.map(group => group.posix_user_id)
.filter(Boolean)
.map(id => User.to_object_id(id))
const missing_posix_users = await User.find({
ldap_visible: true,
_id: {
$nin: posix_user_ids
}
})
for ( const user of missing_posix_users ) {
const group = new this({
name: `${user.uid} (posix)`,
user_ids: [user.id],
posix_user_id: user.id,
posix_group_id: await user.get_uid_number(),
})
await group.save()
groups.push(group)
}
return groups
}
async to_api() {
@ -49,6 +106,7 @@ class GroupModel extends Model {
name: this.name,
user_ids: this.user_ids,
ldap_visible: this.ldap_visible,
grants_sudo: !!this.grants_sudo,
}
}
}

View File

@ -26,6 +26,7 @@ class User extends AuthUser {
last_name: String,
tagline: String,
email: String,
email_verified: {type: Boolean, default: false},
ldap_visible: {type: Boolean, default: true},
active: {type: Boolean, default: true},
mfa_token: MFAToken,
@ -38,9 +39,42 @@ class User extends AuthUser {
photo_file_id: String,
trap: String,
notify_config: NotifyConfig,
uid_number: Number,
login_shell: String,
is_default_user_for_coreid: { type: Boolean, default: false },
}}
}
async grant_defaults() {
const default_user = await this.constructor.findOne({is_default_user_for_coreid: true, active: true})
this.login_shell = default_user.login_shell
this.roles = default_user.roles
this.permissions = default_user.permissions
const groups = await default_user.groups()
for ( const group of groups ) {
group.user_ids.push(this.id)
await group.save()
}
}
async get_uid_number() {
if ( !this.uid_number ) {
const Setting = this.models.get('Setting')
let last_uid = await Setting.get('ldap.last_alloc_uid')
if ( last_uid < 1 ) {
last_uid = this.configs.get('ldap:server.schema.start_uid')
}
this.uid_number = last_uid + 1
await Setting.set('ldap.last_alloc_uid', this.uid_number)
await this.save()
}
return this.uid_number
}
async photo() {
const File = this.models.get('upload::File')
return File.findById(this.photo_file_id)
@ -77,10 +111,12 @@ class User extends AuthUser {
uid: this.uid,
first_name: this.first_name,
last_name: this.last_name,
name: `${this.first_name} ${this.last_name}`,
email: this.email,
tagline: this.tagline,
trap: this.trap,
group_ids: (await this.groups()).map(x => x.id),
profile_photo: `${this.configs.get('app.url')}api/v1/auth/users/${this.uid}/photo`,
}
}
@ -119,13 +155,49 @@ class User extends AuthUser {
await this.save()
}
async check_credential_string(credential) {
// Check if the credentials are an app_password
const app_password_verified = Array.isArray(this.app_passwords)
&& this.app_passwords.length > 0
&& await this.check_app_password(credential)
// Check if the user has MFA enabled.
// If so, split the incoming password to fetch the MFA code
// e.g. normalPassword:123456
if ( !app_password_verified && this.mfa_enabled ) {
const parts = credential.split(':')
const mfa_code = parts.pop()
const actual_password = parts.join(':')
// Check the credentials
if ( !await this.check_password(actual_password) ) {
return false
}
// Now, check the MFA code
if ( !this.mfa_token.verify(mfa_code) ) {
return false
}
// If not MFA, just check the credentials
} else if (!app_password_verified && !await this.check_password(credential)) {
return false
}
return true
}
async check_password(password) {
return this.get_provider().check_user_auth(this, password)
}
async check_app_password(password) {
for ( const pw of this.app_passwords ) {
if ( await pw.verify(password) ) return true
if ( await pw.verify(password) ) {
pw.accessed = new Date
await pw.save()
return true
}
}
return false
@ -169,9 +241,45 @@ class User extends AuthUser {
this.get_provider().logout(request)
}
async has_sudo() {
const groups = await this.groups()
return groups.some(group => group.grants_sudo)
}
async to_sudo(iam_targets = []) {
const Policy = this.models.get('iam:Policy')
const granted = []
for ( const target of iam_targets ) {
if ( await Policy.check_user_access(this, target, 'sudo') ) {
granted.push(target)
}
}
return {
objectClass: ['sudoRole'],
cn: `sudo_${this.uid.toLowerCase()}`,
sudoUser: this.uid.toLowerCase(),
...(granted.length ? {
iamtarget: granted,
sudoHost: 'ALL',
sudoRunAs: 'ALL',
sudoCommand: 'ALL',
} : {})
}
}
async to_ldap(iam_targets = []) {
const Policy = this.models.get('iam:Policy')
const uid_number = await this.get_uid_number()
const shell = this.login_shell || this.configs.get('ldap:server.schema.default_shell')
const domain = this.configs.get('ldap:server.schema.base_dc').split(',').map(x => x.replace('dc=', '')).join('.')
const group_ids = []
for ( const group of await this.groups() ) {
group_ids.push(await group.get_gid_number())
}
const ldap_data = {
uid: this.uid.toLowerCase(),
uuid: this.uuid,
@ -179,10 +287,16 @@ class User extends AuthUser {
sn: this.last_name,
gecos: `${this.first_name} ${this.last_name}`,
mail: this.email,
objectClass: ['inetOrgPerson', 'person'],
objectClass: ['inetOrgPerson', 'person', 'posixaccount'],
objectclass: ['inetOrgPerson', 'person', 'posixaccount'],
entryuuid: this.uuid,
entryUUID: this.uuid,
objectGuid: this.uuid,
objectguid: this.uuid,
uidNumber: uid_number,
gidNumber: String(await this.get_uid_number()), // group_ids.map(x => String(x)),
loginShell: shell,
homeDirectory: `/home/${this.uid}@${domain}`
}
if ( this.tagline ) ldap_data.extras_tagline = this.tagline
@ -216,6 +330,10 @@ class User extends AuthUser {
return LDAP.parseDN(`uid=${this.uid.toLowerCase()},${this.ldap_server.auth_dn().format(this.configs.get('ldap:server.format'))}`)
}
get sudo_dn() {
return LDAP.parseDN(`cn=sudo_${this.uid.toLowerCase()},${this.ldap_server.sudo_dn().format(this.configs.get('ldap:server.format'))}`)
}
// The following are used by OpenID connect
async claims(use, scope) {

View File

@ -0,0 +1,23 @@
const { Model } = require('flitter-orm')
class PermissionModel extends Model {
static get schema() {
return {
active: { type: Boolean, default: true },
target_type: String,
permission: String
}
}
async to_api() {
return {
_id: this.id,
id: this.id,
active: this.active,
target_type: this.target_type,
permission: this.permission,
}
}
}
module.exports = exports = PermissionModel

View File

@ -12,39 +12,49 @@ class PolicyModel extends Model {
entity_type: String, // user | group
entity_id: String,
access_type: String, // allow | deny
target_type: { type: String, default: 'application' }, // application | api_scope
target_type: { type: String, default: 'application' }, // application | api_scope | machine | machine_group
target_id: String,
active: { type: Boolean, default: true },
for_permission: { type: Boolean, default: false },
permission: String,
}
}
static async check_allow(entity_id, target_id) {
static async check_allow(entity_id, target_id, permission = undefined) {
const policies = await this.find({
entity_id,
target_id,
access_type: 'allow',
active: true,
...(permission ? {
for_permission: true,
permission,
} : {})
})
return policies.length > 0
}
static async check_deny(entity_id, target_id) {
static async check_deny(entity_id, target_id, permission = undefined) {
const policies = await this.find({
entity_id,
target_id,
access_type: 'deny',
active: true,
...(permission ? {
for_permission: true,
permission,
} : {})
})
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_entity_access(entity_id, target_id, permission = undefined) {
return (await this.check_allow(entity_id, target_id, permission)) && !(await this.check_deny(entity_id, target_id, permission))
}
static async check_user_denied(user, target_id) {
static async check_user_denied(user, target_id, permission = undefined) {
const groups = await user.groups()
const group_ids = groups.map(x => x.id)
@ -53,6 +63,10 @@ class PolicyModel extends Model {
target_id,
access_type: 'deny',
active: true,
...(permission ? {
for_permission: true,
permission,
} : {})
})
const group_denials = await this.find({
@ -60,41 +74,92 @@ class PolicyModel extends Model {
target_id,
access_type: 'deny',
active: true,
...(permission ? {
for_permission: true,
permission,
} : {})
})
return user_denials.length > 0 || group_denials.length > 0
}
static async check_user_access(user, target_id) {
static async get_all_related(target_id) {
const all = [target_id]
const Machine = this.prototype.models.get('ldap:Machine')
const MachineGroup = this.prototype.models.get('ldap:MachineGroup')
const machine = await Machine.findById(target_id)
if ( machine?.active ) {
const groups = await MachineGroup.find({
active: true,
machine_ids: machine.id,
})
groups.map(x => all.push(x.id))
}
const group = await MachineGroup.findById(target_id)
if ( group?.active ) {
const machines = await Machine.find({
active: true,
_id: {
$in: group.machine_ids.map(x => Machine.to_object_id(x)),
}
})
machines.map(x => all.push(x.id))
}
return all
}
static async check_user_access(user, target_id, permission = undefined) {
const groups = await user.groups()
const group_ids = groups.map(x => x.id)
const target_ids = await this.get_all_related(target_id)
const user_approvals = await this.find({
entity_id: user.id,
target_id,
target_id: { $in: target_ids },
access_type: 'allow',
active: true,
...(permission ? {
for_permission: true,
permission,
} : {})
})
const user_denials = await this.find({
entity_id: user.id,
target_id,
target_id: { $in: target_ids },
access_type: 'deny',
active: true,
...(permission ? {
for_permission: true,
permission,
} : {})
})
const group_approvals = await this.find({
entity_id: { $in: group_ids },
target_id,
target_id: { $in: target_ids },
access_type: 'allow',
active: true,
...(permission ? {
for_permission: true,
permission,
} : {})
})
const group_denials = await this.find({
entity_id: { $in: group_ids },
target_id,
target_id: { $in: target_ids },
access_type: 'deny',
active: true,
...(permission ? {
for_permission: true,
permission,
} : {})
})
// IF user has explicit denial, deny
@ -132,6 +197,18 @@ class PolicyModel extends Model {
target_display = `Application: ${app.name}`
} else if ( this.target_type === 'api_scope' ) {
target_display = `API Scope: ${this.target_id}`
} else if ( this.target_type === 'machine' ) {
const Machine = this.models.get('ldap:Machine')
const machine = await Machine.findById(this.target_id)
target_display = `Computer: ${machine.name}`
if ( machine.host_name ) {
target_display += ` (${machine.host_name})`
}
} else if ( this.target_type === 'machine_group' ) {
const MachineGroup = this.models.get('ldap:MachineGroup')
const group = await MachineGroup.findById(this.target_id)
target_display = `Computer Group: ${group.name} (${group.machine_ids.length} computers)`
}
return {
@ -143,6 +220,8 @@ class PolicyModel extends Model {
target_display,
target_type: this.target_type,
target_id: this.target_id,
for_permission: this.for_permission,
permission: this.permission,
}
}
}

View File

@ -0,0 +1,73 @@
const { Model } = require('flitter-orm')
const LDAP = require('ldapjs')
const bcrypt = require('bcrypt')
class MachineModel extends Model {
static get services() {
return [...super.services, 'models', 'ldap_server', 'configs']
}
static get schema() {
return {
name: String,
bind_password: String,
description: String,
host_name: String,
location: String,
active: { type: Boolean, default: true },
ldap_visible: { type: Boolean, default: true },
}
}
async to_api() {
return {
id: this.id,
name: this.name,
description: this.description,
host_name: this.host_name,
location: this.location,
ldap_visible: this.ldap_visible,
iam_filter: `(|(iamTarget=${this.id}))`,
}
}
async groups() {
const MachineGroup = this.models.get('ldap:MachineGroup')
return MachineGroup.find({
machine_ids: this.id,
active: true
})
}
async set_bind_password(password) {
this.bind_password = await bcrypt.hash(password, 10)
return this
}
async check_bind_password(password) {
return await bcrypt.compare(password, this.bind_password)
}
get dn() {
return LDAP.parseDN(`cn=${this.name},${this.ldap_server.machine_dn().format(this.configs.get('ldap:server.format'))}`)
}
async to_ldap() {
const data = {
cn: this.name,
dn: this.dn.format(this.configs.get('ldap:server.format')),
name: this.name,
id: this.id,
objectClass: ['computer'],
description: this.description,
dNSHostName: this.host_name,
location: this.location,
primaryGroupID: 515, // compat with AD
sAMAccountType: 805306369, // compat with AD
}
return data;
}
}
module.exports = exports = MachineModel

View File

@ -0,0 +1,47 @@
const { Model } = require('flitter-orm')
const uuid = require('uuid').v4
const LDAP = require('ldapjs')
class MachineGroupModel extends Model {
static get services() {
return [...super.services, 'models', 'ldap_server', 'configs']
}
static get schema() {
return {
name: String,
description: String,
UUID: { type: String, default: uuid },
active: { type: Boolean, default: true },
machine_ids: [String],
ldap_visible: { type: Boolean, default: true },
}
}
async to_api() {
return {
id: this.id,
name: this.name,
description: this.description || '',
UUID: this.UUID,
machine_ids: this.machine_ids,
ldap_visible: this.ldap_visible,
}
}
get dn() {
return LDAP.parseDN(`cn=${this.name},${this.ldap_server.machine_group_dn().format(this.configs.get('ldap:server.format'))}`)
}
async to_ldap() {
return {
cn: this.name,
dn: this.dn.format(this.configs.get('ldap:server.format')),
id: this.id,
uuid: this.UUID,
description: this.description,
}
}
}
module.exports = exports = MachineGroupModel

View File

@ -0,0 +1,32 @@
const { Model } = require('flitter-orm')
const {v4: uuid} = require("uuid");
class Client extends Model {
static get services() {
return [...super.services, 'models']
}
static get schema() {
return {
name: String,
secret: {type: String, default: uuid},
active: {type: Boolean, default: true},
}
}
async application() {
const Application = this.models.get('Application')
return Application.findOne({ active: true, radius_client_ids: this.id })
}
async to_api() {
return {
id: this.id,
name: this.name,
secret: this.secret,
active: this.active,
}
}
}
module.exports = exports = Client

View File

@ -17,7 +17,9 @@ class SAMLRequestMiddleware extends Middleware {
// Verify that the issuer is known
const sp = await ServiceProvider.findOne({entity_id: data.issuer, active: true})
if (!sp)
return res.error(401, 'Unable to continue. The SAML issuer is unknown.')
return res.error(401, {
message: 'Unable to continue. The SAML issuer is unknown.'
})
req.saml_request = {
relay_state: req.query.RelayState || req.body.RelayState,

View File

@ -58,7 +58,29 @@ class TrapUtility {
allows(route) {
const config = this.config()
return route.startsWith('/assets') || config.allowed_routes.includes(route.toLowerCase().trim())
const allowed = route.startsWith('/assets') || config.allowed_routes.includes(route.toLowerCase().trim())
if ( allowed ) return true
for ( const allowed_route of config.allowed_routes ) {
console.log('comparing', allowed_route, 'to', route)
const allowed_parts = allowed_route.split('/')
const parts = route.split('/')
let matches = true
for ( let i = 0; i < allowed_parts.length; i += 1 ) {
if ( allowed_parts[i] !== parts[i] && allowed_parts[i] !== '*' ) {
matches = false
}
}
if ( matches ) {
console.log('allows true')
return true
}
}
console.log('allows false')
return false
}
}
@ -68,8 +90,19 @@ class TrapsMiddleware extends Middleware {
}
async test(req, res, next, args = {}) {
const Setting = this.models.get('Setting')
req.trap = new TrapUtility(req, res, this.configs.get('traps.types'))
if (
!req.trap.has_trap()
&& req.user
&& !req.user.email_verified
&& (await Setting.get('auth.require_email_verify'))
) {
req.session.email_verify_flow = req.originalUrl
await req.trap.begin('verify_email', { session_only: false })
}
if ( !req.trap.has_trap() ) return next()
else if ( req.trap.allows(req.path) ) return next()
else return req.trap.redirect()

View File

@ -36,6 +36,14 @@ const auth_routes = {
['middleware::api:Permission', { check: 'v1:auth:users:get' }],
'controller::api:v1:Auth.get_user',
],
'/users/:id/flat': [
'middleware::auth:APIRoute',
['middleware::api:Permission', { check: 'v1:auth:users:get' }],
'controller::api:v1:Auth.get_user_flat',
],
'/users/:id/photo': [
'controller::api:v1:Auth.get_user_photo',
],
'/groups/:id': [
'middleware::auth:APIRoute',
['middleware::api:Permission', { check: 'v1:auth:groups:get' }],

View File

@ -14,6 +14,14 @@ const iam_routes = {
['middleware::api:Permission', { check: 'v1:iam:policy:get' }],
'controller::api:v1:IAM.get_policy',
],
'/permission': [
['middleware::api:Permission', { check: 'v1:iam:permission:list' }],
'controller::api:v1:IAM.get_permissions',
],
'/permission/:id': [
['middleware::api:Permission', { check: 'v1:iam:permission:get' }],
'controller::api:v1:IAM.get_permission',
],
},
post: {
@ -21,6 +29,10 @@ const iam_routes = {
['middleware::api:Permission', { check: 'v1:iam:policy:create' }],
'controller::api:v1:IAM.create_policy',
],
'/permission': [
['middleware::api:Permission', { check: 'v1:iam:permission:create' }],
'controller::api:v1:IAM.create_permission',
],
'/check_entity_access': [
['middleware::api:Permission', { check: 'v1:iam:check_entity_access' }],
'controller::api:v1:IAM.check_entity_access',
@ -36,6 +48,10 @@ const iam_routes = {
['middleware::api:Permission', { check: 'v1:iam:policy:update' }],
'controller::api:v1:IAM.update_policy',
],
'/permission/:id': [
['middleware::api:Permission', { check: 'v1:iam:permission:update' }],
'controller::api:v1:IAM.update_permission',
],
},
delete: {
@ -43,6 +59,10 @@ const iam_routes = {
['middleware::api:Permission', { check: 'v1:iam:policy:delete' }],
'controller::api:v1:IAM.delete_policy',
],
'/permission/:id': [
['middleware::api:Permission', { check: 'v1:iam:permission:delete' }],
'controller::api:v1:IAM.delete_permission',
],
},
}

View File

@ -22,6 +22,22 @@ const ldap_routes = {
['middleware::api:Permission', { check: 'v1:ldap:groups:get' }],
'controller::api:v1:LDAP.get_group',
],
'/machines': [
['middleware::api:Permission', { check: 'v1:ldap:machines:list' }],
'controller::api:v1:LDAP.get_machines',
],
'/machines/:id': [
['middleware::api:Permission', { check: 'v1:ldap:machines:get' }],
'controller::api:v1:LDAP.get_machine',
],
'/machine-groups': [
['middleware::api:Permission', { check: 'v1:ldap:machine_groups:list' }],
'controller::api:v1:LDAP.get_machine_groups',
],
'/machine-groups/:id': [
['middleware::api:Permission', { check: 'v1:ldap:machine_groups:get' }],
'controller::api:v1:LDAP.get_machine_group',
],
'/config': [
['middleware::api:Permission', { check: 'v1:ldap:config:get' }],
'controller::api:v1:LDAP.get_config',
@ -37,6 +53,14 @@ const ldap_routes = {
['middleware::api:Permission', { check: 'v1:ldap:groups:create' }],
'controller::api:v1:LDAP.create_group',
],
'/machines': [
['middleware::api:Permission', { check: 'v1:ldap:machines:create' }],
'controller::api:v1:LDAP.create_machine',
],
'/machine-groups': [
['middleware::api:Permission', { check: 'v1:ldap:machine_groups:create' }],
'controller::api:v1:LDAP.create_machine_group',
],
},
patch: {
@ -48,6 +72,14 @@ const ldap_routes = {
['middleware::api:Permission', { check: 'v1:ldap:groups:update' }],
'controller::api:v1:LDAP.update_group',
],
'/machines/:id': [
['middleware::api:Permission', { check: 'v1:ldap:machines:update' }],
'controller::api:v1:LDAP.update_machine',
],
'/machine-groups/:id': [
['middleware::api:Permission', { check: 'v1:ldap:machine_groups:update' }],
'controller::api:v1:LDAP.update_machine_group',
],
},
delete: {
@ -59,6 +91,14 @@ const ldap_routes = {
['middleware::api:Permission', { check: 'v1:ldap:groups:delete' }],
'controller::api:v1:LDAP.delete_group',
],
'/machines/:id': [
['middleware::api:Permission', { check: 'v1:ldap:machines:delete' }],
'controller::api:v1:LDAP.delete_machine',
],
'/machine-groups/:id': [
['middleware::api:Permission', { check: 'v1:ldap:machine_groups:delete' }],
'controller::api:v1:LDAP.delete_machine_group',
],
},
}

View File

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

View File

@ -67,6 +67,21 @@ const index = {
'controller::auth:Forms.logout_provider_present_success',
],
'/finish-registration': [
'middleware::auth:UserOnly',
'controller::auth:Forms.finish_registration',
],
'/verify-email': [
'middleware::auth:UserOnly',
'controller::auth:Forms.show_verify_email',
],
'/verify-email/sent': [
'middleware::auth:UserOnly',
'controller::auth:Forms.send_verify_email',
],
'/login-message': [
'middleware::auth:UserOnly',
'controller::api:v1:System.show_login_message',

View File

@ -7,6 +7,9 @@ const openid = {
],
get: {
'/grant-and-save/:app_id/:uid': [
'middleware::auth:UserOnly', 'controller::OpenID.grant_and_save',
],
'/interaction/:uid': [
'controller::OpenID.handle_interaction',
],

View File

@ -2,6 +2,7 @@ const Unit = require('libflitter/Unit')
const LDAP = require('ldapjs')
const Validator = require('email-validator')
const net = require('net')
const fs = require('fs')
// TODO support logging ALL ldap requests when in DEBUG, not just routed ones
// TODO need to support LDAP server auto-discovery/detection features
@ -36,6 +37,18 @@ class LDAPServerUnit extends Unit {
return this.build_dn(this.config.schema.group_base)
}
machine_dn() {
return this.build_dn(this.config.schema.machine_base)
}
machine_group_dn() {
return this.build_dn(this.config.schema.machine_group_base)
}
sudo_dn() {
return this.build_dn(this.config.schema.sudo_base)
}
/**
* Get the anonymous DN.
* @returns {ldap/DN}
@ -77,7 +90,11 @@ class LDAPServerUnit extends Unit {
// If Flitter is configured to use an SSL certificate,
// use it to enable LDAPS in the server.
if ( this.express.use_ssl() ) {
if ( this.config.ssl?.enable ) {
this.output.info('Using configured SSL certificate. The LDAP server will require an ldaps:// connection.')
server_config.certificate = fs.readFileSync(this.config.ssl.certificate)
server_config.key = fs.readFileSync(this.config.ssl.key)
} else if ( this.express.use_ssl() ) {
this.output.info('Using configured SSL certificate. The LDAP server will require an ldaps:// connection.')
server_config.certificate = await this.express.ssl_certificate()
server_config.key = await this.express.ssl_key()

View File

@ -1,3 +1,4 @@
const fs = require('fs')
const Unit = require('libflitter/Unit')
const { Provider, interactionPolicy: { Prompt, base: policy } } = require('oidc-provider')
const uuid = require('uuid').v4
@ -14,6 +15,15 @@ class OpenIDConnectUnit extends Unit {
return [...super.services, 'output', 'configs', 'models']
}
load_jwks(file) {
if ( fs.existsSync(file) ) {
const content = fs.readFileSync(file)
try {
return JSON.parse(content)
} catch (e) {}
}
}
async go(app) {
this.Vue = this.app.di().get('Vue')
const issuer = this.configs.get('app.url')
@ -23,9 +33,13 @@ class OpenIDConnectUnit extends Unit {
CoreIDAdapter.connect(app)
const jwks_file = this.configs.get('oidc.jwks_file')
const jwks = this.load_jwks(jwks_file)
this.provider = new Provider(issuer, {
adapter: CoreIDAdapter,
clients: [],
jwks,
interactions: {
interactions,
url: (ctx, interaction) => `/openid/interaction/${ctx.oidc.uid.toLowerCase()}`,
@ -58,6 +72,15 @@ class OpenIDConnectUnit extends Unit {
...configuration,
})
const reportError = ({ headers: { authorization }, oidc: { body, client } }, err) => {
this.output.error('OpenIDConnect authorization error!')
this.output.error(err)
}
this.provider.on('grant.error', reportError)
this.provider.on('introspection.error', reportError)
this.provider.on('revocation.error', reportError)
if ( configuration.proxy ) this.provider.proxy = true
app.express.use('/oidc', this.wrap(this.provider.callback))
}
@ -91,6 +114,11 @@ class OpenIDConnectUnit extends Unit {
}
}
// Stupid /jwks only listens on GET which is incompatible w/ some apps
if ( req.url === '/jwks' ) {
req.method = 'GET'
}
return callback(req, res, next)
}
}

63
app/unit/RadiusUnit.js Normal file
View File

@ -0,0 +1,63 @@
const fs = require('fs/promises')
const uuid = require('uuid')
const { Unit } = require('libflitter')
const CoreIDAuthentication = require('../classes/radius/CoreIDAuthentication')
const net = require("net");
class RadiusUnit extends Unit {
static get services() {
return [...super.services, 'configs', 'output', 'models']
}
async go(app) {
if ( !this.configs.get('radius.enable') ) return;
const CoreIDRadiusServer = (await import('../classes/radius/CoreIDRadiusServer.mjs')).default
// Load the certificates
const pubkey = await fs.readFile(this.configs.get('radius.cert_file.public'))
const privkey = await fs.readFile(this.configs.get('radius.cert_file.private'))
this.radius = new CoreIDRadiusServer({
// logger
secret: this.configs.get('radius.secret', uuid.v4()),
port: this.configs.get('radius.port', 1812),
address: this.configs.get('radius.interface', '0.0.0.0'),
tlsOptions: {
cert: pubkey,
key: privkey,
},
authentication: new CoreIDAuthentication(),
})
if ( await this.port_free() ) {
this.output.info('Starting RADIUS server...')
await this.radius.start()
} else {
this.output.error('Will not start RADIUS server. Reason: configured port is already in use')
delete this.radius
}
}
async cleanup(app) {
if ( this.radius ) {
await this.radius.server.close()
}
}
async port_free() {
return new Promise((res, rej) => {
const server = net.createServer()
server.once('error', (e) => {
res(false)
})
server.once('listening', () => {
server.close()
res(true)
})
server.listen(this.configs.get('radius.port', 1812))
})
}
}
module.exports = exports = RadiusUnit

View File

@ -13,6 +13,22 @@ class SettingsUnit extends Unit {
Error.stackTraceLimit = 50
app.express.set('trust proxy', true)
const User = this.models.get('auth:User')
const user = await User.findOne({is_default_user_for_coreid: true})
if ( !user ) {
const user = new User({
uid: '__coreid_default_user__',
provider: 'flitter',
block_login: true,
first_name: 'Default_User',
last_name: 'Default_User',
ldap_visible: false,
is_default_user_for_coreid: true,
})
await user.save()
}
const Setting = this.models.get('Setting')
const default_settings = this.configs.get('setting.settings')
for ( const key in default_settings ) {
@ -21,6 +37,15 @@ class SettingsUnit extends Unit {
this.output.debug(`Guarantee setting key "${key}" with default value "${default_value}".`)
await Setting.guarantee(key, default_value)
}
const Permission = this.models.get('iam:Permission')
const default_permissions = this.configs.get('auth.iam.default_permissions')
for ( const perm of default_permissions ) {
const existing = await Permission.findOne(perm)
if ( !existing ) {
await (new Permission(perm)).save()
}
}
}
}

View File

@ -4,5 +4,4 @@ block content
.cobalt-container
.row.pad-top
.col-12
cobalt-form(v-if="form_id" :resource="resource" :form_id="form_id" :initial_mode="mode")
cobalt-form(v-if="!form_id" :resource="resource" :initial_mode="mode")
coreid-outlet(initial_page="cobalt.form" :initial_resource="resource" :initial_form_id="form_id" :initial_mode="mode")

View File

@ -4,4 +4,4 @@ block content
.cobalt-container
.row.pad-top
.col-12
cobalt-listing(:resource="resource")
coreid-outlet(initial_page="cobalt.listing" :initial_resource="resource")

View File

@ -4,4 +4,4 @@ block content
.cobalt-container
.row.pad-top
.col-12
coreid-app-setup
coreid-outlet(initial_page="app.setup")

View File

@ -3,5 +3,5 @@ extends ../../theme/dash/base
block content
.profile-container
.row.pad-top
.col-12.offset-0.col-md-8.offset-md-2.col-xl-6.offset-xl-3
coreid-profile-edit
.col-12
coreid-outlet(initial_page="dash.profile")

View File

@ -18,4 +18,4 @@ block append content
| people who self-host various applications. With its built-in OAuth2, LDAP, and SAML servers
| and self-service password & admin panel, #{_app && _app.name || 'CoreID'} gives you the ability
| to easily integrate a single-sign-on solution into your self-hosting infrastructure without
| jumping through hoops to make it work. You can learn more <a href="https://wiki.garrettmills.dev/s/bsvhg1el9dtuppanj77g/starship-coreid">here.</a>
| jumping through hoops to make it work. You can learn more <a href="https://garrettmills.dev/go/coreid">here.</a>

View File

@ -3,6 +3,19 @@ const auth_config = {
default_provider: env('AUTH_DEFAULT_PROVIDER', 'flitter'),
default_login_route: '/dash',
iam: {
default_permissions: [
{
target_type: 'machine',
permission: 'sudo',
},
{
target_type: 'machine_group',
permission: 'sudo',
},
],
},
mfa: {
secret_length: env('MFA_SECRET_LENGTH', 20)
},
@ -178,6 +191,7 @@ const auth_config = {
ldap_client: ['ldap:bind', 'ldap:search'],
coreid_base: ['my:profile'],
saml_admin: ['v1:saml', 'saml'],
radius_admin: ['v1:radius', 'radius'],
base_user: [
// Message Service
@ -197,7 +211,7 @@ const auth_config = {
'ldap:search:users:me',
],
root: ['v1', 'ldap', 'saml', 'profile', 'oauth', 'app', 'auth', 'iam'],
root: ['v1', 'ldap', 'saml', 'profile', 'oauth', 'app', 'auth', 'iam', 'radius'],
},

View File

@ -15,6 +15,7 @@ const jobs_config = {
'mailer',
'password_resets',
'notifications',
'verifications',
],
// Mapping of worker name => worker config
@ -23,7 +24,7 @@ const jobs_config = {
// The name of the worker is "main"
main: {
// This worker will process these queues
queues: ['mailer', 'password_resets', 'notifications'],
queues: ['mailer', 'password_resets', 'notifications', 'verifications'],
},
// You can have many workers, and multiple workers can
@ -31,6 +32,13 @@ const jobs_config = {
// worker processes of the same type.
// (e.g. you can have two "main" workers)
},
connector: {
enabled: env('JOB_QUEUE_CONNECTOR', false),
mount: env('JOB_QUEUE_CONNECTOR_MOUNT', '/job_queue_api'),
secret: env('JOB_QUEUE_CONNECTOR_SECRET'),
},
}
module.exports = exports = jobs_config

View File

@ -5,13 +5,24 @@ const ldap_server = {
max_connections: env('LDAP_MAX_CONNECTIONS'),
interface: env('LDAP_LISTEN_INTERFACE', '0.0.0.0'),
ssl: {
enable: env('LDAP_SSL_ENABLE', false),
certificate: env('LDAP_CERT_PATH'),
key: env('LDAP_CERT_KEY_PATH'),
},
schema: {
base_dc: env('LDAP_BASE_DC', 'dc=example,dc=com'),
authentication_base: env('LDAP_AUTH_BASE', 'ou=people'),
group_base: env('LDAP_GROUP_BASE', 'ou=groups'),
machine_base: env('LDAP_MACHINE_BASE', 'ou=computers'),
machine_group_base: env('LDAP_MACHINE_BASE', 'ou=computer groups'),
sudo_base: env('LDAP_SUDO_BASE', 'ou=sudo'),
auth: {
user_id: 'uid',
}
},
start_uid: env('LDAP_START_UID', 80000),
default_shell: env('LDAP_DEFAULT_SHELL', '/bin/bash'),
},
format: {

View File

@ -1,7 +1,8 @@
const oidc_config = {
provider: {
proxy: env('OPENID_CONNECT_PROXY', false),
}
},
jwks_file: env('OPENID_CONNECT_JWKS_FILE'),
}
module.exports = exports = oidc_config

15
config/radius.config.js Normal file
View File

@ -0,0 +1,15 @@
const uuid = require('uuid')
const radius_config = {
enable: env('RADIUS_ENABLE', false),
port: env('RADIUS_PORT', 1812),
interface: env('RADIUS_INTERFACE', '0.0.0.0'),
secret: env('RADIUS_SECRET', uuid.v4()),
cert_file: {
public: env('RADIUS_CERT_FILE'),
private: env('RADIUS_KEY_FILE'),
},
}
module.exports = exports = radius_config

View File

@ -10,6 +10,7 @@ const redis_config = {
// https://github.com/luin/ioredis#connect-to-redis
server: {
host: env('REDIS_HOST', 'localhost'),
password: env('REDIS_PASS'),
port: env('REDIS_PORT', 6379),
},
}

View File

@ -1,9 +1,11 @@
const setting_config = {
settings: {
'auth.allow_registration': true,
'auth.require_email_verify': false,
'auth.default_roles': [ 'base_user' ],
'home.allow_landing': true,
'home.redirect_authenticated': true,
'ldap.last_alloc_uid': -1,
}
}

View File

@ -29,6 +29,32 @@ const traps_config = {
'/auth/login-message/dismiss',
],
},
registrant_flow: {
redirect_to: '/auth/finish-registration',
allowed_routes: [
'/auth/finish-registration',
'/auth/logout',
'/auth/login',
'/api/v1/locale/batch',
'/api/v1/auth/validate/username',
'/api/v1/auth/attempt',
],
},
verify_email: {
redirect_to: '/auth/verify-email',
allowed_routes: [
'/auth/verify-email',
'/auth/verify-email/sent',
'/auth/logout',
'/auth/login',
'/api/v1/locale/batch',
'/api/v1/auth/validate/username',
'/api/v1/auth/attempt',
'/auth/action/*',
'/api/v1/message/banners',
'/api/v1/message/banners/read/*',
],
},
},
}

4
deploy/0-namespace.yaml Normal file
View File

@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: starship

217
deploy/1-deployment.yaml Normal file
View File

@ -0,0 +1,217 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: coreid-www
namespace: starship
spec:
selector:
matchLabels:
app: coreid
template:
metadata:
name: coreid
labels:
app: coreid
spec:
volumes:
- name: coreid-secrets-vol
secret:
secretName: coreid-secrets
optional: false
containers:
- name: coreid-web
image: ${DOCKER_REGISTRY}/starship/coreid
imagePullPolicy: Always
volumeMounts:
- mountPath: /secrets
readOnly: true
name: coreid-secrets-vol
env:
- name: APP_URL
value: "https://${COREID_DOMAIN}/"
- name: DATABASE_HOST
value: '${COREID_DATABASE_HOST}'
- name: DATABASE_NAME
value: '${COREID_DATABASE_NAME}'
- name: LDAP_BASE_DC
value: '${COREID_LDAP_BASE_DC}'
- name: REDIS_HOST
value: '${COREID_REDIS_HOST}'
- name: SMTP_HOST
value: '${COREID_SMTP_HOST}'
- name: SECRET
valueFrom:
secretKeyRef:
key: SECRET
name: coreid-secrets
optional: false
- name: SMTP_USER
valueFrom:
secretKeyRef:
key: SMTP_USER
name: coreid-secrets
optional: false
- name: SMTP_DEFAULT_SENDER
valueFrom:
secretKeyRef:
key: SMTP_DEFAULT_SENDER
name: coreid-secrets
optional: false
- name: SMTP_PASS
valueFrom:
secretKeyRef:
key: SMTP_PASS
name: coreid-secrets
optional: false
- name: REDIS_PASS
valueFrom:
secretKeyRef:
key: REDIS_PASS
name: coreid-secrets
optional: false
- name: APP_NAME
value: "Starship CoreID"
- name: SERVER_PORT
value: '8000'
- name: DATABASE_PORT
value: '27017'
- name: DATABASE_AUTH
value: 'false'
- name: ENVIRONMENT
value: production
- name: SSL_ENABLE
value: 'false'
- name: LDAP_SERVER_PORT
value: '636'
- name: LDAP_SSL_ENABLE
value: 'true'
- name: LDAP_CERT_PATH
value: '/secrets/X509_CERT'
- name: LDAP_CERT_KEY_PATH
value: '/secrets/X509_KEY'
- name: SAML_CERT_FILE
value: '/secrets/X509_CERT'
- name: SAML_KEY_FILE
value: '/secrets/X509_KEY'
- name: RADIUS_CERT_FILE
value: '/secrets/X509_CERT'
- name: RADIUS_KEY_FILE
value: '/secrets/X509_KEY'
- name: REDIS_PORT
value: '6379'
- name: SMTP_PORT
value: '587'
- name: OPENID_CONNECT_PROXY
value: 'true'
- name: SESSION_MAX_AGE
value: '1209600000'
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: coreid-jobs
namespace: starship
spec:
selector:
matchLabels:
app: coreid-jobs
template:
metadata:
name: coreid
labels:
app: coreid-jobs
spec:
volumes:
- name: coreid-secrets-vol
secret:
secretName: coreid-secrets
optional: false
containers:
- name: coreid-job-worker
image: ${DOCKER_REGISTRY}/starship/coreid
imagePullPolicy: Always
command: ["node", "/app/flitter", "worker", "main"]
volumeMounts:
- mountPath: /secrets
readOnly: true
name: coreid-secrets-vol
env:
- name: APP_URL
value: "https://${COREID_DOMAIN}/"
- name: DATABASE_HOST
value: '${COREID_DATABASE_HOST}'
- name: DATABASE_NAME
value: '${COREID_DATABASE_NAME}'
- name: LDAP_BASE_DC
value: '${COREID_LDAP_BASE_DC}'
- name: REDIS_HOST
value: '${COREID_REDIS_HOST}'
- name: SMTP_HOST
value: '${COREID_SMTP_HOST}'
- name: SECRET
valueFrom:
secretKeyRef:
key: SECRET
name: coreid-secrets
optional: false
- name: SMTP_USER
valueFrom:
secretKeyRef:
key: SMTP_USER
name: coreid-secrets
optional: false
- name: SMTP_DEFAULT_SENDER
valueFrom:
secretKeyRef:
key: SMTP_DEFAULT_SENDER
name: coreid-secrets
optional: false
- name: SMTP_PASS
valueFrom:
secretKeyRef:
key: SMTP_PASS
name: coreid-secrets
optional: false
- name: REDIS_PASS
valueFrom:
secretKeyRef:
key: REDIS_PASS
name: coreid-secrets
optional: false
- name: APP_NAME
value: "Starship CoreID"
- name: SERVER_PORT
value: '8000'
- name: DATABASE_PORT
value: '27017'
- name: DATABASE_AUTH
value: 'false'
- name: ENVIRONMENT
value: production
- name: SSL_ENABLE
value: 'false'
- name: LDAP_SERVER_PORT
value: '636'
- name: LDAP_SSL_ENABLE
value: 'true'
- name: LDAP_CERT_PATH
value: '/secrets/X509_CERT'
- name: LDAP_CERT_KEY_PATH
value: '/secrets/X509_KEY'
- name: SAML_CERT_FILE
value: '/secrets/X509_CERT'
- name: SAML_KEY_FILE
value: '/secrets/X509_KEY'
- name: RADIUS_CERT_FILE
value: '/secrets/X509_CERT'
- name: RADIUS_KEY_FILE
value: '/secrets/X509_KEY'
- name: REDIS_PORT
value: '6379'
- name: SMTP_PORT
value: '587'
- name: OPENID_CONNECT_PROXY
value: 'true'
- name: SESSION_MAX_AGE
value: '1209600000'

24
deploy/2-service.yaml Normal file
View File

@ -0,0 +1,24 @@
---
apiVersion: v1
kind: Service
metadata:
name: coreid-web
namespace: starship
spec:
selector:
app: coreid
ports:
- port: 80
targetPort: 8000
---
apiVersion: v1
kind: Service
metadata:
name: coreid-ldaps
namespace: starship
spec:
selector:
app: coreid
ports:
- port: 636
targetPort: 636

13
deploy/3-certificate.yaml Normal file
View File

@ -0,0 +1,13 @@
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: coreid-tls
namespace: starship
spec:
secretName: coreid-tls-secret
dnsNames:
- ${COREID_DOMAIN}
issuerRef:
name: letsencrypt-ca
kind: ClusterIssuer

25
deploy/4-ingress.yaml Normal file
View File

@ -0,0 +1,25 @@
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: coreid-ingress
namespace: starship
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: 'false'
spec:
tls:
- hosts:
- ${COREID_DOMAIN}
secretName: coreid-tls-secret
ingressClassName: nginx
rules:
- host: ${COREID_DOMAIN}
http:
paths:
- pathType: Prefix
path: '/'
backend:
service:
name: coreid-web
port:
number: 80

32
deploy/README.md Normal file
View File

@ -0,0 +1,32 @@
This deployment is parameterized for use with `envsubst(1)`.
You will need to set up the secret values and environment variables (see below).
```shell
bash -c 'for f in *.yaml; do envsubst < $f | kubectl apply -f -; done'
```
## Supported environment variables
Set these environment variables in your shell before running the above command to apply the Kubernetes spec.
- `COREID_DOMAIN` - domain name where CoreID is accessed (e.g. `coreid.mydomain.com`)
- `DOCKER_REGISTRY` - host of the docker registry to pull the image from (e.g. `registry.mydomain.com`)
- this is the same registry that is used by `yarn docker:build` and `yarn docker:push`
- `COREID_DATABASE_HOST` - MongoDB host (e.g. `mongo.mylan.net`)
- `COREID_DATABASE_NAME` - MongoDB database name to use (e.g. `coreid_p1`)
- `COREID_LDAP_BASE_DC` - base DC to use for LDAP tree (e.g. `dc=platform,dc=local`)
- `COREID_REDIS_HOST` - Redis host (e.g. `redis.mylan.net`)
- `COREID_SMTP_HOST` - SMTP server host (e.g. `smtp.mymail.com`)
## Secret values
The spec expects there to be a `coreid-secrets` secret in the `starship` namespace with the following values:
- `SECRET` - hash seed used by CoreID (e.g. `df8db5a2-429b-4597-a013-18efee2465e0`)
- `SMTP_USER` - username used to log-into SMTP server (e.g. `user@mymail.com`)
- `SMTP_DEFAULT_SENDER` - email to use as FROM address. Usually same as `SMTP_USER` (e.g. `user@mymail.com`)
- `SMTP_PASS` - password for `SMTP_USER`
- `REDIS_PASS` - password for the Redis service
- `X509_CERT` - contents of the x509 certificate to be used for SAML/LDAP/RADIUS
- `X509_KEY` - contents of the x509 certificate key to be used for SAML/LDAP/RADIUS

View File

@ -33,3 +33,6 @@ SMTP_PORT="587"
SMTP_USER="coreid@localhost.localdomain"
SMTP_DEFAULT_SENDER="coreid@localhost.localdomain"
SMTP_PASS="something super secure"
JOB_QUEUE_CONNECTOR=true
JOB_QUEUE_CONNECTOR_SECRET=

View File

@ -1,3 +1,5 @@
Error.stackTraceLimit = 200
/*
* Load the units file.
* -------------------------------------------------------------

View File

@ -3,9 +3,12 @@ module.exports = exports = {
application_already_exists: 'An Application with that identifier already exists.',
group_not_found: 'Group not found with that ID.',
machine_not_found: 'Machine not found with that ID.',
group_already_exists: 'A group with that name already exists.',
machine_already_exists: 'A machine with that name already exists.',
user_not_found: 'User not found with that ID.',
photo_not_found: 'This user has no photo.',
user_already_exists: 'A user with that identifier already exists.',
client_not_found: 'Client not found with that ID.',
@ -14,6 +17,8 @@ module.exports = exports = {
token_not_found: 'Token not found with that ID, or the token has expired.',
provider_already_exists: 'A service provider with that entity_id already exists.',
permission_already_exists: 'A permission for that target_type already exists.',
permission_not_found: 'Permission not found with that ID.',
setting_not_found: 'No such setting exists with that key.',
@ -25,7 +30,9 @@ module.exports = exports = {
invalid_ldap_client_id: 'Invalid ldap_client_id:',
invalid_oauth_client_id: 'Invalid oauth_client_id:',
invalid_radius_client_id: 'Invalid radius_client_id:',
invalid_saml_service_provider_id: 'Invalid saml_service_provider_id:',
invalid_target_type: 'Invalid target_type.',
insufficient_permissions: 'Insufficient permissions.',
missing_field: {

View File

@ -26,4 +26,6 @@ module.exports = exports = {
oauth_prompt: 'CLIENT_NAME is requesting access to your APP_NAME account. Once you grant it, you may not be prompted for permission again.',
will_redirect: 'You will be redirected to:',
reauth_to_continue: 'Please re-authenticate to continue.',
must_verify_email: 'You must verify your e-mail address to continue. Click below to send the verification e-mail.',
verify_email_sent: 'Check your e-mail for the link to verify your account.',
}

View File

@ -0,0 +1,5 @@
module.exports = exports = {
authn: 'Biometric/Hardware Authentication',
enable: 'Setup Hardware Login',
desc: 'On supported devices, you can log into your APP_NAME account using a hardware token or biometric sensor such as your phone\'s fingerprint reader.',
}

View File

@ -7,11 +7,13 @@ module.exports = exports = {
invalid: 'Invalid',
unnamed: '(unnamed)',
never: 'Never',
yes: 'Yes',
no: 'No',
deny: 'Deny',
grant: 'Grant Access',
grant: 'Allow access',
grant_once: 'Allow access once',
back: 'Back',
next: 'Next',
cancel: 'Cancel',

View File

@ -1,4 +1,5 @@
module.exports = exports = {
policy_not_found: 'Policy not found with that ID.',
permission_not_found: 'Permission not found with that ID.',
invalid_entity: 'Invalid entity_type. Must be one of:'
}

View File

@ -37,6 +37,7 @@ module.exports = exports = {
test_notification: 'This is a test notification! If you see this, it means you have properly configured your notification settings.',
issued: 'Issued:',
accessed: 'Last Used:',
gen_new: 'Generate New',
recovery_codes: 'Recovery Codes',
recovery_1: 'Recovery codes can be used to regain access to your account in the event that you lose access to the device that generates your MFA codes.',
@ -51,4 +52,7 @@ module.exports = exports = {
mfa_1: 'MFA is a good-practice security measure that requires you to provide a second factor of identification when you sign in from a service or device that makes use of APP_NAME.',
mfa_2: 'Once enabled, APP_NAME will prompt you to enter a code when you sign-in with the APP_NAME web interface from a new device. It will also require you to append the code to your password when signing in to a service that uses APP_NAME as a backend.',
advanced_header: 'Advanced',
advanced_shell: 'Default Login Shell',
}

View File

@ -13,19 +13,25 @@
"framework",
"express"
],
"scripts": {
"docker:build": "docker build -t ${DOCKER_REGISTRY}/starship/coreid .",
"docker:push": "docker push ${DOCKER_REGISTRY}/starship/coreid"
},
"author": "Garrett Mills <garrett@glmdev.tech> (https://garrettmills.dev/)",
"license": "MIT",
"dependencies": {
"@coreid/radius-server": "^2.2.2",
"@passwordless-id/webauthn": "^1.2.0",
"bullmq": "^1.8.8",
"email-validator": "^2.0.4",
"flitter-auth": "^0.19.1",
"flitter-auth": "^0.19.6",
"flitter-cli": "^0.16.0",
"flitter-di": "^0.5.0",
"flitter-flap": "^0.5.2",
"flitter-forms": "^0.8.1",
"flitter-gotify": "^0.1.0",
"flitter-i18n": "^0.1.1",
"flitter-jobs": "^0.1.2",
"flitter-jobs": "^0.4.0",
"flitter-less": "^0.5.3",
"flitter-orm": "^0.4.0",
"flitter-redis": "^0.1.1",
@ -33,12 +39,13 @@
"ioredis": "^4.17.1",
"is-absolute-url": "^3.0.3",
"ldapjs": "^1.0.2",
"libflitter": "^0.57.0",
"libflitter": "^0.59.0",
"moment": "^2.24.0",
"mongodb": "^3.5.9",
"nodemailer": "^6.4.6",
"oidc-provider": "^6.29.0",
"qrcode": "^1.4.4",
"radius": "^1.1.4",
"samlp": "^3.4.1",
"speakeasy": "^2.0.0",
"uuid": "^8.3.0",

4394
yarn.lock

File diff suppressed because it is too large Load Diff