Skip to content

Commit 98ffc77

Browse files
committed
authentication commands
1 parent 89ff00e commit 98ffc77

File tree

5 files changed

+238
-28
lines changed

5 files changed

+238
-28
lines changed

cmd/login.go

+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package cmd
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"os"
12+
"strings"
13+
"time"
14+
15+
"github.com/spf13/cobra"
16+
"github.com/spf13/viper"
17+
)
18+
19+
const logo string = `
20+
@@@@ @@@@
21+
@@@@@@@@@@@ @@@@@@@ @@@@ @@@@@@@ @@@@@@@@@@@
22+
@@@ @@@@ @@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@ @@@@ @@@@
23+
@@@ ... .. . @@@
24+
@@@ @@@@@@@ @@@@@@@@ . @@@
25+
@@@ . @@ @@ @@@@ @@@@ @@@@@@@@ @@ @@ @@@@@@ @@@ @@@ @@@
26+
@@@ .. @@@@@@ @@ @@ @@ @@ @ @@ @ @@ @@ @@ @@ .@@ @@@@
27+
@@@ .. @@ @@ @@ @@ @@ @@ @@ @@ @@ @@@@ @@ @@ @@@@
28+
@@@ . @@ @@ @@ @@ @@ @@ @@ @@ @@ @@ @@@ @@@
29+
@@@ @@@@@@@ @@@@ @@@@ @@ @@ @@@@@@@@ @@@@@@ @ .. @@@
30+
@@@ . ..@@@
31+
@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@
32+
@@@@@@ @@@@@@
33+
@ @
34+
`
35+
36+
type LoginRequest struct {
37+
Otp string `json:"otp"`
38+
}
39+
40+
type LoginResponse struct {
41+
AccessToken string `json:"access_token"`
42+
RefreshToken string `json:"refresh_token"`
43+
}
44+
45+
func loginWithCode(code string) {
46+
api_url := viper.GetString("api_url")
47+
req, err := json.Marshal(LoginRequest{Otp: code})
48+
cobra.CheckErr(err)
49+
50+
resp, err := http.Post(api_url+"/v1/auth/otp/login", "application/json", bytes.NewReader(req))
51+
cobra.CheckErr(err)
52+
53+
if resp.StatusCode != 200 {
54+
cobra.CheckErr(errors.New("Invalid login code"))
55+
}
56+
57+
body, err := io.ReadAll(resp.Body)
58+
cobra.CheckErr(err)
59+
60+
var creds LoginResponse
61+
err = json.Unmarshal(body, &creds)
62+
cobra.CheckErr(err)
63+
if creds.AccessToken == "" || creds.RefreshToken == "" {
64+
cobra.CheckErr(errors.New("Invalid credentials received"))
65+
}
66+
viper.Set("access_token", creds.AccessToken)
67+
viper.Set("refresh_token", creds.RefreshToken)
68+
viper.Set("last_refresh", time.Now().Unix())
69+
viper.WriteConfig()
70+
// TODO: check if the logo fits
71+
fmt.Print(logo)
72+
fmt.Println("Logged in successfully!")
73+
}
74+
75+
var loginCmd = &cobra.Command{
76+
Use: "login",
77+
Aliases: []string{"auth", "authenticate", "signin"},
78+
Short: "Authenticate the CLI with your account",
79+
Run: func(cmd *cobra.Command, args []string) {
80+
baseUrl := viper.GetString("base_url")
81+
fmt.Print("Welcome to the boot.dev CLI!\n\n")
82+
fmt.Println("Please navigate to:\n" + baseUrl + "/cli/login?redirect=/cli/login")
83+
84+
reader := bufio.NewReader(os.Stdin)
85+
fmt.Print("\nPaste your login code: ")
86+
text, err := reader.ReadString('\n')
87+
cobra.CheckErr(err)
88+
89+
loginWithCode(strings.Trim(text, " \n"))
90+
},
91+
}
92+
93+
func init() {
94+
rootCmd.AddCommand(loginCmd)
95+
}

cmd/logout.go

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"net/http"
7+
"time"
8+
9+
"github.com/spf13/cobra"
10+
"github.com/spf13/viper"
11+
)
12+
13+
func logout() {
14+
api_url := viper.GetString("api_url")
15+
client := &http.Client{}
16+
r, err := http.NewRequest("POST", api_url+"/v1/auth/logout", bytes.NewBuffer([]byte{}))
17+
r.Header.Add("X-Refresh-Token", viper.GetString("refresh_token"))
18+
client.Do(r)
19+
20+
cobra.CheckErr(err)
21+
22+
viper.Set("access_token", "")
23+
viper.Set("refresh_token", "")
24+
viper.Set("last_refresh", time.Now().Unix())
25+
viper.WriteConfig()
26+
fmt.Println("Logged out successfully.")
27+
}
28+
29+
var logoutCmd = &cobra.Command{
30+
Use: "logout",
31+
Aliases: []string{"signout"},
32+
Short: "Disconnect the CLI from your account",
33+
Run: func(cmd *cobra.Command, args []string) {
34+
requireAuth()
35+
logout()
36+
},
37+
}
38+
39+
func init() {
40+
rootCmd.AddCommand(logoutCmd)
41+
}

cmd/root.go

+80-26
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,26 @@
11
package cmd
22

33
import (
4+
"bytes"
5+
"encoding/json"
46
"fmt"
7+
"io"
8+
"net/http"
59
"os"
10+
"path"
11+
"time"
612

713
"github.com/spf13/cobra"
814
"github.com/spf13/viper"
915
)
1016

1117
var cfgFile string
1218

13-
// rootCmd represents the base command when called without any subcommands
1419
var rootCmd = &cobra.Command{
15-
Use: "v2",
16-
Short: "A brief description of your application",
17-
Long: `A longer description that spans multiple lines and likely contains
18-
examples and usage of using your application. For example:
19-
20-
Cobra is a CLI library for Go that empowers applications.
21-
This application is a tool to generate the needed files
22-
to quickly create a Cobra application.`,
23-
// Uncomment the following line if your bare application
24-
// has an action associated with it:
25-
// Run: func(cmd *cobra.Command, args []string) { },
20+
Use: "bootdev",
21+
Short: "The official boot.dev CLI",
22+
Long: `The official CLI for boot.dev. This program is meant
23+
to be a companion app (not a replacement) for the website.`,
2624
}
2725

2826
// Execute adds all child commands to the root command and sets flags appropriately.
@@ -37,37 +35,93 @@ func Execute() {
3735
func init() {
3836
cobra.OnInitialize(initConfig)
3937

40-
// Here you will define your flags and configuration settings.
41-
// Cobra supports persistent flags, which, if defined here,
42-
// will be global for your application.
43-
44-
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.v2.yaml)")
45-
46-
// Cobra also supports local flags, which will only run
47-
// when this action is called directly.
48-
rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
38+
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.bootdev.yaml)")
4939
}
5040

5141
// initConfig reads in config file and ENV variables if set.
5242
func initConfig() {
43+
viper.SetDefault("base_url", "https://boot.dev")
44+
viper.SetDefault("api_url", "https://api.boot.dev")
45+
viper.SetDefault("access_token", "")
46+
viper.SetDefault("refresh_token", "")
47+
viper.SetDefault("last_refresh", 0)
5348
if cfgFile != "" {
5449
// Use config file from the flag.
5550
viper.SetConfigFile(cfgFile)
51+
err := viper.ReadInConfig()
52+
cobra.CheckErr(err)
5653
} else {
5754
// Find home directory.
5855
home, err := os.UserHomeDir()
5956
cobra.CheckErr(err)
6057

61-
// Search config in home directory with name ".v2" (without extension).
58+
// Search config in home directory with name ".bootdev" (without extension).
6259
viper.AddConfigPath(home)
6360
viper.SetConfigType("yaml")
64-
viper.SetConfigName(".v2")
61+
viper.SetConfigName(".bootdev")
62+
if err := viper.ReadInConfig(); err != nil {
63+
home, err := os.UserHomeDir()
64+
cobra.CheckErr(err)
65+
viper.SafeWriteConfigAs(path.Join(home, ".bootdev.yaml"))
66+
viper.ReadInConfig()
67+
cobra.CheckErr(err)
68+
}
6569
}
6670

71+
viper.SetEnvPrefix("bd")
6772
viper.AutomaticEnv() // read in environment variables that match
73+
}
74+
75+
func promptLoginAndExitIf(condition bool) {
76+
if condition {
77+
fmt.Println("You must be logged in to use that command.")
78+
fmt.Println("Please run 'bootdev login' first.")
79+
os.Exit(1)
80+
}
81+
}
82+
83+
// Call this function at the beginning of a command handler
84+
// if you need to make authenticated requests. This will
85+
// automatically refresh the tokens, if necessary, and prompt
86+
// the user to re-login if anything goes wrong.
87+
func requireAuth() {
88+
access_token := viper.GetString("access_token")
89+
promptLoginAndExitIf(access_token == "")
90+
91+
// We only refresh if our token is getting stale.
92+
last_refresh := viper.GetInt64("last_refresh")
93+
if time.Now().Add(-time.Minute*55).Unix() <= last_refresh {
94+
return
95+
}
96+
97+
api_url := viper.GetString("api_url")
98+
99+
client := &http.Client{}
100+
r, err := http.NewRequest("POST", api_url+"/v1/auth/refresh", bytes.NewBuffer([]byte{}))
101+
r.Header.Add("X-Refresh-Token", viper.GetString("refresh_token"))
102+
promptLoginAndExitIf(err != nil)
103+
resp, err := client.Do(r)
104+
promptLoginAndExitIf(err != nil)
105+
106+
defer resp.Body.Close()
107+
promptLoginAndExitIf(err != nil)
108+
109+
if resp.StatusCode != 200 {
110+
promptLoginAndExitIf(err != nil)
111+
}
112+
113+
body, err := io.ReadAll(resp.Body)
114+
promptLoginAndExitIf(err != nil)
68115

69-
// If a config file is found, read it in.
70-
if err := viper.ReadInConfig(); err == nil {
71-
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
116+
var creds LoginResponse
117+
err = json.Unmarshal(body, &creds)
118+
promptLoginAndExitIf(err != nil)
119+
if creds.AccessToken == "" || creds.RefreshToken == "" {
120+
promptLoginAndExitIf(err != nil)
72121
}
122+
viper.Set("access_token", creds.AccessToken)
123+
viper.Set("refresh_token", creds.RefreshToken)
124+
viper.Set("last_refresh", time.Now().Unix())
125+
err = viper.WriteConfig()
126+
promptLoginAndExitIf(err != nil)
73127
}

go.mod

+5-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ module github.com/bootdotdev/bootdev/v2
22

33
go 1.22.1
44

5+
require (
6+
github.com/spf13/cobra v1.8.0
7+
github.com/spf13/viper v1.18.2
8+
)
9+
510
require (
611
github.com/fsnotify/fsnotify v1.7.0 // indirect
712
github.com/hashicorp/hcl v1.0.0 // indirect
@@ -14,9 +19,7 @@ require (
1419
github.com/sourcegraph/conc v0.3.0 // indirect
1520
github.com/spf13/afero v1.11.0 // indirect
1621
github.com/spf13/cast v1.6.0 // indirect
17-
github.com/spf13/cobra v1.8.0 // indirect
1822
github.com/spf13/pflag v1.0.5 // indirect
19-
github.com/spf13/viper v1.18.2 // indirect
2023
github.com/subosito/gotenv v1.6.0 // indirect
2124
go.uber.org/atomic v1.9.0 // indirect
2225
go.uber.org/multierr v1.9.0 // indirect

go.sum

+17
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,33 @@
11
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
22
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
33
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
5+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6+
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
7+
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
48
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
59
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
10+
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
11+
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
612
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
713
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
814
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
915
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
16+
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
17+
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
18+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
19+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
1020
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
1121
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
1222
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
1323
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
1424
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
1525
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
1626
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
27+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
28+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
29+
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
30+
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
1731
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
1832
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
1933
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
@@ -37,6 +51,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
3751
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
3852
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
3953
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
54+
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
4055
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
4156
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
4257
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
@@ -51,6 +66,8 @@ golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
5166
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
5267
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
5368
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
69+
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
70+
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
5471
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
5572
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
5673
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

0 commit comments

Comments
 (0)