backend/plugins/github/models/connection.go (307 lines of code) (raw):
/*
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"`
}
// 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_"}
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
}