From 7f82c983f8863fa5c23dae32b222d20d8366fffc Mon Sep 17 00:00:00 2001 From: Youngjin Jo Date: Thu, 7 Nov 2024 23:03:26 +0900 Subject: [PATCH] feat: complete login subcommand for LOCAL login Signed-off-by: Youngjin Jo --- cmd/login.go | 443 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 364 insertions(+), 79 deletions(-) diff --git a/cmd/login.go b/cmd/login.go index b459dc4..1468fbd 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -1,113 +1,341 @@ package cmd import ( - "bufio" "bytes" "encoding/json" "fmt" + "io" "net/http" "os" - "strings" + "regexp" "github.com/pterm/pterm" "github.com/spf13/cobra" "github.com/spf13/viper" ) +var url string + // loginCmd represents the login command var loginCmd = &cobra.Command{ Use: "login", Short: "Login to SpaceONE", Long: `A command that allows you to login to SpaceONE. -It will prompt you for your User ID, Password, and Domain ID, then fetch the token.`, - Run: func(cmd *cobra.Command, args []string) { - // Check if a token already exists in the configuration - token := viper.GetString("token") - if token != "" { - pterm.Info.Println("Existing token found, attempting to authenticate with saved credentials.") - if verifyToken(token) { - pterm.Success.Println("Successfully authenticated with saved token.") - return - } else { - pterm.Warning.Println("Saved token is invalid or expired, proceeding with login.") - } - } +It will prompt you for your User ID, Password, and fetch the Domain ID automatically, then fetch the token.`, + Run: executeLogin, +} - // Prompt for user credentials - reader := bufio.NewReader(os.Stdin) - fmt.Print("Enter User ID: ") - userID, _ := reader.ReadString('\n') - userID = strings.TrimSpace(userID) - - fmt.Print("Enter Password: ") - password, _ := reader.ReadString('\n') - password = strings.TrimSpace(password) - - fmt.Print("Enter Domain ID: ") - domainID, _ := reader.ReadString('\n') - domainID = strings.TrimSpace(domainID) - - // Read tokenEndpoint from configuration - tokenEndpoint := viper.GetString("token_endpoint") - if tokenEndpoint == "" { - pterm.Error.Println("No token endpoint specified in the configuration file.") - os.Exit(1) +func executeLogin(cmd *cobra.Command, args []string) { + if token := viper.GetString("token"); token != "" { + pterm.Info.Println("Existing token found, attempting to authenticate with saved credentials.") + if verifyToken(token) { + pterm.Success.Println("Successfully authenticated with saved token.") + return } + pterm.Warning.Println("Saved token is invalid or expired, proceeding with login.") + } - // Prepare the request payload for token issue - payload := map[string]interface{}{ - "credentials": map[string]string{ - "user_id": userID, - "password": password, - }, - "auth_type": "LOCAL", - "timeout": 0, - "verify_code": "string", - "domain_id": domainID, - } - jsonPayload, err := json.Marshal(payload) - if err != nil { - pterm.Error.Println("Failed to create request payload:", err) - os.Exit(1) - } + if url == "" { + pterm.Error.Println("URL must be provided with the -u flag.") + exitWithError() + } - // Make the request to get the access token - resp, err := http.Post(tokenEndpoint, "application/json", bytes.NewBuffer(jsonPayload)) - if err != nil { - pterm.Error.Println("Failed to make request to token endpoint:", err) - os.Exit(1) - } - defer resp.Body.Close() + userID, password := promptCredentials() - if resp.StatusCode != http.StatusOK { - pterm.Error.Println("Failed to retrieve token, status code:", resp.StatusCode) - os.Exit(1) - } + re := regexp.MustCompile(`https://(.*?)\.`) + matches := re.FindStringSubmatch(url) + if len(matches) < 2 { + pterm.Error.Println("Invalid URL format.") + exitWithError() + } + name := matches[1] - // Parse the response - var result map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&result) - if err != nil { - pterm.Error.Println("Failed to parse response body:", err) - os.Exit(1) + baseUrl := viper.GetString("base_url") + if baseUrl == "" { + pterm.Error.Println("No token endpoint specified in the configuration file.") + exitWithError() + } + + domainID, err := fetchDomainID(baseUrl, name) + if err != nil { + pterm.Error.Println("Failed to fetch Domain ID:", err) + exitWithError() + } + + accessToken, refreshToken, err := issueToken(baseUrl, userID, password, domainID) + if err != nil { + pterm.Error.Println("Failed to retrieve token:", err) + exitWithError() + } + + workspaces, err := fetchWorkspaces(baseUrl, accessToken) + if err != nil { + pterm.Error.Println("Failed to fetch workspaces:", err) + exitWithError() + } + + workspaceID := selectWorkspace(workspaces) + domainID, roleType, err := fetchDomainIDAndRole(baseUrl, accessToken) + if err != nil { + pterm.Error.Println("Failed to fetch Domain ID and Role Type:", err) + exitWithError() + } + + scope := determineScope(roleType, len(workspaces)) + newAccessToken, err := grantToken(baseUrl, refreshToken, scope, domainID, workspaceID) + if err != nil { + pterm.Error.Println("Failed to retrieve new access token:", err) + exitWithError() + } + + saveToken(newAccessToken) + pterm.Success.Println("Successfully logged in and saved token.") +} + +func promptCredentials() (string, string) { + userId, _ := pterm.DefaultInteractiveTextInput.Show("Enter your user ID") + passwordInput := pterm.DefaultInteractiveTextInput.WithMask("*") + password, _ := passwordInput.Show("Enter your password") + return userId, password +} + +func issueToken(baseUrl, userID, password, domainID string) (string, string, error) { + payload := map[string]interface{}{ + "credentials": map[string]string{ + "user_id": userID, + "password": password, + }, + "auth_type": "LOCAL", + "timeout": 0, + "verify_code": "string", + "domain_id": domainID, + } + jsonPayload, err := json.Marshal(payload) + if err != nil { + return "", "", err + } + + resp, err := http.Post(baseUrl+"/token/issue", "application/json", bytes.NewBuffer(jsonPayload)) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("status code: %d", resp.StatusCode) + } + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", "", err + } + + accessToken, ok := result["access_token"].(string) + if !ok { + return "", "", fmt.Errorf("access token not found in response") + } + + refreshToken, ok := result["refresh_token"].(string) + if !ok { + return "", "", fmt.Errorf("refresh token not found in response") + } + + return accessToken, refreshToken, nil +} + +func determineScope(roleType string, workspaceCount int) string { + switch roleType { + case "DOMAIN_ADMIN": + if workspaceCount == 0 { + return "DOMAIN" } + return "WORKSPACE" + case "USER": + return "WORKSPACE" + default: + pterm.Error.Println("Unknown role_type:", roleType) + exitWithError() + return "" // Unreachable + } +} + +func grantToken(baseUrl, refreshToken, scope, domainID, workspaceID string) (string, error) { + payload := map[string]interface{}{ + "grant_type": "REFRESH_TOKEN", + "token": refreshToken, + "scope": scope, + "domain_id": domainID, + "workspace_id": workspaceID, + } + jsonPayload, err := json.Marshal(payload) + if err != nil { + return "", err + } + + resp, err := http.Post(baseUrl+"/token/grant", "application/json", bytes.NewBuffer(jsonPayload)) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("status code: %d", resp.StatusCode) + } + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + accessToken, ok := result["access_token"].(string) + if !ok { + return "", fmt.Errorf("access token not found in response") + } + + return accessToken, nil +} + +func saveToken(token string) { + viper.Set("token", token) + if err := viper.WriteConfig(); err != nil { + pterm.Error.Println("Failed to save configuration file:", err) + exitWithError() + } +} + +func exitWithError() { + os.Exit(1) +} - accessToken, ok := result["access_token"].(string) +func fetchDomainID(baseUrl string, name string) (string, error) { + payload := map[string]string{"name": name} + jsonPayload, err := json.Marshal(payload) + if err != nil { + return "", err + } + + resp, err := http.Post(baseUrl+"/domain/get-auth-info", "application/json", bytes.NewBuffer(jsonPayload)) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to fetch domain ID, status code: %d", resp.StatusCode) + } + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + domainID, ok := result["domain_id"].(string) + if !ok { + return "", fmt.Errorf("domain_id not found in response") + } + + return domainID, nil +} + +func fetchWorkspaces(baseUrl string, accessToken string) ([]map[string]interface{}, error) { + payload := map[string]string{} + jsonPayload, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + getWorkspacesUrl := baseUrl + "/user-profile/get-workspaces" + req, err := http.NewRequest("POST", getWorkspacesUrl, bytes.NewBuffer(jsonPayload)) + if err != nil { + return nil, err + } + + req.Header.Set("accept", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %v", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch workspaces, status code: %d", resp.StatusCode) + } + + var result map[string]interface{} + if err := json.Unmarshal(responseBody, &result); err != nil { + return nil, err + } + + workspaces, ok := result["results"].([]interface{}) + if !ok || len(workspaces) == 0 { + pterm.Warning.Println("There are no accessible workspaces. Ask your administrators or workspace owners for access.") + exitWithError() + } + + var workspaceList []map[string]interface{} + for _, workspace := range workspaces { + workspaceMap, ok := workspace.(map[string]interface{}) if !ok { - pterm.Error.Println("Access token not found in response") - os.Exit(1) + return nil, fmt.Errorf("failed to parse workspace data") } + workspaceList = append(workspaceList, workspaceMap) + } - // Save token to configuration - viper.Set("token", accessToken) - err = viper.WriteConfig() - if err != nil { - pterm.Error.Println("Failed to save configuration file:", err) - os.Exit(1) - } + return workspaceList, nil +} + +func fetchDomainIDAndRole(baseUrl string, accessToken string) (string, string, error) { + payload := map[string]string{} + jsonPayload, err := json.Marshal(payload) + if err != nil { + return "", "", err + } + + getUserProfileUrl := baseUrl + "/user-profile/get" + req, err := http.NewRequest("POST", getUserProfileUrl, bytes.NewBuffer(jsonPayload)) + if err != nil { + return "", "", err + } + + req.Header.Set("accept", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("failed to fetch user profile, status code: %d", resp.StatusCode) + } - pterm.Success.Println("Successfully logged in and saved token.") - }, + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", "", err + } + + domainID, ok := result["domain_id"].(string) + if !ok { + return "", "", fmt.Errorf("domain_id not found in response") + } + + roleType, ok := result["role_type"].(string) + if !ok { + return "", "", fmt.Errorf("role_type not found in response") + } + + return domainID, roleType, nil } func verifyToken(token string) bool { @@ -116,8 +344,65 @@ func verifyToken(token string) bool { return true } +func selectWorkspace(workspaces []map[string]interface{}) string { + const pageSize = 15 + totalWorkspaces := len(workspaces) + totalPages := (totalWorkspaces + pageSize - 1) / pageSize + + currentPage := 0 + for { + startIndex := currentPage * pageSize + endIndex := startIndex + pageSize + if endIndex > totalWorkspaces { + endIndex = totalWorkspaces + } + + var options []string + for i := startIndex; i < endIndex; i++ { + name := workspaces[i]["name"].(string) + options = append(options, fmt.Sprintf("%d: %s", i+1, name)) + } + + if currentPage > 0 { + options = append([]string{"< Previous Page"}, options...) + } + if endIndex < totalWorkspaces { + options = append(options, "Next Page >") + } + + pterm.Info.Printfln("Available Workspaces (Page %d of %d):", currentPage+1, totalPages) + selectedOption, err := pterm.DefaultInteractiveSelect. + WithOptions(options). + WithMaxHeight(20). + Show() + if err != nil { + pterm.Error.Println("Error selecting workspace:", err) + exitWithError() + } + + if selectedOption == "< Previous Page" { + currentPage-- + continue + } else if selectedOption == "Next Page >" { + currentPage++ + continue + } + + var index int + fmt.Sscanf(selectedOption, "%d", &index) + + if index >= 1 && index <= totalWorkspaces { + return workspaces[index-1]["workspace_id"].(string) + } else { + pterm.Error.Println("Invalid selection. Please try again.") + } + } +} + func init() { rootCmd.AddCommand(loginCmd) + loginCmd.Flags().StringVarP(&url, "url", "u", "", "The URL to use for login (e.g. cfctl login -u https://example.com)") + loginCmd.MarkFlagRequired("url") // Load configuration file viper.SetConfigName("cfctl")