Add saml2 auth support
This commit is contained in:
parent
018d8e6224
commit
147538fa8e
3
peertube-plugin-auth-saml2/README.md
Normal file
3
peertube-plugin-auth-saml2/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# SAML2 auth plugin for PeerTube
|
||||||
|
|
||||||
|
Add SAML2 support to login form in PeerTube.
|
294
peertube-plugin-auth-saml2/main.js
Normal file
294
peertube-plugin-auth-saml2/main.js
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
const saml2 = require('saml2-js')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
|
const store = {
|
||||||
|
assertUrl: null,
|
||||||
|
authDisplayName: 'SAML 2',
|
||||||
|
serviceProvider: null,
|
||||||
|
identityProvider: null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register ({
|
||||||
|
registerExternalAuth,
|
||||||
|
unregisterExternalAuth,
|
||||||
|
registerSetting,
|
||||||
|
settingsManager,
|
||||||
|
storageManager,
|
||||||
|
peertubeHelpers,
|
||||||
|
getRouter
|
||||||
|
}) {
|
||||||
|
const { logger } = peertubeHelpers
|
||||||
|
|
||||||
|
const metadataUrl = peertubeHelpers.config.getWebserverUrl() + '/plugins/auth-saml2/router/metadata.xml'
|
||||||
|
|
||||||
|
registerSetting({
|
||||||
|
name: 'client-id',
|
||||||
|
label: 'Client ID',
|
||||||
|
type: 'input',
|
||||||
|
private: true,
|
||||||
|
default: metadataUrl
|
||||||
|
})
|
||||||
|
|
||||||
|
registerSetting({
|
||||||
|
name: 'sign-get-request',
|
||||||
|
label: 'Sign get request',
|
||||||
|
type: 'input-checkbox',
|
||||||
|
private: true,
|
||||||
|
default: false
|
||||||
|
})
|
||||||
|
|
||||||
|
registerSetting({
|
||||||
|
name: 'auth-display-name',
|
||||||
|
label: 'Auth display name',
|
||||||
|
type: 'input',
|
||||||
|
private: true,
|
||||||
|
default: 'SAML 2'
|
||||||
|
})
|
||||||
|
|
||||||
|
registerSetting({
|
||||||
|
name: 'login-url',
|
||||||
|
label: 'SSO login URL',
|
||||||
|
type: 'input',
|
||||||
|
private: true
|
||||||
|
})
|
||||||
|
|
||||||
|
registerSetting({
|
||||||
|
name: 'provider-certificate',
|
||||||
|
label: 'Identity provider certificate',
|
||||||
|
type: 'input-textarea',
|
||||||
|
private: true
|
||||||
|
})
|
||||||
|
|
||||||
|
registerSetting({
|
||||||
|
name: 'username-property',
|
||||||
|
label: 'Username property',
|
||||||
|
type: 'input',
|
||||||
|
private: true,
|
||||||
|
default: 'preferred_username'
|
||||||
|
})
|
||||||
|
|
||||||
|
registerSetting({
|
||||||
|
name: 'mail-property',
|
||||||
|
label: 'Email property',
|
||||||
|
type: 'input',
|
||||||
|
private: true,
|
||||||
|
default: 'email'
|
||||||
|
})
|
||||||
|
|
||||||
|
registerSetting({
|
||||||
|
name: 'display-name-property',
|
||||||
|
label: 'Display name property',
|
||||||
|
type: 'input',
|
||||||
|
private: true
|
||||||
|
})
|
||||||
|
|
||||||
|
registerSetting({
|
||||||
|
name: 'role-property',
|
||||||
|
label: 'Role property',
|
||||||
|
type: 'input',
|
||||||
|
private: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = getRouter()
|
||||||
|
|
||||||
|
store.assertUrl = peertubeHelpers.config.getWebserverUrl() + '/plugins/auth-saml2/router/assert'
|
||||||
|
router.post('/assert', (req, res) => handleAssert(peertubeHelpers, settingsManager, req, res))
|
||||||
|
|
||||||
|
router.get('/metadata.xml', (req, res) => {
|
||||||
|
if (!store.serviceProvider) {
|
||||||
|
logger.warn('Cannot get SAML 2 metadata: service provider not created.')
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.type('application/xml').send(store.serviceProvider.create_metadata())
|
||||||
|
})
|
||||||
|
|
||||||
|
await loadSettingsAndCreateProviders(registerExternalAuth, unregisterExternalAuth, peertubeHelpers, settingsManager, storageManager)
|
||||||
|
store.authDisplayName = await settingsManager.getSetting('auth-display-name')
|
||||||
|
|
||||||
|
settingsManager.onSettingsChange(settings => {
|
||||||
|
loadSettingsAndCreateProviders(registerExternalAuth, unregisterExternalAuth, peertubeHelpers, settingsManager, storageManager)
|
||||||
|
.catch(err => logger.error('Cannot load settings and create client after settings changes.', { err }))
|
||||||
|
|
||||||
|
if (settings['auth-display-name']) store.authDisplayName = settings['auth-display-name']
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unregister () {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
register,
|
||||||
|
unregister
|
||||||
|
}
|
||||||
|
|
||||||
|
// ############################################################################
|
||||||
|
|
||||||
|
async function loadSettingsAndCreateProviders (
|
||||||
|
registerExternalAuth,
|
||||||
|
unregisterExternalAuth,
|
||||||
|
peertubeHelpers,
|
||||||
|
settingsManager,
|
||||||
|
storageManager
|
||||||
|
) {
|
||||||
|
const { logger } = peertubeHelpers
|
||||||
|
|
||||||
|
if (store.serviceProvider || store.identityProvider) {
|
||||||
|
unregisterExternalAuth('saml2')
|
||||||
|
}
|
||||||
|
|
||||||
|
store.serviceProvider = null
|
||||||
|
store.identityProvider = null
|
||||||
|
|
||||||
|
const settings = await settingsManager.getSettings([
|
||||||
|
'client-id',
|
||||||
|
'sign-get-request',
|
||||||
|
'login-url',
|
||||||
|
'provider-certificate'
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!settings['login-url']) {
|
||||||
|
logger.info('Do not register external saml2 auth because login URL is not set.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings['provider-certificate']) {
|
||||||
|
logger.info('Do not register external saml2 auth because provider certificate is not set.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { publicKey: servicePublicKey, privateKey: servicePrivateKey } = await lazyLoadServiceCertificates(peertubeHelpers, storageManager)
|
||||||
|
|
||||||
|
const serviceOptions = {
|
||||||
|
entity_id: settings['client-id'],
|
||||||
|
private_key: servicePrivateKey,
|
||||||
|
certificate: servicePublicKey,
|
||||||
|
assert_endpoint: store.assertUrl
|
||||||
|
}
|
||||||
|
store.serviceProvider = new saml2.ServiceProvider(serviceOptions)
|
||||||
|
|
||||||
|
const identityOptions = {
|
||||||
|
sso_login_url: settings['login-url'],
|
||||||
|
certificates: [
|
||||||
|
settings['provider-certificate']
|
||||||
|
],
|
||||||
|
sign_get_request: settings['sign-get-request'],
|
||||||
|
allow_unencrypted_assertion: true
|
||||||
|
}
|
||||||
|
store.identityProvider = new saml2.IdentityProvider(identityOptions)
|
||||||
|
|
||||||
|
const result = registerExternalAuth({
|
||||||
|
authName: 'saml2',
|
||||||
|
authDisplayName: () => store.authDisplayName,
|
||||||
|
onAuthRequest: async (req, res) => {
|
||||||
|
store.serviceProvider.create_login_request_url(store.identityProvider, {}, (err, loginUrl, requestId) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error('Cannot SAML 2 authenticate.', { err })
|
||||||
|
return redirectOnError(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect(loginUrl)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
store.userAuthenticated = result.userAuthenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAssert(peertubeHelpers, settingsManager, req, res) {
|
||||||
|
const { logger } = peertubeHelpers
|
||||||
|
|
||||||
|
const options = { request_body: req.body }
|
||||||
|
|
||||||
|
store.serviceProvider.post_assert(store.identityProvider, options, async (err, samlResponse) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error('Error SAML 2 assert.', { err })
|
||||||
|
return redirectOnError(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('User authenticated by SAML 2.', { samlResponse })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await buildUser(settingsManager, samlResponse.user)
|
||||||
|
|
||||||
|
return store.userAuthenticated({
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
...user
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error SAML 2 build user.', { err })
|
||||||
|
return redirectOnError(res)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectOnError (res) {
|
||||||
|
res.redirect('/login?externalAuthError=true')
|
||||||
|
}
|
||||||
|
|
||||||
|
function findInUser (samlUser, key) {
|
||||||
|
if (!key) return undefined
|
||||||
|
|
||||||
|
if (samlUser[key]) return samlUser[key]
|
||||||
|
|
||||||
|
if (samlUser.attributes[key]) return samlUser.attributes[key][0]
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildUser (settingsManager, samlUser) {
|
||||||
|
const settings = await settingsManager.getSettings([
|
||||||
|
'mail-property',
|
||||||
|
'username-property',
|
||||||
|
'display-name-property',
|
||||||
|
'role-property'
|
||||||
|
])
|
||||||
|
|
||||||
|
let username = findInUser(samlUser, settings['username-property']) || ''
|
||||||
|
username = username.replace(/[^a-z0-9._]/g, '_')
|
||||||
|
|
||||||
|
return {
|
||||||
|
username,
|
||||||
|
email: findInUser(samlUser, settings['mail-property']),
|
||||||
|
displayName: findInUser(samlUser, settings['display-name-property']),
|
||||||
|
role: findInUser(samlUser, settings['role-property'])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lazyLoadServiceCertificates (peertubeHelpers, storageManager) {
|
||||||
|
const { logger } = peertubeHelpers
|
||||||
|
|
||||||
|
let privateKey = await storageManager.getData('service-private-key')
|
||||||
|
let publicKey = await storageManager.getData('service-public-key')
|
||||||
|
|
||||||
|
if (!privateKey || !publicKey) {
|
||||||
|
logger.info('Generating public/private keys for SAML 2.')
|
||||||
|
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
const options = {
|
||||||
|
modulusLength: 2048,
|
||||||
|
publicKeyEncoding: {
|
||||||
|
type: 'spki',
|
||||||
|
format: 'pem'
|
||||||
|
},
|
||||||
|
privateKeyEncoding: {
|
||||||
|
type: 'pkcs8',
|
||||||
|
format: 'pem'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
crypto.generateKeyPair('rsa', options, (err, publicKey, privateKey) => {
|
||||||
|
if (err) return rej(err)
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
storageManager.storeData('service-private-key', privateKey),
|
||||||
|
storageManager.storeData('service-public-key', publicKey)
|
||||||
|
]).then(() => res({ publicKey, privateKey }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { privateKey, publicKey }
|
||||||
|
}
|
141
peertube-plugin-auth-saml2/package-lock.json
generated
Normal file
141
peertube-plugin-auth-saml2/package-lock.json
generated
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
{
|
||||||
|
"name": "peertube-plugin-auth-saml2",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"requires": true,
|
||||||
|
"dependencies": {
|
||||||
|
"async": {
|
||||||
|
"version": "2.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz",
|
||||||
|
"integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==",
|
||||||
|
"requires": {
|
||||||
|
"lodash": "^4.17.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"debug": {
|
||||||
|
"version": "2.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
|
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||||
|
"requires": {
|
||||||
|
"ms": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ejs": {
|
||||||
|
"version": "2.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/ejs/-/ejs-2.7.4.tgz",
|
||||||
|
"integrity": "sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA=="
|
||||||
|
},
|
||||||
|
"lodash": {
|
||||||
|
"version": "4.17.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
|
||||||
|
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
|
||||||
|
},
|
||||||
|
"lodash-node": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash-node/-/lodash-node-2.4.1.tgz",
|
||||||
|
"integrity": "sha1-6oL3sQDHM9GkKvdoAeUGEF4qgOw="
|
||||||
|
},
|
||||||
|
"ms": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
|
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
|
||||||
|
},
|
||||||
|
"node-forge": {
|
||||||
|
"version": "0.7.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.7.6.tgz",
|
||||||
|
"integrity": "sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw=="
|
||||||
|
},
|
||||||
|
"saml2-js": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/saml2-js/-/saml2-js-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-8OOZxeMxpdtEszFbvR01TcjKb8g/HsE1si/EGdTawSQY0KgwtvPTG2Ctc5xhEW45xhU+j+UrU5ttZxC72YXSUQ==",
|
||||||
|
"requires": {
|
||||||
|
"async": "^2.5.0",
|
||||||
|
"debug": "^2.6.0",
|
||||||
|
"underscore": "^1.8.0",
|
||||||
|
"xml-crypto": "^0.10.0",
|
||||||
|
"xml-encryption": "^0.11.0",
|
||||||
|
"xml2js": "^0.4.0",
|
||||||
|
"xmlbuilder": "~2.2.0",
|
||||||
|
"xmldom": "^0.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sax": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
|
||||||
|
},
|
||||||
|
"underscore": {
|
||||||
|
"version": "1.10.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.10.2.tgz",
|
||||||
|
"integrity": "sha512-N4P+Q/BuyuEKFJ43B9gYuOj4TQUHXX+j2FqguVOpjkssLUUrnJofCcBccJSCoeturDoZU6GorDTHSvUDlSQbTg=="
|
||||||
|
},
|
||||||
|
"xml-crypto": {
|
||||||
|
"version": "0.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-0.10.1.tgz",
|
||||||
|
"integrity": "sha1-+DL3TM9W8kr8rhFjofyrRNlndKg=",
|
||||||
|
"requires": {
|
||||||
|
"xmldom": "=0.1.19",
|
||||||
|
"xpath.js": ">=0.0.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"xmldom": {
|
||||||
|
"version": "0.1.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.19.tgz",
|
||||||
|
"integrity": "sha1-Yx/Ad3bv2EEYvyUXGzftTQdaCrw="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"xml-encryption": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-jVvES7i5ovdO7N+NjgncA326xYKjhqeAnnvIgRnY7ROLCfFqEDLwP0Sxp/30SHG0AXQV1048T5yinOFyvwGFzg==",
|
||||||
|
"requires": {
|
||||||
|
"async": "^2.1.5",
|
||||||
|
"ejs": "^2.5.6",
|
||||||
|
"node-forge": "^0.7.0",
|
||||||
|
"xmldom": "~0.1.15",
|
||||||
|
"xpath": "0.0.27"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"xml2js": {
|
||||||
|
"version": "0.4.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||||
|
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
|
||||||
|
"requires": {
|
||||||
|
"sax": ">=0.6.0",
|
||||||
|
"xmlbuilder": "~11.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"xmlbuilder": {
|
||||||
|
"version": "11.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||||
|
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"xmlbuilder": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-2.2.1.tgz",
|
||||||
|
"integrity": "sha1-kyZDDxMNh0NdTECGZDqikm4QWjI=",
|
||||||
|
"requires": {
|
||||||
|
"lodash-node": "~2.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"xmldom": {
|
||||||
|
"version": "0.1.31",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.31.tgz",
|
||||||
|
"integrity": "sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ=="
|
||||||
|
},
|
||||||
|
"xpath": {
|
||||||
|
"version": "0.0.27",
|
||||||
|
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.27.tgz",
|
||||||
|
"integrity": "sha512-fg03WRxtkCV6ohClePNAECYsmpKKTv5L8y/X3Dn1hQrec3POx2jHZ/0P2qQ6HvsrU1BmeqXcof3NGGueG6LxwQ=="
|
||||||
|
},
|
||||||
|
"xpath.js": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xpath.js/-/xpath.js-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-jg+qkfS4K8E7965sqaUl8mRngXiKb3WZGfONgE18pr03FUQiuSV6G+Ej4tS55B+rIQSFEIw3phdVAQ4pPqNWfQ=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
24
peertube-plugin-auth-saml2/package.json
Normal file
24
peertube-plugin-auth-saml2/package.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "peertube-plugin-auth-saml2",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Add SAML 2 support to login form in PeerTube.",
|
||||||
|
"engine": {
|
||||||
|
"peertube": ">=2.2.0"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"peertube",
|
||||||
|
"plugin",
|
||||||
|
"auth"
|
||||||
|
],
|
||||||
|
"homepage": "https://framagit.org/framasoft/peertube/official-plugins/tree/master/peertube-plugin-auth-saml2",
|
||||||
|
"author": "Chocobozzz",
|
||||||
|
"bugs": "https://framagit.org/framasoft/peertube/official-plugins/issues",
|
||||||
|
"library": "./main.js",
|
||||||
|
"staticDirs": {},
|
||||||
|
"css": [],
|
||||||
|
"clientScripts": [],
|
||||||
|
"translations": {},
|
||||||
|
"dependencies": {
|
||||||
|
"saml2-js": "^2.0.5"
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user