diff --git a/core/app/api/v2/api_key.go b/core/app/api/v2/api_key.go index d40363b3f..9c52c832a 100644 --- a/core/app/api/v2/api_key.go +++ b/core/app/api/v2/api_key.go @@ -35,6 +35,10 @@ func bindAPIKeyRequest(c *gin.Context, value interface{}) bool { func apiKeyError(c *gin.Context, err error) { var business buserr.BusinessError if errors.As(err, &business) { + if business.Msg == "ErrPasswordExpired" { + helper.ErrorWithDetail(c, 313, business.Msg, nil) + return + } helper.ErrorWithDetail(c, http.StatusBadRequest, business.Msg, business.Err) return } diff --git a/core/app/api/v2/auth.go b/core/app/api/v2/auth.go index 0a6dff624..4ea76aa7c 100644 --- a/core/app/api/v2/auth.go +++ b/core/app/api/v2/auth.go @@ -408,6 +408,10 @@ func (b *BaseApi) GenerateApiKey(c *gin.Context) { helper.BadAuth(c, "ErrApiConfigDisable", nil) return } + if err := appauth.RequireUnexpiredPassword(c, xpack.AuthProvider); err != nil { + apiKeyError(c, err) + return + } apiKey, err := xpack.AuthProvider.GenerateApiKey(c) if err != nil { helper.InternalServer(c, err) @@ -433,6 +437,10 @@ func (b *BaseApi) UpdateApiConfig(c *gin.Context) { helper.BadAuth(c, "ErrApiConfigDisable", nil) return } + if err := appauth.RequireUnexpiredPassword(c, xpack.AuthProvider); err != nil { + apiKeyError(c, err) + return + } var req dto.ApiInterfaceConfig if err := helper.CheckBindAndValidate(&req, c); err != nil { return @@ -466,6 +474,10 @@ func (b *BaseApi) GetCurrentUser(c *gin.Context) { } if c.GetBool("API_AUTH") || appauth.HasAPICredentials(c) { userInfo.ApiKey = "" + } else if expired, policyErr := appauth.IsPasswordExpired(c, xpack.AuthProvider); expired || policyErr != nil { + // Bootstrap must remain usable for password recovery. Policy failures + // never disclose a reusable legacy secret through this read endpoint. + userInfo.ApiKey = "" } c.Header("Cache-Control", "no-store") helper.SuccessWithData(c, userInfo) diff --git a/core/app/auth/password_policy.go b/core/app/auth/password_policy.go new file mode 100644 index 000000000..2e4a8077a --- /dev/null +++ b/core/app/auth/password_policy.go @@ -0,0 +1,59 @@ +package auth + +import ( + "errors" + "strconv" + "time" + + "github.com/1Panel-dev/1Panel/core/app/repo" + "github.com/1Panel-dev/1Panel/core/buserr" + "github.com/1Panel-dev/1Panel/core/constant" + "github.com/1Panel-dev/1Panel/core/utils/common" + "github.com/gin-gonic/gin" +) + +type PasswordExpirationProvider interface { + ShouldCheckPasswordExpiration(*gin.Context) (bool, error) + LoadPasswordExpirationTime(*gin.Context) (string, error) +} + +func IsPasswordExpired(c *gin.Context, provider PasswordExpirationProvider) (bool, error) { + daysText, err := repo.NewISettingRepo().GetValueByKey("ExpirationDays") + if err != nil { + return false, err + } + days, err := strconv.Atoi(daysText) + if err != nil || days < 0 { + return false, errors.New("invalid password expiration policy") + } + if days == 0 { + return false, nil + } + if provider == nil { + return false, errors.New("password expiration provider unavailable") + } + check, err := provider.ShouldCheckPasswordExpiration(c) + if err != nil || !check { + return false, err + } + deadline, err := provider.LoadPasswordExpirationTime(c) + if err != nil { + return false, err + } + expiresAt, err := time.ParseInLocation(constant.DateTimeLayout, deadline, common.LoadExpiredLocation()) + if err != nil { + return false, err + } + return !time.Now().Before(expiresAt), nil +} + +func RequireUnexpiredPassword(c *gin.Context, provider PasswordExpirationProvider) error { + expired, err := IsPasswordExpired(c, provider) + if err != nil { + return err + } + if expired { + return buserr.New("ErrPasswordExpired") + } + return nil +} diff --git a/core/app/service/api_key.go b/core/app/service/api_key.go index 9c4dc2039..01e4a3dab 100644 --- a/core/app/service/api_key.go +++ b/core/app/service/api_key.go @@ -52,6 +52,11 @@ func (s *APIKeyService) owner(c *gin.Context) (auth.APIKeyOwner, error) { return s.Provider.CurrentAPIKeyOwner(c) } +func (s *APIKeyService) requireUnexpiredOwnerPassword(c *gin.Context) error { + policy, _ := s.Provider.(auth.PasswordExpirationProvider) + return auth.RequireUnexpiredPassword(c, policy) +} + func (s *APIKeyService) managementOwner(c *gin.Context) (auth.APIKeyOwner, error) { if _, err := auth.RequireAPIKeyManagement(c); err != nil { return auth.APIKeyOwner{}, err @@ -171,6 +176,9 @@ func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKe if err != nil { return nil, err } + if err := s.requireUnexpiredOwnerPassword(c); err != nil { + return nil, err + } if req.RequestID == "" { req.RequestID = uuid.NewString() } else if _, err = uuid.Parse(req.RequestID); err != nil { @@ -249,6 +257,9 @@ func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error { if err != nil { return err } + if err := s.requireUnexpiredOwnerPassword(c); err != nil { + return err + } if req.ID == "legacy" { return s.updateLegacy(c, owner, req.APIKeyMutation, &req.APIKeyFields, "update", "") } @@ -280,6 +291,11 @@ func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error { if req.Status != constant.StatusEnable && req.Status != constant.StatusDisable { return buserr.New("ErrInvalidParams") } + if req.Status == constant.StatusEnable { + if err := s.requireUnexpiredOwnerPassword(c); err != nil { + return err + } + } action := "disable" if req.Status == constant.StatusEnable { action = "enable" @@ -343,6 +359,9 @@ func (s *APIKeyService) Reveal(c *gin.Context, req dto.APIKeyMutation) (*dto.API if err != nil { return nil, err } + if err := s.requireUnexpiredOwnerPassword(c); err != nil { + return nil, err + } if req.ID == "" || req.ID == "legacy" || req.Revision == 0 { return nil, buserr.New("ErrInvalidParams") } @@ -511,6 +530,9 @@ func (s *APIKeyService) PrepareAppBinding(c *gin.Context, id string) (*auth.APIK if err != nil { return nil, err } + if err := s.requireUnexpiredOwnerPassword(c); err != nil { + return nil, err + } if id == "" { id = "legacy" } diff --git a/core/middleware/password_expired.go b/core/middleware/password_expired.go index aa4a5a3b6..416e52ff3 100644 --- a/core/middleware/password_expired.go +++ b/core/middleware/password_expired.go @@ -2,17 +2,13 @@ package middleware import ( "net/http" - "strconv" "strings" - "time" "github.com/1Panel-dev/1Panel/core/app/api/v2/helper" - baseRepo "github.com/1Panel-dev/1Panel/core/app/repo" + "github.com/1Panel-dev/1Panel/core/app/auth" "github.com/1Panel-dev/1Panel/core/buserr" - "github.com/1Panel-dev/1Panel/core/constant" "github.com/1Panel-dev/1Panel/core/global" psessionUtils "github.com/1Panel-dev/1Panel/core/init/session/psession" - "github.com/1Panel-dev/1Panel/core/utils/common" "github.com/1Panel-dev/1Panel/core/utils/xpack" "github.com/gin-gonic/gin" ) @@ -31,15 +27,7 @@ func PasswordExpired() gin.HandlerFunc { c.Next() return } - if strings.HasPrefix(c.Request.URL.Path, "/api/v2/core/auth") || - c.Request.URL.Path == "/api/v2/core/settings/search" || - c.Request.URL.Path == "/api/v2/core/settings/search/base" || - c.Request.URL.Path == "/api/v2/core/xpack/settings/search" || - c.Request.URL.Path == "/api/v2/core/xapp/verifyQRCode" || - c.Request.URL.Path == "/api/v2/core/enterprise/users/info" || - c.Request.URL.Path == "/api/v2/core/enterprise/licenses/info" || - c.Request.URL.Path == "/api/v2/core/enterprise/licenses/status" || - c.Request.URL.Path == "/api/v2/core/enterprise/licenses/upload" { + if passwordExpirationRecoveryRoute(c.Request.Method, c.Request.URL.Path) { c.Next() return } @@ -67,41 +55,46 @@ func PasswordExpired() gin.HandlerFunc { helper.BadAuth(c, "ErrNotLogin", err) return } - settingRepo := baseRepo.NewISettingRepo() - expirationDays, err := settingRepo.GetValueByKey("ExpirationDays") - if err != nil { - helper.ErrorWithDetail(c, http.StatusInternalServerError, "ErrPasswordExpired", err) - return - } - expiredDays, _ := strconv.Atoi(expirationDays) - if expiredDays == 0 { - c.Next() - return - } - shouldCheck, err := xpack.AuthProvider.ShouldCheckPasswordExpiration(c) - if err != nil { - helper.ErrorWithDetail(c, http.StatusInternalServerError, "ErrPasswordExpired", err) - return - } - if !shouldCheck { - c.Next() - return - } - expirationTime, err := xpack.AuthProvider.LoadPasswordExpirationTime(c) - if err != nil { - helper.ErrorWithDetail(c, http.StatusInternalServerError, "ErrPasswordExpired", err) - return - } - expiredTime, err := time.ParseInLocation(constant.DateTimeLayout, expirationTime, common.LoadExpiredLocation()) + expired, err := auth.IsPasswordExpired(c, xpack.AuthProvider) if err != nil { helper.ErrorWithDetail(c, http.StatusInternalServerError, "ErrPasswordExpired", err) return } - if time.Now().After(expiredTime) { + if expired { helper.ErrorWithDetail(c, 313, "ErrPasswordExpired", err) return } c.Next() } } + +func passwordExpirationRecoveryRoute(method, path string) bool { + const core = "/api/v2/core" + switch method { + case http.MethodGet: + switch path { + case core + "/auth/captcha", core + "/auth/setting", core + "/auth/current", core + "/auth/ldap/status", + core + "/auth/oidc/status", core + "/auth/oidc/callback", + core + "/auth/saml2/status", core + "/auth/saml2/metadata", core + "/auth/saml2/logout", + core + "/xpack/settings/search", core + "/enterprise/users/info", + core + "/enterprise/licenses/info", core + "/enterprise/licenses/status": + return true + } + case http.MethodPost: + switch path { + case core + "/auth/login", core + "/auth/mfalogin", core + "/auth/logout", + core + "/auth/passkey/begin", core + "/auth/passkey/finish", + core + "/auth/current/update", core + "/auth/expired/reset", + core + "/auth/oidc/begin", core + "/auth/oidc/finish", + core + "/auth/saml2/begin", core + "/auth/saml2/acs", core + "/auth/saml2/finish", core + "/auth/saml2/logout", + core + "/settings/search/base", core + "/enterprise/licenses/upload", core + "/xapp/verifyQRCode", + core + "/auth/api/keys/search", core + "/auth/api/keys/status", + core + "/auth/api/keys/revoke", core + "/auth/api/keys/delete": + // The status service independently rejects Enable while allowing + // Disable. Metadata and credential revocation remain available. + return true + } + } + return false +}