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>
pull/2058/head
Lilith 2 years ago committed by GitHub
parent 9ef0f8a901
commit c1cbfd5766
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -37,6 +37,24 @@ func (s *APIV1Service) registerAuthRoutes(g *echo.Group) {
g.POST("/auth/signin", func(c echo.Context) error { g.POST("/auth/signin", func(c echo.Context) error {
ctx := c.Request().Context() ctx := c.Request().Context()
signin := &SignIn{} 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 { if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err) return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err)
} }

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

@ -19,6 +19,8 @@ const (
SystemSettingSecretSessionName SystemSettingName = "secret-session" SystemSettingSecretSessionName SystemSettingName = "secret-session"
// SystemSettingAllowSignUpName is the name of allow signup setting. // SystemSettingAllowSignUpName is the name of allow signup setting.
SystemSettingAllowSignUpName SystemSettingName = "allow-signup" 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 is the name of disable public memos setting.
SystemSettingDisablePublicMemosName SystemSettingName = "disable-public-memos" SystemSettingDisablePublicMemosName SystemSettingName = "disable-public-memos"
// SystemSettingMaxUploadSizeMiBName is the name of max upload size setting. // 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 { if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName) 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: case SystemSettingDisablePublicMemosName:
var value bool var value bool
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil { 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 { if err := systemSettingUpsert.Validate(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid system setting").SetInternal(err) 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{ systemSetting, err := s.Store.UpsertSystemSetting(ctx, &store.SystemSetting{
Name: systemSettingUpsert.Name.String(), Name: systemSettingUpsert.Name.String(),

@ -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;

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

@ -9,10 +9,13 @@ import showUpdateCustomizedProfileDialog from "../UpdateCustomizedProfileDialog"
import Icon from "../Icon"; import Icon from "../Icon";
import LearnMore from "../LearnMore"; import LearnMore from "../LearnMore";
import "@/less/settings/system-section.less"; import "@/less/settings/system-section.less";
import { showCommonDialog } from "../Dialog/CommonDialog";
import showDisablePasswordLoginDialog from "../DisablePasswordLoginDialog";
interface State { interface State {
dbSize: number; dbSize: number;
allowSignUp: boolean; allowSignUp: boolean;
disablePasswordLogin: boolean;
disablePublicMemos: boolean; disablePublicMemos: boolean;
additionalStyle: string; additionalStyle: string;
additionalScript: string; additionalScript: string;
@ -28,6 +31,7 @@ const SystemSection = () => {
const [state, setState] = useState<State>({ const [state, setState] = useState<State>({
dbSize: systemStatus.dbSize, dbSize: systemStatus.dbSize,
allowSignUp: systemStatus.allowSignUp, allowSignUp: systemStatus.allowSignUp,
disablePasswordLogin: systemStatus.disablePasswordLogin,
additionalStyle: systemStatus.additionalStyle, additionalStyle: systemStatus.additionalStyle,
additionalScript: systemStatus.additionalScript, additionalScript: systemStatus.additionalScript,
disablePublicMemos: systemStatus.disablePublicMemos, disablePublicMemos: systemStatus.disablePublicMemos,
@ -55,6 +59,7 @@ const SystemSection = () => {
...state, ...state,
dbSize: systemStatus.dbSize, dbSize: systemStatus.dbSize,
allowSignUp: systemStatus.allowSignUp, allowSignUp: systemStatus.allowSignUp,
disablePasswordLogin: systemStatus.disablePasswordLogin,
additionalStyle: systemStatus.additionalStyle, additionalStyle: systemStatus.additionalStyle,
additionalScript: systemStatus.additionalScript, additionalScript: systemStatus.additionalScript,
disablePublicMemos: systemStatus.disablePublicMemos, 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 = () => { const handleUpdateCustomizedProfileButtonClick = () => {
showUpdateCustomizedProfileDialog(); showUpdateCustomizedProfileDialog();
}; };
@ -241,6 +267,10 @@ const SystemSection = () => {
<span className="normal-text">{t("setting.system-section.allow-user-signup")}</span> <span className="normal-text">{t("setting.system-section.allow-user-signup")}</span>
<Switch checked={state.allowSignUp} onChange={(event) => handleAllowSignUpChanged(event.target.checked)} /> <Switch checked={state.allowSignUp} onChange={(event) => handleAllowSignUpChanged(event.target.checked)} />
</div> </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"> <div className="form-label">
<span className="normal-text">{t("setting.system-section.disable-public-memos")}</span> <span className="normal-text">{t("setting.system-section.disable-public-memos")}</span>
<Switch checked={state.disablePublicMemos} onChange={(event) => handleDisablePublicMemosChanged(event.target.checked)} /> <Switch checked={state.disablePublicMemos} onChange={(event) => handleDisablePublicMemosChanged(event.target.checked)} />

@ -256,6 +256,11 @@
}, },
"database-file-size": "Database File Size", "database-file-size": "Database File Size",
"allow-user-signup": "Allow user signup", "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", "ignore-version-upgrade": "Ignore version upgrade",
"disable-public-memos": "Disable public memos", "disable-public-memos": "Disable public memos",
"max-upload-size": "Maximum upload size (MiB)", "max-upload-size": "Maximum upload size (MiB)",
@ -300,7 +305,8 @@
"authorization-endpoint": "Authorization endpoint", "authorization-endpoint": "Authorization endpoint",
"token-endpoint": "Token endpoint", "token-endpoint": "Token endpoint",
"user-endpoint": "User 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": { "filter": {
@ -381,7 +387,9 @@
"update-succeed": "Update succeeded", "update-succeed": "Update succeeded",
"page-not-found": "404 - Page Not Found 😥", "page-not-found": "404 - Page Not Found 😥",
"maximum-upload-size-is": "Maximum allowed upload size is {{size}} MiB", "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": { "days": {
"mon": "Mon", "mon": "Mon",

@ -19,6 +19,7 @@ const Auth = () => {
const mode = systemStatus.profile.mode; const mode = systemStatus.profile.mode;
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const disablePasswordLogin = systemStatus.disablePasswordLogin;
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]); const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
useEffect(() => { useEffect(() => {
@ -135,6 +136,7 @@ const Auth = () => {
<img className="h-20 w-auto rounded-full shadow mr-1" src={systemStatus.customizedProfile.logoUrl} alt="" /> <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> <p className="text-3xl text-black opacity-80 dark:text-gray-200">{systemStatus.customizedProfile.name}</p>
</div> </div>
{!disablePasswordLogin && (
<form className="w-full mt-4" onSubmit={handleFormSubmit}> <form className="w-full mt-4" onSubmit={handleFormSubmit}>
<div className="flex flex-col justify-start items-start w-full gap-4"> <div className="flex flex-col justify-start items-start w-full gap-4">
<Input <Input
@ -179,6 +181,7 @@ const Auth = () => {
)} )}
</div> </div>
</form> </form>
)}
{!systemStatus.host && ( {!systemStatus.host && (
<p className="w-full inline-block float-right text-sm mt-4 text-gray-500 text-right whitespace-pre-wrap"> <p className="w-full inline-block float-right text-sm mt-4 text-gray-500 text-right whitespace-pre-wrap">
{t("auth.host-tip")} {t("auth.host-tip")}
@ -186,7 +189,7 @@ const Auth = () => {
)} )}
{identityProviderList.length > 0 && ( {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"> <div className="w-full flex flex-col space-y-2">
{identityProviderList.map((identityProvider) => ( {identityProviderList.map((identityProvider) => (
<Button <Button

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

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

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

Loading…
Cancel
Save