diff --git a/client/components/settings/lockedUsersBody.css b/client/components/settings/lockedUsersBody.css new file mode 100644 index 000000000..5ef1933ad --- /dev/null +++ b/client/components/settings/lockedUsersBody.css @@ -0,0 +1,47 @@ +.text-red { + color: #e74c3c; +} + +td i.fa-lock.text-red, +li i.fa-lock.text-red { + margin-right: 5px; +} + +.locked-users-table { + width: 100%; + border-collapse: collapse; + margin: 15px 0; +} + +.locked-users-table th, +.locked-users-table td { + padding: 8px; + text-align: left; + border-bottom: 1px solid #ddd; +} + +.locked-users-table th { + background-color: #f2f2f2; + font-weight: bold; +} + +.locked-users-table tr:hover { + background-color: #f5f5f5; +} + +.loading-indicator { + padding: 10px; + text-align: center; +} + +.loading-indicator i { + margin-right: 5px; +} + +.locked-users-settings { + padding: 0 10px; +} + +button.js-unlock-all-users { + margin-bottom: 20px; +} diff --git a/client/components/settings/lockedUsersBody.js b/client/components/settings/lockedUsersBody.js new file mode 100644 index 000000000..8b44609cc --- /dev/null +++ b/client/components/settings/lockedUsersBody.js @@ -0,0 +1,175 @@ +import { ReactiveCache } from '/imports/reactiveCache'; +import LockoutSettings from '/models/lockoutSettings'; + +BlazeComponent.extendComponent({ + onCreated() { + this.lockedUsers = new ReactiveVar([]); + this.isLoadingLockedUsers = new ReactiveVar(false); + + // Don't load immediately to prevent unnecessary spinner + // The data will be loaded when the tab is selected in peopleBody.js switchMenu + }, + + refreshLockedUsers() { + // Set loading state initially, but we'll hide it if no users are found + this.isLoadingLockedUsers.set(true); + + Meteor.call('getLockedUsers', (err, users) => { + if (err) { + this.isLoadingLockedUsers.set(false); + const reason = err.reason || ''; + const message = `${TAPi18n.__(err.error)}\n${reason}`; + alert(message); + return; + } + + // If no users are locked, don't show loading spinner and set empty array + if (!users || users.length === 0) { + this.isLoadingLockedUsers.set(false); + this.lockedUsers.set([]); + return; + } + + // Format the remaining time to be more human-readable + users.forEach(user => { + if (user.remainingLockTime > 60) { + const minutes = Math.floor(user.remainingLockTime / 60); + const seconds = user.remainingLockTime % 60; + user.remainingTimeFormatted = `${minutes}m ${seconds}s`; + } else { + user.remainingTimeFormatted = `${user.remainingLockTime}s`; + } + }); + + this.lockedUsers.set(users); + this.isLoadingLockedUsers.set(false); + }); + }, + + unlockUser(event) { + const userId = $(event.currentTarget).data('user-id'); + if (!userId) return; + + if (confirm(TAPi18n.__('accounts-lockout-confirm-unlock'))) { + Meteor.call('unlockUser', userId, (err, result) => { + if (err) { + const reason = err.reason || ''; + const message = `${TAPi18n.__(err.error)}\n${reason}`; + alert(message); + return; + } + + if (result) { + alert(TAPi18n.__('accounts-lockout-user-unlocked')); + this.refreshLockedUsers(); + } + }); + } + }, + + unlockAllUsers() { + if (confirm(TAPi18n.__('accounts-lockout-confirm-unlock-all'))) { + Meteor.call('unlockAllUsers', (err, result) => { + if (err) { + const reason = err.reason || ''; + const message = `${TAPi18n.__(err.error)}\n${reason}`; + alert(message); + return; + } + + if (result) { + alert(TAPi18n.__('accounts-lockout-user-unlocked')); + this.refreshLockedUsers(); + } + }); + } + }, + + saveLockoutSettings() { + // Get values from form + const knownFailuresBeforeLockout = parseInt($('#known-failures-before-lockout').val(), 10) || 3; + const knownLockoutPeriod = parseInt($('#known-lockout-period').val(), 10) || 60; + const knownFailureWindow = parseInt($('#known-failure-window').val(), 10) || 15; + + const unknownFailuresBeforeLockout = parseInt($('#unknown-failures-before-lockout').val(), 10) || 3; + const unknownLockoutPeriod = parseInt($('#unknown-lockout-period').val(), 10) || 60; + const unknownFailureWindow = parseInt($('#unknown-failure-window').val(), 10) || 15; + + // Update the database + LockoutSettings.update('known-failuresBeforeLockout', { + $set: { value: knownFailuresBeforeLockout }, + }); + LockoutSettings.update('known-lockoutPeriod', { + $set: { value: knownLockoutPeriod }, + }); + LockoutSettings.update('known-failureWindow', { + $set: { value: knownFailureWindow }, + }); + + LockoutSettings.update('unknown-failuresBeforeLockout', { + $set: { value: unknownFailuresBeforeLockout }, + }); + LockoutSettings.update('unknown-lockoutPeriod', { + $set: { value: unknownLockoutPeriod }, + }); + LockoutSettings.update('unknown-failureWindow', { + $set: { value: unknownFailureWindow }, + }); + + // Reload the AccountsLockout configuration + Meteor.call('reloadAccountsLockout', (err, ret) => { + if (!err && ret) { + const message = TAPi18n.__('accounts-lockout-settings-updated'); + alert(message); + } else { + const reason = err?.reason || ''; + const message = `${TAPi18n.__(err?.error || 'error-updating-settings')}\n${reason}`; + alert(message); + } + }); + }, + + knownFailuresBeforeLockout() { + return LockoutSettings.findOne('known-failuresBeforeLockout')?.value || 3; + }, + + knownLockoutPeriod() { + return LockoutSettings.findOne('known-lockoutPeriod')?.value || 60; + }, + + knownFailureWindow() { + return LockoutSettings.findOne('known-failureWindow')?.value || 15; + }, + + unknownFailuresBeforeLockout() { + return LockoutSettings.findOne('unknown-failuresBeforeLockout')?.value || 3; + }, + + unknownLockoutPeriod() { + return LockoutSettings.findOne('unknown-lockoutPeriod')?.value || 60; + }, + + unknownFailureWindow() { + return LockoutSettings.findOne('unknown-failureWindow')?.value || 15; + }, + + lockedUsers() { + return this.lockedUsers.get(); + }, + + isLoadingLockedUsers() { + return this.isLoadingLockedUsers.get(); + }, + + events() { + return [ + { + 'click button.js-refresh-locked-users': this.refreshLockedUsers, + 'click button#refreshLockedUsers': this.refreshLockedUsers, + 'click button.js-unlock-user': this.unlockUser, + 'click button.js-unlock-all-users': this.unlockAllUsers, + 'click button.js-lockout-save': this.saveLockoutSettings, + }, + ]; + }, +}).register('lockedUsersGeneral'); diff --git a/client/components/settings/peopleBody.css b/client/components/settings/peopleBody.css index 5d426ce37..52a6954c5 100644 --- a/client/components/settings/peopleBody.css +++ b/client/components/settings/peopleBody.css @@ -89,3 +89,87 @@ table tr:nth-child(even) { #deleteAction { margin-left: 5% !important; } + +.divLockedUsersFilter { + display: flex; + align-items: center; + margin: 0 15px; +} + +.divLockedUsersFilter .flex-container { + display: flex; + align-items: center; + gap: 8px; +} + +.divLockedUsersFilter .people-filter { + margin-bottom: 0; + color: #777; + line-height: 34px; +} + +.divLockedUsersFilter .user-filter { + border: 1px solid #ccc; + border-radius: 2px; + padding: 4px 8px; + background-color: white; +} + +.unlock-all-btn { + margin-left: 15px; + background-color: #e67e22; + color: white; + border: none; + border-radius: 2px; + padding: 5px 10px; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; +} + +.unlock-all-btn:hover { + background-color: #d35400; +} + +.account-active-status { + width: 20px; + text-align: center; +} + +.js-toggle-active-status { + cursor: pointer; +} + +.unlock-all-success { + position: fixed; + top: 10%; + left: 50%; + transform: translateX(-50%); + background-color: #27ae60; + color: white; + padding: 10px 20px; + border-radius: 4px; + z-index: 9999; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + animation: fadeOut 3s ease-in forwards; +} + +@keyframes fadeOut { + 0% { opacity: 1; } + 70% { opacity: 1; } + 100% { opacity: 0; } +} + +.account-status { + width: 20px; + text-align: center; +} + +.text-green { + color: #27ae60; +} + +.js-toggle-lock-status { + cursor: pointer; +} diff --git a/client/components/settings/peopleBody.jade b/client/components/settings/peopleBody.jade index 5f56a6142..afd918b8e 100644 --- a/client/components/settings/peopleBody.jade +++ b/client/components/settings/peopleBody.jade @@ -38,12 +38,28 @@ template(name="people") button#searchButton i.fa.fa-search | {{_ 'search'}} + .divLockedUsersFilter + .flex-container + span.people-filter {{_ 'admin-people-filter-show'}} + select.user-filter#userFilterSelect + option(value="all") {{_ 'admin-people-filter-all'}} + option(value="locked") {{_ 'admin-people-filter-locked'}} + option(value="active") {{_ 'admin-people-filter-active'}} + option(value="inactive") {{_ 'admin-people-filter-inactive'}} + button#unlockAllUsers.unlock-all-btn + i.fa.fa-unlock + | {{_ 'accounts-lockout-unlock-all'}} .ext-box-right span {{#unless isMiniScreen}}{{_ 'people-number'}}{{/unless}} #{peopleNumber} .divAddOrRemoveTeam#divAddOrRemoveTeam button#addOrRemoveTeam i.fa.fa-edit | {{_ 'add'}} / {{_ 'delete'}} {{_ 'teams'}} + else if lockedUsersSetting.get + span + i.fa.fa-lock.text-red + unless isMiniScreen + | {{_ 'accounts-lockout-locked-users'}} .content-body .side-menu @@ -60,6 +76,10 @@ template(name="people") a.js-people-menu(data-id="people-setting") i.fa.fa-user | {{_ 'people'}} + li + a.js-locked-users-menu(data-id="locked-users-setting") + i.fa.fa-lock.text-red + | {{_ 'accounts-lockout-locked-users'}} .main-body if loading.get +spinner @@ -69,6 +89,8 @@ template(name="people") +teamGeneral else if peopleSetting.get +peopleGeneral + else if lockedUsersSetting.get + +lockedUsersGeneral template(name="orgGeneral") @@ -114,6 +136,8 @@ template(name="peopleGeneral") tr th +selectAllUser + th {{_ 'accounts-lockout-status'}} + th {{_ 'admin-people-active-status'}} th {{_ 'username'}} th {{_ 'fullname'}} th {{_ 'initials'}} @@ -232,8 +256,20 @@ template(name="peopleRow") else td input.selectUserChkBox(type="checkbox", id="{{userData._id}}") + td.account-status + if isUserLocked + i.fa.fa-lock.text-red.js-toggle-lock-status(data-user-id=userData._id, data-is-locked="true", title="{{_ 'accounts-lockout-click-to-unlock'}}") + else + i.fa.fa-unlock.text-green.js-toggle-lock-status(data-user-id=userData._id, data-is-locked="false", title="{{_ 'accounts-lockout-user-unlocked'}}") + td.account-active-status + if userData.loginDisabled + i.fa.fa-ban.text-red.js-toggle-active-status(data-user-id=userData._id, data-is-active="false", title="{{_ 'admin-people-user-inactive'}}") + else + i.fa.fa-check-circle.text-green.js-toggle-active-status(data-user-id=userData._id, data-is-active="true", title="{{_ 'admin-people-user-active'}}") if userData.loginDisabled td.username {{ userData.username }} + else if isUserLocked + td.username {{ userData.username }} else td.username {{ userData.username }} if userData.loginDisabled @@ -645,3 +681,32 @@ template(name="settingsUserPopup") // that does now remove member from board, card members and assignees correctly, // but that should be used to remove user from all boards similarly // - wekan/models/users.js Delete is not enabled + +template(name="lockedUsersGeneral") + .locked-users-settings + h3 {{_ 'accounts-lockout-settings'}} + p {{_ 'accounts-lockout-info'}} + + h4 {{_ 'accounts-lockout-known-users'}} + .title {{_ 'accounts-lockout-failures-before'}} + .form-group + input.wekan-form-control#known-failures-before-lockout(type="number", min="1", max="10", placeholder="3" value="{{knownFailuresBeforeLockout}}") + .title {{_ 'accounts-lockout-period'}} + .form-group + input.wekan-form-control#known-lockout-period(type="number", min="10", max="600", placeholder="60" value="{{knownLockoutPeriod}}") + .title {{_ 'accounts-lockout-failure-window'}} + .form-group + input.wekan-form-control#known-failure-window(type="number", min="1", max="60", placeholder="15" value="{{knownFailureWindow}}") + + h4 {{_ 'accounts-lockout-unknown-users'}} + .title {{_ 'accounts-lockout-failures-before'}} + .form-group + input.wekan-form-control#unknown-failures-before-lockout(type="number", min="1", max="10", placeholder="3" value="{{unknownFailuresBeforeLockout}}") + .title {{_ 'accounts-lockout-period'}} + .form-group + input.wekan-form-control#unknown-lockout-period(type="number", min="10", max="600", placeholder="60" value="{{unknownLockoutPeriod}}") + .title {{_ 'accounts-lockout-failure-window'}} + .form-group + input.wekan-form-control#unknown-failure-window(type="number", min="1", max="60", placeholder="15" value="{{unknownFailureWindow}}") + + button.js-lockout-save.primary {{_ 'save'}} diff --git a/client/components/settings/peopleBody.js b/client/components/settings/peopleBody.js index b80890c58..63167deeb 100644 --- a/client/components/settings/peopleBody.js +++ b/client/components/settings/peopleBody.js @@ -1,4 +1,5 @@ import { ReactiveCache } from '/imports/reactiveCache'; +import LockoutSettings from '/models/lockoutSettings'; const orgsPerPage = 25; const teamsPerPage = 25; @@ -14,14 +15,16 @@ BlazeComponent.extendComponent({ this.error = new ReactiveVar(''); this.loading = new ReactiveVar(false); this.orgSetting = new ReactiveVar(true); - this.teamSetting = new ReactiveVar(true); - this.peopleSetting = new ReactiveVar(true); + this.teamSetting = new ReactiveVar(false); + this.peopleSetting = new ReactiveVar(false); + this.lockedUsersSetting = new ReactiveVar(false); this.findOrgsOptions = new ReactiveVar({}); this.findTeamsOptions = new ReactiveVar({}); this.findUsersOptions = new ReactiveVar({}); this.numberOrgs = new ReactiveVar(0); this.numberTeams = new ReactiveVar(0); this.numberPeople = new ReactiveVar(0); + this.userFilterType = new ReactiveVar('all'); this.page = new ReactiveVar(1); this.loadNextPageLocked = false; @@ -92,6 +95,34 @@ BlazeComponent.extendComponent({ this.filterPeople(); } }, + 'change #userFilterSelect'(event) { + const filterType = $(event.target).val(); + this.userFilterType.set(filterType); + this.filterPeople(); + }, + 'click #unlockAllUsers'(event) { + event.preventDefault(); + if (confirm(TAPi18n.__('accounts-lockout-confirm-unlock-all'))) { + Meteor.call('unlockAllUsers', (error) => { + if (error) { + console.error('Error unlocking all users:', error); + } else { + // Show a brief success message + const message = document.createElement('div'); + message.className = 'unlock-all-success'; + message.textContent = TAPi18n.__('accounts-lockout-all-users-unlocked'); + document.body.appendChild(message); + + // Remove the message after a short delay + setTimeout(() => { + if (message.parentNode) { + message.parentNode.removeChild(message); + } + }, 3000); + } + }); + } + }, 'click #newOrgButton'() { Popup.open('newOrg'); }, @@ -104,23 +135,50 @@ BlazeComponent.extendComponent({ 'click a.js-org-menu': this.switchMenu, 'click a.js-team-menu': this.switchMenu, 'click a.js-people-menu': this.switchMenu, + 'click a.js-locked-users-menu': this.switchMenu, }, ]; }, filterPeople() { const value = $('#searchInput').first().val(); - if (value === '') { - this.findUsersOptions.set({}); - } else { + const filterType = this.userFilterType.get(); + const currentTime = Number(new Date()); + + let query = {}; + + // Apply text search filter if there's a search value + if (value !== '') { const regex = new RegExp(value, 'i'); - this.findUsersOptions.set({ + query = { $or: [ { username: regex }, { 'profile.fullname': regex }, { 'emails.address': regex }, ], - }); + }; } + + // Apply filter based on selected option + switch (filterType) { + case 'locked': + // Show only locked users + query['services.accounts-lockout.unlockTime'] = { $gt: currentTime }; + break; + case 'active': + // Show only active users (loginDisabled is false or undefined) + query['loginDisabled'] = { $ne: true }; + break; + case 'inactive': + // Show only inactive users (loginDisabled is true) + query['loginDisabled'] = true; + break; + case 'all': + default: + // Show all users, no additional filter + break; + } + + this.findUsersOptions.set(query); }, loadNextPage() { if (this.loadNextPageLocked === false) { @@ -186,6 +244,16 @@ BlazeComponent.extendComponent({ this.orgSetting.set('org-setting' === targetID); this.teamSetting.set('team-setting' === targetID); this.peopleSetting.set('people-setting' === targetID); + this.lockedUsersSetting.set('locked-users-setting' === targetID); + + // When switching to locked users tab, refresh the locked users list + if ('locked-users-setting' === targetID) { + // Find the lockedUsersGeneral component and call refreshLockedUsers + const lockedUsersComponent = Blaze.getView($('.main-body')[0])._templateInstance; + if (lockedUsersComponent && lockedUsersComponent.refreshLockedUsers) { + lockedUsersComponent.refreshLockedUsers(); + } + } } }, }).register('people'); @@ -206,8 +274,36 @@ Template.peopleRow.helpers({ userData() { return ReactiveCache.getUser(this.userId); }, + isUserLocked() { + const user = ReactiveCache.getUser(this.userId); + if (!user) return false; + + // Check if user has accounts-lockout with unlockTime property + if (user.services && + user.services['accounts-lockout'] && + user.services['accounts-lockout'].unlockTime) { + + // Check if unlockTime is in the future + const currentTime = Number(new Date()); + return user.services['accounts-lockout'].unlockTime > currentTime; + } + + return false; + } }); +// Initialize filter dropdown +Template.people.rendered = function() { + const template = this; + + // Set the initial value of the dropdown + Tracker.afterFlush(function() { + if (template.findAll('#userFilterSelect').length) { + $('#userFilterSelect').val('all'); + } + }); +}; + Template.editUserPopup.onCreated(function () { this.authenticationMethods = new ReactiveVar([]); this.errorMessage = new ReactiveVar(''); @@ -415,6 +511,49 @@ BlazeComponent.extendComponent({ else document.getElementById("divAddOrRemoveTeam").style.display = 'none'; }, + 'click .js-toggle-active-status': function(ev) { + ev.preventDefault(); + const userId = this.userId; + const user = ReactiveCache.getUser(userId); + + if (!user) return; + + // Toggle loginDisabled status + const isActive = !(user.loginDisabled === true); + + // Update the user's active status + Users.update(userId, { + $set: { + loginDisabled: isActive + } + }); + }, + 'click .js-toggle-lock-status': function(ev){ + ev.preventDefault(); + const userId = this.userId; + const user = ReactiveCache.getUser(userId); + + if (!user) return; + + // Check if user is currently locked + const isLocked = user.services && + user.services['accounts-lockout'] && + user.services['accounts-lockout'].unlockTime && + user.services['accounts-lockout'].unlockTime > Number(new Date()); + + if (isLocked) { + // Unlock the user + Meteor.call('unlockUser', userId, (error) => { + if (error) { + console.error('Error unlocking user:', error); + } + }); + } else { + // Lock the user - this is optional, you may want to only allow unlocking + // If you want to implement locking too, you would need a server method for it + // For now, we'll leave this as a no-op + } + }, }, ]; }, diff --git a/client/components/settings/settingBody.jade b/client/components/settings/settingBody.jade index 166449dd1..d678590ff 100644 --- a/client/components/settings/settingBody.jade +++ b/client/components/settings/settingBody.jade @@ -171,6 +171,8 @@ template(name='accountSettings') label {{_ 'no'}} button.js-accounts-save.primary {{_ 'save'}} + // Brute force lockout settings moved to People/Locked Users section + template(name='announcementSettings') ul#announcement-setting.setting-detail li diff --git a/client/components/settings/settingBody.js b/client/components/settings/settingBody.js index 9021957c6..d66c5b307 100644 --- a/client/components/settings/settingBody.js +++ b/client/components/settings/settingBody.js @@ -1,6 +1,7 @@ import { ReactiveCache } from '/imports/reactiveCache'; import { TAPi18n } from '/imports/i18n'; import { ALLOWED_WAIT_SPINNERS } from '/config/const'; +import LockoutSettings from '/models/lockoutSettings'; BlazeComponent.extendComponent({ onCreated() { @@ -23,6 +24,7 @@ BlazeComponent.extendComponent({ Meteor.subscribe('announcements'); Meteor.subscribe('accessibilitySettings'); Meteor.subscribe('globalwebhooks'); + Meteor.subscribe('lockoutSettings'); }, setError(error) { @@ -342,15 +344,23 @@ BlazeComponent.extendComponent({ $set: { booleanValue: allowUserDelete }, }); }, + + // Brute force lockout settings method moved to lockedUsersBody.js + allowEmailChange() { - return AccountSettings.findOne('accounts-allowEmailChange').booleanValue; + return AccountSettings.findOne('accounts-allowEmailChange')?.booleanValue || false; }, + allowUserNameChange() { - return AccountSettings.findOne('accounts-allowUserNameChange').booleanValue; + return AccountSettings.findOne('accounts-allowUserNameChange')?.booleanValue || false; }, + allowUserDelete() { - return AccountSettings.findOne('accounts-allowUserDelete').booleanValue; + return AccountSettings.findOne('accounts-allowUserDelete')?.booleanValue || false; }, + + // Lockout settings helper methods moved to lockedUsersBody.js + allBoardsHideActivities() { Meteor.call('setAllBoardsHideActivities', (err, ret) => { if (!err && ret) { diff --git a/imports/i18n/data/en.i18n.json b/imports/i18n/data/en.i18n.json index f857a5359..55cdb8cb9 100644 --- a/imports/i18n/data/en.i18n.json +++ b/imports/i18n/data/en.i18n.json @@ -1272,5 +1272,35 @@ "accessibility-page-enabled": "Accessibility page enabled", "accessibility-info-not-added-yet": "Accessibility info has not been added yet", "accessibility-title": "Accessibility title", - "accessibility-content": "Accessibility content" + "accessibility-content": "Accessibility content", + "accounts-lockout-settings": "Brute Force Protection Settings", + "accounts-lockout-info": "These settings control how login attempts are protected against brute force attacks.", + "accounts-lockout-known-users": "Settings for known users (correct username, wrong password)", + "accounts-lockout-unknown-users": "Settings for unknown users (non-existent username)", + "accounts-lockout-failures-before": "Failures before lockout", + "accounts-lockout-period": "Lockout period (seconds)", + "accounts-lockout-failure-window": "Failure window (seconds)", + "accounts-lockout-settings-updated": "Brute force protection settings have been updated", + "accounts-lockout-locked-users": "Locked Users", + "accounts-lockout-locked-users-info": "Users currently locked out due to too many failed login attempts", + "accounts-lockout-no-locked-users": "There are currently no locked users", + "accounts-lockout-failed-attempts": "Failed Attempts", + "accounts-lockout-remaining-time": "Remaining Time", + "accounts-lockout-user-unlocked": "User has been unlocked successfully", + "accounts-lockout-confirm-unlock": "Are you sure you want to unlock this user?", + "accounts-lockout-confirm-unlock-all": "Are you sure you want to unlock all locked users?", + "accounts-lockout-show-locked-users": "Show locked users only", + "accounts-lockout-user-locked": "User is locked", + "accounts-lockout-click-to-unlock": "Click to unlock this user", + "accounts-lockout-status": "Status", + "admin-people-filter-show": "Show:", + "admin-people-filter-all": "All Users", + "admin-people-filter-locked": "Locked Users Only", + "admin-people-filter-active": "Active", + "admin-people-filter-inactive": "Not Active", + "admin-people-active-status": "Active Status", + "admin-people-user-active": "User is active - click to deactivate", + "admin-people-user-inactive": "User is inactive - click to activate", + "accounts-lockout-all-users-unlocked": "All locked users have been unlocked", + "accounts-lockout-unlock-all": "Unlock All" } diff --git a/models/lockoutSettings.js b/models/lockoutSettings.js new file mode 100644 index 000000000..04bd18cc6 --- /dev/null +++ b/models/lockoutSettings.js @@ -0,0 +1,157 @@ +import { ReactiveCache } from '/imports/reactiveCache'; + +LockoutSettings = new Mongo.Collection('lockoutSettings'); + +LockoutSettings.attachSchema( + new SimpleSchema({ + _id: { + type: String, + }, + value: { + type: Number, + decimal: false, + }, + category: { + type: String, + }, + sort: { + type: Number, + decimal: true, + }, + createdAt: { + type: Date, + optional: true, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert) { + return new Date(); + } else if (this.isUpsert) { + return { $setOnInsert: new Date() }; + } else { + this.unset(); + } + }, + }, + modifiedAt: { + type: Date, + denyUpdate: false, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert || this.isUpsert || this.isUpdate) { + return new Date(); + } else { + this.unset(); + } + }, + }, + }), +); + +LockoutSettings.allow({ + update(userId) { + const user = ReactiveCache.getUser(userId); + return user && user.isAdmin; + }, +}); + +if (Meteor.isServer) { + Meteor.startup(() => { + LockoutSettings._collection.createIndex({ modifiedAt: -1 }); + + // Known users settings + LockoutSettings.upsert( + { _id: 'known-failuresBeforeLockout' }, + { + $setOnInsert: { + value: process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURES_BEFORE + ? parseInt(process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURES_BEFORE, 10) : 3, + category: 'known', + sort: 0, + }, + }, + ); + + LockoutSettings.upsert( + { _id: 'known-lockoutPeriod' }, + { + $setOnInsert: { + value: process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_PERIOD + ? parseInt(process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_PERIOD, 10) : 60, + category: 'known', + sort: 1, + }, + }, + ); + + LockoutSettings.upsert( + { _id: 'known-failureWindow' }, + { + $setOnInsert: { + value: process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURE_WINDOW + ? parseInt(process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURE_WINDOW, 10) : 15, + category: 'known', + sort: 2, + }, + }, + ); + + // Unknown users settings + const typoVar = process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURES_BERORE; + const correctVar = process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURES_BEFORE; + + LockoutSettings.upsert( + { _id: 'unknown-failuresBeforeLockout' }, + { + $setOnInsert: { + value: (correctVar || typoVar) + ? parseInt(correctVar || typoVar, 10) : 3, + category: 'unknown', + sort: 0, + }, + }, + ); + + LockoutSettings.upsert( + { _id: 'unknown-lockoutPeriod' }, + { + $setOnInsert: { + value: process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_LOCKOUT_PERIOD + ? parseInt(process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_LOCKOUT_PERIOD, 10) : 60, + category: 'unknown', + sort: 1, + }, + }, + ); + + LockoutSettings.upsert( + { _id: 'unknown-failureWindow' }, + { + $setOnInsert: { + value: process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURE_WINDOW + ? parseInt(process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURE_WINDOW, 10) : 15, + category: 'unknown', + sort: 2, + }, + }, + ); + }); +} + +LockoutSettings.helpers({ + getKnownConfig() { + return { + failuresBeforeLockout: LockoutSettings.findOne('known-failuresBeforeLockout')?.value || 3, + lockoutPeriod: LockoutSettings.findOne('known-lockoutPeriod')?.value || 60, + failureWindow: LockoutSettings.findOne('known-failureWindow')?.value || 15 + }; + }, + getUnknownConfig() { + return { + failuresBeforeLockout: LockoutSettings.findOne('unknown-failuresBeforeLockout')?.value || 3, + lockoutPeriod: LockoutSettings.findOne('unknown-lockoutPeriod')?.value || 60, + failureWindow: LockoutSettings.findOne('unknown-failureWindow')?.value || 15 + }; + } +}); + +export default LockoutSettings; diff --git a/server/accounts-lockout-config.js b/server/accounts-lockout-config.js new file mode 100644 index 000000000..af437c25b --- /dev/null +++ b/server/accounts-lockout-config.js @@ -0,0 +1,33 @@ +import { AccountsLockout } from 'meteor/wekan-accounts-lockout'; +import LockoutSettings from '/models/lockoutSettings'; + +Meteor.startup(() => { + // Wait for the database to be ready + Meteor.setTimeout(() => { + try { + // Get configurations from database + const knownUsersConfig = { + failuresBeforeLockout: LockoutSettings.findOne('known-failuresBeforeLockout')?.value || 3, + lockoutPeriod: LockoutSettings.findOne('known-lockoutPeriod')?.value || 60, + failureWindow: LockoutSettings.findOne('known-failureWindow')?.value || 15 + }; + + const unknownUsersConfig = { + failuresBeforeLockout: LockoutSettings.findOne('unknown-failuresBeforeLockout')?.value || 3, + lockoutPeriod: LockoutSettings.findOne('unknown-lockoutPeriod')?.value || 60, + failureWindow: LockoutSettings.findOne('unknown-failureWindow')?.value || 15 + }; + + // Initialize the AccountsLockout with configuration + const accountsLockout = new AccountsLockout({ + knownUsers: knownUsersConfig, + unknownUsers: unknownUsersConfig, + }); + + // Start the accounts lockout mechanism + accountsLockout.startup(); + } catch (error) { + console.error('Failed to initialize accounts lockout:', error); + } + }, 2000); // Small delay to ensure database is ready +}); diff --git a/server/methods/lockedUsers.js b/server/methods/lockedUsers.js new file mode 100644 index 000000000..e4eaf8bbc --- /dev/null +++ b/server/methods/lockedUsers.js @@ -0,0 +1,107 @@ +import { ReactiveCache } from '/imports/reactiveCache'; + +// Method to find locked users and release them if needed +Meteor.methods({ + getLockedUsers() { + // Check if user has admin rights + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + const user = ReactiveCache.getUser(userId); + if (!user || !user.isAdmin) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + // Current time to check against unlockTime + const currentTime = Number(new Date()); + + // Find users that are locked (known users) + const lockedUsers = Meteor.users.find( + { + 'services.accounts-lockout.unlockTime': { + $gt: currentTime, + } + }, + { + fields: { + _id: 1, + username: 1, + emails: 1, + 'services.accounts-lockout.unlockTime': 1, + 'services.accounts-lockout.failedAttempts': 1 + } + } + ).fetch(); + + // Format the results for the UI + return lockedUsers.map(user => { + const email = user.emails && user.emails.length > 0 ? user.emails[0].address : 'No email'; + const remainingLockTime = Math.round((user.services['accounts-lockout'].unlockTime - currentTime) / 1000); + + return { + _id: user._id, + username: user.username || 'No username', + email, + failedAttempts: user.services['accounts-lockout'].failedAttempts || 0, + unlockTime: user.services['accounts-lockout'].unlockTime, + remainingLockTime // in seconds + }; + }); + }, + + unlockUser(userId) { + // Check if user has admin rights + const adminId = Meteor.userId(); + if (!adminId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + const admin = ReactiveCache.getUser(adminId); + if (!admin || !admin.isAdmin) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + // Make sure the user to unlock exists + const userToUnlock = Meteor.users.findOne(userId); + if (!userToUnlock) { + throw new Meteor.Error('error-user-not-found', 'User not found'); + } + + // Unlock the user + Meteor.users.update( + { _id: userId }, + { + $unset: { + 'services.accounts-lockout': 1 + } + } + ); + + return true; + }, + + unlockAllUsers() { + // Check if user has admin rights + const adminId = Meteor.userId(); + if (!adminId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + const admin = ReactiveCache.getUser(adminId); + if (!admin || !admin.isAdmin) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + // Unlock all users + Meteor.users.update( + { 'services.accounts-lockout.unlockTime': { $exists: true } }, + { + $unset: { + 'services.accounts-lockout': 1 + } + }, + { multi: true } + ); + + return true; + } +}); diff --git a/server/methods/lockoutSettings.js b/server/methods/lockoutSettings.js new file mode 100644 index 000000000..047999bdc --- /dev/null +++ b/server/methods/lockoutSettings.js @@ -0,0 +1,46 @@ +import { AccountsLockout } from 'meteor/wekan-accounts-lockout'; +import { ReactiveCache } from '/imports/reactiveCache'; +import LockoutSettings from '/models/lockoutSettings'; + +Meteor.methods({ + reloadAccountsLockout() { + // Check if user has admin rights + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + const user = ReactiveCache.getUser(userId); + if (!user || !user.isAdmin) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + try { + // Get configurations from database + const knownUsersConfig = { + failuresBeforeLockout: LockoutSettings.findOne('known-failuresBeforeLockout')?.value || 3, + lockoutPeriod: LockoutSettings.findOne('known-lockoutPeriod')?.value || 60, + failureWindow: LockoutSettings.findOne('known-failureWindow')?.value || 15 + }; + + const unknownUsersConfig = { + failuresBeforeLockout: LockoutSettings.findOne('unknown-failuresBeforeLockout')?.value || 3, + lockoutPeriod: LockoutSettings.findOne('unknown-lockoutPeriod')?.value || 60, + failureWindow: LockoutSettings.findOne('unknown-failureWindow')?.value || 15 + }; + + // Initialize the AccountsLockout with configuration + const accountsLockout = new AccountsLockout({ + knownUsers: knownUsersConfig, + unknownUsers: unknownUsersConfig, + }); + + // Start the accounts lockout mechanism + accountsLockout.startup(); + + return true; + } catch (error) { + console.error('Failed to reload accounts lockout:', error); + throw new Meteor.Error('error-reloading-settings', 'Error reloading settings'); + } + } +}); diff --git a/server/publications/lockoutSettings.js b/server/publications/lockoutSettings.js new file mode 100644 index 000000000..c94309c33 --- /dev/null +++ b/server/publications/lockoutSettings.js @@ -0,0 +1,6 @@ +import LockoutSettings from '/models/lockoutSettings'; + +Meteor.publish('lockoutSettings', function() { + const ret = LockoutSettings.find(); + return ret; +});