feat: add system setting to disable password-based login (#2039)

* system setting to disable password login

* fix linter warning

* fix indentation warning

* Prohibit disable-password-login if no identity providers are configured

* Warnings and explicit confirmation when en-/disabling password-login

- Disabling password login now gives a warning and requires a second
  confirmation which needs to be explicitly typed.
- (Re)Enabling password login now also gives a simple warning.
- Removing an identity provider while password-login is disabled now
  also warns about possible problems.

* Fix formatting

* Fix code-style

---------

Co-authored-by: traumweh <5042134-traumweh@users.noreply.gitlab.com>
This commit is contained in:
Lilith 2023-07-30 13:22:02 +00:00 committed by GitHub
parent 9ef0f8a901
commit c1cbfd5766
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 257 additions and 55 deletions

View File

@ -37,6 +37,24 @@ func (s *APIV1Service) registerAuthRoutes(g *echo.Group) {
g.POST("/auth/signin", func(c echo.Context) error {
ctx := c.Request().Context()
signin := &SignIn{}
disablePasswordLoginSystemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
Name: SystemSettingDisablePasswordLoginName.String(),
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err)
}
if disablePasswordLoginSystemSetting != nil {
disablePasswordLogin := false
err = json.Unmarshal([]byte(disablePasswordLoginSystemSetting.Value), &disablePasswordLogin)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting").SetInternal(err)
}
if disablePasswordLogin {
return echo.NewHTTPError(http.StatusUnauthorized, "Password login is deactivated")
}
}
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err)
}

View File

@ -19,6 +19,8 @@ type SystemStatus struct {
// System settings
// Allow sign up.
AllowSignUp bool `json:"allowSignUp"`
// Disable password login.
DisablePasswordLogin bool `json:"disablePasswordLogin"`
// Disable public memos.
DisablePublicMemos bool `json:"disablePublicMemos"`
// Max upload size.
@ -48,14 +50,15 @@ func (s *APIV1Service) registerSystemRoutes(g *echo.Group) {
ctx := c.Request().Context()
systemStatus := SystemStatus{
Profile: *s.Profile,
DBSize: 0,
AllowSignUp: false,
DisablePublicMemos: false,
MaxUploadSizeMiB: 32,
AutoBackupInterval: 0,
AdditionalStyle: "",
AdditionalScript: "",
Profile: *s.Profile,
DBSize: 0,
AllowSignUp: false,
DisablePasswordLogin: false,
DisablePublicMemos: false,
MaxUploadSizeMiB: 32,
AutoBackupInterval: 0,
AdditionalStyle: "",
AdditionalScript: "",
CustomizedProfile: CustomizedProfile{
Name: "memos",
LogoURL: "",
@ -103,6 +106,8 @@ func (s *APIV1Service) registerSystemRoutes(g *echo.Group) {
switch systemSetting.Name {
case SystemSettingAllowSignUpName.String():
systemStatus.AllowSignUp = baseValue.(bool)
case SystemSettingDisablePasswordLoginName.String():
systemStatus.DisablePasswordLogin = baseValue.(bool)
case SystemSettingDisablePublicMemosName.String():
systemStatus.DisablePublicMemos = baseValue.(bool)
case SystemSettingMaxUploadSizeMiBName.String():

View File

@ -19,6 +19,8 @@ const (
SystemSettingSecretSessionName SystemSettingName = "secret-session"
// SystemSettingAllowSignUpName is the name of allow signup setting.
SystemSettingAllowSignUpName SystemSettingName = "allow-signup"
// SystemSettingDisablePasswordLoginName is the name of disable password login setting.
SystemSettingDisablePasswordLoginName SystemSettingName = "disable-password-login"
// SystemSettingDisablePublicMemosName is the name of disable public memos setting.
SystemSettingDisablePublicMemosName SystemSettingName = "disable-public-memos"
// SystemSettingMaxUploadSizeMiBName is the name of max upload size setting.
@ -92,6 +94,11 @@ func (upsert UpsertSystemSettingRequest) Validate() error {
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
case SystemSettingDisablePasswordLoginName:
var value bool
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
case SystemSettingDisablePublicMemosName:
var value bool
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
@ -201,6 +208,20 @@ func (s *APIV1Service) registerSystemSettingRoutes(g *echo.Group) {
if err := systemSettingUpsert.Validate(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid system setting").SetInternal(err)
}
if systemSettingUpsert.Name == SystemSettingDisablePasswordLoginName {
var disablePasswordLogin bool
if err := json.Unmarshal([]byte(systemSettingUpsert.Value), &disablePasswordLogin); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid system setting").SetInternal(err)
}
identityProviderList, err := s.Store.ListIdentityProviders(ctx, &store.FindIdentityProvider{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert system setting").SetInternal(err)
}
if disablePasswordLogin && len(identityProviderList) == 0 {
return echo.NewHTTPError(http.StatusForbidden, "Cannot disable passwords if no SSO identity provider is configured.")
}
}
systemSetting, err := s.Store.UpsertSystemSetting(ctx, &store.SystemSetting{
Name: systemSettingUpsert.Name.String(),

View File

@ -0,0 +1,98 @@
import { Button } from "@mui/joy";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslate } from "@/utils/i18n";
import { useGlobalStore } from "@/store/module";
import * as api from "@/helpers/api";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
type Props = DialogProps;
interface State {
disablePasswordLogin: boolean;
}
const DisablePasswordLoginDialog: React.FC<Props> = ({ destroy }: Props) => {
const t = useTranslate();
const globalStore = useGlobalStore();
const systemStatus = globalStore.state.systemStatus;
const [state, setState] = useState<State>({
disablePasswordLogin: systemStatus.disablePasswordLogin,
});
const [confirmedOnce, setConfirmedOnce] = useState(false);
const [typingConfirmation, setTypingConfirmation] = useState("");
const handleCloseBtnClick = () => {
destroy();
};
const allowConfirmAction = () => {
return !confirmedOnce || typingConfirmation === "CONFIRM";
};
const handleConfirmBtnClick = async () => {
if (!confirmedOnce) {
setConfirmedOnce(true);
} else {
setState({ ...state, disablePasswordLogin: true });
globalStore.setSystemStatus({ disablePasswordLogin: true });
try {
await api.upsertSystemSetting({
name: "disable-password-login",
value: JSON.stringify(true),
});
handleCloseBtnClick();
} catch (error: any) {
console.error(error);
toast.error(error.response.data.message || t("message.updating-setting-failed"));
}
}
};
const handleTypingConfirmationChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setTypingConfirmation(text);
};
return (
<>
<div className="dialog-header-container !w-64">
<p className="title-text">{t("setting.system-section.disable-password-login")}</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<Icon.X />
</button>
</div>
<div className="dialog-content-container !w-64">
{confirmedOnce ? (
<>
<p className="content-text">{t("setting.system-section.disable-password-login-final-warning")}</p>
<input type="text" className="input-text" value={typingConfirmation} onChange={handleTypingConfirmationChanged} />
</>
) : (
<p className="content-text">{t("setting.system-section.disable-password-login-warning")}</p>
)}
<div className="mt-4 w-full flex flex-row justify-end items-center space-x-2">
<Button variant="plain" color="neutral" onClick={handleCloseBtnClick}>
{t("common.close")}
</Button>
<Button onClick={handleConfirmBtnClick} color="danger" disabled={!allowConfirmAction()}>
{t("common.confirm")}
</Button>
</div>
</div>
</>
);
};
function showDisablePasswordLoginDialog() {
generateDialog(
{
className: "disable-password-login-dialog",
dialogName: "disable-password-login-dialog",
},
DisablePasswordLoginDialog
);
}
export default showDisablePasswordLoginDialog;

View File

@ -7,9 +7,19 @@ import showCreateIdentityProviderDialog from "../CreateIdentityProviderDialog";
import Dropdown from "../kit/Dropdown";
import { showCommonDialog } from "../Dialog/CommonDialog";
import LearnMore from "../LearnMore";
import { useGlobalStore } from "@/store/module";
interface State {
disablePasswordLogin: boolean;
}
const SSOSection = () => {
const t = useTranslate();
const globalStore = useGlobalStore();
const systemStatus = globalStore.state.systemStatus;
const [state] = useState<State>({
disablePasswordLogin: systemStatus.disablePasswordLogin,
});
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
useEffect(() => {
@ -22,9 +32,15 @@ const SSOSection = () => {
};
const handleDeleteIdentityProvider = async (identityProvider: IdentityProvider) => {
let content = t("setting.sso-section.confirm-delete", { name: identityProvider.name });
if (state.disablePasswordLogin) {
content += "\n\n" + t("setting.sso-section.disabled-password-login-warning");
}
showCommonDialog({
title: t("setting.sso-section.delete-sso"),
content: t("setting.sso-section.confirm-delete", { name: identityProvider.name }),
content: content,
style: "warning",
dialogName: "delete-identity-provider-dialog",
onConfirm: async () => {

View File

@ -9,10 +9,13 @@ import showUpdateCustomizedProfileDialog from "../UpdateCustomizedProfileDialog"
import Icon from "../Icon";
import LearnMore from "../LearnMore";
import "@/less/settings/system-section.less";
import { showCommonDialog } from "../Dialog/CommonDialog";
import showDisablePasswordLoginDialog from "../DisablePasswordLoginDialog";
interface State {
dbSize: number;
allowSignUp: boolean;
disablePasswordLogin: boolean;
disablePublicMemos: boolean;
additionalStyle: string;
additionalScript: string;
@ -28,6 +31,7 @@ const SystemSection = () => {
const [state, setState] = useState<State>({
dbSize: systemStatus.dbSize,
allowSignUp: systemStatus.allowSignUp,
disablePasswordLogin: systemStatus.disablePasswordLogin,
additionalStyle: systemStatus.additionalStyle,
additionalScript: systemStatus.additionalScript,
disablePublicMemos: systemStatus.disablePublicMemos,
@ -55,6 +59,7 @@ const SystemSection = () => {
...state,
dbSize: systemStatus.dbSize,
allowSignUp: systemStatus.allowSignUp,
disablePasswordLogin: systemStatus.disablePasswordLogin,
additionalStyle: systemStatus.additionalStyle,
additionalScript: systemStatus.additionalScript,
disablePublicMemos: systemStatus.disablePublicMemos,
@ -76,6 +81,27 @@ const SystemSection = () => {
});
};
const handleDisablePasswordLoginChanged = async (value: boolean) => {
if (value) {
showDisablePasswordLoginDialog();
} else {
showCommonDialog({
title: t("setting.system-section.enable-password-login"),
content: t("setting.system-section.enable-password-login-warning"),
style: "warning",
dialogName: "enable-password-login-dialog",
onConfirm: async () => {
setState({ ...state, disablePasswordLogin: value });
globalStore.setSystemStatus({ disablePasswordLogin: value });
await api.upsertSystemSetting({
name: "disable-password-login",
value: JSON.stringify(value),
});
},
});
}
};
const handleUpdateCustomizedProfileButtonClick = () => {
showUpdateCustomizedProfileDialog();
};
@ -241,6 +267,10 @@ const SystemSection = () => {
<span className="normal-text">{t("setting.system-section.allow-user-signup")}</span>
<Switch checked={state.allowSignUp} onChange={(event) => handleAllowSignUpChanged(event.target.checked)} />
</div>
<div className="form-label">
<span className="normal-text">{t("setting.system-section.disable-password-login")}</span>
<Switch checked={state.disablePasswordLogin} onChange={(event) => handleDisablePasswordLoginChanged(event.target.checked)} />
</div>
<div className="form-label">
<span className="normal-text">{t("setting.system-section.disable-public-memos")}</span>
<Switch checked={state.disablePublicMemos} onChange={(event) => handleDisablePublicMemosChanged(event.target.checked)} />

View File

@ -256,6 +256,11 @@
},
"database-file-size": "Database File Size",
"allow-user-signup": "Allow user signup",
"disable-password-login": "Disable password login",
"disable-password-login-warning": "This will disable password login for all users. It is not possible to log in without reverting this setting in the database if your configured identity providers fail. You'll also have to be extra carefull when removing an identity provider❗",
"disable-password-login-final-warning": "Please type \"CONFIRM\" if you know what you are doing.",
"enable-password-login": "Enable password login",
"enable-password-login-warning": "This will enable password login for all users. Continue only if you want to users to be able to log in using both SSO and password❗",
"ignore-version-upgrade": "Ignore version upgrade",
"disable-public-memos": "Disable public memos",
"max-upload-size": "Maximum upload size (MiB)",
@ -300,7 +305,8 @@
"authorization-endpoint": "Authorization endpoint",
"token-endpoint": "Token endpoint",
"user-endpoint": "User endpoint",
"scopes": "Scopes"
"scopes": "Scopes",
"disabled-password-login-warning": "Password-login is disabled, be extra careful when removing identity providers❗"
}
},
"filter": {
@ -381,7 +387,9 @@
"update-succeed": "Update succeeded",
"page-not-found": "404 - Page Not Found 😥",
"maximum-upload-size-is": "Maximum allowed upload size is {{size}} MiB",
"file-exceeds-upload-limit-of": "File {{file}} exceeds upload limit of {{size}} MiB"
"file-exceeds-upload-limit-of": "File {{file}} exceeds upload limit of {{size}} MiB",
"updating-setting-failed": "Updating setting failed",
"password-login-disabled": "Can't remove last identity provider when password login is disabled"
},
"days": {
"mon": "Mon",

View File

@ -19,6 +19,7 @@ const Auth = () => {
const mode = systemStatus.profile.mode;
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const disablePasswordLogin = systemStatus.disablePasswordLogin;
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
useEffect(() => {
@ -135,50 +136,52 @@ const Auth = () => {
<img className="h-20 w-auto rounded-full shadow mr-1" src={systemStatus.customizedProfile.logoUrl} alt="" />
<p className="text-3xl text-black opacity-80 dark:text-gray-200">{systemStatus.customizedProfile.name}</p>
</div>
<form className="w-full mt-4" onSubmit={handleFormSubmit}>
<div className="flex flex-col justify-start items-start w-full gap-4">
<Input
className="w-full"
size="lg"
type="text"
placeholder={t("common.username")}
value={username}
onChange={handleUsernameInputChanged}
required
/>
<Input
className="w-full"
size="lg"
type="password"
placeholder={t("common.password")}
value={password}
onChange={handlePasswordInputChanged}
required
/>
</div>
<div className="flex flex-row justify-end items-center w-full mt-6">
{actionBtnLoadingState.isLoading && <Icon.Loader className="w-4 h-auto mr-2 animate-spin dark:text-gray-300" />}
{!systemStatus.host ? (
<Button disabled={actionBtnLoadingState.isLoading} onClick={handleSignUpButtonClick}>
{t("common.sign-up")}
</Button>
) : (
<>
{systemStatus?.allowSignUp && (
<>
<Button variant={"plain"} disabled={actionBtnLoadingState.isLoading} onClick={handleSignUpButtonClick}>
{t("common.sign-up")}
</Button>
<span className="mr-2 font-mono text-gray-200">/</span>
</>
)}
<Button type="submit" disabled={actionBtnLoadingState.isLoading} onClick={handleSignInButtonClick}>
{t("common.sign-in")}
{!disablePasswordLogin && (
<form className="w-full mt-4" onSubmit={handleFormSubmit}>
<div className="flex flex-col justify-start items-start w-full gap-4">
<Input
className="w-full"
size="lg"
type="text"
placeholder={t("common.username")}
value={username}
onChange={handleUsernameInputChanged}
required
/>
<Input
className="w-full"
size="lg"
type="password"
placeholder={t("common.password")}
value={password}
onChange={handlePasswordInputChanged}
required
/>
</div>
<div className="flex flex-row justify-end items-center w-full mt-6">
{actionBtnLoadingState.isLoading && <Icon.Loader className="w-4 h-auto mr-2 animate-spin dark:text-gray-300" />}
{!systemStatus.host ? (
<Button disabled={actionBtnLoadingState.isLoading} onClick={handleSignUpButtonClick}>
{t("common.sign-up")}
</Button>
</>
)}
</div>
</form>
) : (
<>
{systemStatus?.allowSignUp && (
<>
<Button variant={"plain"} disabled={actionBtnLoadingState.isLoading} onClick={handleSignUpButtonClick}>
{t("common.sign-up")}
</Button>
<span className="mr-2 font-mono text-gray-200">/</span>
</>
)}
<Button type="submit" disabled={actionBtnLoadingState.isLoading} onClick={handleSignInButtonClick}>
{t("common.sign-in")}
</Button>
</>
)}
</div>
</form>
)}
{!systemStatus.host && (
<p className="w-full inline-block float-right text-sm mt-4 text-gray-500 text-right whitespace-pre-wrap">
{t("auth.host-tip")}
@ -186,7 +189,7 @@ const Auth = () => {
)}
{identityProviderList.length > 0 && (
<>
<Divider className="!my-4">{t("common.or")}</Divider>
{!disablePasswordLogin && <Divider className="!my-4">{t("common.or")}</Divider>}
<div className="w-full flex flex-col space-y-2">
{identityProviderList.map((identityProvider) => (
<Button

View File

@ -11,6 +11,7 @@ export const initialGlobalState = async () => {
appearance: "system" as Appearance,
systemStatus: {
allowSignUp: false,
disablePasswordLogin: false,
disablePublicMemos: false,
maxUploadSizeMiB: 0,
autoBackupInterval: 0,

View File

@ -19,6 +19,7 @@ const globalSlice = createSlice({
},
dbSize: 0,
allowSignUp: false,
disablePasswordLogin: false,
disablePublicMemos: false,
additionalStyle: "",
additionalScript: "",

View File

@ -18,6 +18,7 @@ interface SystemStatus {
dbSize: number;
// System settings
allowSignUp: boolean;
disablePasswordLogin: boolean;
disablePublicMemos: boolean;
maxUploadSizeMiB: number;
autoBackupInterval: number;