From 53a8ad71c6538a53f02e5dc219c9b0df10a77585 Mon Sep 17 00:00:00 2001 From: MaysWind Date: Tue, 21 Oct 2025 01:52:28 +0800 Subject: [PATCH] support Nextcloud OAuth 2.0 authentication --- cmd/database.go | 8 + cmd/initializer.go | 4 + cmd/webserver.go | 49 ++- conf/ezbookkeeping.ini | 43 ++- go.mod | 1 + go.sum | 2 + pkg/api/authorizations.go | 145 +++++++- pkg/api/base.go | 8 + pkg/api/oauth2_authentications.go | 313 ++++++++++++++++++ pkg/api/server_settings.go | 10 +- pkg/auth/oauth2/nextcloud_oauth2_provider.go | 110 ++++++ pkg/auth/oauth2/oauth2_authentication.go | 105 ++++++ pkg/auth/oauth2/oauth2_context.go | 31 ++ pkg/auth/oauth2/oauth2_provider.go | 22 ++ pkg/auth/oauth2/oauth2_user_info.go | 13 + pkg/core/handler.go | 3 + pkg/core/token_claims.go | 12 +- pkg/core/user_external_auth_type.go | 18 + pkg/duplicatechecker/duplicate_checker.go | 1 + .../duplicate_checker_container.go | 9 + .../duplicate_checker_type.go | 1 + .../in_memory_duplicate_checker.go | 5 + pkg/errs/error.go | 2 + pkg/errs/external_auth.go | 10 + pkg/errs/oauth2.go | 19 ++ pkg/errs/setting.go | 4 + pkg/errs/user.go | 1 + ...ommon_http_exchange_rates_data_provider.go | 31 +- .../exchange_rates_data_provider_container.go | 34 +- ...mmon_http_large_language_model_provider.go | 28 +- .../google_ai_large_language_model_adapter.go | 2 +- .../ollama_large_language_model_adapter.go | 2 +- .../openai_chat_completions_api_provider.go | 2 +- ...compatible_large_language_model_adapter.go | 5 +- ...ompatible_chat_completions_api_provider.go | 2 +- ...penrouter_chat_completions_api_provider.go | 2 +- pkg/middlewares/authorization.go | 19 ++ pkg/models/oauth2.go | 19 ++ pkg/models/user_external_auth.go | 17 + pkg/services/tokens.go | 12 + pkg/services/user_external_auths.go | 117 +++++++ pkg/settings/setting.go | 117 ++++--- pkg/settings/setting_container.go | 4 + pkg/storage/webdav_storage.go | 17 +- pkg/utils/api.go | 78 ++--- pkg/utils/http.go | 37 +++ src/consts/oauth2.ts | 3 + src/lib/server_settings.ts | 16 + src/lib/services.ts | 13 + src/locales/de.json | 20 +- src/locales/en.json | 20 +- src/locales/es.json | 20 +- src/locales/fr.json | 20 +- src/locales/helpers.ts | 56 +++- src/locales/it.json | 20 +- src/locales/ja.json | 20 +- src/locales/ko.json | 20 +- src/locales/nl.json | 20 +- src/locales/pt_BR.json | 20 +- src/locales/ru.json | 20 +- src/locales/th.json | 20 +- src/locales/uk.json | 20 +- src/locales/vi.json | 20 +- src/locales/zh_Hans.json | 20 +- src/locales/zh_Hant.json | 20 +- src/models/oauth2.ts | 4 + src/router/desktop.ts | 12 + src/stores/index.ts | 58 +++- src/views/base/LoginPageBase.ts | 14 +- src/views/desktop/LoginPage.vue | 36 +- src/views/desktop/OAuth2CallbackPage.vue | 215 ++++++++++++ src/views/mobile/LoginPage.vue | 56 +++- third-party-dependencies.json | 6 + vite.config.ts | 4 + 74 files changed, 2046 insertions(+), 241 deletions(-) create mode 100644 pkg/api/oauth2_authentications.go create mode 100644 pkg/auth/oauth2/nextcloud_oauth2_provider.go create mode 100644 pkg/auth/oauth2/oauth2_authentication.go create mode 100644 pkg/auth/oauth2/oauth2_context.go create mode 100644 pkg/auth/oauth2/oauth2_provider.go create mode 100644 pkg/auth/oauth2/oauth2_user_info.go create mode 100644 pkg/core/user_external_auth_type.go create mode 100644 pkg/errs/external_auth.go create mode 100644 pkg/errs/oauth2.go create mode 100644 pkg/models/oauth2.go create mode 100644 pkg/models/user_external_auth.go create mode 100644 pkg/services/user_external_auths.go create mode 100644 src/consts/oauth2.ts create mode 100644 src/models/oauth2.ts create mode 100644 src/views/desktop/OAuth2CallbackPage.vue diff --git a/cmd/database.go b/cmd/database.go index 636098da..8cb9eff6 100644 --- a/cmd/database.go +++ b/cmd/database.go @@ -149,5 +149,13 @@ func updateAllDatabaseTablesStructure(c *core.CliContext) error { log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] user application cloud settings table maintained successfully") + err = datastore.Container.UserDataStore.SyncStructs(new(models.UserExternalAuth)) + + if err != nil { + return err + } + + log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] user external auth table maintained successfully") + return nil } diff --git a/cmd/initializer.go b/cmd/initializer.go index 2c26e525..aa4d3035 100644 --- a/cmd/initializer.go +++ b/cmd/initializer.go @@ -200,5 +200,9 @@ func getConfigWithoutSensitiveData(config *settings.Config) *settings.Config { } } + if clonedConfig.OAuth2ClientSecret != "" { + clonedConfig.OAuth2ClientSecret = "****" + } + return clonedConfig } diff --git a/cmd/webserver.go b/cmd/webserver.go index df293433..c50e3962 100644 --- a/cmd/webserver.go +++ b/cmd/webserver.go @@ -15,6 +15,7 @@ import ( "github.com/urfave/cli/v3" "github.com/mayswind/ezbookkeeping/pkg/api" + "github.com/mayswind/ezbookkeeping/pkg/auth/oauth2" "github.com/mayswind/ezbookkeeping/pkg/core" "github.com/mayswind/ezbookkeeping/pkg/cron" "github.com/mayswind/ezbookkeeping/pkg/errs" @@ -72,6 +73,13 @@ func startWebServer(c *core.CliContext) error { return err } + err = oauth2.InitializeOAuth2Provider(config) + + if err != nil { + log.BootErrorf(c, "[webserver.startWebServer] initializes oauth 2.0 provider failed, because %s", err.Error()) + return err + } + err = cron.InitializeCronJobSchedulerContainer(c, config, true) if err != nil { @@ -242,14 +250,26 @@ func startWebServer(c *core.CliContext) error { } } + if config.EnableOAuth2Login { + oauth2Route := router.Group("/oauth2") + oauth2Route.Use(bindMiddleware(middlewares.RequestId(config))) + oauth2Route.Use(bindMiddleware(middlewares.RequestLog)) + { + oauth2Route.GET("/login", bindRedirect(api.OAuth2Authentications.LoginHandler)) + oauth2Route.GET("/callback", bindRedirect(api.OAuth2Authentications.CallbackHandler)) + } + } + apiRoute := router.Group("/api") apiRoute.Use(bindMiddleware(middlewares.RequestId(config))) apiRoute.Use(bindMiddleware(middlewares.RequestLog)) { - apiRoute.POST("/authorize.json", bindApiWithTokenUpdate(api.Authorizations.AuthorizeHandler, config)) + if config.EnableInternalAuth { + apiRoute.POST("/authorize.json", bindApiWithTokenUpdate(api.Authorizations.AuthorizeHandler, config)) + } - if config.EnableTwoFactor { + if config.EnableInternalAuth && config.EnableTwoFactor { twoFactorRoute := apiRoute.Group("/2fa") twoFactorRoute.Use(bindMiddleware(middlewares.JWTTwoFactorAuthorization)) { @@ -258,7 +278,15 @@ func startWebServer(c *core.CliContext) error { } } - if config.EnableUserRegister { + if config.EnableOAuth2Login { + oauth2Route := apiRoute.Group("/oauth2") + oauth2Route.Use(bindMiddleware(middlewares.JWTOAuth2CallbackAuthorization)) + { + oauth2Route.POST("/authorize.json", bindApiWithTokenUpdate(api.Authorizations.OAuth2CallbackAuthorizeHandler, config)) + } + } + + if config.EnableInternalAuth && config.EnableUserRegister { apiRoute.POST("/register.json", bindApiWithTokenUpdate(api.Users.UserRegisterHandler, config)) } @@ -272,7 +300,7 @@ func startWebServer(c *core.CliContext) error { } } - if config.EnableUserForgetPassword { + if config.EnableInternalAuth && config.EnableUserForgetPassword { apiRoute.POST("/forget_password/request.json", bindApi(api.ForgetPasswords.UserForgetPasswordRequestHandler)) resetPasswordRoute := apiRoute.Group("/forget_password/reset") @@ -444,6 +472,19 @@ func bindMiddleware(fn core.MiddlewareHandlerFunc) gin.HandlerFunc { } } +func bindRedirect(fn core.RedirectHandlerFunc) gin.HandlerFunc { + return func(ginCtx *gin.Context) { + c := core.WrapWebContext(ginCtx) + url, err := fn(c) + + if err != nil { + utils.PrintJsonErrorResult(c, err) + } else { + c.Redirect(http.StatusFound, url) + } + } +} + func bindApi(fn core.ApiHandlerFunc) gin.HandlerFunc { return func(ginCtx *gin.Context) { c := core.WrapWebContext(ginCtx) diff --git a/conf/ezbookkeeping.ini b/conf/ezbookkeeping.ini index 651e8c99..b33be93f 100644 --- a/conf/ezbookkeeping.ini +++ b/conf/ezbookkeeping.ini @@ -270,15 +270,52 @@ max_failures_per_ip_per_minute = 5 max_failures_per_user_per_minute = 5 [auth] -# Set to true to enable two-factor authorization +# Set to true to enable internal authentication +enable_internal_auth = true + +# Set to true to enable OAuth 2.0 authentication +enable_oauth2_auth = false + +# For "internal" authentication only, set to true to enable two-factor authorization enable_two_factor = true -# Set to true to allow users to reset password +# For "internal" authentication only, set to true to allow users to reset password enable_forget_password = true -# Set to true to require email must be verified when use forget password +# For "internal" authentication only, set to true to require email must be verified when use forget password forget_password_require_email_verify = false +# For "oauth2" authentication only, OAuth 2.0 client ID +oauth2_client_id = + +# For "oauth2" authentication only, OAuth 2.0 client secret +oauth2_client_secret = + +# For "oauth2" authentication only, OAuth 2.0 provider user identifier claim name, supports "email" and "username", default is "email" +oauth2_user_identifier = email + +# For "oauth2" authentication only, if the user returned by OAuth 2.0 is not registered, automatically create a new user (requires "enable_register" to be set to true) +oauth2_auto_register = true + +# For "oauth2" authentication only, OAuth 2.0 provider, supports "nextcloud" currently +oauth2_provider = + +# For "oauth2" authentication only, OAuth 2.0 state expired seconds (60 - 4294967295), default is 300 (5 minutes) +oauth2_state_expired_time = 300 + +# For "oauth2" authentication only, requesting OAuth 2.0 api timeout (0 - 4294967295 milliseconds) +# Set to 0 to disable timeout for requesting OAuth 2.0 api, default is 10000 (10 seconds) +oauth2_request_timeout = 10000 + +# For "oauth2" authentication only, proxy for ezbookkeeping server requesting OAuth 2.0 api, supports "system" (use system proxy), "none" (do not use proxy), or proxy URL which starts with "http://", "https://" or "socks5://", default is "system" +oauth2_proxy = system + +# For "oauth2" authentication only, set to true to skip tls verification when request OAuth 2.0 api +oauth2_skip_tls_verify = false + +# For "oauth2" authentication and "nextcloud" OAuth 2.0 provider only, nextcloud base url, e.g. "https://cloud.example.org/" +nextcloud_base_url = + [user] # Set to true to allow users to register account by themselves enable_register = true diff --git a/go.mod b/go.mod index ac369acc..0cc4070c 100644 --- a/go.mod +++ b/go.mod @@ -91,6 +91,7 @@ require ( github.com/xuri/nfp v0.0.1 // indirect golang.org/x/arch v0.18.0 // indirect golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect + golang.org/x/oauth2 v0.31.0 // indirect golang.org/x/sys v0.35.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect diff --git a/go.sum b/go.sum index f3e060f8..655b60a7 100644 --- a/go.sum +++ b/go.sum @@ -186,6 +186,8 @@ golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXy golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= +golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/pkg/api/authorizations.go b/pkg/api/authorizations.go index cdf049e6..d5e10ef6 100644 --- a/pkg/api/authorizations.go +++ b/pkg/api/authorizations.go @@ -22,6 +22,7 @@ type AuthorizationsApi struct { userAppCloudSettings *services.UserApplicationCloudSettingsService tokens *services.TokenService twoFactorAuthorizations *services.TwoFactorAuthorizationService + userExternalAuths *services.UserExternalAuthService } // Initialize a authorization api singleton instance @@ -48,11 +49,16 @@ var ( userAppCloudSettings: services.UserApplicationCloudSettings, tokens: services.Tokens, twoFactorAuthorizations: services.TwoFactorAuthorizations, + userExternalAuths: services.UserExternalAuths, } ) // AuthorizeHandler verifies and authorizes current login request func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Error) { + if !a.CurrentConfig().EnableInternalAuth { + return nil, errs.ErrCannotLoginByPassword + } + var credential models.UserLoginRequest err := c.ShouldBindJSON(&credential) @@ -151,7 +157,7 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Err applicationCloudSettingSlice = &userApplicationCloudSettings.Settings } - log.Infof(c, "[authorizations.AuthorizeHandler] user \"uid:%d\" has logined, token type is %d, token will be expired at %d", user.Uid, claims.Type, claims.ExpiresAt) + log.Infof(c, "[authorizations.AuthorizeHandler] user \"uid:%d\" has logged in, token type is %d, token will be expired at %d", user.Uid, claims.Type, claims.ExpiresAt) authResp := a.getAuthResponse(c, token, twoFactorEnable, user, applicationCloudSettingSlice) return authResp, nil @@ -159,6 +165,10 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Err // TwoFactorAuthorizeHandler verifies and authorizes current 2fa login by passcode func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any, *errs.Error) { + if !a.CurrentConfig().EnableInternalAuth { + return nil, errs.ErrCannotLoginByPassword + } + var credential models.TwoFactorLoginRequest err := c.ShouldBindJSON(&credential) @@ -198,7 +208,7 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any, user, err := a.users.GetUserById(c, uid) if err != nil { - log.Errorf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get user \"uid:%d\" info, because %s", user.Uid, err.Error()) + log.Errorf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error()) return nil, errs.ErrUserNotFound } @@ -246,6 +256,10 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any, // TwoFactorAuthorizeByRecoveryCodeHandler verifies and authorizes current 2fa login by recovery code func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebContext) (any, *errs.Error) { + if !a.CurrentConfig().EnableInternalAuth { + return nil, errs.ErrCannotLoginByPassword + } + var credential models.TwoFactorRecoveryCodeLoginRequest err := c.ShouldBindJSON(&credential) @@ -276,7 +290,7 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebC user, err := a.users.GetUserById(c, uid) if err != nil { - log.Errorf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get user \"uid:%d\" info, because %s", user.Uid, err.Error()) + log.Errorf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error()) return nil, errs.ErrUserNotFound } @@ -338,6 +352,131 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebC return authResp, nil } +// OAuth2CallbackAuthorizeHandler verifies and authorizes current OAuth 2.0 callback login +func (a *AuthorizationsApi) OAuth2CallbackAuthorizeHandler(c *core.WebContext) (any, *errs.Error) { + if !a.CurrentConfig().EnableOAuth2Login { + return nil, errs.ErrOAuth2NotEnabled + } + + var credential models.OAuth2CallbackLoginRequest + err := c.ShouldBindJSON(&credential) + + if err != nil { + log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] parse request failed, because %s", err.Error()) + return nil, errs.NewIncompleteOrIncorrectSubmissionError(err) + } + + userExternalAuthType := core.UserExternalAuthType(credential.Provider) + + if !userExternalAuthType.IsValid() { + log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] provider \"%s\" is invalid", credential.Provider) + return nil, errs.ErrInvalidOAuth2Provider + } + + uid := c.GetCurrentUid() + err = a.CheckFailureCount(c, uid) + + if err != nil { + log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.Or(err, errs.ErrFailureCountLimitReached) + } + + user, err := a.users.GetUserById(c, uid) + + if err != nil { + log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error()) + return nil, errs.ErrUserNotFound + } + + if user.Disabled { + log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] user \"uid:%d\" is disabled", user.Uid) + return nil, errs.ErrUserIsDisabled + } + + if a.CurrentConfig().EnableUserForceVerifyEmail && !user.EmailVerified { + log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] user \"uid:%d\" has not verified email", user.Uid) + return nil, errs.ErrEmailIsNotVerified + } + + oldTokenClaims := c.GetTokenClaims() + + if oldTokenClaims.Type == core.USER_TOKEN_TYPE_OAUTH2_CALLBACK_REQUIRE_VERIFY { + if credential.Password == "" { + return nil, errs.ErrPasswordIsEmpty + } + + if !a.users.IsPasswordEqualsUserPassword(credential.Password, user) { + failureCheckErr := a.CheckAndIncreaseFailureCount(c, uid) + + if failureCheckErr != nil { + log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] cannot login for user \"uid:%d\", because %s", user.Uid, failureCheckErr.Error()) + return nil, errs.Or(failureCheckErr, errs.ErrFailureCountLimitReached) + } + + return nil, errs.ErrUserPasswordWrong + } + + userExternalAuth := &models.UserExternalAuth{ + Uid: user.Uid, + ExternalAuthType: userExternalAuthType, + } + + if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierEmail { + userExternalAuth.ExternalEmail = user.Email + } else if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierUsername { + userExternalAuth.ExternalUsername = user.Username + } + + err = a.userExternalAuths.CreateUserExternalAuth(c, userExternalAuth) + + if err != nil { + log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to create user external auth for user \"uid:%d\", because %s", user.Uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + log.Infof(c, "[authorizations.OAuth2CallbackAuthorizeHandler] user external auth has been created for user \"uid:%d\"", user.Uid) + } else if oldTokenClaims.Type == core.USER_TOKEN_TYPE_OAUTH2_CALLBACK { + _, err = a.userExternalAuths.GetUserExternalAuthByUid(c, uid, userExternalAuthType) + + if err != nil { + log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to get user external auth for user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.Or(err, errs.ErrUserExternalAuthNotFound) + } + } else { + return nil, errs.ErrSystemError + } + + err = a.tokens.DeleteTokenByClaims(c, oldTokenClaims) + + if err != nil { + log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to revoke temporary token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error()) + } + + token, claims, err := a.tokens.CreateToken(c, user) + + if err != nil { + log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error()) + return nil, errs.ErrTokenGenerating + } + + c.SetTextualToken(token) + c.SetTokenClaims(claims) + + userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid) + var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil + + if err != nil { + log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error()) + } else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 { + applicationCloudSettingSlice = &userApplicationCloudSettings.Settings + } + + log.Infof(c, "[authorizations.OAuth2CallbackAuthorizeHandler] user \"uid:%d\" has logged in, token will be expired at %d", user.Uid, claims.ExpiresAt) + + authResp := a.getAuthResponse(c, token, false, user, applicationCloudSettingSlice) + return authResp, nil +} + func (a *AuthorizationsApi) getAuthResponse(c *core.WebContext, token string, need2FA bool, user *models.User, applicationCloudSettings *models.ApplicationCloudSettingSlice) *models.AuthResponse { return &models.AuthResponse{ Token: token, diff --git a/pkg/api/base.go b/pkg/api/base.go index 861b0860..3cf49f1b 100644 --- a/pkg/api/base.go +++ b/pkg/api/base.go @@ -3,6 +3,7 @@ package api import ( "fmt" "sort" + "time" "github.com/mayswind/ezbookkeeping/pkg/avatars" "github.com/mayswind/ezbookkeeping/pkg/core" @@ -120,6 +121,13 @@ func (a *ApiUsingDuplicateChecker) SetSubmissionRemarkIfEnable(checkerType dupli } } +// SetSubmissionRemarkWithCustomExpirationIfEnable saves the identification and remark by the current duplicate checker with custom expiration time if the duplicate submission check is enabled +func (a *ApiUsingDuplicateChecker) SetSubmissionRemarkWithCustomExpirationIfEnable(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string, remark string, expiration time.Duration) { + if a.CurrentConfig().EnableDuplicateSubmissionsCheck { + a.container.SetSubmissionRemarkWithCustomExpiration(checkerType, uid, identification, remark, expiration) + } +} + // RemoveSubmissionRemarkIfEnable removes the identification and remark by the current duplicate checker if the duplicate submission check is enabled func (a *ApiUsingDuplicateChecker) RemoveSubmissionRemarkIfEnable(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string) { if a.CurrentConfig().EnableDuplicateSubmissionsCheck { diff --git a/pkg/api/oauth2_authentications.go b/pkg/api/oauth2_authentications.go new file mode 100644 index 00000000..019f3a50 --- /dev/null +++ b/pkg/api/oauth2_authentications.go @@ -0,0 +1,313 @@ +package api + +import ( + "errors" + "fmt" + "net/url" + "strings" + + "github.com/mayswind/ezbookkeeping/pkg/auth/oauth2" + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/duplicatechecker" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/locales" + "github.com/mayswind/ezbookkeeping/pkg/log" + "github.com/mayswind/ezbookkeeping/pkg/models" + "github.com/mayswind/ezbookkeeping/pkg/services" + "github.com/mayswind/ezbookkeeping/pkg/settings" + "github.com/mayswind/ezbookkeeping/pkg/utils" + "github.com/mayswind/ezbookkeeping/pkg/validators" +) + +const oauth2CallbackPageUrlSuccessFormat = "%sdesktop/#/oauth2_callback?platform=%s&provider=%s&token=%s" +const oauth2CallbackPageUrlNeedVerifyFormat = "%sdesktop/#/oauth2_callback?platform=%s&provider=%s&userName=%s&token=%s" +const oauth2CallbackPageUrlFailedFormat = "%sdesktop/#/oauth2_callback?error=%s" + +// OAuth2AuthenticationApi represents OAuth 2.0 authorization api +type OAuth2AuthenticationApi struct { + ApiUsingConfig + ApiUsingDuplicateChecker + users *services.UserService + tokens *services.TokenService + userExternalAuths *services.UserExternalAuthService +} + +// Initialize a OAuth 2.0 authentication api singleton instance +var ( + OAuth2Authentications = &OAuth2AuthenticationApi{ + ApiUsingConfig: ApiUsingConfig{ + container: settings.Container, + }, + ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{ + ApiUsingConfig: ApiUsingConfig{ + container: settings.Container, + }, + container: duplicatechecker.Container, + }, + users: services.Users, + tokens: services.Tokens, + userExternalAuths: services.UserExternalAuths, + } +) + +// LoginHandler handles user login request via OAuth 2.0 +func (a *OAuth2AuthenticationApi) LoginHandler(c *core.WebContext) (string, *errs.Error) { + var oauth2LoginReq models.OAuth2LoginRequest + err := c.ShouldBindQuery(&oauth2LoginReq) + + if err != nil { + log.Warnf(c, "[oauth2_authentications.LoginHandler] parse request failed, because %s", err.Error()) + return "", errs.NewIncompleteOrIncorrectSubmissionError(err) + } + + if oauth2LoginReq.Platform != "mobile" && oauth2LoginReq.Platform != "desktop" { + return "", errs.ErrInvalidOAuth2LoginRequest + } + + state := fmt.Sprintf("%s|%s", oauth2LoginReq.Platform, oauth2LoginReq.ClientSessionId) + remark := "" + + if a.CurrentConfig().EnableDuplicateSubmissionsCheck { + found := false + found, remark = a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_OAUTH2_REDIRECT, 0, oauth2LoginReq.ClientSessionId) + + if found { + log.Errorf(c, "[oauth2_authentications.LoginHandler] another oauth 2.0 state \"%s\" has been processing for client session id \"%s\"", remark, oauth2LoginReq.ClientSessionId) + return "", errs.ErrRepeatedRequest + } + + randomString, err := utils.GetRandomNumberOrLowercaseLetter(32) + + if err != nil { + log.Errorf(c, "[oauth2_authentications.LoginHandler] failed to generate random string for oauth 2.0 state, because %s", err.Error()) + return "", errs.ErrSystemError + } + + remark = fmt.Sprintf("%s|%s|%s", oauth2LoginReq.Platform, oauth2LoginReq.ClientSessionId, randomString) + state = fmt.Sprintf("%s|%s|%s", oauth2LoginReq.Platform, oauth2LoginReq.ClientSessionId, utils.MD5EncodeToString([]byte(remark))) + } + + redirectUrl, err := oauth2.GetOAuth2AuthUrl(c, state) + + if err != nil { + log.Errorf(c, "[oauth2_authentications.LoginHandler] failed to get oauth 2.0 auth url, because %s", err.Error()) + return "", errs.Or(err, errs.ErrSystemError) + } + + a.SetSubmissionRemarkWithCustomExpirationIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_OAUTH2_REDIRECT, 0, oauth2LoginReq.ClientSessionId, remark, a.CurrentConfig().OAuth2StateExpiredTimeDuration) + + return redirectUrl, nil +} + +// CallbackHandler handles OAuth 2.0 callback request +func (a *OAuth2AuthenticationApi) CallbackHandler(c *core.WebContext) (string, *errs.Error) { + var oauth2CallbackReq models.OAuth2CallbackRequest + err := c.ShouldBindQuery(&oauth2CallbackReq) + + if err != nil { + log.Warnf(c, "[oauth2_authentications.CallbackHandler] parse request failed, because %s", err.Error()) + return a.redirectToFailedCallbackPage(c, errs.NewIncompleteOrIncorrectSubmissionError(err)) + } + + if oauth2CallbackReq.State == "" { + return a.redirectToFailedCallbackPage(c, errs.ErrMissingOAuth2State) + } + + if oauth2CallbackReq.Code == "" { + return a.redirectToFailedCallbackPage(c, errs.ErrMissingOAuth2Code) + } + + platform := "" + clientSessionId := "" + + stateParts := strings.Split(oauth2CallbackReq.State, "|") + + if len(stateParts) >= 2 { + platform = stateParts[0] + clientSessionId = stateParts[1] + } else { + return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2State) + } + + if platform != "mobile" && platform != "desktop" { + return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2LoginRequest) + } + + if a.CurrentConfig().EnableDuplicateSubmissionsCheck { + found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_OAUTH2_REDIRECT, 0, clientSessionId) + + if !found { + log.Errorf(c, "[oauth2_authentications.CallbackHandler] cannot find oauth 2.0 state in duplicate checker for client session id \"%s\"", clientSessionId) + return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2Callback) + } + + remarkParts := strings.Split(remark, "|") + + if len(remarkParts) != 3 || remarkParts[0] != platform || remarkParts[1] != clientSessionId { + log.Errorf(c, "[oauth2_authentications.CallbackHandler] invalid oauth 2.0 state \"%s\" in duplicate checker for client session id \"%s\"", remark, clientSessionId) + return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2State) + } + + expectedState := fmt.Sprintf("%s|%s|%s", platform, clientSessionId, remarkParts[2]) + expectedState = fmt.Sprintf("%s|%s|%s", platform, clientSessionId, utils.MD5EncodeToString([]byte(expectedState))) + + if oauth2CallbackReq.State != expectedState { + log.Errorf(c, "[oauth2_authentications.CallbackHandler] mismatched random string in oauth 2.0 state, expected \"%s\", got \"%s\"", expectedState, oauth2CallbackReq.State) + return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2State) + } + + a.RemoveSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_OAUTH2_REDIRECT, 0, clientSessionId) + } + + oauth2Token, err := oauth2.GetOAuth2Token(c, oauth2CallbackReq.Code) + + if err != nil { + log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to retrieve oauth 2.0 token, because %s", err.Error()) + return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrCannotRetrieveOAuth2Token)) + } + + oauth2UserInfo, err := oauth2.GetOAuth2UserInfo(c, oauth2Token) + + if err != nil { + log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to retrieve oauth 2.0 user info, because %s", err.Error()) + return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrInvalidOAuth2Token)) + } + + if oauth2UserInfo == nil { + log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to retrieve oauth 2.0 user info, because user info is nil") + return a.redirectToFailedCallbackPage(c, errs.ErrCannotRetrieveUserInfo) + } + + if oauth2UserInfo.UserName == "" || oauth2UserInfo.Email == "" { + log.Errorf(c, "[oauth2_authentications.CallbackHandler] invalid oauth 2.0 user info, userName: %s, email: %s", oauth2UserInfo.UserName, oauth2UserInfo.Email) + return a.redirectToFailedCallbackPage(c, errs.ErrCannotRetrieveUserInfo) + } + + userExternalAuthType := oauth2.GetExternalUserAuthType() + var userExternalAuth *models.UserExternalAuth + + if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierEmail { + userExternalAuth, err = a.userExternalAuths.GetUserExternalAuthByExternalEmail(c, oauth2UserInfo.Email, userExternalAuthType) + } else if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierUsername { + userExternalAuth, err = a.userExternalAuths.GetUserExternalAuthByExternalUserName(c, oauth2UserInfo.UserName, userExternalAuthType) + } else { + userExternalAuth, err = a.userExternalAuths.GetUserExternalAuthByExternalEmail(c, oauth2UserInfo.Email, userExternalAuthType) + } + + if err != nil && !errors.Is(err, errs.ErrUserExternalAuthNotFound) { + log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to get user external auth, because %s", err.Error()) + return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed)) + } + + var user *models.User + + if err == nil { // user already bound to external auth, redirect to success page + user, err = a.users.GetUserById(c, userExternalAuth.Uid) + + if err != nil { + log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to get user by id %d, because %s", userExternalAuth.Uid, err.Error()) + return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed)) + } + } else if errors.Is(err, errs.ErrUserExternalAuthNotFound) { // user not bound to external auth, try to bind or register new user + if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierEmail { + user, err = a.users.GetUserByEmail(c, oauth2UserInfo.Email) + } else if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierUsername { + user, err = a.users.GetUserByUsername(c, oauth2UserInfo.UserName) + } else { + user, err = a.users.GetUserByEmail(c, oauth2UserInfo.Email) + } + + if err != nil && !errors.Is(err, errs.ErrUserNotFound) { + log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to get user, because %s", err.Error()) + return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed)) + } + + if user == nil && a.CurrentConfig().EnableUserRegister && a.CurrentConfig().OAuth2AutoRegister { + userName := strings.TrimSpace(oauth2UserInfo.UserName) + email := strings.TrimSpace(oauth2UserInfo.Email) + nickName := strings.TrimSpace(oauth2UserInfo.NickName) + languageCode := "" + currencyCode := "USD" + + if _, exists := locales.AllLanguages[oauth2UserInfo.LanguageCode]; exists { + languageCode = oauth2UserInfo.LanguageCode + } + + if _, exists := validators.AllCurrencyNames[oauth2UserInfo.CurrencyCode]; exists { + currencyCode = oauth2UserInfo.CurrencyCode + } + + user = &models.User{ + Username: userName, + Email: email, + Nickname: nickName, + Password: "", + Language: languageCode, + DefaultCurrency: currencyCode, + FirstDayOfWeek: oauth2UserInfo.FirstDayOfWeek, + FiscalYearStart: core.FISCAL_YEAR_START_DEFAULT, + TransactionEditScope: models.TRANSACTION_EDIT_SCOPE_ALL, + FeatureRestriction: a.CurrentConfig().DefaultFeatureRestrictions, + } + + err = a.users.CreateUser(c, user) + + if err != nil { + log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to create user \"%s\", because %s", user.Username, err.Error()) + return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed)) + } + + log.Infof(c, "[oauth2_authentications.CallbackHandler] user \"%s\" has registered successfully, uid is %d", user.Username, user.Uid) + + userExternalAuth := &models.UserExternalAuth{ + Uid: user.Uid, + ExternalAuthType: userExternalAuthType, + ExternalUsername: oauth2UserInfo.UserName, + ExternalEmail: oauth2UserInfo.Email, + } + + err = a.userExternalAuths.CreateUserExternalAuth(c, userExternalAuth) + + if err != nil { + log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to create user external auth for user \"uid:%d\", because %s", user.Uid, err.Error()) + return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed)) + } + + log.Infof(c, "[oauth2_authentications.CallbackHandler] user external auth has been created for user \"uid:%d\"", user.Uid) + } else if user == nil { + return a.redirectToFailedCallbackPage(c, errs.ErrOAuth2AutoRegistrationNotEnabled) + } + } + + if userExternalAuth == nil { + token, _, err := a.tokens.CreateOAuth2CallbackRequireVerifyToken(c, user) + + if err != nil { + log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to create oauth 2.0 callback verify token for user \"uid:%d\", because %s", user.Uid, err.Error()) + return a.redirectToFailedCallbackPage(c, errs.ErrTokenGenerating) + } + + return a.redirectToVerifyCallbackPage(c, platform, userExternalAuthType, user.Username, token) + } else { + token, _, err := a.tokens.CreateOAuth2CallbackToken(c, user) + + if err != nil { + log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to create oauth 2.0 callback token for user \"uid:%d\", because %s", user.Uid, err.Error()) + return a.redirectToFailedCallbackPage(c, errs.ErrTokenGenerating) + } + + return a.redirectToSuccessCallbackPage(c, platform, userExternalAuthType, token) + } +} + +func (a *OAuth2AuthenticationApi) redirectToSuccessCallbackPage(c *core.WebContext, platform string, externalAuthType core.UserExternalAuthType, token string) (string, *errs.Error) { + return fmt.Sprintf(oauth2CallbackPageUrlSuccessFormat, a.CurrentConfig().RootUrl, platform, externalAuthType, url.QueryEscape(token)), nil +} + +func (a *OAuth2AuthenticationApi) redirectToVerifyCallbackPage(c *core.WebContext, platform string, externalAuthType core.UserExternalAuthType, userName string, token string) (string, *errs.Error) { + return fmt.Sprintf(oauth2CallbackPageUrlNeedVerifyFormat, a.CurrentConfig().RootUrl, platform, externalAuthType, userName, url.QueryEscape(token)), nil +} + +func (a *OAuth2AuthenticationApi) redirectToFailedCallbackPage(c *core.WebContext, err *errs.Error) (string, *errs.Error) { + return fmt.Sprintf(oauth2CallbackPageUrlFailedFormat, a.CurrentConfig().RootUrl, url.QueryEscape(utils.GetDisplayErrorMessage(err))), nil +} diff --git a/pkg/api/server_settings.go b/pkg/api/server_settings.go index d8df47c8..9722d27d 100644 --- a/pkg/api/server_settings.go +++ b/pkg/api/server_settings.go @@ -35,14 +35,18 @@ func (a *ServerSettingsApi) ServerSettingsJavascriptHandler(c *core.WebContext) builder := &strings.Builder{} builder.WriteString(ezbookkeepingServerSettingsJavascriptFileHeader) - a.appendBooleanSetting(builder, "r", config.EnableUserRegister) - a.appendBooleanSetting(builder, "f", config.EnableUserForgetPassword) + a.appendBooleanSetting(builder, "a", config.EnableInternalAuth) + a.appendBooleanSetting(builder, "o", config.EnableOAuth2Login) + a.appendBooleanSetting(builder, "r", config.EnableInternalAuth && config.EnableUserRegister) + a.appendBooleanSetting(builder, "f", config.EnableInternalAuth && config.EnableUserForgetPassword) a.appendBooleanSetting(builder, "v", config.EnableUserVerifyEmail) a.appendBooleanSetting(builder, "p", config.EnableTransactionPictures) a.appendBooleanSetting(builder, "s", config.EnableScheduledTransaction) a.appendBooleanSetting(builder, "e", config.EnableDataExport) a.appendBooleanSetting(builder, "i", config.EnableDataImport) + a.appendStringSetting(builder, "op", config.OAuth2Provider) + if config.EnableMCPServer { a.appendBooleanSetting(builder, "mcp", config.EnableMCPServer) } @@ -138,7 +142,7 @@ func (a *ServerSettingsApi) appendStringSetting(builder *strings.Builder, key st builder.WriteString(";\n") } -func (a *ServerSettingsApi) appendMultiLanguageTipSetting(builder *strings.Builder, key string, value settings.TipConfig) { +func (a *ServerSettingsApi) appendMultiLanguageTipSetting(builder *strings.Builder, key string, value settings.MultiLanguageContentConfig) { builder.WriteString(ezbookkeepingServerSettingsGlobalVariableFullName) builder.WriteString("[") a.appendEncodedString(builder, key) diff --git a/pkg/auth/oauth2/nextcloud_oauth2_provider.go b/pkg/auth/oauth2/nextcloud_oauth2_provider.go new file mode 100644 index 00000000..c7a56279 --- /dev/null +++ b/pkg/auth/oauth2/nextcloud_oauth2_provider.go @@ -0,0 +1,110 @@ +package oauth2 + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/log" +) + +type nextcloudUserInfoResponse struct { + OCS *struct { + Meta *struct { + Status string `json:"status"` + StatusCode int `json:"statuscode"` + } `json:"meta"` + Data *struct { + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"display-name"` + } `json:"data"` + } `json:"ocs"` +} + +// NextcloudOAuth2Provider represents Nextcloud OAuth 2.0 provider +type NextcloudOAuth2Provider struct { + baseUrl string +} + +// NewNextcloudOAuth2Provider creates a new Nextcloud OAuth 2.0 provider instance +func NewNextcloudOAuth2Provider(baseUrl string) OAuth2Provider { + if baseUrl[len(baseUrl)-1] != '/' { + baseUrl += "/" + } + + return &NextcloudOAuth2Provider{ + baseUrl: baseUrl, + } +} + +// GetAuthUrl returns the authentication url of the Nextcloud provider +func (p *NextcloudOAuth2Provider) GetAuthUrl() string { + return p.baseUrl + "apps/oauth2/authorize" +} + +// GetTokenUrl returns the token url of the Nextcloud provider +func (p *NextcloudOAuth2Provider) GetTokenUrl() string { + return p.baseUrl + "apps/oauth2/api/v1/token" +} + +// GetUserInfo returns the user info by the Nextcloud provider +func (p *NextcloudOAuth2Provider) GetUserInfo(c core.Context, oauth2Client *http.Client) (*OAuth2UserInfo, error) { + url := p.baseUrl + "ocs/v2.php/cloud/user?format=json" + resp, err := oauth2Client.Get(url) + + if err != nil { + log.Errorf(c, "[nextcloud_oauth2_provider.GetUserInfo] failed to get user info response, because %s", err.Error()) + return nil, errs.ErrFailedToRequestRemoteApi + } + + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + + log.Debugf(c, "[nextcloud_oauth2_provider.GetUserInfo] response is %s", body) + + if resp.StatusCode != 200 { + log.Errorf(c, "[nextcloud_oauth2_provider.GetUserInfo] failed to get user info response, because response code is %d", resp.StatusCode) + return nil, errs.ErrFailedToRequestRemoteApi + } + + return p.parseUserInfo(c, body) +} + +// GetScopes returns the scopes required by the Nextcloud provider +func (p *NextcloudOAuth2Provider) GetScopes() []string { + return []string{"profile", "email"} +} + +func (p *NextcloudOAuth2Provider) parseUserInfo(c core.Context, body []byte) (*OAuth2UserInfo, error) { + userInfoResp := &nextcloudUserInfoResponse{} + err := json.Unmarshal(body, &userInfoResp) + + if err != nil { + log.Warnf(c, "[nextcloud_oauth2_provider.parseUserInfo] failed to parse user info response body, because %s", err.Error()) + return nil, errs.ErrCannotRetrieveUserInfo + } + + if userInfoResp.OCS == nil || userInfoResp.OCS.Meta == nil || userInfoResp.OCS.Data == nil { + log.Warnf(c, "[nextcloud_oauth2_provider.parseUserInfo] invalid user info response body") + return nil, errs.ErrCannotRetrieveUserInfo + } + + if userInfoResp.OCS.Meta.StatusCode != 200 { + log.Warnf(c, "[nextcloud_oauth2_provider.parseUserInfo] user info response status code is %d", userInfoResp.OCS.Meta.StatusCode) + return nil, errs.ErrCannotRetrieveUserInfo + } + + if userInfoResp.OCS.Data.ID == "" { + log.Warnf(c, "[nextcloud_oauth2_provider.parseUserInfo] user info id is empty") + return nil, errs.ErrCannotRetrieveUserInfo + } + + return &OAuth2UserInfo{ + UserName: userInfoResp.OCS.Data.ID, + Email: userInfoResp.OCS.Data.Email, + NickName: userInfoResp.OCS.Data.DisplayName, + }, nil +} diff --git a/pkg/auth/oauth2/oauth2_authentication.go b/pkg/auth/oauth2/oauth2_authentication.go new file mode 100644 index 00000000..79f25c2b --- /dev/null +++ b/pkg/auth/oauth2/oauth2_authentication.go @@ -0,0 +1,105 @@ +package oauth2 + +import ( + "net/http" + + "golang.org/x/oauth2" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/settings" + "github.com/mayswind/ezbookkeeping/pkg/utils" +) + +// OAuth2Container contains the current OAuth 2.0 authentication provider +type OAuth2Container struct { + oauth2Config *oauth2.Config + oauth2Provider OAuth2Provider + oauth2HttpClient *http.Client + externalUserAuthType core.UserExternalAuthType +} + +// Initialize a OAuth 2.0 container singleton instance +var ( + Container = &OAuth2Container{} +) + +// InitializeOAuth2Provider initializes the current OAuth 2.0 provider according to the config +func InitializeOAuth2Provider(config *settings.Config) error { + if !config.EnableOAuth2Login { + return nil + } + + if config.OAuth2ClientID == "" || config.OAuth2ClientSecret == "" || config.OAuth2UserIdentifier == "" || config.OAuth2Provider == "" { + return errs.ErrInvalidOAuth2Config + } + + var oauth2Provider OAuth2Provider + var externalUserAuthType core.UserExternalAuthType + + if config.OAuth2Provider == settings.OAuth2ProviderNextcloud { + oauth2Provider = NewNextcloudOAuth2Provider(config.OAuth2NextcloudBaseUrl) + externalUserAuthType = core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_NEXTCLOUD + } else { + return errs.ErrInvalidOAuth2Provider + } + + Container.oauth2Config = buildOAuth2Config(config, oauth2Provider) + Container.oauth2Provider = oauth2Provider + Container.oauth2HttpClient = utils.NewHttpClient(config.OAuth2RequestTimeout, config.OAuth2Proxy, config.OAuth2SkipTLSVerify, settings.GetUserAgent()) + Container.externalUserAuthType = externalUserAuthType + + return nil +} + +// GetOAuth2AuthUrl returns the OAuth 2.0 authentication url +func GetOAuth2AuthUrl(c core.Context, state string) (string, error) { + if Container.oauth2Config == nil { + return "", errs.ErrOAuth2NotEnabled + } + + return Container.oauth2Config.AuthCodeURL(state), nil +} + +// GetOAuth2Token exchanges the authorization code for an OAuth 2.0 token +func GetOAuth2Token(c core.Context, code string) (*oauth2.Token, error) { + if Container.oauth2Config == nil || Container.oauth2HttpClient == nil { + return nil, errs.ErrOAuth2NotEnabled + } + + return Container.oauth2Config.Exchange(wrapOAuth2Context(c, Container.oauth2HttpClient), code) +} + +// GetOAuth2UserInfo retrieves the OAuth 2.0 user info using the provided OAuth 2.0 token +func GetOAuth2UserInfo(c core.Context, token *oauth2.Token) (*OAuth2UserInfo, error) { + if Container.oauth2Config == nil || Container.oauth2Provider == nil || Container.oauth2HttpClient == nil { + return nil, errs.ErrOAuth2NotEnabled + } + + if token == nil { + return nil, errs.ErrInvalidOAuth2Token + } + + oauth2Client := oauth2.NewClient(wrapOAuth2Context(c, Container.oauth2HttpClient), oauth2.StaticTokenSource(token)) + return Container.oauth2Provider.GetUserInfo(c, oauth2Client) +} + +// GetExternalUserAuthType returns the external user auth type of the current OAuth 2.0 provider +func GetExternalUserAuthType() core.UserExternalAuthType { + return Container.externalUserAuthType +} + +func buildOAuth2Config(config *settings.Config, oauth2Provider OAuth2Provider) *oauth2.Config { + redirectURL := config.RootUrl + "oauth2/callback" + + return &oauth2.Config{ + ClientID: config.OAuth2ClientID, + ClientSecret: config.OAuth2ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: oauth2Provider.GetAuthUrl(), + TokenURL: oauth2Provider.GetTokenUrl(), + }, + RedirectURL: redirectURL, + Scopes: oauth2Provider.GetScopes(), + } +} diff --git a/pkg/auth/oauth2/oauth2_context.go b/pkg/auth/oauth2/oauth2_context.go new file mode 100644 index 00000000..fb79dd74 --- /dev/null +++ b/pkg/auth/oauth2/oauth2_context.go @@ -0,0 +1,31 @@ +package oauth2 + +import ( + "net/http" + + "golang.org/x/oauth2" + + "github.com/mayswind/ezbookkeeping/pkg/core" +) + +// OAuth2Context represents the context for OAuth 2.0 operations +type OAuth2Context struct { + core.Context + httpClient *http.Client +} + +// Value returns the value associated with key +func (o *OAuth2Context) Value(key any) any { + if key == oauth2.HTTPClient { + return o.httpClient + } + + return o.Context.Value(key) +} + +func wrapOAuth2Context(ctx core.Context, httpClient *http.Client) core.Context { + return &OAuth2Context{ + Context: ctx, + httpClient: httpClient, + } +} diff --git a/pkg/auth/oauth2/oauth2_provider.go b/pkg/auth/oauth2/oauth2_provider.go new file mode 100644 index 00000000..55e72dc2 --- /dev/null +++ b/pkg/auth/oauth2/oauth2_provider.go @@ -0,0 +1,22 @@ +package oauth2 + +import ( + "net/http" + + "github.com/mayswind/ezbookkeeping/pkg/core" +) + +// OAuth2Provider defines the structure of OAuth 2.0 provider +type OAuth2Provider interface { + // GetAuthUrl returns the authentication url of the provider + GetAuthUrl() string + + // GetTokenUrl returns the token url of the provider + GetTokenUrl() string + + // GetUserInfo returns the user info + GetUserInfo(c core.Context, oauth2Client *http.Client) (*OAuth2UserInfo, error) + + // GetScopes returns the scopes required by the provider + GetScopes() []string +} diff --git a/pkg/auth/oauth2/oauth2_user_info.go b/pkg/auth/oauth2/oauth2_user_info.go new file mode 100644 index 00000000..450dbad1 --- /dev/null +++ b/pkg/auth/oauth2/oauth2_user_info.go @@ -0,0 +1,13 @@ +package oauth2 + +import "github.com/mayswind/ezbookkeeping/pkg/core" + +// OAuth2UserInfo represents the user info retrieved from OAuth 2.0 provider +type OAuth2UserInfo struct { + UserName string + Email string + NickName string + LanguageCode string + CurrencyCode string + FirstDayOfWeek core.WeekDay +} diff --git a/pkg/core/handler.go b/pkg/core/handler.go index 4e363ef2..66e63858 100644 --- a/pkg/core/handler.go +++ b/pkg/core/handler.go @@ -12,6 +12,9 @@ type CliHandlerFunc func(*CliContext) error // MiddlewareHandlerFunc represents the middleware handler function type MiddlewareHandlerFunc func(*WebContext) +// RedirectHandlerFunc represents the redirect handler function +type RedirectHandlerFunc func(*WebContext) (string, *errs.Error) + // ApiHandlerFunc represents the api handler function type ApiHandlerFunc func(*WebContext) (any, *errs.Error) diff --git a/pkg/core/token_claims.go b/pkg/core/token_claims.go index 2ecadb84..b0242e80 100644 --- a/pkg/core/token_claims.go +++ b/pkg/core/token_claims.go @@ -11,11 +11,13 @@ type TokenType byte // Token types const ( - USER_TOKEN_TYPE_NORMAL TokenType = 1 - USER_TOKEN_TYPE_REQUIRE_2FA TokenType = 2 - USER_TOKEN_TYPE_EMAIL_VERIFY TokenType = 3 - USER_TOKEN_TYPE_PASSWORD_RESET TokenType = 4 - USER_TOKEN_TYPE_MCP TokenType = 5 + USER_TOKEN_TYPE_NORMAL TokenType = 1 + USER_TOKEN_TYPE_REQUIRE_2FA TokenType = 2 + USER_TOKEN_TYPE_EMAIL_VERIFY TokenType = 3 + USER_TOKEN_TYPE_PASSWORD_RESET TokenType = 4 + USER_TOKEN_TYPE_MCP TokenType = 5 + USER_TOKEN_TYPE_OAUTH2_CALLBACK_REQUIRE_VERIFY TokenType = 6 + USER_TOKEN_TYPE_OAUTH2_CALLBACK TokenType = 7 ) // UserTokenClaims represents user token diff --git a/pkg/core/user_external_auth_type.go b/pkg/core/user_external_auth_type.go new file mode 100644 index 00000000..858dd287 --- /dev/null +++ b/pkg/core/user_external_auth_type.go @@ -0,0 +1,18 @@ +package core + +// UserExternalAuthType represents the type of user external authentication +type UserExternalAuthType string + +// User External Auth Type +const ( + USER_EXTERNAL_AUTH_TYPE_OAUTH2_NEXTCLOUD UserExternalAuthType = "nextcloud" +) + +// IsValid checks if the UserExternalAuthType is valid +func (t UserExternalAuthType) IsValid() bool { + switch t { + case USER_EXTERNAL_AUTH_TYPE_OAUTH2_NEXTCLOUD: + return true + } + return false +} diff --git a/pkg/duplicatechecker/duplicate_checker.go b/pkg/duplicatechecker/duplicate_checker.go index cc87728b..cbfe2745 100644 --- a/pkg/duplicatechecker/duplicate_checker.go +++ b/pkg/duplicatechecker/duplicate_checker.go @@ -6,6 +6,7 @@ import "time" type DuplicateChecker interface { GetSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string) (bool, string) SetSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string, remark string) + SetSubmissionRemarkWithCustomExpiration(checkerType DuplicateCheckerType, uid int64, identification string, remark string, expiration time.Duration) RemoveSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string) GetOrSetCronJobRunningInfo(jobName string, runningInfo string, runningInterval time.Duration) (bool, string) RemoveCronJobRunningInfo(jobName string) diff --git a/pkg/duplicatechecker/duplicate_checker_container.go b/pkg/duplicatechecker/duplicate_checker_container.go index 8dfb5d81..aa9b3986 100644 --- a/pkg/duplicatechecker/duplicate_checker_container.go +++ b/pkg/duplicatechecker/duplicate_checker_container.go @@ -57,6 +57,15 @@ func (c *DuplicateCheckerContainer) SetSubmissionRemark(checkerType DuplicateChe c.current.SetSubmissionRemark(checkerType, uid, identification, remark) } +// SetSubmissionRemarkWithCustomExpiration saves the identification and remark by the current duplicate checker with custom expiration time +func (c *DuplicateCheckerContainer) SetSubmissionRemarkWithCustomExpiration(checkerType DuplicateCheckerType, uid int64, identification string, remark string, expiration time.Duration) { + if c.current == nil { + return + } + + c.current.SetSubmissionRemarkWithCustomExpiration(checkerType, uid, identification, remark, expiration) +} + // RemoveSubmissionRemark removes the identification and remark by the current duplicate checker func (c *DuplicateCheckerContainer) RemoveSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string) { if c.current == nil { diff --git a/pkg/duplicatechecker/duplicate_checker_type.go b/pkg/duplicatechecker/duplicate_checker_type.go index a087a5dd..4f6ccd12 100644 --- a/pkg/duplicatechecker/duplicate_checker_type.go +++ b/pkg/duplicatechecker/duplicate_checker_type.go @@ -13,5 +13,6 @@ const ( DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE DuplicateCheckerType = 5 DUPLICATE_CHECKER_TYPE_NEW_PICTURE DuplicateCheckerType = 6 DUPLICATE_CHECKER_TYPE_IMPORT_TRANSACTIONS DuplicateCheckerType = 7 + DUPLICATE_CHECKER_TYPE_OAUTH2_REDIRECT DuplicateCheckerType = 8 DUPLICATE_CHECKER_TYPE_FAILURE_CHECK DuplicateCheckerType = 255 ) diff --git a/pkg/duplicatechecker/in_memory_duplicate_checker.go b/pkg/duplicatechecker/in_memory_duplicate_checker.go index 0f68d695..eed54f43 100644 --- a/pkg/duplicatechecker/in_memory_duplicate_checker.go +++ b/pkg/duplicatechecker/in_memory_duplicate_checker.go @@ -42,6 +42,11 @@ func (c *InMemoryDuplicateChecker) SetSubmissionRemark(checkerType DuplicateChec c.cache.Set(c.getCacheKey(checkerType, uid, identification), remark, cache.DefaultExpiration) } +// SetSubmissionRemarkWithCustomExpiration saves the identification and remark to in-memory cache with custom expiration time +func (c *InMemoryDuplicateChecker) SetSubmissionRemarkWithCustomExpiration(checkerType DuplicateCheckerType, uid int64, identification string, remark string, expiration time.Duration) { + c.cache.Set(c.getCacheKey(checkerType, uid, identification), remark, expiration) +} + // RemoveSubmissionRemark removes the identification and remark in in-memory cache func (c *InMemoryDuplicateChecker) RemoveSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string) { c.cache.Delete(c.getCacheKey(checkerType, uid, identification)) diff --git a/pkg/errs/error.go b/pkg/errs/error.go index 41d463df..e11024a6 100644 --- a/pkg/errs/error.go +++ b/pkg/errs/error.go @@ -41,6 +41,8 @@ const ( NormalSubcategoryUserCustomExchangeRate = 13 NormalSubcategoryModelContextProtocol = 14 NormalSubcategoryLargeLanguageModel = 15 + NormalSubcategoryUserExternalAuth = 16 + NormalSubcategoryOAuth2 = 17 ) // Error represents the specific error returned to user diff --git a/pkg/errs/external_auth.go b/pkg/errs/external_auth.go new file mode 100644 index 00000000..934c841c --- /dev/null +++ b/pkg/errs/external_auth.go @@ -0,0 +1,10 @@ +package errs + +import ( + "net/http" +) + +// Error codes related to user external authentication +var ( + ErrUserExternalAuthNotFound = NewNormalError(NormalSubcategoryUserExternalAuth, 0, http.StatusBadRequest, "user external auth is not found") +) diff --git a/pkg/errs/oauth2.go b/pkg/errs/oauth2.go new file mode 100644 index 00000000..c9058b3f --- /dev/null +++ b/pkg/errs/oauth2.go @@ -0,0 +1,19 @@ +package errs + +import ( + "net/http" +) + +// Error codes related to oauth 2.0 +var ( + ErrOAuth2NotEnabled = NewNormalError(NormalSubcategoryOAuth2, 0, http.StatusUnauthorized, "oauth 2.0 not enabled") + ErrOAuth2AutoRegistrationNotEnabled = NewNormalError(NormalSubcategoryOAuth2, 1, http.StatusUnauthorized, "oauth 2.0 auto registration not enabled") + ErrInvalidOAuth2LoginRequest = NewNormalError(NormalSubcategoryOAuth2, 2, http.StatusUnauthorized, "invalid oauth 2.0 login request") + ErrInvalidOAuth2Callback = NewNormalError(NormalSubcategoryOAuth2, 3, http.StatusUnauthorized, "invalid oauth 2.0 callback") + ErrMissingOAuth2State = NewNormalError(NormalSubcategoryOAuth2, 4, http.StatusUnauthorized, "missing state in oauth 2.0 callback") + ErrMissingOAuth2Code = NewNormalError(NormalSubcategoryOAuth2, 5, http.StatusUnauthorized, "missing code in oauth 2.0 callback") + ErrInvalidOAuth2State = NewNormalError(NormalSubcategoryOAuth2, 6, http.StatusUnauthorized, "invalid state in oauth 2.0 callback") + ErrCannotRetrieveOAuth2Token = NewNormalError(NormalSubcategoryOAuth2, 7, http.StatusUnauthorized, "cannot retrieve oauth 2.0 token") + ErrInvalidOAuth2Token = NewNormalError(NormalSubcategoryOAuth2, 8, http.StatusUnauthorized, "invalid oauth 2.0 token") + ErrCannotRetrieveUserInfo = NewNormalError(NormalSubcategoryOAuth2, 9, http.StatusUnauthorized, "cannot retrieve user info from oauth 2.0 provider") +) diff --git a/pkg/errs/setting.go b/pkg/errs/setting.go index 8fc901b0..dee92866 100644 --- a/pkg/errs/setting.go +++ b/pkg/errs/setting.go @@ -26,4 +26,8 @@ var ( ErrInvalidIpAddressPattern = NewSystemError(SystemSubcategorySetting, 19, http.StatusInternalServerError, "invalid ip address pattern") ErrInvalidLLMProvider = NewSystemError(SystemSubcategorySetting, 20, http.StatusInternalServerError, "invalid llm provider") ErrInvalidLLMModelId = NewSystemError(SystemSubcategorySetting, 21, http.StatusInternalServerError, "invalid llm model id") + ErrInvalidOAuth2Config = NewSystemError(SystemSubcategorySetting, 22, http.StatusInternalServerError, "invalid oauth 2.0 config") + ErrInvalidOAuth2UserIdentifier = NewSystemError(SystemSubcategorySetting, 23, http.StatusInternalServerError, "invalid oauth 2.0 user identifier") + ErrInvalidOAuth2Provider = NewSystemError(SystemSubcategorySetting, 24, http.StatusInternalServerError, "invalid oauth 2.0 provider") + ErrInvalidOAuth2StateExpiredTime = NewSystemError(SystemSubcategorySetting, 25, http.StatusInternalServerError, "invalid oauth 2.0 state expired time") ) diff --git a/pkg/errs/user.go b/pkg/errs/user.go index 07d1c221..98a111e9 100644 --- a/pkg/errs/user.go +++ b/pkg/errs/user.go @@ -38,4 +38,5 @@ var ( ErrUserAvatarExtensionInvalid = NewNormalError(NormalSubcategoryUser, 29, http.StatusNotFound, "user avatar file extension invalid") ErrExceedMaxUserAvatarFileSize = NewNormalError(NormalSubcategoryUser, 30, http.StatusBadRequest, "exceed the maximum size of user avatar file") ErrNotPermittedToPerformThisAction = NewNormalError(NormalSubcategoryUser, 31, http.StatusBadRequest, "not permitted to perform this action") + ErrCannotLoginByPassword = NewNormalError(NormalSubcategoryUser, 32, http.StatusBadRequest, "cannot login by password") ) diff --git a/pkg/exchangerates/common_http_exchange_rates_data_provider.go b/pkg/exchangerates/common_http_exchange_rates_data_provider.go index 8c79501f..04e5c1e5 100644 --- a/pkg/exchangerates/common_http_exchange_rates_data_provider.go +++ b/pkg/exchangerates/common_http_exchange_rates_data_provider.go @@ -1,11 +1,9 @@ package exchangerates import ( - "crypto/tls" "io" "net/http" "sort" - "time" "github.com/mayswind/ezbookkeeping/pkg/core" "github.com/mayswind/ezbookkeeping/pkg/errs" @@ -28,23 +26,10 @@ type HttpExchangeRatesDataSource interface { type CommonHttpExchangeRatesDataProvider struct { ExchangeRatesDataProvider dataSource HttpExchangeRatesDataSource + httpClient *http.Client } func (e *CommonHttpExchangeRatesDataProvider) GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error) { - transport := http.DefaultTransport.(*http.Transport).Clone() - utils.SetProxyUrl(transport, currentConfig.ExchangeRatesProxy) - - if currentConfig.ExchangeRatesSkipTLSVerify { - transport.TLSClientConfig = &tls.Config{ - InsecureSkipVerify: true, - } - } - - client := &http.Client{ - Transport: transport, - Timeout: time.Duration(currentConfig.ExchangeRatesRequestTimeout) * time.Millisecond, - } - requests, err := e.dataSource.BuildRequests() if err != nil { @@ -56,14 +41,7 @@ func (e *CommonHttpExchangeRatesDataProvider) GetLatestExchangeRates(c core.Cont for i := 0; i < len(requests); i++ { req := requests[i] - - if len(req.Header.Values("User-Agent")) < 1 { - req.Header.Set("User-Agent", settings.GetUserAgent()) - } else if req.Header.Get("User-Agent") == "" { - req.Header.Del("User-Agent") - } - - resp, err := client.Do(req) + resp, err := e.httpClient.Do(req) if err != nil { log.Errorf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] failed to request latest exchange rate data for user \"uid:%d\", because %s", uid, err.Error()) @@ -76,7 +54,7 @@ func (e *CommonHttpExchangeRatesDataProvider) GetLatestExchangeRates(c core.Cont log.Debugf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] response#%d is %s", i, body) if resp.StatusCode != 200 { - log.Errorf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] failed to get latest exchange rate data response for user \"uid:%d\", because response code is not %d", uid, resp.StatusCode) + log.Errorf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] failed to get latest exchange rate data response for user \"uid:%d\", because response code is %d", uid, resp.StatusCode) return nil, errs.ErrFailedToRequestRemoteApi } @@ -125,8 +103,9 @@ func (e *CommonHttpExchangeRatesDataProvider) GetLatestExchangeRates(c core.Cont return finalExchangeRateResponse, nil } -func newCommonHttpExchangeRatesDataProvider(dataSource HttpExchangeRatesDataSource) *CommonHttpExchangeRatesDataProvider { +func newCommonHttpExchangeRatesDataProvider(config *settings.Config, dataSource HttpExchangeRatesDataSource) *CommonHttpExchangeRatesDataProvider { return &CommonHttpExchangeRatesDataProvider{ dataSource: dataSource, + httpClient: utils.NewHttpClient(config.ExchangeRatesRequestTimeout, config.ExchangeRatesProxy, config.ExchangeRatesSkipTLSVerify, settings.GetUserAgent()), } } diff --git a/pkg/exchangerates/exchange_rates_data_provider_container.go b/pkg/exchangerates/exchange_rates_data_provider_container.go index 9b6427b9..65d641fa 100644 --- a/pkg/exchangerates/exchange_rates_data_provider_container.go +++ b/pkg/exchangerates/exchange_rates_data_provider_container.go @@ -20,55 +20,55 @@ var ( // InitializeExchangeRatesDataSource initializes the current exchange rates data source according to the config func InitializeExchangeRatesDataSource(config *settings.Config) error { if config.ExchangeRatesDataSource == settings.ReserveBankOfAustraliaDataSource { - Container.current = newCommonHttpExchangeRatesDataProvider(&ReserveBankOfAustraliaDataSource{}) + Container.current = newCommonHttpExchangeRatesDataProvider(config, &ReserveBankOfAustraliaDataSource{}) return nil } else if config.ExchangeRatesDataSource == settings.BankOfCanadaDataSource { - Container.current = newCommonHttpExchangeRatesDataProvider(&BankOfCanadaDataSource{}) + Container.current = newCommonHttpExchangeRatesDataProvider(config, &BankOfCanadaDataSource{}) return nil } else if config.ExchangeRatesDataSource == settings.CzechNationalBankDataSource { - Container.current = newCommonHttpExchangeRatesDataProvider(&CzechNationalBankDataSource{}) + Container.current = newCommonHttpExchangeRatesDataProvider(config, &CzechNationalBankDataSource{}) return nil } else if config.ExchangeRatesDataSource == settings.DanmarksNationalbankDataSource { - Container.current = newCommonHttpExchangeRatesDataProvider(&DanmarksNationalbankDataSource{}) + Container.current = newCommonHttpExchangeRatesDataProvider(config, &DanmarksNationalbankDataSource{}) return nil } else if config.ExchangeRatesDataSource == settings.EuroCentralBankDataSource { - Container.current = newCommonHttpExchangeRatesDataProvider(&EuroCentralBankDataSource{}) + Container.current = newCommonHttpExchangeRatesDataProvider(config, &EuroCentralBankDataSource{}) return nil } else if config.ExchangeRatesDataSource == settings.NationalBankOfGeorgiaDataSource { - Container.current = newCommonHttpExchangeRatesDataProvider(&NationalBankOfGeorgiaDataSource{}) + Container.current = newCommonHttpExchangeRatesDataProvider(config, &NationalBankOfGeorgiaDataSource{}) return nil } else if config.ExchangeRatesDataSource == settings.CentralBankOfHungaryDataSource { - Container.current = newCommonHttpExchangeRatesDataProvider(&CentralBankOfHungaryDataSource{}) + Container.current = newCommonHttpExchangeRatesDataProvider(config, &CentralBankOfHungaryDataSource{}) return nil } else if config.ExchangeRatesDataSource == settings.BankOfIsraelDataSource { - Container.current = newCommonHttpExchangeRatesDataProvider(&BankOfIsraelDataSource{}) + Container.current = newCommonHttpExchangeRatesDataProvider(config, &BankOfIsraelDataSource{}) return nil } else if config.ExchangeRatesDataSource == settings.CentralBankOfMyanmarDataSource { - Container.current = newCommonHttpExchangeRatesDataProvider(&CentralBankOfMyanmarDataSource{}) + Container.current = newCommonHttpExchangeRatesDataProvider(config, &CentralBankOfMyanmarDataSource{}) return nil } else if config.ExchangeRatesDataSource == settings.NorgesBankDataSource { - Container.current = newCommonHttpExchangeRatesDataProvider(&NorgesBankDataSource{}) + Container.current = newCommonHttpExchangeRatesDataProvider(config, &NorgesBankDataSource{}) return nil } else if config.ExchangeRatesDataSource == settings.NationalBankOfPolandDataSource { - Container.current = newCommonHttpExchangeRatesDataProvider(&NationalBankOfPolandDataSource{}) + Container.current = newCommonHttpExchangeRatesDataProvider(config, &NationalBankOfPolandDataSource{}) return nil } else if config.ExchangeRatesDataSource == settings.NationalBankOfRomaniaDataSource { - Container.current = newCommonHttpExchangeRatesDataProvider(&NationalBankOfRomaniaDataSource{}) + Container.current = newCommonHttpExchangeRatesDataProvider(config, &NationalBankOfRomaniaDataSource{}) return nil } else if config.ExchangeRatesDataSource == settings.BankOfRussiaDataSource { - Container.current = newCommonHttpExchangeRatesDataProvider(&BankOfRussiaDataSource{}) + Container.current = newCommonHttpExchangeRatesDataProvider(config, &BankOfRussiaDataSource{}) return nil } else if config.ExchangeRatesDataSource == settings.SwissNationalBankDataSource { - Container.current = newCommonHttpExchangeRatesDataProvider(&SwissNationalBankDataSource{}) + Container.current = newCommonHttpExchangeRatesDataProvider(config, &SwissNationalBankDataSource{}) return nil } else if config.ExchangeRatesDataSource == settings.NationalBankOfUkraineDataSource { - Container.current = newCommonHttpExchangeRatesDataProvider(&NationalBankOfUkraineDataSource{}) + Container.current = newCommonHttpExchangeRatesDataProvider(config, &NationalBankOfUkraineDataSource{}) return nil } else if config.ExchangeRatesDataSource == settings.CentralBankOfUzbekistanDataSource { - Container.current = newCommonHttpExchangeRatesDataProvider(&CentralBankOfUzbekistanDataSource{}) + Container.current = newCommonHttpExchangeRatesDataProvider(config, &CentralBankOfUzbekistanDataSource{}) return nil } else if config.ExchangeRatesDataSource == settings.InternationalMonetaryFundDataSource { - Container.current = newCommonHttpExchangeRatesDataProvider(&InternationalMonetaryFundDataSource{}) + Container.current = newCommonHttpExchangeRatesDataProvider(config, &InternationalMonetaryFundDataSource{}) return nil } else if config.ExchangeRatesDataSource == settings.UserCustomExchangeRatesDataSource { Container.current = newUserCustomExchangeRatesDataProvider() diff --git a/pkg/llm/provider/common/common_http_large_language_model_provider.go b/pkg/llm/provider/common/common_http_large_language_model_provider.go index 91384360..89a0b588 100644 --- a/pkg/llm/provider/common/common_http_large_language_model_provider.go +++ b/pkg/llm/provider/common/common_http_large_language_model_provider.go @@ -1,11 +1,9 @@ package common import ( - "crypto/tls" "io" "net/http" "strings" - "time" "github.com/mayswind/ezbookkeeping/pkg/core" "github.com/mayswind/ezbookkeeping/pkg/errs" @@ -28,7 +26,8 @@ type HttpLargeLanguageModelAdapter interface { // CommonHttpLargeLanguageModelProvider defines the structure of common http large language model provider type CommonHttpLargeLanguageModelProvider struct { provider.LargeLanguageModelProvider - adapter HttpLargeLanguageModelAdapter + adapter HttpLargeLanguageModelAdapter + httpClient *http.Client } // GetJsonResponse returns the json response from common http large language model provider @@ -51,20 +50,6 @@ func (p *CommonHttpLargeLanguageModelProvider) GetJsonResponse(c core.Context, u } func (p *CommonHttpLargeLanguageModelProvider) getTextualResponse(c core.Context, uid int64, currentLLMConfig *settings.LLMConfig, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) (*data.LargeLanguageModelTextualResponse, error) { - transport := http.DefaultTransport.(*http.Transport).Clone() - utils.SetProxyUrl(transport, currentLLMConfig.LargeLanguageModelAPIProxy) - - if currentLLMConfig.LargeLanguageModelAPISkipTLSVerify { - transport.TLSClientConfig = &tls.Config{ - InsecureSkipVerify: true, - } - } - - client := &http.Client{ - Transport: transport, - Timeout: time.Duration(currentLLMConfig.LargeLanguageModelAPIRequestTimeout) * time.Millisecond, - } - httpRequest, err := p.adapter.BuildTextualRequest(c, uid, request, responseType) if err != nil { @@ -72,9 +57,7 @@ func (p *CommonHttpLargeLanguageModelProvider) getTextualResponse(c core.Context return nil, errs.ErrFailedToRequestRemoteApi } - httpRequest.Header.Set("User-Agent", settings.GetUserAgent()) - - resp, err := client.Do(httpRequest) + resp, err := p.httpClient.Do(httpRequest) if err != nil { log.Errorf(c, "[common_http_large_language_model_provider.getTextualResponse] failed to request large language model api for user \"uid:%d\", because %s", uid, err.Error()) @@ -95,8 +78,9 @@ func (p *CommonHttpLargeLanguageModelProvider) getTextualResponse(c core.Context } // NewCommonHttpLargeLanguageModelProvider creates a http adapter based large language model provider instance -func NewCommonHttpLargeLanguageModelProvider(adapter HttpLargeLanguageModelAdapter) *CommonHttpLargeLanguageModelProvider { +func NewCommonHttpLargeLanguageModelProvider(llmConfig *settings.LLMConfig, adapter HttpLargeLanguageModelAdapter) *CommonHttpLargeLanguageModelProvider { return &CommonHttpLargeLanguageModelProvider{ - adapter: adapter, + adapter: adapter, + httpClient: utils.NewHttpClient(llmConfig.LargeLanguageModelAPIRequestTimeout, llmConfig.LargeLanguageModelAPIProxy, llmConfig.LargeLanguageModelAPISkipTLSVerify, settings.GetUserAgent()), } } diff --git a/pkg/llm/provider/googleai/google_ai_large_language_model_adapter.go b/pkg/llm/provider/googleai/google_ai_large_language_model_adapter.go index 4381c0e0..4bf06a45 100644 --- a/pkg/llm/provider/googleai/google_ai_large_language_model_adapter.go +++ b/pkg/llm/provider/googleai/google_ai_large_language_model_adapter.go @@ -160,7 +160,7 @@ func (p *GoogleAILargeLanguageModelAdapter) buildJsonRequestBody(c core.Context, // NewGoogleAILargeLanguageModelProvider creates a new Google AI large language model provider instance func NewGoogleAILargeLanguageModelProvider(llmConfig *settings.LLMConfig) provider.LargeLanguageModelProvider { - return common.NewCommonHttpLargeLanguageModelProvider(&GoogleAILargeLanguageModelAdapter{ + return common.NewCommonHttpLargeLanguageModelProvider(llmConfig, &GoogleAILargeLanguageModelAdapter{ GoogleAIAPIKey: llmConfig.GoogleAIAPIKey, GoogleAIModelID: llmConfig.GoogleAIModelID, }) diff --git a/pkg/llm/provider/ollama/ollama_large_language_model_adapter.go b/pkg/llm/provider/ollama/ollama_large_language_model_adapter.go index b1770bc7..ee8e8042 100644 --- a/pkg/llm/provider/ollama/ollama_large_language_model_adapter.go +++ b/pkg/llm/provider/ollama/ollama_large_language_model_adapter.go @@ -159,7 +159,7 @@ func (p *OllamaLargeLanguageModelAdapter) getOllamaRequestUrl() string { // NewOllamaLargeLanguageModelProvider creates a new Ollama large language model provider instance func NewOllamaLargeLanguageModelProvider(llmConfig *settings.LLMConfig) provider.LargeLanguageModelProvider { - return common.NewCommonHttpLargeLanguageModelProvider(&OllamaLargeLanguageModelAdapter{ + return common.NewCommonHttpLargeLanguageModelProvider(llmConfig, &OllamaLargeLanguageModelAdapter{ OllamaServerURL: llmConfig.OllamaServerURL, OllamaModelID: llmConfig.OllamaModelID, }) diff --git a/pkg/llm/provider/openai/openai_chat_completions_api_provider.go b/pkg/llm/provider/openai/openai_chat_completions_api_provider.go index 8bf60c54..6a7be388 100644 --- a/pkg/llm/provider/openai/openai_chat_completions_api_provider.go +++ b/pkg/llm/provider/openai/openai_chat_completions_api_provider.go @@ -37,7 +37,7 @@ func (p *OpenAIOfficialChatCompletionsAPIProvider) GetModelID() string { // NewOpenAILargeLanguageModelProvider creates a new OpenAI large language model provider instance func NewOpenAILargeLanguageModelProvider(llmConfig *settings.LLMConfig) provider.LargeLanguageModelProvider { - return newCommonOpenAIChatCompletionsAPILargeLanguageModelAdapter(&OpenAIOfficialChatCompletionsAPIProvider{ + return newCommonOpenAIChatCompletionsAPILargeLanguageModelAdapter(llmConfig, &OpenAIOfficialChatCompletionsAPIProvider{ OpenAIAPIKey: llmConfig.OpenAIAPIKey, OpenAIModelID: llmConfig.OpenAIModelID, }) diff --git a/pkg/llm/provider/openai/openai_common_compatible_large_language_model_adapter.go b/pkg/llm/provider/openai/openai_common_compatible_large_language_model_adapter.go index fb9a1c92..a8d144a3 100644 --- a/pkg/llm/provider/openai/openai_common_compatible_large_language_model_adapter.go +++ b/pkg/llm/provider/openai/openai_common_compatible_large_language_model_adapter.go @@ -14,6 +14,7 @@ import ( "github.com/mayswind/ezbookkeeping/pkg/llm/provider" "github.com/mayswind/ezbookkeeping/pkg/llm/provider/common" "github.com/mayswind/ezbookkeeping/pkg/log" + "github.com/mayswind/ezbookkeeping/pkg/settings" ) // OpenAIChatCompletionsAPIProvider defines the structure of OpenAI chat completions API provider @@ -212,8 +213,8 @@ func (p *CommonOpenAIChatCompletionsAPILargeLanguageModelAdapter) buildJsonReque return requestBodyBytes, nil } -func newCommonOpenAIChatCompletionsAPILargeLanguageModelAdapter(apiProvider OpenAIChatCompletionsAPIProvider) provider.LargeLanguageModelProvider { - return common.NewCommonHttpLargeLanguageModelProvider(&CommonOpenAIChatCompletionsAPILargeLanguageModelAdapter{ +func newCommonOpenAIChatCompletionsAPILargeLanguageModelAdapter(llmConfig *settings.LLMConfig, apiProvider OpenAIChatCompletionsAPIProvider) provider.LargeLanguageModelProvider { + return common.NewCommonHttpLargeLanguageModelProvider(llmConfig, &CommonOpenAIChatCompletionsAPILargeLanguageModelAdapter{ apiProvider: apiProvider, }) } diff --git a/pkg/llm/provider/openai/openai_compatible_chat_completions_api_provider.go b/pkg/llm/provider/openai/openai_compatible_chat_completions_api_provider.go index 09f32021..066502ee 100644 --- a/pkg/llm/provider/openai/openai_compatible_chat_completions_api_provider.go +++ b/pkg/llm/provider/openai/openai_compatible_chat_completions_api_provider.go @@ -51,7 +51,7 @@ func (p *OpenAICompatibleChatCompletionsAPIProvider) getFinalChatCompletionsRequ // NewOpenAICompatibleLargeLanguageModelProvider creates a new OpenAI compatible large language model provider instance func NewOpenAICompatibleLargeLanguageModelProvider(llmConfig *settings.LLMConfig) provider.LargeLanguageModelProvider { - return newCommonOpenAIChatCompletionsAPILargeLanguageModelAdapter(&OpenAICompatibleChatCompletionsAPIProvider{ + return newCommonOpenAIChatCompletionsAPILargeLanguageModelAdapter(llmConfig, &OpenAICompatibleChatCompletionsAPIProvider{ OpenAICompatibleBaseURL: llmConfig.OpenAICompatibleBaseURL, OpenAICompatibleAPIKey: llmConfig.OpenAICompatibleAPIKey, OpenAICompatibleModelID: llmConfig.OpenAICompatibleModelID, diff --git a/pkg/llm/provider/openai/openrouter_chat_completions_api_provider.go b/pkg/llm/provider/openai/openrouter_chat_completions_api_provider.go index 2c1c7962..6edd6bb3 100644 --- a/pkg/llm/provider/openai/openrouter_chat_completions_api_provider.go +++ b/pkg/llm/provider/openai/openrouter_chat_completions_api_provider.go @@ -39,7 +39,7 @@ func (p *OpenRouterChatCompletionsAPIProvider) GetModelID() string { // NewOpenRouterLargeLanguageModelProvider creates a new OpenRouter large language model provider instance func NewOpenRouterLargeLanguageModelProvider(llmConfig *settings.LLMConfig) provider.LargeLanguageModelProvider { - return newCommonOpenAIChatCompletionsAPILargeLanguageModelAdapter(&OpenRouterChatCompletionsAPIProvider{ + return newCommonOpenAIChatCompletionsAPILargeLanguageModelAdapter(llmConfig, &OpenRouterChatCompletionsAPIProvider{ OpenRouterAPIKey: llmConfig.OpenRouterAPIKey, OpenRouterModelID: llmConfig.OpenRouterModelID, }) diff --git a/pkg/middlewares/authorization.go b/pkg/middlewares/authorization.go index c3629c1c..2ec6efe5 100644 --- a/pkg/middlewares/authorization.go +++ b/pkg/middlewares/authorization.go @@ -111,6 +111,25 @@ func JWTMCPAuthorization(c *core.WebContext) { c.Next() } +// JWTOAuth2CallbackAuthorization verifies whether current request is OAuth 2.0 callback +func JWTOAuth2CallbackAuthorization(c *core.WebContext) { + claims, err := getTokenClaims(c, TOKEN_SOURCE_TYPE_HEADER) + + if err != nil { + utils.PrintJsonErrorResult(c, errs.ErrTokenExpired) + return + } + + if claims.Type != core.USER_TOKEN_TYPE_OAUTH2_CALLBACK && claims.Type != core.USER_TOKEN_TYPE_OAUTH2_CALLBACK_REQUIRE_VERIFY { + log.Warnf(c, "[authorization.JWTOAuth2CallbackAuthorization] user \"uid:%d\" token is not for oauth 2.0 callback request", claims.Uid) + utils.PrintJsonErrorResult(c, errs.ErrCurrentInvalidToken) + return + } + + c.SetTokenClaims(claims) + c.Next() +} + func jwtAuthorization(c *core.WebContext, source TokenSourceType) { claims, err := getTokenClaims(c, source) diff --git a/pkg/models/oauth2.go b/pkg/models/oauth2.go new file mode 100644 index 00000000..d2504fae --- /dev/null +++ b/pkg/models/oauth2.go @@ -0,0 +1,19 @@ +package models + +// OAuth2LoginRequest represents all parameters of OAuth 2.0 login request +type OAuth2LoginRequest struct { + Platform string `form:"platform" binding:"required"` + ClientSessionId string `form:"client_session_id" binding:"required"` +} + +// OAuth2CallbackRequest represents all parameters of OAuth 2.0 callback request +type OAuth2CallbackRequest struct { + State string `form:"state"` + Code string `form:"code"` +} + +// OAuth2CallbackLoginRequest represents all parameters of OAuth 2.0 callback login request +type OAuth2CallbackLoginRequest struct { + Provider string `json:"provider" binding:"required,notBlank"` + Password string `json:"password" binding:"omitempty,min=6,max=128"` +} diff --git a/pkg/models/user_external_auth.go b/pkg/models/user_external_auth.go new file mode 100644 index 00000000..49fcc406 --- /dev/null +++ b/pkg/models/user_external_auth.go @@ -0,0 +1,17 @@ +package models + +import "github.com/mayswind/ezbookkeeping/pkg/core" + +// UserExternalAuth represents user external auth data stored in database +type UserExternalAuth struct { + Uid int64 `xorm:"PK"` + ExternalAuthType core.UserExternalAuthType `xorm:"VARCHAR(32) PK UNIQUE(uqe_userexternalauth_authtype_username) UNIQUE(uqe_userexternalauth_authtype_email)"` + ExternalUsername string `xorm:"VARCHAR(32) UNIQUE(uqe_userexternalauth_authtype_username) NOT NULL"` + ExternalEmail string `xorm:"VARCHAR(100) UNIQUE(uqe_userexternalauth_authtype_email) NOT NULL"` + CreatedUnixTime int64 +} + +// UserExternalAuthRevokeRequest represents all parameters of user external auth revoke request +type UserExternalAuthRevokeRequest struct { + ExternalAuthType core.UserExternalAuthType `json:"externalAuthType" binding:"required,notBlank"` +} diff --git a/pkg/services/tokens.go b/pkg/services/tokens.go index f32f673b..a0f0bc1d 100644 --- a/pkg/services/tokens.go +++ b/pkg/services/tokens.go @@ -133,6 +133,18 @@ func (s *TokenService) CreateMCPTokenViaCli(c *core.CliContext, user *models.Use return token, tokenRecord, err } +// CreateOAuth2CallbackRequireVerifyToken generates a new OAuth 2.0 callback token requiring user to verify and saves to database +func (s *TokenService) CreateOAuth2CallbackRequireVerifyToken(c *core.WebContext, user *models.User) (string, *core.UserTokenClaims, error) { + token, claims, _, err := s.createToken(c, user, core.USER_TOKEN_TYPE_OAUTH2_CALLBACK_REQUIRE_VERIFY, s.getUserAgent(c), s.CurrentConfig().TemporaryTokenExpiredTimeDuration) + return token, claims, err +} + +// CreateOAuth2CallbackToken generates a new OAuth 2.0 callback token and saves to database +func (s *TokenService) CreateOAuth2CallbackToken(c *core.WebContext, user *models.User) (string, *core.UserTokenClaims, error) { + token, claims, _, err := s.createToken(c, user, core.USER_TOKEN_TYPE_OAUTH2_CALLBACK, s.getUserAgent(c), s.CurrentConfig().TemporaryTokenExpiredTimeDuration) + return token, claims, err +} + // UpdateTokenLastSeen updates the last seen time of specified token func (s *TokenService) UpdateTokenLastSeen(c core.Context, tokenRecord *models.TokenRecord) error { if tokenRecord.Uid <= 0 { diff --git a/pkg/services/user_external_auths.go b/pkg/services/user_external_auths.go new file mode 100644 index 00000000..815a1dbf --- /dev/null +++ b/pkg/services/user_external_auths.go @@ -0,0 +1,117 @@ +package services + +import ( + "time" + + "xorm.io/xorm" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/datastore" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/models" +) + +// UserExternalAuthService represents user external auth service +type UserExternalAuthService struct { + ServiceUsingDB +} + +// Initialize a user external auth service singleton instance +var ( + UserExternalAuths = &UserExternalAuthService{ + ServiceUsingDB: ServiceUsingDB{ + container: datastore.Container, + }, + } +) + +// GetUserAllExternalAuthsByUid returns the user all external auth list according to user uid +func (s *UserExternalAuthService) GetUserAllExternalAuthsByUid(c core.Context, uid int64) ([]*models.UserExternalAuth, error) { + if uid <= 0 { + return nil, errs.ErrUserIdInvalid + } + + var userExternalAuths []*models.UserExternalAuth + err := s.UserDB().NewSession(c).Where("uid=?", uid).Find(&userExternalAuths) + + return userExternalAuths, err +} + +// GetUserExternalAuthByUid returns the user external auth record by uid +func (s *UserExternalAuthService) GetUserExternalAuthByUid(c core.Context, uid int64, externalAuthType core.UserExternalAuthType) (*models.UserExternalAuth, error) { + if uid <= 0 { + return nil, errs.ErrUserIdInvalid + } + + userExternalAuth := &models.UserExternalAuth{} + has, err := s.UserDB().NewSession(c).Where("uid=? AND external_auth_type=?", uid, externalAuthType).Get(userExternalAuth) + + if err != nil { + return nil, err + } else if !has { + return nil, errs.ErrUserExternalAuthNotFound + } + + return userExternalAuth, err +} + +// GetUserExternalAuthByExternalUserName returns the user external auth record by external username +func (s *UserExternalAuthService) GetUserExternalAuthByExternalUserName(c core.Context, externalUserName string, externalAuthType core.UserExternalAuthType) (*models.UserExternalAuth, error) { + userExternalAuth := &models.UserExternalAuth{} + has, err := s.UserDB().NewSession(c).Where("external_auth_type=? AND external_username=?", externalAuthType, externalUserName).Get(userExternalAuth) + + if err != nil { + return nil, err + } else if !has { + return nil, errs.ErrUserExternalAuthNotFound + } + + return userExternalAuth, err +} + +// GetUserExternalAuthByExternalEmail returns the user external auth record by external email +func (s *UserExternalAuthService) GetUserExternalAuthByExternalEmail(c core.Context, externalEmail string, externalAuthType core.UserExternalAuthType) (*models.UserExternalAuth, error) { + userExternalAuth := &models.UserExternalAuth{} + has, err := s.UserDB().NewSession(c).Where("external_auth_type=? AND external_email=?", externalAuthType, externalEmail).Get(userExternalAuth) + + if err != nil { + return nil, err + } else if !has { + return nil, errs.ErrUserExternalAuthNotFound + } + + return userExternalAuth, err +} + +// CreateUserExternalAuth creates a new user external auth record in database +func (s *UserExternalAuthService) CreateUserExternalAuth(c core.Context, userExternalAuth *models.UserExternalAuth) error { + if userExternalAuth.Uid <= 0 { + return errs.ErrUserIdInvalid + } + + userExternalAuth.CreatedUnixTime = time.Now().Unix() + + return s.UserDB().DoTransaction(c, func(sess *xorm.Session) error { + _, err := sess.Insert(userExternalAuth) + return err + }) +} + +// DeleteUserExternalAuth deletes given user external auth record from database +func (s *UserExternalAuthService) DeleteUserExternalAuth(c core.Context, uid int64, externalAuthType core.UserExternalAuthType) error { + if uid <= 0 { + return errs.ErrUserIdInvalid + } + + return s.UserDB().DoTransaction(c, func(sess *xorm.Session) error { + deletedRows, err := sess.Where("uid=? AND external_auth_type=?", uid, externalAuthType).Delete(&models.UserExternalAuth{}) + + if err != nil { + return err + } else if deletedRows < 1 { + return errs.ErrUserExternalAuthNotFound + } + + return nil + }) +} diff --git a/pkg/settings/setting.go b/pkg/settings/setting.go index 34cb2351..2d65cba8 100644 --- a/pkg/settings/setting.go +++ b/pkg/settings/setting.go @@ -85,6 +85,17 @@ const ( InMemoryDuplicateCheckerType string = "in_memory" ) +// OAuth 2.0 user identifier types +const ( + OAuth2UserIdentifierEmail string = "email" + OAuth2UserIdentifierUsername string = "username" +) + +// OAuth 2.0 rovider types +const ( + OAuth2ProviderNextcloud string = "nextcloud" +) + // Map provider types const ( OpenStreetMapProvider string = "openstreetmap" @@ -164,6 +175,9 @@ const ( defaultMaxFailuresPerIpPerMinute uint32 = 5 defaultMaxFailuresPerUserPerMinute uint32 = 5 + defaultOAuth2StateExpiredTime uint32 = 300 // 5 minutes + defaultOAuth2RequestTimeout uint32 = 10000 // 10 seconds + defaultTransactionPictureFileMaxSize uint32 = 10485760 // 10MB defaultUserAvatarFileMaxSize uint32 = 1048576 // 1MB @@ -240,15 +254,8 @@ type LLMConfig struct { LargeLanguageModelAPISkipTLSVerify bool } -// TipConfig represents a tip setting config -type TipConfig struct { - Enabled bool - DefaultContent string - MultiLanguageContent map[string]string -} - -// NotificationConfig represents a notification setting config -type NotificationConfig struct { +// MultiLanguageContentConfig represents a multi-language content setting config +type MultiLanguageContentConfig struct { Enabled bool DefaultContent string MultiLanguageContent map[string]string @@ -351,9 +358,22 @@ type Config struct { MaxFailuresPerUserPerMinute uint32 // Auth + EnableInternalAuth bool + EnableOAuth2Login bool EnableTwoFactor bool EnableUserForgetPassword bool ForgetPasswordRequireVerifyEmail bool + OAuth2ClientID string + OAuth2ClientSecret string + OAuth2UserIdentifier string + OAuth2AutoRegister bool + OAuth2Provider string + OAuth2StateExpiredTime uint32 + OAuth2StateExpiredTimeDuration time.Duration + OAuth2RequestTimeout uint32 + OAuth2Proxy string + OAuth2SkipTLSVerify bool + OAuth2NextcloudBaseUrl string // User EnableUserRegister bool @@ -372,12 +392,12 @@ type Config struct { MaxImportFileSize uint32 // Tip - LoginPageTips TipConfig + LoginPageTips MultiLanguageContentConfig // Notification - AfterRegisterNotification NotificationConfig - AfterLoginNotification NotificationConfig - AfterOpenNotification NotificationConfig + AfterRegisterNotification MultiLanguageContentConfig + AfterLoginNotification MultiLanguageContentConfig + AfterOpenNotification MultiLanguageContentConfig // Map MapProvider string @@ -956,9 +976,47 @@ func loadSecurityConfiguration(config *Config, configFile *ini.File, sectionName } func loadAuthConfiguration(config *Config, configFile *ini.File, sectionName string) error { + config.EnableInternalAuth = getConfigItemBoolValue(configFile, sectionName, "enable_internal_auth", true) + config.EnableOAuth2Login = getConfigItemBoolValue(configFile, sectionName, "enable_oauth2_auth", false) config.EnableTwoFactor = getConfigItemBoolValue(configFile, sectionName, "enable_two_factor", true) config.EnableUserForgetPassword = getConfigItemBoolValue(configFile, sectionName, "enable_forget_password", false) config.ForgetPasswordRequireVerifyEmail = getConfigItemBoolValue(configFile, sectionName, "forget_password_require_email_verify", false) + config.OAuth2ClientID = getConfigItemStringValue(configFile, sectionName, "oauth2_client_id") + config.OAuth2ClientSecret = getConfigItemStringValue(configFile, sectionName, "oauth2_client_secret") + + oauth2UserIdentifier := getConfigItemStringValue(configFile, sectionName, "oauth2_user_identifier") + + if oauth2UserIdentifier == OAuth2UserIdentifierEmail { + config.OAuth2UserIdentifier = OAuth2UserIdentifierEmail + } else if oauth2UserIdentifier == OAuth2UserIdentifierUsername { + config.OAuth2UserIdentifier = OAuth2UserIdentifierUsername + } else { + return errs.ErrInvalidOAuth2UserIdentifier + } + + config.OAuth2AutoRegister = getConfigItemBoolValue(configFile, sectionName, "oauth2_auto_register", true) + + oauth2Provider := getConfigItemStringValue(configFile, sectionName, "oauth2_provider") + + if oauth2Provider == OAuth2ProviderNextcloud { + config.OAuth2Provider = OAuth2ProviderNextcloud + } else { + return errs.ErrInvalidOAuth2Provider + } + + config.OAuth2StateExpiredTime = getConfigItemUint32Value(configFile, sectionName, "oauth2_state_expired_time", defaultOAuth2StateExpiredTime) + + if config.OAuth2StateExpiredTime < 60 { + return errs.ErrInvalidOAuth2StateExpiredTime + } + + config.OAuth2StateExpiredTimeDuration = time.Duration(config.OAuth2StateExpiredTime) * time.Second + + config.OAuth2Proxy = getConfigItemStringValue(configFile, sectionName, "oauth2_proxy", "system") + config.OAuth2RequestTimeout = getConfigItemUint32Value(configFile, sectionName, "oauth2_request_timeout", defaultOAuth2RequestTimeout) + config.OAuth2SkipTLSVerify = getConfigItemBoolValue(configFile, sectionName, "oauth2_skip_tls_verify", false) + + config.OAuth2NextcloudBaseUrl = getConfigItemStringValue(configFile, sectionName, "nextcloud_base_url") return nil } @@ -996,15 +1054,15 @@ func loadDataConfiguration(config *Config, configFile *ini.File, sectionName str } func loadTipConfiguration(config *Config, configFile *ini.File, sectionName string) error { - config.LoginPageTips = getTipConfiguration(configFile, sectionName, "enable_tips_in_login_page", "login_page_tips_content") + config.LoginPageTips = getMultiLanguageContentConfig(configFile, sectionName, "enable_tips_in_login_page", "login_page_tips_content") return nil } func loadNotificationConfiguration(config *Config, configFile *ini.File, sectionName string) error { - config.AfterRegisterNotification = getNotificationConfiguration(configFile, sectionName, "enable_notification_after_register", "after_register_notification_content") - config.AfterLoginNotification = getNotificationConfiguration(configFile, sectionName, "enable_notification_after_login", "after_login_notification_content") - config.AfterOpenNotification = getNotificationConfiguration(configFile, sectionName, "enable_notification_after_open", "after_open_notification_content") + config.AfterRegisterNotification = getMultiLanguageContentConfig(configFile, sectionName, "enable_notification_after_register", "after_register_notification_content") + config.AfterLoginNotification = getMultiLanguageContentConfig(configFile, sectionName, "enable_notification_after_login", "after_login_notification_content") + config.AfterOpenNotification = getMultiLanguageContentConfig(configFile, sectionName, "enable_notification_after_open", "after_open_notification_content") return nil } @@ -1141,29 +1199,8 @@ func getFinalPath(workingPath, p string) (string, error) { return p, err } -func getTipConfiguration(configFile *ini.File, sectionName string, enableKey string, contentKey string) TipConfig { - config := TipConfig{ - Enabled: getConfigItemBoolValue(configFile, sectionName, enableKey, false), - DefaultContent: getConfigItemStringValue(configFile, sectionName, contentKey, ""), - MultiLanguageContent: make(map[string]string), - } - - for languageTag := range locales.AllLanguages { - multiLanguageContentKey := strings.ToLower(languageTag) - multiLanguageContentKey = strings.Replace(multiLanguageContentKey, "-", "_", -1) - multiLanguageContentKey = contentKey + "_" + multiLanguageContentKey - content := getConfigItemStringValue(configFile, sectionName, multiLanguageContentKey, "") - - if content != "" { - config.MultiLanguageContent[languageTag] = content - } - } - - return config -} - -func getNotificationConfiguration(configFile *ini.File, sectionName string, enableKey string, contentKey string) NotificationConfig { - config := NotificationConfig{ +func getMultiLanguageContentConfig(configFile *ini.File, sectionName string, enableKey string, contentKey string) MultiLanguageContentConfig { + config := MultiLanguageContentConfig{ Enabled: getConfigItemBoolValue(configFile, sectionName, enableKey, false), DefaultContent: getConfigItemStringValue(configFile, sectionName, contentKey, ""), MultiLanguageContent: make(map[string]string), diff --git a/pkg/settings/setting_container.go b/pkg/settings/setting_container.go index 24502cec..fe09f075 100644 --- a/pkg/settings/setting_container.go +++ b/pkg/settings/setting_container.go @@ -26,5 +26,9 @@ func (c *ConfigContainer) GetCurrentConfig() *Config { } func GetUserAgent() string { + if Version == "" { + return "ezBookkeeping" + } + return fmt.Sprintf("ezBookkeeping/%s", Version) } diff --git a/pkg/storage/webdav_storage.go b/pkg/storage/webdav_storage.go index 77d065a4..1b3db6a3 100644 --- a/pkg/storage/webdav_storage.go +++ b/pkg/storage/webdav_storage.go @@ -2,12 +2,10 @@ package storage import ( "bytes" - "crypto/tls" "io" "net/http" "path/filepath" "strings" - "time" "github.com/mayswind/ezbookkeeping/pkg/core" "github.com/mayswind/ezbookkeeping/pkg/errs" @@ -26,22 +24,9 @@ type WebDAVObjectStorage struct { // NewWebDAVObjectStorage returns a WebDAV object storage func NewWebDAVObjectStorage(config *settings.Config, pathPrefix string) (*WebDAVObjectStorage, error) { webDavConfig := config.WebDAVConfig - transport := http.DefaultTransport.(*http.Transport).Clone() - utils.SetProxyUrl(transport, webDavConfig.Proxy) - - if webDavConfig.SkipTLSVerify { - transport.TLSClientConfig = &tls.Config{ - InsecureSkipVerify: true, - } - } - - client := &http.Client{ - Transport: transport, - Timeout: time.Duration(webDavConfig.RequestTimeout) * time.Millisecond, - } storage := &WebDAVObjectStorage{ - httpClient: client, + httpClient: utils.NewHttpClient(webDavConfig.RequestTimeout, webDavConfig.Proxy, webDavConfig.SkipTLSVerify, settings.GetUserAgent()), webDavConfig: webDavConfig, rootPath: webDavConfig.RootPath, } diff --git a/pkg/utils/api.go b/pkg/utils/api.go index 75fd7e42..c64281d1 100644 --- a/pkg/utils/api.go +++ b/pkg/utils/api.go @@ -2,6 +2,7 @@ package utils import ( "encoding/json" + "errors" "net/http" "reflect" @@ -11,6 +12,22 @@ import ( "github.com/mayswind/ezbookkeeping/pkg/errs" ) +// GetDisplayErrorMessage returns the display error message for given error +func GetDisplayErrorMessage(err *errs.Error) string { + if err.Code() == errs.ErrIncompleteOrIncorrectSubmission.Code() && len(err.BaseError) > 0 { + var validationErrors validator.ValidationErrors + ok := errors.As(err.BaseError[0], &validationErrors) + + if ok { + for _, err := range validationErrors { + return getValidationErrorText(err) + } + } + } + + return err.Error() +} + // PrintJsonSuccessResult writes success response in json format to current http context func PrintJsonSuccessResult(c *core.WebContext, result any) { c.JSON(http.StatusOK, core.O{ @@ -32,23 +49,10 @@ func PrintDataSuccessResult(c *core.WebContext, contentType string, fileName str func PrintJsonErrorResult(c *core.WebContext, err *errs.Error) { c.SetResponseError(err) - errorMessage := err.Error() - - if err.Code() == errs.ErrIncompleteOrIncorrectSubmission.Code() && len(err.BaseError) > 0 { - validationErrors, ok := err.BaseError[0].(validator.ValidationErrors) - - if ok { - for _, err := range validationErrors { - errorMessage = getValidationErrorText(err) - break - } - } - } - result := core.O{ "success": false, "errorCode": err.Code(), - "errorMessage": errorMessage, + "errorMessage": GetDisplayErrorMessage(err), "path": c.Request.URL.Path, } @@ -68,19 +72,6 @@ func PrintJSONRPCSuccessResult(c *core.WebContext, jsonRPCRequest *core.JSONRPCR func PrintJSONRPCErrorResult(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest, err *errs.Error) { c.SetResponseError(err) - errorMessage := err.Error() - - if err.Code() == errs.ErrIncompleteOrIncorrectSubmission.Code() && len(err.BaseError) > 0 { - validationErrors, ok := err.BaseError[0].(validator.ValidationErrors) - - if ok { - for _, err := range validationErrors { - errorMessage = getValidationErrorText(err) - break - } - } - } - var id any if jsonRPCRequest != nil { @@ -97,27 +88,13 @@ func PrintJSONRPCErrorResult(c *core.WebContext, jsonRPCRequest *core.JSONRPCReq jsonRPCError = core.JSONRPCInvalidParamsError } - c.AbortWithStatusJSON(err.HttpStatusCode, core.NewJSONRPCErrorResponseWithCause(id, jsonRPCError, errorMessage)) + c.AbortWithStatusJSON(err.HttpStatusCode, core.NewJSONRPCErrorResponseWithCause(id, jsonRPCError, GetDisplayErrorMessage(err))) } // PrintDataErrorResult writes error response in custom content type to current http context func PrintDataErrorResult(c *core.WebContext, contentType string, err *errs.Error) { c.SetResponseError(err) - - errorMessage := err.Error() - - if err.Code() == errs.ErrIncompleteOrIncorrectSubmission.Code() && len(err.BaseError) > 0 { - validationErrors, ok := err.BaseError[0].(validator.ValidationErrors) - - if ok { - for _, err := range validationErrors { - errorMessage = getValidationErrorText(err) - break - } - } - } - - c.Data(err.HttpStatusCode, contentType, []byte(errorMessage)) + c.Data(err.HttpStatusCode, contentType, []byte(GetDisplayErrorMessage(err))) c.Abort() } @@ -149,23 +126,10 @@ func WriteEventStreamJsonSuccessResult(c *core.WebContext, result any) { func WriteEventStreamJsonErrorResult(c *core.WebContext, originalErr *errs.Error) { c.SetResponseError(originalErr) - errorMessage := originalErr.Error() - - if originalErr.Code() == errs.ErrIncompleteOrIncorrectSubmission.Code() && len(originalErr.BaseError) > 0 { - validationErrors, ok := originalErr.BaseError[0].(validator.ValidationErrors) - - if ok { - for _, err := range validationErrors { - errorMessage = getValidationErrorText(err) - break - } - } - } - result := core.O{ "success": false, "errorCode": originalErr.Code(), - "errorMessage": errorMessage, + "errorMessage": GetDisplayErrorMessage(originalErr), "path": c.Request.URL.Path, } diff --git a/pkg/utils/http.go b/pkg/utils/http.go index cc052aa6..d10b036d 100644 --- a/pkg/utils/http.go +++ b/pkg/utils/http.go @@ -1,10 +1,47 @@ package utils import ( + "crypto/tls" "net/http" "net/url" + "time" ) +type defaultTransport struct { + defaultUserAgent string + baseTransport http.RoundTripper +} + +func (t *defaultTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if len(req.Header.Values("User-Agent")) < 1 { + req.Header.Set("User-Agent", t.defaultUserAgent) + } else if req.Header.Get("User-Agent") == "" { + req.Header.Del("User-Agent") + } + + return t.baseTransport.RoundTrip(req) +} + +// NewHttpClient creates and returns a new http client with specified settings +func NewHttpClient(requestTimeout uint32, proxy string, skipTLSVerify bool, defaultUserAgent string) *http.Client { + baseTransport := http.DefaultTransport.(*http.Transport).Clone() + SetProxyUrl(baseTransport, proxy) + + if skipTLSVerify { + baseTransport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, + } + } + + return &http.Client{ + Transport: &defaultTransport{ + defaultUserAgent: defaultUserAgent, + baseTransport: baseTransport, + }, + Timeout: time.Duration(requestTimeout) * time.Millisecond, + } +} + // SetProxyUrl sets proxy url to http transport according to specified proxy setting func SetProxyUrl(transport *http.Transport, proxy string) { if proxy == "none" { diff --git a/src/consts/oauth2.ts b/src/consts/oauth2.ts new file mode 100644 index 00000000..e6e9b228 --- /dev/null +++ b/src/consts/oauth2.ts @@ -0,0 +1,3 @@ +export const OAUTH2_PROVIDER_DISPLAY_NAME: Record = { + 'nextcloud': 'Nextcloud' +} diff --git a/src/lib/server_settings.ts b/src/lib/server_settings.ts index 41ea3ff6..5de4e607 100644 --- a/src/lib/server_settings.ts +++ b/src/lib/server_settings.ts @@ -3,6 +3,14 @@ function getServerSetting(key: string): string | number | boolean | Record{ + return getServerSetting('ocn') as Record; +} + export function isMCPServerEnabled(): boolean { return getServerSetting('mcp') === 1; } diff --git a/src/lib/services.ts b/src/lib/services.ts index 7ef874b9..c5304f53 100644 --- a/src/lib/services.ts +++ b/src/lib/services.ts @@ -135,6 +135,9 @@ import type { UserProfileUpdateRequest, UserProfileUpdateResponse } from '@/models/user.ts'; +import type { + OAuth2CallbackLoginRequest +} from '@/models/oauth2.ts'; import type { UserApplicationCloudSettingsUpdateRequest } from '@/models/user_app_cloud_setting.ts'; @@ -265,6 +268,13 @@ export default { } }); }, + authorizeOAuth2: ({ req, token }: { req: OAuth2CallbackLoginRequest, token: string }): ApiResponsePromise => { + return axios.post>('oauth2/authorize.json', req, { + headers: { + Authorization: `Bearer ${token}` + } + }); + }, register: (req: UserRegisterRequest): ApiResponsePromise => { return axios.post>('register.json', req); }, @@ -695,6 +705,9 @@ export default { cancelRequest: (cancelableUuid: string) => { cancelableRequests[cancelableUuid] = true; }, + generateOAuth2LoginUrl: (platform: 'mobile' | 'desktop', clientSessionId: string): string => { + return `${getBasePath()}/oauth2/login?platform=${platform}&client_session_id=${clientSessionId}`; + }, generateQrCodeUrl: (qrCodeName: string): string => { return `${getBasePath()}${BASE_QRCODE_PATH}/${qrCodeName}.png`; }, diff --git a/src/locales/de.json b/src/locales/de.json index 0496accb..745bf5ee 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -108,6 +108,7 @@ }, "misc": { "multiTextJoinSeparator": ", ", + "loginWithCustomProvider": "Log in with {name}", "hoursBehindDefaultTimezone": "{hours} Stunde(n) hinter der Standardzeitzone", "hoursAheadOfDefaultTimezone": "{hours} Stunde(n) vor der Standardzeitzone", "hoursMinutesBehindDefaultTimezone": "{hours} Stunde(n) und {minutes} Minute(n) hinter der Standardzeitzone", @@ -129,7 +130,8 @@ "moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.", "clearTransactionsInAccountTip": "You CANNOT undo this action. This will clear your transactions data in {account}. Please enter your current password to confirm.", "accountActivationAndResendValidationEmailTip": "Ein Aktivierungslink wurde an Ihre E-Mail-Adresse gesendet: {email}. Wenn Sie die E-Mail nicht erhalten haben, geben Sie bitte das Passwort erneut ein und klicken Sie auf die Schaltfläche unten, um die Bestätigungs-E-Mail erneut zu senden.", - "resendValidationEmailTip": "Wenn Sie die E-Mail nicht erhalten haben, geben Sie bitte das Passwort erneut ein und klicken Sie auf die Schaltfläche unten, um die Bestätigungs-E-Mail an: {email} erneut zu senden." + "resendValidationEmailTip": "Wenn Sie die E-Mail nicht erhalten haben, geben Sie bitte das Passwort erneut ein und klicken Sie auf die Schaltfläche unten, um die Bestätigungs-E-Mail an: {email} erneut zu senden.", + "oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify." } }, "dataExport": { @@ -1086,6 +1088,7 @@ "user avatar file extension invalid": "Dateierweiterung des Benutzeravatars ist ungültig", "exceed the maximum size of user avatar file": "Hochgeladener Benutzeravatar überschreitet die maximal zulässige Dateigröße", "not permitted to perform this action": "Sie sind nicht berechtigt, diese Aktion auszuführen", + "cannot login by password": "You cannot login by password", "unauthorized access": "Unbefugter Zugriff", "current token is invalid": "Aktuelles Token ist ungültig", "current token is expired": "Aktuelles Token ist abgelaufen", @@ -1238,6 +1241,17 @@ "image for AI recognition is empty": "Image for AI recognition file is empty", "exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size", "no transaction information detected": "No transaction information detected", + "user external auth is not found": "User external authentication is not found", + "oauth 2.0 not enabled": "OAuth 2.0 is not enabled", + "oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled", + "invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request", + "invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback", + "missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback", + "missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback", + "invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback", + "cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token", + "invalid oauth 2.0 token": "Invalid OAuth 2.0 token", + "cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider", "query items cannot be blank": "Abfrageelemente dürfen nicht leer sein", "query items too much": "Zu viele Abfrageelemente", "query items have invalid item": "Ungültiges Element in Abfrageelementen", @@ -1391,6 +1405,7 @@ "Operation": "Vorgang", "Open": "Open", "Close": "Schließen", + "or": "or", "Submit": "Einreichen", "Add": "Hinzufügen", "Import": "Importieren", @@ -1572,7 +1587,10 @@ "This month or later": "Dieser Monat oder später", "This year or later": "Dieses Jahr oder später", "Log In": "Anmelden", + "Log in with OAuth 2.0": "Log in with OAuth 2.0", + "Log in with Connect ID": "Log in with Connect ID", "Click here to log in": "Hier klicken, um sich anzumelden", + "Logging in...": "Logging in...", "Back to login page": "Zurück zur Anmeldeseite", "Back to home page": "Zurück zur Startseite", "Don't have an account?": "Sie haben kein Konto?", diff --git a/src/locales/en.json b/src/locales/en.json index fc7b52a1..d7b54a52 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -108,6 +108,7 @@ }, "misc": { "multiTextJoinSeparator": ", ", + "loginWithCustomProvider": "Log in with {name}", "hoursBehindDefaultTimezone": "{hours} hour(s) behind default timezone", "hoursAheadOfDefaultTimezone": "{hours} hour(s) ahead of default timezone", "hoursMinutesBehindDefaultTimezone": "{hours} hour(s) and {minutes} minutes behind default timezone", @@ -129,7 +130,8 @@ "moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.", "clearTransactionsInAccountTip": "You CANNOT undo this action. This will clear your transactions data in {account}. Please enter your current password to confirm.", "accountActivationAndResendValidationEmailTip": "Account activation link has been sent to your email address: {email}, If you don't receive the mail, please fill password again and click the button below to resend the validation mail.", - "resendValidationEmailTip": "If you don't receive the mail, please fill password again and click the button below to resend the validation mail to: {email}" + "resendValidationEmailTip": "If you don't receive the mail, please fill password again and click the button below to resend the validation mail to: {email}", + "oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify." } }, "dataExport": { @@ -1086,6 +1088,7 @@ "user avatar file extension invalid": "User avatar file extension is invalid", "exceed the maximum size of user avatar file": "The uploaded user avatar exceeds the maximum allowed file size", "not permitted to perform this action": "You are not permitted to perform this action", + "cannot login by password": "You cannot login by password", "unauthorized access": "Unauthorized access", "current token is invalid": "Current token is invalid", "current token is expired": "Current token is expired", @@ -1238,6 +1241,17 @@ "image for AI recognition is empty": "Image for AI recognition file is empty", "exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size", "no transaction information detected": "No transaction information detected", + "user external auth is not found": "User external authentication is not found", + "oauth 2.0 not enabled": "OAuth 2.0 is not enabled", + "oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled", + "invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request", + "invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback", + "missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback", + "missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback", + "invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback", + "cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token", + "invalid oauth 2.0 token": "Invalid OAuth 2.0 token", + "cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider", "query items cannot be blank": "There are no query items", "query items too much": "There are too many query items", "query items have invalid item": "There is invalid item in query items", @@ -1391,6 +1405,7 @@ "Operation": "Operation", "Open": "Open", "Close": "Close", + "or": "or", "Submit": "Submit", "Add": "Add", "Import": "Import", @@ -1572,7 +1587,10 @@ "This month or later": "This month or later", "This year or later": "This year or later", "Log In": "Log In", + "Log in with OAuth 2.0": "Log in with OAuth 2.0", + "Log in with Connect ID": "Log in with Connect ID", "Click here to log in": "Click here to log in", + "Logging in...": "Logging in...", "Back to login page": "Back to login page", "Back to home page": "Back to home page", "Don't have an account?": "Don't have an account?", diff --git a/src/locales/es.json b/src/locales/es.json index 12fef65c..f29a3965 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -108,6 +108,7 @@ }, "misc": { "multiTextJoinSeparator": ", ", + "loginWithCustomProvider": "Log in with {name}", "hoursBehindDefaultTimezone": "{hours} hora(s) de retraso en la zona horaria predeterminada", "hoursAheadOfDefaultTimezone": "{hours} hora(s) por delante de la zona horaria predeterminada", "hoursMinutesBehindDefaultTimezone": "{hours} hora(s) y {minutes} minutos de retraso en la zona horaria predeterminada", @@ -129,7 +130,8 @@ "moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.", "clearTransactionsInAccountTip": "You CANNOT undo this action. This will clear your transactions data in {account}. Please enter your current password to confirm.", "accountActivationAndResendValidationEmailTip": "El enlace de activación de la cuenta se envió a su dirección de correo electrónico: {email}. Si no recibe el correo, ingrese la contraseña nuevamente y haga clic en el botón a continuación para reenviar el correo de validación.", - "resendValidationEmailTip": "Si no recibe el correo, complete nuevamente la contraseña y haga clic en el botón a continuación para reenviar el correo de validación a: {email}" + "resendValidationEmailTip": "Si no recibe el correo, complete nuevamente la contraseña y haga clic en el botón a continuación para reenviar el correo de validación a: {email}", + "oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify." } }, "dataExport": { @@ -1086,6 +1088,7 @@ "user avatar file extension invalid": "La extensión del archivo de avatar del usuario no es válida", "exceed the maximum size of user avatar file": "El avatar de usuario subido supera el tamaño de archivo máximo permitido", "not permitted to perform this action": "No tienes permiso para realizar esta acción.", + "cannot login by password": "You cannot login by password", "unauthorized access": "Acceso no autorizado", "current token is invalid": "El token actual no es válido", "current token is expired": "El token actual ha caducado", @@ -1238,6 +1241,17 @@ "image for AI recognition is empty": "Image for AI recognition file is empty", "exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size", "no transaction information detected": "No transaction information detected", + "user external auth is not found": "User external authentication is not found", + "oauth 2.0 not enabled": "OAuth 2.0 is not enabled", + "oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled", + "invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request", + "invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback", + "missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback", + "missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback", + "invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback", + "cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token", + "invalid oauth 2.0 token": "Invalid OAuth 2.0 token", + "cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider", "query items cannot be blank": "--", "query items too much": "--", "query items have invalid item": "Hay un elemento no válido en los elementos de consulta", @@ -1391,6 +1405,7 @@ "Operation": "Operación", "Open": "Open", "Close": "Cerrar", + "or": "or", "Submit": "Enviar", "Add": "Agregar", "Import": "Importar", @@ -1572,7 +1587,10 @@ "This month or later": "Este mes o más tarde", "This year or later": "Este año o más tarde", "Log In": "Acceso", + "Log in with OAuth 2.0": "Log in with OAuth 2.0", + "Log in with Connect ID": "Log in with Connect ID", "Click here to log in": "Haga clic aquí para iniciar sesión", + "Logging in...": "Logging in...", "Back to login page": "Volver a la página de inicio de sesión", "Back to home page": "Volver a la página de inicio", "Don't have an account?": "¿No tienes una cuenta?", diff --git a/src/locales/fr.json b/src/locales/fr.json index 36f7ff8e..315ad2bc 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -108,6 +108,7 @@ }, "misc": { "multiTextJoinSeparator": ", ", + "loginWithCustomProvider": "Log in with {name}", "hoursBehindDefaultTimezone": "{hours} heure(s) de retard sur le fuseau horaire par défaut", "hoursAheadOfDefaultTimezone": "{hours} heure(s) d'avance sur le fuseau horaire par défaut", "hoursMinutesBehindDefaultTimezone": "{hours} heure(s) et {minutes} minutes de retard sur le fuseau horaire par défaut", @@ -129,7 +130,8 @@ "moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.", "clearTransactionsInAccountTip": "You CANNOT undo this action. This will clear your transactions data in {account}. Please enter your current password to confirm.", "accountActivationAndResendValidationEmailTip": "Le lien d'activation du compte a été envoyé à votre adresse e-mail : {email}, Si vous ne recevez pas le mail, veuillez remplir à nouveau le mot de passe et cliquer sur le bouton ci-dessous pour renvoyer l'e-mail de validation.", - "resendValidationEmailTip": "Si vous ne recevez pas le mail, veuillez remplir à nouveau le mot de passe et cliquer sur le bouton ci-dessous pour renvoyer l'e-mail de validation à : {email}" + "resendValidationEmailTip": "Si vous ne recevez pas le mail, veuillez remplir à nouveau le mot de passe et cliquer sur le bouton ci-dessous pour renvoyer l'e-mail de validation à : {email}", + "oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify." } }, "dataExport": { @@ -1086,6 +1088,7 @@ "user avatar file extension invalid": "L'extension du fichier d'avatar utilisateur est invalide", "exceed the maximum size of user avatar file": "L'avatar utilisateur téléchargé dépasse la taille de fichier maximale autorisée", "not permitted to perform this action": "Vous n'êtes pas autorisé à effectuer cette action", + "cannot login by password": "You cannot login by password", "unauthorized access": "Accès non autorisé", "current token is invalid": "Le token actuel est invalide", "current token is expired": "Le token actuel a expiré", @@ -1238,6 +1241,17 @@ "image for AI recognition is empty": "Le fichier d'image pour la reconnaissance IA est vide", "exceed the maximum size of image file for AI recognition": "L'image téléchargée pour la reconnaissance IA dépasse la taille de fichier maximale autorisée", "no transaction information detected": "Aucune information de transaction détectée", + "user external auth is not found": "User external authentication is not found", + "oauth 2.0 not enabled": "OAuth 2.0 is not enabled", + "oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled", + "invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request", + "invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback", + "missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback", + "missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback", + "invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback", + "cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token", + "invalid oauth 2.0 token": "Invalid OAuth 2.0 token", + "cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider", "query items cannot be blank": "Il n'y a pas d'éléments de requête", "query items too much": "Il y a trop d'éléments de requête", "query items have invalid item": "Il y a un élément invalide dans les éléments de requête", @@ -1391,6 +1405,7 @@ "Operation": "Opération", "Open": "Ouvrir", "Close": "Fermer", + "or": "or", "Submit": "Soumettre", "Add": "Ajouter", "Import": "Importer", @@ -1572,7 +1587,10 @@ "This month or later": "Ce mois ou plus tard", "This year or later": "Cette année ou plus tard", "Log In": "Se connecter", + "Log in with OAuth 2.0": "Log in with OAuth 2.0", + "Log in with Connect ID": "Log in with Connect ID", "Click here to log in": "Cliquez ici pour vous connecter", + "Logging in...": "Logging in...", "Back to login page": "Retour à la page de connexion", "Back to home page": "Retour à la page d'accueil", "Don't have an account?": "Vous n'avez pas de compte ?", diff --git a/src/locales/helpers.ts b/src/locales/helpers.ts index 71b1f3a7..5590e72c 100644 --- a/src/locales/helpers.ts +++ b/src/locales/helpers.ts @@ -160,6 +160,7 @@ import { UTC_TIMEZONE, ALL_TIMEZONES } from '@/consts/timezone.ts'; import { ALL_CURRENCIES } from '@/consts/currency.ts'; import { DEFAULT_EXPENSE_CATEGORIES, DEFAULT_INCOME_CATEGORIES, DEFAULT_TRANSFER_CATEGORIES } from '@/consts/category.ts'; import { KnownErrorCode, SPECIFIED_API_NOT_FOUND_ERRORS, PARAMETERIZED_ERRORS } from '@/consts/api.ts'; +import { OAUTH2_PROVIDER_DISPLAY_NAME } from '@/consts/oauth2.ts'; import { DEFAULT_DOCUMENT_LANGUAGE_FOR_IMPORT_FILE, SUPPORTED_DOCUMENT_LANGUAGES_FOR_IMPORT_FILE, SUPPORTED_IMPORT_FILE_CATEGORY_AND_TYPES } from '@/consts/file.ts'; import { @@ -870,18 +871,18 @@ export function useI18n() { return textArray.join(separator); } - function getServerTipContent(tipConfig: Record): string { - if (!tipConfig) { + function getServerMultiLanguageConfigContent(multiLanguageConfig: Record): string { + if (!multiLanguageConfig) { return ''; } const currentLanguage = getCurrentLanguageTag(); - if (isString(tipConfig[currentLanguage])) { - return tipConfig[currentLanguage]; + if (isString(multiLanguageConfig[currentLanguage])) { + return multiLanguageConfig[currentLanguage]; } - return tipConfig['default'] || ''; + return multiLanguageConfig['default'] || ''; } function getCurrentLanguageTag(): string { @@ -2139,6 +2140,46 @@ export function useI18n() { return ret; } + function getLocalizedOAuth2ProviderName(oauth2Provider: string, oidcDisplayNames: Record): string { + if (oauth2Provider === 'oidc') { + const providerDisplayName = getServerMultiLanguageConfigContent(oidcDisplayNames); + + if (providerDisplayName) { + return providerDisplayName; + } else { + return 'Connect ID'; + } + } else { + const providerDisplayName = OAUTH2_PROVIDER_DISPLAY_NAME[oauth2Provider]; + + if (providerDisplayName) { + return providerDisplayName; + } else { + return 'OAuth 2.0'; + } + } + } + + function getLocalizedOAuth2LoginText(oauth2Provider: string, oidcDisplayNames: Record): string { + if (oauth2Provider === 'oidc') { + const providerDisplayName = getServerMultiLanguageConfigContent(oidcDisplayNames); + + if (providerDisplayName) { + return t('format.misc.loginWithCustomProvider', { name: providerDisplayName }); + } else { + return t('Log in with Connect ID'); + } + } else { + const providerDisplayName = OAUTH2_PROVIDER_DISPLAY_NAME[oauth2Provider]; + + if (providerDisplayName) { + return t('format.misc.loginWithCustomProvider', { name: providerDisplayName }); + } else { + return t('Log in with OAuth 2.0'); + } + } + } + function setLanguage(languageKey: string | null, force?: boolean): LocaleDefaultSettings | null { if (!languageKey) { languageKey = getDefaultLanguage(); @@ -2266,7 +2307,7 @@ export function useI18n() { ti: translateIf, te: translateError, joinMultiText, - getServerTipContent, + getServerMultiLanguageConfigContent, // get current language info getCurrentLanguageTag, getCurrentLanguageInfo, @@ -2411,6 +2452,9 @@ export function useI18n() { getAdaptiveAmountRate, getAmountPrependAndAppendText, getCategorizedAccountsWithDisplayBalance, + // other format functions + getLocalizedOAuth2ProviderName, + getLocalizedOAuth2LoginText, // localization setting functions setLanguage, setTimeZone, diff --git a/src/locales/it.json b/src/locales/it.json index 092688c8..a7d14ba3 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -108,6 +108,7 @@ }, "misc": { "multiTextJoinSeparator": ", ", + "loginWithCustomProvider": "Log in with {name}", "hoursBehindDefaultTimezone": "Indietro di {hours} ore rispetto al fuso orario standard", "hoursAheadOfDefaultTimezone": "Avanti di {hours} ore rispetto al fuso orario standard", "hoursMinutesBehindDefaultTimezone": "Indietro di {hours} ore e {minutes} minuti rispetto al fuso orario standard", @@ -129,7 +130,8 @@ "moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.", "clearTransactionsInAccountTip": "You CANNOT undo this action. This will clear your transactions data in {account}. Please enter your current password to confirm.", "accountActivationAndResendValidationEmailTip": "Abbiamo inviato un link per l'attivazione del tuo account all'indirizzo {email}. Se non hai ricevuto la mail, inserisci nuovamente la password e premi il bottone per ritentare l'invio.", - "resendValidationEmailTip": "Se non hai ricevuto la mail, inserisci nuovamente la password e premi il bottone per ritentare l'invio all'indirizzo: {email}" + "resendValidationEmailTip": "Se non hai ricevuto la mail, inserisci nuovamente la password e premi il bottone per ritentare l'invio all'indirizzo: {email}", + "oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify." } }, "dataExport": { @@ -1086,6 +1088,7 @@ "user avatar file extension invalid": "Estensione del file avatar utente non valida", "exceed the maximum size of user avatar file": "L'avatar utente caricato supera la dimensione massima consentita del file", "not permitted to perform this action": "Non sei autorizzato a eseguire questa azione", + "cannot login by password": "You cannot login by password", "unauthorized access": "Accesso non autorizzato", "current token is invalid": "Token corrente non valido", "current token is expired": "Token corrente scaduto", @@ -1238,6 +1241,17 @@ "image for AI recognition is empty": "Image for AI recognition file is empty", "exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size", "no transaction information detected": "No transaction information detected", + "user external auth is not found": "User external authentication is not found", + "oauth 2.0 not enabled": "OAuth 2.0 is not enabled", + "oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled", + "invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request", + "invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback", + "missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback", + "missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback", + "invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback", + "cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token", + "invalid oauth 2.0 token": "Invalid OAuth 2.0 token", + "cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider", "query items cannot be blank": "Non ci sono elementi di query", "query items too much": "Ci sono troppi elementi di query", "query items have invalid item": "C'è un elemento non valido negli elementi di query", @@ -1391,6 +1405,7 @@ "Operation": "Operazione", "Open": "Open", "Close": "Chiudi", + "or": "or", "Submit": "Invia", "Add": "Aggiungi", "Import": "Importa", @@ -1572,7 +1587,10 @@ "This month or later": "Questo mese o successivo", "This year or later": "Quest'anno o successivo", "Log In": "Accedi", + "Log in with OAuth 2.0": "Log in with OAuth 2.0", + "Log in with Connect ID": "Log in with Connect ID", "Click here to log in": "Clicca qui per accedere", + "Logging in...": "Logging in...", "Back to login page": "Torna alla pagina di accesso", "Back to home page": "Torna alla home page", "Don't have an account?": "Non hai un account?", diff --git a/src/locales/ja.json b/src/locales/ja.json index ec9ca86d..8c0e2871 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -108,6 +108,7 @@ }, "misc": { "multiTextJoinSeparator": "、", + "loginWithCustomProvider": "Log in with {name}", "hoursBehindDefaultTimezone": "デフォルトのタイムゾーンより{hours}時間遅れています", "hoursAheadOfDefaultTimezone": "デフォルトのタイムゾーンから{hours}時間進んでいます", "hoursMinutesBehindDefaultTimezone": "デフォルトのタイムゾーンより{hours}時間{minutes}分遅れています", @@ -129,7 +130,8 @@ "moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.", "clearTransactionsInAccountTip": "You CANNOT undo this action. This will clear your transactions data in {account}. Please enter your current password to confirm.", "accountActivationAndResendValidationEmailTip": "アカウントの有効化リンクがメールアドレスに送信されました:{email}、メールが届かない場合はパスワードをもう一度入力して下のボタンをクリックして認証メールを再送信してください。", - "resendValidationEmailTip": "メールが届かない場合は、パスワードをもう一度入力の上、以下のボタンをクリックして検証メールを再送信してください: {email}" + "resendValidationEmailTip": "メールが届かない場合は、パスワードをもう一度入力の上、以下のボタンをクリックして検証メールを再送信してください: {email}", + "oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify." } }, "dataExport": { @@ -1086,6 +1088,7 @@ "user avatar file extension invalid": "ユーザーアバターファイルの拡張子が無効です", "exceed the maximum size of user avatar file": "アップロードされたユーザーアバターは最大ファイルサイズを超えています", "not permitted to perform this action": "このアクションを実行は許可されていません", + "cannot login by password": "You cannot login by password", "unauthorized access": "不正アクセス", "current token is invalid": "現在のトークンは無効です", "current token is expired": "現在のトークンの有効期限が切れています", @@ -1238,6 +1241,17 @@ "image for AI recognition is empty": "Image for AI recognition file is empty", "exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size", "no transaction information detected": "No transaction information detected", + "user external auth is not found": "User external authentication is not found", + "oauth 2.0 not enabled": "OAuth 2.0 is not enabled", + "oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled", + "invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request", + "invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback", + "missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback", + "missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback", + "invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback", + "cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token", + "invalid oauth 2.0 token": "Invalid OAuth 2.0 token", + "cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider", "query items cannot be blank": "クエリ項目がありません", "query items too much": "クエリ項目が多すぎます", "query items have invalid item": "クエリ項目に無効な項目があります", @@ -1391,6 +1405,7 @@ "Operation": "操作", "Open": "Open", "Close": "閉じる", + "or": "or", "Submit": "送信", "Add": "追加", "Import": "インポート", @@ -1572,7 +1587,10 @@ "This month or later": "今月以降", "This year or later": "今年以降", "Log In": "ログイン", + "Log in with OAuth 2.0": "Log in with OAuth 2.0", + "Log in with Connect ID": "Log in with Connect ID", "Click here to log in": "ここをクリックしてログインしてください", + "Logging in...": "Logging in...", "Back to login page": "ログインページに戻る", "Back to home page": "Back to home page", "Don't have an account?": "Don't have an account?", diff --git a/src/locales/ko.json b/src/locales/ko.json index 394b4fb4..32c4aa98 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -108,6 +108,7 @@ }, "misc": { "multiTextJoinSeparator": ", ", + "loginWithCustomProvider": "Log in with {name}", "hoursBehindDefaultTimezone": "기본 시간대보다 {hours}시간 느립니다", "hoursAheadOfDefaultTimezone": "기본 시간대보다 {hours}시간 빠릅니다", "hoursMinutesBehindDefaultTimezone": "기본 시간대보다 {hours}시간 {minutes}분 느립니다", @@ -129,7 +130,8 @@ "moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.", "clearTransactionsInAccountTip": "이 작업은 되돌릴 수 없습니다. {account}의 거래 데이터를 지웁니다. 계속하시려면 현재 비밀번호를 입력하세요.", "accountActivationAndResendValidationEmailTip": "계정 활성화 링크가 귀하의 이메일 주소({email})로 전송되었습니다. 메일을 받지 못하신 경우, 비밀번호를 다시 입력하고 아래 버튼을 클릭하여 확인 메일을 재전송하십시오.", - "resendValidationEmailTip": "메일을 받지 못하신 경우, 비밀번호를 다시 입력하고 아래 버튼을 클릭하여 확인 메일을 {email}로 재전송하십시오." + "resendValidationEmailTip": "메일을 받지 못하신 경우, 비밀번호를 다시 입력하고 아래 버튼을 클릭하여 확인 메일을 {email}로 재전송하십시오.", + "oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify." } }, "dataExport": { @@ -1086,6 +1088,7 @@ "user avatar file extension invalid": "사용자 아바타 파일 확장자가 유효하지 않습니다", "exceed the maximum size of user avatar file": "업로드된 사용자 아바타가 허용된 최대 파일 크기를 초과합니다", "not permitted to perform this action": "이 작업을 수행할 수 있는 권한이 없습니다", + "cannot login by password": "You cannot login by password", "unauthorized access": "권한이 없는 접근입니다", "current token is invalid": "현재 토큰이 유효하지 않습니다", "current token is expired": "현재 토큰이 만료되었습니다", @@ -1238,6 +1241,17 @@ "image for AI recognition is empty": "AI 인식을 위한 이미지 파일이 비어 있습니다.", "exceed the maximum size of image file for AI recognition": "AI 인식을 위한 업로드된 이미지가 허용된 최대 파일 크기를 초과합니다.", "no transaction information detected": "거래 정보가 감지되지 않았습니다.", + "user external auth is not found": "User external authentication is not found", + "oauth 2.0 not enabled": "OAuth 2.0 is not enabled", + "oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled", + "invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request", + "invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback", + "missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback", + "missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback", + "invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback", + "cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token", + "invalid oauth 2.0 token": "Invalid OAuth 2.0 token", + "cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider", "query items cannot be blank": "쿼리 항목이 비어 있을 수 없습니다.", "query items too much": "쿼리 항목이 너무 많습니다.", "query items have invalid item": "쿼리 항목에 유효하지 않은 항목이 있습니다.", @@ -1391,6 +1405,7 @@ "Operation": "작업", "Open": "열기", "Close": "닫기", + "or": "or", "Submit": "제출", "Add": "추가", "Import": "가져오기", @@ -1572,7 +1587,10 @@ "This month or later": "이번 달 이후", "This year or later": "올해 이후", "Log In": "로그인", + "Log in with OAuth 2.0": "Log in with OAuth 2.0", + "Log in with Connect ID": "Log in with Connect ID", "Click here to log in": "여기를 클릭하여 로그인", + "Logging in...": "Logging in...", "Back to login page": "로그인 페이지로 돌아가기", "Back to home page": "홈 페이지로 돌아가기", "Don't have an account?": "계정이 없으신가요?", diff --git a/src/locales/nl.json b/src/locales/nl.json index 74a52369..9eea5944 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -108,6 +108,7 @@ }, "misc": { "multiTextJoinSeparator": ", ", + "loginWithCustomProvider": "Log in with {name}", "hoursBehindDefaultTimezone": "{hours} uur achter standaardtijdzone", "hoursAheadOfDefaultTimezone": "{hours} uur voor op standaardtijdzone", "hoursMinutesBehindDefaultTimezone": "{hours} uur en {minutes} minuten achter standaardtijdzone", @@ -129,7 +130,8 @@ "moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.", "clearTransactionsInAccountTip": "You CANNOT undo this action. This will clear your transactions data in {account}. Please enter your current password to confirm.", "accountActivationAndResendValidationEmailTip": "Een activatielink is verzonden naar je e-mailadres: {email}. Als je de e-mail niet ontvangt, vul dan je wachtwoord opnieuw in en klik op de knop hieronder om de validatiemail opnieuw te verzenden.", - "resendValidationEmailTip": "Als je de e-mail niet ontvangt, vul dan je wachtwoord opnieuw in en klik op de knop hieronder om de validatiemail opnieuw te verzenden naar: {email}" + "resendValidationEmailTip": "Als je de e-mail niet ontvangt, vul dan je wachtwoord opnieuw in en klik op de knop hieronder om de validatiemail opnieuw te verzenden naar: {email}", + "oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify." } }, "dataExport": { @@ -1086,6 +1088,7 @@ "user avatar file extension invalid": "Bestandsextensie van gebruikersavatar is ongeldig", "exceed the maximum size of user avatar file": "Geüploade avatar overschrijdt de maximaal toegestane bestandsgrootte", "not permitted to perform this action": "Je hebt geen toestemming om deze actie uit te voeren", + "cannot login by password": "You cannot login by password", "unauthorized access": "Ongeautoriseerde toegang", "current token is invalid": "Huidige token is ongeldig", "current token is expired": "Huidige token is verlopen", @@ -1238,6 +1241,17 @@ "image for AI recognition is empty": "Image for AI recognition file is empty", "exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size", "no transaction information detected": "No transaction information detected", + "user external auth is not found": "User external authentication is not found", + "oauth 2.0 not enabled": "OAuth 2.0 is not enabled", + "oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled", + "invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request", + "invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback", + "missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback", + "missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback", + "invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback", + "cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token", + "invalid oauth 2.0 token": "Invalid OAuth 2.0 token", + "cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider", "query items cannot be blank": "Geen zoekitems opgegeven", "query items too much": "Te veel zoekitems", "query items have invalid item": "Ongeldig item in zoekitems", @@ -1391,6 +1405,7 @@ "Operation": "Bewerking", "Open": "Openen", "Close": "Sluiten", + "or": "or", "Submit": "Verzenden", "Add": "Toevoegen", "Import": "Importeren", @@ -1572,7 +1587,10 @@ "This month or later": "Deze maand of later", "This year or later": "Dit jaar of later", "Log In": "Inloggen", + "Log in with OAuth 2.0": "Log in with OAuth 2.0", + "Log in with Connect ID": "Log in with Connect ID", "Click here to log in": "Klik hier om in te loggen", + "Logging in...": "Logging in...", "Back to login page": "Terug naar inlogpagina", "Back to home page": "Terug naar startpagina", "Don't have an account?": "Nog geen account?", diff --git a/src/locales/pt_BR.json b/src/locales/pt_BR.json index a25e4f22..12d83f9e 100644 --- a/src/locales/pt_BR.json +++ b/src/locales/pt_BR.json @@ -108,6 +108,7 @@ }, "misc": { "multiTextJoinSeparator": ", ", + "loginWithCustomProvider": "Log in with {name}", "hoursBehindDefaultTimezone": "{hours} hora(s) atrás do fuso horário padrão", "hoursAheadOfDefaultTimezone": "{hours} hora(s) à frente do fuso horário padrão", "hoursMinutesBehindDefaultTimezone": "{hours} hora(s) e {minutes} minutos atrás do fuso horário padrão", @@ -129,7 +130,8 @@ "moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.", "clearTransactionsInAccountTip": "You CANNOT undo this action. This will clear your transactions data in {account}. Please enter your current password to confirm.", "accountActivationAndResendValidationEmailTip": "O link de ativação da conta foi enviado para seu endereço de e-mail: {email}. Se você não receber o e-mail, por favor preencha a senha novamente e clique no botão abaixo para reenviar o e-mail de validação.", - "resendValidationEmailTip": "Se você não receber o e-mail, por favor preencha a senha novamente e clique no botão abaixo para reenviar o e-mail de validação para: {email}" + "resendValidationEmailTip": "Se você não receber o e-mail, por favor preencha a senha novamente e clique no botão abaixo para reenviar o e-mail de validação para: {email}", + "oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify." } }, "dataExport": { @@ -1086,6 +1088,7 @@ "user avatar file extension invalid": "A extensão do arquivo de avatar do usuário é inválida", "exceed the maximum size of user avatar file": "O avatar de usuário enviado excede o tamanho máximo permitido de arquivo", "not permitted to perform this action": "Você não tem permissão para realizar esta ação", + "cannot login by password": "You cannot login by password", "unauthorized access": "Acesso não autorizado", "current token is invalid": "Token atual é inválido", "current token is expired": "Token atual está expirado", @@ -1238,6 +1241,17 @@ "image for AI recognition is empty": "Image for AI recognition file is empty", "exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size", "no transaction information detected": "No transaction information detected", + "user external auth is not found": "User external authentication is not found", + "oauth 2.0 not enabled": "OAuth 2.0 is not enabled", + "oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled", + "invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request", + "invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback", + "missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback", + "missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback", + "invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback", + "cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token", + "invalid oauth 2.0 token": "Invalid OAuth 2.0 token", + "cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider", "query items cannot be blank": "Não há itens de consulta", "query items too much": "Há muitos itens de consulta", "query items have invalid item": "Há item inválido nos itens de consulta", @@ -1391,6 +1405,7 @@ "Operation": "Operação", "Open": "Open", "Close": "Fechar", + "or": "or", "Submit": "Enviar", "Add": "Adicionar", "Import": "Importar", @@ -1572,7 +1587,10 @@ "This month or later": "Este mês ou depois", "This year or later": "Este ano ou depois", "Log In": "Fazer Login", + "Log in with OAuth 2.0": "Log in with OAuth 2.0", + "Log in with Connect ID": "Log in with Connect ID", "Click here to log in": "Clique aqui para fazer login", + "Logging in...": "Logging in...", "Back to login page": "Voltar para a página de login", "Back to home page": "Voltar para a página inicial", "Don't have an account?": "Não tem uma conta?", diff --git a/src/locales/ru.json b/src/locales/ru.json index 0669b1cc..32411cef 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -108,6 +108,7 @@ }, "misc": { "multiTextJoinSeparator": ", ", + "loginWithCustomProvider": "Log in with {name}", "hoursBehindDefaultTimezone": "{hours} час(ов) позади часового пояса по умолчанию", "hoursAheadOfDefaultTimezone": "{hours} час(ов) впереди часового пояса по умолчанию", "hoursMinutesBehindDefaultTimezone": "{hours} час(ов) и {minutes} минут позади часового пояса по умолчанию", @@ -129,7 +130,8 @@ "moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.", "clearTransactionsInAccountTip": "You CANNOT undo this action. This will clear your transactions data in {account}. Please enter your current password to confirm.", "accountActivationAndResendValidationEmailTip": "Ссылка для активации учетной записи была отправлена на ваш электронный адрес: {email}. Если вы не получили письмо, заполните пароль снова и нажмите кнопку ниже, чтобы отправить письмо повторно.", - "resendValidationEmailTip": "Если вы не получили письмо, заполните пароль снова и нажмите кнопку ниже, чтобы отправить письмо повторно на: {email}" + "resendValidationEmailTip": "Если вы не получили письмо, заполните пароль снова и нажмите кнопку ниже, чтобы отправить письмо повторно на: {email}", + "oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify." } }, "dataExport": { @@ -1086,6 +1088,7 @@ "user avatar file extension invalid": "Недопустимое расширение файла аватара пользователя", "exceed the maximum size of user avatar file": "Загруженный аватар пользователя превышает максимально допустимый размер файла", "not permitted to perform this action": "Вам не разрешено выполнять это действие", + "cannot login by password": "You cannot login by password", "unauthorized access": "Несанкционированный доступ", "current token is invalid": "Текущий токен недействителен", "current token is expired": "Текущий токен истек", @@ -1238,6 +1241,17 @@ "image for AI recognition is empty": "Image for AI recognition file is empty", "exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size", "no transaction information detected": "No transaction information detected", + "user external auth is not found": "User external authentication is not found", + "oauth 2.0 not enabled": "OAuth 2.0 is not enabled", + "oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled", + "invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request", + "invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback", + "missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback", + "missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback", + "invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback", + "cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token", + "invalid oauth 2.0 token": "Invalid OAuth 2.0 token", + "cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider", "query items cannot be blank": "Нет элементов запроса", "query items too much": "Слишком много элементов запроса", "query items have invalid item": "В элементах запроса присутствует недопустимый элемент", @@ -1391,6 +1405,7 @@ "Operation": "Операция", "Open": "Open", "Close": "Закрыть", + "or": "or", "Submit": "Отправить", "Add": "Добавить", "Import": "Импорт", @@ -1572,7 +1587,10 @@ "This month or later": "В этом месяце или позже", "This year or later": "В этом году или позже", "Log In": "Войти", + "Log in with OAuth 2.0": "Log in with OAuth 2.0", + "Log in with Connect ID": "Log in with Connect ID", "Click here to log in": "Нажмите здесь, чтобы войти", + "Logging in...": "Logging in...", "Back to login page": "Вернуться на страницу входа", "Back to home page": "Вернуться на главную страницу", "Don't have an account?": "Нет учетной записи?", diff --git a/src/locales/th.json b/src/locales/th.json index 06ad7724..e5a4c875 100644 --- a/src/locales/th.json +++ b/src/locales/th.json @@ -108,6 +108,7 @@ }, "misc": { "multiTextJoinSeparator": ", ", + "loginWithCustomProvider": "Log in with {name}", "hoursBehindDefaultTimezone": "ช้ากว่าเขตเวลาเริ่มต้น {hours} ชั่วโมง", "hoursAheadOfDefaultTimezone": "เร็วกว่าเขตเวลาเริ่มต้น {hours} ชั่วโมง", "hoursMinutesBehindDefaultTimezone": "ช้ากว่าเขตเวลาเริ่มต้น {hours} ชั่วโมง {minutes} นาที", @@ -129,7 +130,8 @@ "moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.", "clearTransactionsInAccountTip": "คุณไม่สามารถยกเลิกการกระทำนี้ได้ การกระทำนี้จะลบข้อมูลธุรกรรมทั้งหมดใน {account} โปรดป้อนรหัสผ่านปัจจุบันเพื่อยืนยัน", "accountActivationAndResendValidationEmailTip": "ลิงก์สำหรับเปิดใช้งานบัญชีได้ถูกส่งไปยังอีเมลของคุณแล้ว: {email} หากคุณไม่ได้รับอีเมล โปรดกรอกรหัสผ่านอีกครั้งแล้วกดปุ่มด้านล่างเพื่อส่งอีเมลยืนยันอีกครั้ง", - "resendValidationEmailTip": "หากคุณไม่ได้รับอีเมล โปรดกรอกรหัสผ่านอีกครั้งแล้วกดปุ่มด้านล่างเพื่อส่งอีเมลยืนยันไปยัง: {email}" + "resendValidationEmailTip": "หากคุณไม่ได้รับอีเมล โปรดกรอกรหัสผ่านอีกครั้งแล้วกดปุ่มด้านล่างเพื่อส่งอีเมลยืนยันไปยัง: {email}", + "oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify." } }, "dataExport": { @@ -1086,6 +1088,7 @@ "user avatar file extension invalid": "นามสกุลไฟล์รูปประจำตัวผู้ใช้ไม่ถูกต้อง", "exceed the maximum size of user avatar file": "ขนาดไฟล์รูปประจำตัวผู้ใช้เกินขนาดสูงสุดที่อนุญาต", "not permitted to perform this action": "คุณไม่ได้รับอนุญาตให้ดำเนินการนี้", + "cannot login by password": "You cannot login by password", "unauthorized access": "การเข้าถึงไม่ได้รับอนุญาต", "current token is invalid": "โทเค็นปัจจุบันไม่ถูกต้อง", "current token is expired": "โทเค็นปัจจุบันหมดอายุ", @@ -1238,6 +1241,17 @@ "image for AI recognition is empty": "ไฟล์รูปภาพสำหรับการจดจำด้วย AI ว่างเปล่า", "exceed the maximum size of image file for AI recognition": "ไฟล์รูปภาพสำหรับการจดจำด้วย AI เกินขนาดสูงสุดที่อนุญาต", "no transaction information detected": "ไม่พบข้อมูลธุรกรรม", + "user external auth is not found": "User external authentication is not found", + "oauth 2.0 not enabled": "OAuth 2.0 is not enabled", + "oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled", + "invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request", + "invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback", + "missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback", + "missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback", + "invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback", + "cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token", + "invalid oauth 2.0 token": "Invalid OAuth 2.0 token", + "cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider", "query items cannot be blank": "ไม่มีรายการสำหรับค้นหา", "query items too much": "รายการค้นหามากเกินไป", "query items have invalid item": "มีรายการไม่ถูกต้องในรายการค้นหา", @@ -1391,6 +1405,7 @@ "Operation": "การดำเนินการ", "Open": "เปิด", "Close": "ปิด", + "or": "or", "Submit": "ส่ง", "Add": "เพิ่ม", "Import": "นำเข้า", @@ -1572,7 +1587,10 @@ "This month or later": "เดือนนี้หรือหลังจากนี้", "This year or later": "ปีนี้หรือหลังจากนี้", "Log In": "เข้าสู่ระบบ", + "Log in with OAuth 2.0": "Log in with OAuth 2.0", + "Log in with Connect ID": "Log in with Connect ID", "Click here to log in": "คลิกที่นี่เพื่อเข้าสู่ระบบ", + "Logging in...": "Logging in...", "Back to login page": "กลับไปยังหน้าล็อกอิน", "Back to home page": "กลับไปยังหน้าหลัก", "Don't have an account?": "ยังไม่มีบัญชี?", diff --git a/src/locales/uk.json b/src/locales/uk.json index 83d0cfa6..793521ea 100644 --- a/src/locales/uk.json +++ b/src/locales/uk.json @@ -108,6 +108,7 @@ }, "misc": { "multiTextJoinSeparator": ", ", + "loginWithCustomProvider": "Log in with {name}", "hoursBehindDefaultTimezone": "{hours} год позаду часового поясу за замовчуванням", "hoursAheadOfDefaultTimezone": "{hours} год попереду часового поясу за замовчуванням", "hoursMinutesBehindDefaultTimezone": "{hours} год і {minutes} хв позаду часового поясу за замовчуванням", @@ -129,7 +130,8 @@ "moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.", "clearTransactionsInAccountTip": "You CANNOT undo this action. This will clear your transactions data in {account}. Please enter your current password to confirm.", "accountActivationAndResendValidationEmailTip": "Посилання для активації облікового запису було надіслано на вашу електронну адресу: {email}. Якщо ви не отримали лист, введіть пароль ще раз і натисніть кнопку нижче, щоб надіслати лист повторно.", - "resendValidationEmailTip": "Якщо ви не отримали лист, введіть пароль ще раз і натисніть кнопку нижче, щоб надіслати лист повторно на адресу: {email}" + "resendValidationEmailTip": "Якщо ви не отримали лист, введіть пароль ще раз і натисніть кнопку нижче, щоб надіслати лист повторно на адресу: {email}", + "oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify." } }, "dataExport": { @@ -1086,6 +1088,7 @@ "user avatar file extension invalid": "Недопустиме розширення файлу аватара користувача", "exceed the maximum size of user avatar file": "Завантажений аватар перевищує максимально допустимий розмір", "not permitted to perform this action": "Вам не дозволено виконувати цю дію", + "cannot login by password": "You cannot login by password", "unauthorized access": "Несанкціонований доступ", "current token is invalid": "Поточний токен недійсний", "current token is expired": "Поточний токен прострочений", @@ -1238,6 +1241,17 @@ "image for AI recognition is empty": "Image for AI recognition file is empty", "exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size", "no transaction information detected": "No transaction information detected", + "user external auth is not found": "User external authentication is not found", + "oauth 2.0 not enabled": "OAuth 2.0 is not enabled", + "oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled", + "invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request", + "invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback", + "missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback", + "missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback", + "invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback", + "cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token", + "invalid oauth 2.0 token": "Invalid OAuth 2.0 token", + "cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider", "query items cannot be blank": "Елементи запиту не можуть бути порожніми", "query items too much": "Занадто багато елементів запиту", "query items have invalid item": "Запит містить недійсний елемент", @@ -1391,6 +1405,7 @@ "Operation": "Дія", "Open": "Open", "Close": "Закрити", + "or": "or", "Submit": "Підтвердити", "Add": "Додати", "Import": "Імпортувати", @@ -1572,7 +1587,10 @@ "This month or later": "Цього місяця або пізніше", "This year or later": "Цього року або пізніше", "Log In": "Увійти", + "Log in with OAuth 2.0": "Log in with OAuth 2.0", + "Log in with Connect ID": "Log in with Connect ID", "Click here to log in": "Натисніть тут, щоб увійти", + "Logging in...": "Logging in...", "Back to login page": "Повернутися на сторінку входу", "Back to home page": "Повернутися на головну сторінку", "Don't have an account?": "Немає облікового запису?", diff --git a/src/locales/vi.json b/src/locales/vi.json index 3accc519..71e76f3c 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -108,6 +108,7 @@ }, "misc": { "multiTextJoinSeparator": ", ", + "loginWithCustomProvider": "Log in with {name}", "hoursBehindDefaultTimezone": "{hours} giờ sau múi giờ mặc định", "hoursAheadOfDefaultTimezone": "{hours} giờ trước múi giờ mặc định", "hoursMinutesBehindDefaultTimezone": "{hours} giờ và {minutes} phút sau múi giờ mặc định", @@ -129,7 +130,8 @@ "moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.", "clearTransactionsInAccountTip": "You CANNOT undo this action. This will clear your transactions data in {account}. Please enter your current password to confirm.", "accountActivationAndResendValidationEmailTip": "Liên kết kích hoạt tài khoản đã được gửi tới email của bạn: {email}. Nếu bạn không nhận được email, vui lòng nhập lại mật khẩu và nhấp nút bên dưới để gửi lại email xác nhận.", - "resendValidationEmailTip": "Nếu bạn không nhận được email, vui lòng nhập lại mật khẩu và nhấp nút bên dưới để gửi lại email xác nhận tới: {email}" + "resendValidationEmailTip": "Nếu bạn không nhận được email, vui lòng nhập lại mật khẩu và nhấp nút bên dưới để gửi lại email xác nhận tới: {email}", + "oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify." } }, "dataExport": { @@ -1086,6 +1088,7 @@ "user avatar file extension invalid": "Đuôi tệp avatar người dùng không hợp lệ", "exceed the maximum size of user avatar file": "Avatar người dùng đã tải lên vượt quá kích thước tệp tối đa cho phép", "not permitted to perform this action": "Bạn không được phép thực hiện hành động này", + "cannot login by password": "You cannot login by password", "unauthorized access": "Truy cập trái phép", "current token is invalid": "Mã thông báo hiện tại không hợp lệ", "current token is expired": "Mã thông báo hiện tại đã hết hạn", @@ -1238,6 +1241,17 @@ "image for AI recognition is empty": "Image for AI recognition file is empty", "exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size", "no transaction information detected": "No transaction information detected", + "user external auth is not found": "User external authentication is not found", + "oauth 2.0 not enabled": "OAuth 2.0 is not enabled", + "oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled", + "invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request", + "invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback", + "missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback", + "missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback", + "invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback", + "cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token", + "invalid oauth 2.0 token": "Invalid OAuth 2.0 token", + "cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider", "query items cannot be blank": "Không có mục truy vấn", "query items too much": "Có quá nhiều mục truy vấn", "query items have invalid item": "Có mục không hợp lệ trong các mục truy vấn", @@ -1391,6 +1405,7 @@ "Operation": "Thao tác", "Open": "Open", "Close": "Đóng", + "or": "or", "Submit": "Gửi", "Add": "Thêm", "Import": "Nhập", @@ -1572,7 +1587,10 @@ "This month or later": "Tháng này trở đi", "This year or later": "Năm nay trở đi", "Log In": "Đăng nhập", + "Log in with OAuth 2.0": "Log in with OAuth 2.0", + "Log in with Connect ID": "Log in with Connect ID", "Click here to log in": "Nhấp vào đây để đăng nhập", + "Logging in...": "Logging in...", "Back to login page": "Quay lại trang đăng nhập", "Back to home page": "Quay lại trang chủ", "Don't have an account?": "Bạn chưa có tài khoản?", diff --git a/src/locales/zh_Hans.json b/src/locales/zh_Hans.json index 3f9b4e6b..24ea5956 100644 --- a/src/locales/zh_Hans.json +++ b/src/locales/zh_Hans.json @@ -108,6 +108,7 @@ }, "misc": { "multiTextJoinSeparator": "、", + "loginWithCustomProvider": "使用 {name} 登录", "hoursBehindDefaultTimezone": "比默认时区晚{hours}小时", "hoursAheadOfDefaultTimezone": "比默认时区早{hours}小时", "hoursMinutesBehindDefaultTimezone": "比默认时区晚{hours}小时{minutes}分", @@ -129,7 +130,8 @@ "moveTransactionsInAccountTip": "您不能撤销该操作。该操作将会把 {fromAccount} 账户中所有的交易数据移动到 {toAccount}。", "clearTransactionsInAccountTip": "您不能撤销该操作。该操作将会清除您在 {account} 账户中的交易数据。请输入您当前的密码以确认。", "accountActivationAndResendValidationEmailTip": "账号激活链接已经发送到您的邮箱地址:{email},如果您没有收到邮件,请再次输入密码并点击下方的按钮重新发送验证邮件。", - "resendValidationEmailTip": "如果您没有收到邮件,请再次输入密码并点击下方的按钮重新发送验证邮件到:{email}" + "resendValidationEmailTip": "如果您没有收到邮件,请再次输入密码并点击下方的按钮重新发送验证邮件到:{email}", + "oauth2bindTip": "您正在使用 {providerName} 登录 \"{userName}\" 用户,请输入你的 ezBookkeeping 的密码进行验证。" } }, "dataExport": { @@ -1086,6 +1088,7 @@ "user avatar file extension invalid": "用户头像文件扩展名无效", "exceed the maximum size of user avatar file": "上传的用户头像超出了允许的最大文件大小", "not permitted to perform this action": "您不能执行该操作", + "cannot login by password": "您不能使用密码登录", "unauthorized access": "未授权的登录", "current token is invalid": "当前认证令牌无效", "current token is expired": "当前认证令牌已过期", @@ -1238,6 +1241,17 @@ "image for AI recognition is empty": "用于AI识别的图片为空", "exceed the maximum size of image file for AI recognition": "用于AI识别的图片超出了允许的最大文件大小", "no transaction information detected": "没有检测到交易信息", + "user external auth is not found": "用户外部认证信息不存在", + "oauth 2.0 not enabled": "OAuth 2.0 没有启用", + "oauth 2.0 auto registration not enabled": "OAuth 2.0 自动注册没有启用", + "invalid oauth 2.0 login request": "无效的 OAuth 2.0 登录请求", + "invalid oauth 2.0 callback": "无效的 OAuth 2.0 回调请求", + "missing state in oauth 2.0 callback": "OAuth 2.0 回调中缺少 state 参数", + "missing code in oauth 2.0 callback": "OAuth 2.0 回调中缺少 code 参数", + "invalid state in oauth 2.0 callback": "OAuth 2.0 回调中的 state 参数无效", + "cannot retrieve oauth 2.0 token": "无法获取 OAuth 2.0 令牌", + "invalid oauth 2.0 token": "无效的 OAuth 2.0 令牌", + "cannot retrieve user info from oauth 2.0 provider": "无法从 OAuth 2.0 提供者获取用户信息", "query items cannot be blank": "请求项目不能为空", "query items too much": "请求项目过多", "query items have invalid item": "请求项目中有非法项目", @@ -1391,6 +1405,7 @@ "Operation": "操作", "Open": "打开", "Close": "关闭", + "or": "或", "Submit": "提交", "Add": "添加", "Import": "导入", @@ -1572,7 +1587,10 @@ "This month or later": "本月或更晚", "This year or later": "今年或更晚", "Log In": "登录", + "Log in with OAuth 2.0": "使用 OAuth 2.0 登录", + "Log in with Connect ID": "使用 Connect ID 登录", "Click here to log in": "点击这里登录", + "Logging in...": "正在登录...", "Back to login page": "返回登录页", "Back to home page": "返回首页", "Don't have an account?": "还没有账号?", diff --git a/src/locales/zh_Hant.json b/src/locales/zh_Hant.json index 00fe170c..6d6eb1af 100644 --- a/src/locales/zh_Hant.json +++ b/src/locales/zh_Hant.json @@ -108,6 +108,7 @@ }, "misc": { "multiTextJoinSeparator": "、", + "loginWithCustomProvider": "使用 {name} 登入", "hoursBehindDefaultTimezone": "比預設時區晚{hours}小時", "hoursAheadOfDefaultTimezone": "比預設時區早{hours}小時", "hoursMinutesBehindDefaultTimezone": "比預設時區晚{hours}小時{minutes}分", @@ -129,7 +130,8 @@ "moveTransactionsInAccountTip": "您不能還原此操作。此操作將會把 {fromAccount} 帳戶中的所有交易資料移動到 {toAccount}。", "clearTransactionsInAccountTip": "您不能還原此操作。此操作將會清除您在 {account} 帳戶中的交易資料。請輸入您目前的密碼以確認。", "accountActivationAndResendValidationEmailTip": "帳號啟用連結已經傳送到您的信箱地址:{email},如果您沒有收到郵件,請再次輸入密碼並點擊下方的按鈕重新發送驗證郵件。", - "resendValidationEmailTip": "如果您沒有收到郵件,請再次輸入密碼並點擊下方的按鈕重新發送驗證郵件到:{email}" + "resendValidationEmailTip": "如果您沒有收到郵件,請再次輸入密碼並點擊下方的按鈕重新發送驗證郵件到:{email}", + "oauth2bindTip": "您正在使用 {providerName} 登入 \"{userName}\" 使用者,請輸入您的 ezBookkeeping 的密碼以進行驗證。" } }, "dataExport": { @@ -1086,6 +1088,7 @@ "user avatar file extension invalid": "使用者頭像檔案副檔名無效", "exceed the maximum size of user avatar file": "上傳的使用者頭像超過允許的最大檔案大小", "not permitted to perform this action": "您不能執行該操作", + "cannot login by password": "您不能使用密碼登入", "unauthorized access": "未授權的登入", "current token is invalid": "目前認證令牌無效", "current token is expired": "目前認證令牌已過期", @@ -1238,6 +1241,17 @@ "image for AI recognition is empty": "用於AI識別的圖片檔案為空", "exceed the maximum size of image file for AI recognition": "用於AI識別的圖片超出了允許的最大檔案大小", "no transaction information detected": "沒有檢測到交易資訊", + "user external auth is not found": "使用者外部驗證資訊不存在", + "oauth 2.0 not enabled": "OAuth 2.0 未啟用", + "oauth 2.0 auto registration not enabled": "OAuth 2.0 自動註冊未啟用", + "invalid oauth 2.0 login request": "無效的 OAuth 2.0 登入請求", + "invalid oauth 2.0 callback": "無效的 OAuth 2.0 回調請求", + "missing state in oauth 2.0 callback": "OAuth 2.0 回調中缺少 state 參數", + "missing code in oauth 2.0 callback": "OAuth 2.0 回調中缺少 code 參數", + "invalid state in oauth 2.0 callback": "OAuth 2.0 回調中的 state 參數無效", + "cannot retrieve oauth 2.0 token": "無法獲取 OAuth 2.0 令牌", + "invalid oauth 2.0 token": "無效的 OAuth 2.0 令牌", + "cannot retrieve user info from oauth 2.0 provider": "無法從 OAuth 2.0 提供者獲取使用者資訊", "query items cannot be blank": "查詢項目不能為空", "query items too much": "查詢項目過多", "query items have invalid item": "查詢項目中有非法項目", @@ -1391,6 +1405,7 @@ "Operation": "操作", "Open": "開啟", "Close": "關閉", + "or": "或", "Submit": "提交", "Add": "新增", "Import": "匯入", @@ -1572,7 +1587,10 @@ "This month or later": "本月或更晚", "This year or later": "今年或更晚", "Log In": "登入", + "Log in with OAuth 2.0": "使用 OAuth 2.0 登入", + "Log in with Connect ID": "使用 Connect ID 登入", "Click here to log in": "點擊這裡登入", + "Logging in...": "正在登入...", "Back to login page": "返回登入頁面", "Back to home page": "返回首頁", "Don't have an account?": "還沒有帳號?", diff --git a/src/models/oauth2.ts b/src/models/oauth2.ts new file mode 100644 index 00000000..523e1f3d --- /dev/null +++ b/src/models/oauth2.ts @@ -0,0 +1,4 @@ +export interface OAuth2CallbackLoginRequest { + readonly provider?: string; + readonly password?: string; +} diff --git a/src/router/desktop.ts b/src/router/desktop.ts index 099b5005..b039c9e0 100644 --- a/src/router/desktop.ts +++ b/src/router/desktop.ts @@ -9,6 +9,7 @@ import SignUpPage from '@/views/desktop/SignupPage.vue'; import VerifyEmailPage from '@/views/desktop/VerifyEmailPage.vue'; import ForgetPasswordPage from '@/views/desktop/ForgetPasswordPage.vue'; import ResetPasswordPage from '@/views/desktop/ResetPasswordPage.vue'; +import OAuth2CallbackPage from '@/views/desktop/OAuth2CallbackPage.vue'; import UnlockPage from '@/views/desktop/UnlockPage.vue'; import HomePage from '@/views/desktop/HomePage.vue'; @@ -226,6 +227,17 @@ const router = createRouter({ token: route.query['token'] }) }, + { + path: '/oauth2_callback', + component: OAuth2CallbackPage, + props: route => ({ + token: route.query['token'], + provider: route.query['provider'], + platform: route.query['platform'], + userName: route.query['userName'], + error: route.query['error'] + }) + }, { path: '/unlock', component: UnlockPage, diff --git a/src/stores/index.ts b/src/stores/index.ts index 8829973b..5ee290a9 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -21,8 +21,8 @@ import type { UserProfileUpdateRequest, UserProfileUpdateResponse } from '@/models/user.ts'; -import type { LocalizedPresetCategory } from '@/core/category.ts'; import type { ForgetPasswordRequest } from '@/models/forget_password.ts'; +import type { LocalizedPresetCategory } from '@/core/category.ts'; import { isObject, @@ -77,6 +77,10 @@ export const useRootStore = defineStore('root', () => { currentNotification.value = content; } + function generateOAuth2LoginUrl(platform: 'mobile' | 'desktop', clientSessionId: string): string { + return services.generateOAuth2LoginUrl(platform, clientSessionId); + } + function authorize(req: UserLoginRequest): Promise { return new Promise((resolve, reject) => { services.authorize(req).then(response => { @@ -187,6 +191,56 @@ export const useRootStore = defineStore('root', () => { }); } + function authorizeOAuth2({ provider, password, token }: { provider: string, password?: string, token: string }): Promise { + return new Promise((resolve, reject) => { + services.authorizeOAuth2({ + req: { + provider, + password + }, + token + }).then(response => { + const data = response.data; + + if (!data || !data.success || !data.result || !data.result.token) { + reject({ message: 'Unable to log in' }); + return; + } + + if (settingsStore.appSettings.applicationLock || hasUserAppLockState()) { + const appLockState = getUserAppLockState(); + + if (!appLockState || appLockState.username !== data.result.user?.username) { + clearCurrentTokenAndUserInfo(true); + settingsStore.setEnableApplicationLock(false); + settingsStore.setEnableApplicationLockWebAuthn(false); + clearWebAuthnConfig(); + } + } + + settingsStore.setApplicationSettingsFromCloudSettings(data.result.applicationCloudSettings); + + updateCurrentToken(data.result.token); + + if (data.result.user && isObject(data.result.user)) { + userStore.storeUserBasicInfo(data.result.user); + } + + resolve(data.result); + }).catch(error => { + logger.error('failed to login', error); + + if (error && error.processed) { + reject(error); + } else if (error.response && error.response.data && error.response.data.errorMessage) { + reject({ error: error.response.data }); + } else { + reject({ message: 'Unable to log in' }); + } + }); + }); + } + function register({ user, presetCategories }: { user: User, presetCategories?: LocalizedPresetCategory[] }): Promise { return new Promise((resolve, reject) => { services.register(user.toRegisterRequest(presetCategories)).then(response => { @@ -588,8 +642,10 @@ export const useRootStore = defineStore('root', () => { currentNotification, // functions setNotificationContent, + generateOAuth2LoginUrl, authorize, authorize2FA, + authorizeOAuth2, register, lock, logout, diff --git a/src/views/base/LoginPageBase.ts b/src/views/base/LoginPageBase.ts index 634e197f..d7912890 100644 --- a/src/views/base/LoginPageBase.ts +++ b/src/views/base/LoginPageBase.ts @@ -8,12 +8,12 @@ import { useExchangeRatesStore } from '@/stores/exchangeRates.ts'; import type { AuthResponse } from '@/models/auth_response.ts'; -import { getLoginPageTips } from '@/lib/server_settings.ts'; +import { getOAuth2Provider, getOIDCCustomDisplayNames, getLoginPageTips } from '@/lib/server_settings.ts'; import { getClientDisplayVersion } from '@/lib/version.ts'; import { setExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts'; -export function useLoginPageBase() { - const { getServerTipContent, setLanguage } = useI18n(); +export function useLoginPageBase(platform: 'mobile' | 'desktop') { + const { getServerMultiLanguageConfigContent, getLocalizedOAuth2LoginText, setLanguage } = useI18n(); const rootStore = useRootStore(); const settingsStore = useSettingsStore(); @@ -27,6 +27,7 @@ export function useLoginPageBase() { const backupCode = ref(''); const tempToken = ref(''); const twoFAVerifyType = ref('passcode'); + const oauth2ClientSessionId = ref(''); const logining = ref(false); const verifying = ref(false); @@ -40,7 +41,9 @@ export function useLoginPageBase() { } }); - const tips = computed(() => getServerTipContent(getLoginPageTips())); + const oauth2LoginUrl = computed(() => rootStore.generateOAuth2LoginUrl(platform, oauth2ClientSessionId.value)); + const oauth2LoginDisplayName = computed(() => getLocalizedOAuth2LoginText(getOAuth2Provider(), getOIDCCustomDisplayNames())); + const tips = computed(() => getServerMultiLanguageConfigContent(getLoginPageTips())); function doAfterLogin(authResponse: AuthResponse): void { if (authResponse.user) { @@ -69,11 +72,14 @@ export function useLoginPageBase() { backupCode, tempToken, twoFAVerifyType, + oauth2ClientSessionId, logining, verifying, // computed states inputIsEmpty, twoFAInputIsEmpty, + oauth2LoginUrl, + oauth2LoginDisplayName, tips, // functions doAfterLogin diff --git a/src/views/desktop/LoginPage.vue b/src/views/desktop/LoginPage.vue index 27c48f2d..162d9841 100644 --- a/src/views/desktop/LoginPage.vue +++ b/src/views/desktop/LoginPage.vue @@ -24,14 +24,14 @@

{{ tt('Welcome to ezBookkeeping') }}

-

{{ tt('Please log in with your ezBookkeeping account') }}

+

{{ tt('Please log in with your ezBookkeeping account') }}

{{ tips }}

- + - + + @click="login" v-if="isInternalAuthEnabled() && !show2faInput"> {{ tt('Log In') }} + + + + {{ tt('or') }} + + + + + {{ oauth2LoginDisplayName }} + {{ tt('Continue') }} @@ -112,7 +122,7 @@ - + {{ tt('Don\'t have an account?') }} @@ -170,7 +180,14 @@ import { ThemeType } from '@/core/theme.ts'; import { APPLICATION_LOGO_PATH } from '@/consts/asset.ts'; import { KnownErrorCode } from '@/consts/api.ts'; -import { isUserRegistrationEnabled, isUserForgetPasswordEnabled, isUserVerifyEmailEnabled } from '@/lib/server_settings.ts'; +import { generateRandomUUID } from '@/lib/misc.ts'; +import { + isUserRegistrationEnabled, + isUserForgetPasswordEnabled, + isUserVerifyEmailEnabled, + isInternalAuthEnabled, + isOAuth2Enabled +} from '@/lib/server_settings.ts'; import { mdiOnepassword, @@ -194,13 +211,16 @@ const { backupCode, tempToken, twoFAVerifyType, + oauth2ClientSessionId, logining, verifying, inputIsEmpty, twoFAInputIsEmpty, + oauth2LoginUrl, + oauth2LoginDisplayName, tips, doAfterLogin -} = useLoginPageBase(); +} = useLoginPageBase('desktop'); const passwordInput = useTemplateRef('passwordInput'); const passcodeInput = useTemplateRef('passcodeInput'); @@ -301,4 +321,6 @@ function verify(): void { } }); } + +oauth2ClientSessionId.value = generateRandomUUID(); diff --git a/src/views/desktop/OAuth2CallbackPage.vue b/src/views/desktop/OAuth2CallbackPage.vue new file mode 100644 index 00000000..52c7f901 --- /dev/null +++ b/src/views/desktop/OAuth2CallbackPage.vue @@ -0,0 +1,215 @@ + + + diff --git a/src/views/mobile/LoginPage.vue b/src/views/mobile/LoginPage.vue index 896bd8d3..4f6926e1 100644 --- a/src/views/mobile/LoginPage.vue +++ b/src/views/mobile/LoginPage.vue @@ -9,7 +9,7 @@ {{ tips }} - + - - + + + + {{ tt('Don\'t have an account?') }}  @@ -176,7 +182,15 @@ import { useRootStore } from '@/stores/index.ts'; import { APPLICATION_LOGO_PATH } from '@/consts/asset.ts'; import { KnownErrorCode } from '@/consts/api.ts'; -import { isUserRegistrationEnabled, isUserForgetPasswordEnabled, isUserVerifyEmailEnabled } from '@/lib/server_settings.ts'; + +import { generateRandomUUID } from '@/lib/misc.ts'; +import { + isUserRegistrationEnabled, + isUserForgetPasswordEnabled, + isUserVerifyEmailEnabled, + isInternalAuthEnabled, + isOAuth2Enabled +} from '@/lib/server_settings.ts'; import { getDesktopVersionPath } from '@/lib/version.ts'; import { useI18nUIComponents, showLoading, hideLoading, isModalShowing } from '@/lib/ui/mobile.ts'; @@ -197,13 +211,16 @@ const { backupCode, tempToken, twoFAVerifyType, + oauth2ClientSessionId, logining, verifying, inputIsEmpty, twoFAInputIsEmpty, + oauth2LoginUrl, + oauth2LoginDisplayName, tips, doAfterLogin -} = useLoginPageBase(); +} = useLoginPageBase('mobile'); const forgetPasswordEmail = ref(''); const resendVerifyEmail = ref(''); @@ -389,4 +406,33 @@ function switch2FAVerifyType(): void { twoFAVerifyType.value = 'passcode'; } } + +oauth2ClientSessionId.value = generateRandomUUID(); + + diff --git a/third-party-dependencies.json b/third-party-dependencies.json index 53d331af..2f3f4ec9 100644 --- a/third-party-dependencies.json +++ b/third-party-dependencies.json @@ -99,6 +99,12 @@ "url": "https://golang.org/x/text", "licenseUrl": "https://cs.opensource.google/go/x/text/+/refs/tags/v0.28.0:LICENSE" }, + { + "name": "Go OAuth2", + "copyright": "Copyright (c) 2009 The Go Authors. All rights reserved.", + "url": "https://golang.org/x/oauth2", + "licenseUrl": "https://cs.opensource.google/go/x/oauth2/+/refs/tags/v0.31.0:LICENSE" + }, { "name": "Gomail", "copyright": "Copyright (c) 2014 Alexandre Cesaro", diff --git a/vite.config.ts b/vite.config.ts index 96eb3c81..57f915a8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -290,6 +290,10 @@ export default defineConfig(() => { target: 'http://127.0.0.1:8080/', changeOrigin: true }, + '/oauth2': { + target: 'http://127.0.0.1:8080/', + changeOrigin: true + }, '/api': { target: 'http://127.0.0.1:8080/', changeOrigin: true