diff --git a/api/v1/auth.go b/api/v1/auth.go index 7d2b279c..d5633d0f 100644 --- a/api/v1/auth.go +++ b/api/v1/auth.go @@ -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) } diff --git a/api/v1/system.go b/api/v1/system.go index def0253f..1808c76c 100644 --- a/api/v1/system.go +++ b/api/v1/system.go @@ -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(): diff --git a/api/v1/system_setting.go b/api/v1/system_setting.go index 37cef754..116f06a5 100644 --- a/api/v1/system_setting.go +++ b/api/v1/system_setting.go @@ -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(), diff --git a/web/src/components/DisablePasswordLoginDialog.tsx b/web/src/components/DisablePasswordLoginDialog.tsx new file mode 100644 index 00000000..fd28af92 --- /dev/null +++ b/web/src/components/DisablePasswordLoginDialog.tsx @@ -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 = ({ destroy }: Props) => { + const t = useTranslate(); + const globalStore = useGlobalStore(); + const systemStatus = globalStore.state.systemStatus; + const [state, setState] = useState({ + 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) => { + const text = e.target.value as string; + setTypingConfirmation(text); + }; + + return ( + <> +
+

{t("setting.system-section.disable-password-login")}

+ +
+
+ {confirmedOnce ? ( + <> +

{t("setting.system-section.disable-password-login-final-warning")}

+ + + ) : ( +

{t("setting.system-section.disable-password-login-warning")}

+ )} +
+ + +
+
+ + ); +}; + +function showDisablePasswordLoginDialog() { + generateDialog( + { + className: "disable-password-login-dialog", + dialogName: "disable-password-login-dialog", + }, + DisablePasswordLoginDialog + ); +} + +export default showDisablePasswordLoginDialog; diff --git a/web/src/components/Settings/SSOSection.tsx b/web/src/components/Settings/SSOSection.tsx index 83631644..d7de5c78 100644 --- a/web/src/components/Settings/SSOSection.tsx +++ b/web/src/components/Settings/SSOSection.tsx @@ -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({ + disablePasswordLogin: systemStatus.disablePasswordLogin, + }); const [identityProviderList, setIdentityProviderList] = useState([]); 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 () => { diff --git a/web/src/components/Settings/SystemSection.tsx b/web/src/components/Settings/SystemSection.tsx index e32ad60a..0991af8a 100644 --- a/web/src/components/Settings/SystemSection.tsx +++ b/web/src/components/Settings/SystemSection.tsx @@ -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({ 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 = () => { {t("setting.system-section.allow-user-signup")} handleAllowSignUpChanged(event.target.checked)} /> +
+ {t("setting.system-section.disable-password-login")} + handleDisablePasswordLoginChanged(event.target.checked)} /> +
{t("setting.system-section.disable-public-memos")} handleDisablePublicMemosChanged(event.target.checked)} /> diff --git a/web/src/locales/en.json b/web/src/locales/en.json index d762efa7..6eb3f853 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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", diff --git a/web/src/pages/Auth.tsx b/web/src/pages/Auth.tsx index 022f362d..23ae3028 100644 --- a/web/src/pages/Auth.tsx +++ b/web/src/pages/Auth.tsx @@ -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([]); useEffect(() => { @@ -135,50 +136,52 @@ const Auth = () => {

{systemStatus.customizedProfile.name}

-
-
- - -
-
- {actionBtnLoadingState.isLoading && } - {!systemStatus.host ? ( - - ) : ( - <> - {systemStatus?.allowSignUp && ( - <> - - / - - )} - - - )} -
-
+ ) : ( + <> + {systemStatus?.allowSignUp && ( + <> + + / + + )} + + + )} + + + )} {!systemStatus.host && (

{t("auth.host-tip")} @@ -186,7 +189,7 @@ const Auth = () => { )} {identityProviderList.length > 0 && ( <> - {t("common.or")} + {!disablePasswordLogin && {t("common.or")}}

{identityProviderList.map((identityProvider) => (