Adds buttons to test availability of server from public internet
This commit is contained in:
parent
d0bfa082e0
commit
c55476b196
@ -1,5 +1,6 @@
|
|||||||
const _ = require('lodash');
|
const _ = require('lodash');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const https = require('https');
|
||||||
const tempWrite = require('temp-write');
|
const tempWrite = require('temp-write');
|
||||||
const moment = require('moment');
|
const moment = require('moment');
|
||||||
const logger = require('../logger').ssl;
|
const logger = require('../logger').ssl;
|
||||||
@ -15,6 +16,7 @@ const letsencryptConfig = '/etc/letsencrypt.ini';
|
|||||||
const certbotCommand = 'certbot';
|
const certbotCommand = 'certbot';
|
||||||
const archiver = require('archiver');
|
const archiver = require('archiver');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { isArray } = require('lodash');
|
||||||
|
|
||||||
function omissions() {
|
function omissions() {
|
||||||
return ['is_deleted'];
|
return ['is_deleted'];
|
||||||
@ -1119,6 +1121,87 @@ const internalCertificate = {
|
|||||||
} else {
|
} else {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
testHttpsChallenge: async (access, domains) => {
|
||||||
|
await access.can('certificates:list');
|
||||||
|
|
||||||
|
if (!isArray(domains)) {
|
||||||
|
throw new error.InternalValidationError('Domains must be an array of strings');
|
||||||
|
}
|
||||||
|
if (domains.length === 0) {
|
||||||
|
throw new error.InternalValidationError('No domains provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test challenge file
|
||||||
|
const testChallengeDir = '/data/letsencrypt-acme-challenge/.well-known/acme-challenge';
|
||||||
|
const testChallengeFile = testChallengeDir + '/test-challenge';
|
||||||
|
fs.mkdirSync(testChallengeDir, {recursive: true});
|
||||||
|
fs.writeFileSync(testChallengeFile, 'Success', {encoding: 'utf8'});
|
||||||
|
|
||||||
|
async function performTestForDomain (domain) {
|
||||||
|
logger.info('Testing http challenge for ' + domain);
|
||||||
|
const url = `http://${domain}/.well-known/acme-challenge/test-challenge`;
|
||||||
|
const formBody = `method=G&url=${encodeURI(url)}&bodytype=T&requestbody=&headername=User-Agent&headervalue=None&locationid=1&ch=false&cc=false`;
|
||||||
|
const options = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Content-Length': Buffer.byteLength(formBody)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await new Promise((resolve) => {
|
||||||
|
|
||||||
|
const req = https.request('https://www.site24x7.com/tools/restapi-tester', options, function (res) {
|
||||||
|
let responseBody = '';
|
||||||
|
|
||||||
|
res.on('data', (chunk) => responseBody = responseBody + chunk);
|
||||||
|
res.on('end', function () {
|
||||||
|
const parsedBody = JSON.parse(responseBody + '');
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
logger.warn(`Failed to test HTTP challenge for domain ${domain}`, res);
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
resolve(parsedBody);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make sure to write the request body.
|
||||||
|
req.write(formBody);
|
||||||
|
req.end();
|
||||||
|
req.on('error', function (e) { logger.warn(`Failed to test HTTP challenge for domain ${domain}`, e);
|
||||||
|
resolve(undefined); });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
// Some error occurred while trying to get the data
|
||||||
|
return 'failed';
|
||||||
|
} else if (`${result.responsecode}` === '200' && result.htmlresponse === 'Success') {
|
||||||
|
// Server exists and has responded with the correct data
|
||||||
|
return 'ok';
|
||||||
|
} else if (`${result.responsecode}` === '404') {
|
||||||
|
// Server exists but responded with a 404
|
||||||
|
return '404';
|
||||||
|
} else if (`${result.responsecode}` === '0' || result.reason.toLowerCase() === 'host unavailable') {
|
||||||
|
// Server does not exist at domain
|
||||||
|
return 'no-host';
|
||||||
|
} else {
|
||||||
|
// Other errors
|
||||||
|
return `other:${result.responsecode}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = {};
|
||||||
|
|
||||||
|
for (const domain of domains){
|
||||||
|
results[domain] = await performTestForDomain(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the test challenge file
|
||||||
|
fs.unlinkSync(testChallengeFile);
|
||||||
|
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -68,6 +68,32 @@ router
|
|||||||
.catch(next);
|
.catch(next);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test HTTP challenge for domains
|
||||||
|
*
|
||||||
|
* /api/nginx/certificates/test-http
|
||||||
|
*/
|
||||||
|
router
|
||||||
|
.route('/test-http')
|
||||||
|
.options((req, res) => {
|
||||||
|
res.sendStatus(204);
|
||||||
|
})
|
||||||
|
.all(jwtdecode())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/nginx/certificates/test-http
|
||||||
|
*
|
||||||
|
* Test HTTP challenge for domains
|
||||||
|
*/
|
||||||
|
.get((req, res, next) => {
|
||||||
|
internalCertificate.testHttpsChallenge(res.locals.access, JSON.parse(req.query.domains))
|
||||||
|
.then((result) => {
|
||||||
|
res.status(200)
|
||||||
|
.send(result);
|
||||||
|
})
|
||||||
|
.catch(next);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Specific certificate
|
* Specific certificate
|
||||||
*
|
*
|
||||||
@ -209,7 +235,6 @@ router
|
|||||||
.catch(next);
|
.catch(next);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download LE Certs
|
* Download LE Certs
|
||||||
*
|
*
|
||||||
|
@ -157,6 +157,17 @@
|
|||||||
"targetSchema": {
|
"targetSchema": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Test HTTP Challenge",
|
||||||
|
"description": "Tests whether the HTTP challenge should work",
|
||||||
|
"href": "/nginx/certificates/{definitions.identity.example}/test-http",
|
||||||
|
"access": "private",
|
||||||
|
"method": "GET",
|
||||||
|
"rel": "info",
|
||||||
|
"http_header": {
|
||||||
|
"$ref": "../examples.json#/definitions/auth_header"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -685,6 +685,16 @@ module.exports = {
|
|||||||
return fetch('post', 'nginx/certificates/' + id + '/renew', undefined, {timeout});
|
return fetch('post', 'nginx/certificates/' + id + '/renew', undefined, {timeout});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Number} id
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
testHttpChallenge: function (domains) {
|
||||||
|
return fetch('get', 'nginx/certificates/test-http?' + new URLSearchParams({
|
||||||
|
domains: JSON.stringify(domains),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Number} id
|
* @param {Number} id
|
||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
|
@ -366,6 +366,19 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Certificate Test Reachability
|
||||||
|
*
|
||||||
|
* @param model
|
||||||
|
*/
|
||||||
|
showNginxCertificateTestReachability: function (model) {
|
||||||
|
if (Cache.User.isAdmin() || Cache.User.canManage('certificates')) {
|
||||||
|
require(['./main', './nginx/certificates/test'], function (App, View) {
|
||||||
|
App.UI.showModalDialog(new View({model: model}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Audit Log
|
* Audit Log
|
||||||
*/
|
*/
|
||||||
|
@ -18,6 +18,14 @@
|
|||||||
<input type="text" name="domain_names" class="form-control" id="input-domains" value="<%- domain_names.join(',') %>" required>
|
<input type="text" name="domain_names" class="form-control" id="input-domains" value="<%- domain_names.join(',') %>" required>
|
||||||
<div class="text-blue"><i class="fe fe-alert-triangle"></i> <%- i18n('ssl', 'hosts-warning') %></div>
|
<div class="text-blue"><i class="fe fe-alert-triangle"></i> <%- i18n('ssl', 'hosts-warning') %></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3 test-domains-container">
|
||||||
|
<button type="button" class="btn btn-secondary test-domains col-sm-12"><%- i18n('certificates', 'test-reachability') %></button>
|
||||||
|
<div class="text-secondary small">
|
||||||
|
<i class="fe fe-info"></i>
|
||||||
|
<%- i18n('certificates', 'reachability-info') %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-12 col-md-12">
|
<div class="col-sm-12 col-md-12">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
@ -29,6 +29,8 @@ module.exports = Mn.View.extend({
|
|||||||
non_loader_content: '.non-loader-content',
|
non_loader_content: '.non-loader-content',
|
||||||
le_error_info: '#le-error-info',
|
le_error_info: '#le-error-info',
|
||||||
domain_names: 'input[name="domain_names"]',
|
domain_names: 'input[name="domain_names"]',
|
||||||
|
test_domains_container: '.test-domains-container',
|
||||||
|
test_domains_button: '.test-domains',
|
||||||
buttons: '.modal-footer button',
|
buttons: '.modal-footer button',
|
||||||
cancel: 'button.cancel',
|
cancel: 'button.cancel',
|
||||||
save: 'button.save',
|
save: 'button.save',
|
||||||
@ -56,10 +58,12 @@ module.exports = Mn.View.extend({
|
|||||||
this.ui.dns_provider_credentials.prop('required', 'required');
|
this.ui.dns_provider_credentials.prop('required', 'required');
|
||||||
}
|
}
|
||||||
this.ui.dns_challenge_content.show();
|
this.ui.dns_challenge_content.show();
|
||||||
|
this.ui.test_domains_container.hide();
|
||||||
} else {
|
} else {
|
||||||
this.ui.dns_provider.prop('required', false);
|
this.ui.dns_provider.prop('required', false);
|
||||||
this.ui.dns_provider_credentials.prop('required', false);
|
this.ui.dns_provider_credentials.prop('required', false);
|
||||||
this.ui.dns_challenge_content.hide();
|
this.ui.dns_challenge_content.hide();
|
||||||
|
this.ui.test_domains_container.show();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -205,6 +209,23 @@ module.exports = Mn.View.extend({
|
|||||||
this.ui.non_loader_content.show();
|
this.ui.non_loader_content.show();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
'click @ui.test_domains_button': function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const domainNames = this.ui.domain_names[0].value.split(',');
|
||||||
|
if (domainNames && domainNames.length > 0) {
|
||||||
|
this.model.set('domain_names', domainNames);
|
||||||
|
this.model.set('back_to_add', true);
|
||||||
|
App.Controller.showNginxCertificateTestReachability(this.model);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'change @ui.domain_names': function(e){
|
||||||
|
const domainNames = e.target.value.split(',');
|
||||||
|
if (domainNames && domainNames.length > 0) {
|
||||||
|
this.ui.test_domains_button.prop('disabled', false);
|
||||||
|
} else {
|
||||||
|
this.ui.test_domains_button.prop('disabled', true);
|
||||||
|
}
|
||||||
|
},
|
||||||
'change @ui.other_certificate_key': function(e){
|
'change @ui.other_certificate_key': function(e){
|
||||||
this.setFileName("other_certificate_key_label", e)
|
this.setFileName("other_certificate_key_label", e)
|
||||||
},
|
},
|
||||||
@ -257,6 +278,10 @@ module.exports = Mn.View.extend({
|
|||||||
this.ui.credentials_file_content.hide();
|
this.ui.credentials_file_content.hide();
|
||||||
this.ui.loader_content.hide();
|
this.ui.loader_content.hide();
|
||||||
this.ui.le_error_info.hide();
|
this.ui.le_error_info.hide();
|
||||||
|
const domainNames = this.ui.domain_names[0].value.split(',');
|
||||||
|
if (!domainNames || domainNames.length === 0 || (domainNames.length === 1 && domainNames[0] === "")) {
|
||||||
|
this.ui.test_domains_button.prop('disabled', true);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
initialize: function (options) {
|
initialize: function (options) {
|
||||||
|
@ -42,6 +42,9 @@
|
|||||||
<% if (provider === 'letsencrypt') { %>
|
<% if (provider === 'letsencrypt') { %>
|
||||||
<a href="#" class="renew dropdown-item"><i class="dropdown-icon fe fe-refresh-cw"></i> <%- i18n('certificates', 'force-renew') %></a>
|
<a href="#" class="renew dropdown-item"><i class="dropdown-icon fe fe-refresh-cw"></i> <%- i18n('certificates', 'force-renew') %></a>
|
||||||
<a href="#" class="download dropdown-item"><i class="dropdown-icon fe fe-download"></i> <%- i18n('certificates', 'download') %></a>
|
<a href="#" class="download dropdown-item"><i class="dropdown-icon fe fe-download"></i> <%- i18n('certificates', 'download') %></a>
|
||||||
|
<% if (meta.dns_challenge === false) { %>
|
||||||
|
<a href="#" class="test dropdown-item"><i class="dropdown-icon fe fe-globe"></i> <%- i18n('certificates', 'test-reachability') %></a>
|
||||||
|
<% } %>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<% } %>
|
<% } %>
|
||||||
<a href="#" class="delete dropdown-item"><i class="dropdown-icon fe fe-trash-2"></i> <%- i18n('str', 'delete') %></a>
|
<a href="#" class="delete dropdown-item"><i class="dropdown-icon fe fe-trash-2"></i> <%- i18n('str', 'delete') %></a>
|
||||||
|
@ -2,7 +2,7 @@ const Mn = require('backbone.marionette');
|
|||||||
const moment = require('moment');
|
const moment = require('moment');
|
||||||
const App = require('../../../main');
|
const App = require('../../../main');
|
||||||
const template = require('./item.ejs');
|
const template = require('./item.ejs');
|
||||||
const dns_providers = require('../../../../../../global/certbot-dns-plugins')
|
const dns_providers = require('../../../../../../global/certbot-dns-plugins');
|
||||||
|
|
||||||
module.exports = Mn.View.extend({
|
module.exports = Mn.View.extend({
|
||||||
template: template,
|
template: template,
|
||||||
@ -12,7 +12,8 @@ module.exports = Mn.View.extend({
|
|||||||
host_link: '.host-link',
|
host_link: '.host-link',
|
||||||
renew: 'a.renew',
|
renew: 'a.renew',
|
||||||
delete: 'a.delete',
|
delete: 'a.delete',
|
||||||
download: 'a.download'
|
download: 'a.download',
|
||||||
|
test: 'a.test'
|
||||||
},
|
},
|
||||||
|
|
||||||
events: {
|
events: {
|
||||||
@ -31,11 +32,16 @@ module.exports = Mn.View.extend({
|
|||||||
let win = window.open($(e.currentTarget).attr('rel'), '_blank');
|
let win = window.open($(e.currentTarget).attr('rel'), '_blank');
|
||||||
win.focus();
|
win.focus();
|
||||||
},
|
},
|
||||||
|
|
||||||
'click @ui.download': function (e) {
|
'click @ui.download': function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
App.Api.Nginx.Certificates.download(this.model.get('id'))
|
App.Api.Nginx.Certificates.download(this.model.get('id'));
|
||||||
}
|
},
|
||||||
|
|
||||||
|
'click @ui.test': function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
App.Controller.showNginxCertificateTestReachability(this.model);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
templateContext: {
|
templateContext: {
|
||||||
|
15
frontend/js/app/nginx/certificates/test.ejs
Normal file
15
frontend/js/app/nginx/certificates/test.ejs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"><%- i18n('certificates', 'reachability-title') %></h5>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="waiting text-center">
|
||||||
|
<%= i18n('str', 'please-wait') %>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-danger error" role="alert"></div>
|
||||||
|
<div class="alert alert-danger success" role="alert"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary cancel" disabled><%- i18n('str', 'close') %></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
73
frontend/js/app/nginx/certificates/test.js
Normal file
73
frontend/js/app/nginx/certificates/test.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
const Mn = require('backbone.marionette');
|
||||||
|
const App = require('../../main');
|
||||||
|
const template = require('./test.ejs');
|
||||||
|
|
||||||
|
module.exports = Mn.View.extend({
|
||||||
|
template: template,
|
||||||
|
className: 'modal-dialog',
|
||||||
|
|
||||||
|
ui: {
|
||||||
|
waiting: '.waiting',
|
||||||
|
error: '.error',
|
||||||
|
success: '.success',
|
||||||
|
close: 'button.cancel'
|
||||||
|
},
|
||||||
|
|
||||||
|
events: {
|
||||||
|
'click @ui.close': function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (this.model.get('back_to_add')) {
|
||||||
|
App.Controller.showNginxCertificateForm(this.model);
|
||||||
|
} else {
|
||||||
|
App.UI.closeModal();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
onRender: function () {
|
||||||
|
this.ui.error.hide();
|
||||||
|
this.ui.success.hide();
|
||||||
|
|
||||||
|
App.Api.Nginx.Certificates.testHttpChallenge(this.model.get('domain_names'))
|
||||||
|
.then((result) => {
|
||||||
|
let allOk = true;
|
||||||
|
let text = '';
|
||||||
|
|
||||||
|
for (const domain in result) {
|
||||||
|
const status = result[domain];
|
||||||
|
if (status === 'ok') {
|
||||||
|
text += `<p><strong>${domain}:</strong> ${App.i18n('certificates', 'reachability-ok')}</p>`;
|
||||||
|
} else {
|
||||||
|
allOk = false;
|
||||||
|
if (status === 'no-host') {
|
||||||
|
text += `<p><strong>${domain}:</strong> ${App.i18n('certificates', 'reachability-not-resolved')}</p>`;
|
||||||
|
} else if (status === 'failed') {
|
||||||
|
text += `<p><strong>${domain}:</strong> ${App.i18n('certificates', 'reachability-failed-to-check')}</p>`;
|
||||||
|
} else if (status === '404') {
|
||||||
|
text += `<p><strong>${domain}:</strong> ${App.i18n('certificates', 'reachability-404')}</p>`;
|
||||||
|
} else if (status.startsWith('other:')) {
|
||||||
|
const code = status.substring(6);
|
||||||
|
text += `<p><strong>${domain}:</strong> ${App.i18n('certificates', 'reachability-other', {code})}</p>`;
|
||||||
|
} else {
|
||||||
|
// This should never happen
|
||||||
|
text += `<p><strong>${domain}:</strong> ?</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ui.waiting.hide();
|
||||||
|
if (allOk) {
|
||||||
|
this.ui.success.html(text).show();
|
||||||
|
} else {
|
||||||
|
this.ui.error.html(text).show();
|
||||||
|
}
|
||||||
|
this.ui.close.prop('disabled', false);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
this.ui.waiting.hide();
|
||||||
|
this.ui.error.text(App.i18n('certificates', 'reachability-failed-to-reach-api')).show();
|
||||||
|
this.ui.close.prop('disabled', false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
@ -188,6 +188,15 @@
|
|||||||
"other-certificate-key": "Certificate Key",
|
"other-certificate-key": "Certificate Key",
|
||||||
"other-intermediate-certificate": "Intermediate Certificate",
|
"other-intermediate-certificate": "Intermediate Certificate",
|
||||||
"force-renew": "Renew Now",
|
"force-renew": "Renew Now",
|
||||||
|
"test-reachability": "Test Server Reachability",
|
||||||
|
"reachability-title": "Test Server Reachability",
|
||||||
|
"reachability-info": "Test whether the domains are reachable from the public internet using Site24x7. This is not necessary when using the DNS Challenge.",
|
||||||
|
"reachability-failed-to-reach-api": "Communication with the API failed, is NPM running correctly?",
|
||||||
|
"reachability-failed-to-check": "Failed to check the reachability due to a communication error with site24x7.com.",
|
||||||
|
"reachability-ok": "Your server is reachable and creating certificates should be possible.",
|
||||||
|
"reachability-404": "There is a server found at this domain but it does not seem to be Nginx Proxy Manager. Please make sure your domain points to the IP where your NPM instance is running.",
|
||||||
|
"reachability-not-resolved": "There is no server available at this domain. Please make sure your domain exists and points to the IP where your NPM instance is running and if necessary port 80 is forwarded in your router.",
|
||||||
|
"reachability-other": "There is a server found at this domain but it returned an unexpected status code {code}. Is it the NPM server? Please make sure your domain points to the IP where your NPM instance is running.",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"renew-title": "Renew Let'sEncrypt Certificate"
|
"renew-title": "Renew Let'sEncrypt Certificate"
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user