support Nextcloud OAuth 2.0 authentication

This commit is contained in:
MaysWind
2025-10-21 01:52:28 +08:00
parent 600ae2bd58
commit 53a8ad71c6
74 changed files with 2046 additions and 241 deletions

View File

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

View File

@@ -200,5 +200,9 @@ func getConfigWithoutSensitiveData(config *settings.Config) *settings.Config {
}
}
if clonedConfig.OAuth2ClientSecret != "" {
clonedConfig.OAuth2ClientSecret = "****"
}
return clonedConfig
}

View File

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

View File

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

1
go.mod
View File

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

2
go.sum
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(),
}
}

View File

@@ -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,
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,6 +41,8 @@ const (
NormalSubcategoryUserCustomExchangeRate = 13
NormalSubcategoryModelContextProtocol = 14
NormalSubcategoryLargeLanguageModel = 15
NormalSubcategoryUserExternalAuth = 16
NormalSubcategoryOAuth2 = 17
)
// Error represents the specific error returned to user

10
pkg/errs/external_auth.go Normal file
View File

@@ -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")
)

19
pkg/errs/oauth2.go Normal file
View File

@@ -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")
)

View File

@@ -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")
)

View File

@@ -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")
)

View File

@@ -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()),
}
}

View File

@@ -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()

View File

@@ -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()),
}
}

View File

@@ -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,
})

View File

@@ -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,
})

View File

@@ -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,
})

View File

@@ -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,
})
}

View File

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

View File

@@ -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,
})

View File

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

19
pkg/models/oauth2.go Normal file
View File

@@ -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"`
}

View File

@@ -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"`
}

View File

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

View File

@@ -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
})
}

View File

@@ -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),

View File

@@ -26,5 +26,9 @@ func (c *ConfigContainer) GetCurrentConfig() *Config {
}
func GetUserAgent() string {
if Version == "" {
return "ezBookkeeping"
}
return fmt.Sprintf("ezBookkeeping/%s", Version)
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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" {

3
src/consts/oauth2.ts Normal file
View File

@@ -0,0 +1,3 @@
export const OAUTH2_PROVIDER_DISPLAY_NAME: Record<string, string> = {
'nextcloud': 'Nextcloud'
}

View File

@@ -3,6 +3,14 @@ function getServerSetting(key: string): string | number | boolean | Record<strin
return settings[key];
}
export function isInternalAuthEnabled(): boolean {
return getServerSetting('a') !== 0;
}
export function isOAuth2Enabled(): boolean {
return getServerSetting('o') === 1;
}
export function isUserRegistrationEnabled(): boolean {
return getServerSetting('r') === 1;
}
@@ -31,6 +39,14 @@ export function isDataImportingEnabled(): boolean {
return getServerSetting('i') === 1;
}
export function getOAuth2Provider(): string {
return getServerSetting('op') as string;
}
export function getOIDCCustomDisplayNames(): Record<string, string>{
return getServerSetting('ocn') as Record<string, string>;
}
export function isMCPServerEnabled(): boolean {
return getServerSetting('mcp') === 1;
}

View File

@@ -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<AuthResponse> => {
return axios.post<ApiResponse<AuthResponse>>('oauth2/authorize.json', req, {
headers: {
Authorization: `Bearer ${token}`
}
});
},
register: (req: UserRegisterRequest): ApiResponsePromise<RegisterResponse> => {
return axios.post<ApiResponse<RegisterResponse>>('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`;
},

View File

@@ -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?",

View File

@@ -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?",

View File

@@ -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?",

View File

@@ -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 ?",

View File

@@ -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, string>): string {
if (!tipConfig) {
function getServerMultiLanguageConfigContent(multiLanguageConfig: Record<string, string>): 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, string>): 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, string>): 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,

View File

@@ -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?",

View File

@@ -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?",

View File

@@ -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?": "계정이 없으신가요?",

View File

@@ -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?",

View File

@@ -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?",

View File

@@ -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?": "Нет учетной записи?",

View File

@@ -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?": "ยังไม่มีบัญชี?",

View File

@@ -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?": "Немає облікового запису?",

View File

@@ -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?",

View File

@@ -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?": "还没有账号?",

View File

@@ -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?": "還沒有帳號?",

4
src/models/oauth2.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface OAuth2CallbackLoginRequest {
readonly provider?: string;
readonly password?: string;
}

View File

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

View File

@@ -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<AuthResponse> {
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<AuthResponse> {
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<RegisterResponse> {
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,

View File

@@ -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<string>('');
const tempToken = ref<string>('');
const twoFAVerifyType = ref<string>('passcode');
const oauth2ClientSessionId = ref<string>('');
const logining = ref<boolean>(false);
const verifying = ref<boolean>(false);
@@ -40,7 +41,9 @@ export function useLoginPageBase() {
}
});
const tips = computed<string>(() => getServerTipContent(getLoginPageTips()));
const oauth2LoginUrl = computed<string>(() => rootStore.generateOAuth2LoginUrl(platform, oauth2ClientSessionId.value));
const oauth2LoginDisplayName = computed<string>(() => getLocalizedOAuth2LoginText(getOAuth2Provider(), getOIDCCustomDisplayNames()));
const tips = computed<string>(() => 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

View File

@@ -24,14 +24,14 @@
<v-card variant="flat" class="w-100 mt-0 px-4 pt-12" max-width="500">
<v-card-text>
<h4 class="text-h4 mb-2">{{ tt('Welcome to ezBookkeeping') }}</h4>
<p class="mb-0">{{ tt('Please log in with your ezBookkeeping account') }}</p>
<p class="mb-0" v-if="isInternalAuthEnabled()">{{ tt('Please log in with your ezBookkeeping account') }}</p>
<p class="mt-1 mb-0" v-if="tips">{{ tips }}</p>
</v-card-text>
<v-card-text class="pb-0 mb-6">
<v-form>
<v-row>
<v-col cols="12">
<v-col cols="12" v-if="isInternalAuthEnabled()">
<v-text-field
type="text"
autocomplete="username"
@@ -45,7 +45,7 @@
/>
</v-col>
<v-col cols="12">
<v-col cols="12" v-if="isInternalAuthEnabled()">
<v-text-field
autocomplete="current-password"
ref="passwordInput"
@@ -101,10 +101,20 @@
<v-col cols="12">
<v-btn block :disabled="inputIsEmpty || logining || verifying"
@click="login" v-if="!show2faInput">
@click="login" v-if="isInternalAuthEnabled() && !show2faInput">
{{ tt('Log In') }}
<v-progress-circular indeterminate size="22" class="ms-2" v-if="logining"></v-progress-circular>
</v-btn>
<v-col cols="12" class="d-flex align-center px-0" v-if="isInternalAuthEnabled() && isOAuth2Enabled()">
<v-divider class="me-3" />
{{ tt('or') }}
<v-divider class="ms-3" />
</v-col>
<v-btn block :disabled="logining || verifying" :href="oauth2LoginUrl" v-if="isOAuth2Enabled()">
{{ oauth2LoginDisplayName }}
</v-btn>
<v-btn block :disabled="twoFAInputIsEmpty || logining || verifying"
@click="verify" v-else-if="show2faInput">
{{ tt('Continue') }}
@@ -112,7 +122,7 @@
</v-btn>
</v-col>
<v-col cols="12" class="text-center text-base">
<v-col cols="12" class="text-center text-base" v-if="isInternalAuthEnabled()">
<span class="me-1">{{ tt('Don\'t have an account?') }}</span>
<router-link class="text-primary" to="/signup"
:class="{'disabled': !isUserRegistrationEnabled()}">
@@ -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<VTextField>('passwordInput');
const passcodeInput = useTemplateRef<VTextField>('passcodeInput');
@@ -301,4 +321,6 @@ function verify(): void {
}
});
}
oauth2ClientSessionId.value = generateRandomUUID();
</script>

View File

@@ -0,0 +1,215 @@
<template>
<div class="layout-wrapper">
<router-link to="/">
<div class="auth-logo d-flex align-start gap-x-3">
<img alt="logo" class="login-page-logo" :src="APPLICATION_LOGO_PATH" />
<h1 class="font-weight-medium leading-normal text-2xl">{{ tt('global.app.title') }}</h1>
</div>
</router-link>
<v-row no-gutters class="auth-wrapper">
<v-col cols="12" md="8" class="auth-image-background d-none d-md-flex align-center justify-center position-relative">
<div class="d-flex auth-img-footer" v-if="!isDarkMode">
<v-img class="img-with-direction" src="img/desktop/background.svg"/>
</div>
<div class="d-flex auth-img-footer" v-if="isDarkMode">
<v-img class="img-with-direction" src="img/desktop/background-dark.svg"/>
</div>
<div class="d-flex align-center justify-center w-100 pt-10">
<v-img class="img-with-direction" max-width="300px" src="img/desktop/people2.svg" v-if="!isDarkMode"/>
<v-img class="img-with-direction" max-width="300px" src="img/desktop/people2-dark.svg" v-else-if="isDarkMode"/>
</div>
</v-col>
<v-col cols="12" md="4" class="auth-card d-flex flex-column">
<div class="d-flex align-center justify-center h-100">
<v-card variant="flat" class="w-100 mt-0 px-4 pt-12" max-width="500">
<v-card-text>
<h4 class="text-h4 mb-2">{{ oauth2LoginDisplayName }}</h4>
<p class="mb-0" v-if="!error && platform && token && !userName">{{ tt('Logging in...') }}</p>
<p class="mb-0" v-else-if="!error && userName">{{ tt('format.misc.oauth2bindTip', { providerName: oauth2ProviderDisplayName, userName: userName }) }}</p>
<p class="mb-0" v-else-if="error">{{ tt(error) }}</p>
<p class="mb-0" v-else>{{ tt('An error occurred') }}</p>
</v-card-text>
<v-card-text class="pb-0 mb-6" v-if="!error && userName">
<v-form>
<v-row>
<v-col cols="12">
<v-text-field
type="password"
autocomplete="password"
:autofocus="true"
:disabled="logining"
:label="tt('Password')"
:placeholder="tt('Your password')"
v-model="password"
@keyup.enter="verify"
/>
</v-col>
<v-col cols="12">
<v-btn block type="submit" :disabled="!password || logining" @click="verify">
{{ tt('Continue') }}
<v-progress-circular indeterminate size="22" class="ms-2" v-if="logining"></v-progress-circular>
</v-btn>
</v-col>
<v-col cols="12">
<router-link class="d-flex align-center justify-center" to="/login"
:class="{ 'disabled': logining }">
<v-icon class="icon-with-direction" :icon="mdiChevronLeft"/>
<span>{{ tt('Back to login page') }}</span>
</router-link>
</v-col>
</v-row>
</v-form>
</v-card-text>
</v-card>
</div>
<v-spacer/>
<div class="d-flex align-center justify-center">
<v-card variant="flat" class="w-100 px-4 pb-4" max-width="500">
<v-card-text class="pt-0">
<v-row>
<v-col cols="12" class="text-center">
<language-select-button :disabled="logining" />
</v-col>
<v-col cols="12" class="d-flex align-center pt-0">
<v-divider />
</v-col>
<v-col cols="12" class="text-center text-sm">
<span>Powered by </span>
<a href="https://github.com/mayswind/ezbookkeeping" target="_blank">ezBookkeeping</a>&nbsp;<span>{{ version }}</span>
</v-col>
</v-row>
</v-card-text>
</v-card>
</div>
</v-col>
</v-row>
<snack-bar ref="snackbar" />
</div>
</template>
<script setup lang="ts">
import SnackBar from '@/components/desktop/SnackBar.vue';
import { computed, useTemplateRef } from 'vue';
import { useRouter } from 'vue-router';
import { useTheme } from 'vuetify';
import { useI18n } from '@/locales/helpers.ts';
import { useLoginPageBase } from '@/views/base/LoginPageBase.ts';
import { useRootStore } from '@/stores/index.ts';
import { ThemeType } from '@/core/theme.ts';
import { APPLICATION_LOGO_PATH } from '@/consts/asset.ts';
import { KnownErrorCode } from '@/consts/api.ts';
import {
isUserVerifyEmailEnabled,
getOAuth2Provider,
getOIDCCustomDisplayNames
} from '@/lib/server_settings.ts';
import {
mdiChevronLeft
} from '@mdi/js';
type SnackBarType = InstanceType<typeof SnackBar>;
const props = defineProps<{
token?: string;
provider?: string;
platform?: string;
userName?: string;
error?: string;
}>();
const router = useRouter();
const theme = useTheme();
const { tt, getLocalizedOAuth2ProviderName, getLocalizedOAuth2LoginText } = useI18n();
const rootStore = useRootStore();
const {
version,
password,
logining,
doAfterLogin
} = useLoginPageBase('desktop');
const snackbar = useTemplateRef<SnackBarType>('snackbar');
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
const oauth2ProviderDisplayName = computed<string>(() => getLocalizedOAuth2ProviderName(getOAuth2Provider(), getOIDCCustomDisplayNames()));
const oauth2LoginDisplayName = computed<string>(() => getLocalizedOAuth2LoginText(getOAuth2Provider(), getOIDCCustomDisplayNames()));
const inputProblemMessage = computed<string | null>(() => {
if (!password.value) {
return 'Password cannot be blank';
} else {
return null;
}
});
function verify(): void {
const problemMessage = inputProblemMessage.value;
if (problemMessage) {
snackbar.value?.showMessage(problemMessage);
return;
}
logining.value = true;
rootStore.authorizeOAuth2({
provider: props.provider || '',
password: password.value,
token: props.token || ''
}).then(authResponse => {
logining.value = false;
doAfterLogin(authResponse);
router.replace('/');
}).catch(error => {
logining.value = false;
if (isUserVerifyEmailEnabled() && error.error && error.error.errorCode === KnownErrorCode.UserEmailNotVerified && error.error.context && error.error.context.email) {
router.push(`/verify_email?email=${encodeURIComponent(error.error.context.email)}&emailSent=${error.error.context.hasValidEmailVerifyToken || false}`);
return;
}
if (!error.processed) {
snackbar.value?.showError(error);
}
});
}
if (!props.error && props.platform && props.token && !props.userName) {
logining.value = true;
rootStore.authorizeOAuth2({
provider: props.provider || '',
token: props.token || ''
}).then(authResponse => {
logining.value = false;
doAfterLogin(authResponse);
router.replace('/');
}).catch(error => {
logining.value = false;
if (isUserVerifyEmailEnabled() && error.error && error.error.errorCode === KnownErrorCode.UserEmailNotVerified && error.error.context && error.error.context.email) {
router.push(`/verify_email?email=${encodeURIComponent(error.error.context.email)}&emailSent=${error.error.context.hasValidEmailVerifyToken || false}`);
return;
}
if (!error.processed) {
snackbar.value?.showError(error);
}
});
}
</script>

View File

@@ -9,7 +9,7 @@
<f7-block-footer>{{ tips }}</f7-block-footer>
</f7-list>
<f7-list form dividers class="margin-bottom-half">
<f7-list form dividers class="margin-bottom-half" v-if="isInternalAuthEnabled()">
<f7-list-input
type="text"
autocomplete="username"
@@ -47,8 +47,14 @@
</f7-list>
<f7-list class="margin-vertical-half">
<f7-list-button :class="{ 'disabled': inputIsEmpty || logining }" :text="tt('Log In')" @click="login"></f7-list-button>
<f7-block-footer>
<f7-list-button :class="{ 'disabled': inputIsEmpty || logining }" :text="tt('Log In')" @click="login" v-if="isInternalAuthEnabled()"></f7-list-button>
<f7-list-item class="login-divider display-flex align-items-center" v-if="isInternalAuthEnabled() && isOAuth2Enabled()">
<hr class="margin-inline-end-half" />
<small>{{ tt('or') }}</small>
<hr class="margin-inline-start-half" />
</f7-list-item>
<f7-list-button external :class="{ 'disabled': logining }" :href="oauth2LoginUrl" :text="oauth2LoginDisplayName" v-if="isOAuth2Enabled()"></f7-list-button>
<f7-block-footer v-if="isInternalAuthEnabled()">
<span>{{ tt('Don\'t have an account?') }}</span>&nbsp;
<f7-link :class="{'disabled': !isUserRegistrationEnabled()}" href="/signup" :text="tt('Create an account')"></f7-link>
</f7-block-footer>
@@ -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<string>('');
const resendVerifyEmail = ref<string>('');
@@ -389,4 +406,33 @@ function switch2FAVerifyType(): void {
twoFAVerifyType.value = 'passcode';
}
}
oauth2ClientSessionId.value = generateRandomUUID();
</script>
<style>
.login-divider > .item-content {
width: 100%;
min-height: 0;
> .item-inner {
padding-top: 0;
padding-bottom: 0;
min-height: 0;
> small {
opacity: 0.7;
}
> hr {display: block;
flex: 1 1 100%;
height: 0;
max-height: 0;
border-style: solid;
border-width: thin 0 0 0;
opacity: 0.12;
transition: inherit;
}
}
}
</style>

View File

@@ -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",

View File

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