first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

2
services/web/scripts/ukamf/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
ukfederation-metadata.xml

View File

@@ -0,0 +1,98 @@
/**
* Checks that all institutional sso provider certs are still current with the
* data provided by the ukamf export file.
*
* Run with: node check-certs /path/ukamf.xml
*
* The ukamf metadata xml file can be downloaded from:
* http://metadata.ukfederation.org.uk/
*/
import { Certificate } from '@fidm/x509'
import UKAMFDB from './ukamf-db.js'
import V1ApiModule from '../../app/src/Features/V1/V1Api.js'
import { db } from '../../app/src/infrastructure/mongodb.js'
import moment from 'moment'
const { promises: V1Api } = V1ApiModule
async function main() {
const [, , file] = process.argv
console.log(`loading file ${file}`)
const ukamfDB = new UKAMFDB(file)
await ukamfDB.init()
const activeProviderIds = await getActiveProviderIds()
for (const providerId of activeProviderIds) {
await checkCert(ukamfDB, providerId)
}
}
async function checkCert(ukamfDB, providerId) {
console.log(`Checking certificates for providerId: ${providerId}`)
try {
const { body } = await V1Api.request({
json: true,
qs: { university_id: providerId },
uri: '/api/v1/overleaf/university_saml',
})
// show notice if sso not currently enabled
if (body.sso_enabled === true) {
console.log(` * SSO enabled`)
} else {
console.log(` ! SSO NOT enabled`)
}
// lookup entity id in ukamf database
const entity = ukamfDB.findByEntityID(body.sso_entity_id)
// if entity found then compare certs
if (entity) {
const samlConfig = entity.getSamlConfig()
// check if certificates match
if (samlConfig.cert === body.sso_cert) {
console.log(' * UKAMF certificate matches configuration')
} else {
console.log(' ! UKAMF certificate DOES NOT match configuration')
}
} else {
console.log(` ! No UKAMF entity found for ${body.sso_entity_id}`)
}
// check expiration on configured certificate
const certificate = Certificate.fromPEM(
Buffer.from(
`-----BEGIN CERTIFICATE-----\n${body.sso_cert}\n-----END CERTIFICATE-----`,
'utf8'
)
)
const validFrom = moment(certificate.validFrom)
const validTo = moment(certificate.validTo)
if (validFrom.isAfter(moment())) {
console.log(` ! Certificate not valid till: ${validFrom.format('LLL')}`)
} else if (validTo.isBefore(moment())) {
console.log(` ! Certificate expired: ${validTo.format('LLL')}`)
} else if (validTo.isBefore(moment().add(60, 'days'))) {
console.log(` ! Certificate expires: ${validTo.format('LLL')}`)
} else {
console.log(` * Certificate expires: ${validTo.format('LLL')}`)
}
} catch (err) {
console.log(` ! ${err.statusCode} Error getting university config from v1`)
}
}
async function getActiveProviderIds() {
return db.users.distinct('samlIdentifiers.providerId', {
'samlIdentifiers.externalUserId': { $exists: true },
})
}
try {
await main()
} catch (error) {
console.error(error.stack)
}
process.exit()

View File

@@ -0,0 +1,67 @@
/*
Checks the SAML metadata provided by the IdP.
Currently, only checking the valid from and to dates for the certificate
Run with: node check-idp-metadata /path/idp-metadata.xml
*/
import { Certificate } from '@fidm/x509'
import _ from 'lodash'
import moment from 'moment'
import fs from 'fs'
import xml2js from 'xml2js'
function checkCertDates(signingKey) {
let cert = _.get(signingKey, [
'ds:KeyInfo',
0,
'ds:X509Data',
0,
'ds:X509Certificate',
0,
])
if (!cert) {
throw new Error('no cert')
}
cert = cert.replace(/\s/g, '')
const certificate = Certificate.fromPEM(
Buffer.from(
`-----BEGIN CERTIFICATE-----\n${cert}\n-----END CERTIFICATE-----`,
'utf8'
)
)
const validFrom = moment(certificate.validFrom)
const validTo = moment(certificate.validTo)
return {
validFrom,
validTo,
}
}
async function main() {
const [, , file] = process.argv
console.log('Checking SAML metadata')
const data = await fs.promises.readFile(file, 'utf8')
const parser = new xml2js.Parser()
const xml = await parser.parseStringPromise(data)
const idp = xml.EntityDescriptor.IDPSSODescriptor
const keys = idp[0].KeyDescriptor
const signingKey =
keys.length === 1
? keys[0]
: keys.find(key => _.get(key, ['$', 'use']) === 'signing')
const certDates = checkCertDates(signingKey)
console.log(
`SSO certificate is valid from ${certDates.validFrom} to ${certDates.validTo}`
)
}
main()

View File

@@ -0,0 +1,72 @@
/**
* Run with: node metadata-processor /path/ukamf.xml http://idp/entity/id
*
* `npm install` must be run for scripts/ukamf first.
*
* The ukamf metadata xml file can be downloaded from:
* http://metadata.ukfederation.org.uk/
*
* The entity id should be provided by the university.
*/
import { Certificate } from '@fidm/x509'
import moment from 'moment'
import UKAMFDB from './ukamf-db.js'
async function main() {
const [, , file, entityId] = process.argv
console.log(`loading file ${file}...\n`)
const ukamfDB = new UKAMFDB(file)
await ukamfDB.init()
const entity = ukamfDB.findByEntityID(entityId)
if (!entity) {
throw new Error(`could not find entity for ${entityId}`)
}
const samlConfig = entity.getSamlConfig()
const certificate = Certificate.fromPEM(
Buffer.from(
`-----BEGIN CERTIFICATE-----\n${samlConfig.cert}\n-----END CERTIFICATE-----`,
'utf8'
)
)
const validFrom = moment(certificate.validFrom)
const validTo = moment(certificate.validTo)
if (validFrom.isAfter(moment())) {
throw new Error(`certificate not valid till: ${validFrom.format('LLL')}`)
}
if (validTo.isBefore(moment())) {
throw new Error(`certificate expired: ${validTo.format('LLL')}`)
}
console.log(
`!!!!!!!!!!!!!\nCERTIFICATE EXPIRES: ${validTo.format(
'LLL'
)}\n!!!!!!!!!!!!!\n`
)
console.log(`SSO Entity ID: ${samlConfig.entityId}\n`)
console.log(`SSO Entry Point: ${samlConfig.entryPoint}\n`)
console.log(`SSO Certificate: ${samlConfig.cert}\n`)
if (samlConfig.hiddenIdP) {
console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!')
console.log('!!!!!!!!!!!!!!! WARNING !!!!!!!!!!!!!!!')
console.log(
`The IdP metadata indicates it should be\nhidden from discovery. Check this is\nthe correct entity ID before using.`
)
console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!')
console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!')
}
}
try {
await main()
} catch (error) {
console.error(error)
}

View File

@@ -0,0 +1,40 @@
{
"name": "ukamf",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"@fidm/x509": "^1.2.1"
}
},
"node_modules/@fidm/asn1": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@fidm/asn1/-/asn1-1.0.4.tgz",
"integrity": "sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==",
"dev": true,
"engines": {
"node": ">= 8"
}
},
"node_modules/@fidm/x509": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@fidm/x509/-/x509-1.2.1.tgz",
"integrity": "sha512-nwc2iesjyc9hkuzcrMCBXQRn653XuAUKorfWM8PZyJawiy1QzLj4vahwzaI25+pfpwOLvMzbJ0uKpWLDNmo16w==",
"dev": true,
"dependencies": {
"@fidm/asn1": "^1.0.4",
"tweetnacl": "^1.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"dev": true
}
}
}

View File

@@ -0,0 +1,6 @@
{
"devDependencies": {
"@fidm/x509": "^1.2.1"
},
"type": "module"
}

View File

@@ -0,0 +1,28 @@
import fs from 'fs'
import xml2js from 'xml2js'
import UKAMFEntity from './ukamf-entity.js'
class UKAMFDB {
constructor(file) {
this.file = file
}
async init() {
const data = await fs.promises.readFile(this.file, 'utf8')
const parser = new xml2js.Parser()
const xml = await parser.parseStringPromise(data)
this.entities = xml.EntitiesDescriptor.EntityDescriptor
}
findByEntityID(matcher) {
const entity = this.entities.find(
matcher instanceof RegExp
? e => e.$.entityID.match(matcher)
: e => e.$.entityID.includes(matcher)
)
return entity ? new UKAMFEntity(entity) : null
}
}
export default UKAMFDB

View File

@@ -0,0 +1,69 @@
import _ from 'lodash'
class UKAMFEntity {
constructor(data) {
this.data = data
}
getSamlConfig() {
let hiddenIdP = false
const idp = this.data.IDPSSODescriptor[0]
const idpMetaData =
_.get(this.data, [
'Extensions',
0,
'mdattr:EntityAttributes',
0,
'saml:Attribute',
]) || []
idpMetaData.forEach(data => {
const value = _.get(data, ['saml:AttributeValue', 0])
if (
value === 'http://refeds.org/category/hide-from-discovery' ||
value === 'https://refeds.org/category/hide-from-discovery'
) {
hiddenIdP = true
}
})
const keys = idp.KeyDescriptor
const signingKey =
keys.length === 1
? keys[0]
: keys.find(key => _.get(key, ['$', 'use']) === 'signing')
const entityId = this.data.$.entityID
let cert = _.get(signingKey, [
'ds:KeyInfo',
0,
'ds:X509Data',
0,
'ds:X509Certificate',
0,
])
if (!cert) {
throw new Error('no cert')
}
cert = cert.replace(/\s/g, '')
let entryPoint = idp.SingleSignOnService.find(
sso =>
_.get(sso, ['$', 'Binding']) ===
'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'
)
entryPoint = _.get(entryPoint, ['$', 'Location'])
if (!entryPoint) {
throw new Error('no entryPoint')
}
return {
cert,
entityId,
entryPoint,
hiddenIdP,
}
}
}
export default UKAMFEntity