diff --git a/api/resthandler/oauth2.go b/api/resthandler/oauth2.go new file mode 100644 index 00000000..29e23e70 --- /dev/null +++ b/api/resthandler/oauth2.go @@ -0,0 +1,142 @@ +// Copyright 2023 Illa Soft, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package resthandler + +import ( + "encoding/json" + "fmt" + "os" + "time" + + "github.com/gin-gonic/gin" + "github.com/go-resty/resty/v2" + "github.com/illacloud/builder-backend/internal/idconvertor" + "github.com/illacloud/builder-backend/pkg/resource" + "github.com/mitchellh/mapstructure" + "go.uber.org/zap" +) + +type OAuth2RestHandler interface { + GoogleOAuth2(c *gin.Context) +} + +type OAuth2RestHandlerImpl struct { + logger *zap.SugaredLogger + resourceService resource.ResourceService +} + +func NewOAuth2RestHandlerImpl(logger *zap.SugaredLogger, resourceService resource.ResourceService) *OAuth2RestHandlerImpl { + return &OAuth2RestHandlerImpl{ + logger: logger, + resourceService: resourceService, + } +} + +func (impl OAuth2RestHandlerImpl) GoogleOAuth2(c *gin.Context) { + stateToken := c.Query("state") + code := c.Query("code") + errorOAuth2Callback := c.Query("error") + if stateToken == "" { + c.JSON(400, nil) + return + } + teamID, userID, resourceID, url, err := extractGSOAuth2Token(stateToken) + if err != nil { + c.Redirect(302, fmt.Sprintf("%s?status=%d&resourceID=%s", url, 2, idconvertor.ConvertIntToString(resourceID))) + return + } + if errorOAuth2Callback != "" || code == "" { + c.Redirect(302, fmt.Sprintf("%s?status=%d&resourceID=%s", url, 2, idconvertor.ConvertIntToString(resourceID))) + return + } + // get resource + res, err := impl.resourceService.GetResource(teamID, resourceID) + if err != nil { + c.Redirect(302, fmt.Sprintf("%s?status=%d&resourceID=%s", url, 2, idconvertor.ConvertIntToString(resourceID))) + return + } + if res.Type != "googlesheets" { + c.Redirect(302, fmt.Sprintf("%s?status=%d&resourceID=%s", url, 2, idconvertor.ConvertIntToString(resourceID))) + return + } + var googleSheetsResource GoogleSheetsResource + if err := mapstructure.Decode(res.Options, &googleSheetsResource); err != nil { + c.Redirect(302, fmt.Sprintf("%s?status=%d&resourceID=%s", url, 2, idconvertor.ConvertIntToString(resourceID))) + return + } + if googleSheetsResource.Authentication != "oauth2" { + c.Redirect(302, fmt.Sprintf("%s?status=%d&resourceID=%s", url, 2, idconvertor.ConvertIntToString(resourceID))) + return + } + + // exchange oauth2 refresh token and access token + client := resty.New() + resp, err := client.R(). + SetFormData(map[string]string{ + "client_id": os.Getenv("ILLA_GS_CLIENT_ID"), + "client_secret": os.Getenv("ILLA_GS_CLIENT_SECRET"), + "code": code, + "grant_type": "authorization_code", + "redirect_uri": os.Getenv("ILLA_GS_REDIRECT_URI"), + }). + Post("https://oauth2.googleapis.com/token") + if resp.IsSuccess() { + type ExchangeTokenSuccessResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Expiry int `json:"expires_in"` + Scope string `json:"scope"` + TokenType string `json:"token_type"` + } + var exchangeTokenSuccessResponse ExchangeTokenSuccessResponse + if err := json.Unmarshal(resp.Body(), &exchangeTokenSuccessResponse); err != nil { + c.Redirect(302, fmt.Sprintf("%s?status=%d&resourceID=%s", url, 2, idconvertor.ConvertIntToString(resourceID))) + return + } + googleSheetsResource.Opts.RefreshToken = exchangeTokenSuccessResponse.RefreshToken + googleSheetsResource.Opts.TokenType = exchangeTokenSuccessResponse.TokenType + googleSheetsResource.Opts.AccessToken = exchangeTokenSuccessResponse.AccessToken + googleSheetsResource.Opts.Status = 2 + } else if resp.IsError() { + c.Redirect(302, fmt.Sprintf("%s?status=%d&resourceID=%s", url, 2, idconvertor.ConvertIntToString(resourceID))) + return + } + + // update resource and return response + if _, err := impl.resourceService.UpdateResource(resource.ResourceDto{ + ID: idconvertor.ConvertStringToInt(res.ID), + Name: res.Name, + Type: res.Type, + Options: map[string]interface{}{ + "authentication": googleSheetsResource.Authentication, + "opts": map[string]interface{}{ + "accessType": googleSheetsResource.Opts.AccessType, + "accessToken": googleSheetsResource.Opts.AccessToken, + "tokenType": googleSheetsResource.Opts.TokenType, + "refreshToken": googleSheetsResource.Opts.RefreshToken, + "status": googleSheetsResource.Opts.Status, + }, + }, + UpdatedAt: time.Now().UTC(), + UpdatedBy: userID, + }); err != nil { + c.Redirect(302, fmt.Sprintf("%s?status=%d&resourceID=%s", url, 2, idconvertor.ConvertIntToString(resourceID))) + return + } + + // redirect + c.Redirect(302, fmt.Sprintf("%s?status=%d&resourceID=%s", url, 1, idconvertor.ConvertIntToString(resourceID))) + return +} diff --git a/api/resthandler/resource.go b/api/resthandler/resource.go index 9fe6abcf..83452073 100644 --- a/api/resthandler/resource.go +++ b/api/resthandler/resource.go @@ -17,12 +17,17 @@ package resthandler import ( "encoding/json" "net/http" + "net/url" + "os" "time" "github.com/go-playground/validator/v10" + "github.com/go-resty/resty/v2" ac "github.com/illacloud/builder-backend/internal/accesscontrol" + "github.com/illacloud/builder-backend/internal/idconvertor" "github.com/illacloud/builder-backend/internal/repository" "github.com/illacloud/builder-backend/pkg/resource" + "github.com/mitchellh/mapstructure" "github.com/gin-gonic/gin" "go.uber.org/zap" @@ -36,6 +41,9 @@ type ResourceRestHandler interface { DeleteResource(c *gin.Context) TestConnection(c *gin.Context) GetMetaInfo(c *gin.Context) + CreateOAuthToken(c *gin.Context) + GoogleSheetsOAuth2(c *gin.Context) + RefreshGSOAuth(c *gin.Context) } type ResourceRestHandlerImpl struct { @@ -366,3 +374,289 @@ func (impl ResourceRestHandlerImpl) GetMetaInfo(c *gin.Context) { c.JSON(http.StatusOK, res) return } + +type CreateOAuthTokenRequest struct { + RedirectURL string `json:"redirectURL" validate:"required"` + AccessType string `json:"accessType" validate:"oneof=rw r"` +} + +type GoogleSheetsResource struct { + Authentication string + Opts OAuth2Opts +} +type OAuth2Opts struct { + AccessType string + AccessToken string + TokenType string + RefreshToken string + Status int +} + +func (impl ResourceRestHandlerImpl) CreateOAuthToken(c *gin.Context) { + // fetch needed params + teamID, errInGetTeamID := GetMagicIntParamFromRequest(c, PARAM_TEAM_ID) + resourceID, errInGetResourceID := GetMagicIntParamFromRequest(c, PARAM_RESOURCE_ID) + userID, errInGetUserID := GetUserIDFromAuth(c) + userAuthToken, errInGetAuthToken := GetUserAuthTokenFromHeader(c) + if errInGetTeamID != nil || errInGetResourceID != nil || errInGetUserID != nil || errInGetAuthToken != nil { + return + } + + // validate + impl.AttributeGroup.Init() + impl.AttributeGroup.SetTeamID(teamID) + impl.AttributeGroup.SetUserAuthToken(userAuthToken) + impl.AttributeGroup.SetUnitType(ac.UNIT_TYPE_RESOURCE) + impl.AttributeGroup.SetUnitID(resourceID) + canManage, errInCheckAttr := impl.AttributeGroup.CanManage(ac.ACTION_MANAGE_EDIT_RESOURCE) + if errInCheckAttr != nil { + FeedbackBadRequest(c, ERROR_FLAG_ACCESS_DENIED, "error in check attribute: "+errInCheckAttr.Error()) + return + } + if !canManage { + FeedbackBadRequest(c, ERROR_FLAG_ACCESS_DENIED, "you can not access this attribute due to access control policy.") + return + } + + // parse request body + var createOAuthTokenRequest CreateOAuthTokenRequest + if err := json.NewDecoder(c.Request.Body).Decode(&createOAuthTokenRequest); err != nil { + FeedbackBadRequest(c, ERROR_FLAG_PARSE_REQUEST_BODY_FAILED, "parse request body error: "+err.Error()) + return + } + // validate request body fields + validate := validator.New() + if err := validate.Struct(createOAuthTokenRequest); err != nil { + FeedbackBadRequest(c, ERROR_FLAG_VALIDATE_REQUEST_BODY_FAILED, "validate request body error: "+err.Error()) + return + } + + // validate the resource id + res, err := impl.resourceService.GetResource(teamID, resourceID) + if err != nil { + FeedbackBadRequest(c, ERROR_FLAG_CAN_NOT_GET_RESOURCE, "get resources error: "+err.Error()) + return + } + if res.Type != "googlesheets" { + FeedbackBadRequest(c, ERROR_FLAG_CAN_NOT_CREATE_TOKEN, "unsupported resource type") + return + } + var googleSheetsResource GoogleSheetsResource + if err := mapstructure.Decode(res.Options, &googleSheetsResource); err != nil { + FeedbackBadRequest(c, ERROR_FLAG_CAN_NOT_CREATE_TOKEN, "get resource error: "+err.Error()) + return + } + if googleSheetsResource.Authentication != "oauth2" { + FeedbackBadRequest(c, ERROR_FLAG_CAN_NOT_CREATE_TOKEN, "unsupported authentication type") + return + } + + // generate access token + access := 0 + if createOAuthTokenRequest.AccessType == "rw" { + access = 1 + } else { + access = 2 + } + token, err := generateGSOAuth2Token(teamID, userID, resourceID, access, createOAuthTokenRequest.RedirectURL) + if err != nil { + FeedbackBadRequest(c, ERROR_FLAG_CAN_NOT_CREATE_TOKEN, "generate token error: "+err.Error()) + return + } + + c.JSON(http.StatusOK, gin.H{ + "accessToken": token, + }) + return +} + +type GoogleSheetsOAuth2Request struct { + AccessToken string `json:"accessToken" validate:"required"` +} + +func (impl ResourceRestHandlerImpl) GoogleSheetsOAuth2(c *gin.Context) { + // fetch needed params + teamID, errInGetTeamID := GetMagicIntParamFromRequest(c, PARAM_TEAM_ID) + resourceID, errInGetResourceID := GetMagicIntParamFromRequest(c, PARAM_RESOURCE_ID) + _, errInGetUserID := GetUserIDFromAuth(c) + userAuthToken, errInGetAuthToken := GetUserAuthTokenFromHeader(c) + if errInGetTeamID != nil || errInGetResourceID != nil || errInGetUserID != nil || errInGetAuthToken != nil { + return + } + + // validate + impl.AttributeGroup.Init() + impl.AttributeGroup.SetTeamID(teamID) + impl.AttributeGroup.SetUserAuthToken(userAuthToken) + impl.AttributeGroup.SetUnitType(ac.UNIT_TYPE_RESOURCE) + impl.AttributeGroup.SetUnitID(resourceID) + canManage, errInCheckAttr := impl.AttributeGroup.CanManage(ac.ACTION_MANAGE_EDIT_RESOURCE) + if errInCheckAttr != nil { + FeedbackBadRequest(c, ERROR_FLAG_ACCESS_DENIED, "error in check attribute: "+errInCheckAttr.Error()) + return + } + if !canManage { + FeedbackBadRequest(c, ERROR_FLAG_ACCESS_DENIED, "you can not access this attribute due to access control policy.") + return + } + + // parse request body + var gsOAuth2Request GoogleSheetsOAuth2Request + gsOAuth2Request.AccessToken = c.Query("accessToken") + // validate request body fields + validate := validator.New() + if err := validate.Struct(gsOAuth2Request); err != nil { + FeedbackBadRequest(c, ERROR_FLAG_VALIDATE_REQUEST_BODY_FAILED, "validate request body error: "+err.Error()) + return + } + + // validate the resource id + res, err := impl.resourceService.GetResource(teamID, resourceID) + if err != nil { + FeedbackBadRequest(c, ERROR_FLAG_CAN_NOT_GET_RESOURCE, "get resources error: "+err.Error()) + return + } + if res.Type != "googlesheets" { + FeedbackBadRequest(c, ERROR_FLAG_CAN_NOT_AUTHORIZE_GS, "unsupported resource type") + return + } + var googleSheetsResource GoogleSheetsResource + if err := mapstructure.Decode(res.Options, &googleSheetsResource); err != nil { + FeedbackBadRequest(c, ERROR_FLAG_CAN_NOT_AUTHORIZE_GS, "get resource error: "+err.Error()) + return + } + if googleSheetsResource.Authentication != "oauth2" { + FeedbackBadRequest(c, ERROR_FLAG_CAN_NOT_AUTHORIZE_GS, "unsupported authentication type") + return + } + + // validate access token + access, err := validateGSOAuth2Token(gsOAuth2Request.AccessToken) + if err != nil { + FeedbackBadRequest(c, ERROR_FLAG_CAN_NOT_AUTHORIZE_GS, "validate token error: "+err.Error()) + return + } + + // return new url + googleOAuthClientID := os.Getenv("ILLA_GS_CLIENT_ID") + redirectURI := os.Getenv("ILLA_GS_REDIRECT_URI") + u := url.URL{} + if access == 1 { + u = url.URL{ + Scheme: "https", + Host: "accounts.google.com", + Path: "o/oauth2/v2/auth/oauthchooseaccount", + RawQuery: "response_type=" + "code" + "&client_id=" + googleOAuthClientID + "&redirect_uri=" + redirectURI + "&state=" + gsOAuth2Request.AccessToken + "&scope=" + "https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/spreadsheets" + "&access_type=" + "offline" + "&prompt=" + "consent" + "&service=" + "lso" + "&o2v=" + "2" + "&flowName=" + "GeneralOAuthFlow", + } + } else { + u = url.URL{ + Scheme: "https", + Host: "accounts.google.com", + Path: "o/oauth2/v2/auth/oauthchooseaccount", + RawQuery: "response_type=" + "code" + "&client_id=" + googleOAuthClientID + "&redirect_uri=" + redirectURI + "&state=" + gsOAuth2Request.AccessToken + "&scope=" + "https://www.googleapis.com/auth/spreadsheets.readonly https://www.googleapis.com/auth/drive.readonly" + "&access_type=" + "offline" + "&prompt=" + "consent" + "&service=" + "lso" + "&o2v=" + "2" + "&flowName=" + "GeneralOAuthFlow", + } + } + c.JSON(200, gin.H{ + "url": u.String(), + }) + return +} + +func (impl ResourceRestHandlerImpl) RefreshGSOAuth(c *gin.Context) { + // fetch needed params + teamID, errInGetTeamID := GetMagicIntParamFromRequest(c, PARAM_TEAM_ID) + resourceID, errInGetResourceID := GetMagicIntParamFromRequest(c, PARAM_RESOURCE_ID) + userID, errInGetUserID := GetUserIDFromAuth(c) + userAuthToken, errInGetAuthToken := GetUserAuthTokenFromHeader(c) + if errInGetTeamID != nil || errInGetResourceID != nil || errInGetUserID != nil || errInGetAuthToken != nil { + return + } + + // validate + impl.AttributeGroup.Init() + impl.AttributeGroup.SetTeamID(teamID) + impl.AttributeGroup.SetUserAuthToken(userAuthToken) + impl.AttributeGroup.SetUnitType(ac.UNIT_TYPE_RESOURCE) + impl.AttributeGroup.SetUnitID(resourceID) + canManage, errInCheckAttr := impl.AttributeGroup.CanManage(ac.ACTION_MANAGE_EDIT_RESOURCE) + if errInCheckAttr != nil { + FeedbackBadRequest(c, ERROR_FLAG_ACCESS_DENIED, "error in check attribute: "+errInCheckAttr.Error()) + return + } + if !canManage { + FeedbackBadRequest(c, ERROR_FLAG_ACCESS_DENIED, "you can not access this attribute due to access control policy.") + return + } + + // validate the resource id + res, err := impl.resourceService.GetResource(teamID, resourceID) + if err != nil { + FeedbackBadRequest(c, ERROR_FLAG_CAN_NOT_REFRESH_GS, "get resources error: "+err.Error()) + return + } + if res.Type != "googlesheets" { + FeedbackBadRequest(c, ERROR_FLAG_CAN_NOT_REFRESH_GS, "unsupported resource type") + return + } + var googleSheetsResource GoogleSheetsResource + if err := mapstructure.Decode(res.Options, &googleSheetsResource); err != nil { + FeedbackBadRequest(c, ERROR_FLAG_CAN_NOT_REFRESH_GS, "get resource error: "+err.Error()) + return + } + if googleSheetsResource.Authentication != "oauth2" { + FeedbackBadRequest(c, ERROR_FLAG_CAN_NOT_REFRESH_GS, "unsupported authentication type") + return + } + + // get new access token + client := resty.New() + resp, err := client.R(). + SetFormData(map[string]string{ + "client_id": os.Getenv("ILLA_GS_CLIENT_ID"), + "client_secret": os.Getenv("ILLA_GS_CLIENT_SECRET"), + "refresh_token": googleSheetsResource.Opts.RefreshToken, + "grant_type": "refresh_token", + }). + Post("https://oauth2.googleapis.com/token") + if resp.IsSuccess() { + type RefreshTokenSuccessResponse struct { + AccessToken string `json:"access_token"` + Expiry int `json:"expires_in"` + Scope string `json:"scope"` + TokenType string `json:"token_type"` + } + var refreshTokenSuccessResponse RefreshTokenSuccessResponse + if err := json.Unmarshal(resp.Body(), &refreshTokenSuccessResponse); err != nil { + FeedbackBadRequest(c, ERROR_FLAG_CAN_NOT_REFRESH_GS, "fresh google sheets error: "+err.Error()) + return + } + googleSheetsResource.Opts.AccessToken = refreshTokenSuccessResponse.AccessToken + } else if resp.IsError() { + googleSheetsResource.Opts.RefreshToken = "" + googleSheetsResource.Opts.Status = 1 + googleSheetsResource.Opts.AccessToken = "" + googleSheetsResource.Opts.TokenType = "" + } + + // update resource and return response + updateRes, err := impl.resourceService.UpdateResource(resource.ResourceDto{ + ID: idconvertor.ConvertStringToInt(res.ID), + Name: res.Name, + Type: res.Type, + Options: map[string]interface{}{ + "authentication": googleSheetsResource.Authentication, + "opts": map[string]interface{}{ + "accessType": googleSheetsResource.Opts.AccessType, + "accessToken": googleSheetsResource.Opts.AccessToken, + "tokenType": googleSheetsResource.Opts.TokenType, + "refreshToken": googleSheetsResource.Opts.RefreshToken, + "status": googleSheetsResource.Opts.Status, + }, + }, + UpdatedAt: time.Now().UTC(), + UpdatedBy: userID, + }) + res.Options = updateRes.Options + FeedbackOK(c, res) + return +} diff --git a/api/resthandler/utils.go b/api/resthandler/utils.go index a2d1473c..b9e02125 100644 --- a/api/resthandler/utils.go +++ b/api/resthandler/utils.go @@ -3,9 +3,12 @@ package resthandler import ( "errors" "net/http" + "os" "strconv" + "time" "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v4" "github.com/illacloud/builder-backend/internal/idconvertor" "github.com/illacloud/builder-backend/internal/repository" ) @@ -123,6 +126,11 @@ const ( ERROR_FLAG_BUILD_USER_INFO_FAILED ERROR_FLAG_BUILD_APP_CONFIG_FAILED ERROR_FLAG_GENERATE_PASSWORD_FAILED + + // google sheets oauth2 failed + ERROR_FLAG_CAN_NOT_CREATE_TOKEN + ERROR_FLAG_CAN_NOT_AUTHORIZE_GS + ERROR_FLAG_CAN_NOT_REFRESH_GS ) func GetUserAuthTokenFromHeader(c *gin.Context) (string, error) { @@ -226,3 +234,73 @@ func FeedbackInternalServerError(c *gin.Context, errorFlag int, errorMessage str }) return } + +type GSOAuth2Claims struct { + Team int `json:"team"` + User int `json:"user"` + Resource int `json:"resource"` + Access int `json:"access"` + URL string `json:"url"` + jwt.RegisteredClaims +} + +func generateGSOAuth2Token(teamID, userID, resourceID, accessType int, redirectURL string) (string, error) { + claims := &GSOAuth2Claims{ + Team: teamID, + User: userID, + Resource: resourceID, + Access: accessType, + URL: redirectURL, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "ILLA", + ExpiresAt: &jwt.NumericDate{ + Time: time.Now().Add(time.Minute * 1), + }, + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + accessToken, err := token.SignedString([]byte(os.Getenv("ILLA_SECRET_KEY"))) + if err != nil { + return "", err + } + + return accessToken, nil +} + +func validateGSOAuth2Token(accessToken string) (int, error) { + authClaims := &GSOAuth2Claims{} + token, err := jwt.ParseWithClaims(accessToken, authClaims, func(token *jwt.Token) (interface{}, error) { + return []byte(os.Getenv("ILLA_SECRET_KEY")), nil + }) + if err != nil { + return 0, err + } + + claims, ok := token.Claims.(*GSOAuth2Claims) + if !(ok && token.Valid) { + return 0, err + } + + access := claims.Access + + return access, nil +} + +func extractGSOAuth2Token(stateToken string) (teamID, userID, resourceID int, url string, err error) { + authClaims := &GSOAuth2Claims{} + token, err := jwt.ParseWithClaims(stateToken, authClaims, func(token *jwt.Token) (interface{}, error) { + return []byte(os.Getenv("ILLA_SECRET_KEY")), nil + }) + if err != nil { + return 0, 0, 0, "", err + } + + claims, ok := token.Claims.(*GSOAuth2Claims) + if !(ok && token.Valid) { + return 0, 0, 0, "", err + } + + return claims.Team, claims.User, claims.Resource, claims.URL, nil +} diff --git a/api/router/oauth2.go b/api/router/oauth2.go new file mode 100644 index 00000000..3810aee6 --- /dev/null +++ b/api/router/oauth2.go @@ -0,0 +1,36 @@ +// Copyright 2023 Illa Soft, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package router + +import ( + "github.com/gin-gonic/gin" + "github.com/illacloud/builder-backend/api/resthandler" +) + +type OAuth2Router interface { + InitOAuth2Router(oauth2Router *gin.RouterGroup) +} + +type OAuth2RouterImpl struct { + OAuth2RestHandler resthandler.OAuth2RestHandler +} + +func NewOAuth2RouterImpl(OAuth2RestHandler resthandler.OAuth2RestHandler) *OAuth2RouterImpl { + return &OAuth2RouterImpl{OAuth2RestHandler: OAuth2RestHandler} +} + +func (impl OAuth2RouterImpl) InitOAuth2Router(oauth2Router *gin.RouterGroup) { + oauth2Router.GET("/authorize", impl.OAuth2RestHandler.GoogleOAuth2) +} diff --git a/api/router/resource.go b/api/router/resource.go index 42f4d984..0d267f7a 100644 --- a/api/router/resource.go +++ b/api/router/resource.go @@ -40,4 +40,7 @@ func (impl ResourceRouterImpl) InitResourceRouter(resourceRouter *gin.RouterGrou resourceRouter.DELETE("/:resourceID", impl.resourceRestHandler.DeleteResource) resourceRouter.POST("/testConnection", impl.resourceRestHandler.TestConnection) resourceRouter.GET("/:resourceID/meta", impl.resourceRestHandler.GetMetaInfo) + resourceRouter.POST("/:resourceID/token", impl.resourceRestHandler.CreateOAuthToken) + resourceRouter.GET("/:resourceID/oauth2", impl.resourceRestHandler.GoogleSheetsOAuth2) + resourceRouter.POST("/:resourceID/refresh", impl.resourceRestHandler.RefreshGSOAuth) } diff --git a/api/router/router.go b/api/router/router.go index 45373bae..edb5d62a 100644 --- a/api/router/router.go +++ b/api/router/router.go @@ -33,10 +33,12 @@ type RESTRouter struct { InternalActionRouter InternalActionRouter ResourceRouter ResourceRouter StatusRouter StatusRouter + OAuth2Router OAuth2Router } func NewRESTRouter(logger *zap.SugaredLogger, builderRouter BuilderRouter, appRouter AppRouter, publicAppRouter PublicAppRouter, roomRouter RoomRouter, - actionRouter ActionRouter, publicActionRouter PublicActionRouter, internalActionRouter InternalActionRouter, resourceRouter ResourceRouter, statusRouter StatusRouter) *RESTRouter { + actionRouter ActionRouter, publicActionRouter PublicActionRouter, internalActionRouter InternalActionRouter, resourceRouter ResourceRouter, statusRouter StatusRouter, + oauth2Router OAuth2Router) *RESTRouter { return &RESTRouter{ logger: logger, BuilderRouter: builderRouter, @@ -48,6 +50,7 @@ func NewRESTRouter(logger *zap.SugaredLogger, builderRouter BuilderRouter, appRo InternalActionRouter: internalActionRouter, ResourceRouter: resourceRouter, StatusRouter: statusRouter, + OAuth2Router: oauth2Router, } } @@ -63,6 +66,7 @@ func (r RESTRouter) InitRouter(router *gin.RouterGroup) { internalActionRouter := v1.Group("/teams/:teamID/apps/:appID/internalActions") roomRouter := v1.Group("/teams/:teamID/room") statusRouter := v1.Group("/status") + oauth2Router := v1.Group("/oauth2") builderRouter.Use(user.RemoteJWTAuth()) appRouter.Use(user.RemoteJWTAuth()) @@ -80,5 +84,6 @@ func (r RESTRouter) InitRouter(router *gin.RouterGroup) { r.InternalActionRouter.InitInternalActionRouter(internalActionRouter) r.ResourceRouter.InitResourceRouter(resourceRouter) r.StatusRouter.InitStatusRouter(statusRouter) + r.OAuth2Router.InitOAuth2Router(oauth2Router) } diff --git a/cmd/http-server/wire_gen.go b/cmd/http-server/wire_gen.go index e5f58010..99e1b14e 100644 --- a/cmd/http-server/wire_gen.go +++ b/cmd/http-server/wire_gen.go @@ -71,6 +71,9 @@ func Initialize() (*Server, error) { resourceServiceImpl := resource.NewResourceServiceImpl(sugaredLogger, resourceRepositoryImpl) resourceRestHandlerImpl := resthandler.NewResourceRestHandlerImpl(sugaredLogger, resourceServiceImpl, attrg) resourceRouterImpl := router.NewResourceRouterImpl(resourceRestHandlerImpl) + // oauth2 + oauth2RestHandlerImpl := resthandler.NewOAuth2RestHandlerImpl(sugaredLogger, resourceServiceImpl) + oauth2RouterImpl := router.NewOAuth2RouterImpl(oauth2RestHandlerImpl) // actions actionRestHandlerImpl := resthandler.NewActionRestHandlerImpl(sugaredLogger, appServiceImpl, actionServiceImpl, attrg) actionRouterImpl := router.NewActionRouterImpl(actionRestHandlerImpl) @@ -84,7 +87,7 @@ func Initialize() (*Server, error) { builderServiceImpl := builder.NewBuilderServiceImpl(sugaredLogger, appRepositoryImpl, resourceRepositoryImpl, actionRepositoryImpl) builderRestHandlerImpl := resthandler.NewBuilderRestHandlerImpl(sugaredLogger, builderServiceImpl, attrg) builderRouterImpl := router.NewBuilderRouterImpl(builderRestHandlerImpl) - restRouter := router.NewRESTRouter(sugaredLogger, builderRouterImpl, appRouterImpl, publicAppRouterImpl, roomRouterImpl, actionRouterImpl, publicActionRouterImpl, internalActionRouterImpl, resourceRouterImpl, statusRouterImpl) + restRouter := router.NewRESTRouter(sugaredLogger, builderRouterImpl, appRouterImpl, publicAppRouterImpl, roomRouterImpl, actionRouterImpl, publicActionRouterImpl, internalActionRouterImpl, resourceRouterImpl, statusRouterImpl, oauth2RouterImpl) server := NewServer(config, engine, restRouter, sugaredLogger) return server, nil } diff --git a/cmd/http-server/wireset/oauth2.go b/cmd/http-server/wireset/oauth2.go new file mode 100644 index 00000000..88be30db --- /dev/null +++ b/cmd/http-server/wireset/oauth2.go @@ -0,0 +1,34 @@ +// Copyright 2023 Illa Soft, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wireset + +import ( + "github.com/google/wire" + "github.com/illacloud/builder-backend/api/resthandler" + "github.com/illacloud/builder-backend/api/router" + "github.com/illacloud/builder-backend/internal/repository" + "github.com/illacloud/builder-backend/pkg/resource" +) + +var OAuth2WireSet = wire.NewSet( + repository.NewResourceRepositoryImpl, + wire.Bind(new(repository.ResourceRepository), new(*repository.ResourceRepositoryImpl)), + resource.NewResourceServiceImpl, + wire.Bind(new(resource.ResourceService), new(*resource.ResourceServiceImpl)), + resthandler.NewOAuth2RestHandlerImpl, + wire.Bind(new(resthandler.OAuth2RestHandler), new(*resthandler.OAuth2RestHandlerImpl)), + router.NewOAuth2RouterImpl, + wire.Bind(new(router.OAuth2Router), new(*router.OAuth2RouterImpl)), +) diff --git a/internal/websocket/hub.go b/internal/websocket/hub.go index 36ec9aac..6c6fa3c9 100644 --- a/internal/websocket/hub.go +++ b/internal/websocket/hub.go @@ -187,6 +187,30 @@ func (hub *Hub) BroadcastToRoomAllClients(message *Message, currentClient *Clien } } +func (hub *Hub) BroadcastToTeamAllClients(message *Message, currentClient *Client, includeCurrentClient bool) { + feed := Feedback{ + ErrorCode: ERROR_CODE_BROADCAST, + ErrorMessage: "", + Broadcast: message.Broadcast, + Data: nil, + } + feedbyte, _ := feed.Serialization() + for clientid, client := range hub.Clients { + if client.IsDead() { + hub.RemoveClient(client) + continue + } + if client.TeamID != currentClient.TeamID { + continue + } + if clientid == currentClient.ID && !includeCurrentClient { + continue + } + client.Send <- feedbyte + } +} + +// WARRING: This method will broadcast to server all clients. Use it carefully. func (hub *Hub) BroadcastToGlobal(message *Message, currentClient *Client, includeCurrentClient bool) { feed := Feedback{ ErrorCode: ERROR_CODE_BROADCAST, diff --git a/pkg/plugins/googlesheets/base.go b/pkg/plugins/googlesheets/base.go index 3ed28c67..82c250ca 100644 --- a/pkg/plugins/googlesheets/base.go +++ b/pkg/plugins/googlesheets/base.go @@ -21,6 +21,7 @@ import ( "strconv" "github.com/mitchellh/mapstructure" + "golang.org/x/oauth2" "golang.org/x/oauth2/google" "google.golang.org/api/drive/v3" "google.golang.org/api/option" @@ -54,7 +55,11 @@ func (g *Connector) getSheetsWithOpts(resourceOpts map[string]interface{}) (*she } return getSheetsWithKey(saOpts.PrivateKey) case OAUTH2_AUTH: - return getSheetsWithOAuth2() + var oauth2Opts OAuth2Opts + if err := mapstructure.Decode(g.ResourceOpts.Opts, &oauth2Opts); err != nil { + return nil, err + } + return getSheetsWithOAuth2(oauth2Opts) default: return nil, errors.New("unsupported authentication method") } @@ -79,9 +84,16 @@ func getSheetsWithKey(privateKey string) (*sheets.Service, error) { return srv, nil } -func getSheetsWithOAuth2() (*sheets.Service, error) { - // TODO - return nil, nil +func getSheetsWithOAuth2(opts OAuth2Opts) (*sheets.Service, error) { + ctx := context.Background() + httpClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: opts.AccessToken})) + + srv, err := sheets.NewService(ctx, option.WithHTTPClient(httpClient)) + if err != nil { + return nil, err + } + + return srv, nil } func (g *Connector) getDriveWithOpts(resourceOpts map[string]interface{}) (*drive.Service, error) { @@ -96,7 +108,11 @@ func (g *Connector) getDriveWithOpts(resourceOpts map[string]interface{}) (*driv } return getDriveWithKey(saOpts.PrivateKey) case OAUTH2_AUTH: - return getDriveWithOAuth2() + var oauth2Opts OAuth2Opts + if err := mapstructure.Decode(g.ResourceOpts.Opts, &oauth2Opts); err != nil { + return nil, err + } + return getDriveWithOAuth2(oauth2Opts) default: return nil, errors.New("unsupported authentication method") } @@ -121,9 +137,15 @@ func getDriveWithKey(privateKey string) (*drive.Service, error) { return srv, nil } -func getDriveWithOAuth2() (*drive.Service, error) { - // TODO - return nil, nil +func getDriveWithOAuth2(opts OAuth2Opts) (*drive.Service, error) { + ctx := context.Background() + httpClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: opts.AccessToken})) + + srv, err := drive.NewService(ctx, option.WithHTTPClient(httpClient)) + if err != nil { + return nil, err + } + return srv, nil } func interfaceToString(i interface{}) string { diff --git a/pkg/plugins/googlesheets/types.go b/pkg/plugins/googlesheets/types.go index fe6e1764..bc887adc 100644 --- a/pkg/plugins/googlesheets/types.go +++ b/pkg/plugins/googlesheets/types.go @@ -26,8 +26,9 @@ type SAOpts struct { type OAuth2Opts struct { AccessType string `validate:"required,oneof=rw r"` AccessToken string + TokenType string RefreshToken string - IDToken string + Status int } type Action struct { diff --git a/pkg/plugins/hfendpoint/service.go b/pkg/plugins/hfendpoint/service.go index cab53fc0..557c4d6a 100644 --- a/pkg/plugins/hfendpoint/service.go +++ b/pkg/plugins/hfendpoint/service.go @@ -146,14 +146,24 @@ func (h *Connector) Run(resourceOptions map[string]interface{}, actionOptions ma } body := make(map[string]interface{}) listBody := make([]map[string]interface{}, 0) + matrixBody := make([][]map[string]interface{}, 0) if err := json.Unmarshal(resp.Body(), &body); err == nil { res.Rows = append(res.Rows, body) } if err := json.Unmarshal(resp.Body(), &listBody); err == nil { res.Rows = listBody } - res.Extra["raw"] = resp.Body() + if err := json.Unmarshal(resp.Body(), &matrixBody); err == nil && len(matrixBody) == 1 { + res.Rows = matrixBody[0] + } + if !isBase64Encoded(string(resp.Body())) { + res.Extra["raw"] = base64Encode(resp.Body()) + } else { + res.Extra["raw"] = string(resp.Body()) + } res.Extra["headers"] = resp.Header() + res.Extra["statusCode"] = resp.StatusCode() + res.Extra["statusText"] = resp.Status() if err != nil { res.Success = false return res, err @@ -161,3 +171,13 @@ func (h *Connector) Run(resourceOptions map[string]interface{}, actionOptions ma return res, nil } + +func isBase64Encoded(s string) bool { + _, err := base64.StdEncoding.DecodeString(s) + return err == nil +} + +func base64Encode(s []byte) string { + encoded := base64.StdEncoding.EncodeToString(s) + return encoded +} diff --git a/pkg/plugins/huggingface/service.go b/pkg/plugins/huggingface/service.go index 79106315..1ea77021 100644 --- a/pkg/plugins/huggingface/service.go +++ b/pkg/plugins/huggingface/service.go @@ -156,8 +156,14 @@ func (h *Connector) Run(resourceOptions map[string]interface{}, actionOptions ma if err := json.Unmarshal(resp.Body(), &matrixBody); err == nil && len(matrixBody) == 1 { res.Rows = matrixBody[0] } - res.Extra["raw"] = resp.Body() + if !isBase64Encoded(string(resp.Body())) { + res.Extra["raw"] = base64Encode(resp.Body()) + } else { + res.Extra["raw"] = string(resp.Body()) + } res.Extra["headers"] = resp.Header() + res.Extra["statusCode"] = resp.StatusCode() + res.Extra["statusText"] = resp.Status() if err != nil { res.Success = false return res, err @@ -166,3 +172,13 @@ func (h *Connector) Run(resourceOptions map[string]interface{}, actionOptions ma return res, nil } + +func isBase64Encoded(s string) bool { + _, err := base64.StdEncoding.DecodeString(s) + return err == nil +} + +func base64Encode(s []byte) string { + encoded := base64.StdEncoding.EncodeToString(s) + return encoded +} diff --git a/pkg/plugins/restapi/service.go b/pkg/plugins/restapi/service.go index 4576ab58..31423556 100644 --- a/pkg/plugins/restapi/service.go +++ b/pkg/plugins/restapi/service.go @@ -15,6 +15,7 @@ package restapi import ( + "encoding/base64" "encoding/json" "errors" "net/http" @@ -241,7 +242,11 @@ func (r *RESTAPIConnector) Run(resourceOptions map[string]interface{}, actionOpt if len(res.Rows) == 0 && len(resp.Body()) > 0 { res.Rows = append(res.Rows, map[string]interface{}{"message": string(resp.Body())}) } - res.Extra["body"] = string(resp.Body()) + if !isBase64Encoded(string(resp.Body())) { + res.Extra["raw"] = base64Encode(resp.Body()) + } else { + res.Extra["raw"] = string(resp.Body()) + } res.Extra["headers"] = resp.Header() res.Extra["statusCode"] = resp.StatusCode() res.Extra["statusText"] = resp.Status() @@ -261,7 +266,11 @@ func (r *RESTAPIConnector) Run(resourceOptions map[string]interface{}, actionOpt if len(res.Rows) == 0 && len(resp.Body()) > 0 { res.Rows = append(res.Rows, map[string]interface{}{"message": string(resp.Body())}) } - res.Extra["body"] = string(resp.Body()) + if !isBase64Encoded(string(resp.Body())) { + res.Extra["raw"] = base64Encode(resp.Body()) + } else { + res.Extra["raw"] = string(resp.Body()) + } res.Extra["headers"] = resp.Header() res.Extra["statusCode"] = resp.StatusCode() res.Extra["statusText"] = resp.Status() @@ -281,7 +290,11 @@ func (r *RESTAPIConnector) Run(resourceOptions map[string]interface{}, actionOpt if len(res.Rows) == 0 && len(resp.Body()) > 0 { res.Rows = append(res.Rows, map[string]interface{}{"message": string(resp.Body())}) } - res.Extra["body"] = string(resp.Body()) + if !isBase64Encoded(string(resp.Body())) { + res.Extra["raw"] = base64Encode(resp.Body()) + } else { + res.Extra["raw"] = string(resp.Body()) + } res.Extra["headers"] = resp.Header() res.Extra["statusCode"] = resp.StatusCode() res.Extra["statusText"] = resp.Status() @@ -301,7 +314,11 @@ func (r *RESTAPIConnector) Run(resourceOptions map[string]interface{}, actionOpt if len(res.Rows) == 0 && len(resp.Body()) > 0 { res.Rows = append(res.Rows, map[string]interface{}{"message": string(resp.Body())}) } - res.Extra["body"] = string(resp.Body()) + if !isBase64Encoded(string(resp.Body())) { + res.Extra["raw"] = base64Encode(resp.Body()) + } else { + res.Extra["raw"] = string(resp.Body()) + } res.Extra["headers"] = resp.Header() res.Extra["statusCode"] = resp.StatusCode() res.Extra["statusText"] = resp.Status() @@ -321,7 +338,11 @@ func (r *RESTAPIConnector) Run(resourceOptions map[string]interface{}, actionOpt if len(res.Rows) == 0 && len(resp.Body()) > 0 { res.Rows = append(res.Rows, map[string]interface{}{"message": string(resp.Body())}) } - res.Extra["body"] = string(resp.Body()) + if !isBase64Encoded(string(resp.Body())) { + res.Extra["raw"] = base64Encode(resp.Body()) + } else { + res.Extra["raw"] = string(resp.Body()) + } res.Extra["headers"] = resp.Header() res.Extra["statusCode"] = resp.StatusCode() res.Extra["statusText"] = resp.Status() @@ -342,7 +363,11 @@ func (r *RESTAPIConnector) Run(resourceOptions map[string]interface{}, actionOpt if len(res.Rows) == 0 && len(resp.Body()) > 0 { res.Rows = append(res.Rows, map[string]interface{}{"message": string(resp.Body())}) } - res.Extra["body"] = string(resp.Body()) + if !isBase64Encoded(string(resp.Body())) { + res.Extra["raw"] = base64Encode(resp.Body()) + } else { + res.Extra["raw"] = string(resp.Body()) + } res.Extra["headers"] = resp.Header() res.Extra["statusCode"] = resp.StatusCode() res.Extra["statusText"] = resp.Status() @@ -362,7 +387,11 @@ func (r *RESTAPIConnector) Run(resourceOptions map[string]interface{}, actionOpt if len(res.Rows) == 0 && len(resp.Body()) > 0 { res.Rows = append(res.Rows, map[string]interface{}{"message": string(resp.Body())}) } - res.Extra["body"] = string(resp.Body()) + if !isBase64Encoded(string(resp.Body())) { + res.Extra["raw"] = base64Encode(resp.Body()) + } else { + res.Extra["raw"] = string(resp.Body()) + } res.Extra["headers"] = resp.Header() res.Extra["statusCode"] = resp.StatusCode() res.Extra["statusText"] = resp.Status() @@ -371,3 +400,13 @@ func (r *RESTAPIConnector) Run(resourceOptions map[string]interface{}, actionOpt res.Success = true return res, nil } + +func isBase64Encoded(s string) bool { + _, err := base64.StdEncoding.DecodeString(s) + return err == nil +} + +func base64Encode(s []byte) string { + encoded := base64.StdEncoding.EncodeToString(s) + return encoded +} diff --git a/pkg/websocket-filter/broadcast_only.go b/pkg/websocket-filter/broadcast_only.go index 381c7ff9..85cca584 100644 --- a/pkg/websocket-filter/broadcast_only.go +++ b/pkg/websocket-filter/broadcast_only.go @@ -16,6 +16,7 @@ package filter import ( "errors" + ws "github.com/illacloud/builder-backend/internal/websocket" ) diff --git a/pkg/websocket-filter/global_broadcast_only.go b/pkg/websocket-filter/global_broadcast_only.go index db10ae65..da2fbdd1 100644 --- a/pkg/websocket-filter/global_broadcast_only.go +++ b/pkg/websocket-filter/global_broadcast_only.go @@ -16,6 +16,7 @@ package filter import ( "errors" + ws "github.com/illacloud/builder-backend/internal/websocket" ) @@ -23,11 +24,11 @@ func SignalGlobalBroadcastOnly(hub *ws.Hub, message *ws.Message) error { // deserialize message currentClient, hit := hub.Clients[message.ClientID] if !hit { - return errors.New("[SignalGlobalBroadcastOnly] target client("+message.ClientID.String()+") does dot exists.") + return errors.New("[SignalGlobalBroadcastOnly] target client(" + message.ClientID.String() + ") does dot exists.") } message.RewriteBroadcast() // feedback to all Client (do not include current client itself) - hub.BroadcastToGlobal(message, currentClient, false) + hub.BroadcastToTeamAllClients(message, currentClient, false) return nil }