Merge branch 'develop' into feature/new-settings-description

# Conflicts:
#	packages/client/src/pages/PortalSettings/categories/security/StyledSecurity.js
#	packages/client/src/pages/PortalSettings/categories/security/sub-components/category-wrapper.js
#	packages/common/store/SettingsStore.js
This commit is contained in:
Nikita Gopienko 2023-09-01 17:03:04 +03:00
commit a952793dcb
34 changed files with 846 additions and 85 deletions

View File

@ -63,6 +63,11 @@ public class CustomExceptionFilterAttribute : ExceptionFilterAttribute
status = HttpStatusCode.Forbidden;
message = "Access denied";
break;
case BruteForceCredentialException:
case RecaptchaException:
status = HttpStatusCode.Forbidden;
withStackTrace = false;
break;
case AuthenticationException:
status = HttpStatusCode.Unauthorized;
withStackTrace = false;

View File

@ -286,16 +286,20 @@
"max-upload-size": 5242880,
"zendesk-key": "",
"samesite": "",
"sso": {
"saml": {
"login":{
"url" :"/sso/login"
},
"logout":{
"url" :"/sso/slo"
}
}
}
"sso": {
"saml": {
"login": {
"url": "/sso/login"
},
"logout": {
"url": "/sso/slo"
}
}
},
"recaptcha": {
"public-key": "",
"private-key": ""
}
},
"ConnectionStrings": {
"default": {

View File

@ -43,6 +43,7 @@
"BackupList": "Backup List",
"BackupListWarningText": "If you delete any items from the list, their corresponding files will also be deleted. This action cannot be undone. To delete all the files use the link:",
"BetaLabel": "BETA",
"BlockingTime": "Blocking time (sec)",
"Branding": "Branding",
"BrandingSectionDescription": "Specify your company information, add links to external resources and email addresses displayed within the DocSpace interface.",
"BrandingSubtitle": "Use this option to provide on-brand experience to users.",
@ -50,11 +51,15 @@
"BreakpointSmallTextPrompt": "Please resize the window or enable full-screen mode",
"BreakpointWarningText": "This section is only available in the desktop version",
"BreakpointWarningTextPrompt": "Please use the desktop site to access <1>{{sectionName}}</1> settings.",
"BruteForceProtection": "Brute Force Protection",
"BruteForceProtectionDescription": "Set up the limit of unsuccessful login attempts by the user to protect the space against brute-force attacks. When the limit is reached, attempts coming from the associated IP address will be banned for specified period of time, or captcha will be requested if enabled.",
"BruteForceProtectionDescriptionMobile": "To protect the portal against brute-force attacks, you can set up the limits of unsuccessful login attempts by the user.",
"ButtonsColor": "Buttons",
"ByApp": "By authenticator app",
"BySms": "By sms",
"ChangeLogoButton": "Change Logo",
"Characters": "{{length}} characters",
"CheckPeriod": "Check period (sec)",
"ClearBackupList": "Delete all backups",
"CompanyInfoSettings": "Company info settings",
"CompanyInfoSettingsDescription": "This information will be displayed in the <1>{{link}}</1> window.",
@ -100,7 +105,10 @@
"EmptyBackupList": "No backups have been created yet. Create one or more backups for them to appear in this list.",
"EnableAutomaticBackup": "Enable automatic backup.",
"EnableAutomaticBackupDescription": "Use this option to back up the space data.",
"EnterNumber": "Enter number",
"EnterTime": "Enter time",
"EnterTitle": "Enter title",
"ErrorMessageBruteForceProtection": "Specified argument was out of the range of valid values.",
"EveryDay": "Every day",
"EveryMonth": "Every month",
"EveryWeek": "Every week",
@ -137,6 +145,7 @@
"MaxCopies": "{{copiesCount}} - maximum number of backup copies",
"Migration": "Migration",
"NewColorScheme": "New color scheme",
"NumberOfAttempts": "Number of attempts",
"PasswordMinLenght": "Minimal password length",
"Path": "Path",
"PleaseNote": "Please note",

View File

@ -57,7 +57,7 @@ export const StyledCategoryWrapper = styled.div`
display: flex;
flex-direction: row;
gap: 4px;
margin-bottom: 16px;
margin-bottom: 8px;
align-items: center;
`;
@ -73,7 +73,7 @@ export const StyledMobileCategoryWrapper = styled.div`
.category-item-heading {
display: flex;
align-items: center;
margin-bottom: 5px;
margin-bottom: 8px;
}
.category-item-subheader {
@ -86,12 +86,13 @@ export const StyledMobileCategoryWrapper = styled.div`
color: ${(props) => props.theme.client.settings.security.descriptionColor};
font-size: 13px;
max-width: 1024px;
line-height: 20px;
}
.inherit-title-link {
margin-right: 7px;
font-size: 16px;
font-weight: 600;
font-weight: 700;
}
.link-text {
@ -118,9 +119,66 @@ export const LearnMoreWrapper = styled.div`
flex-direction: column;
margin-bottom: 20px;
padding-right: 8px;
line-height: 20px;
}
.page-subtitle {
color: ${(props) =>
props.theme.client.settings.security.descriptionColor} !important;
}
.learn-subtitle {
margin-bottom: 10px;
}
`;
export const StyledBruteForceProtection = styled.div`
width: 100%;
.brute-force-protection-input {
width: 100%;
max-width: 350px;
}
.error-text {
position: absolute;
font-size: 10px;
color: #f21c0e;
}
.save-cancel-buttons {
margin-top: 24px;
}
.input-container {
margin-bottom: 8px;
}
.mobile-description {
margin-bottom: 12px;
}
.description {
max-width: 700px;
padding-bottom: 19px;
.page-subtitle {
line-height: 20px;
color: ${(props) =>
props.theme.client.settings.security.descriptionColor};
padding-bottom: 7px;
}
.link {
line-height: 15px;
font-weight: 600;
color: ${(props) =>
props.theme.client.settings.security.descriptionColor};
text-decoration: underline;
}
@media (max-width: 600px) {
padding-bottom: 20px;
}
}
`;

View File

@ -0,0 +1,343 @@
import { useState, useEffect } from "react";
import { isMobile } from "react-device-detect";
import { withTranslation } from "react-i18next";
import { inject, observer } from "mobx-react";
import { StyledBruteForceProtection } from "../StyledSecurity";
import isEqual from "lodash/isEqual";
import FieldContainer from "@docspace/components/field-container";
import toastr from "@docspace/components/toast/toastr";
import TextInput from "@docspace/components/text-input";
import SaveCancelButtons from "@docspace/components/save-cancel-buttons";
import Text from "@docspace/components/text";
import { size } from "@docspace/components/utils/device";
import { useNavigate, useLocation } from "react-router-dom";
import { saveToSessionStorage, getFromSessionStorage } from "../../../utils";
import BruteForceProtectionLoader from "../sub-components/loaders/brute-force-protection-loader";
import Link from "@docspace/components/link";
const BruteForceProtection = (props) => {
const {
t,
numberAttempt,
blockingTime,
checkPeriod,
setBruteForceProtection,
getBruteForceProtection,
initSettings,
isInit,
bruteForceProtectionUrl,
} = props;
const defaultNumberAttempt = numberAttempt?.toString();
const defaultBlockingTime = blockingTime?.toString();
const defaultCheckPeriod = checkPeriod?.toString();
const [currentNumberAttempt, setCurrentNumberAttempt] =
useState(defaultNumberAttempt);
const [currentBlockingTime, setCurrentBlockingTime] =
useState(defaultBlockingTime);
const [currentCheckPeriod, setCurrentCheckPeriod] =
useState(defaultCheckPeriod);
const [showReminder, setShowReminder] = useState(false);
const [isGetSettingsLoaded, setIsGetSettingsLoaded] = useState(false);
const [isLoadingSave, setIsLoadingSave] = useState(false);
const [hasErrorNumberAttempt, setHasErrorNumberAttempt] = useState(false);
const [hasErrorBlockingTime, setHasErrorBlockingTime] = useState(false);
const [hasErrorCheckPeriod, setHasErrorCheckPeriod] = useState(false);
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
if (
currentNumberAttempt == null ||
currentCheckPeriod == null ||
currentBlockingTime == null
)
return;
setHasErrorNumberAttempt(!parseInt(currentNumberAttempt));
setHasErrorBlockingTime(!parseInt(currentBlockingTime));
setHasErrorCheckPeriod(!parseInt(currentCheckPeriod));
}, [currentNumberAttempt, currentBlockingTime, currentCheckPeriod]);
useEffect(() => {
isInit && getSettings();
}, [isInit]);
useEffect(() => {
checkWidth();
window.addEventListener("resize", checkWidth);
if (!isInit) initSettings();
return () => window.removeEventListener("resize", checkWidth);
}, []);
useEffect(() => {
if (!isGetSettingsLoaded) return;
const defaultSettings = getFromSessionStorage(
"defaultBruteForceProtection"
);
const checkNullNumberAttempt = !+currentNumberAttempt;
const checkNullBlockingTime = !+currentBlockingTime;
const checkNullCheckPeriod = !+currentCheckPeriod;
const newSettings = {
numberAttempt: checkNullNumberAttempt
? currentNumberAttempt
: currentNumberAttempt.replace(/^0+/, ""),
blockingTime: checkNullBlockingTime
? currentBlockingTime
: currentBlockingTime.replace(/^0+/, ""),
checkPeriod: checkNullCheckPeriod
? currentCheckPeriod
: currentCheckPeriod.replace(/^0+/, ""),
};
saveToSessionStorage("currentBruteForceProtection", newSettings);
if (isEqual(defaultSettings, newSettings)) {
setShowReminder(false);
return;
}
setShowReminder(true);
}, [
currentNumberAttempt,
currentBlockingTime,
currentCheckPeriod,
isGetSettingsLoaded,
]);
const checkWidth = () => {
window.innerWidth > size.smallTablet &&
location.pathname.includes("brute-force-protection") &&
navigate("/portal-settings/security/access-portal");
};
const getSettings = () => {
const currentSettings = getFromSessionStorage(
"currentBruteForceProtection"
);
const defaultData = {
numberAttempt: defaultNumberAttempt.replace(/^0+/, ""),
blockingTime: defaultBlockingTime.replace(/^0+/, ""),
checkPeriod: defaultCheckPeriod.replace(/^0+/, ""),
};
saveToSessionStorage("defaultBruteForceProtection", defaultData);
if (currentSettings) {
setCurrentNumberAttempt(currentSettings.numberAttempt);
setCurrentBlockingTime(currentSettings.blockingTime);
setCurrentCheckPeriod(currentSettings.checkPeriod);
setIsGetSettingsLoaded(true);
return;
}
setCurrentNumberAttempt(defaultNumberAttempt);
setCurrentBlockingTime(defaultBlockingTime);
setCurrentCheckPeriod(defaultCheckPeriod);
setIsGetSettingsLoaded(true);
};
const onValidation = (inputValue) => {
const isPositiveOrZeroNumber =
Math.sign(inputValue) === 1 || Math.sign(inputValue) === 0;
return !(
!isPositiveOrZeroNumber ||
inputValue.indexOf(".") !== -1 ||
inputValue.indexOf(" ") !== -1 ||
inputValue.length > 4
);
};
const onChangeNumberAttempt = (e) => {
const inputValue = e.target.value;
onValidation(inputValue) &&
setCurrentNumberAttempt(inputValue) &&
setShowReminder(true);
};
const onChangeBlockingTime = (e) => {
const inputValue = e.target.value;
onValidation(inputValue) &&
setCurrentBlockingTime(inputValue) &&
setShowReminder(true);
};
const onChangeCheckPeriod = (e) => {
const inputValue = e.target.value;
onValidation(inputValue) &&
setCurrentCheckPeriod(inputValue) &&
setShowReminder(true);
};
const onSaveClick = () => {
if (hasErrorNumberAttempt || hasErrorCheckPeriod) return;
setIsLoadingSave(true);
const numberCurrentNumberAttempt = parseInt(currentNumberAttempt);
const numberCurrentBlockingTime = parseInt(currentBlockingTime);
const numberCurrentCheckPeriod = parseInt(currentCheckPeriod);
setBruteForceProtection(
numberCurrentNumberAttempt,
numberCurrentBlockingTime,
numberCurrentCheckPeriod
)
.then(() => {
saveToSessionStorage("defaultBruteForceProtection", {
numberAttempt: currentNumberAttempt.replace(/^0+/, ""),
blockingTime: currentBlockingTime.replace(/^0+/, ""),
checkPeriod: currentCheckPeriod.replace(/^0+/, ""),
});
getBruteForceProtection();
setShowReminder(false);
setIsLoadingSave(false);
toastr.success(t("SuccessfullySaveSettingsMessage"));
})
.catch((error) => {
toastr.error(error);
});
};
const onCancelClick = () => {
const defaultSettings = getFromSessionStorage(
"defaultBruteForceProtection"
);
setCurrentNumberAttempt(defaultSettings.numberAttempt);
setCurrentBlockingTime(defaultSettings.blockingTime);
setCurrentCheckPeriod(defaultSettings.checkPeriod);
setShowReminder(false);
};
const errorNode = (
<div className="error-text">{t("ErrorMessageBruteForceProtection")}</div>
);
if (isMobile && !isGetSettingsLoaded) return <BruteForceProtectionLoader />;
return (
<StyledBruteForceProtection>
<div className="description">
<Text className="page-subtitle">
{t("BruteForceProtectionDescription")}
</Text>
<Link
className="link"
fontSize="13px"
target="_blank"
isHovered
href={bruteForceProtectionUrl}
>
{t("Common:LearnMore")}
</Link>
</div>
<FieldContainer
className="input-container"
labelText={t("NumberOfAttempts")}
isVertical={true}
>
<TextInput
className="brute-force-protection-input"
tabIndex={1}
value={currentNumberAttempt}
onChange={onChangeNumberAttempt}
isDisabled={isLoadingSave}
placeholder={t("EnterNumber")}
hasError={hasErrorNumberAttempt}
/>
{hasErrorNumberAttempt && errorNode}
</FieldContainer>
<FieldContainer
className="input-container"
labelText={t("BlockingTime")}
isVertical={true}
>
<TextInput
className="brute-force-protection-input"
tabIndex={2}
value={currentBlockingTime}
onChange={onChangeBlockingTime}
isDisabled={isLoadingSave}
placeholder={t("EnterTime")}
hasError={hasErrorBlockingTime}
/>
{hasErrorBlockingTime && errorNode}
</FieldContainer>
<FieldContainer
className="input-container"
labelText={t("CheckPeriod")}
isVertical={true}
>
<TextInput
className="brute-force-protection-input"
tabIndex={3}
value={currentCheckPeriod}
onChange={onChangeCheckPeriod}
isDisabled={isLoadingSave}
placeholder={t("EnterTime")}
hasError={hasErrorCheckPeriod}
/>
{hasErrorCheckPeriod && errorNode}
<SaveCancelButtons
className="save-cancel-buttons"
tabIndex={4}
onSaveClick={onSaveClick}
onCancelClick={onCancelClick}
showReminder={showReminder}
reminderTest={t("YouHaveUnsavedChanges")}
saveButtonLabel={t("Common:SaveButton")}
cancelButtonLabel={t("Common:CancelButton")}
displaySettings={true}
hasScroll={false}
additionalClassSaveButton="brute-force-protection-save"
additionalClassCancelButton="brute-force-protection-cancel"
isSaving={isLoadingSave}
/>
</FieldContainer>
</StyledBruteForceProtection>
);
};
export default inject(({ auth, setup }) => {
const {
numberAttempt,
blockingTime,
checkPeriod,
setBruteForceProtection,
getBruteForceProtection,
bruteForceProtectionUrl,
} = auth.settingsStore;
const { initSettings, isInit } = setup;
return {
numberAttempt,
blockingTime,
checkPeriod,
setBruteForceProtection,
getBruteForceProtection,
initSettings,
isInit,
bruteForceProtectionUrl,
};
})(withTranslation(["Settings", "Common"])(observer(BruteForceProtection)));

View File

@ -9,6 +9,7 @@ import TrustedMailSection from "./trustedMail";
import IpSecuritySection from "./ipSecurity";
import AdminMessageSection from "./adminMessage";
import SessionLifetimeSection from "./sessionLifetime";
import BruteForceProtectionSection from "./bruteForceProtection";
import MobileView from "./mobileView";
import StyledSettingsSeparator from "SRC_DIR/pages/PortalSettings/StyledSettingsSeparator";
import { size } from "@docspace/components/utils/device";
@ -141,6 +142,15 @@ const AccessPortal = (props) => {
</div>
<IpSecuritySection />
<StyledSettingsSeparator />
<CategoryWrapper
notTooltip={true}
t={t}
title={t("BruteForceProtection")}
/>
<BruteForceProtectionSection />
<StyledSettingsSeparator />
<Text fontSize="16px" fontWeight="700">
@ -212,4 +222,4 @@ export default inject(({ auth }) => {
lifetimeSettingsUrl,
ipSettingsUrl,
};
})(withTranslation("Settings")(observer(AccessPortal)));
})(withTranslation(["Settings", "Profile"])(observer(AccessPortal)));

View File

@ -57,6 +57,12 @@ const MobileView = (props) => {
url="/portal-settings/security/access-portal/ip"
onClickLink={onClickLink}
/>
<MobileCategoryWrapper
title={t("BruteForceProtection")}
subtitle={t("BruteForceProtectionDescriptionMobile")}
url="/portal-settings/security/access-portal/brute-force-protection"
onClickLink={onClickLink}
/>
<MobileCategoryWrapper
title={t("AdminsMessage")}
subtitle={

View File

@ -0,0 +1,51 @@
import styled from "styled-components";
import Loaders from "@docspace/common/components/Loaders";
const StyledLoader = styled.div`
padding-right: 8px;
.header {
margin-bottom: 12px;
}
.content {
display: flex;
flex-direction: column;
gap: 2px;
}
.buttons {
width: calc(100% - 32px);
position: absolute;
bottom: 16px;
}
`;
const BruteForceProtectionLoader = () => {
return (
<StyledLoader>
<Loaders.Rectangle className="header" height="80px" />
<div className="content">
<div>
<Loaders.Rectangle width="140px" height="20px" />
<Loaders.Rectangle height="32px" />
</div>
<div>
<Loaders.Rectangle width="117px" height="20px" />
<Loaders.Rectangle height="32px" />
</div>
<div>
<Loaders.Rectangle width="117px" height="20px" />
<Loaders.Rectangle height="32px" />
</div>
</div>
<Loaders.Rectangle className="buttons" height="40px" />
</StyledLoader>
);
};
export default BruteForceProtectionLoader;

View File

@ -2,23 +2,19 @@ import { saveToSessionStorage, getFromSessionStorage } from "../utils";
export const resetSessionStorage = () => {
const portalNameFromSessionStorage = getFromSessionStorage("portalName");
const portalNameDefaultFromSessionStorage = getFromSessionStorage(
"portalNameDefault"
);
const greetingTitleFromSessionStorage = getFromSessionStorage(
"greetingTitle"
);
const portalNameDefaultFromSessionStorage =
getFromSessionStorage("portalNameDefault");
const greetingTitleFromSessionStorage =
getFromSessionStorage("greetingTitle");
const greetingTitleDefaultFromSessionStorage = getFromSessionStorage(
"greetingTitleDefault"
);
const languageFromSessionStorage = getFromSessionStorage("language");
const languageDefaultFromSessionStorage = getFromSessionStorage(
"languageDefault"
);
const languageDefaultFromSessionStorage =
getFromSessionStorage("languageDefault");
const timezoneFromSessionStorage = getFromSessionStorage("timezone");
const timezoneDefaultFromSessionStorage = getFromSessionStorage(
"timezoneDefault"
);
const timezoneDefaultFromSessionStorage =
getFromSessionStorage("timezoneDefault");
const selectColorId = getFromSessionStorage("selectColorId");
const defaultColorId = getFromSessionStorage("defaultColorId");
@ -41,6 +37,12 @@ export const resetSessionStorage = () => {
);
const currentIPSettings = getFromSessionStorage("currentIPSettings");
const defaultIPSettings = getFromSessionStorage("defaultIPSettings");
const currentBruteForceProtection = getFromSessionStorage(
"currentBruteForceProtection"
);
const defaultBruteForceProtection = getFromSessionStorage(
"defaultBruteForceProtection"
);
const currentAdminMessageSettings = getFromSessionStorage(
"currentAdminMessageSettings"
);
@ -58,10 +60,9 @@ export const resetSessionStorage = () => {
"defaultStoragePeriod"
);
const companyNameFromeSessionStorage = getFromSessionStorage("companyName");
const companySettingsFromSessionStorage = getFromSessionStorage(
"companySettings"
);
const companyNameFromSessionStorage = getFromSessionStorage("companyName");
const companySettingsFromSessionStorage =
getFromSessionStorage("companySettings");
const defaultCompanySettingsFromSessionStorage = getFromSessionStorage(
"defaultCompanySettings"
);
@ -100,6 +101,12 @@ export const resetSessionStorage = () => {
if (currentIPSettings !== defaultIPSettings) {
saveToSessionStorage("currentIPSettings", defaultIPSettings);
}
if (currentBruteForceProtection !== defaultBruteForceProtection) {
saveToSessionStorage(
"currentBruteForceProtection",
defaultBruteForceProtection
);
}
if (currentAdminMessageSettings !== defaultAdminMessageSettings) {
saveToSessionStorage(
"currentAdminMessageSettings",

View File

@ -154,15 +154,22 @@ export const settingsTree = [
tKey: "IPSecurity",
},
{
id: "portal-settings_catalog-admin-message",
id: "portal-settings_catalog-brute-force-protection",
key: "1-0-4",
icon: "",
link: "brute-force-protection",
tKey: "BruteForceProtection",
},
{
id: "portal-settings_catalog-admin-message",
key: "1-0-5",
icon: "",
link: "admin-message",
tKey: "AdminsMessage",
},
{
id: "portal-settings_catalog-session-life-time",
key: "1-0-5",
key: "1-0-6",
icon: "",
link: "lifetime",
tKey: "SessionLifetime",

View File

@ -54,6 +54,11 @@ const TrustedMailPage = loadable(() =>
const IpSecurityPage = loadable(() =>
import("../pages/PortalSettings/categories/security/access-portal/ipSecurity")
);
const BruteForceProtectionPage = loadable(() =>
import(
"../pages/PortalSettings/categories/security/access-portal/bruteForceProtection"
)
);
const AdminMessagePage = loadable(() =>
import(
"../pages/PortalSettings/categories/security/access-portal/adminMessage"
@ -187,6 +192,10 @@ const PortalSettingsRoutes = {
path: "security/access-portal/ip",
element: <IpSecurityPage />,
},
{
path: "security/access-portal/brute-force-protection",
element: <BruteForceProtectionPage />,
},
{
path: "security/access-portal/admin-message",
element: <AdminMessagePage />,

View File

@ -84,7 +84,6 @@ class SettingsSetupStore {
initSettings = async () => {
if (this.isInit) return;
this.isInit = true;
if (authStore.isAuthenticated) {
await authStore.settingsStore.getPortalPasswordSettings();
@ -92,7 +91,10 @@ class SettingsSetupStore {
await authStore.settingsStore.getIpRestrictionsEnable();
await authStore.settingsStore.getIpRestrictions();
await authStore.settingsStore.getSessionLifetime();
await authStore.settingsStore.getBruteForceProtection();
}
this.isInit = true;
};
setIsLoadingDownloadReport = (state) => {

View File

@ -30,7 +30,12 @@ export function getPortalPasswordSettings(confirmKey = null) {
return request(options);
}
export function setPortalPasswordSettings(minLength, upperCase, digits, specSymbols) {
export function setPortalPasswordSettings(
minLength,
upperCase,
digits,
specSymbols
) {
return request({
method: "put",
url: "/settings/security/password",
@ -123,6 +128,21 @@ export function setLifetimeAuditSettings(data) {
});
}
export function getBruteForceProtection() {
return request({
method: "get",
url: "/settings/security/loginSettings",
});
}
export function setBruteForceProtection(AttemptCount, BlockTime, CheckPeriod) {
return request({
method: "put",
url: "/settings/security/loginSettings",
data: { AttemptCount, BlockTime, CheckPeriod },
});
}
export function getLoginHistoryReport() {
return request({
method: "post",
@ -240,7 +260,13 @@ export function restoreWhiteLabelSettings(isDefault) {
});
}
export function setCompanyInfoSettings(address, companyName, email, phone, site) {
export function setCompanyInfoSettings(
address,
companyName,
email,
phone,
site
) {
const data = {
settings: { address, companyName, email, phone, site },
};
@ -276,7 +302,7 @@ export function getCustomSchemaList() {
export function setAdditionalResources(
feedbackAndSupportEnabled,
videoGuidesEnabled,
helpCenterEnabled,
helpCenterEnabled
) {
const data = {
settings: {
@ -323,7 +349,7 @@ export function setCustomSchema(
regDateCaption,
groupHeadCaption,
guestCaption,
guestsCaption,
guestsCaption
) {
const data = {
userCaption,
@ -402,7 +428,14 @@ export function getMachineName(confirmKey = null) {
return request(options);
}
export function setPortalOwner(email, hash, lng, timeZone, confirmKey = null, analytics) {
export function setPortalOwner(
email,
hash,
lng,
timeZone,
confirmKey = null,
analytics
) {
const options = {
method: "put",
url: "/settings/wizard/complete",
@ -780,7 +813,15 @@ export function removeWebhook(id) {
}
export function getWebhooksJournal(props) {
const { configId, eventId, count, startIndex, deliveryFrom, deliveryTo, groupStatus } = props;
const {
configId,
eventId,
count,
startIndex,
deliveryFrom,
deliveryTo,
groupStatus,
} = props;
const params = {};

View File

@ -1,10 +1,11 @@
import { request, setWithCredentialsStatus } from "../client";
export function login(userName, passwordHash, session) {
export function login(userName, passwordHash, session, recaptchaResponse) {
const data = {
userName,
passwordHash,
session,
recaptchaResponse,
};
return request({

View File

@ -157,6 +157,10 @@ class SettingsStore {
interfaceDirection = localStorage.getItem("interfaceDirection") || "ltr";
numberAttempt = null;
blockingTime = null;
checkPeriod = null;
constructor() {
makeAutoObservable(this);
}
@ -289,6 +293,10 @@ class SettingsStore {
return `${this.helpLink}/administration/docspace-settings.aspx#ipsecurity`;
}
get bruteForceProtectionUrl() {
return `${this.helpLink}/administration/configuration.aspx#loginsettings`;
}
get administratorMessageSettingsUrl() {
return `${this.helpLink}/administration/docspace-settings.aspx#administratormessage`;
}
@ -833,6 +841,26 @@ class SettingsStore {
return res;
};
setBruteForceProtectionSettings = (settings) => {
this.numberAttempt = settings.attemptCount;
this.blockingTime = settings.blockTime;
this.checkPeriod = settings.checkPeriod;
};
getBruteForceProtection = async () => {
const res = await api.settings.getBruteForceProtection();
this.setBruteForceProtectionSettings(res);
};
setBruteForceProtection = async (AttemptCount, BlockTime, CheckPeriod) => {
return api.settings.setBruteForceProtection(
AttemptCount,
BlockTime,
CheckPeriod
);
};
setIsBurgerLoading = (isBurgerLoading) => {
this.isBurgerLoading = isBurgerLoading;
};

View File

@ -161,6 +161,7 @@ class AxiosClient {
}
break;
case 403:
if (options.skipLogout) return Promise.reject(error);
const pathname = window.location.pathname;
const isArchived = pathname.indexOf("/rooms/archived") !== -1;

View File

@ -3,10 +3,11 @@ import { setWithCredentialsStatus } from "../api/client";
export async function login(
user: string,
hash: string,
session = true
session = true,
captchaToken: string
): Promise<string | object> {
try {
const response = await api.user.login(user, hash, session);
const response = await api.user.login(user, hash, session, captchaToken);
if (!response || (!response.token && !response.tfa))
throw response.error.message;

View File

@ -53,6 +53,8 @@ const {
strongBlue,
lightGrayishStrongBlue,
darkRed,
lightErrorStatus,
} = globalColors;
const Base = {
@ -2785,6 +2787,11 @@ const Base = {
container: {
backgroundColor: grayLightMid,
},
captcha: {
border: `1px solid ${lightErrorStatus}`,
color: lightErrorStatus,
},
},
facebookButton: {

View File

@ -47,6 +47,8 @@ const {
strongBlue,
lightGrayishStrongBlue,
darkRed,
darkErrorStatus,
} = globalColors;
const Dark = {
@ -2791,6 +2793,11 @@ const Dark = {
container: {
backgroundColor: "#474747",
},
captcha: {
border: `1px solid ${darkErrorStatus}`,
color: darkErrorStatus,
},
},
facebookButton: {

View File

@ -56,6 +56,9 @@ const globalColors = {
hoverError: "#F7CDBE",
hoverInfo: "#F8F7BF",
hoverWarning: "#F7E6BE",
lightErrorStatus: "#F24724",
darkErrorStatus: "#E06451",
};
export default globalColors;

View File

@ -35,6 +35,8 @@ declare global {
size: number;
};
type CaptchaPublicKeyType = string | undefined;
interface IEmailValid {
value: string;
isValid: boolean;
@ -60,6 +62,7 @@ declare global {
version: string;
standalone: boolean;
trustedDomains: string[];
recaptchaPublicKey: CaptchaPublicKeyType;
}
interface IBuildInfo {
@ -85,7 +88,7 @@ declare global {
type TThemeObj = {
accent: string;
buttons: string;
}
};
interface ITheme {
id: number;
@ -165,13 +168,13 @@ declare global {
type TLogoPath = {
light: string;
dark?: string;
}
};
type TLogoSize = {
width: number;
height: number;
isEmpty: boolean;
}
};
interface ILogoUrl {
name: string;

View File

@ -120,6 +120,7 @@
"iconv-lite": "^0.6.3",
"morgan": "^1.10.0",
"nconf": "^0.12.0",
"react-google-recaptcha": "^3.1.0",
"utf-8-validate": "^5.0.10",
"winston": "^3.8.2",
"winston-cloudwatch": "^6.1.1",

View File

@ -7,6 +7,7 @@
"InvalidUsernameOrPassword": "Invalid username or password",
"LoginWithAccountNotFound": "Can't find associated third-party account. You need to connect your social networking account at the profile editing page first.",
"LoginWithBruteForce": "Authorization temporarily blocked",
"LoginWithBruteForceCaptcha": "Confirm that you are not a robot",
"RecaptchaInvalid": "Invalid Recaptcha",
"SsoAttributesNotFound": "Authentication failed (assertion attributes not found)",
"SsoAuthFailed": "Authentication failed",

View File

@ -47,6 +47,7 @@ interface ILoginProps extends IInitialState {
isDesktopEditor?: boolean;
theme: IUserTheme;
setTheme: (theme: IUserTheme) => void;
isBaseTheme: boolean;
}
const Login: React.FC<ILoginProps> = ({
@ -59,6 +60,7 @@ const Login: React.FC<ILoginProps> = ({
theme,
setTheme,
logoUrls,
isBaseTheme,
}) => {
const isRestoringPortal =
portalSettings?.tenantStatus === TenantStatus.PortalRestore;
@ -266,6 +268,8 @@ const Login: React.FC<ILoginProps> = ({
</div>
)}
<LoginForm
isBaseTheme={isBaseTheme}
recaptchaPublicKey={portalSettings?.recaptchaPublicKey}
isDesktop={!!isDesktopEditor}
isLoading={isLoading}
hashSettings={portalSettings?.passwordHash}
@ -314,5 +318,6 @@ export default inject(({ loginStore }) => {
return {
theme: loginStore.theme,
setTheme: loginStore.setTheme,
isBaseTheme: loginStore.theme.isBase,
};
})(observer(Login));

View File

@ -22,6 +22,11 @@ interface ILoginContentProps {
enabledJoin?: boolean;
}
interface IStyledCaptchaProps {
isCaptchaError?: boolean;
theme?: IUserTheme;
}
export const LoginFormWrapper = styled.div`
display: grid;
grid-template-rows: ${(props: ILoginFormWrapperProps) =>
@ -38,7 +43,7 @@ export const LoginFormWrapper = styled.div`
}
.bg-cover {
background-image: ${props => props.bgPattern};
background-image: ${(props) => props.bgPattern};
background-repeat: no-repeat;
background-attachment: fixed;
background-size: cover;
@ -55,20 +60,43 @@ export const LoginFormWrapper = styled.div`
`;
export const LoginContent = styled.div`
min-height: ${(props: ILoginContentProps) => props.enabledJoin ? "calc(100vh - 68px)" : "100vh"};
flex: 1 0 auto;
flex-direction: column;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
@media ${hugeMobile} {
min-height: 100%;
justify-content: start;
}
min-height: ${(props: ILoginContentProps) =>
props.enabledJoin ? "calc(100vh - 68px)" : "100vh"};
flex: 1 0 auto;
flex-direction: column;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
@media ${hugeMobile} {
min-height: 100%;
justify-content: start;
}
`;
export const StyledCaptcha = styled.div`
margin: 24px 0;
width: fit-content;
.captcha-wrapper {
${(props: IStyledCaptchaProps) =>
props.isCaptchaError &&
css`
border: ${props.theme.login.captcha.border};
padding: 4px 4px 4px 2px;
`};
margin-bottom: 2px;
}
${(props: IStyledCaptchaProps) =>
props.isCaptchaError &&
css`
p {
color: ${props.theme.login.captcha.color};
}
`}
`;

View File

@ -16,6 +16,8 @@ import toastr from "@docspace/components/toast/toastr";
import { thirdPartyLogin } from "@docspace/common/api/user";
import { setWithCredentialsStatus } from "@docspace/common/api/client";
import { isMobileOnly } from "react-device-detect";
import ReCAPTCHA from "react-google-recaptcha";
import { StyledCaptcha } from "../StyledLogin";
interface ILoginFormProps {
isLoading: boolean;
@ -25,6 +27,8 @@ interface ILoginFormProps {
match: MatchType;
onRecoverDialogVisible: () => void;
enableAdmMess: boolean;
recaptchaPublicKey: CaptchaPublicKeyType;
isBaseTheme: boolean;
}
const settings = {
@ -43,7 +47,11 @@ const LoginForm: React.FC<ILoginFormProps> = ({
onRecoverDialogVisible,
enableAdmMess,
cookieSettingsEnabled,
recaptchaPublicKey,
isBaseTheme,
}) => {
const captchaRef = useRef(null);
const [isEmailErrorShow, setIsEmailErrorShow] = useState(false);
const [errorText, setErrorText] = useState("");
const [identifier, setIdentifier] = useState("");
@ -53,9 +61,12 @@ const LoginForm: React.FC<ILoginFormProps> = ({
const [isDisabled, setIsDisabled] = useState(false);
const [isChecked, setIsChecked] = useState(false);
const [isDialogVisible, setIsDialogVisible] = useState(false);
const [isWithoutPasswordLogin, setIsWithoutPasswordLogin] = useState(
IS_ROOMS_MODE
);
const [isCaptcha, setIsCaptcha] = useState(false);
const [isWithoutPasswordLogin, setIsWithoutPasswordLogin] =
useState(IS_ROOMS_MODE);
const [isCaptchaSuccessful, setIsCaptchaSuccess] = useState(false);
const [isCaptchaError, setIsCaptchaError] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
@ -142,6 +153,17 @@ const LoginForm: React.FC<ILoginFormProps> = ({
const onSubmit = () => {
//errorText && setErrorText("");
let captchaToken = "";
if (recaptchaPublicKey && isCaptcha) {
if (!isCaptchaSuccessful) {
setIsCaptchaError(true);
return;
}
captchaToken = captchaRef.current.getValue();
}
let hasError = false;
const user = identifier.trim();
@ -173,7 +195,8 @@ const LoginForm: React.FC<ILoginFormProps> = ({
isDesktop && checkPwd();
const session = !isChecked;
login(user, hash, session)
login(user, hash, session, captchaToken)
.then((res: string | object) => {
const isConfirm = typeof res === "string" && res.includes("confirm");
const redirectPath = sessionStorage.getItem("referenceUrl");
@ -198,6 +221,14 @@ const LoginForm: React.FC<ILoginFormProps> = ({
errorMessage = error;
}
if (recaptchaPublicKey && error?.response?.status === 403) {
setIsCaptcha(true);
}
if (isCaptcha) {
captchaRef.current.reset();
}
setIsEmailErrorShow(true);
setErrorText(errorMessage);
setPasswordValid(!errorMessage);
@ -251,6 +282,10 @@ const LoginForm: React.FC<ILoginFormProps> = ({
setIsLoading(false);
};
const onSuccessfullyComplete = () => {
setIsCaptchaSuccess(true);
};
return (
<form className="auth-form-container">
<FieldContainer
@ -356,6 +391,21 @@ const LoginForm: React.FC<ILoginFormProps> = ({
onDialogClose={onDialogClose}
/>
)}
{recaptchaPublicKey && isCaptcha && (
<StyledCaptcha isCaptchaError={isCaptchaError}>
<div className="captcha-wrapper">
<ReCAPTCHA
sitekey={recaptchaPublicKey}
ref={captchaRef}
theme={isBaseTheme ? "light" : "dark"}
onChange={onSuccessfullyComplete}
/>
</div>
{isCaptchaError && (
<Text>{t("Errors:LoginWithBruteForceCaptcha")}</Text>
)}
</StyledCaptcha>
)}
</>
)}

View File

@ -521,7 +521,7 @@ public class AuthenticationController : ControllerBase
var requestIp = MessageSettings.GetIP(Request);
(_, user) = await _bruteForceLoginManager.AttemptAsync(inDto.UserName, inDto.PasswordHash, requestIp);
user = await _bruteForceLoginManager.AttemptAsync(inDto.UserName, inDto.PasswordHash, requestIp, inDto.RecaptchaResponse);
}
else
{
@ -549,7 +549,12 @@ public class AuthenticationController : ControllerBase
catch (BruteForceCredentialException)
{
await _messageService.SendAsync(!string.IsNullOrEmpty(inDto.UserName) ? inDto.UserName : AuditResource.EmailNotSpecified, MessageAction.LoginFailBruteForce);
throw new AuthenticationException("Login Fail. Too many attempts");
throw new BruteForceCredentialException(Resource.ErrorTooManyLoginAttempts);
}
catch (RecaptchaException)
{
await _messageService.SendAsync(!string.IsNullOrEmpty(inDto.UserName) ? inDto.UserName : AuditResource.EmailNotSpecified, MessageAction.LoginFailRecaptcha);
throw new RecaptchaException(Resource.RecaptchaInvalid);
}
catch (Exception ex)
{

View File

@ -437,7 +437,7 @@ public class SecurityController : BaseSettingsController
{
throw new ArgumentOutOfRangeException(nameof(checkPeriod));
}
if (blockTime < 0)
if (blockTime < 1)
{
throw new ArgumentOutOfRangeException(nameof(blockTime));
}

View File

@ -69,6 +69,10 @@ public class AuthRequestsDto
/// <summary>Confirmation data</summary>
/// <type>ASC.Web.Api.ApiModel.RequestsDto.ConfirmData, ASC.Web.Api</type>
public ConfirmData ConfirmData { get; set; }
/// <summary>Recaptcha response</summary>
/// <type>System.String, System</type>
public string RecaptchaResponse { get; set; }
}
/// <summary>

View File

@ -33,14 +33,23 @@ public class BruteForceLoginManager
private readonly UserManager _userManager;
private readonly TenantManager _tenantManager;
private readonly IDistributedCache _distributedCache;
private readonly SetupInfo _setupInfo;
private readonly Recaptcha _recaptcha;
private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1);
public BruteForceLoginManager(SettingsManager settingsManager, UserManager userManager, TenantManager tenantManager, IDistributedCache distributedCache)
public BruteForceLoginManager(SettingsManager settingsManager,
UserManager userManager,
TenantManager tenantManager,
IDistributedCache distributedCache,
SetupInfo setupInfo,
Recaptcha recaptcha)
{
_settingsManager = settingsManager;
_userManager = userManager;
_tenantManager = tenantManager;
_distributedCache = distributedCache;
_setupInfo = setupInfo;
_recaptcha = recaptcha;
}
public async Task<(bool, bool)> IncrementAsync(string key, string requestIp, bool throwException, string exceptionMessage = null)
@ -124,15 +133,17 @@ public class BruteForceLoginManager
}
}
public async Task<(bool, UserInfo)> AttemptAsync(string login, string passwordHash, string requestIp)
public async Task<UserInfo> AttemptAsync(string login, string passwordHash, string requestIp, string recaptchaResponse)
{
UserInfo user = null;
var showRecaptcha = true;
var secretEmail = SetupInfo.IsSecretEmail(login);
var recaptchaPassed = secretEmail || await CheckRecaptchaAsync(recaptchaResponse, requestIp);
var blockCacheKey = GetBlockCacheKey(login, requestIp);
if (GetFromCache<string>(blockCacheKey) != null)
if (!recaptchaPassed && GetFromCache<string>(blockCacheKey) != null)
{
throw new BruteForceCredentialException();
}
@ -140,7 +151,7 @@ public class BruteForceLoginManager
try
{
await _semaphore.WaitAsync();
if (GetFromCache<string>(blockCacheKey) != null)
if (!recaptchaPassed && GetFromCache<string>(blockCacheKey) != null)
{
throw new BruteForceCredentialException();
}
@ -149,9 +160,8 @@ public class BruteForceLoginManager
var now = DateTime.UtcNow;
LoginSettingsWrapper settings = null;
List<DateTime> history = null;
var secretEmail = SetupInfo.IsSecretEmail(login);
if (!secretEmail)
if (!recaptchaPassed)
{
historyCacheKey = GetHistoryCacheKey(login, requestIp);
@ -162,8 +172,6 @@ public class BruteForceLoginManager
history = history.Where(item => item > checkTime).ToList();
history.Add(now);
showRecaptcha = history.Count > settings.AttemptCount - 1;
if (history.Count > settings.AttemptCount)
{
SetToCache(blockCacheKey, "block", now.Add(settings.BlockTime));
@ -184,7 +192,7 @@ public class BruteForceLoginManager
throw new Exception("user not found");
}
if (!secretEmail)
if (!recaptchaPassed)
{
history.RemoveAt(history.Count - 1);
@ -200,7 +208,26 @@ public class BruteForceLoginManager
_semaphore.Release();
}
return (showRecaptcha, user);
return user;
}
private async Task<bool> CheckRecaptchaAsync(string recaptchaResponse, string requestIp)
{
var recaptchaPassed = false;
if (!string.IsNullOrEmpty(_setupInfo.RecaptchaPublicKey) &&
!string.IsNullOrEmpty(_setupInfo.RecaptchaPrivateKey) &&
!string.IsNullOrEmpty(recaptchaResponse))
{
recaptchaPassed = await _recaptcha.ValidateRecaptchaAsync(recaptchaResponse, requestIp);
if (!recaptchaPassed)
{
throw new RecaptchaException();
}
}
return recaptchaPassed;
}
private T GetFromCache<T>(string key)

View File

@ -1842,6 +1842,15 @@ namespace ASC.Web.Core.PublicResources {
}
}
/// <summary>
/// Looks up a localized string similar to Too many login attempts. Please try again later.
/// </summary>
public static string ErrorTooManyLoginAttempts {
get {
return ResourceManager.GetString("ErrorTooManyLoginAttempts", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The user could not be found.
/// </summary>

View File

@ -991,4 +991,7 @@
<data name="TariffsFeature_thirdparty" xml:space="preserve">
<value>Third-party integrations</value>
</data>
<data name="ErrorTooManyLoginAttempts" xml:space="preserve">
<value>Too many login attempts. Please try again later</value>
</data>
</root>

View File

@ -169,9 +169,9 @@ public class SetupInfo
SalesEmail = GetAppSettings("web.payment.email", "sales@onlyoffice.com");
_webAutotestSecretEmail = (configuration["web:autotest:secret-email"] ?? "").Trim();
RecaptchaPublicKey = GetAppSettings("web.recaptcha.public-key", null);
RecaptchaPrivateKey = GetAppSettings("web.recaptcha.private-key", "");
RecaptchaVerifyUrl = GetAppSettings("web.recaptcha.verify-url", "https://www.recaptcha.net/recaptcha/api/siteverify");
RecaptchaPublicKey = GetAppSettings("web:recaptcha:public-key", null);
RecaptchaPrivateKey = GetAppSettings("web:recaptcha:private-key", null);
RecaptchaVerifyUrl = GetAppSettings("web:recaptcha:verify-url", "https://www.recaptcha.net/recaptcha/api/siteverify");
_webDisplayMobappsBanner = (configuration["web.display.mobapps.banner"] ?? "").Trim().Split(new char[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries);
ShareTwitterUrl = GetAppSettings("web.share.twitter", "https://twitter.com/intent/tweet?text={0}");

View File

@ -3376,6 +3376,7 @@ __metadata:
nodemon: ^2.0.22
npm-run-all: ^4.1.5
playwright: ^1.32.0
react-google-recaptcha: ^3.1.0
sass: ^1.59.3
sass-loader: ^12.6.0
serve: 14.2.0
@ -20855,7 +20856,7 @@ __metadata:
languageName: node
linkType: hard
"prop-types@npm:^15.0.0, prop-types@npm:^15.5.10, prop-types@npm:^15.5.6, prop-types@npm:^15.5.8, prop-types@npm:^15.6.0, prop-types@npm:^15.6.1, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1":
"prop-types@npm:^15.0.0, prop-types@npm:^15.5.0, prop-types@npm:^15.5.10, prop-types@npm:^15.5.6, prop-types@npm:^15.5.8, prop-types@npm:^15.6.0, prop-types@npm:^15.6.1, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1":
version: 15.8.1
resolution: "prop-types@npm:15.8.1"
dependencies:
@ -21250,6 +21251,18 @@ __metadata:
languageName: node
linkType: hard
"react-async-script@npm:^1.2.0":
version: 1.2.0
resolution: "react-async-script@npm:1.2.0"
dependencies:
hoist-non-react-statics: ^3.3.0
prop-types: ^15.5.0
peerDependencies:
react: ">=16.4.1"
checksum: 303890eeaf9e18d59fca77f9c891bf3b52d2ec9ea88f0af9d19c160a1f101b447c5104ca46e2dd84c19de756d4797f1f054d041b888a3d57204d9145f4b1b532
languageName: node
linkType: hard
"react-autosize-textarea@npm:^7.1.0":
version: 7.1.0
resolution: "react-autosize-textarea@npm:7.1.0"
@ -21497,6 +21510,18 @@ __metadata:
languageName: node
linkType: hard
"react-google-recaptcha@npm:^3.1.0":
version: 3.1.0
resolution: "react-google-recaptcha@npm:3.1.0"
dependencies:
prop-types: ^15.5.0
react-async-script: ^1.2.0
peerDependencies:
react: ">=16.4.1"
checksum: 9dc64daf9684d979b1f66d97e00a42c9ceaa9b9fe8b29c4d02d77edf86781e2008f2ae1bef14509351ff3b2ffbcc26463f0d88dd612d7280b455b8c07101e663
languageName: node
linkType: hard
"react-hammerjs@npm:^1.0.1":
version: 1.0.1
resolution: "react-hammerjs@npm:1.0.1"