Browse Source

Merge pull request #289 from joyent/sungo/tokens

API tokens
sungo 1 year ago
parent
commit
a8f47a2efc

+ 3 - 2
Dockerfile

@@ -1,4 +1,5 @@
-FROM golang:1.12.1-alpine AS build
+# vim: se syn=dockerfile:
+FROM golang:1.12.4-alpine AS build
 ENV CGO_ENABLED 0
 
 RUN apk add --no-cache --update make git perl-utils dep shadow
@@ -25,5 +26,5 @@ FROM scratch
 COPY --from=build /go/src/github.com/joyent/conch-shell/bin/conch /bin/conch
 COPY --from=build /etc/ssl /etc/ssl
 
-ENTRYPOINT [ "/bin/conch", "--no-version-check" ]
+ENTRYPOINT [ "/bin/conch" ]
 CMD ["version"]

+ 1 - 1
Dockerfile.release

@@ -1,5 +1,5 @@
 # vim: se syn=dockerfile:
-FROM golang:1.12.1-alpine
+FROM golang:1.12.4-alpine
 ENV CGO_ENABLED 0
 
 RUN apk add --no-cache --update make git perl-utils dep shadow

+ 10 - 3
Makefile

@@ -1,5 +1,10 @@
 VERSION ?= $(shell git describe --tags --abbrev=0 | sed 's/^v//')
 DISABLE_API_VERSION_CHECK ?= 0
+DISABLE_API_TOKEN_CRUD ?= 0
+DISABLE_ADMIN_FUNCTIONS ?= 0
+
+# Pass in a different value please. Please?
+TOKEN_OBFUSCATION_KEY ?= "eig0Ahcoi4phepoow2Wee8ahfoe3een4shebahz0Uhu8O"
 
 build: vendor clean test all ## Test and build binaries for local architecture into bin/
 
@@ -49,7 +54,7 @@ RELEASES   := $(foreach bin,$(RELEASE_BINARIES),release/$(bin))
 
 GIT_REV    := $(shell git describe --always --abbrev --dirty --long)
 FLAGS_PATH := github.com/joyent/conch-shell/pkg/util
-LD_FLAGS   := -ldflags="-X $(FLAGS_PATH).Version=$(VERSION) -X $(FLAGS_PATH).GitRev=$(GIT_REV) -X $(FLAGS_PATH).DisableApiVersionCheck=$(DISABLE_API_VERSION_CHECK)"
+LD_FLAGS   := -ldflags="-X github.com/joyent/conch-shell/pkg/config.ObfuscationKey=${TOKEN_OBFUSCATION_KEY} -X $(FLAGS_PATH).Version=$(VERSION) -X $(FLAGS_PATH).GitRev=$(GIT_REV) -X $(FLAGS_PATH).FlagsDisableApiVersionCheck=$(DISABLE_API_VERSION_CHECK) -X $(FLAGS_PATH).FlagsDisableApiTokenCRUD=$(DISABLE_API_TOKEN_CRUD) -X $(FLAGS_PATH).FlagsNoAdmin=$(DISABLE_ADMIN_FUNCTIONS)"
 BUILD      := CGO_ENABLED=0 go build $(LD_FLAGS) 
 
 ####
@@ -61,7 +66,8 @@ release: vendor test $(RELEASES) ## Build release binaries with checksums
 
 bin/%:
 	@mkdir -p bin
-	$(BUILD) -o bin/$(subst bin/,,$@) cmd/$(subst bin/,,$@)/*.go
+	@echo "> Building bin/$(subst bin/,,$@)"
+	@$(BUILD) -o bin/$(subst bin/,,$@) cmd/$(subst bin/,,$@)/*.go
 
 os   = $(firstword $(subst -, ,$1))
 arch = $(lastword $(subst -, ,$1))
@@ -72,7 +78,8 @@ define release_me
 	$(eval GOARCH:=$(call arch, $(platform)))
 	$(eval RPATH:=release/$(BIN)-$(GOOS)-$(GOARCH))
 
-	GOOS=$(GOOS) GOARCH=$(GOARCH) $(BUILD) -o $(RPATH) cmd/$(BIN)/*.go
+	@echo "> Building $(RPATH)"
+	@GOOS=$(GOOS) GOARCH=$(GOARCH) $(BUILD) -o $(RPATH) cmd/$(BIN)/*.go
 	shasum -a 256 $(RPATH) > $(RPATH).sha256
 endef
 

+ 65 - 29
pkg/cmd/conch1/main.go

@@ -7,6 +7,7 @@
 package conch1
 
 import (
+	"errors"
 	"fmt"
 	"os"
 
@@ -40,16 +41,38 @@ func Init() *cli.Cli {
 					conch.MinimumAPIVersion,
 					conch.BreakingAPIVersion,
 				)
-				if util.NoApiVersionCheck {
+				if util.DisableApiVersionCheck() {
 					fmt.Println("\n** API version checking is disabled. Functionality cannot be guaranteed **")
 				}
 			}
 		},
 	)
+
 	var (
+		tokenOpt = app.String(cli.StringOpt{
+			Name:   "token",
+			Value:  "",
+			Desc:   "API token",
+			EnvVar: "CONCH_TOKEN",
+		})
+
+		environmentOpt = app.String(cli.StringOpt{
+			Name:   "environment env",
+			Value:  "production",
+			Desc:   "Specify the environment to be used: production, staging, development (provide URL in the --url parameter)",
+			EnvVar: "CONCH_ENV",
+		})
+
+		urlOpt = app.String(cli.StringOpt{
+			Name:   "url",
+			Value:  "",
+			Desc:   "If the environment is 'development', this specifies the API URL. Ignored if --environment is 'production' or 'staging'",
+			EnvVar: "CONCH_URL",
+		})
+
 		useJSON         = app.BoolOpt("json j", false, "Output JSON")
 		configFile      = app.StringOpt("config c", "~/.conch.json", "Path to config file")
-		noVersion       = app.BoolOpt("no-version-check", false, "Skip Github version check")
+		noVersion       = app.BoolOpt("no-version-check", false, "Does nothing. Included for backwards compatibility.") // TODO(sungo): remove back compat
 		profileOverride = app.StringOpt("profile p", "", "Override the active profile")
 		debugMode       = app.BoolOpt("debug", false, "Debug mode")
 		traceMode       = app.BoolOpt("trace", false, "Trace http requests. Warning: this is super loud")
@@ -65,6 +88,31 @@ func Init() *cli.Cli {
 			util.JSON = false
 		}
 
+		if *noVersion {
+			fmt.Fprintf(os.Stderr, "--no-version-check is deprecated and no longer functional")
+		}
+
+		if (*profileOverride != "") && (len(*tokenOpt) > 0) {
+			util.IgnoreConfig = true
+			util.Token = *tokenOpt
+
+			if len(*environmentOpt) > 0 {
+				if (*environmentOpt == "development") && (len(*urlOpt) == 0) {
+					util.Bail(errors.New("--url must be provied if --environment=development is set"))
+				}
+			}
+
+			switch *environmentOpt {
+			case "staging":
+				util.BaseURL = config.StagingURL
+			case "development":
+				util.BaseURL = *urlOpt
+			default:
+				util.BaseURL = config.ProductionURL
+			}
+
+		}
+
 		expandedPath, err := homedir.Expand(*configFile)
 		if err != nil {
 			util.Bail(err)
@@ -78,44 +126,32 @@ func Init() *cli.Cli {
 			if *profileOverride != "" {
 				if prof.Name == *profileOverride {
 					util.ActiveProfile = prof
+					if prof.Token != "" {
+						util.Token = string(prof.Token)
+					}
+
 					break
 				}
 			} else if prof.Active {
 				util.ActiveProfile = prof
+				if prof.Token != "" {
+					util.Token = string(prof.Token)
+				}
+
 				break
 			}
 		}
-		if (*profileOverride != "") && (util.ActiveProfile == nil) {
-			util.Bail(fmt.Errorf("could not find a profile named '%s'", *profileOverride))
-		}
 
-		checkVersion := true
-		if *noVersion || cfg.SkipVersionCheck {
-			checkVersion = false
-		}
-
-		if checkVersion {
-			gh, err := util.LatestGithubRelease()
-			if (err != nil) && (err != util.ErrNoGithubRelease) {
-				util.Bail(err)
-			}
-			if gh.Upgrade {
-				os.Stderr.WriteString(fmt.Sprintf(`
-A new release is available! You have v%s but %s is available.
-The changelog can be viewed via 'conch update changelog'
-
-You can obtain the new release by:
-  * Running 'conch update self', which will attempt to overwrite the current application
-  * Manually download the new release at %s
-
-`,
-					util.Version,
-					gh.TagName,
-					gh.URL,
-				))
+		if !util.IgnoreConfig {
+			if (*profileOverride != "") && (util.ActiveProfile == nil) {
+				util.Bail(fmt.Errorf("could not find a profile named '%s'", *profileOverride))
 			}
 		}
 
+		// There is no way to avoid the version check, save piping stderr to
+		// /dev/null.  The API is changing too much and introducing too much
+		// breakage on the regular for users to stick using old versions.
+		util.GithubReleaseCheck()
 	}
 
 	return app

+ 30 - 1
pkg/commands/admin/init.go

@@ -20,6 +20,10 @@ var UserEmail string
 
 // Init loads up the commands
 func Init(app *cli.Cli) {
+	if util.NoAdmin() {
+		return
+	}
+
 	app.Command(
 		"admin",
 		"Commands for various server-side administrative tasks",
@@ -61,7 +65,7 @@ func Init(app *cli.Cli) {
 
 					cmd.Command(
 						"revoke",
-						"Revoke the auth tokens for a given user",
+						"Revoke the api tokens and/or logins for a given user",
 						revokeTokens,
 					)
 
@@ -101,6 +105,31 @@ func Init(app *cli.Cli) {
 						demoteUser,
 					)
 
+					cmd.Command(
+						"tokens",
+						"List the API tokens for a user",
+						listTokens,
+					)
+
+					cmd.Command(
+						"token",
+						"Operate on a user's API tokens",
+						func(cmd *cli.Cmd) {
+							cmd.Command(
+								"get",
+								"Get a user's API token",
+								getToken,
+							)
+
+							cmd.Command(
+								"delete rm",
+								"Delete a user's API token",
+								removeToken,
+							)
+
+						},
+					)
+
 				},
 			)
 		},

+ 118 - 6
pkg/commands/admin/main.go

@@ -69,21 +69,131 @@ func listAllUsers(app *cli.Cmd) {
 
 func revokeTokens(app *cli.Cmd) {
 	var (
-		forceOpt = app.BoolOpt("force", false, "Perform destructive actions")
+		forceOpt   = app.BoolOpt("force", false, "Perform destructive actions")
+		revokeAuth = app.BoolOpt("auth-only", false, "Revoke auth tokens, not API tokens. This will force a user to log in again on the website (and old versions of the shell)")
+		tokenAuth  = app.BoolOpt("tokens-only", false, "Revoke all API tokens. This will likely break a lot of automation so use this carefully")
+		allAuth    = app.BoolOpt("all", false, "The nuclear option. Revoke all auth *and* API tokens, forcing the user to login again *and* to generate new API tokens for automation processes. Use this very carefully")
 	)
-	app.Spec = "--force"
+	app.Spec = "--force (--auth-only | --tokens-only | --all)"
 
 	app.Action = func() {
 		if !*forceOpt {
 			return
 		}
 
-		if err := util.API.RevokeUserTokens(UserEmail); err != nil {
+		if *allAuth {
+			if err := util.API.RevokeUserTokensAndLogins(UserEmail); err != nil {
+				util.Bail(err)
+			}
+
+			if !util.JSON {
+				fmt.Printf("Login and API tokens revoked for %s.\n", UserEmail)
+			}
+			return
+		}
+
+		if *revokeAuth {
+			if err := util.API.RevokeUserLogins(UserEmail); err != nil {
+				util.Bail(err)
+			}
+
+			if !util.JSON {
+				fmt.Printf("Login tokens revoked for %s.\n", UserEmail)
+			}
+			return
+		}
+		if *tokenAuth {
+			if err := util.API.RevokeUserTokens(UserEmail); err != nil {
+				util.Bail(err)
+			}
+
+			if !util.JSON {
+				fmt.Printf("API tokens revoked for %s.\n", UserEmail)
+			}
+			return
+		}
+	}
+}
+
+func listTokens(app *cli.Cmd) {
+	app.Before = util.BuildAPIAndVerifyLogin
+
+	app.Action = func() {
+		tokens, err := util.API.GetUserTokens(UserEmail)
+		if err != nil {
 			util.Bail(err)
 		}
+		if util.JSON {
+			util.JSONOut(tokens)
+			return
+		}
 
-		if !util.JSON {
-			fmt.Printf("Tokens revoked for %s.\n", UserEmail)
+		sort.Sort(tokens)
+
+		table := util.GetMarkdownTable()
+		table.SetHeader([]string{"Name", "Created", "Last Used"})
+
+		for _, t := range tokens {
+			timeStr := ""
+			if !t.LastUsed.IsZero() {
+				timeStr = util.TimeStr(t.LastUsed)
+			}
+
+			table.Append([]string{
+				t.Name,
+				util.TimeStr(t.Created),
+				timeStr,
+			})
+		}
+
+		table.Render()
+	}
+}
+
+func getToken(cmd *cli.Cmd) {
+	cmd.Before = util.BuildAPIAndVerifyLogin
+
+	var nameArg = cmd.StringArg("NAME", "", "Name for the token")
+	cmd.Spec = "NAME"
+
+	cmd.Action = func() {
+		token, err := util.API.GetUserToken(UserEmail, *nameArg)
+		if err != nil {
+			util.Bail(err)
+		}
+
+		if util.JSON {
+			util.JSONOut(token)
+			return
+		}
+
+		lastUsed := "[ Never Used ]"
+		if !token.LastUsed.IsZero() {
+			lastUsed = util.TimeStr(token.LastUsed)
+		}
+
+		fmt.Printf(`
+Name: %s
+Created: %s
+Last Used: %s
+`,
+			token.Name,
+			util.TimeStr(token.Created),
+			lastUsed,
+		)
+	}
+}
+
+func removeToken(app *cli.Cmd) {
+	app.Before = util.BuildAPIAndVerifyLogin
+
+	var nameArg = app.StringArg("NAME", "", "Name for the token")
+	app.Spec = "NAME"
+
+	app.Action = func() {
+		err := util.API.DeleteUserToken(UserEmail, *nameArg)
+		if err != nil {
+			util.Bail(err)
 		}
 	}
 }
@@ -130,8 +240,10 @@ func createUser(app *cli.Cmd) {
 }
 
 func resetUserPassword(app *cli.Cmd) {
+	var tokensOpt = app.BoolOpt("revoke-tokens", false, "Also revoke the user's API tokens")
+
 	app.Action = func() {
-		if err := util.API.ResetUserPassword(UserEmail); err != nil {
+		if err := util.API.ResetUserPassword(UserEmail, *tokensOpt); err != nil {
 			util.Bail(err)
 		}
 		if !util.JSON {

+ 24 - 44
pkg/commands/profile/init.go

@@ -9,6 +9,7 @@ package profile
 
 import (
 	"github.com/jawher/mow.cli"
+	"github.com/joyent/conch-shell/pkg/util"
 )
 
 // Init loads up the profile commands
@@ -37,30 +38,12 @@ func Init(app *cli.Cli) {
 				listProfiles,
 			)
 
-			cmd.Command(
-				"refresh",
-				"Refresh the auth token for the active profile",
-				refreshJWT,
-			)
-
-			cmd.Command(
-				"relogin",
-				"Log in again, preserving all other profile data",
-				relogin,
-			)
-
 			cmd.Command(
 				"change-password",
 				"Change the password associated with this profile",
 				changePassword,
 			)
 
-			cmd.Command(
-				"revoke-tokens",
-				"Revoke all auth tokens. User must log in again after this.",
-				revokeJWT,
-			)
-
 			cmd.Command(
 				"set",
 				"Change profile settings",
@@ -76,38 +59,35 @@ func Init(app *cli.Cli) {
 						"Change which profile is active",
 						setActive,
 					)
+
+					cmd.Command(
+						"token",
+						"Change the API token for the active profile. This will convert the profile to token auth if it was previously using login auth",
+						setToken,
+					)
 				},
 			)
 
 			cmd.Command(
-				"global",
-				"Change global settings that apply regardless of the profile chosen",
-				func(cmd *cli.Cmd) {
-					cmd.Command(
-						"version-check vc",
-						"Enable/disable version checking",
-						func(cmd *cli.Cmd) {
-							cmd.Command(
-								"status",
-								"See if version checking is enabled or disabled",
-								statusVersionCheck,
-							)
-
-							cmd.Command(
-								"enable",
-								"Enable version checking",
-								enableVersionCheck,
-							)
+				"upgrade",
+				"Upgrade this profile to use API tokens. This will generate a specific API token for this instance which will *not* be displayed or otherwise accessible",
+				upgradeToToken,
+			)
 
-							cmd.Command(
-								"disable",
-								"Disable version checking",
-								disableVersionCheck,
-							)
-						},
-					)
-				},
+			cmd.Command(
+				"relogin",
+				"Log in again, preserving all other profile data",
+				relogin,
 			)
+
+			if !util.DisableApiTokenCRUD() {
+				cmd.Command(
+					"revoke-tokens",
+					"Revoke all auth tokens. User must log in again after this.",
+					revokeJWT,
+				)
+			}
+
 		},
 	)
 }

+ 191 - 111
pkg/commands/profile/profile.go

@@ -9,6 +9,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"os"
 	"time"
 
 	"github.com/Bowery/prompt"
@@ -22,69 +23,53 @@ import (
 func newProfile(app *cli.Cmd) {
 	var (
 		nameOpt      = app.StringOpt("name", "", "Profile name. Must be unique")
-		userOpt      = app.StringOpt("user", "", "API User name")
-		apiOpt       = app.StringOpt("api url", "", "API URL")
-		passwordOpt  = app.StringOpt("password pass", "", "API Password")
-		workspaceOpt = app.StringOpt("workspace ws", "", "Default workspace")
 		overwriteOpt = app.BoolOpt("overwrite force", false, "Overwrite any profile with a matching name")
-	)
-
-	app.Action = func() {
-		p := &config.ConchProfile{}
+		workspaceOpt = app.StringOpt("workspace ws", "", "Default workspace")
 
-		password := *passwordOpt
+		tokenOpt    = app.StringOpt("token", "", "Use an API token instead of a password")
+		userOpt     = app.StringOpt("user", "", "API User name")
+		passwordOpt = app.StringOpt("password pass", "", "API Password")
 
-		if *nameOpt == "" {
-			s, err := prompt.Basic("Profile Name:", true)
-			if err != nil {
-				util.Bail(err)
-			}
+		envOpt = app.StringOpt("environment env", "production", "Specify the environment: production, staging, development (provide URL in the --url parameter)")
+		urlOpt = app.StringOpt("url", "", "If the environment is 'development', this defines the API URL. Ignored otherwise")
+	)
 
-			p.Name = s
-		} else {
-			p.Name = *nameOpt
-		}
+	app.Spec = "--name [OPTIONS]"
 
-		if !*overwriteOpt {
-			if _, ok := util.Config.Profiles[p.Name]; ok {
+	app.Action = func() {
+		var err error
+		var p *config.ConchProfile
+		if _, ok := util.Config.Profiles[*nameOpt]; ok {
+			if !*overwriteOpt {
 				util.Bail(
 					fmt.Errorf(
 						"a profile already exists with name '%s'",
-						p.Name,
+						*nameOpt,
 					),
 				)
 			}
-		}
-
-		if *userOpt == "" {
-			s, err := prompt.Basic("User Name:", true)
-			if err != nil {
-				util.Bail(err)
-			}
-			p.User = s
 
+			p = util.Config.Profiles[*nameOpt]
 		} else {
-			p.User = *userOpt
+			p = &config.ConchProfile{}
+			p.Name = *nameOpt
 		}
 
-		if password == "" {
-			s, err := prompt.Password("Password:")
-			if err != nil {
-				util.Bail(err)
+		switch *envOpt {
+		case "production":
+			p.BaseURL = config.ProductionURL
+		case "staging":
+			p.BaseURL = config.StagingURL
+		case "development":
+			if *urlOpt == "" {
+				util.Bail(errors.New("please provide --url"))
 			}
-
-			password = s
+			p.BaseURL = *urlOpt
+		default:
+			p.BaseURL = config.ProductionURL
 		}
 
-		if *apiOpt == "" {
-			s, err := prompt.BasicDefault("API URL:", "https://conch.joyent.us")
-			if err != nil {
-				util.Bail(err)
-			}
-			p.BaseURL = s
-		} else {
-			p.BaseURL = *apiOpt
-		}
+		/***/
 
 		util.API = &conch.Conch{
 			BaseURL: p.BaseURL,
@@ -94,18 +79,43 @@ func newProfile(app *cli.Cmd) {
 			util.API.UA = util.UserAgent
 		}
 
-		err := util.API.Login(p.User, password)
+		/***/
 
-		if err != nil {
-			if util.JSON || err != conch.ErrMustChangePassword {
+		if *tokenOpt != "" {
+			p.Token = config.Token(*tokenOpt)
+			util.API.Token = *tokenOpt
+
+			if ok, err := util.API.VerifyToken(); !ok {
 				util.Bail(err)
 			}
-			util.ActiveProfile = p
-			util.InteractiveForcePasswordChange()
-		}
 
-		p.JWT = util.API.JWT
-		p.Expires = p.JWT.Expires
+		} else {
+			if *userOpt == "" {
+				util.Bail(errors.New("please provide a user name"))
+			}
+			p.User = *userOpt
+
+			password := *passwordOpt
+			if password == "" {
+				s, err := prompt.Password("Password:")
+				if err != nil {
+					util.Bail(err)
+				}
+
+				password = s
+			}
+
+			if err := util.API.Login(p.User, password); err != nil {
+				if util.JSON || err != conch.ErrMustChangePassword {
+					util.Bail(err)
+				}
+				util.ActiveProfile = p
+				util.InteractiveForcePasswordChange()
+			}
+
+			p.JWT = util.API.JWT
+			p.Expires = p.JWT.Expires
+		}
 
 		if *workspaceOpt == "" {
 			p.WorkspaceUUID = uuid.UUID{}
@@ -128,7 +138,8 @@ func newProfile(app *cli.Cmd) {
 		}
 
 		util.Config.Profiles[p.Name] = p
-		util.WriteConfig()
+		util.WriteConfigForce()
+
 		if !util.JSON {
 			fmt.Printf("Done. Config written to %s\n", util.Config.Path)
 		}
@@ -156,7 +167,7 @@ func deleteProfile(app *cli.Cmd) {
 			}
 		}
 
-		util.WriteConfig()
+		util.WriteConfigForce()
 		if !util.JSON {
 			fmt.Printf("Done. Config written to %s\n", util.Config.Path)
 		}
@@ -190,7 +201,11 @@ func listProfiles(app *cli.Cmd) {
 		for _, prof := range util.Config.Profiles {
 			active := ""
 			if prof.Active {
-				active = "*"
+				if util.IgnoreConfig {
+					active = "*?"
+				} else {
+					active = "*"
+				}
 			}
 			workspaceName := ""
 			if !uuid.Equal(prof.WorkspaceUUID, uuid.UUID{}) {
@@ -198,7 +213,8 @@ func listProfiles(app *cli.Cmd) {
 					workspaceName = prof.WorkspaceName
 				}
 			}
-			expires := "[relogin]"
+
+			expires := ""
 			if !prof.JWT.Expires.IsZero() {
 				expires = util.TimeStr(prof.JWT.Expires)
 			}
@@ -213,6 +229,9 @@ func listProfiles(app *cli.Cmd) {
 			})
 		}
 		table.Render()
+		if util.IgnoreConfig {
+			fmt.Println("\n? The active profile has been overridden by the use of a token")
+		}
 	}
 }
 
@@ -227,6 +246,10 @@ func setWorkspace(app *cli.Cmd) {
 	}
 
 	app.Action = func() {
+		if util.ActiveProfile == nil {
+			util.Bail(errors.New("there is no active profile. Please use 'profile set active' to mark a profile as active"))
+		}
+
 		workspaceUUID, err := util.MagicWorkspaceID(*workspaceArg)
 		if err != nil {
 			util.Bail(err)
@@ -240,7 +263,7 @@ func setWorkspace(app *cli.Cmd) {
 		util.ActiveProfile.WorkspaceUUID = ws.ID
 		util.ActiveProfile.WorkspaceName = ws.Name
 
-		util.WriteConfig()
+		util.WriteConfigForce()
 		if !util.JSON {
 			fmt.Printf("Done. Config written to %s\n", util.Config.Path)
 		}
@@ -269,7 +292,7 @@ func setActive(app *cli.Cmd) {
 			)
 		}
 
-		util.WriteConfig()
+		util.WriteConfigForce()
 		if !util.JSON {
 			fmt.Printf("Done. Config written to %s\n", util.Config.Path)
 		}
@@ -277,54 +300,51 @@ func setActive(app *cli.Cmd) {
 	}
 }
 
-func refreshJWT(app *cli.Cmd) {
+func revokeJWT(app *cli.Cmd) {
+	var (
+		forceOpt   = app.BoolOpt("force", false, "Perform destructive actions")
+		revokeAuth = app.BoolOpt("auth-only", false, "Revoke auth tokens, not API tokens. This will force you to log in again on the website")
+		tokenAuth  = app.BoolOpt("tokens-only", false, "Revoke all API tokens. This will likely break all your automations and your ability to continue using the shell so use this carefully")
+		allAuth    = app.BoolOpt("all", false, "The nuclear option. Revoke all auth *and* API tokens, forcing you to login again *and* to generate new API tokens for automation processes, including the shell. Use this very carefully")
+	)
+	app.Spec = "--force (--auth-only | --tokens-only | --all)"
+
 	app.Action = func() {
-		util.BuildAPI()
-		if util.ActiveProfile == nil {
-			util.Bail(errors.New("no active profile. Please use 'conch profile' to create or set an active profile"))
+		if !*forceOpt {
+			return
 		}
+		util.BuildAPI()
 
-		if err := util.API.VerifyLogin(0, true); err != nil {
-			if util.JSON || err != conch.ErrMustChangePassword {
+		if *allAuth {
+			if err := util.API.RevokeMyTokensAndLogins(); err != nil {
 				util.Bail(err)
 			}
-			util.InteractiveForcePasswordChange()
-		}
-
-		util.ActiveProfile.JWT = util.API.JWT
-
-		util.WriteConfig()
-
-		if util.JSON {
-			util.JSONOut(struct {
-				Expires time.Time `json:"expires"`
-			}{util.API.JWT.Expires})
 
+			if !util.JSON {
+				fmt.Println("Login and API tokens revoked")
+			}
 			return
 		}
 
-		fmt.Printf(
-			"Auth for profile '%s' now expires at %s\n",
-			util.ActiveProfile.Name,
-			util.TimeStr(util.API.JWT.Expires),
-		)
-	}
-}
-
-func revokeJWT(app *cli.Cmd) {
-	var forceOpt = app.BoolOpt("force", false, "Perform destructive actions")
-	app.Spec = "--force"
+		if *revokeAuth {
+			if err := util.API.RevokeMyLogins(); err != nil {
+				util.Bail(err)
+			}
 
-	app.Action = func() {
-		if *forceOpt {
-			util.BuildAPIAndVerifyLogin()
-			if err := util.API.RevokeOwnTokens(); err != nil {
+			if !util.JSON {
+				fmt.Println("Login tokens revoked")
+			}
+			return
+		}
+		if *tokenAuth {
+			if err := util.API.RevokeMyTokens(); err != nil {
 				util.Bail(err)
 			}
 
 			if !util.JSON {
-				fmt.Println("Tokens revoked.")
+				fmt.Println("API tokens revoked")
 			}
+			return
 		}
 	}
 }
@@ -332,9 +352,24 @@ func revokeJWT(app *cli.Cmd) {
 func relogin(app *cli.Cmd) {
 	var (
 		passwordOpt = app.StringOpt("password pass", "", "API Password")
+		forceOpt    = app.BoolOpt("force", false, "If your profile uses a token, this option will be required since the command will eliminate the token from the config")
 	)
 
 	app.Action = func() {
+		if util.ActiveProfile == nil {
+			util.Bail(errors.New("there is no active profile. Please use 'profile set active' to mark a profile as active"))
+		}
+
+		if util.ActiveProfile.Token != "" {
+			if !*forceOpt {
+				util.Bail(errors.New("the current profile uses an API token. Running 'relogin' will irrevocably remove the token from the shell's configuration. Use --force to perform this destructive action"))
+			}
+
+		}
+		if util.ActiveProfile.User == "" {
+			util.Bail(errors.New("the profile lacks a user name, likely because it is token based. cannot continue"))
+		}
+
 		util.BuildAPI()
 
 		password := *passwordOpt
@@ -357,8 +392,11 @@ func relogin(app *cli.Cmd) {
 		}
 
 		util.ActiveProfile.JWT = util.API.JWT
+		util.ActiveProfile.Expires = util.API.JWT.Expires
+		util.ActiveProfile.Token = ""
+		util.Token = ""
+		util.WriteConfigForce()
 
-		util.WriteConfig()
 		if !util.JSON {
 			fmt.Printf("Done. Config written to %s\n", util.Config.Path)
 		}
@@ -367,7 +405,8 @@ func relogin(app *cli.Cmd) {
 
 func changePassword(app *cli.Cmd) {
 	var (
-		passwordOpt = app.StringOpt("password pass", "", "Account password")
+		passwordOpt  = app.StringOpt("password pass", "", "Account password")
+		revokeTokens = app.BoolOpt("purge-tokens", false, "Also purge API tokens")
 	)
 
 	app.Action = func() {
@@ -378,33 +417,74 @@ func changePassword(app *cli.Cmd) {
 		if password == "" {
 			util.InteractiveForcePasswordChange()
 		} else {
-			if err := util.API.ChangePassword(password); err != nil {
+			err := util.IsPasswordSane(password, nil)
+			if err != nil {
+				util.Bail(err)
+			}
+
+			if err := util.API.ChangeMyPassword(password, *revokeTokens); err != nil {
 				util.Bail(err)
 			}
 		}
+		util.WriteConfigForce()
 	}
 }
 
-func enableVersionCheck(app *cli.Cmd) {
-	app.Action = func() {
-		util.Config.SkipVersionCheck = false
-		util.WriteConfig()
-	}
-}
+func setToken(cmd *cli.Cmd) {
+	var tokenArg = cmd.StringArg("TOKEN", "", "An API token")
+	cmd.Spec = "TOKEN"
 
-func disableVersionCheck(app *cli.Cmd) {
-	app.Action = func() {
-		util.Config.SkipVersionCheck = true
-		util.WriteConfig()
+	cmd.Action = func() {
+		if util.ActiveProfile == nil {
+			util.Bail(errors.New("there is no active profile. Please use 'profile set active' to mark a profile as active"))
+		}
+
+		util.ActiveProfile.Token = config.Token(*tokenArg)
+		util.Token = *tokenArg
+
+		util.ActiveProfile.JWT = conch.ConchJWT{}
+
+		util.WriteConfigForce()
 	}
+
 }
 
-func statusVersionCheck(app *cli.Cmd) {
-	app.Action = func() {
-		if util.Config.SkipVersionCheck {
-			fmt.Println("disabled")
-		} else {
-			fmt.Println("enabled")
+func upgradeToToken(cmd *cli.Cmd) {
+	var forceOpt = cmd.BoolOpt("force", false, "Generate a new token, even if the current profile already uses one")
+	cmd.Action = func() {
+		if util.Token != "" && !*forceOpt {
+			util.Bail(errors.New("this profile already uses a token"))
+			return
+		}
+
+		hostname, err := os.Hostname()
+
+		if err != nil {
+			hostname = "unknown"
+		}
+
+		uid := os.Getuid()
+
+		epoch := time.Now().Unix()
+		id := fmt.Sprintf("%d@%s || %d", uid, hostname, epoch)
+
+		util.BuildAPI()
+
+		token, err := util.API.CreateMyToken(id)
+		if err != nil {
+			util.Bail(err)
+		}
+
+		util.ActiveProfile.Token = config.Token(token.Token)
+		util.Token = token.Token
+
+		util.ActiveProfile.JWT = conch.ConchJWT{}
+
+		util.WriteConfigForce()
+
+		if !util.JSON {
+			fmt.Printf("Created a token named '%s' and will now use it for this profile\n", id)
+
 		}
 	}
 }

+ 36 - 0
pkg/commands/user/init.go

@@ -72,6 +72,42 @@ func Init(app *cli.Cli) {
 					)
 				},
 			)
+
+			// The biggest use case for disabling these functions is security,
+			// particularly when it comes to edge automation. It's probably a
+			// bad idea for some automation on a random server to be able to
+			// create and remove tokens.
+			if !util.DisableApiTokenCRUD() {
+				cmd.Command(
+					"tokens",
+					"List API tokens",
+					listTokens,
+				)
+
+				cmd.Command(
+					"token",
+					"Operate on a single token",
+					func(cmd *cli.Cmd) {
+						cmd.Command(
+							"remove del rm",
+							"Remove an API token",
+							removeToken,
+						)
+
+						cmd.Command(
+							"create",
+							"Create an API token",
+							createToken,
+						)
+
+						cmd.Command(
+							"get",
+							"See information about a single API token",
+							getToken,
+						)
+					},
+				)
+			}
 		},
 	)
 }

+ 111 - 0
pkg/commands/user/user.go

@@ -146,3 +146,114 @@ func deleteSetting(app *cli.Cmd) {
 		}
 	}
 }
+
+func listTokens(app *cli.Cmd) {
+	app.Before = util.BuildAPIAndVerifyLogin
+
+	app.Action = func() {
+		tokens, err := util.API.GetMyTokens()
+		if err != nil {
+			util.Bail(err)
+		}
+		if util.JSON {
+			util.JSONOut(tokens)
+			return
+		}
+
+		sort.Sort(tokens)
+
+		table := util.GetMarkdownTable()
+		table.SetHeader([]string{"Name", "Created", "Last Used"})
+
+		for _, t := range tokens {
+			timeStr := ""
+			if !t.LastUsed.IsZero() {
+				timeStr = util.TimeStr(t.LastUsed)
+			}
+
+			table.Append([]string{
+				t.Name,
+				util.TimeStr(t.Created),
+				timeStr,
+			})
+		}
+
+		table.Render()
+	}
+}
+
+func createToken(app *cli.Cmd) {
+	app.Before = util.BuildAPIAndVerifyLogin
+
+	var nameArg = app.StringArg("NAME", "", "Name for the token")
+	app.Spec = "NAME"
+
+	app.Action = func() {
+		token, err := util.API.CreateMyToken(*nameArg)
+		if err != nil {
+			util.Bail(err)
+		}
+		if util.JSON {
+			util.JSONOut(token)
+			return
+		}
+
+		fmt.Println("***")
+		fmt.Println("*** This is the *only* time the token string will be shown.")
+		fmt.Println("***")
+		fmt.Println("*** Please make sure to record it as the string cannot be retrieved later ")
+		fmt.Println("***")
+		fmt.Println()
+		fmt.Printf("Name: %s\n", token.Name)
+		fmt.Printf("Token: %s    <--- Write this down\n", token.Token)
+		fmt.Println()
+	}
+}
+
+func removeToken(app *cli.Cmd) {
+	app.Before = util.BuildAPIAndVerifyLogin
+
+	var nameArg = app.StringArg("NAME", "", "Name for the token")
+	app.Spec = "NAME"
+
+	app.Action = func() {
+		err := util.API.DeleteMyToken(*nameArg)
+		if err != nil {
+			util.Bail(err)
+		}
+	}
+}
+
+func getToken(cmd *cli.Cmd) {
+	cmd.Before = util.BuildAPIAndVerifyLogin
+
+	var nameArg = cmd.StringArg("NAME", "", "Name for the token")
+	cmd.Spec = "NAME"
+
+	cmd.Action = func() {
+		token, err := util.API.GetMyToken(*nameArg)
+		if err != nil {
+			util.Bail(err)
+		}
+
+		if util.JSON {
+			util.JSONOut(token)
+			return
+		}
+
+		lastUsed := "[ Never Used ]"
+		if !token.LastUsed.IsZero() {
+			lastUsed = util.TimeStr(token.LastUsed)
+		}
+
+		fmt.Printf(`
+Name: %s
+Created: %s
+Last Used: %s
+`,
+			token.Name,
+			util.TimeStr(token.Created),
+			lastUsed,
+		)
+	}
+}

+ 51 - 23
pkg/conch/auth.go

@@ -17,9 +17,7 @@ import (
 	uuid "gopkg.in/satori/go.uuid.v1"
 )
 
-// RevokeUserTokens revokes all auth tokens for a the given user. This action
-// is typically limited server-side to admins.
-func (c *Conch) RevokeUserTokens(user string) error {
+func (c *Conch) RevokeUserTokensAndLogins(user string) error {
 	var uPart string
 	_, err := uuid.FromString(user)
 	if err == nil {
@@ -31,19 +29,59 @@ func (c *Conch) RevokeUserTokens(user string) error {
 	return c.post("/user/"+uPart+"/revoke", nil, nil)
 }
 
-// RevokeOwnTokens revokes all auth tokens for the current user. Login() is
-// required after to generate new tokens. Clears the JWT, and
-// Expires attributes
-func (c *Conch) RevokeOwnTokens() error {
-	if err := c.post("/user/me/revoke", nil, nil); err != nil {
-		return err
+func (c *Conch) RevokeUserLogins(user string) error {
+	var uPart string
+	_, err := uuid.FromString(user)
+	if err == nil {
+		uPart = user
+	} else {
+		uPart = "email=" + user
 	}
 
-	c.JWT = ConchJWT{}
-	return nil
+	return c.post("/user/"+uPart+"/revoke?auth_only=1", nil, nil)
+}
+
+func (c *Conch) RevokeUserTokens(user string) error {
+	var uPart string
+	_, err := uuid.FromString(user)
+	if err == nil {
+		uPart = user
+	} else {
+		uPart = "email=" + user
+	}
+
+	return c.post("/user/"+uPart+"/revoke?api_only=1", nil, nil)
+}
+
+func (c *Conch) GetUserToken(user string, name string) (u UserToken, err error) {
+	escapedName := url.PathEscape(name)
+	return u, c.get("/user/email="+user+"/token/"+escapedName, &u)
+}
+
+func (c *Conch) GetUserTokens(user string) (UserTokens, error) {
+	u := make(UserTokens, 0)
+	return u, c.get("/user/email="+user+"/token", &u)
 }
 
-// VerifyLogin determines if the user's session data is still valid.
+func (c *Conch) DeleteUserToken(user string, name string) error {
+	escapedName := url.PathEscape(name)
+	return c.httpDelete("/user/email=" + user + "/token/" + escapedName)
+}
+
+func (c *Conch) VerifyToken() (bool, error) {
+	if c.Token == "" {
+		return false, ErrBadInput
+	}
+
+	_, err := c.GetUserSettings()
+	if err != nil {
+		return false, err
+	}
+
+	return true, nil
+}
+
+// VerifyJwtLogin determines if the user's JWT auth data is still valid.
 //
 // One can pass in an integer value, representing when to force a token
 // refresh, based on the number of seconds left until expiry. Pass in 0 to
@@ -52,7 +90,7 @@ func (c *Conch) RevokeOwnTokens() error {
 // If the second parameter is true, a JWT refresh is forced, regardless of any
 // other parameters.
 //
-func (c *Conch) VerifyLogin(refreshTime int, forceJWT bool) error {
+func (c *Conch) VerifyJwtLogin(refreshTime int, forceJWT bool) error {
 	u, _ := url.Parse(c.BaseURL)
 
 	if !forceJWT {
@@ -200,13 +238,3 @@ func (c *Conch) ParseJWT(token string, signature string) (ConchJWT, error) {
 
 	return jwt, nil
 }
-
-// ChangePassword changes the password for the currently active profile
-func (c *Conch) ChangePassword(password string) error {
-	b := struct {
-		Password string `json:"password"`
-	}{password}
-
-	return c.post("/user/me/password", b, nil)
-
-}

+ 1 - 1
pkg/conch/conch.go

@@ -32,7 +32,7 @@ type omit bool
 
 const (
 	// MinimumAPIVersion sets the earliest API version that we support.
-	MinimumAPIVersion  = "2.26.0"
+	MinimumAPIVersion  = "2.27.0"
 	BreakingAPIVersion = "3.0.0"
 )
 

+ 6 - 4
pkg/conch/sling.go

@@ -86,10 +86,12 @@ func (c *Conch) sling() *sling.Sling {
 		Base(c.BaseURL).
 		Set("User-Agent", c.UA)
 
-	// BUG(sungo) This is mostly for the config back compat code. Once that's
-	// gone, we can probably just check the expires value
-	if (c.JWT.Token != "") && (c.JWT.Signature != "") {
-		s = s.Set("Authorization", "Bearer "+c.JWT.FullToken())
+	if c.Token != "" {
+		s = s.Set("Authorization", "Bearer "+c.Token)
+	} else {
+		if (c.JWT.Token != "") && (c.JWT.Signature != "") {
+			s = s.Set("Authorization", "Bearer "+c.JWT.FullToken())
+		}
 	}
 
 	return s

+ 34 - 0
pkg/conch/structs.go

@@ -31,6 +31,7 @@ type Conch struct {
 	Debug   bool
 	Trace   bool
 	JWT     ConchJWT
+	Token   string // replacement for JWT
 
 	HTTPClient *http.Client
 	CookieJar  *cookiejar.Jar
@@ -619,3 +620,36 @@ The payload looks like:
 Where '47' is the rack unit start for the device
 */
 type WorkspaceRackLayoutAssignments map[string]int
+
+// corresponds to conch.git/json-schema/input.yaml;NewUserToken
+type CreateNewUserToken struct {
+	Name string `json:"name"`
+}
+
+// corresponds to conch.git/json-schema/response.yaml;UserToken
+type UserToken struct {
+	Name     string    `json:"name"`
+	Created  time.Time `json:"created"`
+	LastUsed time.Time `json:"last_used,omitempty"`
+	Expires  time.Time `json:"expires"`
+}
+
+type UserTokens []UserToken
+
+func (u UserTokens) Len() int {
+	return len(u)
+}
+
+func (u UserTokens) Swap(i, j int) {
+	u[i], u[j] = u[j], u[i]
+}
+
+func (u UserTokens) Less(i, j int) bool {
+	return u[i].Name < u[j].Name
+}
+
+// corresponds to conch.git/json-schema/response.yaml;NewUserToken
+type NewUserToken struct {
+	UserToken
+	Token string `json:"token"`
+}

+ 79 - 2
pkg/conch/user.go

@@ -8,10 +8,73 @@ package conch
 
 import (
 	"fmt"
+	"net/url"
 
 	uuid "gopkg.in/satori/go.uuid.v1"
 )
 
+func (c *Conch) GetMyTokens() (UserTokens, error) {
+	u := make(UserTokens, 0)
+	return u, c.get("/user/me/token", &u)
+}
+
+func (c *Conch) GetMyToken(name string) (u UserToken, err error) {
+	escapedName := url.PathEscape(name)
+	return u, c.get("/user/me/token/"+escapedName, &u)
+}
+
+func (c *Conch) CreateMyToken(name string) (u NewUserToken, err error) {
+	return u, c.post(
+		"/user/me/token",
+		CreateNewUserToken{Name: name},
+		&u,
+	)
+}
+
+func (c *Conch) DeleteMyToken(name string) error {
+	escapedName := url.PathEscape(name)
+	return c.httpDelete("/user/me/token/" + escapedName)
+}
+
+func (c *Conch) RevokeMyLogins() error {
+	return c.post("/user/me/revoke?auth_only=1", nil, nil)
+}
+
+func (c *Conch) RevokeMyTokens() error {
+	return c.post("/user/me/revoke?api_only=1", nil, nil)
+}
+
+func (c *Conch) RevokeMyTokensAndLogins() error {
+	if err := c.post("/user/me/revoke", nil, nil); err != nil {
+		return err
+	}
+
+	c.JWT = ConchJWT{}
+	return nil
+}
+
+func (c *Conch) ChangeMyPassword(password string, revokeTokens bool) error {
+	b := struct {
+		Password string `json:"password"`
+	}{password}
+
+	url := "/user/me/password?"
+	if revokeTokens {
+		url = url + "clear_tokens=all"
+	} else {
+		// This is a bit opinionated of me. Changing your password will always
+		// clear your login tokens, but never your API tokens unless you ask.
+		//
+		// While the API allows one to not clear any tokens, I can't figure a
+		// use case where you'd want to change your password but let existing
+		// sessions to just keep working.
+		url = url + "clear_tokens=login_only"
+	}
+
+	return c.post(url, b, nil)
+
+}
+
 // GetUserSettings returns the results of /user/me/settings
 // The return is a map[string]interface{} because the database structure is a
 // string name and a jsonb data field.  There is no way for this library to
@@ -77,8 +140,22 @@ func (c *Conch) CreateUser(email string, password string, name string, isAdmin b
 
 // ResetUserPassword resets the password for the provided user, causing an
 // email to be sent
-func (c *Conch) ResetUserPassword(email string) error {
-	return c.httpDelete("/user/email=" + email + "/password")
+func (c *Conch) ResetUserPassword(email string, revokeTokens bool) error {
+	url := "/user/email=" + email + "/password?"
+	if revokeTokens {
+		url = url + "clear_tokens=all"
+	} else {
+		// This is a bit opinionated of me. Changing someone's password will
+		// always clear their login tokens, but never their API tokens unless you
+		// ask.
+		//
+		// While the API allows one to not clear any tokens, I can't figure a
+		// use case where you'd want to change someone's password but let
+		// existing sessions to just keep working.
+		url = url + "clear_tokens=login_only"
+	}
+
+	return c.httpDelete(url)
 }
 
 // GetAllUsers retrieves a list of all users, if the user has the right

+ 80 - 2
pkg/conch/user_test.go

@@ -78,9 +78,18 @@ func TestUserErrors(t *testing.T) {
 	})
 
 	t.Run("ResetUserPassword", func(t *testing.T) {
-		gock.New(API.BaseURL).Delete("/user/email=foo@bar.bat").Reply(400).JSON(ErrApi)
-		err := API.ResetUserPassword("foo@bar.bat")
+		gock.New(API.BaseURL).Delete("/user/email=foo@bar.bat").
+			MatchParam("clear_tokens", "login_only").Reply(400).JSON(ErrApi)
+
+		err := API.ResetUserPassword("foo@bar.bat", false)
+		st.Expect(t, err, ErrApiUnpacked)
+
+		gock.New(API.BaseURL).Delete("/user/email=foo@bar.bat").
+			MatchParam("clear_tokens", "all").Reply(400).JSON(ErrApi)
+
+		err = API.ResetUserPassword("foo@bar.bat", true)
 		st.Expect(t, err, ErrApiUnpacked)
+
 	})
 
 	t.Run("GetAllUsers", func(t *testing.T) {
@@ -98,4 +107,73 @@ func TestUserErrors(t *testing.T) {
 		st.Expect(t, err, ErrApiUnpacked)
 	})
 
+	t.Run("GetMyTokens", func(t *testing.T) {
+		gock.New(API.BaseURL).Get("/user/me/token").Reply(400).JSON(ErrApi)
+		tokens, err := API.GetMyTokens()
+		st.Expect(t, tokens, make(conch.UserTokens, 0))
+		st.Expect(t, err, ErrApiUnpacked)
+
+	})
+
+	t.Run("GetMyToken", func(t *testing.T) {
+		tokenName := "token_test"
+		gock.New(API.BaseURL).Get("/user/me/token/" + tokenName).Reply(400).JSON(ErrApi)
+
+		token, err := API.GetMyToken(tokenName)
+		st.Expect(t, token, conch.UserToken{})
+		st.Expect(t, err, ErrApiUnpacked)
+	})
+
+	t.Run("CreateMyToken", func(t *testing.T) {
+		tokenName := "token_test"
+		gock.New(API.BaseURL).Post("/user/me/token").Reply(400).JSON(ErrApi)
+
+		token, err := API.CreateMyToken(tokenName)
+		st.Expect(t, token, conch.NewUserToken{})
+		st.Expect(t, err, ErrApiUnpacked)
+	})
+
+	t.Run("DeleteMyToken", func(t *testing.T) {
+		tokenName := "token_test"
+		gock.New(API.BaseURL).Delete("/user/me/token/" + tokenName).Reply(400).JSON(ErrApi)
+
+		err := API.DeleteMyToken(tokenName)
+		st.Expect(t, err, ErrApiUnpacked)
+	})
+
+	t.Run("RevokeMyLogins", func(t *testing.T) {
+		gock.New(API.BaseURL).Post("/user/me/revoke").
+			MatchParam("auth_only", "1").Reply(400).JSON(ErrApi)
+
+		err := API.RevokeMyLogins()
+		st.Expect(t, err, ErrApiUnpacked)
+	})
+
+	t.Run("RevokeMyTokens", func(t *testing.T) {
+		gock.New(API.BaseURL).Post("/user/me/revoke").
+			MatchParam("api_only", "1").Reply(400).JSON(ErrApi)
+
+		err := API.RevokeMyTokens()
+		st.Expect(t, err, ErrApiUnpacked)
+	})
+
+	t.Run("RevokeMyTokensAndLogins", func(t *testing.T) {
+		gock.New(API.BaseURL).Post("/user/me/revoke").Reply(400).JSON(ErrApi)
+		err := API.RevokeMyTokensAndLogins()
+		st.Expect(t, err, ErrApiUnpacked)
+	})
+
+	t.Run("ChangeMyPassword", func(t *testing.T) {
+		gock.New(API.BaseURL).Post("/user/me/password").
+			MatchParam("clear_tokens", "login_only").Reply(400).JSON(ErrApi)
+		err := API.ChangeMyPassword("pants", false)
+		st.Expect(t, err, ErrApiUnpacked)
+
+		gock.New(API.BaseURL).Post("/user/me/password").
+			MatchParam("clear_tokens", "all").Reply(400).JSON(ErrApi)
+		err = API.ChangeMyPassword("pants", true)
+		st.Expect(t, err, ErrApiUnpacked)
+
+	})
+
 }

+ 100 - 41
pkg/config/main.go

@@ -12,14 +12,26 @@ import (
 	"encoding/base64"
 	"encoding/json"
 	"errors"
+	"fmt"
 	"io/ioutil"
 	"strings"
 	"time"
 
 	"github.com/joyent/conch-shell/pkg/conch"
+	"github.com/joyent/conch-shell/pkg/config/obfuscate"
 	uuid "gopkg.in/satori/go.uuid.v1"
 )
 
+const (
+	ProductionURL = "https://conch.joyent.us"
+	StagingURL    = "https://staging.conch.joyent.us"
+)
+
+var (
+	// For the love of Eris, override this default via the Makefile
+	ObfuscationKey = "shies9rohz1beigheyoish1viovohWachohw7aithee9apheez"
+)
+
 // ErrConfigNoPath is issued when a file operation is attempted on a
 // ConchConfig that lacks a path
 var ErrConfigNoPath = errors.New("no path found in config data")
@@ -27,9 +39,38 @@ var ErrConfigNoPath = errors.New("no path found in config data")
 // ConchConfig represents the configuration information for the shell, mostly
 // just a profile list
 type ConchConfig struct {
-	Path             string                   `json:"path"`
-	SkipVersionCheck bool                     `json:"skip_version_check"`
-	Profiles         map[string]*ConchProfile `json:"profiles"`
+	Path     string                   `json:"path"`
+	Profiles map[string]*ConchProfile `json:"profiles"`
+}
+
+// We're going to obfuscate the token itself. I'm aware this is krypto and not
+// even remotely secure. But it will prevent the tokens from being just c&p'd
+// out of the configs on a remote box.
+type Token string
+
+func (t Token) String() string {
+	return string(t)
+}
+
+func (t Token) MarshalJSON() ([]byte, error) {
+	if len(string(t)) == 0 {
+		return []byte("\"\""), nil
+	}
+	str, err := obfuscate.Obfuscate(string(t), ObfuscationKey)
+	return []byte(fmt.Sprintf("\"%s\"", str)), err
+}
+
+func (t *Token) UnmarshalJSON(b []byte) error {
+	if string(b) == "\"\"" {
+		*t = Token("")
+		return nil
+	}
+
+	str := strings.ReplaceAll(string(b), "\"", "")
+
+	token, _ := obfuscate.Deobfuscate(str, ObfuscationKey)
+	*t = Token(token)
+	return nil
 }
 
 // ConchProfile is an individual environment, consisting of login data, API
@@ -41,8 +82,9 @@ type ConchProfile struct {
 	WorkspaceName string         `json:"workspace_name"`
 	BaseURL       string         `json:"api_url"`
 	Active        bool           `json:"active"`
-	JWT           conch.ConchJWT `json:"jwt"`
-	Expires       time.Time      `json:"expires,omitempty"`
+	JWT           conch.ConchJWT `json:"jwt"`               // TODO(sungo): DEPRECATED
+	Expires       time.Time      `json:"expires,omitempty"` // TODO(sungo): DEPRECATED
+	Token         Token          `json:"token"`
 }
 
 // New provides an initialized struct with default values geared towards a
@@ -50,9 +92,8 @@ type ConchProfile struct {
 // "http://localhost:5001".
 func New() (c *ConchConfig) {
 	c = &ConchConfig{
-		Path:             "~/.conch.json",
-		Profiles:         make(map[string]*ConchProfile),
-		SkipVersionCheck: false,
+		Path:     "~/.conch.json",
+		Profiles: make(map[string]*ConchProfile),
 	}
 
 	return c
@@ -61,20 +102,18 @@ func New() (c *ConchConfig) {
 // NewFromJSON unmarshals a JSON blob into a ConchConfig struct
 func NewFromJSON(j string) (c *ConchConfig, err error) {
 
-	// BUG(sungo): This transition code is a mess but necessary for
-	// compatbility. Need to give it a release or two in production before
-	// removing this grossness.
+	// Keeping all these structures local because they're old, crufty, and
+	// there's no need to pollute the global space with this noise
 	type conchProfileTransition struct {
-		Name             string    `json:"name"`
-		User             string    `json:"user"`
-		Session          string    `json:"session,omitempty"`
-		WorkspaceUUID    uuid.UUID `json:"workspace_id"`
-		WorkspaceName    string    `json:"workspace_name"`
-		BaseURL          string    `json:"api_url"`
-		Active           bool      `json:"active"`
-		JWT              string    `json:"jwt"`
-		Expires          int64     `json:"expires,omitempty"`
-		SkipVersionCheck bool      `json:"skip_version_check"`
+		Name          string    `json:"name"`
+		User          string    `json:"user"`
+		Session       string    `json:"session,omitempty"`
+		WorkspaceUUID uuid.UUID `json:"workspace_id"`
+		WorkspaceName string    `json:"workspace_name"`
+		BaseURL       string    `json:"api_url"`
+		Active        bool      `json:"active"`
+		JWT           string    `json:"jwt"`
+		Expires       int64     `json:"expires,omitempty"`
 	}
 
 	type conchConfigTransition struct {
@@ -88,42 +127,62 @@ func NewFromJSON(j string) (c *ConchConfig, err error) {
 	}
 
 	c = New()
+
+	// First we see if the JSON parses as the old profile structure
 	err = json.Unmarshal([]byte(j), ct)
 
 	if err != nil {
+		// Well, that didn't work. Let's try again with the current structure
 		err = json.Unmarshal([]byte(j), c)
 		if err != nil {
+			// That didn't work either. Not much we can do except bail
 			return c, err
 		}
 
+		// Great. We have a current profile
 		if c.Profiles == nil {
+			// Except we don't have any profiles
 			c.Profiles = make(map[string]*ConchProfile)
 		}
 
-	} else {
-		for _, p := range ct.Profiles {
+		// If we have a token, zero out the old JWT structure because who cares
+		// about that if we have a token
+		for _, profile := range c.Profiles {
+			if string(profile.Token) != "" {
+				profile.JWT = conch.ConchJWT{}
+			}
+		}
+
+		return c, nil
+	}
 
-			jwt := conch.ConchJWT{}
+	// Oh joy. We have old data.
+	//
+	// This mostly just pulls apart the old JWT string into a fancy structure.
+	// This will also be totally replaced by the Token string which is... well,
+	// it's a JWT string. Circle of life, I guess. Or something.
+	for _, p := range ct.Profiles {
 
-			bits := strings.Split(p.JWT, ".")
-			if len(bits) == 3 {
-				token := bits[0] + "." + bits[1]
-				sig := bits[2]
-				jwt, _ = parseJWT(token, sig)
-			}
+		jwt := conch.ConchJWT{}
 
-			pNew := &ConchProfile{
-				Name:          p.Name,
-				User:          p.User,
-				WorkspaceUUID: p.WorkspaceUUID,
-				WorkspaceName: p.WorkspaceName,
-				BaseURL:       p.BaseURL,
-				Active:        p.Active,
-				JWT:           jwt,
-				Expires:       time.Unix(p.Expires, 0),
-			}
-			c.Profiles[pNew.Name] = pNew
+		bits := strings.Split(p.JWT, ".")
+		if len(bits) == 3 {
+			token := bits[0] + "." + bits[1]
+			sig := bits[2]
+			jwt, _ = parseJWT(token, sig)
+		}
+
+		pNew := &ConchProfile{
+			Name:          p.Name,
+			User:          p.User,
+			WorkspaceUUID: p.WorkspaceUUID,
+			WorkspaceName: p.WorkspaceName,
+			BaseURL:       p.BaseURL,
+			Active:        p.Active,
+			JWT:           jwt,
+			Expires:       time.Unix(p.Expires, 0),
 		}
+		c.Profiles[pNew.Name] = pNew
 	}
 
 	return c, nil

+ 74 - 0
pkg/config/obfuscate/main.go

@@ -0,0 +1,74 @@
+// Copyright Joyent, Inc.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+package obfuscate
+
+import (
+	"crypto/aes"
+	"crypto/cipher"
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/base64"
+	"errors"
+	"io"
+)
+
+func Obfuscate(data string, key string) (string, error) {
+
+	// We need a 32 byte key which sha256 is happy to give us
+	sum := sha256.Sum256([]byte(key))
+	c, err := aes.NewCipher(sum[:])
+
+	if err != nil {
+		return "", err
+	}
+
+	gcm, err := cipher.NewGCM(c)
+	if err != nil {
+		return "", err
+	}
+
+	nonce := make([]byte, gcm.NonceSize())
+
+	if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
+		return "", err
+	}
+
+	b := gcm.Seal(nonce, nonce, []byte(data), nil)
+	return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+func Deobfuscate(text string, key string) (string, error) {
+
+	data, err := base64.RawURLEncoding.DecodeString(text)
+	if err != nil {
+		return "", err
+	}
+
+	sum := sha256.Sum256([]byte(key))
+	c, err := aes.NewCipher(sum[:])
+	if err != nil {
+		return "", err
+	}
+
+	gcm, err := cipher.NewGCM(c)
+	if err != nil {
+		return "", err
+	}
+
+	nonceSize := gcm.NonceSize()
+	if len(data) < nonceSize {
+		return "", errors.New("ciphertext smaller than nonce")
+	}
+
+	nonce, ciphertext := data[:nonceSize], data[nonceSize:]
+
+	plain, err := gcm.Open(nil, nonce, ciphertext, nil)
+	if err != nil {
+		return "", err
+	}
+	return string(plain), nil
+}

+ 96 - 22
pkg/util/util.go

@@ -33,9 +33,15 @@ var (
 	// JSON tells us if we should output JSON
 	JSON bool
 
+	IgnoreConfig bool
+	Token        string
+	BaseURL      string
+
 	// Config is a global Config object
 	Config *config.ConchConfig
 
+	ConfigPath string
+
 	// ActiveProfile represents, well, the active profile
 	ActiveProfile *config.ConchProfile
 
@@ -53,20 +59,30 @@ var (
 
 // These variables are provided by the build environment
 var (
-	Version                string
-	GitRev                 string
-	DisableApiVersionCheck string
-
+	Version    string
+	GitRev     string
 	SemVersion semver.Version
+
+	FlagsDisableApiVersionCheck string // Used in shell development
+	FlagsDisableApiTokenCRUD    string // Useful for preventing automations from creating and deleting tokens
+	FlagsNoAdmin                string // Useful for preventing automations from accessing admin commands
+
 )
 
-var NoApiVersionCheck bool
+func DisableApiVersionCheck() bool {
+	return FlagsDisableApiVersionCheck != "0"
+}
+
+func DisableApiTokenCRUD() bool {
+	return FlagsDisableApiTokenCRUD != "0"
+}
+
+func NoAdmin() bool {
+	return FlagsNoAdmin != "0"
+}
 
 func init() {
 	SemVersion = CleanVersion(Version)
-	if DisableApiVersionCheck == "1" {
-		NoApiVersionCheck = true
-	}
 }
 
 // DateFormat should be used in date formatting calls to ensure uniformity of
@@ -86,15 +102,35 @@ func TimeStr(t time.Time) string {
 // VerifyLogin
 func BuildAPIAndVerifyLogin() {
 	BuildAPI()
-	if err := API.VerifyLogin(RefreshTokenTime, false); err != nil {
+
+	if Token != "" {
+		ok, err := API.VerifyToken()
+		if !ok {
+			Bail(err)
+		}
+		return
+	}
+
+	if err := API.VerifyJwtLogin(RefreshTokenTime, false); err != nil {
 		Bail(err)
 	}
+
 	ActiveProfile.JWT = API.JWT
 	WriteConfig()
 }
 
 // WriteConfig serializes the Config struct to disk
 func WriteConfig() {
+	if IgnoreConfig {
+		return
+	}
+
+	if err := Config.SerializeToFile(Config.Path); err != nil {
+		Bail(err)
+	}
+}
+
+func WriteConfigForce() {
 	if err := Config.SerializeToFile(Config.Path); err != nil {
 		Bail(err)
 	}
@@ -102,16 +138,28 @@ func WriteConfig() {
 
 // BuildAPI builds a Conch object
 func BuildAPI() {
-	if ActiveProfile == nil {
-		Bail(errors.New("no active profile. Please use 'conch profile' to create or set an active profile"))
-	}
+	if IgnoreConfig {
+		API = &conch.Conch{
+			BaseURL: BaseURL,
+			Debug:   Debug,
+			Trace:   Trace,
+			Token:   Token,
+		}
 
-	API = &conch.Conch{
-		BaseURL: ActiveProfile.BaseURL,
-		JWT:     ActiveProfile.JWT,
-		Debug:   Debug,
-		Trace:   Trace,
+	} else {
+		if ActiveProfile == nil {
+			Bail(errors.New("no active profile. Please use 'conch profile' to create or set an active profile"))
+		}
+
+		API = &conch.Conch{
+			BaseURL: ActiveProfile.BaseURL,
+			JWT:     ActiveProfile.JWT,
+			Token:   string(ActiveProfile.Token),
+			Debug:   Debug,
+			Trace:   Trace,
+		}
 	}
+
 	if UserAgent != "" {
 		API.UA = UserAgent
 	}
@@ -121,7 +169,7 @@ func BuildAPI() {
 		Bail(err)
 	}
 
-	if NoApiVersionCheck {
+	if DisableApiVersionCheck() {
 		return
 	}
 
@@ -166,10 +214,14 @@ func Bail(err error) {
 
 	switch err {
 	case conch.ErrBadInput:
-		msg = err.Error() + " -- Internal Error. Please file a GHI"
+		msg = err.Error() + " -- Internal Error. Please run with --debug and file a Github Issue"
 
 	case conch.ErrNotAuthorized:
-		msg = err.Error() + " -- Running 'profile relogin' might resolve this"
+		if len(Token) > 0 {
+			msg = err.Error() + " -- The API token might be incorrect or revoked"
+		} else {
+			msg = err.Error() + " -- Running 'profile relogin' might resolve this"
+		}
 
 	case conch.ErrMalformedJWT:
 		msg = "The server sent a malformed auth token. Please contact the Conch team"
@@ -387,7 +439,7 @@ func InteractiveForcePasswordChange() {
 		}
 
 	}
-	if err := API.ChangePassword(password); err != nil {
+	if err := API.ChangeMyPassword(password, false); err != nil {
 		Bail(err)
 	}
 
@@ -397,7 +449,7 @@ func InteractiveForcePasswordChange() {
 
 	ActiveProfile.JWT = API.JWT
 
-	WriteConfig()
+	WriteConfigForce()
 }
 
 // DDP pretty prints a structure to stderr. "Deep Data Printer"
@@ -408,6 +460,28 @@ func DDP(v interface{}) {
 	)
 }
 
+func GithubReleaseCheck() {
+	gh, err := LatestGithubRelease()
+	if (err != nil) && (err != ErrNoGithubRelease) {
+		Bail(err)
+	}
+	if gh.Upgrade {
+		os.Stderr.WriteString(fmt.Sprintf(`
+A new release is available! You have v%s but %s is available.
+The changelog can be viewed via 'conch update changelog'
+
+You can obtain the new release by:
+* Running 'conch update self', which will attempt to overwrite the current application
+* Manually download the new release at %s
+
+`,
+			Version,
+			gh.TagName,
+			gh.URL,
+		))
+	}
+}
+
 func init() {
 	spew.Config = spew.ConfigState{
 		Indent:                  "    ",