concourse/fly/commands/login.go

581 lines
13 KiB
Go

package commands
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net"
"net/http"
"strings"
"github.com/concourse/concourse/atc"
"github.com/concourse/concourse/fly/rc"
"github.com/concourse/concourse/go-concourse/concourse"
semisemanticversion "github.com/cppforlife/go-semi-semantic/version"
"github.com/mitchellh/mapstructure"
"github.com/skratchdot/open-golang/open"
"github.com/vito/go-interact/interact"
"golang.org/x/oauth2"
)
type LoginCommand struct {
ATCURL string `short:"c" long:"concourse-url" description:"Concourse URL to authenticate with"`
Insecure bool `short:"k" long:"insecure" description:"Skip verification of the endpoint's SSL certificate"`
Username string `short:"u" long:"username" description:"Username for basic auth"`
Password string `short:"p" long:"password" description:"Password for basic auth"`
TeamName string `short:"n" long:"team-name" description:"Team to authenticate with"`
CACert atc.PathFlag `long:"ca-cert" description:"Path to Concourse PEM-encoded CA certificate file."`
OpenBrowser bool `short:"b" long:"open-browser" description:"Open browser to the auth endpoint"`
BrowserOnly bool
}
func (command *LoginCommand) Execute(args []string) error {
if Fly.Target == "" {
return errors.New("name for the target must be specified (--target/-t)")
}
var target rc.Target
var err error
var caCert string
if command.CACert != "" {
caCertBytes, err := ioutil.ReadFile(string(command.CACert))
if err != nil {
return err
}
caCert = string(caCertBytes)
}
if command.ATCURL != "" {
if command.TeamName == "" {
command.TeamName = atc.DefaultTeamName
}
target, err = rc.NewUnauthenticatedTarget(
Fly.Target,
command.ATCURL,
command.TeamName,
command.Insecure,
caCert,
Fly.Verbose,
)
} else {
target, err = rc.LoadUnauthenticatedTarget(
Fly.Target,
command.TeamName,
command.Insecure,
caCert,
Fly.Verbose,
)
}
if err != nil {
return err
}
client := target.Client()
command.TeamName = target.Team().Name()
fmt.Printf("logging in to team '%s'\n\n", command.TeamName)
if len(args) != 0 {
return errors.New("unexpected argument [" + strings.Join(args, ", ") + "]")
}
err = target.ValidateWithWarningOnly()
if err != nil {
return err
}
var tokenType string
var tokenValue string
version, err := target.Version()
if err != nil {
return err
}
semver, err := semisemanticversion.NewVersionFromString(version)
if err != nil {
return err
}
legacySemver, err := semisemanticversion.NewVersionFromString("3.14.1")
if err != nil {
return err
}
devSemver, err := semisemanticversion.NewVersionFromString("0.0.0-dev")
if err != nil {
return err
}
if semver.Compare(legacySemver) <= 0 && semver.Compare(devSemver) != 0 {
// Legacy Auth Support
tokenType, tokenValue, err = command.legacyAuth(target, command.BrowserOnly)
} else {
if command.Username != "" && command.Password != "" {
tokenType, tokenValue, err = command.passwordGrant(client, command.Username, command.Password)
} else {
tokenType, tokenValue, err = command.authCodeGrant(client.URL(), command.BrowserOnly)
}
}
if err != nil {
return err
}
fmt.Println("")
payload, unmarshalErr := unmarshalToken(tokenValue)
if unmarshalErr != nil {
return unmarshalErr
}
if payload != nil {
if isAdmin(payload) {
err = command.adminCheckTeamExists(target.URL(), tokenType, tokenValue, caCert)
} else {
err = checkTokenTeams(payload, command.TeamName)
}
}
if err != nil {
return err
}
return command.saveTarget(
client.URL(),
&rc.TargetToken{
Type: tokenType,
Value: tokenValue,
},
target.CACert(),
)
}
func (command *LoginCommand) passwordGrant(client concourse.Client, username, password string) (string, string, error) {
oauth2Config := oauth2.Config{
ClientID: "fly",
ClientSecret: "Zmx5",
Endpoint: oauth2.Endpoint{TokenURL: client.URL() + "/sky/token"},
Scopes: []string{"openid", "profile", "email", "federated:id", "groups"},
}
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client.HTTPClient())
token, err := oauth2Config.PasswordCredentialsToken(ctx, username, password)
if err != nil {
return "", "", err
}
return token.TokenType, token.AccessToken, nil
}
func (command *LoginCommand) authCodeGrant(targetUrl string, browserOnly bool) (string, string, error) {
var tokenStr string
stdinChannel := make(chan string)
tokenChannel := make(chan string)
errorChannel := make(chan error)
portChannel := make(chan string)
go listenForTokenCallback(tokenChannel, errorChannel, portChannel, targetUrl)
port := <-portChannel
var openURL string
fmt.Println("navigate to the following URL in your browser:")
fmt.Println("")
openURL = fmt.Sprintf("%s/login?fly_port=%s", targetUrl, port)
fmt.Printf(" %s\n", openURL)
if !browserOnly {
fmt.Println("")
fmt.Printf("or enter token manually: ")
}
if command.OpenBrowser {
// try to open the browser window, but don't get all hung up if it
// fails, since we already printed about it.
_ = open.Start(openURL)
}
if !browserOnly {
go waitForTokenInput(stdinChannel, errorChannel)
}
select {
case tokenStrMsg := <-tokenChannel:
tokenStr = tokenStrMsg
case tokenStrMsg := <-stdinChannel:
tokenStr = tokenStrMsg
case errorMsg := <-errorChannel:
return "", "", errorMsg
}
segments := strings.SplitN(tokenStr, " ", 2)
return segments[0], segments[1], nil
}
func unmarshalToken(tokenValue string) (map[string]interface{}, error) {
tokenContents := strings.Split(tokenValue, ".")
if len(tokenContents) < 2 {
// this is really bad and makes it hard to write proper integration tests
return nil, nil
}
rawData, err := base64.StdEncoding.WithPadding(base64.NoPadding).DecodeString(tokenContents[1])
if err != nil {
return nil, err
}
var payload map[string]interface{}
if err := json.Unmarshal(rawData, &payload); err != nil {
return nil, err
}
return payload, nil
}
func isAdmin(payload map[string]interface{}) bool {
if isAdmin, isAdminExistsInToken := payload["is_admin"]; isAdminExistsInToken && isAdmin.(bool) {
return true
}
return false
}
func (command *LoginCommand) adminCheckTeamExists(atcUrl, tokenType, tokenValue, caCert string) error {
target, err := rc.NewAuthenticatedTarget(
Fly.Target,
atcUrl,
command.TeamName,
command.Insecure,
&rc.TargetToken{
Type: tokenType,
Value: tokenValue,
},
caCert,
Fly.Verbose,
)
if err != nil {
return err
}
teams, err := target.Client().ListTeams()
if err != nil {
return err
}
var teamExists bool
for _, team := range teams {
if command.TeamName == team.Name {
teamExists = true
break
}
}
if !teamExists {
return fmt.Errorf("team %s doesn't exist", command.TeamName)
}
return nil
}
func getPayloadTeams(payload map[string]interface{}) ([]string, error) {
var teamNames []string
teamRoles := map[string][]string{}
if err := mapstructure.Decode(payload["teams"], &teamRoles); err == nil {
for team := range teamRoles {
teamNames = append(teamNames, team)
}
} else if err := mapstructure.Decode(payload["teams"], &teamNames); err != nil {
return nil, err
}
return teamNames, nil
}
func checkTokenTeams(payload map[string]interface{}, loginTeam string) error {
tokenTeams, err := getPayloadTeams(payload)
if err != nil {
return err
}
for _, team := range tokenTeams {
if team == loginTeam {
return nil
}
}
userName, _ := payload["user_name"].(string)
return fmt.Errorf("user [%s] is not in team [%s]", userName, loginTeam)
}
func listenForTokenCallback(tokenChannel chan string, errorChannel chan error, portChannel chan string, targetUrl string) {
s := &http.Server{
Addr: "127.0.0.1:0",
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", targetUrl)
tokenChannel <- r.FormValue("token")
w.WriteHeader(200)
_, _ = fmt.Fprint(w, "token received by fly")
}),
}
err := listenAndServeWithPort(s, portChannel)
if err != nil {
errorChannel <- err
}
}
func listenAndServeWithPort(srv *http.Server, portChannel chan string) error {
addr := srv.Addr
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
_, port, err := net.SplitHostPort(ln.Addr().String())
if err != nil {
return err
}
portChannel <- port
return srv.Serve(tcpKeepAliveListener{ln.(*net.TCPListener)})
}
type tcpKeepAliveListener struct {
*net.TCPListener
}
func waitForTokenInput(tokenChannel chan string, errorChannel chan error) {
for {
var tokenType string
var tokenValue string
count, err := fmt.Scanf("%s %s", &tokenType, &tokenValue)
if err != nil {
if count != 2 {
fmt.Println("token must be of the format 'TYPE VALUE', e.g. 'Bearer ...'")
continue
}
errorChannel <- err
return
}
tokenChannel <- tokenType + " " + tokenValue
break
}
}
func (command *LoginCommand) saveTarget(url string, token *rc.TargetToken, caCert string) error {
err := rc.SaveTarget(
Fly.Target,
url,
command.Insecure,
command.TeamName,
&rc.TargetToken{
Type: token.Type,
Value: token.Value,
},
caCert,
)
if err != nil {
return err
}
fmt.Println("target saved")
return nil
}
func (command *LoginCommand) legacyAuth(target rc.Target, browserOnly bool) (string, string, error) {
httpClient := target.Client().HTTPClient()
authResponse, err := httpClient.Get(target.URL() + "/api/v1/teams/" + target.Team().Name() + "/auth/methods")
if err != nil {
return "", "", err
}
type authMethod struct {
Type string `json:"type"`
DisplayName string `json:"display_name"`
AuthURL string `json:"auth_url"`
}
defer authResponse.Body.Close()
var authMethods []authMethod
json.NewDecoder(authResponse.Body).Decode(&authMethods)
var chosenMethod authMethod
if command.Username != "" || command.Password != "" {
for _, method := range authMethods {
if method.Type == "basic" {
chosenMethod = method
break
}
}
if chosenMethod.Type == "" {
return "", "", errors.New("basic auth is not available")
}
} else {
choices := make([]interact.Choice, len(authMethods))
for i, method := range authMethods {
choices[i] = interact.Choice{
Display: method.DisplayName,
Value: method,
}
}
if len(choices) == 0 {
chosenMethod = authMethod{
Type: "none",
}
}
if len(choices) == 1 {
chosenMethod = authMethods[0]
}
if len(choices) > 1 {
err = interact.NewInteraction("choose an auth method", choices...).Resolve(&chosenMethod)
if err != nil {
return "", "", err
}
fmt.Println("")
}
}
switch chosenMethod.Type {
case "oauth":
var tokenStr string
stdinChannel := make(chan string)
tokenChannel := make(chan string)
errorChannel := make(chan error)
portChannel := make(chan string)
go listenForTokenCallback(tokenChannel, errorChannel, portChannel, target.Client().URL())
port := <-portChannel
theURL := fmt.Sprintf("%s&fly_local_port=%s\n", chosenMethod.AuthURL, port)
fmt.Println("navigate to the following URL in your browser:")
fmt.Println("")
fmt.Printf(" %s", theURL)
if !browserOnly {
fmt.Println("")
fmt.Printf("or enter token manually: ")
}
if command.OpenBrowser {
// try to open the browser window, but don't get all hung up if it
// fails, since we already printed about it.
_ = open.Start(theURL)
}
if !browserOnly {
go waitForTokenInput(stdinChannel, errorChannel)
}
select {
case tokenStrMsg := <-tokenChannel:
tokenStr = tokenStrMsg
case tokenStrMsg := <-stdinChannel:
tokenStr = tokenStrMsg
case errorMsg := <-errorChannel:
return "", "", errorMsg
}
segments := strings.SplitN(tokenStr, " ", 2)
return segments[0], segments[1], nil
case "basic":
var username string
if command.Username != "" {
username = command.Username
} else {
err := interact.NewInteraction("username").Resolve(interact.Required(&username))
if err != nil {
return "", "", err
}
}
var password string
if command.Password != "" {
password = command.Password
} else {
var interactivePassword interact.Password
err := interact.NewInteraction("password").Resolve(interact.Required(&interactivePassword))
if err != nil {
return "", "", err
}
password = string(interactivePassword)
}
request, err := http.NewRequest("GET", target.URL()+"/api/v1/teams/"+target.Team().Name()+"/auth/token", nil)
if err != nil {
return "", "", err
}
request.SetBasicAuth(username, password)
tokenResponse, err := httpClient.Do(request)
if err != nil {
return "", "", err
}
type authToken struct {
Type string `json:"token_type"`
Value string `json:"token_value"`
}
defer tokenResponse.Body.Close()
var token authToken
json.NewDecoder(tokenResponse.Body).Decode(&token)
return token.Type, token.Value, nil
case "none":
request, err := http.NewRequest("GET", target.URL()+"/api/v1/teams/"+target.Team().Name()+"/auth/token", nil)
if err != nil {
return "", "", err
}
tokenResponse, err := httpClient.Do(request)
if err != nil {
return "", "", err
}
type authToken struct {
Type string `json:"token_type"`
Value string `json:"token_value"`
}
defer tokenResponse.Body.Close()
var token authToken
json.NewDecoder(tokenResponse.Body).Decode(&token)
return token.Type, token.Value, nil
}
return "", "", nil
}