package ec2macosinit

import (
	"crypto/rand"
	"encoding/base64"
	"fmt"
	"path/filepath"
	"strings"
)

const (
	// PasswordLength is the default number of characters that the auto-generated password should be
	PasswordLength = 25
	// DsclPath is the default path for the dscl utility needed for the functions in this file
	DsclPath = "/usr/bin/dscl"
)

// UserManagementModule contains the necessary values to run a User Management Module
type UserManagementModule struct {
	RandomizePassword bool   `toml:"RandomizePassword"`
	User              string `toml:"User"`
}

// Do for the UserManagementModule is the primary entry point for the User Management Module.
func (c *UserManagementModule) Do(ctx *ModuleContext) (message string, err error) {
	// Check if randomizing password is requested. If so, then perform action, otherwise return with no work to do
	if c.RandomizePassword {
		message, err = c.randomizePassword()
		if err != nil {
			return "", fmt.Errorf("ec2macosinit: failed to randomize password: %s", err)
		}
	} else {
		return "randomizing password disabled, skipping", nil
	}

	// For now, `message` will only be set if RandomizePassword is true. Instead of returning above, it is returned here
	// for readability and future additions to the module
	return message, nil
}

// isSecureTokenSet wraps the sysadminctl call to provide a bool for checking if its enabled
// The way to detect if the Secure Token is set for a user is `sysadminctl`, here is an example for ec2-user:
//     /usr/sbin/sysadminctl -secureTokenStatus ec2-user
//     2021-01-14 18:17:47.414 sysadminctl[96836:904874] Secure token is DISABLED for user ec2-user
// When enabled it shows:
//     2021-01-14 19:21:55.854 sysadminctl[14193:181530] Secure token is ENABLED for user ec2-user
func (c *UserManagementModule) isSecureTokenSet() (enabled bool, err error) {
	// Fetch the text from the built-in tool sysadminctl
	statusText, err := executeCommand([]string{"/usr/sbin/sysadminctl", "-secureTokenStatus", c.User}, "", []string{})
	if err != nil {
		return false, fmt.Errorf("ec2macosinit: unable to get Secure Token status for %s: %s", c.User, err)
	}
	// If the text has "ENABLED" then return true, otherwise return false
	if strings.Contains(statusText.stdout, "Secure token is ENABLED") {
		return true, nil
	}
	return false, nil
}

// disableSecureTokenCreation disables the default behavior to enable the Secure Token on the next user password change.
// From https://support.apple.com/guide/deployment-reference-macos/using-secure-and-bootstrap-tokens-apdff2cf769b/web
// This is the command used to avoid setting the SecureToken when changing the password
//     /usr/bin/dscl . append /Users/ec2-user AuthenticationAuthority ";DisabledTags;SecureToken"
func (c *UserManagementModule) disableSecureTokenCreation() (err error) {
	_, err = executeCommand([]string{DsclPath, ".", "append", filepath.Join("Users", c.User), "AuthenticationAuthority", ";DisabledTags;SecureToken"}, "", []string{})
	if err != nil {
		return fmt.Errorf("ec2macosinit: failed disable Secure Token creation: %s", err)
	}
	return nil
}

// enableSecureTokenCreation enables the default behavior to enable the Secure Token on the next user password change.
// From https://support.apple.com/guide/deployment-reference-macos/using-secure-and-bootstrap-tokens-apdff2cf769b/web
// This is the command used to remove the setting for the SecureToken when changing the password
//     /usr/bin/dscl . delete /Users/ec2-user AuthenticationAuthority ";DisabledTags;SecureToken"
func (c *UserManagementModule) enableSecureTokenCreation() (err error) {
	_, err = executeCommand([]string{DsclPath, ".", "delete", filepath.Join("Users", c.User), "AuthenticationAuthority", ";DisabledTags;SecureToken"}, "", []string{})
	if err != nil {
		return fmt.Errorf("ec2macosinit: failed to disable Secure Token creation: %s", err)
	}
	return nil
}

// changePassword changes the password to a provided string.
func (c *UserManagementModule) changePassword(password string) (err error) {
	_, err = executeCommand([]string{DsclPath, ".", "-passwd", filepath.Join("Users", c.User), password}, "", []string{})
	if err != nil {
		return fmt.Errorf("ec2macosinit: failed to set %s's password: %s", c.User, err)
	}
	return nil
}

// randomizePassword confirms if the Secure Token is set and randomizes the user password.
// The password change functionality, at its core, is simply detecting if the user password can be randomized for
// the default "ec2-user" user. The complexity comes in when dealing with the Secure Token. From Big Sur onward, the
// Secure Token is set on all initial password changes, this is not ideal since future password changes would require
// knowing this random password. This process is built to avoid the Secure Token being set on this first randomization.
// The basic flow is:
//   1. Check for the Secure Token already being set which would prevent changing the password
//   2. Add a special property to avoid the Secure Token from being set
//   3. Change the password to a random string
//   4. Undo the special property so that the next password change will set the Secure Token
func (c *UserManagementModule) randomizePassword() (message string, err error) {
	// This detection of the user probably needs to move into the Do() function when there is more to do, but since this
	// is the first place the c.User is used, its handled here
	// If user is undefined, default to ec2-user
	if c.User == "" {
		c.User = "ec2-user"
	}

	// Verify that user exists
	exists, err := userExists(c.User)
	if err != nil {
		return "", fmt.Errorf("ec2macosinit: error while checking if user %s exists: %s\n", c.User, err)
	}
	if !exists { // if the user doesn't exist, error out
		return "", fmt.Errorf("ec2macosinit: user %s does not exist\n", c.User)
	}

	// Check for Secure Token, if its already set then attempting to change the password will fail
	secureTokenSet, err := c.isSecureTokenSet()
	if err != nil {
		return "", fmt.Errorf("ec2macosinit: unable to confirm Secure Token is DISABLED: %s", err)
	}

	// Only proceed if user doesn't have Secure Token enabled
	if secureTokenSet {
		return "", fmt.Errorf("ec2macosinit: unable to change password, Secure Token Set for %s", c.User)
	}

	// Change Secure Token behavior if needed
	err = c.disableSecureTokenCreation()
	if err != nil {
		return "", fmt.Errorf("ec2macosinit: unable to disable Secure Token generation: %s", err)
	}
	defer func() {
		// Set Secure Token behavior back if needed
		deferErr := c.enableSecureTokenCreation()
		if deferErr != nil {
			// Catch a failure and change status returns to represent an error condition
			message = "" // Overwrite new message to indicate error
			err = fmt.Errorf("ec2macosinit: unable to enable Secure Token generation: %s %s", deferErr, err)
		}
	}()

	// Generate random password
	password, err := generateSecurePassword(PasswordLength)
	if err != nil {
		return "", fmt.Errorf("ec2macosinit: unable to generate secure password: %s", err)
	}

	// Change the password
	err = c.changePassword(password)
	if err != nil {
		return "", fmt.Errorf("ec2macosinit: unable to set secure password: %s", err)
	}

	return fmt.Sprintf("successfully set secure password for %s", c.User), nil
}

// generateRandomBytes returns securely generated random bytes for use in generating a password
// It will return an error if the system's secure random number generator fails to function correctly
func generateRandomBytes(n int) ([]byte, error) {
	b := make([]byte, n)
	_, err := rand.Read(b)
	if err != nil {
		return nil, fmt.Errorf("ec2macosinit: unable to read random bytes from OS: %s", err)
	}

	return b, nil
}

// generateSecurePassword generates a password securely for use in randomizePassword using the crypto/rand library
func generateSecurePassword(length int) (password string, err error) {
	// Fetch the requested number of bytes, this ensures at least that much entropy
	randomBytes, err := generateRandomBytes(length)
	if err != nil {
		return "", fmt.Errorf("ec2macosinit: unable to generate secure password %s", err)
	}
	// URLEncode it to have safe characters for passwords
	source := base64.URLEncoding.EncodeToString(randomBytes)

	// Return only the length requested since URL Encoding can result in longer strings
	return source[0:length], nil
}
