From 71f4ace21504e016f9897c95c1bb3f1fc36c026e Mon Sep 17 00:00:00 2001 From: Youngjin Jo <yjinjo@berkeley.edu> Date: Thu, 28 Nov 2024 00:58:20 +0900 Subject: [PATCH 1/6] chore: add workspace owner role type Signed-off-by: Youngjin Jo <yjinjo@berkeley.edu> --- cmd/other/login.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/other/login.go b/cmd/other/login.go index b37f7bd..198a1d8 100644 --- a/cmd/other/login.go +++ b/cmd/other/login.go @@ -587,7 +587,7 @@ func determineScope(roleType string, workspaceCount int) string { switch roleType { case "DOMAIN_ADMIN": return "DOMAIN" - case "USER": + case "WORKSPACE_OWNER", "WORKSPACE_MEMBER", "USER": return "WORKSPACE" default: pterm.Error.Println("Unknown role_type:", roleType) From cc1fd86a95f6f36251da012a378e4577977cc265 Mon Sep 17 00:00:00 2001 From: Youngjin Jo <yjinjo@berkeley.edu> Date: Thu, 28 Nov 2024 01:24:10 +0900 Subject: [PATCH 2/6] feat: add multiple user login feature Signed-off-by: Youngjin Jo <yjinjo@berkeley.edu> --- cmd/other/login.go | 184 +++++++++++++++++++++++++++++++-------------- 1 file changed, 128 insertions(+), 56 deletions(-) diff --git a/cmd/other/login.go b/cmd/other/login.go index 198a1d8..c1a529b 100644 --- a/cmd/other/login.go +++ b/cmd/other/login.go @@ -200,7 +200,7 @@ func executeAppLogin(currentEnv string, mainViper *viper.Viper) { } func executeUserLogin(currentEnv string) { - loadEnvironmentConfig() + loadEnvironmentConfig() // Load the environment-specific configuration baseUrl := providedUrl if baseUrl == "" { @@ -219,28 +219,59 @@ func executeUserLogin(currentEnv string) { cacheConfigPath := filepath.Join(homeDir, ".cfctl", "cache", "config.yaml") cacheViper.SetConfigFile(cacheConfigPath) - var userID, password string + var userID string + var password string + if err := cacheViper.ReadInConfig(); err == nil { - savedUserID := cacheViper.GetString(fmt.Sprintf("environments.%s.userID", currentEnv)) - savedEncryptedPassword := cacheViper.GetString(fmt.Sprintf("environments.%s.password", currentEnv)) - - if savedUserID != "" && savedEncryptedPassword != "" { - userID = savedUserID - var err error - password, err = decrypt(savedEncryptedPassword) - if err != nil { - pterm.Warning.Println("Failed to decrypt saved password, requesting new credentials") - userID, password = promptCredentials() + // Access the 'users' field in the configuration + usersField := cacheViper.Get("environments." + currentEnv + ".users") + if usersField != nil { + // Make sure we are getting a slice of interfaces + users, ok := usersField.([]interface{}) + if !ok { + pterm.Error.Println("Failed to load users correctly.") + exitWithError() + } + + if len(users) > 0 { + pterm.Info.Println("Multiple users detected. Please select an account to login:") + + // Display the existing users + for i, user := range users { + userMap, ok := user.(map[string]interface{}) + if !ok { + pterm.Error.Println("Invalid user format.") + exitWithError() + } + + pterm.Printf("%d. %s\n", i+1, userMap["userid"].(string)) // Print actual userID + } + + // Prompt user for selection + userSelection := promptUserSelection(len(users), users) // Pass the 'users' slice + selectedUser := users[userSelection-1].(map[string]interface{}) + + userID = selectedUser["userid"].(string) + password = selectedUser["password"].(string) + token := selectedUser["token"].(string) + + // Check if the token is still valid + if !isTokenExpired(token) { + pterm.Success.Printf("Using saved credentials for %s\n", userID) + } else { + pterm.Warning.Println("Token expired. Please enter your password again.") + password = promptPassword() // Prompt for password if token expired + } } else { - pterm.Info.Printf("Using saved credentials for %s\n", userID) + userID, password = promptCredentials() // Prompt for new credentials if no user exists } } else { - userID, password = promptCredentials() + pterm.Error.Println("No users found in the configuration.") + exitWithError() } } else { - userID, password = promptCredentials() + userID, password = promptCredentials() // Prompt if the configuration cannot be read } - // Load the main config file specifically for environment name mainViper := viper.New() mainViper.SetConfigFile(filepath.Join(homeDir, ".cfctl", "config.yaml")) @@ -265,18 +296,15 @@ func executeUserLogin(currentEnv string) { exitWithError() } - // Issue tokens using user credentials + // Proceed with issuing the token and saving credentials accessToken, refreshToken, err := issueToken(baseUrl, userID, password, domainID) if err != nil { pterm.Error.Println("Failed to retrieve token:", err) exitWithError() } - if encryptedPassword, err := encrypt(password); err == nil { - saveCredentials(currentEnv, userID, encryptedPassword) - } else { - pterm.Warning.Printf("Failed to encrypt password: %v\n", err) - } + // Save the new credentials to the configuration file + saveCredentials(currentEnv, userID, password, accessToken) // Fetch workspaces workspaces, err := fetchWorkspaces(baseUrl, accessToken) @@ -320,6 +348,75 @@ func executeUserLogin(currentEnv string) { pterm.Success.Println("Successfully logged in and saved token.") } +// Prompt for user credentials if they aren't saved +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 +} + +// Prompt for password when token is expired +func promptPassword() string { + passwordInput := pterm.DefaultInteractiveTextInput.WithMask("*") + password, _ := passwordInput.Show("Enter your password") + return password +} + +// Prompt for user selection, now receiving 'users' slice as an argument +func promptUserSelection(max int, users []interface{}) int { + // Open the keyboard input once + if err := keyboard.Open(); err != nil { + pterm.Error.Println("Failed to initialize keyboard:", err) + exitWithError() + } + defer keyboard.Close() // Ensure keyboard is closed at the end + + selectedIndex := 0 + for { + fmt.Print("\033[H\033[2J") // Clear the screen + + // Display the list of available users + pterm.DefaultHeader.WithFullWidth(). + WithBackgroundStyle(pterm.NewStyle(pterm.BgDarkGray)). + WithTextStyle(pterm.NewStyle(pterm.FgLightWhite)). + Println("Select a user account:") + + for i := 0; i < max; i++ { + userMap := users[i].(map[string]interface{}) + pterm.Printf("→ %d: %s\n", i+1, userMap["userid"].(string)) // Print actual userID + } + + pterm.DefaultBasicText.WithStyle(pterm.NewStyle(pterm.FgGray)). + Println("\nNavigation: [j]down [k]up, [Enter]select, [q]quit") + + // Get the keyboard input for selection + _, key, err := keyboard.GetKey() + if err != nil { + pterm.Error.Println("Error reading keyboard input:", err) + exitWithError() + } + + switch key { + case keyboard.KeyEnter: + if selectedIndex >= 0 && selectedIndex < max { + return selectedIndex + 1 + } + case 'j': // Down + if selectedIndex < max-1 { + selectedIndex++ + } + case 'k': // Up + if selectedIndex > 0 { + selectedIndex-- + } + case 'q', 'Q': // Quit + pterm.Error.Println("Selection cancelled.") + os.Exit(1) + } + } +} + func getEncryptionKey() ([]byte, error) { key, err := keyring.Get(keyringService, keyringUser) if err == keyring.ErrNotFound { @@ -395,7 +492,7 @@ func decrypt(cryptoText string) (string, error) { return string(ciphertext), nil } -func saveCredentials(currentEnv, userID, encryptedPassword string) { +func saveCredentials(currentEnv, userID, password, token string) { homeDir, err := os.UserHomeDir() if err != nil { pterm.Error.Printf("Failed to get user home directory: %v\n", err) @@ -409,14 +506,6 @@ func saveCredentials(currentEnv, userID, encryptedPassword string) { } cacheConfigPath := filepath.Join(cacheDir, "config.yaml") - - if _, err := os.Stat(cacheConfigPath); os.IsNotExist(err) { - if err := os.WriteFile(cacheConfigPath, []byte{}, 0600); err != nil { - pterm.Error.Printf("Failed to create cache config file: %v\n", err) - return - } - } - cacheViper := viper.New() cacheViper.SetConfigFile(cacheConfigPath) @@ -435,29 +524,19 @@ func saveCredentials(currentEnv, userID, encryptedPassword string) { envSettings = make(map[string]interface{}) } - orderedSettings := make(map[string]interface{}) - - if endpoint, exists := envSettings["endpoint"]; exists { - orderedSettings["endpoint"] = endpoint - } else { - orderedSettings["endpoint"] = providedUrl - } - - orderedSettings["proxy"] = true - - if token, exists := envSettings["token"]; exists { - orderedSettings["token"] = token + users := envSettings["users"].([]interface{}) + newUser := map[string]interface{}{ + "userid": userID, + "password": password, + "token": token, } - orderedSettings["userid"] = userID - - orderedSettings["password"] = encryptedPassword - - cacheViper.Set(envPath, orderedSettings) + users = append(users, newUser) + envSettings["users"] = users + cacheViper.Set(envPath, envSettings) if err := cacheViper.WriteConfig(); err != nil { pterm.Error.Printf("Failed to save credentials: %v\n", err) - return } } @@ -635,13 +714,6 @@ func exitWithError() { os.Exit(1) } -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 fetchDomainID(baseUrl string, name string) (string, error) { // Parse the endpoint parts := strings.Split(baseUrl, "://") From 552d08f647c9f99edd6e54c6e121af34ac3f8393 Mon Sep 17 00:00:00 2001 From: Youngjin Jo <yjinjo@berkeley.edu> Date: Thu, 28 Nov 2024 03:21:23 +0900 Subject: [PATCH 3/6] feat: add multi-user login feature of one domain Signed-off-by: Youngjin Jo <yjinjo@berkeley.edu> --- cmd/other/login.go | 321 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 243 insertions(+), 78 deletions(-) diff --git a/cmd/other/login.go b/cmd/other/login.go index c1a529b..6e36fa8 100644 --- a/cmd/other/login.go +++ b/cmd/other/login.go @@ -223,10 +223,8 @@ func executeUserLogin(currentEnv string) { var password string if err := cacheViper.ReadInConfig(); err == nil { - // Access the 'users' field in the configuration usersField := cacheViper.Get("environments." + currentEnv + ".users") if usersField != nil { - // Make sure we are getting a slice of interfaces users, ok := usersField.([]interface{}) if !ok { pterm.Error.Println("Failed to load users correctly.") @@ -234,45 +232,60 @@ func executeUserLogin(currentEnv string) { } if len(users) > 0 { - pterm.Info.Println("Multiple users detected. Please select an account to login:") - - // Display the existing users - for i, user := range users { - userMap, ok := user.(map[string]interface{}) - if !ok { - pterm.Error.Println("Invalid user format.") - exitWithError() + pterm.Info.Println("Select an account to login or add a new user:") + + // Display user selection including "Add new user" option + userSelection := promptUserSelection(len(users), users) + + if userSelection <= len(users) { + // Selected existing user + selectedUser := users[userSelection-1].(map[string]interface{}) + userID = selectedUser["userid"].(string) + encryptedPassword := selectedUser["password"].(string) + token := selectedUser["token"].(string) + + // Check if token is still valid + if !isTokenExpired(token) { + // Use stored password + decryptedPassword, err := decrypt(encryptedPassword) + if err != nil { + pterm.Error.Printf("Failed to decrypt password: %v\n", err) + exitWithError() + } + password = decryptedPassword + pterm.Success.Printf("Using saved credentials for %s\n", userID) + } else { + // Token expired, ask for password again + password = promptPassword() + // Verify the password matches + decryptedPassword, err := decrypt(encryptedPassword) + if err != nil { + pterm.Error.Printf("Failed to decrypt password: %v\n", err) + exitWithError() + } + if password != decryptedPassword { + pterm.Error.Println("Password does not match.") + exitWithError() + } } - - pterm.Printf("%d. %s\n", i+1, userMap["userid"].(string)) // Print actual userID - } - - // Prompt user for selection - userSelection := promptUserSelection(len(users), users) // Pass the 'users' slice - selectedUser := users[userSelection-1].(map[string]interface{}) - - userID = selectedUser["userid"].(string) - password = selectedUser["password"].(string) - token := selectedUser["token"].(string) - - // Check if the token is still valid - if !isTokenExpired(token) { - pterm.Success.Printf("Using saved credentials for %s\n", userID) } else { - pterm.Warning.Println("Token expired. Please enter your password again.") - password = promptPassword() // Prompt for password if token expired + // Selected to add new user + userID, password = promptCredentials() } } else { - userID, password = promptCredentials() // Prompt for new credentials if no user exists + // No existing users, prompt for new credentials + userID, password = promptCredentials() } } else { - pterm.Error.Println("No users found in the configuration.") - exitWithError() + // Users field doesn't exist, prompt for new credentials + userID, password = promptCredentials() } } else { - userID, password = promptCredentials() // Prompt if the configuration cannot be read + // Configuration cannot be read, prompt for new credentials + userID, password = promptCredentials() } - // Load the main config file specifically for environment name + + // Proceed with domain ID fetching and token issuance mainViper := viper.New() mainViper.SetConfigFile(filepath.Join(homeDir, ".cfctl", "config.yaml")) if err := mainViper.ReadInConfig(); err != nil { @@ -296,21 +309,23 @@ func executeUserLogin(currentEnv string) { exitWithError() } - // Proceed with issuing the token and saving credentials + // Attempt to issue token accessToken, refreshToken, err := issueToken(baseUrl, userID, password, domainID) if err != nil { pterm.Error.Println("Failed to retrieve token:", err) exitWithError() } - // Save the new credentials to the configuration file - saveCredentials(currentEnv, userID, password, accessToken) + // Encrypt password before saving + encryptedPassword, err := encrypt(password) + if err != nil { + pterm.Error.Printf("Failed to encrypt password: %v\n", err) + exitWithError() + } - // Fetch workspaces workspaces, err := fetchWorkspaces(baseUrl, accessToken) if err != nil { pterm.Error.Println("Failed to fetch workspaces:", err) - exitWithError() } // Fetch Domain ID and Role Type @@ -343,8 +358,10 @@ func executeUserLogin(currentEnv string) { exitWithError() } - // Save the new access token - saveToken(newAccessToken) + // Save the new credentials to the configuration file + saveCredentials(currentEnv, userID, encryptedPassword, newAccessToken) + + fmt.Println() pterm.Success.Println("Successfully logged in and saved token.") } @@ -365,58 +382,179 @@ func promptPassword() string { // Prompt for user selection, now receiving 'users' slice as an argument func promptUserSelection(max int, users []interface{}) int { - // Open the keyboard input once if err := keyboard.Open(); err != nil { pterm.Error.Println("Failed to initialize keyboard:", err) exitWithError() } - defer keyboard.Close() // Ensure keyboard is closed at the end + defer keyboard.Close() selectedIndex := 0 + currentPage := 0 + pageSize := 10 + searchMode := false + searchTerm := "" + filteredUsers := users + for { fmt.Print("\033[H\033[2J") // Clear the screen - // Display the list of available users + // Apply search filter + if searchTerm != "" { + filteredUsers = filterUsers(users, searchTerm) + if len(filteredUsers) == 0 { + filteredUsers = users // Show all users if no search results + } + } else { + filteredUsers = users + } + + // Calculate pagination + totalUsers := len(filteredUsers) + totalPages := (totalUsers + pageSize - 1) / pageSize + startIndex := currentPage * pageSize + endIndex := startIndex + pageSize + if endIndex > totalUsers { + endIndex = totalUsers + } + + // Display header with page information pterm.DefaultHeader.WithFullWidth(). WithBackgroundStyle(pterm.NewStyle(pterm.BgDarkGray)). WithTextStyle(pterm.NewStyle(pterm.FgLightWhite)). - Println("Select a user account:") + Printf("Select a user account (Page %d of %d)", currentPage+1, totalPages) - for i := 0; i < max; i++ { - userMap := users[i].(map[string]interface{}) - pterm.Printf("→ %d: %s\n", i+1, userMap["userid"].(string)) // Print actual userID + // Display existing users + for i := startIndex; i < endIndex; i++ { + userMap := filteredUsers[i].(map[string]interface{}) + if i == selectedIndex { + pterm.Printf("→ %d: %s\n", i+1, userMap["userid"].(string)) + } else { + pterm.Printf(" %d: %s\n", i+1, userMap["userid"].(string)) + } } + // Display option to add new user + if selectedIndex == totalUsers { + pterm.Printf("→ %d: Add new user\n", totalUsers+1) + } else { + pterm.Printf(" %d: Add new user\n", totalUsers+1) + } + + // Show navigation help pterm.DefaultBasicText.WithStyle(pterm.NewStyle(pterm.FgGray)). - Println("\nNavigation: [j]down [k]up, [Enter]select, [q]quit") + Println("\nNavigation: [h]prev-page [j]down [k]up [l]next-page [/]search [Enter]select [q]quit") - // Get the keyboard input for selection - _, key, err := keyboard.GetKey() + // Show search prompt if in search mode + if searchMode { + fmt.Println() + pterm.Info.Printf("Search (ESC to cancel, Enter to confirm): %s", searchTerm) + } + + // Get keyboard input + char, key, err := keyboard.GetKey() if err != nil { pterm.Error.Println("Error reading keyboard input:", err) exitWithError() } + // Handle search mode input + if searchMode { + switch key { + case keyboard.KeyEsc: + searchMode = false + searchTerm = "" + filteredUsers = users // Return to full user list when search term is cleared + case keyboard.KeyBackspace, keyboard.KeyBackspace2: + if len(searchTerm) > 0 { + searchTerm = searchTerm[:len(searchTerm)-1] + } + case keyboard.KeyEnter: + searchMode = false + default: + if char != 0 { + searchTerm += string(char) + } + } + currentPage = 0 + selectedIndex = 0 + continue + } + + // Handle normal mode input switch key { case keyboard.KeyEnter: - if selectedIndex >= 0 && selectedIndex < max { - return selectedIndex + 1 + if selectedIndex <= len(filteredUsers) { + // If "Add new user" is selected + if selectedIndex == len(filteredUsers) { + return len(users) + 1 + } + // Find the original index of the selected user + selectedUserMap := filteredUsers[selectedIndex].(map[string]interface{}) + selectedUserID := selectedUserMap["userid"].(string) + + for i, user := range users { + userMap := user.(map[string]interface{}) + if userMap["userid"].(string) == selectedUserID { + return i + 1 + } + } } + } + + switch char { case 'j': // Down - if selectedIndex < max-1 { + if selectedIndex < min(endIndex, totalUsers) { selectedIndex++ } case 'k': // Up - if selectedIndex > 0 { + if selectedIndex > startIndex { selectedIndex-- } - case 'q', 'Q': // Quit - pterm.Error.Println("Selection cancelled.") + case 'l': // Next page + if currentPage < totalPages-1 { + currentPage++ + selectedIndex = currentPage * pageSize + } + case 'h': // Previous page + if currentPage > 0 { + currentPage-- + selectedIndex = currentPage * pageSize + } + case '/': // Enter search mode + searchMode = true + searchTerm = "" + selectedIndex = 0 + case 'q', 'Q': + fmt.Println() + pterm.Error.Println("User selection cancelled.") os.Exit(1) } } } +// filterUsers filters the users list based on the search term +func filterUsers(users []interface{}, searchTerm string) []interface{} { + var filtered []interface{} + searchTerm = strings.ToLower(searchTerm) + + for _, user := range users { + userMap := user.(map[string]interface{}) + userid := strings.ToLower(userMap["userid"].(string)) + if strings.Contains(userid, searchTerm) { + filtered = append(filtered, user) + } + } + return filtered +} + +// min returns the minimum of two integers +func min(a, b int) int { + if a < b { + return a + } + return b +} + func getEncryptionKey() ([]byte, error) { key, err := keyring.Get(keyringService, keyringUser) if err == keyring.ErrNotFound { @@ -492,6 +630,14 @@ func decrypt(cryptoText string) (string, error) { return string(ciphertext), nil } +// Define a struct for user credentials +type UserCredentials struct { + UserID string `yaml:"userid"` + Password string `yaml:"password"` + Token string `yaml:"token"` +} + +// saveCredentials saves the user's credentials to the configuration func saveCredentials(currentEnv, userID, password, token string) { homeDir, err := os.UserHomeDir() if err != nil { @@ -514,29 +660,53 @@ func saveCredentials(currentEnv, userID, password, token string) { return } - if !cacheViper.IsSet("environments") { - cacheViper.Set("environments", map[string]interface{}{}) - } - envPath := fmt.Sprintf("environments.%s", currentEnv) envSettings := cacheViper.GetStringMap(envPath) if envSettings == nil { envSettings = make(map[string]interface{}) } - users := envSettings["users"].([]interface{}) - newUser := map[string]interface{}{ - "userid": userID, - "password": password, - "token": token, + var users []UserCredentials + if existingUsers, ok := envSettings["users"]; ok { + if userList, ok := existingUsers.([]interface{}); ok { + for _, u := range userList { + if userMap, ok := u.(map[string]interface{}); ok { + user := UserCredentials{ + UserID: userMap["userid"].(string), + Password: userMap["password"].(string), + Token: userMap["token"].(string), + } + users = append(users, user) + } + } + } } - users = append(users, newUser) - envSettings["users"] = users + // Update existing user or add new user + userExists := false + for i, user := range users { + if user.UserID == userID { + users[i].Password = password + users[i].Token = token + userExists = true + break + } + } + + if !userExists { + newUser := UserCredentials{ + UserID: userID, + Password: password, + Token: token, + } + users = append(users, newUser) + } + envSettings["users"] = users cacheViper.Set(envPath, envSettings) + if err := cacheViper.WriteConfig(); err != nil { - pterm.Error.Printf("Failed to save credentials: %v\n", err) + pterm.Error.Printf("Failed to save user credentials: %v\n", err) } } @@ -1311,12 +1481,13 @@ func selectScopeOrWorkspace(workspaces []map[string]interface{}, roleType string exitWithError() } + // Handle navigation and other commands switch key { case keyboard.KeyEnter: if selectedIndex == 0 { - return "0" // DOMAIN ADMIN 선택 + return "0" } else { - return selectWorkspaceOnly(workspaces) // WORKSPACES 선택 + return selectWorkspaceOnly(workspaces) } } @@ -1391,13 +1562,14 @@ func selectWorkspaceOnly(workspaces []map[string]interface{}) string { } } - // Show navigation help + // Show navigation help and search prompt pterm.DefaultBasicText.WithStyle(pterm.NewStyle(pterm.FgGray)). Println("\nNavigation: [h]prev-page [j]down [k]up [l]next-page [/]search [q]uit") // Show search or input prompt at the bottom if searchMode { - pterm.Info.Printf("\nSearch (ESC to cancel, Enter to confirm): %s", searchTerm) + fmt.Println() + pterm.Info.Printf("Search (ESC to cancel, Enter to confirm): %s", searchTerm) } else { fmt.Print("\nSelect a workspace above or input a number: ") if inputBuffer != "" { @@ -1487,13 +1659,6 @@ func selectWorkspaceOnly(workspaces []map[string]interface{}) string { } } -func min(a, b int) int { - if a < b { - return a - } - return b -} - func filterWorkspaces(workspaces []map[string]interface{}, searchTerm string) []map[string]interface{} { var filtered []map[string]interface{} searchTerm = strings.ToLower(searchTerm) From eb1e6a62e286d21a5fccea97788ef5ceccc44252 Mon Sep 17 00:00:00 2001 From: Youngjin Jo <yjinjo@berkeley.edu> Date: Thu, 28 Nov 2024 03:39:37 +0900 Subject: [PATCH 4/6] feat: add multi-app login feature Signed-off-by: Youngjin Jo <yjinjo@berkeley.edu> --- cmd/other/login.go | 376 ++++++++++++++++++++++----------------------- go.mod | 5 + go.sum | 22 +++ 3 files changed, 211 insertions(+), 192 deletions(-) diff --git a/cmd/other/login.go b/cmd/other/login.go index 6e36fa8..6050dcf 100644 --- a/cmd/other/login.go +++ b/cmd/other/login.go @@ -17,6 +17,7 @@ import ( "strings" "time" + "github.com/AlecAivazis/survey/v2" "github.com/eiannone/keyboard" "google.golang.org/grpc/metadata" @@ -69,134 +70,200 @@ func (t *tokenAuth) RequireTransportSecurity() bool { } func executeLogin(cmd *cobra.Command, args []string) { - // Load the environment-specific configuration without printing endpoint - loadEnvironmentConfig() - - // Get current environment homeDir, err := os.UserHomeDir() if err != nil { pterm.Error.Println("Failed to get user home directory:", err) - exitWithError() + return } - mainViper := viper.New() - mainViper.SetConfigFile(filepath.Join(homeDir, ".cfctl", "config.yaml")) - if err := mainViper.ReadInConfig(); err != nil { - pterm.Error.Println("Failed to read main config file:", err) - exitWithError() + configPath := filepath.Join(homeDir, ".cfctl", "config.yaml") + viper.SetConfigFile(configPath) + + if err := viper.ReadInConfig(); err != nil { + pterm.Error.Printf("Failed to read config file: %v\n", err) + return } - currentEnv := mainViper.GetString("environment") + currentEnv := viper.GetString("environment") if currentEnv == "" { - pterm.Error.Println("No environment specified in config.yaml") - exitWithError() + pterm.Error.Println("No environment selected") + return } - // Print endpoint once here - pterm.Info.Printf("Using endpoint: %s\n", providedUrl) - + // Check if it's an app environment if strings.HasSuffix(currentEnv, "-app") { - executeAppLogin(currentEnv, mainViper) + if err := executeAppLogin(currentEnv); err != nil { + pterm.Error.Printf("Login failed: %v\n", err) + return + } } else { + // Execute normal user login executeUserLogin(currentEnv) } } -func executeAppLogin(currentEnv string, mainViper *viper.Viper) { - token := mainViper.GetString(fmt.Sprintf("environments.%s.token", currentEnv)) - if token == "" { - pterm.Error.Println("No App token found for app environment.") +type TokenInfo struct { + Token string `yaml:"token"` +} - // Create a styled box for the app key type guidance - headerBox := pterm.DefaultBox.WithTitle("App Guide"). - WithTitleTopCenter(). - WithRightPadding(4). - WithLeftPadding(4). - WithBoxStyle(pterm.NewStyle(pterm.FgLightCyan)) - - appTokenExplain := "Please create a Domain Admin App in SpaceONE Console.\n" + - "This requires Domain Admin privilege.\n\n" + - "Or Please create a Workspace App in SpaceONE Console.\n" + - "This requires Workspace Owner privilege." - - headerBox.Println(appTokenExplain) - fmt.Println() - - // Create the steps content - steps := []string{ - "1. Go to SpaceONE Console", - "2. Navigate to either 'Admin > App Page' or specific 'Workspace > App page'", - "3. Click 'Create' to create your App", - "4. Copy value of either 'client_secret' from Client ID or 'token' from Spacectl (CLI)", +// promptToken prompts for token input +func promptToken() (string, error) { + prompt := &survey.Password{ + Message: "Enter your token:", + } + + var token string + err := survey.AskOne(prompt, &token, survey.WithValidator(survey.Required)) + if err != nil { + return "", err + } + + return token, nil +} + +// saveAppToken saves the token +func saveAppToken(currentEnv, token string) error { + homeDir, _ := os.UserHomeDir() + configPath := filepath.Join(homeDir, ".cfctl", "config.yaml") + + viper.SetConfigFile(configPath) + if err := viper.ReadInConfig(); err != nil && !os.IsNotExist(err) { + return err + } + + envPath := fmt.Sprintf("environments.%s", currentEnv) + envSettings := viper.GetStringMap(envPath) + if envSettings == nil { + envSettings = make(map[string]interface{}) + } + + // Initialize tokens array if it doesn't exist + var tokens []TokenInfo + if existingTokens, ok := envSettings["tokens"]; ok { + if tokenList, ok := existingTokens.([]interface{}); ok { + for _, t := range tokenList { + if tokenMap, ok := t.(map[string]interface{}); ok { + tokenInfo := TokenInfo{ + Token: tokenMap["token"].(string), + } + tokens = append(tokens, tokenInfo) + } + } } + } - // Determine proxy value based on endpoint - isIdentityEndpoint := strings.Contains(strings.ToLower(providedUrl), "identity") - proxyValue := "true" - if !isIdentityEndpoint { - proxyValue = "false" + // Add new token if it doesn't exist + tokenExists := false + for _, t := range tokens { + if t.Token == token { + tokenExists = true + break } + } - // Create yaml config example with highlighting - yamlExample := pterm.DefaultBox.WithTitle("Config Example"). - WithTitleTopCenter(). - WithRightPadding(4). - WithLeftPadding(4). - Sprint(fmt.Sprintf("environment: %s\nenvironments:\n %s:\n endpoint: %s\n proxy: %s\n token: %s", - currentEnv, - currentEnv, - providedUrl, - proxyValue, - pterm.FgLightCyan.Sprint("YOUR_COPIED_TOKEN"))) - - // Create instruction box - instructionBox := pterm.DefaultBox.WithTitle("Required Steps"). - WithTitleTopCenter(). - WithRightPadding(4). - WithLeftPadding(4) + if !tokenExists { + newToken := TokenInfo{ + Token: token, + } + tokens = append(tokens, newToken) + } - // Combine all steps - allSteps := append(steps, - fmt.Sprintf("5. Add the token under the proxy in your config file:\n%s", yamlExample), - "6. Run 'cfctl login' again") + // Update environment settings + envSettings["tokens"] = tokens - // Print all steps in the instruction box - instructionBox.Println(strings.Join(allSteps, "\n\n")) + // Keep the existing endpoint and proxy settings + if endpoint, ok := envSettings["endpoint"]; ok { + envSettings["endpoint"] = endpoint + } + if proxy, ok := envSettings["proxy"]; ok { + envSettings["proxy"] = proxy + } - exitWithError() + viper.Set(envPath, envSettings) + return viper.WriteConfig() +} + +// promptTokenSelection shows available tokens and lets user select one +func promptTokenSelection(tokens []TokenInfo) (string, error) { + if len(tokens) == 0 { + return "", fmt.Errorf("no tokens available") } - claims, ok := verifyAppToken(token) - if !ok { - exitWithError() + if err := keyboard.Open(); err != nil { + return "", err } + defer keyboard.Close() - headerBox := pterm.DefaultBox.WithTitle("App Token Information"). - WithTitleTopCenter(). - WithRightPadding(4). - WithLeftPadding(4). - WithBoxStyle(pterm.NewStyle(pterm.FgLightCyan)) + selectedIndex := 0 + for { + fmt.Print("\033[H\033[2J") // Clear screen - var tokenInfo string - roleType := claims["rol"].(string) + pterm.DefaultHeader.WithFullWidth(). + WithBackgroundStyle(pterm.NewStyle(pterm.BgDarkGray)). + WithTextStyle(pterm.NewStyle(pterm.FgLightWhite)). + Println("Select a token:") - if roleType == "DOMAIN_ADMIN" { - tokenInfo = fmt.Sprintf("Role Type: %s\nDomain ID: %s\nAccess Scope: All Workspaces\nExpires: %s", - pterm.FgGreen.Sprint("DOMAIN ADMIN"), - claims["did"].(string), - time.Unix(int64(claims["exp"].(float64)), 0).Format("2006-01-02 15:04:05")) - } else if roleType == "WORKSPACE_OWNER" { - tokenInfo = fmt.Sprintf("Role Type: %s\nDomain ID: %s\nWorkspace ID: %s\nExpires: %s", - pterm.FgYellow.Sprint("WORKSPACE OWNER"), - claims["did"].(string), - claims["wid"].(string), - time.Unix(int64(claims["exp"].(float64)), 0).Format("2006-01-02 15:04:05")) - } - - headerBox.Println(tokenInfo) - fmt.Println() + // Display available tokens + for i, token := range tokens { + maskedToken := maskToken(token.Token) + if i == selectedIndex { + pterm.Printf("→ %d: %s\n", i+1, maskedToken) + } else { + pterm.Printf(" %d: %s\n", i+1, maskedToken) + } + } - pterm.Success.Println("Successfully authenticated with App token.") + pterm.DefaultBasicText.WithStyle(pterm.NewStyle(pterm.FgGray)). + Println("\nNavigation: [j]down [k]up [Enter]select [q]quit") + + char, key, err := keyboard.GetKey() + if err != nil { + return "", err + } + + switch key { + case keyboard.KeyEnter: + return tokens[selectedIndex].Token, nil + } + + switch char { + case 'j': + if selectedIndex < len(tokens)-1 { + selectedIndex++ + } + case 'k': + if selectedIndex > 0 { + selectedIndex-- + } + case 'q', 'Q': + return "", fmt.Errorf("selection cancelled") + } + } +} + +// maskToken returns a masked version of the token for display +func maskToken(token string) string { + if len(token) <= 10 { + return strings.Repeat("*", len(token)) + } + return token[:5] + "..." + token[len(token)-5:] +} + +// executeAppLogin handles login for app environments +func executeAppLogin(currentEnv string) error { + // Get token from user + token, err := promptToken() + if err != nil { + return err + } + + // Save token to tokens array + if err := saveAppToken(currentEnv, token); err != nil { + return err + } + + pterm.Success.Printf("Token successfully saved\n") + return nil } func executeUserLogin(currentEnv string) { @@ -1330,113 +1397,38 @@ func grantToken(baseUrl, refreshToken, scope, domainID, workspaceID string) (str return accessToken.(string), nil } -// saveToken updates the token in the appropriate configuration file based on the environment suffix -func saveToken(newToken string) { - homeDir, err := os.UserHomeDir() - if err != nil { - pterm.Error.Printf("Failed to get user home directory: %v\n", err) - exitWithError() - } - - // Get current environment from main config - mainViper := viper.New() - mainConfigPath := filepath.Join(homeDir, ".cfctl", "config.yaml") - mainViper.SetConfigFile(mainConfigPath) - - if err := mainViper.ReadInConfig(); err != nil { - pterm.Error.Printf("Failed to read main config: %v\n", err) - exitWithError() - } - - currentEnvironment := mainViper.GetString("environment") - if currentEnvironment == "" { - pterm.Error.Printf("No environment specified in config\n") - exitWithError() - } - - // Determine which config file to use based on environment suffix - var configPath string - v := viper.New() +// saveSelectedToken saves the selected token as the current token for the environment +func saveSelectedToken(currentEnv, selectedToken string) error { + homeDir, _ := os.UserHomeDir() + configPath := filepath.Join(homeDir, ".cfctl", "config.yaml") - if strings.HasSuffix(currentEnvironment, "-user") { - // User configuration goes in cache directory - cacheDir := filepath.Join(homeDir, ".cfctl", "cache") - if err := os.MkdirAll(cacheDir, 0755); err != nil { - pterm.Error.Printf("Failed to create cache directory: %v\n", err) - exitWithError() - } - configPath = filepath.Join(cacheDir, "config.yaml") - } else if strings.HasSuffix(currentEnvironment, "-app") { - // App configuration goes in main config - configPath = mainConfigPath - } else { - pterm.Error.Printf("Invalid environment suffix (must end with -app or -user): %s\n", currentEnvironment) - exitWithError() + viper.SetConfigFile(configPath) + if err := viper.ReadInConfig(); err != nil && !os.IsNotExist(err) { + return err } - // Initialize or read the config file - v.SetConfigFile(configPath) - - // Create config file with basic structure if it doesn't exist - if _, err := os.Stat(configPath); os.IsNotExist(err) { - initialConfig := []byte("environments:\n") - if err := os.WriteFile(configPath, initialConfig, 0644); err != nil { - pterm.Error.Printf("Failed to create config file: %v\n", err) - exitWithError() - } - } - - if err := v.ReadInConfig(); err != nil { - pterm.Error.Printf("Failed to read config: %v\n", err) - exitWithError() - } - - // Get current environment settings - envPath := fmt.Sprintf("environments.%s", currentEnvironment) - envSettings := v.GetStringMap(envPath) + envPath := fmt.Sprintf("environments.%s", currentEnv) + envSettings := viper.GetStringMap(envPath) if envSettings == nil { envSettings = make(map[string]interface{}) } - // Update token while preserving other settings - envSettings["token"] = newToken - - // Save updated settings - v.Set(envPath, envSettings) - - if err := v.WriteConfig(); err != nil { - pterm.Error.Printf("Failed to save token: %v\n", err) - exitWithError() + // Keep only endpoint and proxy settings + newEnvSettings := make(map[string]interface{}) + if endpoint, ok := envSettings["endpoint"]; ok { + newEnvSettings["endpoint"] = endpoint } - - fmt.Println() - pterm.Success.Printf("Token successfully saved to %s\n", configPath) -} - -// sortEnvironmentContent sorts the environment content to ensure token is at the end -func sortEnvironmentContent(content []string, token string, indentLevel int) []string { - var sorted []string - var endpointLine, proxyLine string - - for _, line := range content { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "endpoint:") { - endpointLine = line - } else if strings.HasPrefix(trimmed, "proxy:") { - proxyLine = line - } + if proxy, ok := envSettings["proxy"]; ok { + newEnvSettings["proxy"] = proxy } - if endpointLine != "" { - sorted = append(sorted, endpointLine) + // Keep the tokens array + if tokens, ok := envSettings["tokens"]; ok { + newEnvSettings["tokens"] = tokens } - if proxyLine != "" { - sorted = append(sorted, proxyLine) - } - - sorted = append(sorted, strings.Repeat(" ", indentLevel)+"token: "+token) - return sorted + viper.Set(envPath, newEnvSettings) + return viper.WriteConfig() } func selectScopeOrWorkspace(workspaces []map[string]interface{}, roleType string) string { diff --git a/go.mod b/go.mod index 1d38c81..01cc449 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/cloudforet-io/cfctl go 1.23.1 require ( + github.com/AlecAivazis/survey/v2 v2.3.7 github.com/atotto/clipboard v0.1.4 github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 github.com/jhump/protoreflect v1.17.0 @@ -30,9 +31,13 @@ require ( github.com/gookit/color v1.5.4 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/rivo/uniseg v0.4.4 // indirect diff --git a/go.sum b/go.sum index a350b81..4ffe438 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8= atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ= atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs= atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU= +github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= +github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8= github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII= @@ -17,6 +19,8 @@ github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/ github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= @@ -25,6 +29,8 @@ github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -51,10 +57,14 @@ github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= @@ -72,9 +82,18 @@ github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8 github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= @@ -159,6 +178,7 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -167,6 +187,7 @@ golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= @@ -180,6 +201,7 @@ golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= From 82cf4726050e5714c992485405a2b6545206e0a2 Mon Sep 17 00:00:00 2001 From: Youngjin Jo <yjinjo@berkeley.edu> Date: Thu, 28 Nov 2024 04:10:23 +0900 Subject: [PATCH 5/6] refactor: add multi-app login Signed-off-by: Youngjin Jo <yjinjo@berkeley.edu> --- cmd/other/login.go | 266 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 235 insertions(+), 31 deletions(-) diff --git a/cmd/other/login.go b/cmd/other/login.go index 6050dcf..73fc2cf 100644 --- a/cmd/other/login.go +++ b/cmd/other/login.go @@ -251,19 +251,143 @@ func maskToken(token string) string { // executeAppLogin handles login for app environments func executeAppLogin(currentEnv string) error { - // Get token from user - token, err := promptToken() - if err != nil { + homeDir, _ := os.UserHomeDir() + configPath := filepath.Join(homeDir, ".cfctl", "config.yaml") + + viper.SetConfigFile(configPath) + if err := viper.ReadInConfig(); err != nil && !os.IsNotExist(err) { return err } - // Save token to tokens array - if err := saveAppToken(currentEnv, token); err != nil { + envPath := fmt.Sprintf("environments.%s.tokens", currentEnv) + var tokens []TokenInfo + if tokensList := viper.Get(envPath); tokensList != nil { + if tokenList, ok := tokensList.([]interface{}); ok { + for _, t := range tokenList { + if tokenMap, ok := t.(map[string]interface{}); ok { + tokenInfo := TokenInfo{ + Token: tokenMap["token"].(string), + } + tokens = append(tokens, tokenInfo) + } + } + } + } + + if err := keyboard.Open(); err != nil { return err } + defer keyboard.Close() + + selectedIndex := 0 + options := []string{"Enter a new token"} + var validTokens []TokenInfo // 유효한 토큰만 저장할 새로운 슬라이스 + + for _, tokenInfo := range tokens { + claims, err := validateAndDecodeToken(tokenInfo.Token) + if err != nil { + pterm.Warning.Printf("Invalid token found in config: %v\n", err) + continue + } + + displayName := getTokenDisplayName(claims) + options = append(options, displayName) + validTokens = append(validTokens, tokenInfo) + } + + if len(validTokens) == 0 && len(tokens) > 0 { + pterm.Warning.Println("All existing tokens are invalid. Please enter a new token.") + // Clear invalid tokens from config + if err := clearInvalidTokens(currentEnv); err != nil { + pterm.Warning.Printf("Failed to clear invalid tokens: %v\n", err) + } + } + + for { + fmt.Print("\033[H\033[2J") // Clear screen + + pterm.DefaultHeader.WithFullWidth(). + WithBackgroundStyle(pterm.NewStyle(pterm.BgDarkGray)). + WithTextStyle(pterm.NewStyle(pterm.FgLightWhite)). + Println("Choose an option:") + + for i, option := range options { + if i == selectedIndex { + pterm.Printf("→ %d: %s\n", i, option) + } else { + pterm.Printf(" %d: %s\n", i, option) + } + } + + pterm.DefaultBasicText.WithStyle(pterm.NewStyle(pterm.FgGray)). + Println("\nNavigation: [j]down [k]up [Enter]select [q]uit") + + char, key, err := keyboard.GetKey() + if err != nil { + return err + } + + switch key { + case keyboard.KeyEnter: + if selectedIndex == 0 { + // Enter a new token + token, err := promptToken() + if err != nil { + return err + } + + // Validate new token before saving + if _, err := validateAndDecodeToken(token); err != nil { + return fmt.Errorf("invalid token: %v", err) + } + + // First save to tokens array + if err := saveAppToken(currentEnv, token); err != nil { + return err + } + // Then set as current token + if err := saveSelectedToken(currentEnv, token); err != nil { + return err + } + pterm.Success.Printf("Token successfully saved and selected\n") + return nil + } else { + // Use selected token from existing valid tokens + selectedToken := validTokens[selectedIndex-1].Token + if err := saveSelectedToken(currentEnv, selectedToken); err != nil { + return fmt.Errorf("failed to save selected token: %v", err) + } + pterm.Success.Printf("Token successfully selected\n") + return nil + } + } + + switch char { + case 'j': + if selectedIndex < len(options)-1 { + selectedIndex++ + } + case 'k': + if selectedIndex > 0 { + selectedIndex-- + } + case 'q', 'Q': + pterm.Error.Println("Selection cancelled.") + os.Exit(1) + } + } +} + +func getTokenDisplayName(claims map[string]interface{}) string { + role := claims["rol"].(string) + domainID := claims["did"].(string) + + if role == "WORKSPACE_OWNER" { + workspaceID := claims["wid"].(string) + return fmt.Sprintf("%s (%s, %s)", role, domainID, workspaceID) + } - pterm.Success.Printf("Token successfully saved\n") - return nil + return fmt.Sprintf("%s (%s)", role, domainID) } func executeUserLogin(currentEnv string) { @@ -912,33 +1036,17 @@ func determineScope(roleType string, workspaceCount int) string { } } +// isTokenExpired checks if the token is expired func isTokenExpired(token string) bool { - parts := strings.Split(token, ".") - if len(parts) != 3 { - pterm.Error.Println("Invalid token format.") - return true - } - - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + claims, err := decodeJWT(token) if err != nil { - pterm.Error.Println("Failed to decode token payload:", err) - return true - } - - var claims map[string]interface{} - if err := json.Unmarshal(payload, &claims); err != nil { - pterm.Error.Println("Failed to unmarshal token payload:", err) - return true + return true // 디코딩 실패 시 만료된 것으로 간주 } - exp, ok := claims["exp"].(float64) - if !ok { - pterm.Error.Println("Expiration time (exp) not found in token.") - return true + if exp, ok := claims["exp"].(float64); ok { + return time.Now().Unix() > int64(exp) } - - expirationTime := time.Unix(int64(exp), 0) - return time.Now().After(expirationTime) + return true // exp 필드가 없거나 잘못된 형식이면 만료된 것으로 간주 } func verifyToken(token string) bool { @@ -1413,8 +1521,10 @@ func saveSelectedToken(currentEnv, selectedToken string) error { envSettings = make(map[string]interface{}) } - // Keep only endpoint and proxy settings + // Keep all existing settings newEnvSettings := make(map[string]interface{}) + + // Keep endpoint and proxy settings if endpoint, ok := envSettings["endpoint"]; ok { newEnvSettings["endpoint"] = endpoint } @@ -1422,11 +1532,14 @@ func saveSelectedToken(currentEnv, selectedToken string) error { newEnvSettings["proxy"] = proxy } - // Keep the tokens array + // Keep tokens array if tokens, ok := envSettings["tokens"]; ok { newEnvSettings["tokens"] = tokens } + // Set the selected token as current token + newEnvSettings["token"] = selectedToken + viper.Set(envPath, newEnvSettings) return viper.WriteConfig() } @@ -1667,3 +1780,94 @@ func filterWorkspaces(workspaces []map[string]interface{}, searchTerm string) [] func init() { LoginCmd.Flags().StringVarP(&providedUrl, "url", "u", "", "The URL to use for login (e.g. cfctl login -u https://example.com)") } + +// decodeJWT decodes a JWT token and returns the claims +func decodeJWT(token string) (map[string]interface{}, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid token format") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, err + } + + var claims map[string]interface{} + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, err + } + + return claims, nil +} + +// validateAndDecodeToken decodes a JWT token and validates its expiration +func validateAndDecodeToken(token string) (map[string]interface{}, error) { + // Check if token has three parts (header.payload.signature) + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid token format: token must have three parts") + } + + // Try to decode the payload + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("invalid token format: failed to decode payload: %v", err) + } + + var claims map[string]interface{} + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, fmt.Errorf("invalid token format: failed to parse payload: %v", err) + } + + // Check required fields + requiredFields := []string{"exp", "rol", "did"} + for _, field := range requiredFields { + if _, ok := claims[field]; !ok { + return nil, fmt.Errorf("invalid token format: missing required field '%s'", field) + } + } + + // Check expiration + if isTokenExpired(token) { + return nil, fmt.Errorf("token has expired") + } + + return claims, nil +} + +// clearInvalidTokens removes invalid tokens from the config +func clearInvalidTokens(currentEnv string) error { + homeDir, _ := os.UserHomeDir() + configPath := filepath.Join(homeDir, ".cfctl", "config.yaml") + + viper.SetConfigFile(configPath) + if err := viper.ReadInConfig(); err != nil { + return err + } + + envPath := fmt.Sprintf("environments.%s", currentEnv) + envSettings := viper.GetStringMap(envPath) + if envSettings == nil { + return nil + } + + var validTokens []TokenInfo + if tokensList := viper.Get(fmt.Sprintf("%s.tokens", envPath)); tokensList != nil { + if tokenList, ok := tokensList.([]interface{}); ok { + for _, t := range tokenList { + if tokenMap, ok := t.(map[string]interface{}); ok { + token := tokenMap["token"].(string) + if _, err := validateAndDecodeToken(token); err == nil { + validTokens = append(validTokens, TokenInfo{Token: token}) + } + } + } + } + } + + // Update config with only valid tokens + envSettings["tokens"] = validTokens + viper.Set(envPath, envSettings) + return viper.WriteConfig() +} From 53d78ed71e0b2adc286822fe1eef7b29808023db Mon Sep 17 00:00:00 2001 From: Youngjin Jo <yjinjo@berkeley.edu> Date: Thu, 28 Nov 2024 04:37:23 +0900 Subject: [PATCH 6/6] refactor: implement user login and add token Signed-off-by: Youngjin Jo <yjinjo@berkeley.edu> --- cmd/other/login.go | 45 +++++++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/cmd/other/login.go b/cmd/other/login.go index 73fc2cf..e46d46d 100644 --- a/cmd/other/login.go +++ b/cmd/other/login.go @@ -61,7 +61,7 @@ type tokenAuth struct { func (t *tokenAuth) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) { return map[string]string{ - "token": t.token, // "Authorization: Bearer" 대신 "token" 키 사용 + "token": t.token, // Use "token" key instead of "Authorization: Bearer" }, nil } @@ -281,7 +281,7 @@ func executeAppLogin(currentEnv string) error { selectedIndex := 0 options := []string{"Enter a new token"} - var validTokens []TokenInfo // 유효한 토큰만 저장할 새로운 슬라이스 + var validTokens []TokenInfo // New slice to store only valid tokens for _, tokenInfo := range tokens { claims, err := validateAndDecodeToken(tokenInfo.Token) @@ -614,23 +614,23 @@ func promptUserSelection(max int, users []interface{}) int { WithTextStyle(pterm.NewStyle(pterm.FgLightWhite)). Printf("Select a user account (Page %d of %d)", currentPage+1, totalPages) + // Display option to add new user first + if selectedIndex == 0 { + pterm.Printf("→ %d: Add new user\n", 1) + } else { + pterm.Printf(" %d: Add new user\n", 1) + } + // Display existing users for i := startIndex; i < endIndex; i++ { userMap := filteredUsers[i].(map[string]interface{}) - if i == selectedIndex { - pterm.Printf("→ %d: %s\n", i+1, userMap["userid"].(string)) + if i+1 == selectedIndex { + pterm.Printf("→ %d: %s\n", i+2, userMap["userid"].(string)) } else { - pterm.Printf(" %d: %s\n", i+1, userMap["userid"].(string)) + pterm.Printf(" %d: %s\n", i+2, userMap["userid"].(string)) } } - // Display option to add new user - if selectedIndex == totalUsers { - pterm.Printf("→ %d: Add new user\n", totalUsers+1) - } else { - pterm.Printf(" %d: Add new user\n", totalUsers+1) - } - // Show navigation help pterm.DefaultBasicText.WithStyle(pterm.NewStyle(pterm.FgGray)). Println("\nNavigation: [h]prev-page [j]down [k]up [l]next-page [/]search [Enter]select [q]quit") @@ -674,13 +674,11 @@ func promptUserSelection(max int, users []interface{}) int { // Handle normal mode input switch key { case keyboard.KeyEnter: - if selectedIndex <= len(filteredUsers) { - // If "Add new user" is selected - if selectedIndex == len(filteredUsers) { - return len(users) + 1 - } + if selectedIndex == 0 { + return len(users) + 1 // Add new user + } else if selectedIndex <= len(filteredUsers) { // Find the original index of the selected user - selectedUserMap := filteredUsers[selectedIndex].(map[string]interface{}) + selectedUserMap := filteredUsers[selectedIndex-1].(map[string]interface{}) selectedUserID := selectedUserMap["userid"].(string) for i, user := range users { @@ -694,22 +692,22 @@ func promptUserSelection(max int, users []interface{}) int { switch char { case 'j': // Down - if selectedIndex < min(endIndex, totalUsers) { + if selectedIndex < min(endIndex-startIndex, totalUsers) { selectedIndex++ } case 'k': // Up - if selectedIndex > startIndex { + if selectedIndex > 0 { selectedIndex-- } case 'l': // Next page if currentPage < totalPages-1 { currentPage++ - selectedIndex = currentPage * pageSize + selectedIndex = 0 } case 'h': // Previous page if currentPage > 0 { currentPage-- - selectedIndex = currentPage * pageSize + selectedIndex = 0 } case '/': // Enter search mode searchMode = true @@ -857,6 +855,9 @@ func saveCredentials(currentEnv, userID, password, token string) { envSettings = make(map[string]interface{}) } + // Save token at the root level of the environment + envSettings["token"] = token + var users []UserCredentials if existingUsers, ok := envSettings["users"]; ok { if userList, ok := existingUsers.([]interface{}); ok {