| /* |
| Licensed to the Apache Software Foundation (ASF) under one or more |
| contributor license agreements. See the NOTICE file distributed with |
| this work for additional information regarding copyright ownership. |
| The ASF licenses this file to You 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 models |
| |
| import ( |
| "encoding/json" |
| "fmt" |
| "io" |
| "net/http" |
| "strings" |
| "time" |
| |
| "github.com/apache/incubator-devlake/core/utils" |
| |
| "github.com/apache/incubator-devlake/core/errors" |
| "github.com/apache/incubator-devlake/core/plugin" |
| helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" |
| "github.com/golang-jwt/jwt/v5" |
| ) |
| |
| const ( |
| AccessToken = "AccessToken" |
| AppKey = "AppKey" |
| ) |
| |
| // GithubAccessToken supports fetching data with multiple tokens |
| type GithubAccessToken struct { |
| helper.AccessToken `mapstructure:",squash"` |
| tokens []string `gorm:"-" json:"-" mapstructure:"-"` |
| tokenIndex int `gorm:"-" json:"-" mapstructure:"-"` |
| } |
| |
| type GithubAppKey struct { |
| helper.AppKey `mapstructure:",squash"` |
| InstallationID int `mapstructure:"installationId" validate:"required" json:"installationId"` |
| } |
| |
| // GithubConn holds the essential information to connect to the GitHub API |
| type GithubConn struct { |
| helper.RestConnection `mapstructure:",squash"` |
| helper.MultiAuth `mapstructure:",squash"` |
| GithubAccessToken `mapstructure:",squash" authMethod:"AccessToken"` |
| GithubAppKey `mapstructure:",squash" authMethod:"AppKey"` |
| RefreshToken string `mapstructure:"refreshToken" json:"refreshToken" gorm:"type:text;serializer:encdec"` |
| TokenExpiresAt time.Time `mapstructure:"tokenExpiresAt" json:"tokenExpiresAt"` |
| RefreshTokenExpiresAt time.Time `mapstructure:"refreshTokenExpiresAt" json:"refreshTokenExpiresAt"` |
| } |
| |
| // UpdateToken updates the token and refresh token information |
| func (conn *GithubConn) UpdateToken(newToken, newRefreshToken string, expiry, refreshExpiry time.Time) { |
| conn.Token = newToken |
| conn.RefreshToken = newRefreshToken |
| conn.TokenExpiresAt = expiry |
| conn.RefreshTokenExpiresAt = refreshExpiry |
| |
| // Update the internal tokens slice used by SetupAuthentication |
| conn.tokens = []string{newToken} |
| conn.tokenIndex = 0 |
| } |
| |
| // PrepareApiClient splits Token to tokens for SetupAuthentication to utilize |
| func (conn *GithubConn) PrepareApiClient(apiClient plugin.ApiClient) errors.Error { |
| |
| if conn.AuthMethod == AccessToken { |
| conn.tokens = strings.Split(conn.Token, ",") |
| } |
| |
| if conn.AuthMethod == AppKey && conn.InstallationID != 0 { |
| token, err := conn.getInstallationAccessToken(apiClient) |
| if err != nil { |
| return err |
| } |
| |
| conn.Token = token.Token |
| conn.tokens = []string{token.Token} |
| } |
| |
| return nil |
| } |
| |
| // SetupAuthentication sets up the HTTP Request Authentication |
| func (conn *GithubConn) SetupAuthentication(req *http.Request) errors.Error { |
| // Rotates token on each request. |
| if len(conn.tokens) > 0 { |
| req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", conn.tokens[conn.tokenIndex])) |
| // Set next token index |
| conn.tokenIndex = (conn.tokenIndex + 1) % len(conn.tokens) |
| } |
| |
| return nil |
| } |
| |
| func (gat *GithubAccessToken) GetTokensCount() int { |
| return len(gat.tokens) |
| } |
| |
| // GithubConnection holds GithubConn plus ID/Name for database storage |
| type GithubConnection struct { |
| helper.BaseConnection `mapstructure:",squash"` |
| GithubConn `mapstructure:",squash"` |
| EnableGraphql bool `mapstructure:"enableGraphql" json:"enableGraphql"` |
| } |
| |
| const ( |
| GithubTokenTypeClassical = "classical" |
| GithubTokenTypeClassicalPrefixLen = 4 |
| GithubTokenTypeClassicalShowPrefixLen = 8 |
| GithubTokenTypeClassicalHiddenLen = 20 |
| GithubTokenTypeFineGrained = "fine-grained" |
| GithubTokenTypeFineGrainedPrefixLen = 11 |
| GithubTokenTypeFineGrainedShowPrefixLen = 8 |
| GithubTokenTypeFineGrainedHiddenLen = 66 |
| GithubTokenTypeUnknown = "unknown" |
| ) |
| |
| func (connection GithubConnection) TableName() string { |
| return "_tool_github_connections" |
| } |
| |
| func (connection *GithubConnection) MergeFromRequest(target *GithubConnection, body map[string]interface{}) error { |
| modifiedConnection := GithubConnection{} |
| if err := helper.DecodeMapStruct(body, &modifiedConnection, true); err != nil { |
| return err |
| } |
| return connection.Merge(target, &modifiedConnection, body) |
| } |
| |
| func (connection *GithubConnection) Merge(existed, modified *GithubConnection, body map[string]interface{}) error { |
| // There are many kinds of update, we just update all fields simply. |
| existedTokenStr := existed.Token |
| existSecretKey := existed.SecretKey |
| |
| existed.Name = modified.Name |
| if _, ok := body["enableGraphql"]; ok { |
| existed.EnableGraphql = modified.EnableGraphql |
| } |
| existed.AppId = modified.AppId |
| existed.SecretKey = modified.SecretKey |
| existed.InstallationID = modified.InstallationID |
| existed.AuthMethod = modified.AuthMethod |
| existed.Proxy = modified.Proxy |
| existed.Endpoint = modified.Endpoint |
| existed.RateLimitPerHour = modified.RateLimitPerHour |
| |
| // handle secret |
| if existSecretKey == "" { |
| if modified.SecretKey != "" { |
| // add secret key, store it |
| existed.SecretKey = modified.SecretKey |
| } |
| // doesn't input secret key, pass |
| } else { |
| if modified.SecretKey == "" { |
| // delete secret key |
| existed.SecretKey = modified.SecretKey |
| } else { |
| // update secret key |
| sanitizeSecretKey := existed.SanitizeSecret().SecretKey |
| if sanitizeSecretKey == modified.SecretKey { |
| // change nothing, restore it |
| existed.SecretKey = existSecretKey |
| } else { |
| // has changed, replace it with the new secret key |
| existed.SecretKey = modified.SecretKey |
| } |
| } |
| } |
| |
| // handle tokens |
| existedTokens := strings.Split(strings.TrimSpace(existedTokenStr), ",") |
| existedTokenMap := make(map[string]string) // {originalToken:sanitizedToken} |
| existedSanitizedTokenMap := make(map[string]string) // {sanitizedToken:originalToken} |
| for _, token := range existedTokens { |
| existedTokenMap[token] = existed.SanitizeToken(token) |
| existedSanitizedTokenMap[existed.SanitizeToken(token)] = token |
| } |
| |
| modifiedTokens := strings.Split(strings.TrimSpace(modified.Token), ",") |
| modifiedTokenMap := make(map[string]string) // {originalToken:sanitizedToken} |
| for _, token := range modifiedTokens { |
| modifiedTokenMap[token] = existed.SanitizeToken(token) |
| } |
| |
| var mergedToken []string |
| mergedTokenMap := make(map[string]struct{}) |
| |
| for token, sanitizeToken := range modifiedTokenMap { |
| // check token |
| if _, ok := existedTokenMap[token]; ok { |
| // find in db, modified but no update, ignore it |
| if _, ok := mergedTokenMap[token]; !ok { |
| mergedToken = append(mergedToken, token) |
| mergedTokenMap[token] = struct{}{} |
| } |
| } else { |
| // not found, a new token, we should keep it |
| // cannot be a sanitized token |
| if _, ok := existedSanitizedTokenMap[token]; !ok { |
| if token != sanitizeToken { |
| if _, ok := mergedTokenMap[token]; !ok { |
| mergedToken = append(mergedToken, token) |
| mergedTokenMap[token] = struct{}{} |
| } |
| } |
| } |
| } |
| |
| // token may be a sanitized token |
| if v, ok := existedSanitizedTokenMap[token]; ok { |
| // find in db, modify nothing, just keep it |
| if _, ok := mergedTokenMap[v]; !ok { |
| mergedToken = append(mergedToken, v) |
| mergedTokenMap[v] = struct{}{} |
| } |
| } else { |
| // unexpected |
| fmt.Printf("unexpected token: %+v will be ignored\n", token) |
| } |
| // check sanitized token |
| if v, ok := existedSanitizedTokenMap[sanitizeToken]; ok { |
| // find in db, modify nothing, just keep it |
| if _, ok := mergedTokenMap[v]; !ok { |
| mergedToken = append(mergedToken, v) |
| mergedTokenMap[v] = struct{}{} |
| } |
| } else { |
| // a new token |
| // but we should check it |
| if sanitizeToken != token { |
| if _, ok := mergedTokenMap[token]; !ok { |
| mergedToken = append(mergedToken, token) |
| mergedTokenMap[token] = struct{}{} |
| } |
| } |
| } |
| } |
| |
| existed.Token = strings.Join(mergedToken, ",") |
| return nil |
| } |
| |
| func (conn *GithubConn) typeIs(token string) string { |
| // classical tokens: |
| // ghp_ for Personal Access Tokens |
| // gho_ for OAuth Access tokens |
| // ghu_ for GitHub App user-to-server tokens |
| // ghs_ for GitHub App server-to-server tokens |
| // ghr_ for GitHub App refresh tokens |
| // total len is 40, {prefix}{showPrefix}{secret}{showSuffix} |
| // fine-grained tokens |
| // github_pat_{82_characters} |
| classicalTokenClassicalPrefixes := []string{"ghp_", "gho_", "ghs_", "ghr_", "ghu_"} |
| classicalTokenFindGrainedPrefixes := []string{"github_pat_"} |
| for _, prefix := range classicalTokenClassicalPrefixes { |
| if strings.HasPrefix(token, prefix) { |
| return GithubTokenTypeClassical |
| } |
| } |
| for _, prefix := range classicalTokenFindGrainedPrefixes { |
| if strings.HasPrefix(token, prefix) { |
| return GithubTokenTypeFineGrained |
| } |
| } |
| return GithubTokenTypeUnknown |
| } |
| |
| func (connection GithubConnection) Sanitize() GithubConnection { |
| connection.GithubConn = connection.GithubConn.Sanitize() |
| return connection |
| } |
| |
| func (conn *GithubConn) Sanitize() GithubConn { |
| conn.SanitizeTokens() |
| conn.SanitizeSecret() |
| return *conn |
| } |
| |
| func (conn *GithubConn) SanitizeSecret() GithubConn { |
| if conn.SecretKey == "" { |
| return *conn |
| } |
| secretKey := conn.SecretKey |
| showPrefixLen, showSuffixLen := 50, 50 |
| hiddenLen := len(secretKey) - showSuffixLen - showSuffixLen |
| secret := strings.Repeat("*", hiddenLen) |
| conn.SecretKey = strings.Replace(secretKey, conn.SecretKey[showPrefixLen:showPrefixLen+hiddenLen], secret, -1) |
| return *conn |
| } |
| |
| func (conn *GithubConn) SanitizeToken(token string) string { |
| if token == "" { |
| return token |
| } |
| sanitizedToken := token |
| var prefixLen, showPrefixLen, hiddenLen int |
| |
| tokenType := conn.typeIs(token) |
| switch tokenType { |
| case GithubTokenTypeClassical: |
| prefixLen, showPrefixLen, hiddenLen = GithubTokenTypeClassicalPrefixLen, GithubTokenTypeClassicalShowPrefixLen, GithubTokenTypeClassicalHiddenLen |
| case GithubTokenTypeFineGrained: |
| prefixLen, showPrefixLen, hiddenLen = GithubTokenTypeFineGrainedPrefixLen, GithubTokenTypeFineGrainedShowPrefixLen, GithubTokenTypeFineGrainedHiddenLen |
| case GithubTokenTypeUnknown: |
| return utils.SanitizeString(token) |
| } |
| tokenLen := len(token) |
| if tokenLen >= prefixLen && prefixLen != 0 && hiddenLen != 0 { |
| secret := strings.Repeat("*", hiddenLen) |
| sanitizedToken = strings.Replace(token, token[prefixLen+showPrefixLen:prefixLen+showPrefixLen+hiddenLen], secret, -1) |
| } |
| return sanitizedToken |
| } |
| |
| func (conn *GithubConn) SanitizeTokens() GithubConn { |
| if conn.Token == "" { |
| return *conn |
| } |
| tokens := strings.Split(conn.Token, ",") |
| var sanitizedTokens []string |
| for _, token := range tokens { |
| if token == "" { |
| continue |
| } |
| sanitizedToken := conn.SanitizeToken(token) |
| sanitizedTokens = append(sanitizedTokens, sanitizedToken) |
| } |
| |
| if len(sanitizedTokens) > 0 { |
| conn.Token = strings.Join(sanitizedTokens, ",") |
| } else { |
| conn.Token = "" |
| } |
| return *conn |
| } |
| |
| // Using GithubUserOfToken because it requires authentication, and it is public information anyway. |
| type GithubUserOfToken struct { |
| Login string `json:"login"` |
| } |
| |
| type InstallationToken struct { |
| Token string `json:"token"` |
| } |
| |
| type GithubApp struct { |
| ID int32 `json:"id"` |
| Slug string `json:"slug"` |
| } |
| |
| type GithubAppInstallation struct { |
| Id int `json:"id"` |
| Account struct { |
| Login string `json:"login"` |
| } `json:"account"` |
| } |
| |
| type GithubAppInstallationWithToken struct { |
| GithubAppInstallation |
| Token string |
| } |
| |
| func (gak *GithubAppKey) CreateJwt() (string, errors.Error) { |
| token := jwt.New(jwt.SigningMethodRS256) |
| t := time.Now().Unix() |
| |
| token.Claims = jwt.MapClaims{ |
| "iat": t, |
| "exp": t + (10 * 60), |
| "iss": gak.AppId, |
| } |
| |
| privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(gak.SecretKey)) |
| if err != nil { |
| return "", errors.BadInput.Wrap(err, "invalid private key") |
| } |
| |
| tokenString, err := token.SignedString(privateKey) |
| if err != nil { |
| return "", errors.BadInput.Wrap(err, "invalid private key") |
| } |
| |
| return tokenString, nil |
| } |
| |
| func (gak *GithubAppKey) getInstallationAccessToken( |
| apiClient plugin.ApiClient, |
| ) (*InstallationToken, errors.Error) { |
| |
| jwt, err := gak.CreateJwt() |
| if err != nil { |
| return nil, err |
| } |
| |
| resp, err := apiClient.Post(fmt.Sprintf("/app/installations/%d/access_tokens", gak.InstallationID), nil, nil, http.Header{ |
| "Authorization": []string{fmt.Sprintf("Bearer %s", jwt)}, |
| }) |
| if err != nil { |
| return nil, err |
| } |
| |
| body, err := errors.Convert01(io.ReadAll(resp.Body)) |
| if err != nil { |
| return nil, err |
| } |
| |
| var installationToken InstallationToken |
| err = errors.Convert(json.Unmarshal(body, &installationToken)) |
| if err != nil { |
| return nil, err |
| } |
| |
| return &installationToken, nil |
| } |