Add support for Ipsilon OpenID Connect

Ipsilon Project (https://ipsilon-project.org/) is a server and a toolkit to
configure Apache-based Service Providers.

Support sign-in via Ipsilon OpenID Connect Identity Provider.
On the Ipsilon side an actual authentication and identity source can be
flexibly configured. We expect only three fields to be provided as part of the
UserInfo by the Ipsilon:

 - user name (subject): an account name for an authenticated user
 - email for the user
 - _groups: list of groups this user is a member of

The Ipsilon authenticator is structured similarly to 'SAML' authenticator but
is simpler to configure.

Signed-off-by: Alexander Bokovoy <ab@vda.li>
This commit is contained in:
Alexander Bokovoy 2018-02-18 15:34:55 +01:00
parent c71361467d
commit c33864a00e
14 changed files with 272 additions and 8 deletions

View file

@ -198,6 +198,10 @@ There are some config settings you need to change in the files below.
| `HMD_SAML_ATTRIBUTE_ID` | `sAMAccountName` | attribute map for `id` (optional, default: NameID of SAML response) |
| `HMD_SAML_ATTRIBUTE_USERNAME` | `mailNickname` | attribute map for `username` (optional, default: NameID of SAML response) |
| `HMD_SAML_ATTRIBUTE_EMAIL` | `mail` | attribute map for `email` (optional, default: NameID of SAML response if `HMD_SAML_IDENTIFIERFORMAT` is default) |
| `HMD_IPSILON_ISSUERTITLE` | `Ipsilon` | Ipsilon instance name for Sign In dialog |
| `HMD_IPSILON_ISSUERHOST` | `https:/example.com/ipsilon/openidc` | authentication endpoint of IdP. For details, see [guide](docs/guides/auth.md#ipsilon). |
| `HMD_IPSILON_REQUIREDGROUPS` | `Hackmd-users` | group names that allowed (use vertical bar to separate) (optional) |
| `HMD_IPSILON_EXTERNALGROUPS` | `Temporary-staff` | group names that not allowed (use vertical bar to separate) (optional) |
| `HMD_IMGUR_CLIENTID` | no example | Imgur API client id |
| `HMD_EMAIL` | `true` or `false` | set to allow email signin |
| `HMD_ALLOW_PDF_EXPORT` | `true` or `false` | Enable or disable PDF exports |
@ -270,7 +274,7 @@ There are some config settings you need to change in the files below.
| service | settings location | description |
| ------- | --------- | ----------- |
| facebook, twitter, github, gitlab, mattermost, dropbox, google, ldap, saml | environment variables or `config.json` | for signin |
| facebook, twitter, github, gitlab, mattermost, dropbox, google, ldap, saml, ipsilon | environment variables or `config.json` | for signin |
| imgur, s3, minio | environment variables or `config.json` | for image upload |
| dropbox(`dropbox/appKey`) | `config.json` | for export and import |
@ -286,6 +290,7 @@ There are some config settings you need to change in the files below.
| dropbox | `/auth/dropbox/callback` |
| google | `/auth/google/callback` |
| saml | `/auth/saml/callback` |
| ipsilon | `/auth/ipsilon/callback` |
# Developer Notes

View file

@ -143,6 +143,30 @@
"HMD_ALLOW_PDF_EXPORT": {
"description": "Enable or disable PDF exports",
"required": false
},
"HMD_IPSILON_CLIENTID": {
"description": "Ipsilon OpenID Connect API client id",
"required": false
},
"HMD_IPSILON_CLIENTSECRET": {
"description": "Ipsilon OpenID Connect API client secret",
"required": false
},
"HMD_IPSILON_ISSUERTITLE": {
"description": "Ipsilon instance name for Sign In dialog",
"required": false
},
"HMD_IPSILON_ISSUERHOST": {
"description": "Ipsilon instance URL",
"required": false
},
"HMD_IPSILON_EXTERNALGROUPS": {
"description": "Ipsilon-reported groups not allowed to access this instance",
"required": false
},
"HMD_IPSILON_REQUIREDGROUPS": {
"description": "Ipsilon-reported groups required to access this instance",
"required": false
}
},
"addons": [

View file

@ -99,6 +99,14 @@
"email": "change or delete this: attribute map for `email` (default: NameID)"
}
},
"ipsilon": {
"clientID": "change this",
"clientSecret": "change this",
"issuerHost": "change this",
"issuerTitle": "Ipsilon or change this",
"requiredGroups": [ "change or delete: group names that allowed" ],
"externalGroups": [ "change or delete: group names that not allowed" ]
},
"imgur": {
"clientID": "change this"
},

View file

@ -209,6 +209,53 @@ The basic procedure is the same as the case of OneLogin which is mentioned above
HMD_SAML_EXTERNALGROUPS=temporary-staff
````
### Ipsilon
Ipsilon Project (https://ipsilon-project.org/) is a server and a toolkit to configure Apache-based Service Providers.
Ipsilon has support for multiple authentication protocols, including OpenID Connect.
Once you have installed Ipsilon and configured it to use an identity provider of your choice, login into an administrative interface and add a new OpenID Connect client (a service provider)
Ipsilon generates most of the parameters automatically, only following values should be set:
* ClientID: your client identifier, say, `hackmd.example.com`
* Client name: a free text title to distinguish your client at Ipsilon login page, say, `Hackmd instance`
* Redirect URIs: an HTTPS URL of your Hackmd deployment ending with `/auth/ipsilon/callback`, say, `https://example.com/hackmd/auth/ipsilon/callback`
* Application type: `web`
* Client URI: an HTTPS url of your Hackmd deployment, `https://example.com/hackmd`
* Subject type: `public`
* Response type: `code`, `code id_token` (you can tick all boxes if unsure)
* Grant types: `authorization_code`, `implicit`, `refresh_token`
* Token Andpoint Auth Method: `client_secret_basic`
* Request Object signing Alg: `none`
* Initiate Login URI: an HTTPS url of your Hackmd deployment, `https://example.com/hackmd`
Once saved, copy value of a generated `Client Secret` field to `config.json`
along with the other values. Below is how a typical Ipsilon deployment with
FreeIPA would look like:
* config.json:
````javascript
{
"production": {
"ipsilon": {
"clientID": "hackmd.example.com",
"clientSecret": "<generated Ipsilon client secret>",
"issuerHost": "https://ipsilon.example.com/ipsilon/openidc/",
"issuerTitle": "My FreeIPA",
"requiredGroups": [ "ipausers" ],
"externalGroups": [ ]
}
}
}
````
A setting `issuerTitle` is supposed to be used in the sign-in dialog in Hackmd to provide a user-friendly `Sign in via $issuerTitle` text.
It is possible to limit what users can and cannot sign into a Hackmd instance with `requiredGroups` and `externalGroups` correspondingly.
User email, if provided, is automatically matched against a gravatar server to provide a gravatar.
### GitLab (self-hosted)

View file

@ -133,6 +133,14 @@ module.exports = {
email: undefined
}
},
ipsilon: {
issuerTitle: undefined,
issuerHost: undefined,
clientID: undefined,
clientSecret: undefined,
externalGroups: [],
requiredGroups: []
},
email: true,
allowEmailRegister: true,
allowPDFExport: true

View file

@ -103,6 +103,14 @@ module.exports = {
email: process.env.HMD_SAML_ATTRIBUTE_EMAIL
}
},
ipsilon: {
issuerTitle: process.env.HMD_IPSILON_ISSUERTITLE,
issuerHost: process.env.HMD_IPSILON_ISSUERHOST,
clientID: process.env.HMD_IPSILON_CLIENTID,
clientSecret: process.env.HMD_IPSILON_CLIENTSECRET,
externalGroups: toArrayConfig(process.env.HMD_IPSILON_EXTERNALGROUPS, '|', []),
requiredGroups: toArrayConfig(process.env.HMD_IPSILON_REQUIREDGROUPS, '|', [])
},
email: toBooleanConfig(process.env.HMD_EMAIL),
allowEmailRegister: toBooleanConfig(process.env.HMD_ALLOW_EMAIL_REGISTER),
allowPDFExport: toBooleanConfig(process.env.HMD_ALLOW_PDF_EXPORT)

View file

@ -99,7 +99,8 @@ config.isGitLabEnable = config.gitlab.clientID && config.gitlab.clientSecret
config.isMattermostEnable = config.mattermost.clientID && config.mattermost.clientSecret
config.isLDAPEnable = config.ldap.url
config.isSAMLEnable = config.saml.idpSsoUrl
config.isPDFExportEnable = config.allowPDFExport
config.isIpsilonEnable = config.ipsilon.clientID && config.ipsilon.clientSecret
config.isPDFExportEnable = config.allowpdfexport
// merge legacy values
let keys = Object.keys(config)

View file

@ -152,6 +152,15 @@ module.exports = function (sequelize, DataTypes) {
photo = generateAvatarURL(profile.username)
}
break
case 'ipsilon':
if (profile.emails[0]) {
photo = 'https://www.gravatar.com/avatar/' + md5(profile.emails[0])
if (bigger) photo += '?s=400'
else photo += '?s=96'
} else {
photo = generateAvatarURL(profile.username)
}
break
}
return photo
},

View file

@ -70,6 +70,8 @@ function showIndex (req, res, next) {
ldap: config.isLDAPEnable,
ldapProviderName: config.ldap.providerName,
saml: config.isSAMLEnable,
ipsilon: config.isIpsilonEnable,
ipsilonIssuerTitle: config.ipsilon.issuerTitle,
email: config.isEmailEnable,
allowEmailRegister: config.allowEmailRegister,
allowPDFExport: config.allowPDFExport,
@ -105,6 +107,8 @@ function responseHackMD (res, note) {
ldap: config.isLDAPEnable,
ldapProviderName: config.ldap.providerName,
saml: config.isSAMLEnable,
ipsilon: config.isIpsilonEnable,
ipsilonIssuerTitle: config.ipsilon.issuerTitle,
email: config.isEmailEnable,
allowEmailRegister: config.allowEmailRegister,
allowPDFExport: config.allowPDFExport

View file

@ -44,6 +44,7 @@ if (config.isGoogleEnable) authRouter.use(require('./google'))
if (config.isLDAPEnable) authRouter.use(require('./ldap'))
if (config.isSAMLEnable) authRouter.use(require('./saml'))
if (config.isEmailEnable) authRouter.use(require('./email'))
if (config.isIpsilonEnable) authRouter.use(require('./ipsilon'))
// logout
authRouter.get('/logout', function (req, res) {

View file

@ -0,0 +1,143 @@
'use strict'
const Router = require('express').Router
const passport = require('passport')
const Issuer = require('openid-client').Issuer
const Strategy = require('openid-client').Strategy
const config = require('../../../config')
const logger = require('../../../logger')
const models = require('../../../models')
const intersection = function (array1, array2) {
return array1.filter((n) => array2.includes(n))
}
let ipsilonAuth = module.exports = Router()
Issuer.discover(config.ipsilon.issuerHost).then(
function (ipsilonIssuer) {
var client = new ipsilonIssuer.Client({
client_id: config.ipsilon.clientID,
client_secret: config.ipsilon.clientSecret,
userinfo_signed_response_alg: 'RS256',
scope: 'openid profile email phone',
claims: {
userinfo: {
name: {
essential: true
},
email: {
essential: true
},
picture: null,
_groups: null
},
id_token: {
auth_time: {
essential: true
}
}
}
})
var params = {
redirect_uri: config.serverurl + '/auth/ipsilon/callback',
scope: client.scope,
claims: client.claims
}
passport.use('oidc',
new Strategy({
client: client,
params: params,
usePKCE: false
}, (tokenset, done) => {
client.userinfo(tokenset.access_token, {
params: {
scope: params.scope,
claims: params.claims
}
}).then(function (userinfo) {
// check authorization: deny any of the external groups
if (config.ipsilon.externalGroups) {
// lack of external groups in userinfo does not prevent logon
if (userinfo._groups) {
var externalGroups = intersection(config.ipsilon.externalGroups, userinfo._groups)
if (externalGroups.length > 0) {
logger.error('ipsilon permission denied: ' + externalGroups.join(', '))
return done('Permission denied', null)
}
}
}
// check authorization: require any of the required groups
if (config.ipsilon.requiredGroups) {
// lack of required groups in userinfo denies logon
if (!userinfo._groups && config.ipsilon.requiredGroups.length > 0) {
logger.error('ipsilon permission denied')
return done('Permission denied', null)
}
if (intersection(config.ipsilon.requiredGroups, userinfo._groups).length === 0) {
logger.error('ipsilon permission denied')
return done('Permission denied', null)
}
}
// create a user
var uuid = userinfo.sub
var profile = {
provider: 'ipsilon',
id: 'IPSILON-' + uuid,
username: uuid,
emails: userinfo.email ? [userinfo.email] : []
}
var stringifiedProfile = JSON.stringify(profile)
models.User.findOrCreate({
where: {
profileid: profile.id.toString()
},
defaults: {
profile: stringifiedProfile
}
}).spread(function (user, created) {
if (user) {
var needSave = false
if (user.profile !== stringifiedProfile) {
user.profile = stringifiedProfile
needSave = true
}
if (needSave) {
user.save().then(function () {
if (config.debug) {
logger.debug('user login: ' + user.id)
}
return done(null, user)
})
} else {
if (config.debug) {
logger.debug('user login: ' + user.id)
}
return done(null, user)
}
}
}).catch(function (err) {
logger.error('ipsilon auth failed: ' + err)
return done(err, null)
})
})
})
)
ipsilonAuth.get('/auth/ipsilon',
passport.authenticate('oidc', {
successReturnToOrRedirect: config.serverurl + '/',
failureRedirect: config.serverurl + '/'
})
)
ipsilonAuth.get('/auth/ipsilon/callback',
passport.authenticate('oidc', {
successReturnToOrRedirect: config.serverurl + '/',
failureRedirect: config.serverurl + '/'
})
)
})

View file

@ -76,8 +76,8 @@
"markdown-it-sup": "^1.0.0",
"markdown-pdf": "^7.0.0",
"mathjax": "~2.7.0",
"mermaid": "~7.1.0",
"mattermost": "^3.4.0",
"mermaid": "~7.1.0",
"meta-marked": "^0.4.2",
"method-override": "^2.3.7",
"minimist": "^1.2.0",
@ -87,6 +87,7 @@
"mysql": "^2.12.0",
"node-uuid": "^1.4.7",
"octicons": "~3.5.0",
"openid-client": "^1.19.4",
"passport": "^0.3.2",
"passport-dropbox-oauth2": "^1.1.0",
"passport-facebook": "^2.1.1",
@ -96,8 +97,8 @@
"passport-ldapauth": "^0.6.0",
"passport-local": "^1.0.0",
"passport-oauth2": "^1.4.0",
"passport-twitter": "^1.0.4",
"passport-saml": "^0.31.0",
"passport-twitter": "^1.0.4",
"passport.socketio": "^3.7.0",
"pdfobject": "^2.0.201604172",
"pg": "^6.1.2",
@ -106,7 +107,7 @@
"randomcolor": "^0.4.4",
"raphael": "git+https://github.com/dmitrybaranovskiy/raphael",
"readline-sync": "^1.4.7",
"request": "^2.79.0",
"request": "^2.83.0",
"reveal.js": "~3.6.0",
"scrypt": "^6.0.3",
"select2": "^3.5.2-browserify",

View file

@ -15,7 +15,7 @@
<% if(allowAnonymous) { %>
<a type="button" href="<%- url %>/new" class="btn btn-sm btn-primary"><i class="fa fa-plus"></i> <%= __('New guest note') %></a>
<% } %>
<% if(facebook || twitter || github || gitlab || mattermost || dropbox || google || ldap || saml || email) { %>
<% if(facebook || twitter || github || gitlab || mattermost || dropbox || google || ldap || saml || ipsilon || email) { %>
<button class="btn btn-sm btn-success ui-signin" data-toggle="modal" data-target=".signin-modal"><%= __('Sign In') %></button>
<% } %>
</div>
@ -49,7 +49,7 @@
<% if (errorMessage && errorMessage.length > 0) { %>
<div class="alert alert-danger" style="max-width: 400px; margin: 0 auto;"><%= errorMessage %></div>
<% } %>
<% if(facebook || twitter || github || gitlab || mattermost || dropbox || google || ldap || saml || email) { %>
<% if(facebook || twitter || github || gitlab || mattermost || dropbox || google || ldap || saml || ipsilon || email) { %>
<span class="ui-signin">
<br>
<a type="button" class="btn btn-lg btn-success ui-signin" data-toggle="modal" data-target=".signin-modal" style="min-width: 200px;"><%= __('Sign In') %></a>

View file

@ -48,7 +48,12 @@
<i class="fa fa-users"></i> <%= __('Sign in via %s', 'SAML') %>
</a>
<% } %>
<% if((facebook || twitter || github || gitlab || mattermost || dropbox || google || saml) && ldap) { %>
<% if(ipsilon) { %>
<a href="<%- url %>/auth/ipsilon" class="btn btn-lg btn-block btn-social btn-success">
<i class="fa fa-users"></i> <%= __('Sign in via %s', ipsilonIssuerTitle ) %>
</a>
<% } %>
<% if((facebook || twitter || github || gitlab || mattermost || dropbox || google || saml || ipsilon) && ldap) { %>
<hr>
<% }%>
<% if(ldap) { %>