Implement RADIUS server!
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -90,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',
|
||||
|
||||
@@ -112,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',
|
||||
|
||||
71
app/assets/app/resource/radius/Client.resource.js
Normal file
71
app/assets/app/resource/radius/Client.resource.js
Normal 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 }
|
||||
@@ -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 ) {
|
||||
|
||||
190
app/controllers/api/v1/Radius.controller.js
Normal file
190
app/controllers/api/v1/Radius.controller.js
Normal file
@@ -0,0 +1,190 @@
|
||||
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')
|
||||
|
||||
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 = req.body.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
|
||||
@@ -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 || [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
32
app/models/radius/Client.model.js
Normal file
32
app/models/radius/Client.model.js
Normal 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
|
||||
48
app/routing/routers/api/v1/radius.routes.js
Normal file
48
app/routing/routers/api/v1/radius.routes.js
Normal 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
|
||||
156
app/unit/RadiusUnit.js
Normal file
156
app/unit/RadiusUnit.js
Normal file
@@ -0,0 +1,156 @@
|
||||
const { Unit } = require('libflitter')
|
||||
const fs = require('fs')
|
||||
const radius = require('radius')
|
||||
const uuid = require('uuid').v4
|
||||
|
||||
/**
|
||||
* Unit that provides a CoreID IAM-integrated RADIUS server.
|
||||
*/
|
||||
class RadiusUnit extends Unit {
|
||||
static get services() {
|
||||
return [...super.services, 'configs', 'output', 'models']
|
||||
}
|
||||
|
||||
async go(app) {
|
||||
const config = this.getConfig()
|
||||
|
||||
// Overwrite radius-server's global config object with the user-provided values
|
||||
const radiusConfig = require('radius-server/config')
|
||||
for ( const key in config ) {
|
||||
if ( !Object.hasOwnProperty.apply(config, [key]) ) continue
|
||||
radiusConfig[key] = config[key]
|
||||
}
|
||||
|
||||
const { Authentication } = require('radius-server/dist/auth')
|
||||
const { UDPServer } = require('radius-server/dist/server/UDPServer')
|
||||
const { RadiusService } = require('radius-server/dist/radius/RadiusService')
|
||||
|
||||
this.output.info('Starting RADIUS server...')
|
||||
|
||||
// Create the authenticator instance
|
||||
const AuthMechanismus = require(`radius-server/dist/auth/${config.authentication}`)[config.authentication]
|
||||
const auth = new AuthMechanismus(config.authenticationOptions)
|
||||
|
||||
const authentication = new Authentication(auth)
|
||||
this.server = new UDPServer(config.port)
|
||||
this.radiusService = new RadiusService(config.secret, authentication)
|
||||
|
||||
// Define the server handler
|
||||
this.server.on('message', async (msg, rinfo) => {
|
||||
this.output.debug('Incoming RADIUS message...')
|
||||
const response = await this.handleMessage(msg)
|
||||
|
||||
if ( response ) {
|
||||
this.output.debug('Sending response...')
|
||||
this.server.sendToClient(
|
||||
response.data,
|
||||
rinfo.port,
|
||||
rinfo.address,
|
||||
(err, _bytes) => {
|
||||
if ( err ) {
|
||||
this.output.error(`Error sending response to:`)
|
||||
this.output.error(rinfo)
|
||||
}
|
||||
},
|
||||
response.expectAcknowledgment
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Start the radius server
|
||||
await this.server.start()
|
||||
this.output.success('Started RADIUS server!')
|
||||
}
|
||||
|
||||
async cleanup(app) {
|
||||
if ( this.server ) {
|
||||
// radius-server doesn't expose a "close" method explicitly, which is annoying
|
||||
// instead, reach in and close the internal UDP socket
|
||||
this.server.server.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming radius request message.
|
||||
* @param msg
|
||||
* @returns {Promise<{expectAcknowledgment: boolean, data: *}|undefined>}
|
||||
*/
|
||||
async handleMessage(msg) {
|
||||
const RadiusClient = this.models.get('radius:Client')
|
||||
const clients = await RadiusClient.find({ active: true })
|
||||
|
||||
// Try the secrets for all active clients.
|
||||
// If we can successfully decode the packet with a client's secret, then we know
|
||||
// that the message came from that client.
|
||||
let authenticatedClient
|
||||
let packet
|
||||
for ( const client of clients ) {
|
||||
try {
|
||||
packet = radius.decode({ packet: msg, secret: client.secret })
|
||||
authenticatedClient = client
|
||||
break
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (packet.code !== 'Access-Request') {
|
||||
console.error('unknown packet type: ', packet.code)
|
||||
return
|
||||
}
|
||||
|
||||
// Include the RADIUS Client ID in the username so we can parse it out in the controller
|
||||
// This allows us to check IAM access in the controller
|
||||
packet.attributes['User-Name'] = `${packet.attributes['User-Name']}@${authenticatedClient.id}`
|
||||
this.output.info(`RADIUS auth attempt: ${packet.attributes['User-Name']}`)
|
||||
|
||||
const response = await this.radiusService.packetHandler.handlePacket(packet)
|
||||
|
||||
// still no response, we are done here
|
||||
if (!response || !response.code) {
|
||||
return
|
||||
}
|
||||
|
||||
// all fine, return radius encoded response
|
||||
return {
|
||||
data: radius.encode_response({
|
||||
packet,
|
||||
code: response.code,
|
||||
secret: authenticatedClient.secret, // use the client's secret to encode the response
|
||||
attributes: response.attributes,
|
||||
}),
|
||||
|
||||
// if message is accept or reject, we conside this as final message
|
||||
// this means we do not expect a reponse from the client again (acknowledgement for package)
|
||||
expectAcknowledgment: response.code === 'Access-Challenge',
|
||||
};
|
||||
}
|
||||
|
||||
getConfig() {
|
||||
const baseUrl = this.configs.get('app.url')
|
||||
|
||||
const config = {
|
||||
port: this.configs.get('radius.port', 1812),
|
||||
secret: uuid(), // this is never used - client-specific secrets are injected instead
|
||||
certificate: {
|
||||
cert: fs.readFileSync(this.configs.get('radius.cert_file.public')),
|
||||
key: [
|
||||
{
|
||||
pem: fs.readFileSync(this.configs.get('radius.cert_file.private')),
|
||||
},
|
||||
],
|
||||
},
|
||||
authentication: 'HTTPAuth',
|
||||
authenticationOptions: {
|
||||
url: `${baseUrl}api/v1/radius/attempt`,
|
||||
},
|
||||
}
|
||||
|
||||
const passphrase = this.configs.get('saml.cert_file.passphrase')
|
||||
if ( passphrase ) {
|
||||
config.certificate.key[0].passphrase = passphrase
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = exports = RadiusUnit
|
||||
Reference in New Issue
Block a user