Implement RADIUS server!
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2021-10-24 13:12:58 -05:00
parent f98f35f626
commit bd69be7137
16 changed files with 861 additions and 11 deletions

View File

@@ -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',

View File

@@ -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',

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

@@ -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

@@ -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

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

@@ -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

@@ -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
View 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