v2.7.0 fac7705

auth.go RawHistoryFolder

1package cli
2
3import (
4	"bufio"
5	"context"
6	"fmt"
7	"os"
8	"strings"
9	"time"
10
11	"github.com/bnema/zerowrap"
12	"github.com/spf13/cobra"
13	"github.com/spf13/viper"
14	"golang.org/x/term"
15
16	"gordon/internal/adapters/in/cli/remote"
17	"gordon/internal/adapters/in/cli/ui/styles"
18	"gordon/internal/adapters/out/secrets"
19	"gordon/internal/adapters/out/tokenstore"
20	"gordon/internal/app"
21	"gordon/internal/domain"
22	"gordon/internal/usecase/auth"
23	"gordon/pkg/duration"
24)
25
26// newAuthCmd creates the auth command group.
27func newAuthCmd() *cobra.Command {
28	cmd := &cobra.Command{
29		Use:   "auth",
30		Short: "Manage Gordon server authentication",
31		Long:  `Commands for managing Gordon server authentication tokens and passwords.`,
32	}
33
34	cmd.AddCommand(newAuthTokenCmd())
35	cmd.AddCommand(newAuthPasswordCmd())
36	cmd.AddCommand(newAuthInternalCmd())
37	cmd.AddCommand(newAuthLoginCmd())
38
39	return cmd
40}
41
42// newAuthTokenCmd creates the token subcommand group.
43func newAuthTokenCmd() *cobra.Command {
44	cmd := &cobra.Command{
45		Use:   "token",
46		Short: "Manage authentication tokens",
47		Long:  `Commands for generating, listing, and revoking authentication tokens.`,
48	}
49
50	cmd.AddCommand(newTokenGenerateCmd())
51	cmd.AddCommand(newTokenListCmd())
52	cmd.AddCommand(newTokenRevokeCmd())
53
54	return cmd
55}
56
57// newTokenGenerateCmd creates the token generate command.
58func newTokenGenerateCmd() *cobra.Command {
59	var (
60		subject    string
61		scopes     string
62		expiry     string
63		configPath string
64	)
65
66	cmd := &cobra.Command{
67		Use:   "generate",
68		Short: "Generate a new authentication token",
69		Long: `Generate a new JWT authentication token for registry and/or admin access.
70
71The token will be stored in the configured secrets backend and can be used
72for docker login as: docker login -u <subject> -p <token>
73
74SCOPES:
75
76Registry scopes (for Docker push/pull):
77  push              Push images to registry
78  pull              Pull images from registry
79
80Admin scopes (for remote CLI access):
81  Format: admin:<resource>:<actions>
82
83  Resources: routes, secrets, config, status, * (all)
84  Actions:   read, write, * (all)
85
86  Examples:
87    admin:*:*              Full admin access (recommended for CLI)
88    admin:routes:read      Read-only routes access
89    admin:status:read      Read-only status access
90
91Combined examples:
92  --scopes "push,pull"                Registry only (default)
93  --scopes "admin:*:*"                Admin CLI only
94  --scopes "push,pull,admin:*:*"      Registry + full admin
95
96EXPIRY:
97
98Supports human-friendly durations:
99  d (days):   1d = 24 hours      w (weeks):  1w = 7 days
100  M (months): 1M = 30 days       y (years):  1y = 365 days
101
102Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d
103Use --expiry=0 for a token that never expires (useful for CI).`,
104		RunE: func(cmd *cobra.Command, args []string) error {
105			return runTokenGenerate(subject, scopes, expiry, configPath)
106		},
107	}
108
109	cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)")
110	cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")
111	cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)")
112	cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
113
114	_ = cmd.MarkFlagRequired("subject")
115
116	return cmd
117}
118
119// newTokenListCmd creates the token list command.
120func newTokenListCmd() *cobra.Command {
121	var configPath string
122
123	cmd := &cobra.Command{
124		Use:   "list",
125		Short: "List all authentication tokens",
126		Long:  `List all stored authentication tokens with their subjects and expiry information.`,
127		RunE: func(cmd *cobra.Command, args []string) error {
128			return runTokenList(configPath)
129		},
130	}
131
132	cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
133
134	return cmd
135}
136
137// newTokenRevokeCmd creates the token revoke command.
138func newTokenRevokeCmd() *cobra.Command {
139	var (
140		configPath string
141		all        bool
142	)
143
144	cmd := &cobra.Command{
145		Use:   "revoke [token-id]",
146		Short: "Revoke authentication token(s)",
147		Long: `Revoke a token by its ID, or all tokens with --all.
148
149Examples:
150  gordon auth token revoke abc123-def456    Revoke specific token
151  gordon auth token revoke --all            Revoke all tokens`,
152		Args: cobra.MaximumNArgs(1),
153		RunE: func(cmd *cobra.Command, args []string) error {
154			if all {
155				return runTokenRevokeAll(configPath)
156			}
157			if len(args) == 0 {
158				return fmt.Errorf("token ID required (or use --all to revoke all tokens)")
159			}
160			return runTokenRevoke(args[0], configPath)
161		},
162	}
163
164	cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
165	cmd.Flags().BoolVar(&all, "all", false, "Revoke all tokens")
166
167	return cmd
168}
169
170// newAuthPasswordCmd creates the password subcommand group.
171func newAuthPasswordCmd() *cobra.Command {
172	cmd := &cobra.Command{
173		Use:   "password",
174		Short: "Password utilities",
175		Long:  `Utilities for managing password authentication.`,
176	}
177
178	cmd.AddCommand(newPasswordHashCmd())
179
180	return cmd
181}
182
183// newAuthInternalCmd creates the internal credentials command.
184func newAuthInternalCmd() *cobra.Command {
185	return &cobra.Command{
186		Use:   "internal",
187		Short: "Show internal registry credentials for manual recovery",
188		Long: `Display the auto-generated internal registry credentials.
189
190These credentials are used by Gordon for pulling images from the local registry
191(localhost:5000) during internal deploys. You can use them for manual recovery:
192
193  docker login localhost:5000 -u gordon-internal -p <password>
194
195Note: Credentials are regenerated each time Gordon starts, and are only
196available while Gordon is running.`,
197		RunE: func(cmd *cobra.Command, args []string) error {
198			return runShowInternalAuth()
199		},
200	}
201}
202
203// newAuthLoginCmd creates the login command for remote authentication.
204func newAuthLoginCmd() *cobra.Command {
205	var (
206		remoteName string
207		username   string
208	)
209
210	cmd := &cobra.Command{
211		Use:   "login",
212		Short: "Authenticate with a Gordon server",
213		Long: `Authenticate with a Gordon server using password authentication.
214
215This command prompts for your username and password, authenticates with the
216remote server's /auth/password endpoint, and stores the returned token.
217
218The token is stored in your remote configuration and used for subsequent
219CLI operations.
220
221Note: This command only works with servers configured for password authentication.
222For servers using token-based auth, use 'gordon remote set-token' instead.
223
224Examples:
225  gordon auth login                    Login using active remote
226  gordon auth login --remote prod      Login to specific remote
227  gordon auth login --username admin   Pre-fill username`,
228		RunE: func(cmd *cobra.Command, args []string) error {
229			return runAuthLogin(remoteName, username)
230		},
231	}
232
233	cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)")
234	cmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication")
235
236	return cmd
237}
238
239// runAuthLogin authenticates with a remote Gordon server.
240func runAuthLogin(remoteName, username string) error {
241	// Load remotes config
242	config, err := remote.LoadRemotes("")
243	if err != nil {
244		return fmt.Errorf("failed to load remotes: %w", err)
245	}
246
247	// Determine which remote to use
248	if remoteName == "" {
249		remoteName = config.Active
250	}
251	if remoteName == "" {
252		return fmt.Errorf("no remote specified and no active remote set. Use --remote or 'gordon remote use <name>'")
253	}
254
255	remoteConfig, ok := config.Remotes[remoteName]
256	if !ok {
257		return fmt.Errorf("remote '%s' not found", remoteName)
258	}
259
260	// Prompt for username if not provided
261	if username == "" {
262		fmt.Print("Username: ")
263		reader := bufio.NewReader(os.Stdin)
264		input, err := reader.ReadString('\n')
265		if err != nil {
266			return fmt.Errorf("failed to read username: %w", err)
267		}
268		username = strings.TrimSpace(input)
269	}
270
271	if username == "" {
272		return fmt.Errorf("username cannot be empty")
273	}
274
275	// Prompt for password (hidden input)
276	fmt.Print("Password: ")
277	passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
278	if err != nil {
279		// Fallback for non-terminal input
280		reader := bufio.NewReader(os.Stdin)
281		password, readErr := reader.ReadString('\n')
282		if readErr != nil {
283			return fmt.Errorf("failed to read password: %w", readErr)
284		}
285		passwordBytes = []byte(strings.TrimSpace(password))
286	}
287	fmt.Println() // newline after hidden password input
288
289	if len(passwordBytes) == 0 {
290		return fmt.Errorf("password cannot be empty")
291	}
292
293	// Create remote client
294	client := remote.NewClient(remoteConfig.URL)
295
296	// Authenticate
297	ctx := context.Background()
298	fmt.Printf("Authenticating with %s...\n", remoteName)
299
300	resp, err := client.Authenticate(ctx, username, string(passwordBytes))
301	if err != nil {
302		return fmt.Errorf("authentication failed: %w", err)
303	}
304
305	// Update remote config with new token
306	if err := remote.UpdateRemoteToken(remoteName, resp.Token); err != nil {
307		return fmt.Errorf("failed to save token: %w", err)
308	}
309
310	fmt.Println()
311	fmt.Println(styles.RenderSuccess("Authentication successful!"))
312	fmt.Printf("Token stored for remote '%s'\n", remoteName)
313	fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn)
314
315	return nil
316}
317
318// runShowInternalAuth displays the internal registry credentials.
319func runShowInternalAuth() error {
320	creds, err := app.GetInternalCredentials()
321	if err != nil {
322		return err
323	}
324
325	fmt.Println("Internal Registry Credentials")
326	fmt.Println("==============================")
327	fmt.Printf("Username: %s\n", creds.Username)
328	fmt.Printf("Password: %s\n", creds.Password)
329	fmt.Println()
330	fmt.Println("Usage:")
331	fmt.Printf("  docker login localhost:5000 -u %s -p %s\n", creds.Username, creds.Password)
332
333	return nil
334}
335
336// newPasswordHashCmd creates the password hash command.
337func newPasswordHashCmd() *cobra.Command {
338	return &cobra.Command{
339		Use:   "hash",
340		Short: "Generate a bcrypt hash for a password",
341		Long: `Interactively prompt for a password and output its bcrypt hash.
342
343The hash can be stored in your secrets backend and referenced in the config:
344
345  [auth]
346  type = "password"
347  password_hash = "gordon/auth/password_hash"`,
348		RunE: func(cmd *cobra.Command, args []string) error {
349			return runPasswordHash()
350		},
351	}
352}
353
354// runTokenGenerate generates a new authentication token.
355func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
356	cfg, err := loadAuthConfig(configPath)
357	if err != nil {
358		return err
359	}
360
361	// Parse expiry (supports human-friendly units: d, w, M, y)
362	var expiry time.Duration
363	if expiryStr != "0" {
364		expiry, err = duration.Parse(expiryStr)
365		if err != nil {
366			return fmt.Errorf("invalid expiry duration: %w", err)
367		}
368	}
369
370	// Parse scopes
371	scopes := strings.Split(scopesStr, ",")
372	for i := range scopes {
373		scopes[i] = strings.TrimSpace(scopes[i])
374	}
375
376	// Create auth service
377	log := zerowrap.New(zerowrap.Config{Level: "warn"})
378	authSvc, err := createAuthServiceForCLI(cfg, log)
379	if err != nil {
380		return err
381	}
382
383	// Generate token
384	ctx := context.Background()
385	token, err := authSvc.GenerateToken(ctx, subject, scopes, expiry)
386	if err != nil {
387		return fmt.Errorf("failed to generate token: %w", err)
388	}
389
390	fmt.Println("Token generated successfully!")
391	fmt.Printf("Subject: %s\n", subject)
392	fmt.Printf("Scopes: %s\n", strings.Join(scopes, ", "))
393	if expiry == 0 {
394		fmt.Println("Expiry: never")
395	} else {
396		fmt.Printf("Expiry: %s\n", expiry)
397	}
398	fmt.Println()
399	fmt.Println("Token (use as password with docker login):")
400	fmt.Println(token)
401	fmt.Println()
402	fmt.Printf("Usage: docker login -u %s -p <token> <registry>\n", subject)
403
404	return nil
405}
406
407// runTokenList lists all stored tokens.
408func runTokenList(configPath string) error {
409	cfg, err := loadAuthConfig(configPath)
410	if err != nil {
411		return err
412	}
413
414	log := zerowrap.New(zerowrap.Config{Level: "warn"})
415	authSvc, err := createAuthServiceForCLI(cfg, log)
416	if err != nil {
417		return err
418	}
419
420	ctx := context.Background()
421	tokens, err := authSvc.ListTokens(ctx)
422	if err != nil {
423		return fmt.Errorf("failed to list tokens: %w", err)
424	}
425
426	if len(tokens) == 0 {
427		fmt.Println("No tokens found.")
428		return nil
429	}
430
431	fmt.Printf("%-36s  %-20s  %-20s  %-10s\n", "ID", "Subject", "Expires", "Revoked")
432	fmt.Println(strings.Repeat("-", 90))
433
434	for _, t := range tokens {
435		expiry := "never"
436		if !t.ExpiresAt.IsZero() {
437			expiry = t.ExpiresAt.Format("2006-01-02 15:04")
438		}
439		revoked := "no"
440		if t.Revoked {
441			revoked = "yes"
442		}
443		fmt.Printf("%-36s  %-20s  %-20s  %-10s\n", t.ID, t.Subject, expiry, revoked)
444	}
445
446	return nil
447}
448
449// runTokenRevoke revokes a token by ID.
450func runTokenRevoke(tokenID, configPath string) error {
451	cfg, err := loadAuthConfig(configPath)
452	if err != nil {
453		return err
454	}
455
456	log := zerowrap.New(zerowrap.Config{Level: "warn"})
457	authSvc, err := createAuthServiceForCLI(cfg, log)
458	if err != nil {
459		return err
460	}
461
462	ctx := context.Background()
463	if err := authSvc.RevokeToken(ctx, tokenID); err != nil {
464		return fmt.Errorf("failed to revoke token: %w", err)
465	}
466
467	fmt.Printf("Token %s has been revoked.\n", tokenID)
468	return nil
469}
470
471// runTokenRevokeAll revokes all tokens.
472func runTokenRevokeAll(configPath string) error {
473	cfg, err := loadAuthConfig(configPath)
474	if err != nil {
475		return err
476	}
477
478	log := zerowrap.New(zerowrap.Config{Level: "warn"})
479	authSvc, err := createAuthServiceForCLI(cfg, log)
480	if err != nil {
481		return err
482	}
483
484	ctx := context.Background()
485	count, err := authSvc.RevokeAllTokens(ctx)
486	if err != nil {
487		return fmt.Errorf("failed to revoke tokens: %w", err)
488	}
489
490	if count == 0 {
491		fmt.Println("No tokens to revoke.")
492	} else {
493		fmt.Printf("Revoked %d token(s).\n", count)
494	}
495	return nil
496}
497
498// runPasswordHash generates a bcrypt hash for a password.
499func runPasswordHash() error {
500	fmt.Print("Enter password: ")
501
502	// Read password without echo (use os.Stdin.Fd() for better compatibility)
503	passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
504	if err != nil {
505		// Fallback for non-terminal input
506		reader := bufio.NewReader(os.Stdin)
507		password, err := reader.ReadString('\n')
508		if err != nil {
509			return fmt.Errorf("failed to read password: %w", err)
510		}
511		passwordBytes = []byte(strings.TrimSpace(password))
512	}
513	fmt.Println()
514
515	if len(passwordBytes) == 0 {
516		return fmt.Errorf("password cannot be empty")
517	}
518
519	// Create a temporary auth service to generate the hash
520	authSvc := auth.NewService(auth.Config{}, nil, zerowrap.Default())
521	hash, err := authSvc.GeneratePasswordHash(string(passwordBytes))
522	if err != nil {
523		return fmt.Errorf("failed to generate hash: %w", err)
524	}
525
526	fmt.Println()
527	fmt.Println("Bcrypt hash (store in your secrets backend):")
528	fmt.Println(hash)
529	fmt.Println()
530	fmt.Println("Then reference the path in your config:")
531	fmt.Println("  [auth]")
532	fmt.Println("  type = \"password\"")
533	fmt.Println("  password_hash = \"gordon/auth/password_hash\"")
534
535	return nil
536}
537
538// cliConfig holds the configuration needed for CLI commands.
539type cliConfig struct {
540	Backend     domain.SecretsBackend
541	DataDir     string
542	TokenSecret []byte
543}
544
545// loadAuthConfig loads the configuration needed for auth CLI commands.
546func loadAuthConfig(configPath string) (*cliConfig, error) {
547	v := viper.New()
548
549	// Set defaults
550	v.SetDefault("server.data_dir", app.DefaultDataDir())
551	v.SetDefault("auth.secrets_backend", "unsafe")
552
553	app.ConfigureViper(v, configPath)
554
555	if err := v.ReadInConfig(); err != nil {
556		if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
557			return nil, fmt.Errorf("failed to read config file: %w", err)
558		}
559		// Config file not found is OK for CLI commands
560	}
561
562	cfg := &cliConfig{
563		DataDir: v.GetString("server.data_dir"),
564	}
565
566	// Determine backend
567	switch v.GetString("auth.secrets_backend") {
568	case "pass":
569		cfg.Backend = domain.SecretsBackendPass
570	case "sops":
571		cfg.Backend = domain.SecretsBackendSops
572	default:
573		cfg.Backend = domain.SecretsBackendUnsafe
574	}
575
576	// Load token secret from secrets backend
577	tokenSecretPath := v.GetString("auth.token_secret")
578	if tokenSecretPath != "" {
579		// Load actual secret from the configured backend
580		ctx := context.Background()
581		log := zerowrap.New(zerowrap.Config{Level: "warn"})
582
583		switch cfg.Backend {
584		case domain.SecretsBackendPass:
585			provider := secrets.NewPassProvider(log)
586			secret, err := provider.GetSecret(ctx, tokenSecretPath)
587			if err != nil {
588				return nil, fmt.Errorf("failed to load token secret from pass: %w", err)
589			}
590			cfg.TokenSecret = []byte(secret)
591		default:
592			// For unsafe backend, use the path as the secret (backwards compatible)
593			cfg.TokenSecret = []byte(tokenSecretPath)
594		}
595	} else {
596		// Generate a default secret for CLI token generation
597		cfg.TokenSecret = []byte("gordon-cli-default-secret")
598	}
599
600	return cfg, nil
601}
602
603// createAuthServiceForCLI creates an auth service for CLI commands.
604func createAuthServiceForCLI(cfg *cliConfig, log zerowrap.Logger) (*auth.Service, error) {
605	// Create token store
606	store, err := tokenstore.NewStore(cfg.Backend, cfg.DataDir, log)
607	if err != nil {
608		return nil, fmt.Errorf("failed to create token store: %w", err)
609	}
610
611	// Create auth service config
612	authConfig := auth.Config{
613		Enabled:     true,
614		AuthType:    domain.AuthTypeToken,
615		TokenSecret: cfg.TokenSecret,
616	}
617
618	return auth.NewService(authConfig, store, log), nil
619}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close