v2.7.1 b3a38c8

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.
355// parseAndConvertScopes parses a comma-separated scope string and converts
356// simple scopes (push, pull, *) to Docker v2 format (repository:*:action).
357func parseAndConvertScopes(scopesStr string) []string {
358	rawScopes := strings.Split(scopesStr, ",")
359	for i := range rawScopes {
360		rawScopes[i] = strings.TrimSpace(rawScopes[i])
361	}
362
363	// Check if scopes are already in v2 format (contain colons)
364	hasSimpleScope := false
365	for _, s := range rawScopes {
366		if !strings.Contains(s, ":") && (s == "push" || s == "pull" || s == "*") {
367			hasSimpleScope = true
368			break
369		}
370	}
371
372	if !hasSimpleScope {
373		return rawScopes
374	}
375
376	// Convert simple scopes to v2 format: repository:*:push,pull
377	var scopes []string
378	var actions []string
379	for _, s := range rawScopes {
380		if s == "push" || s == "pull" || s == "*" {
381			actions = append(actions, s)
382		} else {
383			// Keep non-simple scopes as-is (e.g., admin scopes)
384			scopes = append(scopes, s)
385		}
386	}
387	if len(actions) > 0 {
388		scopes = append(scopes, "repository:*:"+strings.Join(actions, ","))
389	}
390	return scopes
391}
392
393func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
394	cfg, err := loadAuthConfig(configPath)
395	if err != nil {
396		return err
397	}
398
399	// Parse expiry (supports human-friendly units: d, w, M, y)
400	var expiry time.Duration
401	if expiryStr != "0" {
402		expiry, err = duration.Parse(expiryStr)
403		if err != nil {
404			return fmt.Errorf("invalid expiry duration: %w", err)
405		}
406	}
407
408	scopes := parseAndConvertScopes(scopesStr)
409
410	// Create auth service
411	log := zerowrap.New(zerowrap.Config{Level: "warn"})
412	authSvc, err := createAuthServiceForCLI(cfg, log)
413	if err != nil {
414		return err
415	}
416
417	// Generate token
418	ctx := context.Background()
419	token, err := authSvc.GenerateToken(ctx, subject, scopes, expiry)
420	if err != nil {
421		return fmt.Errorf("failed to generate token: %w", err)
422	}
423
424	fmt.Println("Token generated successfully!")
425	fmt.Printf("Subject: %s\n", subject)
426	fmt.Printf("Scopes: %s\n", strings.Join(scopes, ", "))
427	if expiry == 0 {
428		fmt.Println("Expiry: never")
429	} else {
430		fmt.Printf("Expiry: %s\n", expiry)
431	}
432	fmt.Println()
433	fmt.Println("Token (use as password with docker login):")
434	fmt.Println(token)
435	fmt.Println()
436	fmt.Printf("Usage: docker login -u %s -p <token> <registry>\n", subject)
437
438	return nil
439}
440
441// runTokenList lists all stored tokens.
442func runTokenList(configPath string) error {
443	cfg, err := loadAuthConfig(configPath)
444	if err != nil {
445		return err
446	}
447
448	log := zerowrap.New(zerowrap.Config{Level: "warn"})
449	authSvc, err := createAuthServiceForCLI(cfg, log)
450	if err != nil {
451		return err
452	}
453
454	ctx := context.Background()
455	tokens, err := authSvc.ListTokens(ctx)
456	if err != nil {
457		return fmt.Errorf("failed to list tokens: %w", err)
458	}
459
460	if len(tokens) == 0 {
461		fmt.Println("No tokens found.")
462		return nil
463	}
464
465	fmt.Printf("%-36s  %-20s  %-20s  %-10s\n", "ID", "Subject", "Expires", "Revoked")
466	fmt.Println(strings.Repeat("-", 90))
467
468	for _, t := range tokens {
469		expiry := "never"
470		if !t.ExpiresAt.IsZero() {
471			expiry = t.ExpiresAt.Format("2006-01-02 15:04")
472		}
473		revoked := "no"
474		if t.Revoked {
475			revoked = "yes"
476		}
477		fmt.Printf("%-36s  %-20s  %-20s  %-10s\n", t.ID, t.Subject, expiry, revoked)
478	}
479
480	return nil
481}
482
483// runTokenRevoke revokes a token by ID.
484func runTokenRevoke(tokenID, configPath string) error {
485	cfg, err := loadAuthConfig(configPath)
486	if err != nil {
487		return err
488	}
489
490	log := zerowrap.New(zerowrap.Config{Level: "warn"})
491	authSvc, err := createAuthServiceForCLI(cfg, log)
492	if err != nil {
493		return err
494	}
495
496	ctx := context.Background()
497	if err := authSvc.RevokeToken(ctx, tokenID); err != nil {
498		return fmt.Errorf("failed to revoke token: %w", err)
499	}
500
501	fmt.Printf("Token %s has been revoked.\n", tokenID)
502	return nil
503}
504
505// runTokenRevokeAll revokes all tokens.
506func runTokenRevokeAll(configPath string) error {
507	cfg, err := loadAuthConfig(configPath)
508	if err != nil {
509		return err
510	}
511
512	log := zerowrap.New(zerowrap.Config{Level: "warn"})
513	authSvc, err := createAuthServiceForCLI(cfg, log)
514	if err != nil {
515		return err
516	}
517
518	ctx := context.Background()
519	count, err := authSvc.RevokeAllTokens(ctx)
520	if err != nil {
521		return fmt.Errorf("failed to revoke tokens: %w", err)
522	}
523
524	if count == 0 {
525		fmt.Println("No tokens to revoke.")
526	} else {
527		fmt.Printf("Revoked %d token(s).\n", count)
528	}
529	return nil
530}
531
532// runPasswordHash generates a bcrypt hash for a password.
533func runPasswordHash() error {
534	fmt.Print("Enter password: ")
535
536	// Read password without echo (use os.Stdin.Fd() for better compatibility)
537	passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
538	if err != nil {
539		// Fallback for non-terminal input
540		reader := bufio.NewReader(os.Stdin)
541		password, err := reader.ReadString('\n')
542		if err != nil {
543			return fmt.Errorf("failed to read password: %w", err)
544		}
545		passwordBytes = []byte(strings.TrimSpace(password))
546	}
547	fmt.Println()
548
549	if len(passwordBytes) == 0 {
550		return fmt.Errorf("password cannot be empty")
551	}
552
553	// Create a temporary auth service to generate the hash
554	authSvc := auth.NewService(auth.Config{}, nil, zerowrap.Default())
555	hash, err := authSvc.GeneratePasswordHash(string(passwordBytes))
556	if err != nil {
557		return fmt.Errorf("failed to generate hash: %w", err)
558	}
559
560	fmt.Println()
561	fmt.Println("Bcrypt hash (store in your secrets backend):")
562	fmt.Println(hash)
563	fmt.Println()
564	fmt.Println("Then reference the path in your config:")
565	fmt.Println("  [auth]")
566	fmt.Println("  type = \"password\"")
567	fmt.Println("  password_hash = \"gordon/auth/password_hash\"")
568
569	return nil
570}
571
572// cliConfig holds the configuration needed for CLI commands.
573type cliConfig struct {
574	Backend     domain.SecretsBackend
575	DataDir     string
576	TokenSecret []byte
577}
578
579// loadAuthConfig loads the configuration needed for auth CLI commands.
580func loadAuthConfig(configPath string) (*cliConfig, error) {
581	v := viper.New()
582
583	// Set defaults
584	v.SetDefault("server.data_dir", app.DefaultDataDir())
585	v.SetDefault("auth.secrets_backend", "unsafe")
586
587	app.ConfigureViper(v, configPath)
588
589	if err := v.ReadInConfig(); err != nil {
590		if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
591			return nil, fmt.Errorf("failed to read config file: %w", err)
592		}
593		// Config file not found is OK for CLI commands
594	}
595
596	cfg := &cliConfig{
597		DataDir: v.GetString("server.data_dir"),
598	}
599
600	// Determine backend
601	switch v.GetString("auth.secrets_backend") {
602	case "pass":
603		cfg.Backend = domain.SecretsBackendPass
604	case "sops":
605		cfg.Backend = domain.SecretsBackendSops
606	default:
607		cfg.Backend = domain.SecretsBackendUnsafe
608	}
609
610	// Load token secret from secrets backend
611	tokenSecretPath := v.GetString("auth.token_secret")
612	if tokenSecretPath != "" {
613		// Load actual secret from the configured backend
614		ctx := context.Background()
615		log := zerowrap.New(zerowrap.Config{Level: "warn"})
616
617		switch cfg.Backend {
618		case domain.SecretsBackendPass:
619			provider := secrets.NewPassProvider(log)
620			secret, err := provider.GetSecret(ctx, tokenSecretPath)
621			if err != nil {
622				return nil, fmt.Errorf("failed to load token secret from pass: %w", err)
623			}
624			cfg.TokenSecret = []byte(secret)
625		default:
626			// For unsafe backend, use the path as the secret (backwards compatible)
627			cfg.TokenSecret = []byte(tokenSecretPath)
628		}
629	} else {
630		// Generate a default secret for CLI token generation
631		cfg.TokenSecret = []byte("gordon-cli-default-secret")
632	}
633
634	return cfg, nil
635}
636
637// createAuthServiceForCLI creates an auth service for CLI commands.
638func createAuthServiceForCLI(cfg *cliConfig, log zerowrap.Logger) (*auth.Service, error) {
639	// Create token store
640	store, err := tokenstore.NewStore(cfg.Backend, cfg.DataDir, log)
641	if err != nil {
642		return nil, fmt.Errorf("failed to create token store: %w", err)
643	}
644
645	// Create auth service config
646	authConfig := auth.Config{
647		Enabled:     true,
648		AuthType:    domain.AuthTypeToken,
649		TokenSecret: cfg.TokenSecret,
650	}
651
652	return auth.NewService(authConfig, store, log), nil
653}

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