This commit is contained in:
XD 2022-10-16 11:30:13 +08:00
parent 0620bb1d26
commit be6654cdc4
46 changed files with 21591 additions and 1916 deletions

5
.env.example Normal file
View File

@ -0,0 +1,5 @@
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=root
DB_PASSWD=123456
DB_NAME=casdoor

25
.eslintrc.js Normal file
View File

@ -0,0 +1,25 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir : __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};

82
.gitignore vendored
View File

@ -1,62 +1,36 @@
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
cache
/config.js
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.env

4
.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

139
README.md
View File

@ -1,139 +0,0 @@
# Virtual-LDAP
Virtual-LDAP is a service used to bridge any other account services to the LDAP protocol. With Virtual-LDAP, you can use existing account services (such as DingTalk) as an authorization service for many open source projects.
Virtual-LDAP has a provider architecture, so you can extend it with a custom provider to support any other account service, such as a database-based account service.
Virtual-LDAP is not a complete implementation of LDAP and currently only supports partial binding, search, and modification requests. All directory groups and users will be obtained from the provider.
The database used for Virtual-LDAP is used to store user passwords. Passwords will be hashed using SHA256 plus a salt value.
## Configuration
Virtual-LDAP using JavaScript to configure all settings, include DN, admins, database, provider and custom groups.
For every configuration items, see example config file below.
```javascript
module.exports = {
ldap: {
// LDAP serve port, it is a insecure port, please connect with ldap://
listenPort: 1389,
// Base DN will be o=Example,dc=example,dc=com
// Groups base DN will be ou=Groups,o=Example,dc=example,dc=com
// Users base DN will be ou=People,o=Example,dc=example,dc=com
rootDN: 'dc=example,dc=com',
organization: 'Example',
// Admins who can search or modify directory
admins: [
{
// Bind DN will be cn=keycloak,dc=example,dc=com
commonName: 'keycloak',
password: 'keycloak',
// Can this admin modify user's password
canModifyEntry: true,
},
{
commonName: 'jenkins',
password: 'jenkins',
canModifyEntry: false,
},
]
},
// Database for storing users' password
database: {
type: 'mysql',
host: '127.0.0.1',
port: '23306',
user: 'root',
password: '123456',
database: 'vldap',
},
// Provider for providen account service
provider: {
name: 'dingtalk',
appKey: '__APPKEY__',
appSecret: '__APPSECRET__',
},
// Custom groups, base DN will be ou=CustomGroups,ou=Groups,o=Example,dc=example,dc=com
customGroups: [
{
// DN will be ou=Jenkins Admins,ou=CustomGroups,ou=Groups,o=Example,dc=example,dc=com
name: 'Jenkins Admins',
// User with these mails will be added to the group
members: [ 'jenkins@example.com' ],
}
]
}
```
## LDAP DN
For Virtual-LDAP using the above configuration file, the commonly used DNs are as follows.
**Root DN**
`dc=example,dc=com`
**Search Directory Bind DN**
`cn=keycloak,dc=example,dc=com`
**Groups Base DN**
`ou=Groups,o=Example,dc=example,dc=com`
**Users Base DN**
`ou=People,o=Example,dc=example,dc=com`
**Custom Groups Base DN**
`ou=CustomGroups,ou=Groups,o=Example,dc=example,dc=com`
**Jenkins Admins DN**
`ou=Jenkins Admins,ou=CustomGroups,ou=Groups,o=Example,dc=example,dc=com`
**Typical User DN**
`mail=user@example.com,ou=People,o=Example,dc=example,dc=com`
## Run Virtual-LDAP
Virtual-LDAP can run from source or run as a service in another project.
### Run from source
```bash
git clone https://github.com/ohdarling/virtual-ldap
cd virtual-ldap
npm start
```
### Run as a service
```javascript
const server = require('virtual-ldap');
server.setupVirtualLDAPServer(require("./config"));
server.runVirtualLDAPServer();
```
## Testing with ApacheDirectoryStudio
<img src="screenshots/1-create-connection.png" alt="create connection" width="624" /><img src="screenshots/2-base-auth.png" alt="auth" width="624" />
<img src="screenshots/3-ldap-browse.png" alt="ldap browse" width="878" />
## License
MIT License

View File

@ -1,50 +0,0 @@
module.exports = {
ldap: {
// LDAP serve port, it is a insecure port, please connect with ldap://
listenPort: 1389,
// Base DN will be o=Example,dc=example,dc=com
// Groups base DN will be ou=Groups,o=Example,dc=example,dc=com
// Users base DN will be ou=People,o=Example,dc=example,dc=com
rootDN: 'dc=example,dc=com',
organization: 'Example',
// Admins who can search or modify directory
admins: [
{
// Bind DN will be cn=keycloak,dc=example,dc=com
commonName: 'keycloak',
password: 'keycloak',
// Can this admin modify user's password
canModifyEntry: true,
},
{
commonName: 'jenkins',
password: 'jenkins',
canModifyEntry: false,
},
]
},
// Database for storing users' password
database: {
type: 'mysql',
host: '127.0.0.1',
port: '23306',
user: 'root',
password: '123456',
database: 'vldap',
},
// Provider for providen account service
provider: {
name: 'dingtalk',
appKey: '__APPKEY__',
appSecret: '__APPSECRET__',
},
// Custom groups, base DN will be ou=CustomGroups,ou=Groups,o=Example,dc=example,dc=com
customGroups: [
{
// DN will be ou=Jenkins Admins,ou=CustomGroups,ou=Groups,o=Example,dc=example,dc=com
name: 'Jenkins Admins',
// User with these mails will be added to the group
members: [ 'jenkins@example.com' ],
}
]
}

View File

@ -1,8 +0,0 @@
const server = require("./lib");
if (require.main === module) {
server.setupVirtualLDAPServer(require("./config.example"));
server.runVirtualLDAPServer();
} else {
module.exports = server;
}

View File

@ -1 +0,0 @@
module.exports = require("./index").serverConfig;

View File

@ -1,38 +0,0 @@
const {
type: dbType
} = require('../config').database;
const {
dbSelect,
dbInsert,
dbUpdate,
} = require('./' + dbType);
const TABLE_USER_CREDENTIALS = 'user_credentials';
async function getDBRecordForUserId(uid) {
const [ record ] = await dbSelect(TABLE_USER_CREDENTIALS, { uid });
if (record) {
return record;
}
return {
userid: uid,
password: '123456',
};
}
async function saveDBRecordForUserId(uid, data) {
const [ record ] = await await dbSelect(TABLE_USER_CREDENTIALS, { uid });
if (record) {
await dbUpdate(TABLE_USER_CREDENTIALS, data, { uid });
} else {
await dbInsert(TABLE_USER_CREDENTIALS, Object.assign({ uid }, data));
}
}
module.exports = {
getDBRecordForUserId,
saveDBRecordForUserId,
};

View File

@ -1,17 +0,0 @@
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for user_credentials
-- ----------------------------
DROP TABLE IF EXISTS `user_credentials`;
CREATE TABLE `user_credentials` (
`uid` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`password` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`otpsecret` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`uid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SET FOREIGN_KEY_CHECKS = 1;

View File

@ -1,19 +0,0 @@
const table = {};
async function dbSelect(table, { uid }) {
return [ table[uid] ];
}
async function dbUpdate(table, params, { uid }) {
table[uid] = Object.assign({}, table[uid] || {}, params);
}
async function dbInsert(table, params) {
table[params.uid] = params;
}
module.exports = {
dbSelect,
dbUpdate,
dbInsert,
};

View File

@ -1,72 +0,0 @@
const mysql = require('mysql2');
const {
database: dbConfig,
} = require('../config');
let dbConnected = false;
let dbPool = null;
async function connect() {
if (!dbConnected) {
const opts = Object.assign({}, dbConfig);
delete opts.type;
dbPool = mysql.createPool(opts).promise();
dbConnected = true;
}
}
async function dbQuery(sql, params) {
connect();
const [ rows, fields ] = await dbPool.query(sql, params);
return rows;
}
async function dbSelect(table, params) {
const fields = Object.keys(params);
const values = fields.map(k => params[k]);
const sql = `SELECT * FROM ${table} WHERE ${fields.map(k => `${k}=?`).join(' AND ')}`;
return dbQuery(sql, values);
}
async function dbUpdate(table, params, conditions) {
const fields = Object.keys(params);
const values = fields.map(k => params[k]);
let conditionSQL = '';
if (conditions) {
const cfields = Object.keys(conditions);
const cvalues = cfields.map(k => conditions[k]);
conditionSQL = ` WHERE ${cfields.map(k => `${k}=?`).join(' AND ')}`;
values.push(...cvalues);
}
const sql = `UPDATE ${table} SET ${fields.map(k => `${k}=?`).join(', ')} ${conditionSQL}`;
return dbQuery(sql, values);
}
async function dbInsert(table, params) {
const fields = Object.keys(params);
const values = fields.map(k => params[k]);
const sql = `INSERT INTO ${table} (${fields.join(', ')}) VALUES (${fields.map(k => '?').join(', ')})`;
return dbQuery(sql, values);
}
async function dbDelete(table, conditions) {
const values = [];
let conditionSQL = '';
if (conditions) {
const cfields = Object.keys(conditions);
const cvalues = fields.map(k => conditions[k]);
conditionSQL = ` WHERE ${cfields.map(k => `${k}=?`).join(' AND ')}`;
values.push(...cvalues);
}
const sql = `DELETE FROM ${table} ${conditionSQL}`
return dbQuery(sql, values);
}
module.exports = {
dbQuery,
dbSelect,
dbUpdate,
dbInsert,
dbDelete,
};

View File

@ -1,17 +0,0 @@
require('log-node')();
const serverConfig = {};
function setupVirtualLDAPServer(config) {
Object.assign(serverConfig, config);
}
function runVirtualLDAPServer() {
require("./server").runVirtualLDAPServer();
}
module.exports = {
serverConfig,
setupVirtualLDAPServer,
runVirtualLDAPServer,
};

View File

@ -1,315 +0,0 @@
const axios = require('axios');
const log = require('log').get('dingtalk-provider');
const {
makeGroupEntry,
makePersonEntry,
makeOrganizationUnitEntry,
addMemberToGroup,
} = require('../utilities/ldap');
const {
saveCacheToFile,
loadCacheFromFile,
} = require('../utilities/cache');
let appKey = '';
let appSecret = '';
let accessToken = '';
let allLDAPUsers = [];
let allLDAPOrgUnits = [];
let allLDAPGroups = [];
let allLDAPEntries = [];
function api(path) {
return `https://oapi.dingtalk.com/${path}?access_token=${accessToken}`;
}
function parseName(name) {
// 如果名字没有空格,以中文处理,第一个字为姓,其他为名
// 如果有空格,以英文处理,最后一个单词为姓,其他为名
let givenName = name.substr(1);
let sn = name.substr(0, 1);
if (name.indexOf(' ') > 0) {
const parts = name.split(' ');
sn = parts.pop();
givenName = parts.join(' ');
}
return { givenName, sn };
}
async function ddGet(path, params) {
const apiUrl = path.substr(0, 8) === 'https://' ? path : api(path);
const ret = await axios(apiUrl, { params }).catch(e => {
log.error("Dingtalk API error", e);
return null;
});
if (ret && ret.data) {
if (ret.data.errcode != 0) {
log.error('Dingtalk API error', ret.data);
return null;
} else {
return ret.data;
}
} else {
log.error('Dingtalk API error', ret);
}
return null;
}
async function getToken() {
const token = await ddGet(`https://oapi.dingtalk.com/gettoken?appkey=${appKey}&appsecret=${appSecret}`);
if (token && token.access_token) {
accessToken = token.access_token;
} else {
log.error('Get access token failed', token);
}
}
/*
{
'100560627': {
ext: '{"faceCount":"92"}',
createDeptGroup: true,
name: 'Product & Dev / 产品技术',
id: 100560627,
autoAddUser: true,
parentid: 111865024,
dn: 'ou=Product & Dev / 产品技术, ou=全员, o=LongBridge, dc=longbridge-inc, dc=com'
},
*/
async function fetchAllDepartments() {
let allDeps = loadCacheFromFile('dingtalk_groups.json');
if (!allDeps) {
const deps = await ddGet('department/list', {
fetch_child: true,
id: 1,
});
if (!deps) {
return [];
}
log.info('Got', deps.department.length, 'departments');
const depsMap = {
'1': {
name: 'Staff',
id: 1,
parentid: null,
},
};
deps.department.forEach(d => {
d.name = d.name.replace(/ \/ /g, ' - ').replace(/\//g, '&').trim();
depsMap[d.id] = d;
});
allDeps = Object.values(depsMap);
const allDepNames = {};
allDeps.forEach(v => {
let name = v.name;
let idx = 2;
while (allDepNames[name]) {
name = v.name + idx;
idx++;
}
allDepNames[name] = 1;
v.name = name;
})
saveCacheToFile('dingtalk_groups.json', allDeps);
}
const depsMap = {};
allDeps.forEach(d => { depsMap[d.id] = d; });
allDeps.forEach(d => {
let obj = d;
let dn = [ `ou=${obj.name}` ];
while (obj.parentid) {
obj = depsMap[obj.parentid];
dn.push(`ou=${obj.name}`);
}
d.dn = dn.join(',');
});
return allDeps;
}
/*
{
unionid: 'DLB1ru0iiZ6rB8z4juaJwWAiEiE',
openId: 'DLB1ru0iiZ6rB8z4juaJwWAiEiE',
remark: '',
userid: '0156594846753096123',
isBoss: false,
hiredDate: 1539532800000,
tel: '',
department: [ 109334341 ],
workPlace: '杭州',
email: '',
order: 177917621779460500,
isLeader: false,
mobile: '18815286506',
active: true,
isAdmin: false,
avatar: 'https://static-legacy.dingtalk.com/media/lADPACOG819UTLzNAu7NAu4_750_750.jpg',
isHide: false,
orgEmail: 'ke.xu@longbridge.sg',
jobnumber: '0049',
name: '徐克',
extattr: {},
stateCode: '86',
position: 'iOS开发工程师'
}
*/
async function fetchDepartmentUsers(department) {
log.info('get users for department', department);
const userlist = [];
let hasMore = true;
let offset = 0;
while (hasMore) {
const users = await ddGet('user/listbypage', {
department_id: department.id,
offset,
size: 100,
order: 'entry_asc',
});
userlist.push(...users.userlist);
hasMore = users.hasMore;
}
userlist.forEach(u => {
u.firstDepartment = department;
});
return userlist;
}
async function fetchAllUsers(departments) {
let allUsers = loadCacheFromFile('dingtalk_users.json');
if (!allUsers && departments.length > 0) {
allUsers = [];
for (let i = 0; i < departments.length; ++i) {
allUsers.push(...(await fetchDepartmentUsers(departments[i])));
}
saveCacheToFile('dingtalk_users.json', allUsers);
}
return allUsers;
}
async function setupProvider(config) {
appKey = config.appKey;
appSecret = config.appSecret;
await reloadFromDingtalkServer();
}
async function reloadFromDingtalkServer() {
await getToken();
// 获取所有部门
let allDepartments = await fetchAllDepartments();
// 映射到 organizationalUnit
const allDepartmentsMap = {};
allLDAPOrgUnits = allDepartments.map(d => {
allDepartmentsMap[d.id] = d;
return makeOrganizationUnitEntry(d.dn, d.name, {
groupid: d.id,
});
});
// 映射到 groupOfNames
const allLDAPGroupsMap = [];
allLDAPGroups = allDepartments.map(d => {
const g = makeGroupEntry(d.dn, d.name, [], {
groupid: d.id,
});
allLDAPGroupsMap[d.id] = g;
return g;
});
Object.values(allDepartmentsMap).forEach(dep => {
if (dep.parentid) {
const parentDep = allDepartmentsMap[dep.parentid];
addMemberToGroup(allLDAPGroupsMap[dep.id], allLDAPGroupsMap[parentDep.id]);
}
})
// 按部门获取所有员工
const allUsers = await fetchAllUsers(allDepartments);
const allUsersMap = {};
allLDAPUsers = allUsers.filter(u => {
if (!allUsersMap[u.userid]) {
allUsersMap[u.userid] = 1;
return u.active;
}
return false;
}).filter(u => {
if (!(u.orgEmail || u.email)) {
log.warn('Incorrect user missing email', u);
return false;
}
return true;
}).map(u => {
const mail = (u.orgEmail || u.email).toLowerCase();
const dn = `mail=${mail},${u.firstDepartment.dn}`;
const { givenName, sn } = parseName(u.name);
// 映射到 iNetOrgPerson
const personEntry = makePersonEntry(dn, {
uid: u.userid,
title: u.position,
mobileTelephoneNumber: u.mobile,
cn: u.name,
givenName,
sn,
mail,
avatarurl: u.avatar,
});
// 将用户加到组里
u.department.forEach(depId => {
let parentDep = allDepartmentsMap[depId];
// allLDAPGroupsMap[parentDep.id].attributes.member.push(personEntry.dn);
while (parentDep && parentDep.id) {
addMemberToGroup(personEntry, allLDAPGroupsMap[parentDep.id]);
// console.log('add member', personEntry.attributes.cn, 'to', allLDAPGroupsMap[parentDep.id].attributes.cn);
parentDep = allDepartmentsMap[parentDep.parentid];
}
})
return personEntry;
});
allLDAPEntries = [].concat(allLDAPGroups, allLDAPOrgUnits, allLDAPUsers);
}
function getAllLDAPEntries() {
return allLDAPEntries;
}
function reloadEntriesFromProvider() {
log.info('Reload entries from Dingtalk');
reloadFromDingtalkServer();
}
// if (0) {
// (async function() {
// await setupProvider(require('../config').provider);
// log.info(getAllLDAPEntries());
// })();
// setTimeout(() => {}, 0);
// }
module.exports = {
setupProvider,
getAllLDAPEntries,
reloadEntriesFromProvider,
};

View File

@ -1,246 +0,0 @@
const ldap = require('ldapjs');
const log = require('log').get('server');
const CronJob = require('cron').CronJob;
const {
ldap: ldapConfig,
} = require('./config');
const { parseDN, parseFilter } = ldap;
const {
validateUserPassword,
storeUserPassword,
} = require('./utilities/password');
const {
getBaseEntries,
getRootDN,
getOrganizationBaseDN,
validateAdminPassword,
validateAdminPermission,
} = require('./utilities/ldap');
const {
createProvider,
getProviderLDAPEntries,
reloadEntriesFromProvider,
} = require('./utilities/provider');
const {
getLDAPCustomGroupEntries,
} = require('./utilities/custom_groups');
const {
getDBRecordForUserId,
saveDBRecordForUserId,
} = require('./db/db');
let server = ldap.createServer();
// equalsDN for comparing dn in case-insensitive
function equalsDN(a, b) {
a = parseDN((a instanceof String ? a : a.toString()).toLowerCase());
b = parseDN((b instanceof String ? b : b.toString()).toLowerCase());
return a.equals(b);
}
function getPersonMatchedDN(reqDN) {
const personFilter = parseFilter('(objectclass=inetOrgPerson)');
const [ matchedUser ] = getProviderLDAPEntries().filter(entry => {
return equalsDN(reqDN, entry.dn) && personFilter.matches(entry.attributes);
});
return matchedUser;
}
function authorize(req, res, next) {
log.debug('===> authorize info');
log.debug('req type', req.constructor.name);
log.debug('reqDN', (req.baseObject || '').toString());
log.debug('bindDN', req.connection.ldap.bindDN.toString());
log.debug('====');
const bindDN = req.connection.ldap.bindDN;
const rootDN = parseDN(getRootDN());
// person can search itself
if (req instanceof ldap.SearchRequest) {
if (equalsDN(bindDN, req.baseObject)) {
return next();
}
}
// root admin can do everything
if (equalsDN(bindDN.parent(), rootDN)) {
return next();
}
return next(new ldap.InsufficientAccessRightsError());
}
// DSE
server.search('', authorize, function(req, res, next) {
var baseObject = {
dn: '',
structuralObjectClass: 'OpenLDAProotDSE',
configContext: 'cn=config',
attributes: {
objectclass: ['top', 'OpenLDAProotDSE'],
namingContexts: [ getRootDN() ],
supportedLDAPVersion: ['3'],
subschemaSubentry:['cn=Subschema']
}
};
log.info("scope "+req.scope+" filter "+req.filter+" baseObject "+req.baseObject);
if('base' == req.scope
&& '(objectclass=*)' == req.filter.toString()
&& req.baseObject == ''){
res.send(baseObject);
}
//log.info('scope: ' + req.scope);
//log.info('filter: ' + req.filter.toString());
//log.info('attributes: ' + req.attributes);
res.end();
return next();
});
server.search('cn=Subschema', authorize, function(req, res, next) {
var schema = {
dn: 'cn=Subschema',
attributes: {
objectclass: ['top', 'subentry', 'subschema', 'extensibleObject'],
cn: ['Subschema']
}
};
res.send(schema);
res.end();
return next();
});
server.search(getRootDN(), authorize, (req, res, next) => {
log.info("search req scope " + req.scope + " filter " + req.filter + " baseObject " + req.baseObject);
// console.log('sizeLimit', req.sizeLimit, 'timeLimit', req.timeLimit);
const reqDN = parseDN(req.baseObject.toString().toLowerCase());
const comparators = {
'one': (objDN) => {
return equalsDN(reqDN, objDN.parent());
},
'sub': (objDN) => {
return equalsDN(reqDN, objDN) || reqDN.parentOf(objDN);
},
'base': (objDN) => {
return equalsDN(reqDN, objDN);
},
}
const comparator = comparators[req.scope];
if (comparator) {
[
...getBaseEntries(),
...getProviderLDAPEntries(),
...getLDAPCustomGroupEntries(),
].filter(entry => {
return comparator(parseDN(entry.dn.toLowerCase())) && req.filter.matches(entry.attributes);
}).forEach(entry => {
log.debug('send entry', entry.dn);
res.send(entry);
});
}
res.end();
return next();
});
server.modify(getOrganizationBaseDN(), authorize, async (req, res, next) => {
// DN: uid=zhangsan@alibaba-inc.com, ou=HR, o=LongBridge, dc=longbridge-inc, dc=com
// changes:
// operation: replace
// modification: {"type":"userpassword","vals":["{SMD5}jFCfdWsVs6GF/1CO2+2ZBwIevCudAH7v"]}
const reqDN = parseDN(req.dn.toString().toLowerCase());
const username = req.connection.ldap.bindDN.rdns[0].attrs.cn.value;
log.debug('modify req', reqDN.toString());
if (!validateAdminPermission(username, 'canModifyEntry')) {
return next(new ldap.InsufficientAccessRightsError());
}
const matchedUser = getPersonMatchedDN(reqDN);
// console.log('modify user', matchedUser);
if (matchedUser) {
// console.log('DN: ' + req.dn.toString());
// console.log('changes:');
for (let i = 0; i < req.changes.length; ++i) {
const c = req.changes[i];
// console.log(' operation: ' + c.operation);
// console.log(' modification: ' + c.modification.toString());
if (c.operation === 'replace' && c.modification.type === 'userpassword') {
let newpass = c.modification.vals[0];
if (newpass.substr(0, 9) !== '{SSHA256}') {
newpass = storeUserPassword(newpass);
}
await saveDBRecordForUserId(matchedUser.attributes.uid, {
password: newpass,
});
// console.log('modify user', matchedUser.attributes.uid, 'pass to', newpass);
}
if (c.operation === 'replace' && c.modification.type === 'otpsecret') {
let newsecret = c.modification.vals[0];
// console.log('modify user', matchedUser.dn, 'otp secret to', newsecret);
await saveDBRecordForUserId(matchedUser.attributes.uid, {
otpsecret: newsecret,
});
}
}
}
res.end();
return next();
});
server.bind(getRootDN(), async (req, res, next) => {
const reqDN = parseDN(req.dn.toString().toLowerCase());
log.debug('bind req', reqDN.toString())
// console.log('bind PW: ' + req.credentials);
if (parseDN(getRootDN().toLowerCase()).equals(reqDN.parent())) {
// admins
const username = reqDN.rdns[0].attrs.cn.value;
if (validateAdminPassword(username, req.credentials)) {
res.end();
return next();
} else {
return next(new ldap.InvalidCredentialsError());
}
} else if (parseDN(getOrganizationBaseDN().toLowerCase()).parentOf(reqDN)) {
// users
const matchedUser = getPersonMatchedDN(reqDN);
if (matchedUser) {
const record = await getDBRecordForUserId(matchedUser.attributes.uid);
if (record && validateUserPassword(record.password, req.credentials)) {
res.end();
return next();
} else {
log.debug('password failed');
return next(new ldap.InvalidCredentialsError());
}
} else {
log.debug('user not found');
return next(new ldap.InvalidCredentialsError());
}
}
return next(new ldap.InsufficientAccessRightsError());
});
function runVirtualLDAPServer() {
server.listen(ldapConfig.listenPort, async function() {
await createProvider();
log.info('virtual-ldap listening at ' + server.url);
});
// reload data from server every hour
new CronJob('0 0 * * * *', () => {
reloadEntriesFromProvider();
}, null, true, 'Asia/Shanghai').start();
}
module.exports = {
runVirtualLDAPServer,
};

View File

@ -1,47 +0,0 @@
const path = require('path');
const fs = require('fs');
const log = require('log').get('cache');
function getCachePath(name) {
const folder = path.join(__dirname, '..', '..', 'cache');
if (!fs.existsSync(folder)) {
fs.mkdirSync(folder);
}
const p = path.resolve(path.join(folder, name));
return p
}
function saveCacheToFile(name, obj, expires = 3600) {
const p = getCachePath(name);
const data = {
expires: new Date((new Date() * 1 + expires * 1000)).toISOString(),
data: obj,
};
fs.writeFileSync(p, JSON.stringify(data), { encoding: 'utf8' });
}
function loadCacheFromFile(name) {
const p = getCachePath(name);
let data = null;
if (fs.existsSync(p)) {
const content = fs.readFileSync(p, { encoding: 'utf8' });
try {
const obj = JSON.parse(content);
if (obj && obj.data && obj.expires) {
const now = new Date() * 1;
const expires = new Date(obj.expires) * 1;
if (now < expires) {
data = obj.data;
}
}
} catch (e) {
log.warn('Invalid cache for', name);
}
}
return data;
}
module.exports = {
saveCacheToFile,
loadCacheFromFile,
};

View File

@ -1,44 +0,0 @@
const {
parseFilter,
} = require('ldapjs');
const {
getProviderLDAPEntries,
} = require('./provider');
const {
makeGroupEntry,
addMemberToGroup,
} = require('./ldap');
const {
customGroups,
} = require('../config');
function getLDAPCustomGroupEntries() {
if (!customGroups) {
return [];
}
const baseGroupEntry = makeGroupEntry('ou=CustomGroups', 'CustomGroups', []);
const allEntries = getProviderLDAPEntries();
const groupEntries = customGroups.map(g => {
const groupEntry = makeGroupEntry(`ou=${g.name},ou=CustomGroups`, g.name, []);
addMemberToGroup(groupEntry, baseGroupEntry);
const members = allEntries.filter(o => {
return g.members.indexOf(o.attributes.mail) >= 0;
});
members.forEach(p => {
addMemberToGroup(p, groupEntry);
});
return groupEntry;
});
return [].concat(baseGroupEntry, groupEntries);
}
module.exports = {
getLDAPCustomGroupEntries,
};

View File

@ -1,225 +0,0 @@
const {
ldap: ldapConfig,
} = require('../config');
let cachedBaseEntries = null;
function getRootDN() {
return ldapConfig.rootDN;
}
function makeDN(...parts) {
return parts.join(',');
}
function getOrganizationBaseDN() {
return makeDN('o=' + ldapConfig.organization, getRootDN());
}
function getPeopleBaseDN() {
const dn = makeDN('ou=People', getOrganizationBaseDN());
return dn;
}
function getGroupsBaseDN() {
const dn = makeDN('ou=Groups', getOrganizationBaseDN());
return dn;
}
function makeGroupEntryDN(...prefix) {
const dn = makeDN(...prefix, getGroupsBaseDN());
return dn;
}
function makeOrganizationUnitEntryDN(...prefix) {
const dn = makeDN(...prefix, getPeopleBaseDN());
return dn;
}
function makeOrganizationUnitEntry(dn, name, attrs = {}) {
// dn: d.dn,
// attributes: {
// objectclass: ['organizationalUnit', 'top'],
// ou: d.name,
// }
const generatedDN = makeOrganizationUnitEntryDN(dn);
const entry = {
dn: generatedDN,
attributes: Object.assign({
objectclass: ['organizationalUnit', 'top'],
ou: name,
entryDN: dn,
}, attrs),
};
return entry;
}
function makePersonEntry(dn, attrs) {
// u.ldap = {
// dn: `mail=${mail}, ${depDN}`,
// attributes: {
// objectclass: ['inetOrgPerson', 'organizationalPerson', 'person', 'top'],
// uid: u.userid,
// title: u.position,
// mobileTelephoneNumber: u.mobile,
// cn: u.name,
// givenName: u.name.substr(1),
// sn: u.name.substr(0, 1),
// mail,
// userPassword: '123456',
// }
// };
const generatedDN = makeOrganizationUnitEntryDN(dn);
const entry = {
dn: generatedDN,
attributes: Object.assign({
objectclass: ['inetOrgPerson', 'organizationalPerson', 'person', 'top'],
userPassword: '********',
// otpSecret: 'abcd',
memberOf: [],
entryDN: dn,
}, attrs)
};
return entry;
}
function makeGroupEntry(dn, name, members, attrs = {}) {
// dn: d.dn,
// attributes: {
// objectclass: ['groupOfNames', 'top'],
// ou: d.name,
// }
const generatedDN = makeGroupEntryDN(dn);
const entry = {
dn: generatedDN,
attributes: Object.assign({
objectclass: ['groupOfNames', 'top'],
cn: name,
ou: name,
member: members,
memberOf: [],
entryDN: dn,
}, attrs),
};
return entry;
}
function getOrganizationEntry() {
const entry = {
dn: getOrganizationBaseDN(),
attributes: {
objectclass: ['organization', 'top'],
ou: ldapConfig.organization,
},
};
return entry;
}
function getPeopleBaseEntry() {
const entry = {
dn: getPeopleBaseDN(),
attributes: {
objectclass: ['organizationalUnit', 'top'],
ou: 'People',
},
};
return entry;
}
function getGroupsBaseEntry() {
const entry = {
dn: getGroupsBaseDN(),
attributes: {
objectclass: ['organizationalUnit', 'top'],
ou: 'Groups',
},
};
return entry;
}
function makeAdminEntry(attrs) {
// {dn: 'cn=admin,'+rootDN, attributes: { objectclass: ['simpleSecurityObject', 'organizationalRole'], hasSubordinates: ['FALSE'] } },
const entry = {
dn: [ `cn=${attrs.commonName}`, getRootDN() ].join(','),
attributes: { objectclass: ['simpleSecurityObject', 'organizationalRole'],
hasSubordinates: ['FALSE'] },
};
return entry;
}
function getAdminEntries() {
return ldapConfig.admins.map(cfg => {
return makeAdminEntry(cfg);
});
}
function getBaseEntries() {
if (!cachedBaseEntries) {
const rootDN = getRootDN();
const rootEntry = {
dn: rootDN,
attributes: {
objectclass: ['dcObject', 'organization', 'top'],
dc: rootDN.split(',')[0].split('=')[1].trim(),
o: ldapConfig.organization,
hasSubordinates: ['TRUE'],
}
};
cachedBaseEntries = [
rootEntry,
getOrganizationEntry(),
getPeopleBaseEntry(),
getGroupsBaseEntry(),
...getAdminEntries(),
];
}
return cachedBaseEntries;
}
function validateAdminPassword(username, password) {
const [ user ] = ldapConfig.admins.filter(u => {
return u.commonName == username;
});
if (user && user.password === password) {
return true;
}
return false;
}
function validateAdminPermission(username, permission) {
const [ user ] = ldapConfig.admins.filter(u => {
return u.commonName == username;
});
if (user && user[permission]) {
return true;
}
return false;
}
function addMemberToGroup(memberEntry, groupEntry) {
if (groupEntry.attributes.member.indexOf(memberEntry.dn) < 0) {
groupEntry.attributes.member.push(memberEntry.dn);
}
if (memberEntry.attributes.memberOf.indexOf(groupEntry.dn) < 0) {
memberEntry.attributes.memberOf.push(groupEntry.dn);
}
}
module.exports = {
getRootDN,
getOrganizationBaseDN,
getAdminEntries,
getBaseEntries,
makeGroupEntry,
makeOrganizationUnitEntry,
makePersonEntry,
validateAdminPassword,
validateAdminPermission,
addMemberToGroup,
};

View File

@ -1,45 +0,0 @@
const crypto = require('crypto');
function validateUserPassword(secret, input) {
if (secret.substr(0, 9) === '{SSHA256}') {
secret = secret.substr(9);
const secbin = Buffer.from(secret, 'base64');
const userpw = secbin.subarray(0, secbin.length - 8).toString('hex');
const salt = secbin.subarray(secbin.length - 8);
const hash = crypto.createHash('sha256');
hash.update(input);
hash.update(salt);
const inputpw = hash.digest('hex');
// console.log('userpw', userpw);
// console.log('inputpw', inputpw);
return inputpw === userpw;
} else {
return secret === input;
}
}
function storeUserPassword(input) {
const hash = crypto.createHash('sha256');
hash.update(input);
const salt = Buffer.alloc(8);
for (let i = 0; i < 8; ++i) {
const b = Math.floor(Math.random() * 256);
// console.log('salt byte', i, b);
salt.writeUInt8(b, i);
}
// console.log('salt', salt.toString('hex'));
hash.update(salt);
const digest = hash.digest();
const hashbin = Buffer.concat([ digest, salt ]);
// console.log('pwhash', digest.toString('hex'));
digest.write(salt.toString('hex'), 'hex');
// console.log('pwhash /w salt', hashbin.toString('hex'));
const pw = '{SSHA256}' + hashbin.toString('base64');
return pw;
}
module.exports = {
validateUserPassword,
storeUserPassword,
};

View File

@ -1,39 +0,0 @@
const {
provider: providerConfig,
} = require('../config');
const log = require('log').get('provider');
let provider = null;
async function createProvider() {
if (!provider) {
const name = providerConfig.name;
log.info('Setting up provider', name);
provider = require('../providers/' + name);
await provider.setupProvider(providerConfig);
log.info('Done');
}
return provider;
};
function getProviderLDAPEntries() {
if (provider) {
return provider.getAllLDAPEntries();
}
return [];
}
function reloadEntriesFromProvider() {
if (provider) {
log.info('Reload entries from provider');
provider.reloadEntriesFromProvider();
}
}
module.exports = {
createProvider,
getProviderLDAPEntries,
reloadEntriesFromProvider,
};

5
nest-cli.json Normal file
View File

@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

16028
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,39 +1,79 @@
{
"name": "virtual-ldap",
"version": "0.2.0",
"author": {
"name": "ohdarling88",
"url": "https://xujiwei.com"
},
"main": "index.js",
"files": [
"index.js",
"lib/**/*.js",
"config.example.js"
],
"keywords": [
"ldap",
"dingtalk",
"keycloak",
"钉钉",
"enterprise"
],
"repository": {
"type": "git",
"url": "https://github.com/ohdarling/virtual-ldap"
},
"license": "MIT",
"name": "server",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"start": "LOG_TIME=abs LOG_LEVEL=info node index.js"
"prebuild": "rimraf dist",
"build": "nest build",
"format": "prettier --write \\\"src/**/*.ts\\\" \\\"../test/**/*.ts\\\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ../test/jest-e2e.json"
},
"dependencies": {
"axios": "^0.21.2",
"cron": "^1.8.2",
"ldapjs": "^1.0.2",
"log": "^6.0.0",
"log-node": "^7.0.0",
"mysql2": "^2.1.0",
"otplib": "^12.0.1",
"minimist": ">=0.2.1"
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.0.0",
"@nestjs/mapped-types": "*",
"@nestjs/platform-express": "^9.0.0",
"@nestjs/typeorm": "^9.0.1",
"@types/ldapjs": "^2.2.4",
"ldapjs": "^2.3.3",
"mysql2": "^2.3.3",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0",
"typeorm": "^0.3.10",
"typeorm-naming-strategies": "^4.1.0"
},
"devDependencies": {
"@nestjs/cli": "^9.0.0",
"@nestjs/schematics": "^9.0.0",
"@nestjs/testing": "^9.0.0",
"@types/express": "^4.17.13",
"@types/jest": "28.1.8",
"@types/node": "^16.0.0",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"jest": "28.1.3",
"prettier": "^2.3.2",
"source-map-support": "^0.5.20",
"supertest": "^6.1.3",
"ts-jest": "28.0.8",
"ts-loader": "^9.2.3",
"ts-node": "^10.0.0",
"tsconfig-paths": "4.1.0",
"typescript": "^4.7.4"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@ -0,0 +1,26 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
exports.__esModule = true;
exports.AppController = void 0;
var common_1 = require("@nestjs/common");
var AppController = /** @class */ (function () {
function AppController(appService) {
this.appService = appService;
}
AppController.prototype.getHello = function () {
return this.appService.getHello();
};
__decorate([
(0, common_1.Get)()
], AppController.prototype, "getHello");
AppController = __decorate([
(0, common_1.Controller)()
], AppController);
return AppController;
}());
exports.AppController = AppController;

View File

@ -0,0 +1,64 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (_) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
exports.__esModule = true;
var testing_1 = require("@nestjs/testing");
var app_controller_1 = require("./app.controller");
var app_service_1 = require("./app.service");
describe('AppController', function () {
var appController;
beforeEach(function () { return __awaiter(void 0, void 0, void 0, function () {
var app;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, testing_1.Test.createTestingModule({
controllers: [app_controller_1.AppController],
providers: [app_service_1.AppService]
}).compile()];
case 1:
app = _a.sent();
appController = app.get(app_controller_1.AppController);
return [2 /*return*/];
}
});
}); });
describe('root', function () {
it('should return "Hello World!"', function () {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

25
server/src/app.module.js Normal file
View File

@ -0,0 +1,25 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
exports.__esModule = true;
exports.AppModule = void 0;
var common_1 = require("@nestjs/common");
var app_controller_1 = require("./app.controller");
var app_service_1 = require("./app.service");
var AppModule = /** @class */ (function () {
function AppModule() {
}
AppModule = __decorate([
(0, common_1.Module)({
imports: [],
controllers: [app_controller_1.AppController],
providers: [app_service_1.AppService]
})
], AppModule);
return AppModule;
}());
exports.AppModule = AppModule;

22
server/src/app.service.js Normal file
View File

@ -0,0 +1,22 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
exports.__esModule = true;
exports.AppService = void 0;
var common_1 = require("@nestjs/common");
var AppService = /** @class */ (function () {
function AppService() {
}
AppService.prototype.getHello = function () {
return 'Hello World!';
};
AppService = __decorate([
(0, common_1.Injectable)()
], AppService);
return AppService;
}());
exports.AppService = AppService;

57
server/src/main.js Normal file
View File

@ -0,0 +1,57 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (_) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
exports.__esModule = true;
var core_1 = require("@nestjs/core");
var app_module_1 = require("./app.module");
function bootstrap() {
return __awaiter(this, void 0, void 0, function () {
var app;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, core_1.NestFactory.create(app_module_1.AppModule)];
case 1:
app = _a.sent();
return [4 /*yield*/, app.listen(3000)];
case 2:
_a.sent();
return [2 /*return*/];
}
});
});
}
bootstrap();

View File

@ -0,0 +1,67 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (_) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
exports.__esModule = true;
var testing_1 = require("@nestjs/testing");
var request = require("supertest");
var app_module_1 = require("./../src/app.module");
describe('AppController (e2e)', function () {
var app;
beforeEach(function () { return __awaiter(void 0, void 0, void 0, function () {
var moduleFixture;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, testing_1.Test.createTestingModule({
imports: [app_module_1.AppModule]
}).compile()];
case 1:
moduleFixture = _a.sent();
app = moduleFixture.createNestApplication();
return [4 /*yield*/, app.init()];
case 2:
_a.sent();
return [2 /*return*/];
}
});
}); });
it('/ (GET)', function () {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

32
src/app.module.ts Normal file
View File

@ -0,0 +1,32 @@
import {Module} from '@nestjs/common';
import {LdapModule} from './ldap/ldap.module';
import {UserModule} from './user/user.module';
import {TypeOrmModule} from "@nestjs/typeorm";
import {ConfigModule, ConfigService} from "@nestjs/config";
import {SnakeNamingStrategy} from 'typeorm-naming-strategies';
const DataModule = TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
type: 'mysql',
host: configService.get('DB_HOST'),
port: +configService.get('DB_PORT'),
username: configService.get('DB_USER'),
password: configService.get('DB_PASSWD'),
database: configService.get('DB_NAME'),
entities: [],
synchronize: false,
autoLoadEntities: true,
namingStrategy: new SnakeNamingStrategy(),
}),
inject: [ConfigService],
});
@Module({
imports: [ConfigModule.forRoot(), DataModule, LdapModule, UserModule],
controllers: [],
providers: [],
})
export class AppModule {
}

19
src/ldap.config.ts Normal file
View File

@ -0,0 +1,19 @@
export const ldapConfig = {
// LDAP serve port, it is a insecure port, please connect with ldap://
listenPort: 1389,
// Base DN will be o=Example,dc=example,dc=com
// Groups base DN will be ou=Groups,o=Example,dc=example,dc=com
// Users base DN will be ou=People,o=Example,dc=example,dc=com
rootDN: 'dc=example,dc=com',
organization: 'Example',
// Admins who can search or modify directory
admins: [
{
commonName: 'admin',
password: '123456',
canModifyEntry: false,
},
]
}

82
src/ldap/function.ts Normal file
View File

@ -0,0 +1,82 @@
import {createServer, InsufficientAccessRightsError, parseDN, parseFilter, SearchRequest} from "ldapjs";
import {getRootDN} from "../utils/ldap";
import {LdapService} from "./ldap.service";
export function equalsDN(a, b) {
a = parseDN((a instanceof String ? a : a.toString()).toLowerCase());
b = parseDN((b instanceof String ? b : b.toString()).toLowerCase());
return a.equals(b);
}
export function getPersonMatchedDN(reqDN) {
const personFilter = parseFilter('(objectclass=inetOrgPerson)');
return {};
}
function authorize(req, res, next) {
console.debug('===> authorize info');
console.debug('req type', req.constructor.name);
console.debug('reqDN', (req.baseObject || '').toString());
console.debug('bindDN', req.connection.ldap.bindDN.toString());
console.debug('====');
const bindDN = req.connection.ldap.bindDN;
const rootDN = parseDN(getRootDN());
// person can search itself
if (req instanceof SearchRequest) {
if (equalsDN(bindDN, req.baseObject)) {
return next();
}
}
// root admin can do everything
if (equalsDN(bindDN.parent(), rootDN)) {
return next();
}
return next(new InsufficientAccessRightsError());
}
export const ldapServer = createServer();
const registerMap = [];
export function ldapRegister(t: LdapService) {
for (const item of registerMap) {
if (item.type === 'search') {
ldapServer.search(item.hook, authorize, function (req, res, next) {
item.descriptor.value.apply(t, [req, res, next]);
})
}
if (item.type === 'bind') {
ldapServer.bind(item.hook, function (req, res, next) {
item.descriptor.value.apply(t, [req, res, next]);
});
}
}
}
export function Search(ditHook: string) {
console.log("search hook " + ditHook);
return function (target: LdapService, propertyKey: string, descriptor: PropertyDescriptor) {
registerMap.push({
type: 'search', hook: ditHook, descriptor: descriptor
});
return descriptor;
// ldapServer.search(ditHook, authorize, async (req, res, next) => {
// await descriptor.value(req, res, next);
// });
}
}
export function Bind(ditHook: string) {
return function (target: LdapService, propertyKey: string, descriptor: PropertyDescriptor) {
registerMap.push({
type: 'bind', hook: ditHook, descriptor: descriptor
});
return descriptor
// ldapServer.bind(ditHook, async (req, res, next) => {
// await descriptor.value(req, res, next);
// });
};
}

10
src/ldap/ldap.module.ts Normal file
View File

@ -0,0 +1,10 @@
import {Module} from '@nestjs/common';
import {LdapService} from "./ldap.service";
import {UserModule} from "../user/user.module";
@Module({
imports: [UserModule],
providers: [LdapService]
})
export class LdapModule {
}

139
src/ldap/ldap.service.ts Normal file
View File

@ -0,0 +1,139 @@
import {Injectable} from "@nestjs/common";
import ldap, {Server, createServer, parseDN, InsufficientAccessRightsError} from "ldapjs";
import {Bind, equalsDN, getPersonMatchedDN, ldapRegister, ldapServer, Search} from "./function";
import {getBaseEntries, getOrganizationBaseDN, getRootDN, validateAdminPassword} from "../utils/ldap";
import {UserService} from "../user/user.service";
@Injectable()
export class LdapService {
public readonly server: Server = ldapServer;
constructor(private userService: UserService) {
}
public run(port: number) {
ldapRegister(this);
this.server.listen(port);
}
@Search('')
private searchDSE(req, res, next) {
const baseObject = {
dn: '',
structuralObjectClass: 'OpenLDAProotDSE',
configContext: 'cn=config',
attributes: {
objectclass: ['top', 'OpenLDAProotDSE'],
namingContexts: [getRootDN()],
supportedLDAPVersion: ['3'],
subschemaSubentry: ['cn=Subschema']
}
};
console.info("scope " + req.scope + " filter " + req.filter + " baseObject " + req.baseObject);
if ('base' == req.scope
&& '(objectclass=*)' == req.filter.toString()
&& req.baseObject == '') {
res.send(baseObject);
}
//log.info('scope: ' + req.scope);
//log.info('filter: ' + req.filter.toString());
//log.info('attributes: ' + req.attributes);
res.end();
return next();
}
@Search('cn=Subschema')
private searchSchema(req, res, next) {
var schema = {
dn: 'cn=Subschema',
attributes: {
objectclass: ['top', 'subentry', 'subschema', 'extensibleObject'],
cn: ['Subschema']
}
};
res.send(schema);
res.end();
return next();
}
@Search(getRootDN())
private async search(req, res, next) {
console.info("search req scope " + req.scope + " filter " + req.filter + " baseObject " + req.baseObject);
// console.log('sizeLimit', req.sizeLimit, 'timeLimit', req.timeLimit);
const reqDN = parseDN(req.baseObject.toString().toLowerCase());
const comparators = {
'one': (objDN) => {
return equalsDN(reqDN, objDN.parent());
},
'sub': (objDN) => {
// console.log(reqDN.toString())
// console.log(objDN.toString())
return equalsDN(reqDN, objDN) || reqDN.parentOf(objDN);
},
'base': (objDN) => {
return equalsDN(reqDN, objDN);
},
}
const comparator = comparators[req.scope];
if (comparator) {
const entries = await this.userService.fetchAllUserEntries();
[
...getBaseEntries(),
...entries,
// ...getLDAPCustomGroupEntries(),
].filter(entry => {
console.log(entry.dn)
return comparator(parseDN(entry.dn.toLowerCase())) && req.filter.matches(entry.attributes);
}).forEach(entry => {
console.debug('send entry', entry.dn);
res.send(entry);
});
}
res.end();
return next();
}
@Bind(getRootDN())
async bind(req, res, next) {
const reqDN = parseDN(req.dn.toString().toLowerCase());
console.debug('bind req', reqDN)
console.log('bind PW: ' + req.credentials);
if (parseDN(getRootDN().toLowerCase()).equals(reqDN.parent())) {
// admins
const username = reqDN['rdns'][0].attrs.cn.value;
if (validateAdminPassword(username, req.credentials)) {
res.end();
return next();
} else {
return next(new ldap.InvalidCredentialsError());
}
} else if (parseDN(getOrganizationBaseDN().toLowerCase()).parentOf(reqDN)) {
// users
const cn = reqDN['rdns'][0].attrs.cn.value;
const matchedUser = await this.userService.findOne(cn)
console.log(matchedUser)
// if (matchedUser) {
// const record = await getDBRecordForUserId(matchedUser.attributes.uid);
// if (record && validateUserPassword(record.password, req.credentials)) {
// res.end();
// return next();
// } else {
// console.debug('password failed');
// return next(new ldap.InvalidCredentialsError());
// }
// } else {
// console.debug('user not found');
// return next(new ldap.InvalidCredentialsError());
// }
}
return next(new InsufficientAccessRightsError());
}
}

10
src/main.ts Normal file
View File

@ -0,0 +1,10 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import {LdapService} from "./ldap/ldap.service";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const ldap = app.get(LdapService)
ldap.run(1389)
}
bootstrap();

View File

@ -0,0 +1 @@
export class CreateUserDto {}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PartialType(CreateUserDto) {}

View File

@ -0,0 +1,14 @@
import {Column, Entity, PrimaryColumn} from "typeorm";
@Entity('user')
export class User {
@PrimaryColumn('varchar')
id:string;
@PrimaryColumn('varchar')
name: string;
@Column('varchar')
owner: string;
@Column('varchar')
password:string;
}

13
src/user/user.module.ts Normal file
View File

@ -0,0 +1,13 @@
import {Module} from '@nestjs/common';
import {UserService} from './user.service';
import {User} from "./entities/user.entity";
import {TypeOrmModule} from "@nestjs/typeorm";
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UserService],
exports: [UserService]
})
export class UserModule {
}

57
src/user/user.service.ts Normal file
View File

@ -0,0 +1,57 @@
import {Injectable} from '@nestjs/common';
import {CreateUserDto} from './dto/create-user.dto';
import {UpdateUserDto} from './dto/update-user.dto';
import {InjectRepository} from "@nestjs/typeorm";
import {User} from "./entities/user.entity";
import {Repository} from "typeorm";
import {makePersonEntry} from "../utils/ldap";
@Injectable()
export class UserService {
constructor(@InjectRepository(User) private userRepository: Repository<User>) {
}
create(createUserDto: CreateUserDto) {
return 'This action adds a new user';
}
findAll() {
return `This action returns all user`;
}
findOne(cn: string) {
return this.userRepository.findOneBy({name: cn});
}
async fetchAllUserEntries() {
const users = await this.userRepository.find();
const entries = [];
for (let user of users) {
const dn = `cn=${user.name}`;
// const dn = `mail=${mail},${u.firstDepartment.dn}`;
// 映射到 iNetOrgPerson
const personEntry = makePersonEntry(dn, {
uid: user.id,
title: "",
mobileTelephoneNumber: "",
cn: user.name,
givenName: "",
sn: "",
mail: "",
avatarurl: "",
});
entries.push(personEntry);
}
return entries;
}
update(id: number, updateUserDto: UpdateUserDto) {
return `This action updates a #${id} user`;
}
remove(id: number) {
return `This action removes a #${id} user`;
}
}

195
src/utils/ldap.ts Normal file
View File

@ -0,0 +1,195 @@
import {ldapConfig} from "../ldap.config";
export function getRootDN() {
return ldapConfig.rootDN;
}
export function makeDN(...parts) {
return parts.filter((value) => {
return value != ""
}).join(',');
}
export function getOrganizationBaseDN() {
return makeDN('o=' + ldapConfig.organization, getRootDN());
}
export function getPeopleBaseDN() {
return makeDN('ou=People', getOrganizationBaseDN());
}
export function getGroupsBaseDN() {
return makeDN('ou=Groups', getOrganizationBaseDN());
}
export function makeGroupEntryDN(...prefix) {
return makeDN(...prefix, getGroupsBaseDN());
}
export function makeOrganizationUnitEntryDN(...prefix) {
return makeDN(...prefix, getPeopleBaseDN());
}
export function makeOrganizationUnitEntry(dn, name, attrs = {}) {
// dn: d.dn,
// attributes: {
// objectclass: ['organizationalUnit', 'top'],
// ou: d.name,
// }
const generatedDN = makeOrganizationUnitEntryDN(dn);
return {
dn: generatedDN,
attributes: Object.assign({
objectclass: ['organizationalUnit', 'top'],
ou: name,
entryDN: dn,
}, attrs),
};
}
export function makePersonEntry(dn, attrs) {
// u.ldap = {
// dn: `mail=${mail}, ${depDN}`,
// attributes: {
// objectclass: ['inetOrgPerson', 'organizationalPerson', 'person', 'top'],
// uid: u.userid,
// title: u.position,
// mobileTelephoneNumber: u.mobile,
// cn: u.name,
// givenName: u.name.substr(1),
// sn: u.name.substr(0, 1),
// mail,
// userPassword: '123456',
// }
// };
// const generatedDN = makeOrganizationUnitEntryDN(dn);
const generatedDN = makeDN(dn, getRootDN());
return {
dn: generatedDN,
attributes: Object.assign({
objectclass: ['inetOrgPerson', 'organizationalPerson', 'person', 'top'],
userPassword: '********',
// otpSecret: 'abcd',
memberOf: [],
entryDN: dn,
}, attrs)
};
}
export function makeGroupEntry(dn, name, members, attrs = {}) {
// dn: d.dn,
// attributes: {
// objectclass: ['groupOfNames', 'top'],
// ou: d.name,
// }
const generatedDN = makeGroupEntryDN(dn);
return {
dn: generatedDN,
attributes: Object.assign({
objectclass: ['groupOfNames', 'top'],
cn: name,
ou: name,
member: members,
memberOf: [],
entryDN: dn,
}, attrs),
};
}
export function getOrganizationEntry() {
return {
dn: getOrganizationBaseDN(),
attributes: {
objectclass: ['organization', 'top'],
ou: ldapConfig.organization,
},
};
}
export function getPeopleBaseEntry() {
return {
dn: getPeopleBaseDN(),
attributes: {
objectclass: ['organizationalUnit', 'top'],
ou: 'People',
},
};
}
export function getGroupsBaseEntry() {
return {
dn: getGroupsBaseDN(),
attributes: {
objectclass: ['organizationalUnit', 'top'],
ou: 'Groups',
},
};
}
export function makeAdminEntry(attrs) {
// {dn: 'cn=admin,'+rootDN, attributes: { objectclass: ['simpleSecurityObject', 'organizationalRole'], hasSubordinates: ['FALSE'] } },
return {
dn: [`cn=${attrs.commonName}`, getRootDN()].join(','),
attributes: {
objectclass: ['simpleSecurityObject', 'organizationalRole'],
hasSubordinates: ['FALSE']
},
};
}
export function getAdminEntries() {
return ldapConfig.admins.map(cfg => {
return makeAdminEntry(cfg);
});
}
export function getBaseEntries() {
const rootDN = getRootDN();
const rootEntry = {
dn: rootDN,
attributes: {
objectclass: ['dcObject', 'organization', 'top'],
dc: rootDN.split(',')[0].split('=')[1].trim(),
o: ldapConfig.organization,
hasSubordinates: ['TRUE'],
}
};
return [
rootEntry,
getOrganizationEntry(),
getPeopleBaseEntry(),
getGroupsBaseEntry(),
...getAdminEntries(),
];
}
export function validateAdminPassword(username, password) {
const [user] = ldapConfig.admins.filter(u => {
return u.commonName == username;
});
return user && user.password === password;
}
export function validateAdminPermission(username, permission) {
const [user] = ldapConfig.admins.filter(u => {
return u.commonName == username;
});
return !!(user && user[permission]);
}
export function addMemberToGroup(memberEntry, groupEntry) {
if (groupEntry.attributes.member.indexOf(memberEntry.dn) < 0) {
groupEntry.attributes.member.push(memberEntry.dn);
}
if (memberEntry.attributes.memberOf.indexOf(groupEntry.dn) < 0) {
memberEntry.attributes.memberOf.push(groupEntry.dn);
}
}

24
test/app.e2e-spec.ts Normal file
View File

@ -0,0 +1,24 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

9
test/jest-e2e.json Normal file
View File

@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

5
tsconfig.build.json Normal file
View File

@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules",
"test", "dist", "**/*spec.ts"]
}

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "es2017",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}

5034
yarn.lock Normal file

File diff suppressed because it is too large Load Diff