Skip to content

Commit b82077c

Browse files
authored
Add support for login command (#159)
* fixes #154 Feat: Add Login command to perform login and keep credentials in config file to perform further operations Signed-off-by: Harsh4902 <[email protected]> * Adds suport for token refreshing mechanism on token expiry Signed-off-by: Harsh4902 <[email protected]> --------- Signed-off-by: Harsh4902 <[email protected]>
1 parent a7a7b3f commit b82077c

File tree

8 files changed

+691
-34
lines changed

8 files changed

+691
-34
lines changed

cmd/cmd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,5 @@ func init() {
4848
rootCmd.AddCommand(NewStartCommand())
4949
rootCmd.AddCommand(NewStopCommand())
5050
rootCmd.AddCommand(NewContextCommand())
51+
rootCmd.AddCommand(NewLoginCommand())
5152
}

cmd/login.go

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
package cmd
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"crypto/sha256"
7+
"encoding/base64"
8+
"fmt"
9+
"html"
10+
"log"
11+
"net/http"
12+
"os"
13+
"strconv"
14+
"strings"
15+
"time"
16+
17+
"github.com/coreos/go-oidc/v3/oidc"
18+
"github.com/golang-jwt/jwt/v4"
19+
"github.com/microcks/microcks-cli/pkg/config"
20+
"github.com/microcks/microcks-cli/pkg/connectors"
21+
"github.com/microcks/microcks-cli/pkg/errors"
22+
"github.com/microcks/microcks-cli/pkg/util/rand"
23+
"github.com/skratchdot/open-golang/open"
24+
"github.com/spf13/cobra"
25+
"golang.org/x/oauth2"
26+
"golang.org/x/term"
27+
)
28+
29+
func NewLoginCommand() *cobra.Command {
30+
var (
31+
ctxName string
32+
username string
33+
password string
34+
sso bool
35+
ssoLaunchBrowser bool
36+
ssoProt int
37+
)
38+
loginCmd := &cobra.Command{
39+
40+
Use: "login SERVER",
41+
Short: "Login into Microcks instance",
42+
Long: "Login into Microcks instance",
43+
Example: `microcks login http://locahost:8080
44+
45+
# Provide name to your logged in context (Defautl context name is server name)
46+
microcks login http://localhost:8080 --name
47+
48+
# Provide username and password as flags
49+
microcks login http://localhost:8080 --username --password
50+
51+
# Perform SSO login
52+
microcks login http://localhost:8080 --sso
53+
54+
# Change port callback server for SSO login
55+
microcks login http://localhost:8080 --sso --sso-port
56+
57+
# Get OAuth URI instead of getting redirect to browser for SSO login
58+
microcks login http://localhost:8080 --sso --sso-launch-browser=false
59+
`,
60+
Run: func(cmd *cobra.Command, args []string) {
61+
ctx := cmd.Context()
62+
var server string
63+
64+
//Chekc if server name is provided or not
65+
if len(args) != 1 {
66+
cmd.HelpFunc()(cmd, args)
67+
os.Exit(1)
68+
}
69+
70+
server = args[0]
71+
mc := connectors.NewMicrocksClient(server)
72+
keycloakUrl, err := mc.GetKeycloakURL()
73+
74+
if err != nil {
75+
log.Fatal(err)
76+
}
77+
78+
if ctxName == "" {
79+
ctxName = server
80+
}
81+
82+
var authToken = ""
83+
var refreshToken = ""
84+
85+
configFile, err := config.DefaultLocalConfigPath()
86+
errors.CheckError(err)
87+
localConfig, err := config.ReadLocalConfig(configFile)
88+
errors.CheckError(err)
89+
90+
if localConfig == nil {
91+
localConfig = &config.LocalConfig{}
92+
}
93+
94+
if keycloakUrl == "null" {
95+
localConfig.UpsertServer(config.Server{
96+
Server: server,
97+
InsecureTLS: true,
98+
KeycloackEnable: false,
99+
})
100+
fmt.Print("No login required...\n")
101+
} else {
102+
if !sso {
103+
//Chek for the enviroment variables
104+
clientID := os.Getenv("MICROCKS_CLIENT_ID")
105+
clientSecret := os.Getenv("MICROCKS_CLIENT_SECRET")
106+
107+
if clientID == "" || clientSecret == "" {
108+
fmt.Printf("Please Set 'MICROCKS_CLIENT_ID' & 'MICROCKS_CLIENT_SECRET' to perform password login\n")
109+
os.Exit(1)
110+
}
111+
//Perform login and retrive tokens
112+
authToken, refreshToken = passwordLogin(keycloakUrl, clientID, clientSecret, username, password)
113+
} else {
114+
httpClient := mc.HttpClient()
115+
ctx = oidc.ClientContext(ctx, httpClient)
116+
kc := connectors.NewKeycloakClient(keycloakUrl, "", "")
117+
oauth2conf, err := kc.GetOIDCConfig()
118+
errors.CheckError(err)
119+
authToken, refreshToken = oauth2login(ctx, ssoProt, oauth2conf, ssoLaunchBrowser)
120+
}
121+
122+
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
123+
claims := jwt.MapClaims{}
124+
_, _, err = parser.ParseUnverified(authToken, &claims)
125+
126+
if err != nil {
127+
fmt.Println(err)
128+
}
129+
130+
em := StringField(claims, "preferred_username")
131+
fmt.Printf("'%s' logged in successfully\n", em)
132+
133+
localConfig.UpsertServer(config.Server{
134+
Server: server,
135+
InsecureTLS: true,
136+
KeycloackEnable: true,
137+
})
138+
}
139+
140+
localConfig.UpsertUser(config.User{
141+
Name: server,
142+
AuthToken: authToken,
143+
RefreshToken: refreshToken,
144+
})
145+
146+
localConfig.CurrentContext = ctxName
147+
localConfig.UpserContext(config.ContextRef{
148+
Name: ctxName,
149+
Server: server,
150+
User: server,
151+
})
152+
153+
err = config.WriteLocalConfig(*localConfig, configFile)
154+
errors.CheckError(err)
155+
156+
fmt.Printf("Context '%s' updated\n", ctxName)
157+
},
158+
}
159+
160+
loginCmd.Flags().StringVar(&ctxName, "name", "", "Name to use for the context")
161+
loginCmd.Flags().StringVar(&username, "username", "", "The username of an account to authenticate")
162+
loginCmd.Flags().StringVar(&password, "password", "", "The password of an account to authenticate")
163+
loginCmd.Flags().BoolVar(&sso, "sso", false, "Perform SSO login")
164+
loginCmd.Flags().BoolVar(&ssoLaunchBrowser, "sso-launch-browser", true, "Automatically launch the system default browser when performing SSO login")
165+
loginCmd.Flags().IntVar(&ssoProt, "sso-port", 8085, "Port to run local OAuth2 login application")
166+
167+
return loginCmd
168+
}
169+
170+
func oauth2login(
171+
ctx context.Context,
172+
port int,
173+
oauth2conf *oauth2.Config,
174+
ssoLaunchBrowser bool,
175+
) (string, string) {
176+
oauth2conf.ClientID = "microcks-app-js"
177+
oauth2conf.RedirectURL = fmt.Sprintf("http://localhost:%d/auth/callback", port)
178+
179+
// handledRequests ensures we do not handle more requests than necessary
180+
handledRequests := 0
181+
// completionChan is to signal flow completed. Non-empty string indicates error
182+
completionChan := make(chan string)
183+
184+
stateNonce, err := rand.String(24)
185+
errors.CheckError(err)
186+
var tokenString string
187+
var refreshToken string
188+
189+
handleErr := func(w http.ResponseWriter, errMsg string) {
190+
http.Error(w, html.EscapeString(errMsg), http.StatusBadRequest)
191+
completionChan <- errMsg
192+
}
193+
194+
// PKCE implementation of https://tools.ietf.org/html/rfc7636
195+
codeVerifier, err := rand.StringFromCharset(
196+
43,
197+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~",
198+
)
199+
errors.CheckError(err)
200+
codeChallengeHash := sha256.Sum256([]byte(codeVerifier))
201+
codeChallenge := base64.RawURLEncoding.EncodeToString(codeChallengeHash[:])
202+
203+
// Authorization redirect callback from OAuth2 auth flow.
204+
// Handles both implicit and authorization code flow
205+
callbackHandler := func(w http.ResponseWriter, r *http.Request) {
206+
if formErr := r.FormValue("error"); formErr != "" {
207+
handleErr(w, fmt.Sprintf("%s: %s", formErr, r.FormValue("error_description")))
208+
return
209+
}
210+
211+
handledRequests++
212+
if handledRequests > 2 {
213+
// Since implicit flow will redirect back to ourselves, this counter ensures we do not
214+
// fallinto a redirect loop (e.g. user visits the page by hand)
215+
handleErr(w, "Unable to complete login flow: too many redirects")
216+
return
217+
}
218+
219+
if state := r.FormValue("state"); state != stateNonce {
220+
handleErr(w, "Unknown state nonce")
221+
return
222+
}
223+
224+
tokenString = r.FormValue("id_token")
225+
if tokenString == "" {
226+
code := r.FormValue("code")
227+
if code == "" {
228+
handleErr(w, fmt.Sprintf("no code in request: %q", r.Form))
229+
return
230+
}
231+
opts := []oauth2.AuthCodeOption{oauth2.SetAuthURLParam("code_verifier", codeVerifier)}
232+
tok, err := oauth2conf.Exchange(ctx, code, opts...)
233+
if err != nil {
234+
handleErr(w, err.Error())
235+
return
236+
}
237+
tokenString = tok.AccessToken
238+
refreshToken = tok.RefreshToken
239+
240+
}
241+
successPage := `
242+
<div style="height:100px; width:100%!; display:flex; flex-direction: column; justify-content: center; align-items:center; background-color:#2ecc71; color:white; font-size:22"><div>Authentication successful!</div></div>
243+
<p style="margin-top:20px; font-size:18; text-align:center">Authentication was successful, you can now return to CLI. This page will close automatically</p>
244+
<script>window.onload=function(){setTimeout(this.close, 1000)}</script>
245+
`
246+
fmt.Fprint(w, successPage)
247+
completionChan <- ""
248+
}
249+
250+
srv := &http.Server{Addr: "localhost:" + strconv.Itoa(port)}
251+
http.HandleFunc("/auth/callback", callbackHandler)
252+
253+
var url string
254+
opts := []oauth2.AuthCodeOption{}
255+
opts = append(opts, oauth2.SetAuthURLParam("client_id", "microcks-app-js"))
256+
opts = append(opts, oauth2.SetAuthURLParam("scope", "openid"))
257+
opts = append(opts, oauth2.SetAuthURLParam("code_challenge", codeChallenge))
258+
opts = append(opts, oauth2.SetAuthURLParam("code_challenge_method", "S256"))
259+
url = oauth2conf.AuthCodeURL(stateNonce, opts...)
260+
261+
fmt.Printf("Performing %s flow login: %s\n", "authorization_code", url)
262+
time.Sleep(1 * time.Second)
263+
ssoAuthFlow(url, ssoLaunchBrowser)
264+
go func() {
265+
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
266+
log.Fatalf("Temporary HTTP server failed: %s", err)
267+
}
268+
}()
269+
errMsg := <-completionChan
270+
if errMsg != "" {
271+
log.Fatal(errMsg)
272+
}
273+
274+
ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
275+
defer cancel()
276+
_ = srv.Shutdown(ctx)
277+
278+
return tokenString, refreshToken
279+
}
280+
281+
func ssoAuthFlow(url string, ssoLaunchBrowser bool) {
282+
if ssoLaunchBrowser {
283+
log.Printf("Opening system default browser for authentication\n")
284+
err := open.Start(url)
285+
errors.CheckError(err)
286+
} else {
287+
log.Printf("To authenticate, copy-and-paste the following URL into your preferred browser: %s\n", url)
288+
}
289+
}
290+
291+
func passwordLogin(keycloakURL, clientId, clientSecret, Username, Password string) (string, string) {
292+
kc := connectors.NewKeycloakClient(keycloakURL, clientId, clientSecret)
293+
username, password := promptCredentials(Username, Password)
294+
295+
authToken, refreshToken, err := kc.ConnectAndGetTokenAndRefreshToken(username, password)
296+
297+
if err != nil {
298+
panic(err)
299+
}
300+
301+
return authToken, refreshToken
302+
}
303+
304+
func promptCredentials(username, password string) (string, string) {
305+
return promptUserName(username), promptPassword(password)
306+
}
307+
308+
func promptUserName(value string) string {
309+
for value == "" {
310+
reader := bufio.NewReader(os.Stdin)
311+
fmt.Print("Username" + ": ")
312+
valueRaw, err := reader.ReadString('\n')
313+
if err != nil {
314+
panic(err)
315+
}
316+
value = strings.TrimSpace(valueRaw)
317+
}
318+
return value
319+
}
320+
321+
func promptPassword(password string) string {
322+
for password == "" {
323+
fmt.Print("Password: ")
324+
passwordRaw, err := term.ReadPassword(int(os.Stdin.Fd()))
325+
if err != nil {
326+
panic(err)
327+
}
328+
password = string(passwordRaw)
329+
fmt.Print("\n")
330+
}
331+
return password
332+
}
333+
334+
func StringField(claims jwt.MapClaims, fieldName string) string {
335+
if fieldIf, ok := claims[fieldName]; ok {
336+
if field, ok := fieldIf.(string); ok {
337+
return field
338+
}
339+
}
340+
return ""
341+
}

go.mod

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@ go 1.23.0
55
toolchain go1.24.1
66

77
require (
8+
github.com/coreos/go-oidc/v3 v3.14.1
89
github.com/docker/docker v28.0.4+incompatible
910
github.com/docker/go-connections v0.5.0
11+
github.com/golang-jwt/jwt/v4 v4.5.2
12+
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
1013
github.com/spf13/cobra v1.9.1
1114
github.com/stretchr/testify v1.10.0
15+
golang.org/x/oauth2 v0.30.0
16+
golang.org/x/term v0.32.0
1217
gopkg.in/yaml.v2 v2.4.0
1318
)
1419

@@ -19,6 +24,7 @@ require (
1924
github.com/distribution/reference v0.6.0 // indirect
2025
github.com/docker/go-units v0.5.0 // indirect
2126
github.com/felixge/httpsnoop v1.0.4 // indirect
27+
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
2228
github.com/go-logr/logr v1.4.2 // indirect
2329
github.com/go-logr/stdr v1.2.2 // indirect
2430
github.com/gogo/protobuf v1.3.2 // indirect
@@ -37,7 +43,8 @@ require (
3743
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect
3844
go.opentelemetry.io/otel/metric v1.35.0 // indirect
3945
go.opentelemetry.io/otel/trace v1.35.0 // indirect
40-
golang.org/x/sys v0.30.0 // indirect
46+
golang.org/x/crypto v0.36.0 // indirect
47+
golang.org/x/sys v0.33.0 // indirect
4148
golang.org/x/time v0.11.0 // indirect
4249
gopkg.in/yaml.v3 v3.0.1 // indirect
4350
gotest.tools/v3 v3.5.2 // indirect

0 commit comments

Comments
 (0)