webadmin: add defender page

This commit is contained in:
Nicola Murino 2021-06-08 13:24:28 +02:00
parent feec2118bb
commit 4be6307d87
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
15 changed files with 322 additions and 57 deletions

View file

@ -66,7 +66,7 @@ type Admin struct {
}
func (a *Admin) checkPassword() error {
if a.Password != "" && !strings.HasPrefix(a.Password, argonPwdPrefix) {
if a.Password != "" && !utils.IsStringPrefixInSlice(a.Password, internalHashPwdPrefixes) {
if config.PasswordHashing.Algo == HashingAlgoBcrypt {
pwd, err := bcrypt.GenerateFromPassword([]byte(a.Password), config.PasswordHashing.BcryptOptions.Cost)
if err != nil {

View file

@ -124,6 +124,7 @@ var (
config Config
provider Provider
sqlPlaceholders []string
internalHashPwdPrefixes = []string{argonPwdPrefix, bcryptPwdPrefix}
hashPwdPrefixes = []string{argonPwdPrefix, bcryptPwdPrefix, pbkdf2SHA1Prefix, pbkdf2SHA256Prefix,
pbkdf2SHA512Prefix, pbkdf2SHA256B64SaltPrefix, md5cryptPwdPrefix, md5cryptApr1PwdPrefix, sha512cryptPwdPrefix}
pbkdfPwdPrefixes = []string{pbkdf2SHA1Prefix, pbkdf2SHA256Prefix, pbkdf2SHA512Prefix, pbkdf2SHA256B64SaltPrefix}

View file

@ -26,13 +26,10 @@ The `ban_time_increment` is calculated as percentage of `ban_time`, so if `ban_t
The `defender` will keep in memory both the host scores and the banned hosts, you can limit the memory usage using the `entries_soft_limit` and `entries_hard_limit` configuration keys.
The REST API allows:
Using the REST API you can:
- to retrieve the score for an IP address
- to retrieve the ban time for an IP address
- to unban an IP address
We don't return the whole list of the banned IP addresses or all stored scores because we store them as a hash map and iterating over all the keys of a hash map is not a fast operation and will slow down the recordings of new events.
- list hosts within the defender's lists
- remove hosts from the defender's lists
The `defender` can also load a permanent block list and/or a safe list of ip addresses/networks from a file:

View file

@ -14,7 +14,12 @@ import (
)
func getDefenderHosts(w http.ResponseWriter, r *http.Request) {
render.JSON(w, r, common.GetDefenderHosts())
hosts := common.GetDefenderHosts()
if hosts == nil {
render.JSON(w, r, make([]common.DefenderEntry, 0))
return
}
render.JSON(w, r, hosts)
}
func getDefenderHostByID(w http.ResponseWriter, r *http.Request) {

View file

@ -82,6 +82,8 @@ const (
webChangeAdminPwdPathDefault = "/web/admin/changepwd"
webTemplateUserDefault = "/web/admin/template/user"
webTemplateFolderDefault = "/web/admin/template/folder"
webDefenderPathDefault = "/web/admin/defender"
webDefenderHostsPathDefault = "/web/admin/defender/hosts"
webClientLoginPathDefault = "/web/client/login"
webClientFilesPathDefault = "/web/client/files"
webClientDirContentsPathDefault = "/web/client/listdir"
@ -128,6 +130,8 @@ var (
webChangeAdminPwdPath string
webTemplateUser string
webTemplateFolder string
webDefenderPath string
webDefenderHostsPath string
webClientLoginPath string
webClientFilesPath string
webClientDirContentsPath string
@ -460,6 +464,8 @@ func updateWebAdminURLs(baseURL string) {
webChangeAdminPwdPath = path.Join(baseURL, webChangeAdminPwdPathDefault)
webTemplateUser = path.Join(baseURL, webTemplateUserDefault)
webTemplateFolder = path.Join(baseURL, webTemplateFolderDefault)
webDefenderHostsPath = path.Join(baseURL, webDefenderHostsPathDefault)
webDefenderPath = path.Join(baseURL, webDefenderPathDefault)
webStaticFilesPath = path.Join(baseURL, webStaticFilesPathDefault)
}

View file

@ -99,6 +99,7 @@ const (
webChangeAdminPwdPath = "/web/admin/changepwd"
webTemplateUser = "/web/admin/template/user"
webTemplateFolder = "/web/admin/template/folder"
webDefenderPath = "/web/admin/defender"
webBasePathClient = "/web/client"
webClientLoginPath = "/web/client/login"
webClientFilesPath = "/web/client/files"
@ -3842,6 +3843,8 @@ func TestChangeAdminPwdMock(t *testing.T) {
func TestUpdateAdminMock(t *testing.T) {
token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
_, err = getJWTAPITokenFromTestServer(altAdminUsername, defaultTokenAuthPass)
assert.Error(t, err)
admin := getTestAdmin()
admin.Username = altAdminUsername
admin.Permissions = []string{dataprovider.PermAdminManageAdmins}
@ -3851,6 +3854,9 @@ func TestUpdateAdminMock(t *testing.T) {
setBearerForReq(req, token)
rr := executeRequest(req)
checkResponseCode(t, http.StatusCreated, rr)
_, err = getJWTAPITokenFromTestServer(altAdminUsername, defaultTokenAuthPass)
assert.NoError(t, err)
req, _ = http.NewRequest(http.MethodPut, path.Join(adminPath, "abc"), bytes.NewBuffer(asJSON))
setBearerForReq(req, token)
rr = executeRequest(req)
@ -3885,6 +3891,7 @@ func TestUpdateAdminMock(t *testing.T) {
altToken, err := getJWTAPITokenFromTestServer(altAdminUsername, defaultTokenAuthPass)
assert.NoError(t, err)
admin.Password = "" // it must remain unchanged
admin.Permissions = []string{dataprovider.PermAdminManageAdmins, dataprovider.PermAdminCloseConnections}
asJSON, err = json.Marshal(admin)
assert.NoError(t, err)
@ -3893,6 +3900,9 @@ func TestUpdateAdminMock(t *testing.T) {
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
_, err = getJWTAPITokenFromTestServer(altAdminUsername, defaultTokenAuthPass)
assert.NoError(t, err)
req, _ = http.NewRequest(http.MethodDelete, path.Join(adminPath, altAdminUsername), nil)
setBearerForReq(req, token)
rr = executeRequest(req)
@ -6241,6 +6251,17 @@ func TestBasicWebUsersMock(t *testing.T) {
checkResponseCode(t, http.StatusOK, rr)
}
func TestRenderDefenderPageMock(t *testing.T) {
token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
req, err := http.NewRequest(http.MethodGet, webDefenderPath, nil)
assert.NoError(t, err)
setJWTCookieForReq(req, token)
rr := executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), "View and manage blocklist")
}
func TestWebAdminBasicMock(t *testing.T) {
token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)

View file

@ -742,6 +742,9 @@ func (s *httpdServer) initializeRouter() {
router.With(checkPerm(dataprovider.PermAdminManageSystem), s.refreshCookie).
Get(webTemplateFolder, handleWebTemplateFolderGet)
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Post(webTemplateFolder, handleWebTemplateFolderPost)
router.With(checkPerm(dataprovider.PermAdminViewDefender)).Get(webDefenderPath, handleWebDefenderPage)
router.With(checkPerm(dataprovider.PermAdminViewDefender)).Get(webDefenderHostsPath, getDefenderHosts)
router.With(checkPerm(dataprovider.PermAdminManageDefender)).Delete(webDefenderHostsPath+"/{id}", deleteDefenderHostByID)
})
}
}

View file

@ -52,6 +52,7 @@ const (
templateMessage = "message.html"
templateStatus = "status.html"
templateLogin = "login.html"
templateDefender = "defender.html"
templateChangePwd = "changepwd.html"
templateMaintenance = "maintenance.html"
templateSetup = "adminsetup.html"
@ -62,6 +63,7 @@ const (
pageFoldersTitle = "Folders"
pageChangePwdTitle = "Change password"
pageMaintenanceTitle = "Maintenance"
pageDefenderTitle = "Defender"
pageSetupTitle = "Create first admin user"
defaultQueryLimit = 500
)
@ -83,6 +85,7 @@ type basePage struct {
FoldersURL string
FolderURL string
FolderTemplateURL string
DefenderURL string
LogoutURL string
ChangeAdminPwdURL string
FolderQuotaScanURL string
@ -95,8 +98,10 @@ type basePage struct {
FoldersTitle string
StatusTitle string
MaintenanceTitle string
DefenderTitle string
Version string
CSRFToken string
HasDefender bool
LoggedAdmin *dataprovider.Admin
}
@ -159,6 +164,11 @@ type maintenancePage struct {
Error string
}
type defenderHostsPage struct {
basePage
DefenderHostsURL string
}
type setupPage struct {
basePage
Username string
@ -234,6 +244,10 @@ func loadAdminTemplates(templatesPath string) {
filepath.Join(templatesPath, templateAdminDir, templateBase),
filepath.Join(templatesPath, templateAdminDir, templateMaintenance),
}
defenderPath := []string{
filepath.Join(templatesPath, templateAdminDir, templateBase),
filepath.Join(templatesPath, templateAdminDir, templateDefender),
}
setupPath := []string{
filepath.Join(templatesPath, templateAdminDir, templateSetup),
}
@ -249,6 +263,7 @@ func loadAdminTemplates(templatesPath string) {
loginTmpl := utils.LoadTemplate(template.ParseFiles(loginPath...))
changePwdTmpl := utils.LoadTemplate(template.ParseFiles(changePwdPaths...))
maintenanceTmpl := utils.LoadTemplate(template.ParseFiles(maintenancePath...))
defenderTmpl := utils.LoadTemplate(template.ParseFiles(defenderPath...))
setupTmpl := utils.LoadTemplate(template.ParseFiles(setupPath...))
adminTemplates[templateUsers] = usersTmpl
@ -263,6 +278,7 @@ func loadAdminTemplates(templatesPath string) {
adminTemplates[templateLogin] = loginTmpl
adminTemplates[templateChangePwd] = changePwdTmpl
adminTemplates[templateMaintenance] = maintenanceTmpl
adminTemplates[templateDefender] = defenderTmpl
adminTemplates[templateSetup] = setupTmpl
}
@ -282,6 +298,7 @@ func getBasePageData(title, currentURL string, r *http.Request) basePage {
FoldersURL: webFoldersPath,
FolderURL: webFolderPath,
FolderTemplateURL: webTemplateFolder,
DefenderURL: webDefenderPath,
LogoutURL: webLogoutPath,
ChangeAdminPwdURL: webChangeAdminPwdPath,
QuotaScanURL: webQuotaScanPath,
@ -296,8 +313,10 @@ func getBasePageData(title, currentURL string, r *http.Request) basePage {
FoldersTitle: pageFoldersTitle,
StatusTitle: pageStatusTitle,
MaintenanceTitle: pageMaintenanceTitle,
DefenderTitle: pageDefenderTitle,
Version: version.GetAsString(),
LoggedAdmin: getAdminFromToken(r),
HasDefender: common.Config.DefenderConfig.Enabled,
CSRFToken: csrfToken,
}
}
@ -540,35 +559,6 @@ func getVirtualFoldersFromPostFields(r *http.Request) []vfs.VirtualFolder {
}
}
/*formValue := r.Form.Get("virtual_folders")
for _, cleaned := range getSliceFromDelimitedValues(formValue, "\n") {
if strings.Contains(cleaned, "::") {
mapping := strings.Split(cleaned, "::")
if len(mapping) > 1 {
vfolder := vfs.VirtualFolder{
BaseVirtualFolder: vfs.BaseVirtualFolder{
Name: strings.TrimSpace(mapping[1]),
},
VirtualPath: strings.TrimSpace(mapping[0]),
QuotaFiles: -1,
QuotaSize: -1,
}
if len(mapping) > 2 {
quotaFiles, err := strconv.Atoi(strings.TrimSpace(mapping[2]))
if err == nil {
vfolder.QuotaFiles = quotaFiles
}
}
if len(mapping) > 3 {
quotaSize, err := strconv.ParseInt(strings.TrimSpace(mapping[3]), 10, 64)
if err == nil {
vfolder.QuotaSize = quotaSize
}
}
virtualFolders = append(virtualFolders, vfolder)
}
}
}*/
return virtualFolders
}
@ -1232,6 +1222,15 @@ func handleWebUpdateAdminPost(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, webAdminsPath, http.StatusSeeOther)
}
func handleWebDefenderPage(w http.ResponseWriter, r *http.Request) {
data := defenderHostsPage{
basePage: getBasePageData(pageDefenderTitle, webDefenderPath, r),
DefenderHostsURL: webDefenderHostsPath,
}
renderAdminTemplate(w, templateDefender, data)
}
func handleGetWebUsers(w http.ResponseWriter, r *http.Request) {
limit := defaultQueryLimit
if _, ok := r.URL.Query()["qlimit"]; ok {

View file

@ -107,18 +107,20 @@
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
timeout: 15000,
success: function (result) {
table.button('delete:name').enable(true);
window.location.href = '{{.AdminsURL}}';
},
error: function ($xhr, textStatus, errorThrown) {
table.button('delete:name').enable(true);
var txt = "Unable to delete the selected admin";
if ($xhr) {
var json = $xhr.responseJSON;
if (json) {
if (json.message){
txt += ": " + json.message;
} else {
txt += ": " + json.error;
}
}
}
$('#errorTxt').text(txt);
$('#errorMsg').show();
setTimeout(function () {

View file

@ -97,6 +97,14 @@
</li>
{{end}}
{{ if and .HasDefender (.LoggedAdmin.HasPermission "view_defender")}}
<li class="nav-item {{if eq .CurrentURL .DefenderURL}}active{{end}}">
<a class="nav-link" href="{{.DefenderURL}}">
<i class="fas fa-shield-alt"></i>
<span>{{.DefenderTitle}}</span></a>
</li>
{{end}}
{{ if .LoggedAdmin.HasPermission "manage_admins"}}
<li class="nav-item {{if eq .CurrentURL .AdminsURL}}active{{end}}">
<a class="nav-link" href="{{.AdminsURL}}">

View file

@ -100,17 +100,19 @@
timeout: 15000,
success: function (result) {
setTimeout(function () {
table.button('disconnect:name').enable(true);
window.location.href = '{{.ConnectionsURL}}';
}, 1000);
},
error: function ($xhr, textStatus, errorThrown) {
table.button('disconnect:name').enable(true);
var txt = "Failed to close the selected connection";
if ($xhr) {
var json = $xhr.responseJSON;
if (json) {
if (json.message){
txt += ": " + json.message;
} else {
txt += ": " + json.error;
}
}
}
$('#errorTxt').text(txt);

View file

@ -0,0 +1,213 @@
{{template "base" .}}
{{define "title"}}{{.Title}}{{end}}
{{define "extra_css"}}
<link href="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.css" rel="stylesheet">
<link href="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.css" rel="stylesheet">
<link href="{{.StaticURL}}/vendor/datatables/fixedHeader.bootstrap4.min.css" rel="stylesheet">
<link href="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.css" rel="stylesheet">
<link href="{{.StaticURL}}/vendor/datatables/select.bootstrap4.min.css" rel="stylesheet">
{{end}}
{{define "page_body"}}
<div id="errorMsg" class="card mb-4 border-left-warning" style="display: none;">
<div id="errorTxt" class="card-body text-form-error"></div>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">View and manage blocklist</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0">
<thead>
<tr>
<th>ID</th>
<th>IP</th>
<th>Ban time</th>
<th>Score</th>
</tr>
</thead>
</table>
</div>
</div>
</div>
{{end}}
{{define "dialog"}}
<div class="modal fade" id="deleteModal" tabindex="-1" role="dialog" aria-labelledby="deleteModalLabel"
aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">
Confirmation required
</h5>
<button class="close" type="button" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">Do you want to remoce the selected blocklist entry?</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-dismiss="modal">
Cancel
</button>
<a class="btn btn-warning" href="#" onclick="deleteAction()">
Delete
</a>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script src="{{.StaticURL}}/vendor/datatables/jquery.dataTables.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.buttons.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.fixedHeader.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.responsive.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.select.min.js"></script>
<script type="text/javascript">
function deleteAction() {
var table = $('#dataTable').DataTable();
table.button('delete:name').enable(false);
var id = table.row({ selected: true }).data()["id"];
var path = '{{.DefenderHostsURL}}' + "/" + fixedEncodeURIComponent(id);
$('#deleteModal').modal('hide');
$.ajax({
url: path,
type: 'DELETE',
dataType: 'json',
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
timeout: 15000,
success: function (result) {
window.location.href = '{{.DefenderURL}}';
},
error: function ($xhr, textStatus, errorThrown) {
var txt = "Unable to delete the selected entry";
if ($xhr) {
var json = $xhr.responseJSON;
if (json) {
if (json.message){
txt += ": " + json.message;
} else {
txt += ": " + json.error;
}
}
}
$('#errorTxt').text(txt);
$('#errorMsg').show();
setTimeout(function () {
$('#errorMsg').hide();
}, 5000);
}
});
}
$(document).ready(function () {
$.fn.dataTable.ext.buttons.refresh = {
text: '<i class="fas fa-sync-alt"></i>',
name: 'refresh',
titleAttr: "Refresh",
action: function (e, dt, node, config) {
location.reload();
}
};
$.fn.dataTable.ext.buttons.delete = {
text: '<i class="fas fa-trash"></i>',
name: 'delete',
titleAttr: "Delete",
action: function (e, dt, node, config) {
$('#deleteModal').modal('show');
},
enabled: false
};
var table = $('#dataTable').DataTable({
"ajax": {
"url": "{{.DefenderHostsURL}}",
"dataSrc": "",
"error": function ($xhr, textStatus, errorThrown) {
$(".dataTables_processing").hide();
var txt = "Failed to get defender's list";
if ($xhr) {
var json = $xhr.responseJSON;
if (json) {
if (json.message){
txt += ": " + json.message;
} else {
txt += ": " + json.error;
}
}
}
$('#errorTxt').text(txt);
$('#errorMsg').show();
setTimeout(function () {
$('#errorMsg').hide();
}, 10000);
}
},
"deferRender": true,
"processing": true,
"columns": [
{ "data": "id" },
{ "data": "ip" },
{
"data": "ban_time",
"defaultContent": ""
},
{
"data": "score",
"defaultContent": ""
}
],
"select": {
"style": "single",
"blurable": true
},
"buttons": [],
"lengthChange": false,
"columnDefs": [
{
"targets": [0],
"visible": false,
"searchable": false
},
],
"scrollX": false,
"scrollY": false,
"responsive": true,
"language": {
"processing": '<i class="fas fa-spinner fa-spin fa-3x fa-fw"></i><span class="sr-only">Loading...</span>',
"loadingRecords": "",
"emptyTable": "No records found"
},
"initComplete": function (settings, json) {
{{if .LoggedAdmin.HasPermission "manage_defender"}}
table.button().add(0, 'delete');
{{end}}
table.button().add(0, 'pageLength');
table.button().add(0, 'refresh');
table.buttons().container().appendTo('#dataTable_wrapper .col-md-6:eq(0)');
},
"order": [[2, 'desc'],[3,'desc']]
});
new $.fn.dataTable.FixedHeader(table);
$.fn.dataTable.ext.errMode = 'none';
{{if .LoggedAdmin.HasPermission "manage_defender"}}
table.on('select deselect', function () {
var selectedRows = table.rows({ selected: true }).count();
table.button('delete:name').enable(selectedRows == 1);
});
{{end}}
});
</script>
{{end}}

View file

@ -105,18 +105,20 @@ function deleteAction() {
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
timeout: 15000,
success: function (result) {
table.button('delete:name').enable(true);
window.location.href = '{{.FoldersURL}}';
},
error: function ($xhr, textStatus, errorThrown) {
table.button('delete:name').enable(true);
var txt = "Unable to delete the selected folder";
if ($xhr) {
var json = $xhr.responseJSON;
if (json) {
if (json.message){
txt += ": " + json.message;
} else {
txt += ": " + json.error;
}
}
}
$('#errorTxt').text(txt);
$('#errorMsg').show();
setTimeout(function () {

View file

@ -108,18 +108,20 @@
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
timeout: 15000,
success: function (result) {
table.button('delete:name').enable(true);
window.location.href = '{{.UsersURL}}';
},
error: function ($xhr, textStatus, errorThrown) {
table.button('delete:name').enable(true);
var txt = "Unable to delete the selected user";
if ($xhr) {
var json = $xhr.responseJSON;
if (json) {
if (json.message){
txt += ": " + json.message;
} else {
txt += ": " + json.error;
}
}
}
$('#errorTxt').text(txt);
$('#errorMsg').show();
setTimeout(function () {

View file

@ -197,7 +197,11 @@
if ($xhr) {
var json = $xhr.responseJSON;
if (json) {
if (json.message){
txt += ": " + json.message;
} else {
txt += ": " + json.error;
}
}
}
$('#errorTxt').text(txt);