diff --git a/docker/.env b/docker/.env index 5bd9c1e544..532f35706f 100644 --- a/docker/.env +++ b/docker/.env @@ -496,6 +496,9 @@ DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 # Used for ThreadPoolExecutor THREAD_POOL_MAX_WORKERS=128 +#Option to disable login form for SSO +DISABLE_PASSWORD_LOGIN=false + # ----------------------------------------------------------------------------- # Knowledge compilation # ----------------------------------------------------------------------------- diff --git a/docker/service_conf.yaml.template b/docker/service_conf.yaml.template index 0714b1ab74..3c98118371 100644 --- a/docker/service_conf.yaml.template +++ b/docker/service_conf.yaml.template @@ -215,6 +215,7 @@ user_default_llm: # http_secret_key: # site: # switch: false +# disable_password_login: false # permission: # switch: false # component: false diff --git a/internal/admin/handler_variables_test.go b/internal/admin/handler_variables_test.go index 1cbe294ff4..e9b4cabb20 100644 --- a/internal/admin/handler_variables_test.go +++ b/internal/admin/handler_variables_test.go @@ -62,7 +62,7 @@ func putVariable(t *testing.T, h *Handler, body string) setVariableResponse { } // TestSetVariableFlattensNativeJSONScalars covers the reported regression: the -// admin console sends a JSON boolean for a toggle, and a string-typed request +// admin console toggles SSO with a JSON boolean, and a string-typed request // field used to reject it as a missing var_name. func TestSetVariableFlattensNativeJSONScalars(t *testing.T) { gin.SetMode(gin.TestMode) @@ -74,15 +74,15 @@ func TestSetVariableFlattensNativeJSONScalars(t *testing.T) { wantValue string }{ { - name: "bool true keeps its literal", - varName: "mail.enabled", - body: `{"var_name":"mail.enabled","var_value":true}`, + name: "sso enable sends true", + varName: "feishu|sso.enabled", + body: `{"var_name":"feishu|sso.enabled","var_value":true}`, wantValue: "true", }, { - name: "bool false keeps its literal", - varName: "mail.enabled", - body: `{"var_name":"mail.enabled","var_value":false}`, + name: "sso disable sends false", + varName: "feishu|sso.enabled", + body: `{"var_name":"feishu|sso.enabled","var_value":false}`, wantValue: "false", }, { @@ -137,15 +137,15 @@ func TestSetVariableUpdatesExistingSetting(t *testing.T) { gin.SetMode(gin.TestMode) handler, db := setupSetVariableHandler(t) - if response := putVariable(t, handler, `{"var_name":"mail.enabled","var_value":true}`); response.Code != int(common.CodeSuccess) { + if response := putVariable(t, handler, `{"var_name":"feishu|sso.enabled","var_value":true}`); response.Code != int(common.CodeSuccess) { t.Fatalf("enable: code = %d, message = %s", response.Code, response.Message) } - if response := putVariable(t, handler, `{"var_name":"mail.enabled","var_value":false}`); response.Code != int(common.CodeSuccess) { + if response := putVariable(t, handler, `{"var_name":"feishu|sso.enabled","var_value":false}`); response.Code != int(common.CodeSuccess) { t.Fatalf("disable: code = %d, message = %s", response.Code, response.Message) } var stored entity.SystemSettings - if err := db.Where("name = ?", "mail.enabled").First(&stored).Error; err != nil { + if err := db.Where("name = ?", "feishu|sso.enabled").First(&stored).Error; err != nil { t.Fatalf("load stored setting: %v", err) } if stored.Value != "false" { @@ -190,9 +190,9 @@ func TestSetVariableRejectsUnusableRequests(t *testing.T) { // A value rejected by ValidateSystemSettingValue surfaces as a // server error, which is the pre-existing contract of this endpoint. name: "bool-typed setting rejects non bool", - body: `{"var_name":"mail.enabled","var_value":"yes"}`, + body: `{"var_name":"feishu|sso.enabled","var_value":"yes"}`, wantCode: common.CodeServerError, - wantMessage: "invalid bool value for mail.enabled: expected true or false", + wantMessage: "invalid bool value for feishu|sso.enabled: expected true or false", }, { // Falsy scalars are legitimate values, not missing fields. diff --git a/internal/common/environments.go b/internal/common/environments.go index c7308fed95..82aebe81c2 100644 --- a/internal/common/environments.go +++ b/internal/common/environments.go @@ -262,6 +262,7 @@ const ( EnvFirecrawlRelayTimeout = "FIRECRAWL_RELAY_TIMEOUT" EnvRAGFlowSecretKey = "RAGFLOW_SECRET_KEY" EnvEnableRegister = "ENABLE_REGISTER" + EnvDisablePasswordLogin = "DISABLE_PASSWORD_LOGIN" EnvMinioHost = "MINIO_HOST" EnvMinioRegion = "MINIO_REGION" EnvLang = "LANG" diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 52757a82ec..44fcca6239 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -105,7 +105,7 @@ func (h *AuthHandler) BetaAuthMiddleware() gin.HandlerFunc { ctx := c.Request.Context() auth := c.GetHeader("Authorization") if auth == "" { - if cookie, err := c.Cookie(authCookie); err == nil { + if cookie, err := c.Cookie(oauthAuthCookie); err == nil { auth = cookie } } diff --git a/internal/handler/error.go b/internal/handler/error.go index e89c092d1c..db364940f4 100644 --- a/internal/handler/error.go +++ b/internal/handler/error.go @@ -41,12 +41,14 @@ func jsonInternalError(c *gin.Context, err error) { // HandleNoRoute handles requests to undefined routes func HandleNoRoute(c *gin.Context) { - // Python parity: these malformed paths resolved to a Werkzeug - // MethodNotAllowed in the Python API, which server_error_response renders - // as HTTP 200 / code 100 with the exception's repr() as the message. gin - // instead falls through to NoRoute, so emit the same body here to keep the - // error paths byte-for-byte aligned. - if (c.Request.Method == http.MethodPost && c.Request.URL.Path == "/api/v1/chats//sessions") || + // Python parity: GET /api/v1/auth/login/ (an empty OAuth channel) resolves + // to a Werkzeug MethodNotAllowed in the Python API, which + // server_error_response renders as HTTP 200 / code 100 with the + // exception's repr() as the message. gin instead falls through to + // NoRoute, so emit the same body here to keep the auth error paths + // byte-for-byte aligned. + if (c.Request.Method == http.MethodGet && c.Request.URL.Path == "/api/v1/auth/login/") || + (c.Request.Method == http.MethodPost && c.Request.URL.Path == "/api/v1/chats//sessions") || (c.Request.Method == http.MethodPatch && strings.HasSuffix(c.Request.URL.Path, "/sessions/")) { common.ResponseWithCodeData(c, common.CodeExceptionError, false, "") return diff --git a/internal/handler/user.go b/internal/handler/user.go index 8e7bce42da..21de728bc0 100644 --- a/internal/handler/user.go +++ b/internal/handler/user.go @@ -45,21 +45,25 @@ func NewUserHandler(userService *service.UserService) *UserHandler { } } -// authCookie carries the signed access token to the SPA after login so its -// JavaScript can send it back as an Authorization header on later requests. -// Not HttpOnly because the SPA has to read it; the server validates the token -// signature and expiry on every request. -const authCookie = "ragflow_auth" +// oauthAuthCookie is the cookie the callback writes on success, so the SPA +// can pick up the signed access token after the redirect. The frontend +// reads it and either re-issues the value as an Authorization header on +// subsequent API calls or hands it off to its own token store. Not +// HttpOnly so the SPA's JS can read it. +const oauthAuthCookie = "ragflow_auth" -// setAuthCookie writes the signed access token so the SPA can send it back as -// a bearer token. Not HttpOnly so the SPA can copy it into its Authorization -// header. The server checks the token signature and expiry on each request. -func setAuthCookie(c *gin.Context, token string) { - // codeql[go/cookie-httponly-not-set] Intentional: the SPA reads the cookie - // via document.cookie, and the API validates the token signature and - // expiry on each request. +// setOAuthAuthCookie writes the signed access token so the SPA can pick it +// up after the redirect. Not HttpOnly so the SPA can copy it into its +// Authorization header on subsequent fetches. The server checks the token +// signature and expiry on each request. +func setOAuthAuthCookie(c *gin.Context, token string) { + // the SPA's bootstrap credential after the OAuth redirect. The + // SPA reads it via document.cookie and copies it into the + // Authorization header. Setting HttpOnly would break the login + // flow. The API validates the token signature and expiry on each request. + // codeql[go/cookie-httponly-not-set] Intentional: this cookie is http.SetCookie(c.Writer, &http.Cookie{ - Name: authCookie, + Name: oauthAuthCookie, Value: token, Path: "/", MaxAge: 60 * 60 * 24 * 7, @@ -106,7 +110,7 @@ func (h *UserHandler) Register(c *gin.Context) { } c.Header("Authorization", authToken) - setAuthCookie(c, authToken) + setOAuthAuthCookie(c, authToken) c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Methods", "*") c.Header("Access-Control-Allow-Headers", "*") @@ -184,7 +188,7 @@ func (h *UserHandler) Login(c *gin.Context) { // Set Authorization header with signed token c.Header("Authorization", authToken) - setAuthCookie(c, authToken) + setOAuthAuthCookie(c, authToken) // Set CORS headers c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Methods", "*") @@ -275,7 +279,7 @@ func (h *UserHandler) LoginByEmail(c *gin.Context) { operationLog.DurationMS = time.Since(startAt).Milliseconds() return } - setAuthCookie(c, authToken) + setOAuthAuthCookie(c, authToken) c.Header("Authorization", authToken) c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Methods", "*") @@ -339,7 +343,7 @@ func (h *UserHandler) Logout(c *gin.Context) { }() http.SetCookie(c.Writer, &http.Cookie{ - Name: authCookie, + Name: oauthAuthCookie, Value: "", Path: "/", MaxAge: -1, @@ -476,6 +480,22 @@ func (h *UserHandler) ChangePassword(c *gin.Context) { common.SuccessWithData(c, true, "password changed successfully") } +// GetLoginChannels get all supported authentication channels +// @Summary Get Login Channels +// @Description Get all supported OAuth authentication channels +// @Tags users +// @Success 200 {object} map[string]interface{} +// @Router /v1/user/login/channels [get] +func (h *UserHandler) GetLoginChannelsDeprecated(c *gin.Context) { + channels, code, err := h.userService.GetLoginChannels() + if err != nil { + common.ResponseWithCodeData(c, code, []interface{}{}, "Load channels failure, error: "+err.Error()) + return + } + + common.SuccessWithData(c, channels, "success") +} + // SetTenantInfo update tenant information // @Summary Set Tenant Info // @Description Update tenant model configuration diff --git a/internal/server/config.go b/internal/server/config.go index 6920acbb75..a610d96b09 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -156,6 +156,11 @@ func Init(configPath string) error { return fmt.Errorf("parse default models config error: %w", err) } + err = globalConfig.ParseOAuthConfig(v) + if err != nil { + return fmt.Errorf("parse OAuth config error: %w", err) + } + return nil } diff --git a/internal/server/config/api_server_config.go b/internal/server/config/api_server_config.go index 14205c62e1..017ce9a9f4 100644 --- a/internal/server/config/api_server_config.go +++ b/internal/server/config/api_server_config.go @@ -30,7 +30,8 @@ const ( ) type AuthenticationConfig struct { - EnableRegister bool `mapstructure:"enable_register"` + DisablePasswordLogin bool `mapstructure:"disable_password_login"` + EnableRegister bool `mapstructure:"enable_register"` } type APIServerConfig struct { @@ -93,6 +94,7 @@ func (c *Config) ParseAPIServerConfig(v *viper.Viper) error { func (c *Config) parseAuthenticationConfig(v *viper.Viper) { apiServerConfig := &c.apiServer + apiServerConfig.Authentication.DisablePasswordLogin = false apiServerConfig.Authentication.EnableRegister = true if !v.IsSet("authentication") { @@ -103,11 +105,22 @@ func (c *Config) parseAuthenticationConfig(v *viper.Viper) { return } + if sub.IsSet("disable_password_login") { + apiServerConfig.Authentication.DisablePasswordLogin = sub.GetBool("disable_password_login") + } + if sub.IsSet("enable_register") { apiServerConfig.Authentication.EnableRegister = sub.GetBool("enable_register") } } +func (c *Config) DisablePasswordLogin() bool { + if c.environments.DisablePasswordLogin != nil { + return *c.environments.DisablePasswordLogin + } + return c.apiServer.Authentication.DisablePasswordLogin +} + func (c *Config) EnableRegister() bool { if c.environments.EnableRegister != nil { return *c.environments.EnableRegister diff --git a/internal/server/config/base.go b/internal/server/config/base.go index 25bbaa68d2..1ffacd2164 100644 --- a/internal/server/config/base.go +++ b/internal/server/config/base.go @@ -43,4 +43,5 @@ type Config struct { // For EE defaultModels DefaultModelsConfig billing BillingConfig + oAuth OAuthConfig } diff --git a/internal/server/config/environments.go b/internal/server/config/environments.go index ccdac0213c..94baae6b7e 100644 --- a/internal/server/config/environments.go +++ b/internal/server/config/environments.go @@ -30,7 +30,8 @@ type Environments struct { SecretKey string `mapstructure:"secret_key"` - EnableRegister *bool `mapstructure:"enable_register"` + EnableRegister *bool `mapstructure:"enable_register"` + DisablePasswordLogin *bool `mapstructure:"disable_password_login"` DocumentEngineType string `mapstructure:"document_engine_type"` DatabaseType string `mapstructure:"database_type"` @@ -143,6 +144,18 @@ func (c *Config) GetEnvironments() error { } } + if envVal := common.GetEnv(common.EnvDisablePasswordLogin); envVal != "" { + if c.environments.DisablePasswordLogin == nil { + c.environments.DisablePasswordLogin = new(bool) + } + str := envVal + if str == "true" || str == "1" || str == "yes" { + *c.environments.DisablePasswordLogin = true + } else { + *c.environments.DisablePasswordLogin = false + } + } + // Doc engine docEngine := common.GetEnvSmall(common.EnvDocEngine) if docEngine != "" { @@ -587,6 +600,10 @@ func (c *Config) GetEnvEnableRegister() bool { return *c.environments.EnableRegister } +func (c *Config) GetEnvDisablePasswordLogin() bool { + return *c.environments.DisablePasswordLogin +} + func (c *Config) GetEnvDocumentEngineType() string { return c.environments.DocumentEngineType } diff --git a/internal/server/config/stub.go b/internal/server/config/stub.go index 6baaf9a161..429a4ac905 100644 --- a/internal/server/config/stub.go +++ b/internal/server/config/stub.go @@ -82,3 +82,11 @@ func (c *Config) GetDefaultOCRModel() ModelConfig { return c.defaultModels.OCRMo // GetDefaultTTSModel returns the deployment TTS model, empty in the OSS build. func (c *Config) GetDefaultTTSModel() ModelConfig { return c.defaultModels.TTSModel } + +// OAuthConfig carries the OAuth/OIDC login provider settings. The OSS build +// has no OAuth login. +type OAuthConfig struct{} + +// ParseOAuthConfig ignores OAuth configuration: the OSS build has no OAuth +// login. +func (c *Config) ParseOAuthConfig(v *viper.Viper) error { return nil } diff --git a/internal/server/config_getallconfigs_test.go b/internal/server/config_getallconfigs_test.go index a3897f600c..92f215c701 100644 --- a/internal/server/config_getallconfigs_test.go +++ b/internal/server/config_getallconfigs_test.go @@ -46,7 +46,7 @@ backends: - password: "" - password: null password_policy: strict -enable_register: true +disable_password_login: true max_token: 64 `)); err != nil { t.Fatal(err) @@ -80,10 +80,10 @@ max_token: 64 map[string]any{"password": "******"}, map[string]any{"password": "******"}, }, - "credentials": wantCredentials, - "password_policy": "strict", - "enable_register": true, - "max_token": int64(64), + "credentials": wantCredentials, + "password_policy": "strict", + "disable_password_login": true, + "max_token": int64(64), } if !reflect.DeepEqual(settings, want) { t.Fatalf("logged settings = %#v, want %#v", settings, want) diff --git a/internal/service/stub.go b/internal/service/stub.go index 99261f70e3..60fae2831f 100644 --- a/internal/service/stub.go +++ b/internal/service/stub.go @@ -78,3 +78,16 @@ func resolveDefaultTenantIDEE(context.Context, string) (string, bool, error) { func resolveUserAuthTenant(_ context.Context, _ *entity.User, tenantID string) (string, error) { return tenantID, nil } + +// LoginChannel is one authentication channel advertised to the login page. +type LoginChannel struct { + Channel string `json:"channel"` + DisplayName string `json:"display_name"` + Icon string `json:"icon"` +} + +// GetLoginChannels returns no channel: the community edition only offers +// password login. +func (s *UserService) GetLoginChannels() ([]*LoginChannel, common.ErrorCode, error) { + return make([]*LoginChannel, 0), common.CodeSuccess, nil +} diff --git a/internal/service/system.go b/internal/service/system.go index 78a464a435..e0587a91b8 100644 --- a/internal/service/system.go +++ b/internal/service/system.go @@ -41,14 +41,16 @@ func NewSystemService() *SystemService { // ConfigResponse system configuration response type ConfigResponse struct { - EnableRegister bool `json:"registerEnabled"` + EnableRegister bool `json:"registerEnabled"` + DisablePasswordLogin bool `json:"disablePasswordLogin"` } // GetConfig get system configuration func (s *SystemService) GetConfig() (*ConfigResponse, error) { cfg := server.GetConfig() return &ConfigResponse{ - EnableRegister: cfg.EnableRegister(), + EnableRegister: cfg.EnableRegister(), + DisablePasswordLogin: cfg.DisablePasswordLogin(), }, nil } diff --git a/test/api/system_api_test.go b/test/api/system_api_test.go index c994c76f87..9516cffbc9 100644 --- a/test/api/system_api_test.go +++ b/test/api/system_api_test.go @@ -27,8 +27,10 @@ func TestRESTSystemPublicContracts(t *testing.T) { } systemConfig := restOK(t, restCall(t, &config, http.MethodGet, "/system/config", nil, nil)) configData := object(t, systemConfig["data"]) - if _, ok := configData["registerEnabled"]; !ok { - t.Fatalf("system config missing %q: %v", "registerEnabled", configData) + for _, key := range []string{"registerEnabled", "disablePasswordLogin"} { + if _, ok := configData[key]; !ok { + t.Fatalf("system config missing %q: %v", key, configData) + } } health := restCall(t, &config, http.MethodGet, "/system/healthz", nil, nil) if health.Status != http.StatusOK && health.Status != http.StatusInternalServerError {