package github import ( "context" "encoding/json" "fmt" "io/ioutil" "math/rand" "net/http" "net/url" "regexp" "sort" "strings" "time" "github.com/pkg/errors" "github.com/MichaelMure/git-bug/bridge/core" "github.com/MichaelMure/git-bug/bridge/core/auth" "github.com/MichaelMure/git-bug/cache" "github.com/MichaelMure/git-bug/input" "github.com/MichaelMure/git-bug/repository" ) var ( ErrBadProjectURL = errors.New("bad project url") GithubClientID = "ce3600aa56c2e69f18a5" ) func (g *Github) ValidParams() map[string]interface{} { return map[string]interface{}{ "URL": nil, "Login": nil, "CredPrefix": nil, "TokenRaw": nil, "Owner": nil, "Project": nil, } } func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) { var err error var owner string var project string var ok bool // getting owner and project name switch { case params.Owner != "" && params.Project != "": // first try to use params if both or project and owner are provided owner = params.Owner project = params.Project case params.URL != "": // try to parse params URL and extract owner and project owner, project, err = splitURL(params.URL) if err != nil { return nil, err } default: // terminal prompt owner, project, err = promptURL(repo) if err != nil { return nil, err } } // validate project owner and override with the correct case ok, owner, err = validateUsername(owner) if err != nil { return nil, err } if !ok { return nil, fmt.Errorf("invalid parameter owner: %v", owner) } var login string var cred auth.Credential switch { case params.CredPrefix != "": cred, err = auth.LoadWithPrefix(repo, params.CredPrefix) if err != nil { return nil, err } l, ok := cred.GetMetadata(auth.MetaKeyLogin) if !ok { return nil, fmt.Errorf("credential doesn't have a login") } login = l case params.TokenRaw != "": token := auth.NewToken(target, params.TokenRaw) login, err = getLoginFromToken(token) if err != nil { return nil, err } token.SetMetadata(auth.MetaKeyLogin, login) cred = token default: if params.Login == "" { login, err = promptLogin() } else { // validate login and override with the correct case ok, login, err = validateUsername(params.Login) if !ok { return nil, fmt.Errorf("invalid parameter login: %v", params.Login) } } if err != nil { return nil, err } cred, err = promptTokenOptions(repo, login, owner, project) if err != nil { return nil, err } } token, ok := cred.(*auth.Token) if !ok { return nil, fmt.Errorf("the Github bridge only handle token credentials") } // verify access to the repository with token ok, err = validateProject(owner, project, token) if err != nil { return nil, err } if !ok { return nil, fmt.Errorf("project doesn't exist or authentication token has an incorrect scope") } conf := make(core.Configuration) conf[core.ConfigKeyTarget] = target conf[confKeyOwner] = owner conf[confKeyProject] = project conf[confKeyDefaultLogin] = login err = g.ValidateConfig(conf) if err != nil { return nil, err } // don't forget to store the now known valid token if !auth.IdExist(repo, cred.ID()) { err = auth.Store(repo, cred) if err != nil { return nil, err } } return conf, core.FinishConfig(repo, metaKeyGithubLogin, login) } func (*Github) ValidateConfig(conf core.Configuration) error { if v, ok := conf[core.ConfigKeyTarget]; !ok { return fmt.Errorf("missing %s key", core.ConfigKeyTarget) } else if v != target { return fmt.Errorf("unexpected target name: %v", v) } if _, ok := conf[confKeyOwner]; !ok { return fmt.Errorf("missing %s key", confKeyOwner) } if _, ok := conf[confKeyProject]; !ok { return fmt.Errorf("missing %s key", confKeyProject) } if _, ok := conf[confKeyDefaultLogin]; !ok { return fmt.Errorf("missing %s key", confKeyDefaultLogin) } return nil } func requestToken() (string, error) { // prompt project visibility to know the token scope needed for the repository index, err := input.PromptChoice("repository visibility", []string{"public", "private"}) if err != nil { return "", err } scope := []string{"public_repo", "repo"}[index] // resp, err := requestUserVerificationCode(scope) if err != nil { return "", err } defer resp.Body.Close() data, err := ioutil.ReadAll(resp.Body) if err != nil { return "", err } values, err := url.ParseQuery(string(data)) if err != nil { return "", err } promptUserToGoToBrowser(values.Get("user_code")) return pollGithubUntilUserAuthorizedGitbug(&values) } func requestUserVerificationCode(scope string) (*http.Response, error) { params := url.Values{} params.Set("client_id", GithubClientID) params.Set("scope", scope) client := &http.Client{ Timeout: defaultTimeout, } resp, err := client.PostForm("https://github.com/login/device/code", params) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { defer resp.Body.Close() bb, _ := ioutil.ReadAll(resp.Body) return nil, fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(bb)) } return resp, nil } func promptUserToGoToBrowser(code string) { fmt.Println("Please visit the following URL in a browser and enter the user authentication code.") fmt.Println() fmt.Println(" URL: https://github.com/login/device") fmt.Println(" user authentiation code: ", code) fmt.Println() } func pollGithubUntilUserAuthorizedGitbug(values1 *url.Values) (string, error) { params := url.Values{} params.Set("client_id", GithubClientID) params.Set("device_code", values1.Get("device_code")) params.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") // fixed by RFC 8628 client := &http.Client{ Timeout: defaultTimeout, } // there exists a minimum interval required by the github API var initialInterval time.Duration = 6 // seconds var interval time.Duration = initialInterval token := "" for { resp, err := client.PostForm("https://github.com/login/oauth/access_token", params) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bb, _ := ioutil.ReadAll(resp.Body) return "", fmt.Errorf("error creating token %v, %v", resp.StatusCode, string(bb)) } data2, err := ioutil.ReadAll(resp.Body) if err != nil { return "", err } values2, err := url.ParseQuery(string(data2)) if err != nil { return "", err } apiError := values2.Get("error") if apiError != "" { if apiError == "slow_down" { interval *= 2 } else { interval = initialInterval } if apiError == "authorization_pending" || apiError == "slow_down" { // no-op } else { // apiError equals on of: "expired_token", "unsupported_grant_type", // "incorrect_client_credentials", "incorrect_device_code", or "access_denied" return "", fmt.Errorf("error creating token %v, %v", apiError, values2.Get("error_description")) } time.Sleep(interval * time.Second) continue } token = values2.Get("access_token") if token == "" { panic("invalid Github API response") } break } return token, nil } func randomFingerprint() string { // Doesn't have to be crypto secure, it's just to avoid token collision rand.Seed(time.Now().UnixNano()) var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") b := make([]rune, 32) for i := range b { b[i] = letterRunes[rand.Intn(len(letterRunes))] } return string(b) } func promptTokenOptions(repo repository.RepoKeyring, login, owner, project string) (auth.Credential, error) { creds, err := auth.List(repo, auth.WithTarget(target), auth.WithKind(auth.KindToken), auth.WithMeta(auth.MetaKeyLogin, login), ) if err != nil { return nil, err } cred, index, err := input.PromptCredential(target, "token", creds, []string{ "enter my token", "interactive token creation", }) switch { case err != nil: return nil, err case cred != nil: return cred, nil case index == 0: return promptToken() case index == 1: value, err := requestToken() if err != nil { return nil, err } token := auth.NewToken(target, value) token.SetMetadata(auth.MetaKeyLogin, login) return token, nil default: panic("missed case") } } func promptToken() (*auth.Token, error) { fmt.Println("You can generate a new token by visiting https://github.com/settings/tokens.") fmt.Println("Choose 'Generate new token' and set the necessary access scope for your repository.") fmt.Println() fmt.Println("The access scope depend on the type of repository.") fmt.Println("Public:") fmt.Println(" - 'public_repo': to be able to read public repositories") fmt.Println("Private:") fmt.Println(" - 'repo' : to be able to read private repositories") fmt.Println() re := regexp.MustCompile(`^[a-zA-Z0-9]{40}$`) var login string validator := func(name string, value string) (complaint string, err error) { if !re.MatchString(value) { return "token has incorrect format", nil } login, err = getLoginFromToken(auth.NewToken(target, value)) if err != nil { return fmt.Sprintf("token is invalid: %v", err), nil } return "", nil } rawToken, err := input.Prompt("Enter token", "token", input.Required, validator) if err != nil { return nil, err } token := auth.NewToken(target, rawToken) token.SetMetadata(auth.MetaKeyLogin, login) return token, nil } func promptURL(repo repository.RepoCommon) (string, string, error) { validRemotes, err := getValidGithubRemoteURLs(repo) if err != nil { return "", "", err } validator := func(name, value string) (string, error) { _, _, err := splitURL(value) if err != nil { return err.Error(), nil } return "", nil } url, err := input.PromptURLWithRemote("Github project URL", "URL", validRemotes, input.Required, validator) if err != nil { return "", "", err } return splitURL(url) } // splitURL extract the owner and project from a github repository URL. It will remove the // '.git' extension from the URL before parsing it. // Note that Github removes the '.git' extension from projects names at their creation func splitURL(url string) (owner string, project string, err error) { cleanURL := strings.TrimSuffix(url, ".git") re := regexp.MustCompile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`) res := re.FindStringSubmatch(cleanURL) if res == nil { return "", "", ErrBadProjectURL } owner = res[1] project = res[2] return } func getValidGithubRemoteURLs(repo repository.RepoCommon) ([]string, error) { remotes, err := repo.GetRemotes() if err != nil { return nil, err } urls := make([]string, 0, len(remotes)) for _, url := range remotes { // split url can work again with shortURL owner, project, err := splitURL(url) if err == nil { shortURL := fmt.Sprintf("%s/%s/%s", "github.com", owner, project) urls = append(urls, shortURL) } } sort.Strings(urls) return urls, nil } func promptLogin() (string, error) { var login string validator := func(_ string, value string) (string, error) { ok, fixed, err := validateUsername(value) if err != nil { return "", err } if !ok { return "invalid login", nil } login = fixed return "", nil } _, err := input.Prompt("Github login", "login", input.Required, validator) if err != nil { return "", err } return login, nil } func validateUsername(username string) (bool, string, error) { url := fmt.Sprintf("%s/users/%s", githubV3Url, username) client := &http.Client{ Timeout: defaultTimeout, } resp, err := client.Get(url) if err != nil { return false, "", err } if resp.StatusCode != http.StatusOK { return false, "", nil } data, err := ioutil.ReadAll(resp.Body) if err != nil { return false, "", err } err = resp.Body.Close() if err != nil { return false, "", err } var decoded struct { Login string `json:"login"` } err = json.Unmarshal(data, &decoded) if err != nil { return false, "", err } if decoded.Login == "" { return false, "", fmt.Errorf("validateUsername: missing login in the response") } return true, decoded.Login, nil } func validateProject(owner, project string, token *auth.Token) (bool, error) { url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project) req, err := http.NewRequest("GET", url, nil) if err != nil { return false, err } // need the token for private repositories req.Header.Set("Authorization", fmt.Sprintf("token %s", token.Value)) client := &http.Client{ Timeout: defaultTimeout, } resp, err := client.Do(req) if err != nil { return false, err } err = resp.Body.Close() if err != nil { return false, err } return resp.StatusCode == http.StatusOK, nil } func getLoginFromToken(token *auth.Token) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) defer cancel() client := buildClient(token) var q loginQuery err := client.Query(ctx, &q, nil) if err != nil { return "", err } if q.Viewer.Login == "" { return "", fmt.Errorf("github say username is empty") } return q.Viewer.Login, nil }