config.go

  1package gitlab
  2
  3import (
  4	"bufio"
  5	"bytes"
  6	"encoding/json"
  7	"fmt"
  8	"io"
  9	"io/ioutil"
 10	"math/rand"
 11	"net/http"
 12	neturl "net/url"
 13	"os"
 14	"regexp"
 15	"strconv"
 16	"strings"
 17	"syscall"
 18	"time"
 19
 20	"github.com/pkg/errors"
 21	"github.com/xanzy/go-gitlab"
 22	"golang.org/x/crypto/ssh/terminal"
 23
 24	"github.com/MichaelMure/git-bug/bridge/core"
 25	"github.com/MichaelMure/git-bug/repository"
 26)
 27
 28const (
 29	target      = "gitlab"
 30	gitlabV4Url = "https://gitlab.com/api/v4"
 31	keyID       = "id"
 32	keyTarget   = "target"
 33	keyToken    = "token"
 34
 35	defaultTimeout = 60 * time.Second
 36)
 37
 38//note to my self: bridge configure --target=gitlab --url=$URL
 39
 40var (
 41	ErrBadProjectURL = errors.New("bad project url")
 42)
 43
 44func (*Gitlab) Configure(repo repository.RepoCommon, params core.BridgeParams) (core.Configuration, error) {
 45	if params.Project != "" {
 46		fmt.Println("warning: --project is ineffective for a gitlab bridge")
 47	}
 48	if params.Owner != "" {
 49		fmt.Println("warning: --owner is ineffective for a gitlab bridge")
 50	}
 51
 52	conf := make(core.Configuration)
 53	var err error
 54	var url string
 55	var token string
 56	var projectID string
 57
 58	// get project url
 59	if params.URL != "" {
 60		url = params.URL
 61
 62	} else {
 63		// remote suggestions
 64		remotes, err := repo.GetRemotes()
 65		if err != nil {
 66			return nil, err
 67		}
 68
 69		// terminal prompt
 70		url, err = promptURL(remotes)
 71		if err != nil {
 72			return nil, err
 73		}
 74	}
 75
 76	// get user token
 77	if params.Token != "" {
 78		token = params.Token
 79	} else {
 80		token, err = promptTokenOptions(url)
 81		if err != nil {
 82			return nil, err
 83		}
 84	}
 85
 86	var ok bool
 87	// validate project url and get it ID
 88	ok, projectID, err = validateProjectURL(url, token)
 89	if err != nil {
 90		return nil, err
 91	}
 92	if !ok {
 93		return nil, fmt.Errorf("invalid project id or wrong token scope")
 94	}
 95
 96	conf[keyID] = projectID
 97	conf[keyToken] = token
 98	conf[keyTarget] = target
 99
100	return conf, nil
101}
102
103func (*Gitlab) ValidateConfig(conf core.Configuration) error {
104	if v, ok := conf[keyTarget]; !ok {
105		return fmt.Errorf("missing %s key", keyTarget)
106	} else if v != target {
107		return fmt.Errorf("unexpected target name: %v", v)
108	}
109
110	if _, ok := conf[keyToken]; !ok {
111		return fmt.Errorf("missing %s key", keyToken)
112	}
113
114	if _, ok := conf[keyID]; !ok {
115		return fmt.Errorf("missing %s key", keyID)
116	}
117
118	return nil
119}
120
121func requestToken(note, username, password string, scope string) (*http.Response, error) {
122	return requestTokenWith2FA(note, username, password, "", scope)
123}
124
125//TODO: FIX THIS ONE
126func requestTokenWith2FA(note, username, password, otpCode string, scope string) (*http.Response, error) {
127	url := fmt.Sprintf("%s/authorizations", gitlabV4Url)
128	params := struct {
129		Scopes      []string `json:"scopes"`
130		Note        string   `json:"note"`
131		Fingerprint string   `json:"fingerprint"`
132	}{
133		Scopes:      []string{scope},
134		Note:        note,
135		Fingerprint: randomFingerprint(),
136	}
137
138	data, err := json.Marshal(params)
139	if err != nil {
140		return nil, err
141	}
142
143	req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
144	if err != nil {
145		return nil, err
146	}
147
148	req.SetBasicAuth(username, password)
149	req.Header.Set("Content-Type", "application/json")
150
151	if otpCode != "" {
152		req.Header.Set("X-GitHub-OTP", otpCode)
153	}
154
155	client := &http.Client{
156		Timeout: defaultTimeout,
157	}
158
159	return client.Do(req)
160}
161
162func decodeBody(body io.ReadCloser) (string, error) {
163	data, _ := ioutil.ReadAll(body)
164
165	aux := struct {
166		Token string `json:"token"`
167	}{}
168
169	err := json.Unmarshal(data, &aux)
170	if err != nil {
171		return "", err
172	}
173
174	if aux.Token == "" {
175		return "", fmt.Errorf("no token found in response: %s", string(data))
176	}
177
178	return aux.Token, nil
179}
180
181func randomFingerprint() string {
182	// Doesn't have to be crypto secure, it's just to avoid token collision
183	rand.Seed(time.Now().UnixNano())
184	var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
185	b := make([]rune, 32)
186	for i := range b {
187		b[i] = letterRunes[rand.Intn(len(letterRunes))]
188	}
189	return string(b)
190}
191
192func promptTokenOptions(url string) (string, error) {
193	for {
194		fmt.Println()
195		fmt.Println("[1]: user provided token")
196		fmt.Println("[2]: interactive token creation")
197		fmt.Print("Select option: ")
198
199		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
200		fmt.Println()
201		if err != nil {
202			return "", err
203		}
204
205		line = strings.TrimRight(line, "\n")
206
207		index, err := strconv.Atoi(line)
208		if err != nil || (index != 1 && index != 2) {
209			fmt.Println("invalid input")
210			continue
211		}
212
213		if index == 1 {
214			return promptToken()
215		}
216
217		return loginAndRequestToken(url)
218	}
219}
220
221func promptToken() (string, error) {
222	fmt.Println("You can generate a new token by visiting https://gitlab.com/settings/tokens.")
223	fmt.Println("Choose 'Generate new token' and set the necessary access scope for your repository.")
224	fmt.Println()
225	fmt.Println("The access scope depend on the type of repository.")
226	fmt.Println("Public:")
227	fmt.Println("  - 'public_repo': to be able to read public repositories")
228	fmt.Println("Private:")
229	fmt.Println("  - 'repo'       : to be able to read private repositories")
230	fmt.Println()
231
232	re, err := regexp.Compile(`^[a-zA-Z0-9\-]{20}`)
233	if err != nil {
234		panic("regexp compile:" + err.Error())
235	}
236
237	for {
238		fmt.Print("Enter token: ")
239
240		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
241		if err != nil {
242			return "", err
243		}
244
245		token := strings.TrimRight(line, "\n")
246		if re.MatchString(token) {
247			return token, nil
248		}
249
250		fmt.Println("token is invalid")
251	}
252}
253
254// TODO: FIX THIS ONE TOO
255func loginAndRequestToken(url string) (string, error) {
256
257	// prompt project visibility to know the token scope needed for the repository
258	isPublic, err := promptProjectVisibility()
259	if err != nil {
260		return "", err
261	}
262
263	username, err := promptUsername()
264	if err != nil {
265		return "", err
266	}
267
268	password, err := promptPassword()
269	if err != nil {
270		return "", err
271	}
272
273	var scope string
274	//TODO: Gitlab scopes
275	if isPublic {
276		// public_repo is requested to be able to read public repositories
277		scope = "public_repo"
278	} else {
279		// 'repo' is request to be able to read private repositories
280		// /!\ token will have read/write rights on every private repository you have access to
281		scope = "repo"
282	}
283
284	// Attempt to authenticate and create a token
285
286	note := fmt.Sprintf("git-bug - %s/%s", url)
287
288	resp, err := requestToken(note, username, password, scope)
289	if err != nil {
290		return "", err
291	}
292
293	defer resp.Body.Close()
294
295	// Handle 2FA is needed
296	OTPHeader := resp.Header.Get("X-GitHub-OTP")
297	if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" {
298		otpCode, err := prompt2FA()
299		if err != nil {
300			return "", err
301		}
302
303		resp, err = requestTokenWith2FA(note, username, password, otpCode, scope)
304		if err != nil {
305			return "", err
306		}
307
308		defer resp.Body.Close()
309	}
310
311	if resp.StatusCode == http.StatusCreated {
312		return decodeBody(resp.Body)
313	}
314
315	b, _ := ioutil.ReadAll(resp.Body)
316	return "", fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(b))
317}
318
319func promptUsername() (string, error) {
320	for {
321		fmt.Print("username: ")
322
323		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
324		if err != nil {
325			return "", err
326		}
327
328		line = strings.TrimRight(line, "\n")
329
330		ok, err := validateUsername(line)
331		if err != nil {
332			return "", err
333		}
334		if ok {
335			return line, nil
336		}
337
338		fmt.Println("invalid username")
339	}
340}
341
342func promptURL(remotes map[string]string) (string, error) {
343	validRemotes := getValidGitlabRemoteURLs(remotes)
344	if len(validRemotes) > 0 {
345		for {
346			fmt.Println("\nDetected projects:")
347
348			// print valid remote gitlab urls
349			for i, remote := range validRemotes {
350				fmt.Printf("[%d]: %v\n", i+1, remote)
351			}
352
353			fmt.Printf("\n[0]: Another project\n\n")
354			fmt.Printf("Select option: ")
355
356			line, err := bufio.NewReader(os.Stdin).ReadString('\n')
357			if err != nil {
358				return "", err
359			}
360
361			line = strings.TrimRight(line, "\n")
362
363			index, err := strconv.Atoi(line)
364			if err != nil || (index < 0 && index >= len(validRemotes)) {
365				fmt.Println("invalid input")
366				continue
367			}
368
369			// if user want to enter another project url break this loop
370			if index == 0 {
371				break
372			}
373
374			return validRemotes[index-1], nil
375		}
376	}
377
378	// manually enter gitlab url
379	for {
380		fmt.Print("Gitlab project URL: ")
381
382		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
383		if err != nil {
384			return "", err
385		}
386
387		url := strings.TrimRight(line, "\n")
388		if line == "" {
389			fmt.Println("URL is empty")
390			continue
391		}
392
393		return url, nil
394	}
395}
396
397func splitURL(url string) (string, string, error) {
398	cleanUrl := strings.TrimSuffix(url, ".git")
399	objectUrl, err := neturl.Parse(cleanUrl)
400	if err != nil {
401		return "", "", nil
402	}
403
404	return fmt.Sprintf("%s%s", objectUrl.Host, objectUrl.Path), objectUrl.Path, nil
405}
406
407func getValidGitlabRemoteURLs(remotes map[string]string) []string {
408	urls := make([]string, 0, len(remotes))
409	for _, u := range remotes {
410		url, _, err := splitURL(u)
411		if err != nil {
412			continue
413		}
414
415		urls = append(urls, url)
416	}
417
418	return urls
419}
420
421func validateUsername(username string) (bool, error) {
422	// no need for a token for this action
423	client := buildClient("")
424
425	users, _, err := client.Users.ListUsers(&gitlab.ListUsersOptions{Username: &username})
426	if err != nil {
427		return false, err
428	}
429
430	if len(users) == 0 {
431		return false, fmt.Errorf("username not found")
432	} else if len(users) > 1 {
433		return false, fmt.Errorf("found multiple matches")
434	}
435
436	return users[0].Username == username, nil
437}
438
439func validateProjectURL(url, token string) (bool, string, error) {
440	client := buildClient(token)
441
442	_, projectPath, err := splitURL(url)
443	if err != nil {
444		return false, "", err
445	}
446
447	project, _, err := client.Projects.GetProject(projectPath[1:], &gitlab.GetProjectOptions{})
448	if err != nil {
449		return false, "", err
450	}
451	projectID := strconv.Itoa(project.ID)
452
453	return true, projectID, nil
454}
455
456func promptPassword() (string, error) {
457	for {
458		fmt.Print("password: ")
459
460		bytePassword, err := terminal.ReadPassword(int(syscall.Stdin))
461		// new line for coherent formatting, ReadPassword clip the normal new line
462		// entered by the user
463		fmt.Println()
464
465		if err != nil {
466			return "", err
467		}
468
469		if len(bytePassword) > 0 {
470			return string(bytePassword), nil
471		}
472
473		fmt.Println("password is empty")
474	}
475}
476
477func prompt2FA() (string, error) {
478	for {
479		fmt.Print("two-factor authentication code: ")
480
481		byte2fa, err := terminal.ReadPassword(int(syscall.Stdin))
482		fmt.Println()
483		if err != nil {
484			return "", err
485		}
486
487		if len(byte2fa) > 0 {
488			return string(byte2fa), nil
489		}
490
491		fmt.Println("code is empty")
492	}
493}
494
495func promptProjectVisibility() (bool, error) {
496	for {
497		fmt.Println("[1]: public")
498		fmt.Println("[2]: private")
499		fmt.Print("repository visibility: ")
500
501		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
502		fmt.Println()
503		if err != nil {
504			return false, err
505		}
506
507		line = strings.TrimRight(line, "\n")
508
509		index, err := strconv.Atoi(line)
510		if err != nil || (index != 0 && index != 1) {
511			fmt.Println("invalid input")
512			continue
513		}
514
515		// return true for public repositories, false for private
516		return index == 0, nil
517	}
518}