瀏覽代碼

support token-based login, primarily for github from the cli

Alexander Belanger 5 年之前
父節點
當前提交
95675acbc4

+ 87 - 30
cli/cmd/auth.go

@@ -8,6 +8,7 @@ import (
 	"github.com/fatih/color"
 
 	"github.com/porter-dev/porter/cli/cmd/api"
+	loginBrowser "github.com/porter-dev/porter/cli/cmd/login"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/spf13/cobra"
 )
@@ -56,6 +57,7 @@ var logoutCmd = &cobra.Command{
 }
 
 var token string = ""
+var manual bool = false
 
 func init() {
 	rootCmd.AddCommand(authCmd)
@@ -77,38 +79,102 @@ func init() {
 		"",
 		"bearer token for authentication",
 	)
+
+	loginCmd.PersistentFlags().BoolVar(
+		&manual,
+		"manual",
+		false,
+		"whether to prompt for manual authentication (username/pw)",
+	)
 }
 
 func login() error {
-	var client *api.Client
+	client := api.NewClientWithToken(getHost()+"/api", getToken())
+
+	user, _ := client.AuthCheck(context.Background())
+
+	if user != nil {
+		color.Yellow("You are already logged in. If you'd like to log out, run \"porter auth logout\".")
+		return nil
+	}
+
+	// check for the --manual flag
+	if manual {
+		return loginManual()
+	}
+
+	// check for a token
+	var err error
+
+	if token == "" {
+		token, err = loginBrowser.Login(getHost())
+
+		if err != nil {
+			return err
+		}
 
-	if token != "" {
 		// set the token in config
-		err := setToken(token)
+		err = setToken(token)
 
 		if err != nil {
 			return err
 		}
 
-		client = api.NewClientWithToken(getHost()+"/api", token)
+		client := api.NewClientWithToken(getHost()+"/api", token)
 
-		user, _ := client.AuthCheck(context.Background())
+		user, err := client.AuthCheck(context.Background())
 
 		if user == nil {
 			color.Red("Invalid token.")
-			return nil
+			return err
+		}
+
+		color.New(color.FgGreen).Println("Successfully logged in!")
+
+		// get a list of projects, and set the current project
+		projects, err := client.ListUserProjects(context.Background(), user.ID)
+
+		if err != nil {
+			return err
+		}
+
+		if len(projects) > 0 {
+			setProject(projects[0].ID)
 		}
 	} else {
-		client = api.NewClient(getHost()+"/api", "cookie.json")
-	}
+		// set the token in config
+		err = setToken(token)
 
-	user, _ := client.AuthCheck(context.Background())
+		if err != nil {
+			return err
+		}
 
-	if user != nil {
-		color.Yellow("You are already logged in. If you'd like to log out, run \"porter auth logout\".")
-		return nil
+		client := api.NewClientWithToken(getHost()+"/api", token)
+
+		user, err := client.AuthCheck(context.Background())
+
+		if user == nil {
+			color.Red("Invalid token.")
+			return err
+		}
+
+		color.New(color.FgGreen).Println("Successfully logged in!")
+
+		projID, err := api.GetProjectIDFromToken(token)
+
+		if err != nil {
+			return err
+		}
+
+		setProject(projID)
 	}
 
+	return nil
+}
+
+func loginManual() error {
+	client := api.NewClient(getHost()+"/api", "cookie.json")
+
 	var username, pw string
 
 	fmt.Println("Please log in with an email and password:")
@@ -136,26 +202,15 @@ func login() error {
 
 	color.New(color.FgGreen).Println("Successfully logged in!")
 
-	// if the login was token-based, decode the claims to get the token
-	if token != "" {
-		projID, err := api.GetProjectIDFromToken(token)
-
-		if err != nil {
-			return err
-		}
+	// get a list of projects, and set the current project
+	projects, err := client.ListUserProjects(context.Background(), _user.ID)
 
-		setProject(projID)
-	} else {
-		// get a list of projects, and set the current project
-		projects, err := client.ListUserProjects(context.Background(), _user.ID)
-
-		if err != nil {
-			return err
-		}
+	if err != nil {
+		return err
+	}
 
-		if len(projects) > 0 {
-			setProject(projects[0].ID)
-		}
+	if len(projects) > 0 {
+		setProject(projects[0].ID)
 	}
 
 	return nil
@@ -199,6 +254,8 @@ func logout(user *api.AuthCheckResponse, client *api.Client, args []string) erro
 		return err
 	}
 
+	setToken("")
+
 	color.Green("Successfully logged out")
 
 	return nil

+ 309 - 0
cli/cmd/login/server.go

@@ -0,0 +1,309 @@
+package login
+
+import (
+	"encoding/json"
+	"fmt"
+	"net"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/porter-dev/porter/cli/cmd/utils"
+)
+
+func redirect(
+	codechan chan string,
+) func(http.ResponseWriter, *http.Request) {
+	return func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "text/html; charset=utf-8")
+		fmt.Fprint(w, successScreen)
+
+		queryParams, _ := url.ParseQuery(r.URL.RawQuery)
+
+		codechan <- queryParams["code"][0]
+	}
+}
+
+func Login(
+	host string,
+) (string, error) {
+	listener, err := net.Listen("tcp", ":0")
+
+	if err != nil {
+		panic(err)
+	}
+
+	port := listener.Addr().(*net.TCPAddr).Port
+
+	errorchan := make(chan error)
+	codechan := make(chan string)
+
+	go func() {
+		http.HandleFunc("/", redirect(
+			codechan,
+		))
+
+		err := http.Serve(listener, nil)
+		errorchan <- err
+	}()
+
+	// open browser for host login
+	redirectHost := fmt.Sprintf("http://localhost:%d", port)
+	loginURL := fmt.Sprintf("%s/api/cli/login?redirect=%s", host, url.QueryEscape(redirectHost))
+
+	err = utils.OpenBrowser(loginURL)
+
+	if err != nil {
+		return "", fmt.Errorf("Could not open browser: %v", err)
+	}
+
+	for {
+		select {
+		case err = <-errorchan:
+			return "", err
+		case code := <-codechan:
+			return ExchangeToken(host, code)
+		}
+	}
+}
+
+type ExchangeResponse struct {
+	Token string `json:"token"`
+}
+
+func ExchangeToken(host, code string) (string, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/api/cli/login/exchange", host),
+		strings.NewReader(fmt.Sprintf(`{"authorization_code": "%s"}`, code)),
+	)
+
+	if err != nil {
+		return "", err
+	}
+
+	// create a request with the authorization code
+	req.Header.Set("Content-Type", "application/json; charset=utf-8")
+	req.Header.Set("Accept", "application/json; charset=utf-8")
+
+	client := &http.Client{
+		Timeout: time.Minute,
+	}
+
+	res, err := client.Do(req)
+
+	if err != nil {
+		return "", err
+	}
+
+	defer res.Body.Close()
+
+	resp := &ExchangeResponse{}
+
+	if err = json.NewDecoder(res.Body).Decode(resp); err != nil {
+		return "", err
+	}
+
+	return resp.Token, nil
+}
+
+const successScreen = `
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset='UTF-8'>
+    <title>Porter | Login</title>
+    <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+    <link href="https://fonts.googleapis.com/css?family=Assistant:400,700|Noto+Sans:400,600,700|Work+Sans:400,500,600|Source+Sans+Pro:400,600,700|Hind+Siliguri:500|Cabin:400,600" rel="stylesheet">
+    <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+    <link href="//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.9.0/katex.min.css" rel="stylesheet" />
+    <link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@600&display=swap" rel="stylesheet">
+    <style>
+      #logo {
+        width: 80px;
+        margin-top: -30px;
+        margin-bottom: 40px;
+      }
+
+      #success {
+        font-family: 'Open Sans', sans-serif;
+        font-size: 18px;
+        color: #CBCBD8;
+        margin-bottom: 17px;
+      }
+
+      #subtitle {
+        font-family: 'Open Sans', sans-serif;
+        font-size: 14px;
+        color: #CBCBD8;
+      }
+
+      body{
+        margin: 0;
+        padding: 0;
+        width: 100%;
+        height: 100vh;
+        background: #f1f3f5;
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        justify-content: center;
+      }
+
+      #text {
+        display: flex;
+        height: 100vh;
+        align-items: center;
+        justify-content: center;
+        text-align: center;
+      }
+
+      h2{
+        color: #fff;
+        font-size: 47px;
+        line-height: 40px;
+      }
+
+      #container {
+        left: 0px;
+        top: -100px;
+        height: calc(100vh + 100px);
+        overflow: hidden;
+        position: relative;
+      }
+
+      #animate{
+        margin: 0 auto;
+        width: 20px;
+        overflow: visible;
+        position: relative;
+      }
+
+      #all{
+        overflow: hidden;
+        height: 100vh;
+        width: 100%;
+        position: fixed;
+      }
+
+      #footer{
+        color: #808080;
+        text-decoration: none;
+        position: fixed;
+        width: 752px;
+        bottom: 20px;
+        align-content: center;
+        float: none;
+        margin-left: calc(50% - 376px);
+      }
+
+      a, p{
+        text-decoration: none;
+        color: #808080;
+        letter-spacing: 6px;
+        transition: all 0.5s ease-in-out;
+        width: auto;
+        float: left;
+        margin: 0;
+        margin-right: 9px;
+      }
+
+      a:hover{
+        color: #fff;
+        letter-spacing: 2px;
+        transition: all 0.5s ease-in-out;
+      }
+    </style>
+  </head>
+  <body>
+    <link href="https://fonts.googleapis.com/css?family=Oswald:600,700" rel="stylesheet"> 
+    <div id="all">
+    <div id="container">
+      <div id="animate">
+      </div>
+    </div>
+    </div>
+    <noscript>You need to enable JavaScript to run this app.</noscript>
+    <img id='logo' src='https://i.ibb.co/y64zfm5/porter.png'>
+    <div id='success'>Authentication successful!</div>
+    <div id='subtitle'>You can now close this window.</div>
+    <script>
+/*      setTimeout(function () {
+        window.close();
+      }, 1000)
+      */
+
+      var container = document.getElementById('animate');
+      var emoji = ['🎉'];
+      var circles = [];
+
+      for (var i = 0; i < 15; i++) {
+        addCircle(i * 550, [10 + 0, 300], emoji[Math.floor(Math.random() * emoji.length)]);
+        addCircle(i * 550, [10 + 0, -300], emoji[Math.floor(Math.random() * emoji.length)]);
+        addCircle(i * 550, [10 - 200, -300], emoji[Math.floor(Math.random() * emoji.length)]);
+        addCircle(i * 550, [10 + 200, 300], emoji[Math.floor(Math.random() * emoji.length)]);
+        addCircle(i * 550, [10 - 400, -300], emoji[Math.floor(Math.random() * emoji.length)]);
+        addCircle(i * 550, [10 + 400, 300], emoji[Math.floor(Math.random() * emoji.length)]);
+        addCircle(i * 550, [10 - 600, -300], emoji[Math.floor(Math.random() * emoji.length)]);
+        addCircle(i * 550, [10 + 600, 300], emoji[Math.floor(Math.random() * emoji.length)]);
+      }
+
+
+
+      function addCircle(delay, range, color) {
+        setTimeout(function() {
+          var c = new Circle(range[0] + Math.random() * range[1], 80 + Math.random() * 4, color, {
+            x: -0.15 + Math.random() * 0.3,
+            y: 1 + Math.random() * 1
+          }, range);
+          circles.push(c);
+        }, delay);
+      }
+
+      function Circle(x, y, c, v, range) {
+        var _this = this;
+        this.x = x;
+        this.y = y;
+        this.color = c;
+        this.v = v;
+        this.range = range;
+        this.element = document.createElement('span');
+        /*this.element.style.display = 'block';*/
+        this.element.style.opacity = 0;
+        this.element.style.position = 'absolute';
+        this.element.style.fontSize = '26px';
+        this.element.style.color = 'hsl('+(Math.random()*360|0)+',80%,50%)';
+        this.element.innerHTML = c;
+        container.appendChild(this.element);
+
+        this.update = function() {
+          if (_this.y > 800) {
+            _this.y = 80 + Math.random() * 4;
+            _this.x = _this.range[0] + Math.random() * _this.range[1];
+          }
+          _this.y += _this.v.y;
+          _this.x += _this.v.x;
+          this.element.style.opacity = 1;
+          this.element.style.transform = 'translate3d(' + _this.x + 'px, ' + _this.y + 'px, 0px)';
+          this.element.style.webkitTransform = 'translate3d(' + _this.x + 'px, ' + _this.y + 'px, 0px)';
+          this.element.style.mozTransform = 'translate3d(' + _this.x + 'px, ' + _this.y + 'px, 0px)';
+        };
+      }
+
+      function animate() {
+        for (var i in circles) {
+          circles[i].update();
+        }
+        requestAnimationFrame(animate);
+      }
+      
+      if (Math.random() < 0.001) {
+        animate();
+      }
+
+      
+    </script>
+  </body>
+</html>
+`

+ 1 - 0
cmd/app/main.go

@@ -59,6 +59,7 @@ func main() {
 		&models.Infra{},
 		&models.GitActionConfig{},
 		&models.Invite{},
+		&models.AuthCode{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 1 - 0
cmd/migrate/main.go

@@ -44,6 +44,7 @@ func main() {
 		&models.Infra{},
 		&models.GitActionConfig{},
 		&models.Invite{},
+		&models.AuthCode{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 66 - 11
dashboard/package-lock.json

@@ -10,6 +10,8 @@
       "dependencies": {
         "@fullstory/browser": "^1.4.5",
         "@material-ui/core": "^4.11.3",
+        "@types/d3-array": "^2.9.0",
+        "@types/d3-time-format": "^3.0.0",
         "@types/js-yaml": "^3.12.5",
         "@types/lodash": "^4.14.165",
         "@types/markdown-to-jsx": "^6.11.3",
@@ -20,11 +22,15 @@
         "@visx/event": "^1.3.0",
         "@visx/gradient": "^1.0.0",
         "@visx/grid": "^1.4.0",
+        "@visx/mock-data": "^1.0.0",
+        "@visx/responsive": "^1.3.0",
         "@visx/scale": "^1.4.0",
         "@visx/shape": "^1.4.0",
         "@visx/tooltip": "^1.3.0",
         "ace-builds": "^1.4.12",
         "axios": "^0.20.0",
+        "d3-array": "^2.11.0",
+        "d3-time-format": "^3.0.0",
         "dotenv": "^8.2.0",
         "ini": ">=1.3.6",
         "js-base64": "^3.6.0",
@@ -548,6 +554,11 @@
       "resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.11.tgz",
       "integrity": "sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw=="
     },
+    "node_modules/@types/d3-array": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-2.9.0.tgz",
+      "integrity": "sha512-sdBMGfNvLUkBypPMEhOcKcblTQfgHbqbYrUqRE31jOwdDHBJBxz4co2MDAq93S4Cp++phk4UiwoEg/1hK3xXAQ=="
+    },
     "node_modules/@types/d3-color": {
       "version": "1.4.1",
       "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-1.4.1.tgz",
@@ -566,6 +577,11 @@
       "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz",
       "integrity": "sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ=="
     },
+    "node_modules/@types/d3-random": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-2.2.0.tgz",
+      "integrity": "sha512-Hjfj9m68NmYZzushzEG7etPvKH/nj9b9s9+qtkNG3/dbRBjQZQg1XS6nRuHJcCASTjxXlyXZnKu2gDxyQIIu9A=="
+    },
     "node_modules/@types/d3-scale": {
       "version": "3.2.2",
       "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.2.2.tgz",
@@ -587,6 +603,11 @@
       "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-1.1.1.tgz",
       "integrity": "sha512-ULX7LoqXTCYtM+tLYOaeAJK7IwCT+4Gxlm2MaH0ErKLi07R5lh8NHCAyWcDkCCmx1AfRcBEV6H9QE9R25uP7jw=="
     },
+    "node_modules/@types/d3-time-format": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-3.0.0.tgz",
+      "integrity": "sha512-UpLg1mn/8PLyjr+J/JwdQJM/GzysMvv2CS8y+WYAL5K0+wbvXv/pPSLEfdNaprCZsGcXTxPsFMy8QtkYv9ueew=="
+    },
     "node_modules/@types/glob": {
       "version": "7.1.3",
       "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.3.tgz",
@@ -1085,11 +1106,35 @@
         "prop-types": "^15.6.2"
       }
     },
+    "node_modules/@visx/mock-data": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/@visx/mock-data/-/mock-data-1.5.0.tgz",
+      "integrity": "sha512-J30eoqXUzA1uM6bdgB4q8fM59dG/UthiN2tvnvWyxxlPNbuYJmRAu0wBsUYYsUZtzgSAjwSImfHeyfb4tXot0g==",
+      "dependencies": {
+        "@types/d3-random": "^2.2.0",
+        "d3-random": "^2.2.2"
+      }
+    },
     "node_modules/@visx/point": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/@visx/point/-/point-1.0.0.tgz",
       "integrity": "sha512-0L3ILwv6ro0DsQVbA1lo8fo6q3wvIeSTt9C8NarUUkoTNSFZaJtlmvwg2238r8fwwmSv0v9QFBj1hBz4o0bHrg=="
     },
+    "node_modules/@visx/responsive": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-1.3.0.tgz",
+      "integrity": "sha512-RMfpjdnHKyhB/bb2i6x/vfQeYzfz+pJc3VUK+dP88lXXTkqv1O/NYIkXk2sWk6QhDw5muChHFmnZ1L8TnIOMXg==",
+      "dependencies": {
+        "@types/lodash": "^4.14.146",
+        "@types/react": "*",
+        "lodash": "^4.17.10",
+        "prop-types": "^15.6.1",
+        "resize-observer-polyfill": "1.5.1"
+      },
+      "peerDependencies": {
+        "react": "^15.0.0-0 || ^16.0.0-0"
+      }
+    },
     "node_modules/@visx/scale": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-1.4.0.tgz",
@@ -2721,6 +2766,11 @@
       "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
       "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="
     },
+    "node_modules/d3-random": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-2.2.2.tgz",
+      "integrity": "sha512-0D9P8TRj6qDAtHhRQn6EfdOtHMfsUWanl3yb/84C4DqpZ+VsgfI5iTVRNRbELCfNvRfpMr8OrqqUTQ6ANGCijw=="
+    },
     "node_modules/d3-scale": {
       "version": "3.2.3",
       "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.2.3.tgz",
@@ -7210,6 +7260,11 @@
       "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
       "dev": true
     },
+    "node_modules/resize-observer-polyfill": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+      "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
+    },
     "node_modules/resolve-cwd": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-2.0.0.tgz",
@@ -10654,9 +10709,9 @@
       "integrity": "sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ=="
     },
     "@types/d3-random": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-1.1.3.tgz",
-      "integrity": "sha512-XXR+ZbFCoOd4peXSMYJzwk0/elP37WWAzS/DG+90eilzVbUSsgKhBcWqylGWe+lA2ubgr7afWAOBaBxRgMUrBQ=="
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-2.2.0.tgz",
+      "integrity": "sha512-Hjfj9m68NmYZzushzEG7etPvKH/nj9b9s9+qtkNG3/dbRBjQZQg1XS6nRuHJcCASTjxXlyXZnKu2gDxyQIIu9A=="
     },
     "@types/d3-scale": {
       "version": "3.2.2",
@@ -11160,12 +11215,12 @@
       }
     },
     "@visx/mock-data": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/@visx/mock-data/-/mock-data-1.0.0.tgz",
-      "integrity": "sha512-yd4lult1oEpmbj7pxzNb398VW+fRYaZWBFFKcqJEibAxlko4kmBfVqFR2gPZTp/K7I0/5mvD3hhNr1NpH2SIHA==",
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/@visx/mock-data/-/mock-data-1.5.0.tgz",
+      "integrity": "sha512-J30eoqXUzA1uM6bdgB4q8fM59dG/UthiN2tvnvWyxxlPNbuYJmRAu0wBsUYYsUZtzgSAjwSImfHeyfb4tXot0g==",
       "requires": {
-        "@types/d3-random": "^1.1.2",
-        "d3-random": "^1.0.3"
+        "@types/d3-random": "^2.2.0",
+        "d3-random": "^2.2.2"
       }
     },
     "@visx/point": {
@@ -12660,9 +12715,9 @@
       "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="
     },
     "d3-random": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-1.1.2.tgz",
-      "integrity": "sha512-6AK5BNpIFqP+cx/sreKzNjWbwZQCSUatxq+pPRmFIQaWuoD+NrbVWw7YWpHiXpCQ/NanKdtGDuB+VQcZDaEmYQ=="
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-2.2.2.tgz",
+      "integrity": "sha512-0D9P8TRj6qDAtHhRQn6EfdOtHMfsUWanl3yb/84C4DqpZ+VsgfI5iTVRNRbELCfNvRfpMr8OrqqUTQ6ANGCijw=="
     },
     "d3-scale": {
       "version": "3.2.3",

+ 1 - 0
dashboard/src/App.tsx

@@ -7,6 +7,7 @@ type PropsType = {};
 
 type StateType = {};
 
+
 export default class App extends Component<PropsType, StateType> {
   render() {
     return (

+ 6 - 7
internal/auth/token/token.go

@@ -27,19 +27,18 @@ type Token struct {
 	IAt       *time.Time `json:"iat"`
 }
 
-func GetTokenForUser(userID, projID uint) (*Token, error) {
-	if userID == 0 || projID == 0 {
+func GetTokenForUser(userID uint) (*Token, error) {
+	if userID == 0 {
 		return nil, fmt.Errorf("id cannot be 0")
 	}
 
 	iat := time.Now()
 
 	return &Token{
-		SubKind:   User,
-		Sub:       fmt.Sprintf("%d", userID),
-		ProjectID: projID,
-		IBy:       userID,
-		IAt:       &iat,
+		SubKind: User,
+		Sub:     fmt.Sprintf("%d", userID),
+		IBy:     userID,
+		IAt:     &iat,
 	}, nil
 }
 

+ 1 - 1
internal/auth/token/token_test.go

@@ -13,7 +13,7 @@ func TestGetAndEncodeTokenForUser(t *testing.T) {
 		TokenSecret: "fakesecret",
 	}
 
-	tok, err := token.GetTokenForUser(1, 1)
+	tok, err := token.GetTokenForUser(1)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)

+ 21 - 0
internal/models/auth_code.go

@@ -0,0 +1,21 @@
+package models
+
+import (
+	"time"
+
+	"gorm.io/gorm"
+)
+
+// AuthCode type that extends gorm.Model
+type AuthCode struct {
+	gorm.Model
+
+	Token             string `gorm:"unique"`
+	AuthorizationCode string `gorm:"unique"`
+	Expiry            *time.Time
+}
+
+func (a *AuthCode) IsExpired() bool {
+	timeLeft := a.Expiry.Sub(time.Now())
+	return timeLeft < 0
+}

+ 11 - 0
internal/repository/auth_code.go

@@ -0,0 +1,11 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// AuthCodeRepository represents the set of queries on the AuthCode model
+type AuthCodeRepository interface {
+	CreateAuthCode(a *models.AuthCode) (*models.AuthCode, error)
+	ReadAuthCode(code string) (*models.AuthCode, error)
+}

+ 37 - 0
internal/repository/gorm/auth_code.go

@@ -0,0 +1,37 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// AuthCodeRepository uses gorm.DB for querying the database
+type AuthCodeRepository struct {
+	db *gorm.DB
+}
+
+// NewAuthCodeRepository returns a AuthCodeRepository which uses
+// gorm.DB for querying the database
+func NewAuthCodeRepository(db *gorm.DB) repository.AuthCodeRepository {
+	return &AuthCodeRepository{db}
+}
+
+// CreateAuthCode creates a new auth code
+func (repo *AuthCodeRepository) CreateAuthCode(a *models.AuthCode) (*models.AuthCode, error) {
+	if err := repo.db.Create(a).Error; err != nil {
+		return nil, err
+	}
+	return a, nil
+}
+
+// ReadAuthCode gets an invite specified by a unique token
+func (repo *AuthCodeRepository) ReadAuthCode(code string) (*models.AuthCode, error) {
+	a := &models.AuthCode{}
+
+	if err := repo.db.Where("authorization_code = ?", code).First(&a).Error; err != nil {
+		return nil, err
+	}
+
+	return a, nil
+}

+ 1 - 0
internal/repository/gorm/repository.go

@@ -20,6 +20,7 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		Infra:            NewInfraRepository(db, key),
 		GitActionConfig:  NewGitActionConfigRepository(db),
 		Invite:           NewInviteRepository(db),
+		AuthCode:         NewAuthCodeRepository(db),
 		KubeIntegration:  NewKubeIntegrationRepository(db, key),
 		BasicIntegration: NewBasicIntegrationRepository(db, key),
 		OIDCIntegration:  NewOIDCIntegrationRepository(db, key),

+ 54 - 0
internal/repository/memory/auth_code.go

@@ -0,0 +1,54 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// AuthCodeRepository uses gorm.DB for querying the database
+type AuthCodeRepository struct {
+	canQuery  bool
+	authCodes []*models.AuthCode
+}
+
+// NewAuthCodeRepository returns a AuthCodeRepository which uses
+// gorm.DB for querying the database
+func NewAuthCodeRepository(canQuery bool) repository.AuthCodeRepository {
+	return &AuthCodeRepository{canQuery, []*models.AuthCode{}}
+}
+
+// CreateAuthCode creates a new invite
+func (repo *AuthCodeRepository) CreateAuthCode(a *models.AuthCode) (*models.AuthCode, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.authCodes = append(repo.authCodes, a)
+	a.ID = uint(len(repo.authCodes))
+
+	return a, nil
+}
+
+// ReadAuthCode gets an auth code object specified by the unique code
+func (repo *AuthCodeRepository) ReadAuthCode(code string) (*models.AuthCode, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	var res *models.AuthCode
+
+	for _, a := range repo.authCodes {
+		if code == a.AuthorizationCode {
+			res = a
+		}
+	}
+
+	if res == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	return res, nil
+}

+ 1 - 0
internal/repository/memory/repository.go

@@ -16,6 +16,7 @@ func NewRepository(canQuery bool) *repository.Repository {
 		Registry:         NewRegistryRepository(canQuery),
 		GitRepo:          NewGitRepoRepository(canQuery),
 		Invite:           NewInviteRepository(canQuery),
+		AuthCode:         NewAuthCodeRepository(canQuery),
 		KubeIntegration:  NewKubeIntegrationRepository(canQuery),
 		BasicIntegration: NewBasicIntegrationRepository(canQuery),
 		OIDCIntegration:  NewOIDCIntegrationRepository(canQuery),

+ 1 - 0
internal/repository/repository.go

@@ -13,6 +13,7 @@ type Repository struct {
 	Infra            InfraRepository
 	GitActionConfig  GitActionConfigRepository
 	Invite           InviteRepository
+	AuthCode         AuthCodeRepository
 	KubeIntegration  KubeIntegrationRepository
 	BasicIntegration BasicIntegrationRepository
 	OIDCIntegration  OIDCIntegrationRepository

+ 102 - 0
server/api/user_handler.go

@@ -3,15 +3,20 @@ package api
 import (
 	"encoding/json"
 	"errors"
+	"fmt"
+	"math/rand"
 	"net/http"
+	"net/url"
 	"strconv"
 	"strings"
+	"time"
 
 	"golang.org/x/crypto/bcrypt"
 
 	"gorm.io/gorm"
 
 	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
@@ -105,6 +110,103 @@ func (app *App) HandleAuthCheck(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// HandleCLILoginUser verifies that a user is logged in, and generates an access
+// token for usage from the CLI
+func (app *App) HandleCLILoginUser(w http.ResponseWriter, r *http.Request) {
+	queryParams, _ := url.ParseQuery(r.URL.RawQuery)
+
+	redirect := queryParams["redirect"][0]
+
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+
+	// generate the token
+	jwt, err := token.GetTokenForUser(userID)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	encoded, err := jwt.EncodeToken(&token.TokenGeneratorConf{
+		TokenSecret: app.ServerConf.TokenGeneratorSecret,
+	})
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	// generate 64 characters long authorization code
+	const letters = "abcdefghijklmnopqrstuvwxyz123456789"
+	code := make([]byte, 64)
+
+	for i := range code {
+		code[i] = letters[rand.Intn(len(letters))]
+	}
+
+	expiry := time.Now().Add(30 * time.Second)
+
+	// create auth code object and send back authorization code
+	authCode := &models.AuthCode{
+		Token:             encoded,
+		AuthorizationCode: string(code),
+		Expiry:            &expiry,
+	}
+
+	authCode, err = app.Repo.AuthCode.CreateAuthCode(authCode)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	http.Redirect(w, r, fmt.Sprintf("%s/?code=%s", redirect, url.QueryEscape(authCode.AuthorizationCode)), 302)
+}
+
+type ExchangeRequest struct {
+	AuthorizationCode string `json:"authorization_code"`
+}
+
+type ExchangeResponse struct {
+	Token string `json:"token"`
+}
+
+// HandleCLILoginExchangeToken exchanges an authorization code for a token
+func (app *App) HandleCLILoginExchangeToken(w http.ResponseWriter, r *http.Request) {
+	// read the request body and look up the authorization token
+	req := &ExchangeRequest{}
+
+	if err := json.NewDecoder(r.Body).Decode(req); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	authCode, err := app.Repo.AuthCode.ReadAuthCode(req.AuthorizationCode)
+
+	if err != nil || authCode.IsExpired() {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	res := &ExchangeResponse{
+		Token: authCode.Token,
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(res); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+}
+
 // HandleLoginUser checks the request header for cookie and validates the user.
 func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
 	session, err := app.Store.Get(r, app.ServerConf.CookieName)

+ 6 - 1
server/router/middleware/auth.go

@@ -64,7 +64,12 @@ func (auth *Auth) BasicAuthenticateWithRedirect(next http.Handler) http.Handler
 			}
 
 			// need state parameter to validate when redirected
-			session.Values["redirect"] = r.URL.Path
+			if r.URL.RawQuery == "" {
+				session.Values["redirect"] = r.URL.Path
+			} else {
+				session.Values["redirect"] = r.URL.Path + "?" + r.URL.RawQuery
+			}
+
 			session.Save(r, w)
 
 			http.Redirect(w, r, "/dashboard", 302)

+ 14 - 0
server/router/router.go

@@ -62,6 +62,20 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		r.Method(
+			"GET",
+			"/cli/login",
+			auth.BasicAuthenticateWithRedirect(
+				requestlog.NewHandler(a.HandleCLILoginUser, l),
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/cli/login/exchange",
+			requestlog.NewHandler(a.HandleCLILoginExchangeToken, l),
+		)
+
 		r.Method(
 			"POST",
 			"/login",