detached 31d3492

Refactor authentication flow to use JWT instead of token; update related handlers and middleware

- Updated DeviceFlowAuth to return JWT and save it in configuration
- Changed authorization header usage in deploy and HTTP handlers to use JWT
- Refactored CheckAndRefreshAuth and FieldCheck to utilize JWT
- Removed token initialization logic and related functions
- Simplified login page to focus on GitHub OAuth without token input
- Updated middleware to validate JWT and check account existence
- Adjusted error handling and logging for better clarity
- Removed unused functions and cleaned up code for maintainability
commit
31d349285e079b1f30e1ea63d8465d33960e0ad8
author
bamen · · 2025-03-29 08:33 UTC
parent
8d0d173
signature
unsigned
15 files · +298 -388
M go.mod +1 -1
@@ -92,7 +92,7 @@ require (
9292 github.com/goccy/go-json v0.10.4 // indirect
9393 github.com/gofrs/flock v0.12.1 // indirect
9494 github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
95- github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
95+ github.com/golang-jwt/jwt/v5 v5.2.2 // direct
9696 github.com/google/go-querystring v1.1.0 // indirect
9797 github.com/google/s2a-go v0.1.8 // indirect
9898 github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
@@ -92,7 +92,7 @@ require (
92 github.com/goccy/go-json v0.10.4 // indirect92 github.com/goccy/go-json v0.10.4 // indirect
93 github.com/gofrs/flock v0.12.1 // indirect93 github.com/gofrs/flock v0.12.1 // indirect
94 github.com/golang-jwt/jwt/v4 v4.5.1 // indirect94 github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
95- github.com/golang-jwt/jwt/v5 v5.2.1 // indirect95+ github.com/golang-jwt/jwt/v5 v5.2.2 // direct
96 github.com/google/go-querystring v1.1.0 // indirect96 github.com/google/go-querystring v1.1.0 // indirect
97 github.com/google/s2a-go v0.1.8 // indirect97 github.com/google/s2a-go v0.1.8 // indirect
98 github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect98 github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
M go.sum +2 -0 generated file: collapsed
M internal/cli/auth/device_flow.go +21 -11
@@ -12,13 +12,13 @@ import (
1212 "github.com/bnema/gordon/internal/cli"
1313 )
1414
15-func DeviceFlowAuth(a *cli.App) error {
15+func DeviceFlowAuth(a *cli.App) (token string, err error) {
1616 fmt.Println("Starting device flow authentication...")
1717
1818 // Request device code
1919 deviceCode, err := requestDeviceCode(a)
2020 if err != nil {
21- return fmt.Errorf("error requesting device code: %w", err)
21+ return "", fmt.Errorf("error requesting device code: %w", err)
2222 }
2323
2424 fmt.Printf("Please go to %v and enter the code: %v\n", deviceCode["verification_uri"], deviceCode["user_code"])
@@ -29,16 +29,24 @@ func DeviceFlowAuth(a *cli.App) error {
2929 interval = int(i)
3030 }
3131
32- token, err := pollForAccessToken(a, deviceCode["device_code"].(string), interval)
32+ token, err = pollForAccessToken(a, deviceCode["device_code"].(string), interval)
3333 if err != nil {
34- return fmt.Errorf("error polling for access token: %w", err)
34+ return "", fmt.Errorf("error polling for access token: %w", err)
3535 }
3636
37- // Save the token
38- a.Config.SetToken(token)
37+ // Save the obtained JWT into the JWTSecret field in the config
38+ a.Config.General.JwtToken = token
39+ err = a.Config.SaveConfig() // Save the updated config
40+ if err != nil {
41+ // Log the error but don't necessarily fail the whole flow,
42+ // as the token might still be usable in memory for the current session.
43+ fmt.Printf("\nWarning: Failed to save token to config file: %v\n", err)
44+ fmt.Println("Authentication successful, but token might not persist.")
45+ } else {
46+ fmt.Println("Authentication successful! Token saved to configuration.")
47+ }
3948
40- fmt.Println("Authentication successful!")
41- return nil
49+ return token, nil
4250 }
4351
4452 func requestDeviceCode(a *cli.App) (map[string]interface{}, error) {
@@ -96,11 +104,13 @@ func pollForAccessToken(a *cli.App, deviceCode string, interval int) (string, er
96104 continue
97105 }
98106
99- if accessToken, ok := tokenResp["access_token"].(string); ok && accessToken != "" {
100- return accessToken, nil
107+ // Expect "jwt_token" instead of "access_token"
108+ if jwtToken, ok := tokenResp["jwt_token"].(string); ok && jwtToken != "" {
109+ fmt.Println() // Print a newline after successful polling dots
110+ return jwtToken, nil
101111 }
102112
103- if tokenResp["error"] == "authorization_pending" {
113+ if errorMsg, ok := tokenResp["error"].(string); ok && errorMsg == "authorization_pending" {
104114 // Optionally, you can print a dot to show progress without cluttering the output
105115 fmt.Print(".")
106116 continue
@@ -12,13 +12,13 @@ import (
12 "github.com/bnema/gordon/internal/cli"12 "github.com/bnema/gordon/internal/cli"
13 )13 )
14 14
15-func DeviceFlowAuth(a *cli.App) error {15+func DeviceFlowAuth(a *cli.App) (token string, err error) {
16 fmt.Println("Starting device flow authentication...")16 fmt.Println("Starting device flow authentication...")
17 17
18 // Request device code18 // Request device code
19 deviceCode, err := requestDeviceCode(a)19 deviceCode, err := requestDeviceCode(a)
20 if err != nil {20 if err != nil {
21- return fmt.Errorf("error requesting device code: %w", err)21+ return "", fmt.Errorf("error requesting device code: %w", err)
22 }22 }
23 23
24 fmt.Printf("Please go to %v and enter the code: %v\n", deviceCode["verification_uri"], deviceCode["user_code"])24 fmt.Printf("Please go to %v and enter the code: %v\n", deviceCode["verification_uri"], deviceCode["user_code"])
@@ -29,16 +29,24 @@ func DeviceFlowAuth(a *cli.App) error {
29 interval = int(i)29 interval = int(i)
30 }30 }
31 31
32- token, err := pollForAccessToken(a, deviceCode["device_code"].(string), interval)32+ token, err = pollForAccessToken(a, deviceCode["device_code"].(string), interval)
33 if err != nil {33 if err != nil {
34- return fmt.Errorf("error polling for access token: %w", err)34+ return "", fmt.Errorf("error polling for access token: %w", err)
35 }35 }
36 36
37- // Save the token37+ // Save the obtained JWT into the JWTSecret field in the config
38- a.Config.SetToken(token)38+ a.Config.General.JwtToken = token
39+ err = a.Config.SaveConfig() // Save the updated config
40+ if err != nil {
41+ // Log the error but don't necessarily fail the whole flow,
42+ // as the token might still be usable in memory for the current session.
43+ fmt.Printf("\nWarning: Failed to save token to config file: %v\n", err)
44+ fmt.Println("Authentication successful, but token might not persist.")
45+ } else {
46+ fmt.Println("Authentication successful! Token saved to configuration.")
47+ }
39 48
40- fmt.Println("Authentication successful!")49+ return token, nil
41- return nil
42 }50 }
43 51
44 func requestDeviceCode(a *cli.App) (map[string]interface{}, error) {52 func requestDeviceCode(a *cli.App) (map[string]interface{}, error) {
@@ -96,11 +104,13 @@ func pollForAccessToken(a *cli.App, deviceCode string, interval int) (string, er
96 continue104 continue
97 }105 }
98 106
99- if accessToken, ok := tokenResp["access_token"].(string); ok && accessToken != "" {107+ // Expect "jwt_token" instead of "access_token"
100- return accessToken, nil108+ if jwtToken, ok := tokenResp["jwt_token"].(string); ok && jwtToken != "" {
109+ fmt.Println() // Print a newline after successful polling dots
110+ return jwtToken, nil
101 }111 }
102 112
103- if tokenResp["error"] == "authorization_pending" {113+ if errorMsg, ok := tokenResp["error"].(string); ok && errorMsg == "authorization_pending" {
104 // Optionally, you can print a dot to show progress without cluttering the output114 // Optionally, you can print a dot to show progress without cluttering the output
105 fmt.Print(".")115 fmt.Print(".")
106 continue116 continue
M internal/cli/cmd/deploy.go +1 -1
@@ -231,7 +231,7 @@ func checkDeployConflict(a *cli.App, targetDomain string, port string) (*common.
231231 }
232232
233233 // Set auth header
234- req.Header.Set("Authorization", "Bearer "+a.Config.General.Token)
234+ req.Header.Set("Authorization", "Bearer "+a.Config.General.JwtToken)
235235
236236 // Send request
237237 client := &http.Client{
@@ -231,7 +231,7 @@ func checkDeployConflict(a *cli.App, targetDomain string, port string) (*common.
231 }231 }
232 232
233 // Set auth header233 // Set auth header
234- req.Header.Set("Authorization", "Bearer "+a.Config.General.Token)234+ req.Header.Set("Authorization", "Bearer "+a.Config.General.JwtToken)
235 235
236 // Send request236 // Send request
237 client := &http.Client{237 client := &http.Client{
M internal/cli/handler/auth.go +9 -8
@@ -11,7 +11,7 @@ import (
1111 )
1212
1313 func CheckAndRefreshAuth(a *cli.App) error {
14- if a.Config.General.Token == "" {
14+ if a.Config.General.JwtToken == "" {
1515 log.Info("No authentication token found. Initiating authentication...")
1616 return ReAuthenticate(a)
1717 }
@@ -46,19 +46,20 @@ func ReAuthenticate(a *cli.App) error {
4646 // Small delay for visual clarity
4747 time.Sleep(100 * time.Millisecond)
4848
49- err := auth.DeviceFlowAuth(a)
49+ token, err := auth.DeviceFlowAuth(a)
50+
5051 if err != nil {
5152 return fmt.Errorf("device flow authentication failed: %w", err)
5253 }
5354
54- // After successful re-authentication, get the new token
55- newToken, err := a.Config.GetToken()
56- if err != nil {
57- return fmt.Errorf("failed to get new token: %w", err)
55+ // if token is the same as the current one, no need to save
56+ if token == a.Config.General.JwtToken {
57+ fmt.Println("Token is still valid, no changes made.")
58+ return nil
5859 }
5960
60- // Update the token in the app config
61- a.Config.General.Token = newToken
61+ // Update the JWTSecret in the config
62+ a.Config.General.JwtToken = token
6263
6364 // Save the new token to config file
6465 err = a.Config.SaveConfig()
@@ -11,7 +11,7 @@ import (
11 )11 )
12 12
13 func CheckAndRefreshAuth(a *cli.App) error {13 func CheckAndRefreshAuth(a *cli.App) error {
14- if a.Config.General.Token == "" {14+ if a.Config.General.JwtToken == "" {
15 log.Info("No authentication token found. Initiating authentication...")15 log.Info("No authentication token found. Initiating authentication...")
16 return ReAuthenticate(a)16 return ReAuthenticate(a)
17 }17 }
@@ -46,19 +46,20 @@ func ReAuthenticate(a *cli.App) error {
46 // Small delay for visual clarity46 // Small delay for visual clarity
47 time.Sleep(100 * time.Millisecond)47 time.Sleep(100 * time.Millisecond)
48 48
49- err := auth.DeviceFlowAuth(a)49+ token, err := auth.DeviceFlowAuth(a)
50+
50 if err != nil {51 if err != nil {
51 return fmt.Errorf("device flow authentication failed: %w", err)52 return fmt.Errorf("device flow authentication failed: %w", err)
52 }53 }
53 54
54- // After successful re-authentication, get the new token55+ // if token is the same as the current one, no need to save
55- newToken, err := a.Config.GetToken()56+ if token == a.Config.General.JwtToken {
56- if err != nil {57+ fmt.Println("Token is still valid, no changes made.")
57- return fmt.Errorf("failed to get new token: %w", err)58+ return nil
58 }59 }
59 60
60- // Update the token in the app config61+ // Update the JWTSecret in the config
61- a.Config.General.Token = newToken62+ a.Config.General.JwtToken = token
62 63
63 // Save the new token to config file64 // Save the new token to config file
64 err = a.Config.SaveConfig()65 err = a.Config.SaveConfig()
M internal/cli/handler/chunked_transfer.go +1 -1
@@ -225,7 +225,7 @@ var clientChunkStore = &transferStore{
225225 // NewChunkedClient creates a new chunked client
226226 func NewChunkedClient(app *cli.App) *ChunkedClient {
227227 baseURL := app.Config.Http.BackendURL + "/api"
228- token := app.Config.General.Token
228+ token := app.Config.General.JwtToken
229229
230230 return &ChunkedClient{
231231 client: &http.Client{
@@ -225,7 +225,7 @@ var clientChunkStore = &transferStore{
225 // NewChunkedClient creates a new chunked client225 // NewChunkedClient creates a new chunked client
226 func NewChunkedClient(app *cli.App) *ChunkedClient {226 func NewChunkedClient(app *cli.App) *ChunkedClient {
227 baseURL := app.Config.Http.BackendURL + "/api"227 baseURL := app.Config.Http.BackendURL + "/api"
228- token := app.Config.General.Token228+ token := app.Config.General.JwtToken
229 229
230 return &ChunkedClient{230 return &ChunkedClient{
231 client: &http.Client{231 client: &http.Client{
M internal/cli/handler/field_check.go +3 -3
@@ -32,9 +32,9 @@ func FieldCheck(a *cli.App) error {
3232 return err
3333 }
3434
35- if a.Config.General.Token == "" {
35+ if a.Config.General.JwtToken == "" {
3636 fmt.Println("Token is not set. Starting OAuth device flow...")
37- err := auth.DeviceFlowAuth(a)
37+ _, err := auth.DeviceFlowAuth(a)
3838 if err != nil {
3939 fmt.Printf("Device flow authentication error: %v\n", err)
4040 return fmt.Errorf("failed to complete device flow authentication: %w", err)
@@ -42,7 +42,7 @@ func FieldCheck(a *cli.App) error {
4242 }
4343
4444 // Save config if BackendURL was empty or if a new token was set
45- if wasBackendURLEmpty || a.Config.General.Token != "" {
45+ if wasBackendURLEmpty || a.Config.General.JwtToken != "" {
4646 err = a.Config.SaveConfig()
4747 if err != nil {
4848 return err
@@ -32,9 +32,9 @@ func FieldCheck(a *cli.App) error {
32 return err32 return err
33 }33 }
34 34
35- if a.Config.General.Token == "" {35+ if a.Config.General.JwtToken == "" {
36 fmt.Println("Token is not set. Starting OAuth device flow...")36 fmt.Println("Token is not set. Starting OAuth device flow...")
37- err := auth.DeviceFlowAuth(a)37+ _, err := auth.DeviceFlowAuth(a)
38 if err != nil {38 if err != nil {
39 fmt.Printf("Device flow authentication error: %v\n", err)39 fmt.Printf("Device flow authentication error: %v\n", err)
40 return fmt.Errorf("failed to complete device flow authentication: %w", err)40 return fmt.Errorf("failed to complete device flow authentication: %w", err)
@@ -42,7 +42,7 @@ func FieldCheck(a *cli.App) error {
42 }42 }
43 43
44 // Save config if BackendURL was empty or if a new token was set44 // Save config if BackendURL was empty or if a new token was set
45- if wasBackendURLEmpty || a.Config.General.Token != "" {45+ if wasBackendURLEmpty || a.Config.General.JwtToken != "" {
46 err = a.Config.SaveConfig()46 err = a.Config.SaveConfig()
47 if err != nil {47 if err != nil {
48 return err48 return err
M internal/cli/handler/http.go +2 -1
@@ -48,7 +48,8 @@ func SendHTTPRequest(a *cli.App, rp *common.RequestPayload, method string, endpo
4848 reauthenticated := false
4949
5050 for {
51- req, err := createRequest(apiUrl, endpoint, method, rp, a.Config.General.Token)
51+ // Use JWTSecret (which now holds the CLI JWT) for authentication
52+ req, err := createRequest(apiUrl, endpoint, method, rp, a.Config.General.JwtToken)
5253 if err != nil {
5354 return nil, fmt.Errorf("failed to create request: %w", err)
5455 }
@@ -48,7 +48,8 @@ func SendHTTPRequest(a *cli.App, rp *common.RequestPayload, method string, endpo
48 reauthenticated := false48 reauthenticated := false
49 49
50 for {50 for {
51- req, err := createRequest(apiUrl, endpoint, method, rp, a.Config.General.Token)51+ // Use JWTSecret (which now holds the CLI JWT) for authentication
52+ req, err := createRequest(apiUrl, endpoint, method, rp, a.Config.General.JwtToken)
52 if err != nil {53 if err != nil {
53 return nil, fmt.Errorf("failed to create request: %w", err)54 return nil, fmt.Errorf("failed to create request: %w", err)
54 }55 }
M internal/cli/handler/start_server.go +0 -5
@@ -49,11 +49,6 @@ func StartServer(a *server.App, port string) error {
4949 // Start the session cleaner cron job
5050 a.StartSessionCleaner()
5151
52- _, err = server.HandleNewTokenInitialization(a)
53- if err != nil {
54- logger.Error("Token initialization error", "error", err)
55- }
56-
5752 // Initialize and start the reverse proxy using the already loaded configuration
5853 p, err := httpserve.InitializeProxy(a)
5954 if err != nil {
@@ -49,11 +49,6 @@ func StartServer(a *server.App, port string) error {
49 // Start the session cleaner cron job49 // Start the session cleaner cron job
50 a.StartSessionCleaner()50 a.StartSessionCleaner()
51 51
52- _, err = server.HandleNewTokenInitialization(a)
53- if err != nil {
54- logger.Error("Token initialization error", "error", err)
55- }
56-
57 // Initialize and start the reverse proxy using the already loaded configuration52 // Initialize and start the reverse proxy using the already loaded configuration
58 p, err := httpserve.InitializeProxy(a)53 p, err := httpserve.InitializeProxy(a)
59 if err != nil {54 if err != nil {
M internal/common/init_config.go +43 -19
@@ -11,11 +11,23 @@ import (
1111 "strings"
1212 "sync"
1313
14+ "crypto/rand"
15+ "encoding/hex"
16+
1417 "github.com/bnema/gordon/pkg/docker"
1518 "github.com/bnema/gordon/pkg/logger"
1619 "github.com/bnema/gordon/pkg/parser"
1720 )
1821
22+// generateRandomSecret generates a secure random string of the specified byte length, hex-encoded.
23+func generateRandomSecret(length int) (string, error) {
24+ bytes := make([]byte, length)
25+ if _, err := rand.Read(bytes); err != nil {
26+ return "", err
27+ }
28+ return hex.EncodeToString(bytes), nil
29+}
30+
1931 var (
2032 globalConfig *Config
2133 globalConfigOnce sync.Once
@@ -53,9 +65,9 @@ type AdminConfig struct {
5365
5466 type GeneralConfig struct {
5567 StorageDir string `yaml:"storageDir"`
56- Token string `yaml:"token"`
5768 LogLevel string `yaml:"logLevel"`
5869 GordonContainerID string `yaml:"-"`
70+ JwtToken string `yaml:"jwtToken"`
5971 }
6072
6173 type HttpConfig struct {
@@ -455,12 +467,7 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
455467 logger.Info("Using environment variable GORDON_STORAGE_DIR", "value", val)
456468 }
457469 }
458- if val := os.Getenv("GORDON_TOKEN"); val != "" {
459- config.General.Token = val
460- if printLogs {
461- logger.Info("Using environment variable GORDON_TOKEN")
462- }
463- }
470+ // Removed GORDON_TOKEN handling
464471 if val := os.Getenv("GORDON_LOG_LEVEL"); val != "" {
465472 config.General.LogLevel = val
466473 if printLogs {
@@ -1020,6 +1027,32 @@ func (config *Config) LoadConfig() (*Config, error) {
10201027 logger.Debug("Log level set from configuration", "level", config.General.LogLevel)
10211028 }
10221029
1030+ // --- Generate JWT Secret if missing ---
1031+ if config.General.JwtToken == "" {
1032+ logger.Warn("JWTSecret is missing from configuration, generating a new one...")
1033+ newSecret, err := generateRandomSecret(32) // Generate a 256-bit key (32 bytes)
1034+ if err != nil {
1035+ logger.Error("Failed to generate JWT secret", "error", err)
1036+ // Decide if this is a fatal error or if we can continue without JWT functionality
1037+ // For now, let's return the error as JWT is crucial for API auth
1038+ return nil, fmt.Errorf("failed to generate JWT secret: %w", err)
1039+ }
1040+ config.General.JwtToken = newSecret
1041+ logger.Info("Generated new JWTSecret")
1042+
1043+ // Save the config immediately to store the new secret
1044+ err = config.SaveConfig()
1045+ if err != nil {
1046+ logger.Error("Failed to save configuration after generating JWT secret", "error", err)
1047+ // Return error as saving failed
1048+ return nil, fmt.Errorf("failed to save config with new JWT secret: %w", err)
1049+ }
1050+ logger.Info("Saved configuration with new JWTSecret")
1051+ } else {
1052+ logger.Debug("JWTSecret found in configuration")
1053+ }
1054+ // --- End JWT Secret Generation ---
1055+
10231056 logger.Debug("LoadConfig: Completed LoadConfig function")
10241057 return config, nil
10251058 }
@@ -1054,16 +1087,6 @@ func fileExists(filepath string) bool {
10541087 return err == nil
10551088 }
10561089
1057-func (config *Config) GetToken() (string, error) {
1058- token := config.General.Token
1059- if token == "" {
1060- return "", fmt.Errorf("no token found in config.yml")
1061- }
1062-
1063- return token, nil
1064-
1065-}
1066-
10671090 // GetRunEnv returns the run environment
10681091 func (config *BuildConfig) GetRunEnv() string {
10691092 return config.RunEnv
@@ -1078,8 +1101,9 @@ func (c *Config) GetBackendURL() string {
10781101 return c.Http.BackendURL
10791102 }
10801103
1081-func (c *Config) SetToken(token string) {
1082- c.General.Token = token
1104+// GetToken returns the token
1105+func (c *Config) GetToken() string {
1106+ return c.General.JwtToken
10831107 }
10841108
10851109 func isWSL() bool {
@@ -11,11 +11,23 @@ import (
11 "strings"11 "strings"
12 "sync"12 "sync"
13 13
14+ "crypto/rand"
15+ "encoding/hex"
16+
14 "github.com/bnema/gordon/pkg/docker"17 "github.com/bnema/gordon/pkg/docker"
15 "github.com/bnema/gordon/pkg/logger"18 "github.com/bnema/gordon/pkg/logger"
16 "github.com/bnema/gordon/pkg/parser"19 "github.com/bnema/gordon/pkg/parser"
17 )20 )
18 21
22+// generateRandomSecret generates a secure random string of the specified byte length, hex-encoded.
23+func generateRandomSecret(length int) (string, error) {
24+ bytes := make([]byte, length)
25+ if _, err := rand.Read(bytes); err != nil {
26+ return "", err
27+ }
28+ return hex.EncodeToString(bytes), nil
29+}
30+
19 var (31 var (
20 globalConfig *Config32 globalConfig *Config
21 globalConfigOnce sync.Once33 globalConfigOnce sync.Once
@@ -53,9 +65,9 @@ type AdminConfig struct {
53 65
54 type GeneralConfig struct {66 type GeneralConfig struct {
55 StorageDir string `yaml:"storageDir"`67 StorageDir string `yaml:"storageDir"`
56- Token string `yaml:"token"`
57 LogLevel string `yaml:"logLevel"`68 LogLevel string `yaml:"logLevel"`
58 GordonContainerID string `yaml:"-"`69 GordonContainerID string `yaml:"-"`
70+ JwtToken string `yaml:"jwtToken"`
59 }71 }
60 72
61 type HttpConfig struct {73 type HttpConfig struct {
@@ -455,12 +467,7 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
455 logger.Info("Using environment variable GORDON_STORAGE_DIR", "value", val)467 logger.Info("Using environment variable GORDON_STORAGE_DIR", "value", val)
456 }468 }
457 }469 }
458- if val := os.Getenv("GORDON_TOKEN"); val != "" {470+ // Removed GORDON_TOKEN handling
459- config.General.Token = val
460- if printLogs {
461- logger.Info("Using environment variable GORDON_TOKEN")
462- }
463- }
464 if val := os.Getenv("GORDON_LOG_LEVEL"); val != "" {471 if val := os.Getenv("GORDON_LOG_LEVEL"); val != "" {
465 config.General.LogLevel = val472 config.General.LogLevel = val
466 if printLogs {473 if printLogs {
@@ -1020,6 +1027,32 @@ func (config *Config) LoadConfig() (*Config, error) {
1020 logger.Debug("Log level set from configuration", "level", config.General.LogLevel)1027 logger.Debug("Log level set from configuration", "level", config.General.LogLevel)
1021 }1028 }
1022 1029
1030+ // --- Generate JWT Secret if missing ---
1031+ if config.General.JwtToken == "" {
1032+ logger.Warn("JWTSecret is missing from configuration, generating a new one...")
1033+ newSecret, err := generateRandomSecret(32) // Generate a 256-bit key (32 bytes)
1034+ if err != nil {
1035+ logger.Error("Failed to generate JWT secret", "error", err)
1036+ // Decide if this is a fatal error or if we can continue without JWT functionality
1037+ // For now, let's return the error as JWT is crucial for API auth
1038+ return nil, fmt.Errorf("failed to generate JWT secret: %w", err)
1039+ }
1040+ config.General.JwtToken = newSecret
1041+ logger.Info("Generated new JWTSecret")
1042+
1043+ // Save the config immediately to store the new secret
1044+ err = config.SaveConfig()
1045+ if err != nil {
1046+ logger.Error("Failed to save configuration after generating JWT secret", "error", err)
1047+ // Return error as saving failed
1048+ return nil, fmt.Errorf("failed to save config with new JWT secret: %w", err)
1049+ }
1050+ logger.Info("Saved configuration with new JWTSecret")
1051+ } else {
1052+ logger.Debug("JWTSecret found in configuration")
1053+ }
1054+ // --- End JWT Secret Generation ---
1055+
1023 logger.Debug("LoadConfig: Completed LoadConfig function")1056 logger.Debug("LoadConfig: Completed LoadConfig function")
1024 return config, nil1057 return config, nil
1025 }1058 }
@@ -1054,16 +1087,6 @@ func fileExists(filepath string) bool {
1054 return err == nil1087 return err == nil
1055 }1088 }
1056 1089
1057-func (config *Config) GetToken() (string, error) {
1058- token := config.General.Token
1059- if token == "" {
1060- return "", fmt.Errorf("no token found in config.yml")
1061- }
1062-
1063- return token, nil
1064-
1065-}
1066-
1067 // GetRunEnv returns the run environment1090 // GetRunEnv returns the run environment
1068 func (config *BuildConfig) GetRunEnv() string {1091 func (config *BuildConfig) GetRunEnv() string {
1069 return config.RunEnv1092 return config.RunEnv
@@ -1078,8 +1101,9 @@ func (c *Config) GetBackendURL() string {
1078 return c.Http.BackendURL1101 return c.Http.BackendURL
1079 }1102 }
1080 1103
1081-func (c *Config) SetToken(token string) {1104+// GetToken returns the token
1082- c.General.Token = token1105+func (c *Config) GetToken() string {
1106+ return c.General.JwtToken
1083 }1107 }
1084 1108
1085 func isWSL() bool {1109 func isWSL() bool {
M internal/httpserve/handlers/login.go +131 -65
@@ -15,90 +15,61 @@ import (
1515 "github.com/bnema/gordon/internal/server"
1616 "github.com/bnema/gordon/internal/templating/render"
1717 "github.com/bnema/gordon/pkg/logger"
18+ "github.com/golang-jwt/jwt/v5"
1819 "github.com/gorilla/sessions"
1920 _ "github.com/joho/godotenv/autoload"
2021 "github.com/labstack/echo-contrib/session"
2122 "github.com/labstack/echo/v4"
2223 )
2324
24-var urlToken string
25-
26-// compareGordonToken compares the token from the URL query parameter with the one from the config.yml
27-func CompareGordonToken(c echo.Context, a *server.App) error {
28- logger.Debug("Starting token comparison")
29- configToken, err := a.Config.GetToken()
30- if err != nil {
31- logger.Error("Error retrieving token from config", "error", err)
32- return err
33- }
34-
35- logger.Debug("Comparing tokens", "url_token_exists", urlToken != "", "config_token_exists", configToken != "")
36- if urlToken != configToken {
37- logger.Warn("Token mismatch or empty", "url_token_empty", urlToken == "")
38- return fmt.Errorf("token is empty or not valid")
39- }
40-
41- logger.Debug("Token validation successful")
42- return nil
25+// --- JWT Claims ---
26+type JwtCustomClaims struct {
27+ AccountID string `json:"account_id"`
28+ jwt.RegisteredClaims
4329 }
4430
45-// HandleTokenSubmission handles the token form submission
46-func HandleTokenSubmission(c echo.Context, a *server.App) error {
47- // Get the token from the form
48- formToken := c.FormValue("token")
49- if formToken == "" {
50- return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login?error=empty_token")
51- }
52-
53- // Store the token in the urlToken variable for compatibility with existing code
54- urlToken = formToken
55-
56- // Validate the token
57- configToken, err := a.Config.GetToken()
58- if err != nil {
59- logger.Debug("Error getting token from config", "error", err)
60- return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login?error=server_error")
61- }
62-
63- if urlToken != configToken {
64- logger.Debug("Invalid token provided", "token", urlToken)
65- return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login?error=invalid_token")
66- }
67-
68- // Redirect to GitHub OAuth
69- return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login/oauth/github")
70-}
71-
72-// RenderLoginPage renders the login.html template
31+// RenderLoginPage renders the login.html template (or redirects if already logged in)
32+// NOTE: This function might be superseded by RenderTemplLoginPage depending on router setup.
33+// Keeping it for now, but it needs adjustment if used.
7334 func RenderLoginPage(c echo.Context, a *server.App) error {
74- // Check for token in query param for backward compatibility
75- urlToken = c.QueryParam("token")
35+ // Check if user is already authenticated via session
36+ sess, _ := getSession(c) // Ignore error for check
37+ if sess != nil && sess.Values != nil {
38+ if authenticated, ok := sess.Values["authenticated"].(bool); ok && authenticated {
39+ // If authenticated, redirect to admin panel
40+ logger.Debug("User already authenticated, redirecting to admin panel")
41+ return c.Redirect(http.StatusFound, a.Config.Admin.Path)
42+ }
43+ }
7644
77- // Check for error message
45+ // Check for error message from previous attempts (e.g., GitHub auth failure)
7846 errorMsg := c.QueryParam("error")
7947
8048 data := map[string]interface{}{
8149 "Title": "Login",
8250 "Error": errorMsg,
51+ // Add other necessary data for the template
8352 }
8453
8554 // Navigate inside the fs.FS to get the template
55+ // TODO: Verify the correct template path and name (e.g., "html/login/index.gohtml")
8656 path := "html/login"
8757 rendererData, err := render.GetHTMLRenderer(path, "index.gohtml", a.TemplateFS, a)
8858 if err != nil {
89- return err
59+ logger.Error("Failed to get HTML renderer for login page", "error", err)
60+ return err // Or render a generic error page
9061 }
9162
9263 renderedHTML, err := rendererData.Render(data, a)
9364 if err != nil {
94- return err
65+ logger.Error("Failed to render login page HTML", "error", err)
66+ return err // Or render a generic error page
9567 }
9668
97- return c.HTML(200, renderedHTML)
69+ return c.HTML(http.StatusOK, renderedHTML)
9870 }
9971
10072 // StartOAuthGithub starts the Github OAuth flow.
101-
10273 func StartOAuthGithub(c echo.Context, a *server.App) error {
10374 logger.Info("Attempting to get an existing session")
10475 sess, err := getSession(c)
@@ -215,7 +186,7 @@ func OAuthCallback(c echo.Context, a *server.App) error {
215186 }
216187 logger.Debug("Retrieved GitHub user details", "login", userInfo.Login, "email_count", len(userInfo.Emails))
217188
218- user, accountID, session, err := handleUser(c, a, accessToken, browserInfo, userInfo)
189+ user, accountID, session, err := handleUser(a, accessToken, browserInfo, userInfo)
219190 if err != nil {
220191 logger.Error("Failed to handle user", "error", err)
221192 return err
@@ -257,7 +228,7 @@ func getSession(c echo.Context) (*sessions.Session, error) {
257228 }
258229
259230 // handleUser handles the user creation or update
260-func handleUser(c echo.Context, a *server.App, accessToken, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) {
231+func handleUser(a *server.App, accessToken, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) {
261232 logger.Debug("Starting handleUser function", "github_login", userInfo.Login)
262233
263234 // --- Check provider count ---
@@ -272,15 +243,8 @@ func handleUser(c echo.Context, a *server.App, accessToken, browserInfo string,
272243 if providerCount == 0 {
273244 logger.Info("First GitHub login detected. Linking to seeded admin account.")
274245
275- // Compare the Gordon token (still need admin token for the *very first* setup)
276- err := CompareGordonToken(c, a)
277- if err != nil {
278- logger.Error("Admin token comparison failed for first login", "error", err)
279- return nil, "", nil, echo.NewHTTPError(http.StatusUnauthorized, "Admin token is empty or not valid for initial setup")
280- }
281- logger.Debug("Admin token comparison successful for first login.")
282-
283246 // --- Link to Seeded Admin ---
247+ // No token check needed anymore, proceed directly to linking.
284248 seededUser, seededAccountID, session, err := linkProviderToSeededAdmin(a, accessToken, browserInfo, userInfo)
285249 if err != nil {
286250 logger.Error("Failed to link provider to seeded admin", "error", err)
@@ -488,5 +452,107 @@ func DeviceTokenRequest(c echo.Context, a *server.App) error {
488452 return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to decode response"})
489453 }
490454
491- return c.JSON(http.StatusOK, tokenResp)
455+ // --- Start JWT Generation ---
456+ // Extract the access token from the response
457+ githubAccessToken, ok := tokenResp["access_token"].(string)
458+ if !ok || githubAccessToken == "" {
459+ logger.Error("GitHub access token not found or invalid in device token response", "response", tokenResp)
460+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to retrieve GitHub token"})
461+ }
462+ logger.Debug("Retrieved GitHub access token via device flow")
463+
464+ // Get GitHub user details using the access token
465+ userInfo, err := githubGetUserDetailsWithToken(githubAccessToken)
466+ if err != nil {
467+ logger.Error("Failed to get GitHub user details using device flow token", "error", err)
468+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch GitHub user details"})
469+ }
470+ logger.Debug("Retrieved GitHub user details via device flow", "login", userInfo.Login)
471+
472+ // Find the user in the database based on GitHub login
473+ _, accountID, err := queries.GetUserByProviderLogin(a.DB, "github", userInfo.Login)
474+ if err != nil {
475+ if err == sql.ErrNoRows {
476+ logger.Warn("Unauthorized GitHub user trying to get CLI token via device flow.", "github_login", userInfo.Login)
477+ return c.JSON(http.StatusUnauthorized, map[string]string{"error": "This GitHub account is not authorized."})
478+ }
479+ logger.Error("Error checking for existing provider by login during device flow", "error", err)
480+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Database error during user lookup"})
481+ }
482+ logger.Info("Found matching user for device flow", "account_id", accountID, "github_login", userInfo.Login)
483+
484+ // Generate JWT
485+ jwtToken, err := generateJWT(accountID, a.Config.General.JwtToken)
486+ if err != nil {
487+ logger.Error("Failed to generate JWT for device flow", "error", err, "account_id", accountID)
488+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate authentication token"})
489+ }
490+ logger.Info("Generated JWT for CLI device flow", "account_id", accountID)
491+
492+ // Return the JWT
493+ return c.JSON(http.StatusOK, map[string]string{"jwt_token": jwtToken})
494+ // --- End JWT Generation ---
495+}
496+
497+// githubGetUserDetailsWithToken gets user details using a provided token
498+// (Similar to githubGetUserDetails but takes token as argument)
499+func githubGetUserDetailsWithToken(accessToken string) (*db.GithubUserInfo, error) {
500+ var userInfo *db.GithubUserInfo // Initialize userInfo
501+
502+ // Fetch user info
503+ err := fetchGithubAPI("https://api.github.com/user", "Bearer "+accessToken, &userInfo)
504+ if err != nil {
505+ return nil, fmt.Errorf("failed to fetch user info: %w", err)
506+ }
507+ if userInfo == nil {
508+ return nil, fmt.Errorf("received nil user info from GitHub API")
509+ }
510+
511+ // Fetch user emails
512+ var emailObjects []struct {
513+ Email string `json:"email"`
514+ }
515+ err = fetchGithubAPI("https://api.github.com/user/emails", "Bearer "+accessToken, &emailObjects)
516+ if err != nil {
517+ // Log email fetch error but continue, email might not be needed or primary
518+ logger.Warn("Failed to fetch user emails from GitHub API", "error", err)
519+ } else {
520+ // Extract emails and populate the Emails field in userInfo
521+ userInfo.Emails = []string{} // Ensure Emails slice is initialized
522+ for _, emailObj := range emailObjects {
523+ userInfo.Emails = append(userInfo.Emails, emailObj.Email)
524+ }
525+ }
526+
527+ return userInfo, nil
528+}
529+
530+// generateJWT creates a new JWT token for the given account ID
531+func generateJWT(accountID string, secret string) (string, error) {
532+ if secret == "" {
533+ return "", fmt.Errorf("JWT secret is not configured")
534+ }
535+ // Set custom claims
536+ claims := &JwtCustomClaims{
537+ AccountID: accountID,
538+ RegisteredClaims: jwt.RegisteredClaims{
539+ // Set token expiration (e.g., 24 hours)
540+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 30)), // 30 days validity for CLI token
541+ IssuedAt: jwt.NewNumericDate(time.Now()),
542+ NotBefore: jwt.NewNumericDate(time.Now()),
543+ Issuer: "gordon",
544+ Subject: "cli_auth",
545+ },
546+ }
547+
548+ // Create token with claims
549+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
550+
551+ // Generate encoded token and return it
552+ t, err := token.SignedString([]byte(secret))
553+ if err != nil {
554+ return "", fmt.Errorf("failed to sign JWT token: %w", err)
555+ }
556+
557+ return t, nil
492558 }
@@ -15,90 +15,61 @@ import (
15 "github.com/bnema/gordon/internal/server"15 "github.com/bnema/gordon/internal/server"
16 "github.com/bnema/gordon/internal/templating/render"16 "github.com/bnema/gordon/internal/templating/render"
17 "github.com/bnema/gordon/pkg/logger"17 "github.com/bnema/gordon/pkg/logger"
18+ "github.com/golang-jwt/jwt/v5"
18 "github.com/gorilla/sessions"19 "github.com/gorilla/sessions"
19 _ "github.com/joho/godotenv/autoload"20 _ "github.com/joho/godotenv/autoload"
20 "github.com/labstack/echo-contrib/session"21 "github.com/labstack/echo-contrib/session"
21 "github.com/labstack/echo/v4"22 "github.com/labstack/echo/v4"
22 )23 )
23 24
24-var urlToken string25+// --- JWT Claims ---
25-26+type JwtCustomClaims struct {
26-// compareGordonToken compares the token from the URL query parameter with the one from the config.yml27+ AccountID string `json:"account_id"`
27-func CompareGordonToken(c echo.Context, a *server.App) error {28+ jwt.RegisteredClaims
28- logger.Debug("Starting token comparison")
29- configToken, err := a.Config.GetToken()
30- if err != nil {
31- logger.Error("Error retrieving token from config", "error", err)
32- return err
33- }
34-
35- logger.Debug("Comparing tokens", "url_token_exists", urlToken != "", "config_token_exists", configToken != "")
36- if urlToken != configToken {
37- logger.Warn("Token mismatch or empty", "url_token_empty", urlToken == "")
38- return fmt.Errorf("token is empty or not valid")
39- }
40-
41- logger.Debug("Token validation successful")
42- return nil
43 }29 }
44 30
45-// HandleTokenSubmission handles the token form submission31+// RenderLoginPage renders the login.html template (or redirects if already logged in)
46-func HandleTokenSubmission(c echo.Context, a *server.App) error {32+// NOTE: This function might be superseded by RenderTemplLoginPage depending on router setup.
47- // Get the token from the form33+// Keeping it for now, but it needs adjustment if used.
48- formToken := c.FormValue("token")
49- if formToken == "" {
50- return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login?error=empty_token")
51- }
52-
53- // Store the token in the urlToken variable for compatibility with existing code
54- urlToken = formToken
55-
56- // Validate the token
57- configToken, err := a.Config.GetToken()
58- if err != nil {
59- logger.Debug("Error getting token from config", "error", err)
60- return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login?error=server_error")
61- }
62-
63- if urlToken != configToken {
64- logger.Debug("Invalid token provided", "token", urlToken)
65- return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login?error=invalid_token")
66- }
67-
68- // Redirect to GitHub OAuth
69- return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login/oauth/github")
70-}
71-
72-// RenderLoginPage renders the login.html template
73 func RenderLoginPage(c echo.Context, a *server.App) error {34 func RenderLoginPage(c echo.Context, a *server.App) error {
74- // Check for token in query param for backward compatibility35+ // Check if user is already authenticated via session
75- urlToken = c.QueryParam("token")36+ sess, _ := getSession(c) // Ignore error for check
37+ if sess != nil && sess.Values != nil {
38+ if authenticated, ok := sess.Values["authenticated"].(bool); ok && authenticated {
39+ // If authenticated, redirect to admin panel
40+ logger.Debug("User already authenticated, redirecting to admin panel")
41+ return c.Redirect(http.StatusFound, a.Config.Admin.Path)
42+ }
43+ }
76 44
77- // Check for error message45+ // Check for error message from previous attempts (e.g., GitHub auth failure)
78 errorMsg := c.QueryParam("error")46 errorMsg := c.QueryParam("error")
79 47
80 data := map[string]interface{}{48 data := map[string]interface{}{
81 "Title": "Login",49 "Title": "Login",
82 "Error": errorMsg,50 "Error": errorMsg,
51+ // Add other necessary data for the template
83 }52 }
84 53
85 // Navigate inside the fs.FS to get the template54 // Navigate inside the fs.FS to get the template
55+ // TODO: Verify the correct template path and name (e.g., "html/login/index.gohtml")
86 path := "html/login"56 path := "html/login"
87 rendererData, err := render.GetHTMLRenderer(path, "index.gohtml", a.TemplateFS, a)57 rendererData, err := render.GetHTMLRenderer(path, "index.gohtml", a.TemplateFS, a)
88 if err != nil {58 if err != nil {
89- return err59+ logger.Error("Failed to get HTML renderer for login page", "error", err)
60+ return err // Or render a generic error page
90 }61 }
91 62
92 renderedHTML, err := rendererData.Render(data, a)63 renderedHTML, err := rendererData.Render(data, a)
93 if err != nil {64 if err != nil {
94- return err65+ logger.Error("Failed to render login page HTML", "error", err)
66+ return err // Or render a generic error page
95 }67 }
96 68
97- return c.HTML(200, renderedHTML)69+ return c.HTML(http.StatusOK, renderedHTML)
98 }70 }
99 71
100 // StartOAuthGithub starts the Github OAuth flow.72 // StartOAuthGithub starts the Github OAuth flow.
101-
102 func StartOAuthGithub(c echo.Context, a *server.App) error {73 func StartOAuthGithub(c echo.Context, a *server.App) error {
103 logger.Info("Attempting to get an existing session")74 logger.Info("Attempting to get an existing session")
104 sess, err := getSession(c)75 sess, err := getSession(c)
@@ -215,7 +186,7 @@ func OAuthCallback(c echo.Context, a *server.App) error {
215 }186 }
216 logger.Debug("Retrieved GitHub user details", "login", userInfo.Login, "email_count", len(userInfo.Emails))187 logger.Debug("Retrieved GitHub user details", "login", userInfo.Login, "email_count", len(userInfo.Emails))
217 188
218- user, accountID, session, err := handleUser(c, a, accessToken, browserInfo, userInfo)189+ user, accountID, session, err := handleUser(a, accessToken, browserInfo, userInfo)
219 if err != nil {190 if err != nil {
220 logger.Error("Failed to handle user", "error", err)191 logger.Error("Failed to handle user", "error", err)
221 return err192 return err
@@ -257,7 +228,7 @@ func getSession(c echo.Context) (*sessions.Session, error) {
257 }228 }
258 229
259 // handleUser handles the user creation or update230 // handleUser handles the user creation or update
260-func handleUser(c echo.Context, a *server.App, accessToken, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) {231+func handleUser(a *server.App, accessToken, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) {
261 logger.Debug("Starting handleUser function", "github_login", userInfo.Login)232 logger.Debug("Starting handleUser function", "github_login", userInfo.Login)
262 233
263 // --- Check provider count ---234 // --- Check provider count ---
@@ -272,15 +243,8 @@ func handleUser(c echo.Context, a *server.App, accessToken, browserInfo string,
272 if providerCount == 0 {243 if providerCount == 0 {
273 logger.Info("First GitHub login detected. Linking to seeded admin account.")244 logger.Info("First GitHub login detected. Linking to seeded admin account.")
274 245
275- // Compare the Gordon token (still need admin token for the *very first* setup)
276- err := CompareGordonToken(c, a)
277- if err != nil {
278- logger.Error("Admin token comparison failed for first login", "error", err)
279- return nil, "", nil, echo.NewHTTPError(http.StatusUnauthorized, "Admin token is empty or not valid for initial setup")
280- }
281- logger.Debug("Admin token comparison successful for first login.")
282-
283 // --- Link to Seeded Admin ---246 // --- Link to Seeded Admin ---
247+ // No token check needed anymore, proceed directly to linking.
284 seededUser, seededAccountID, session, err := linkProviderToSeededAdmin(a, accessToken, browserInfo, userInfo)248 seededUser, seededAccountID, session, err := linkProviderToSeededAdmin(a, accessToken, browserInfo, userInfo)
285 if err != nil {249 if err != nil {
286 logger.Error("Failed to link provider to seeded admin", "error", err)250 logger.Error("Failed to link provider to seeded admin", "error", err)
@@ -488,5 +452,107 @@ func DeviceTokenRequest(c echo.Context, a *server.App) error {
488 return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to decode response"})452 return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to decode response"})
489 }453 }
490 454
491- return c.JSON(http.StatusOK, tokenResp)455+ // --- Start JWT Generation ---
456+ // Extract the access token from the response
457+ githubAccessToken, ok := tokenResp["access_token"].(string)
458+ if !ok || githubAccessToken == "" {
459+ logger.Error("GitHub access token not found or invalid in device token response", "response", tokenResp)
460+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to retrieve GitHub token"})
461+ }
462+ logger.Debug("Retrieved GitHub access token via device flow")
463+
464+ // Get GitHub user details using the access token
465+ userInfo, err := githubGetUserDetailsWithToken(githubAccessToken)
466+ if err != nil {
467+ logger.Error("Failed to get GitHub user details using device flow token", "error", err)
468+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch GitHub user details"})
469+ }
470+ logger.Debug("Retrieved GitHub user details via device flow", "login", userInfo.Login)
471+
472+ // Find the user in the database based on GitHub login
473+ _, accountID, err := queries.GetUserByProviderLogin(a.DB, "github", userInfo.Login)
474+ if err != nil {
475+ if err == sql.ErrNoRows {
476+ logger.Warn("Unauthorized GitHub user trying to get CLI token via device flow.", "github_login", userInfo.Login)
477+ return c.JSON(http.StatusUnauthorized, map[string]string{"error": "This GitHub account is not authorized."})
478+ }
479+ logger.Error("Error checking for existing provider by login during device flow", "error", err)
480+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Database error during user lookup"})
481+ }
482+ logger.Info("Found matching user for device flow", "account_id", accountID, "github_login", userInfo.Login)
483+
484+ // Generate JWT
485+ jwtToken, err := generateJWT(accountID, a.Config.General.JwtToken)
486+ if err != nil {
487+ logger.Error("Failed to generate JWT for device flow", "error", err, "account_id", accountID)
488+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate authentication token"})
489+ }
490+ logger.Info("Generated JWT for CLI device flow", "account_id", accountID)
491+
492+ // Return the JWT
493+ return c.JSON(http.StatusOK, map[string]string{"jwt_token": jwtToken})
494+ // --- End JWT Generation ---
495+}
496+
497+// githubGetUserDetailsWithToken gets user details using a provided token
498+// (Similar to githubGetUserDetails but takes token as argument)
499+func githubGetUserDetailsWithToken(accessToken string) (*db.GithubUserInfo, error) {
500+ var userInfo *db.GithubUserInfo // Initialize userInfo
501+
502+ // Fetch user info
503+ err := fetchGithubAPI("https://api.github.com/user", "Bearer "+accessToken, &userInfo)
504+ if err != nil {
505+ return nil, fmt.Errorf("failed to fetch user info: %w", err)
506+ }
507+ if userInfo == nil {
508+ return nil, fmt.Errorf("received nil user info from GitHub API")
509+ }
510+
511+ // Fetch user emails
512+ var emailObjects []struct {
513+ Email string `json:"email"`
514+ }
515+ err = fetchGithubAPI("https://api.github.com/user/emails", "Bearer "+accessToken, &emailObjects)
516+ if err != nil {
517+ // Log email fetch error but continue, email might not be needed or primary
518+ logger.Warn("Failed to fetch user emails from GitHub API", "error", err)
519+ } else {
520+ // Extract emails and populate the Emails field in userInfo
521+ userInfo.Emails = []string{} // Ensure Emails slice is initialized
522+ for _, emailObj := range emailObjects {
523+ userInfo.Emails = append(userInfo.Emails, emailObj.Email)
524+ }
525+ }
526+
527+ return userInfo, nil
528+}
529+
530+// generateJWT creates a new JWT token for the given account ID
531+func generateJWT(accountID string, secret string) (string, error) {
532+ if secret == "" {
533+ return "", fmt.Errorf("JWT secret is not configured")
534+ }
535+ // Set custom claims
536+ claims := &JwtCustomClaims{
537+ AccountID: accountID,
538+ RegisteredClaims: jwt.RegisteredClaims{
539+ // Set token expiration (e.g., 24 hours)
540+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 30)), // 30 days validity for CLI token
541+ IssuedAt: jwt.NewNumericDate(time.Now()),
542+ NotBefore: jwt.NewNumericDate(time.Now()),
543+ Issuer: "gordon",
544+ Subject: "cli_auth",
545+ },
546+ }
547+
548+ // Create token with claims
549+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
550+
551+ // Generate encoded token and return it
552+ t, err := token.SignedString([]byte(secret))
553+ if err != nil {
554+ return "", fmt.Errorf("failed to sign JWT token: %w", err)
555+ }
556+
557+ return t, nil
492 }558 }
M internal/httpserve/middleware/requiretoken.go +63 -192
@@ -1,225 +1,96 @@
11 package middleware
22
33 import (
4- "encoding/json"
54 "fmt"
6- "io"
75 "net/http"
86 "strings"
9- "sync"
10- "time"
117
12- "github.com/bnema/gordon/internal/cli/auth"
13- "github.com/bnema/gordon/internal/db"
8+ "errors"
9+
1410 "github.com/bnema/gordon/internal/db/queries"
1511 "github.com/bnema/gordon/internal/server"
1612 "github.com/bnema/gordon/pkg/logger"
13+ "github.com/golang-jwt/jwt/v5"
1714 "github.com/labstack/echo/v4"
1815 )
1916
20-// GithubUserInfo is an alias to db.GithubUserInfo
21-type GithubUserInfo = db.GithubUserInfo
17+// --- JWT Claims (copied from handlers/login.go for middleware use) ---
18+// Consider moving this to a shared location if used in more places.
19+type JwtCustomClaims struct {
20+ AccountID string `json:"account_id"`
21+ jwt.RegisteredClaims
22+}
2223
23-// middleware/require_token.go
24+// RequireToken validates JWT tokens for API requests
2425 func RequireToken(a *server.App) echo.MiddlewareFunc {
25- tokenCache := auth.GetTokenCache()
26-
2726 return func(next echo.HandlerFunc) echo.HandlerFunc {
2827 return func(c echo.Context) error {
29- token := strings.TrimPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
30- if token == "" {
31- return c.JSON(http.StatusUnauthorized, map[string]interface{}{
32- "success": false,
33- "error": "Token is missing",
34- })
28+ authHeader := c.Request().Header.Get("Authorization")
29+ if authHeader == "" {
30+ logger.Warn("API request missing Authorization header")
31+ return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Missing authorization token"})
3532 }
3633
37- // Check cache first
38- if _, cachedUser, found := tokenCache.GetWithUser(token); found {
39- // Verify if user exists in database and is authorized
40- _, isAuthorized, err := queries.CheckDBUserIsGood(a.DB, cachedUser)
41- if err != nil || !isAuthorized {
42- return c.JSON(http.StatusUnauthorized, map[string]interface{}{
43- "success": false,
44- "error": "User not authorized",
45- })
46- }
47- c.Set("user", cachedUser)
48- return next(c)
34+ tokenString := strings.TrimPrefix(authHeader, "Bearer ")
35+ if tokenString == authHeader { // No "Bearer " prefix found
36+ logger.Warn("API request Authorization header malformed", "header", authHeader)
37+ return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Malformed authorization token"})
4938 }
5039
51- // Validate with GitHub for new tokens
52- githubUser, err := validateGitHubToken(c)
53- if err != nil {
54- return c.JSON(http.StatusUnauthorized, map[string]interface{}{
55- "success": false,
56- "error": "Invalid token",
57- })
40+ if a.Config.General.JwtToken == "" {
41+ logger.Error("JWT Secret is not configured, cannot validate token")
42+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Server configuration error"})
5843 }
5944
60- // Verify if user exists in database and is authorized
61- _, isAuthorized, err := queries.CheckDBUserIsGood(a.DB, githubUser)
62- if err != nil {
63- return c.JSON(http.StatusInternalServerError, map[string]interface{}{
64- "success": false,
65- "error": "Internal server error",
66- })
67- }
45+ // Parse and validate the token
46+ token, err := jwt.ParseWithClaims(tokenString, &JwtCustomClaims{}, func(token *jwt.Token) (interface{}, error) {
47+ // Validate the alg is what we expect:
48+ if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
49+ return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
50+ }
51+ return []byte(a.Config.General.JwtToken), nil
52+ })
6853
69- if !isAuthorized {
70- return c.JSON(http.StatusUnauthorized, map[string]interface{}{
71- "success": false,
72- "error": "User not authorized. Please login through the web interface first.",
73- })
54+ if err != nil {
55+ logger.Warn("Invalid JWT token received", "error", err)
56+ if errors.Is(err, jwt.ErrTokenMalformed) {
57+ return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Malformed token"})
58+ } else if errors.Is(err, jwt.ErrTokenExpired) || errors.Is(err, jwt.ErrTokenNotValidYet) {
59+ return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Token expired or not yet valid"})
60+ } else {
61+ return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"})
62+ }
7463 }
7564
76- tokenCache.SetWithUser(token, token, githubUser, 15*time.Minute)
77- c.Set("user", githubUser)
78- return next(c)
79- }
80- }
81-}
65+ // Check if claims are valid and extract AccountID
66+ if claims, ok := token.Claims.(*JwtCustomClaims); ok && token.Valid {
67+ if claims.AccountID == "" {
68+ logger.Warn("JWT token is valid but missing AccountID claim")
69+ return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token claims"})
70+ }
8271
83-var sessionUpdateCache sync.Map
72+ // --- Optional: Check if AccountID exists and is active in DB ---
73+ accountExists, err := queries.CheckDBAccountExists(a.DB, claims.AccountID) // Corrected function name
74+ if err != nil {
75+ logger.Error("Database error checking account existence during token validation", "account_id", claims.AccountID, "error", err)
76+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Internal server error"})
77+ }
78+ if !accountExists {
79+ logger.Warn("JWT token validated for non-existent account", "account_id", claims.AccountID)
80+ return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Account not found"})
81+ }
82+ // --- End Optional DB Check ---
8483
85-func shouldUpdateSession(token string) bool {
86- key := token
87- now := time.Now()
84+ logger.Debug("JWT token validated successfully", "account_id", claims.AccountID)
85+ // Set account ID in context for downstream handlers
86+ c.Set("accountID", claims.AccountID)
87+ return next(c)
88+ }
8889
89- if lastUpdate, exists := sessionUpdateCache.Load(key); exists {
90- if now.Sub(lastUpdate.(time.Time)) < 5*time.Minute {
91- logger.Debug("skipping session update", "reason", "too_recent")
92- return false
90+ logger.Warn("Invalid JWT token claims or token invalid")
91+ return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"})
9392 }
9493 }
95-
96- sessionUpdateCache.Store(key, now)
97- logger.Debug("session update required")
98- return true
99-}
100-
101-func validateGitHubToken(c echo.Context) (*GithubUserInfo, error) {
102- requestID := c.Response().Header().Get(echo.HeaderXRequestID)
103- token := c.Request().Header.Get("Authorization")
104- if token == "" {
105- logger.Warn("missing token in validation request",
106- "request_id", requestID)
107- return nil, echo.NewHTTPError(http.StatusUnauthorized, "Token is missing")
108- }
109-
110- token = strings.TrimPrefix(token, "Bearer ")
111- logger.Debug("validating GitHub token",
112- "request_id", requestID)
113-
114- client := &http.Client{}
115- req, err := http.NewRequest("GET", "https://api.github.com/user", nil)
116- if err != nil {
117- logger.Error("failed to create GitHub API request",
118- "request_id", requestID,
119- "error", err)
120- return nil, fmt.Errorf("failed to create request: %w", err)
121- }
122-
123- req.Header.Set("Authorization", "token "+token)
124- req.Header.Set("Accept", "application/vnd.github.v3+json")
125-
126- resp, err := client.Do(req)
127- if err != nil {
128- logger.Error("GitHub API request failed",
129- "request_id", requestID,
130- "error", err)
131- return nil, fmt.Errorf("failed to send request: %w", err)
132- }
133- defer resp.Body.Close()
134-
135- if resp.StatusCode != http.StatusOK {
136- logger.Warn("invalid GitHub token",
137- "request_id", requestID,
138- "status", resp.Status)
139- return nil, echo.NewHTTPError(http.StatusUnauthorized, "Invalid token")
140- }
141-
142- body, err := io.ReadAll(resp.Body)
143- if err != nil {
144- logger.Error("failed to read GitHub response",
145- "request_id", requestID,
146- "error", err)
147- return nil, fmt.Errorf("failed to read response body: %w", err)
148- }
149-
150- var githubUser GithubUserInfo
151- if err := json.Unmarshal(body, &githubUser); err != nil {
152- logger.Error("failed to parse GitHub user data",
153- "request_id", requestID,
154- "error", err)
155- return nil, fmt.Errorf("failed to parse user data: %w", err)
156- }
157-
158- logger.Debug("GitHub user info fetched",
159- "request_id", requestID,
160- "user", githubUser.Login)
161-
162- emails, err := fetchGitHubUserEmails(token)
163- if err != nil {
164- logger.Error("failed to fetch GitHub user emails",
165- "request_id", requestID,
166- "user", githubUser.Login,
167- "error", err)
168- return nil, fmt.Errorf("failed to fetch user emails: %w", err)
169- }
170- githubUser.Emails = emails
171-
172- logger.Debug("completed GitHub user validation",
173- "request_id", requestID,
174- "user", githubUser.Login,
175- "email_count", len(emails))
176-
177- return &githubUser, nil
17894 }
17995
180-func fetchGitHubUserEmails(token string) ([]string, error) {
181- logger.Debug("fetching GitHub user emails")
182-
183- client := &http.Client{}
184- req, err := http.NewRequest("GET", "https://api.github.com/user/emails", nil)
185- if err != nil {
186- logger.Error("failed to create email request",
187- "error", err)
188- return nil, fmt.Errorf("failed to create request: %w", err)
189- }
190-
191- req.Header.Set("Authorization", "token "+token)
192- req.Header.Set("Accept", "application/vnd.github.v3+json")
193-
194- resp, err := client.Do(req)
195- if err != nil {
196- logger.Error("failed to send email request",
197- "error", err)
198- return nil, fmt.Errorf("failed to send request: %w", err)
199- }
200- defer resp.Body.Close()
201-
202- if resp.StatusCode != http.StatusOK {
203- logger.Warn("failed to fetch emails",
204- "status", resp.Status)
205- return nil, fmt.Errorf("failed to fetch user emails: %s", resp.Status)
206- }
207-
208- var emailsResp []struct {
209- Email string `json:"email"`
210- }
211- if err := json.NewDecoder(resp.Body).Decode(&emailsResp); err != nil {
212- logger.Error("failed to parse email response",
213- "error", err)
214- return nil, fmt.Errorf("failed to parse email response: %w", err)
215- }
216-
217- emails := make([]string, len(emailsResp))
218- for i, e := range emailsResp {
219- emails[i] = e.Email
220- }
221-
222- logger.Debug("fetched GitHub emails successfully",
223- "email_count", len(emails))
224- return emails, nil
225-}
96+// Removed unused functions: validateGitHubToken, fetchGitHubUserEmails, shouldUpdateSession, sessionUpdateCache
@@ -1,225 +1,96 @@
1 package middleware1 package middleware
2 2
3 import (3 import (
4- "encoding/json"
5 "fmt"4 "fmt"
6- "io"
7 "net/http"5 "net/http"
8 "strings"6 "strings"
9- "sync"
10- "time"
11 7
12- "github.com/bnema/gordon/internal/cli/auth"8+ "errors"
13- "github.com/bnema/gordon/internal/db"9+
14 "github.com/bnema/gordon/internal/db/queries"10 "github.com/bnema/gordon/internal/db/queries"
15 "github.com/bnema/gordon/internal/server"11 "github.com/bnema/gordon/internal/server"
16 "github.com/bnema/gordon/pkg/logger"12 "github.com/bnema/gordon/pkg/logger"
13+ "github.com/golang-jwt/jwt/v5"
17 "github.com/labstack/echo/v4"14 "github.com/labstack/echo/v4"
18 )15 )
19 16
20-// GithubUserInfo is an alias to db.GithubUserInfo17+// --- JWT Claims (copied from handlers/login.go for middleware use) ---
21-type GithubUserInfo = db.GithubUserInfo18+// Consider moving this to a shared location if used in more places.
19+type JwtCustomClaims struct {
20+ AccountID string `json:"account_id"`
21+ jwt.RegisteredClaims
22+}
22 23
23-// middleware/require_token.go24+// RequireToken validates JWT tokens for API requests
24 func RequireToken(a *server.App) echo.MiddlewareFunc {25 func RequireToken(a *server.App) echo.MiddlewareFunc {
25- tokenCache := auth.GetTokenCache()
26-
27 return func(next echo.HandlerFunc) echo.HandlerFunc {26 return func(next echo.HandlerFunc) echo.HandlerFunc {
28 return func(c echo.Context) error {27 return func(c echo.Context) error {
29- token := strings.TrimPrefix(c.Request().Header.Get("Authorization"), "Bearer ")28+ authHeader := c.Request().Header.Get("Authorization")
30- if token == "" {29+ if authHeader == "" {
31- return c.JSON(http.StatusUnauthorized, map[string]interface{}{30+ logger.Warn("API request missing Authorization header")
32- "success": false,31+ return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Missing authorization token"})
33- "error": "Token is missing",
34- })
35 }32 }
36 33
37- // Check cache first34+ tokenString := strings.TrimPrefix(authHeader, "Bearer ")
38- if _, cachedUser, found := tokenCache.GetWithUser(token); found {35+ if tokenString == authHeader { // No "Bearer " prefix found
39- // Verify if user exists in database and is authorized36+ logger.Warn("API request Authorization header malformed", "header", authHeader)
40- _, isAuthorized, err := queries.CheckDBUserIsGood(a.DB, cachedUser)37+ return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Malformed authorization token"})
41- if err != nil || !isAuthorized {
42- return c.JSON(http.StatusUnauthorized, map[string]interface{}{
43- "success": false,
44- "error": "User not authorized",
45- })
46- }
47- c.Set("user", cachedUser)
48- return next(c)
49 }38 }
50 39
51- // Validate with GitHub for new tokens40+ if a.Config.General.JwtToken == "" {
52- githubUser, err := validateGitHubToken(c)41+ logger.Error("JWT Secret is not configured, cannot validate token")
53- if err != nil {42+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Server configuration error"})
54- return c.JSON(http.StatusUnauthorized, map[string]interface{}{
55- "success": false,
56- "error": "Invalid token",
57- })
58 }43 }
59 44
60- // Verify if user exists in database and is authorized45+ // Parse and validate the token
61- _, isAuthorized, err := queries.CheckDBUserIsGood(a.DB, githubUser)46+ token, err := jwt.ParseWithClaims(tokenString, &JwtCustomClaims{}, func(token *jwt.Token) (interface{}, error) {
62- if err != nil {47+ // Validate the alg is what we expect:
63- return c.JSON(http.StatusInternalServerError, map[string]interface{}{48+ if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
64- "success": false,49+ return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
65- "error": "Internal server error",50+ }
66- })51+ return []byte(a.Config.General.JwtToken), nil
67- }52+ })
68 53
69- if !isAuthorized {54+ if err != nil {
70- return c.JSON(http.StatusUnauthorized, map[string]interface{}{55+ logger.Warn("Invalid JWT token received", "error", err)
71- "success": false,56+ if errors.Is(err, jwt.ErrTokenMalformed) {
72- "error": "User not authorized. Please login through the web interface first.",57+ return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Malformed token"})
73- })58+ } else if errors.Is(err, jwt.ErrTokenExpired) || errors.Is(err, jwt.ErrTokenNotValidYet) {
59+ return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Token expired or not yet valid"})
60+ } else {
61+ return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"})
62+ }
74 }63 }
75 64
76- tokenCache.SetWithUser(token, token, githubUser, 15*time.Minute)65+ // Check if claims are valid and extract AccountID
77- c.Set("user", githubUser)66+ if claims, ok := token.Claims.(*JwtCustomClaims); ok && token.Valid {
78- return next(c)67+ if claims.AccountID == "" {
79- }68+ logger.Warn("JWT token is valid but missing AccountID claim")
80- }69+ return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token claims"})
81-}70+ }
82 71
83-var sessionUpdateCache sync.Map72+ // --- Optional: Check if AccountID exists and is active in DB ---
73+ accountExists, err := queries.CheckDBAccountExists(a.DB, claims.AccountID) // Corrected function name
74+ if err != nil {
75+ logger.Error("Database error checking account existence during token validation", "account_id", claims.AccountID, "error", err)
76+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Internal server error"})
77+ }
78+ if !accountExists {
79+ logger.Warn("JWT token validated for non-existent account", "account_id", claims.AccountID)
80+ return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Account not found"})
81+ }
82+ // --- End Optional DB Check ---
84 83
85-func shouldUpdateSession(token string) bool {84+ logger.Debug("JWT token validated successfully", "account_id", claims.AccountID)
86- key := token85+ // Set account ID in context for downstream handlers
87- now := time.Now()86+ c.Set("accountID", claims.AccountID)
87+ return next(c)
88+ }
88 89
89- if lastUpdate, exists := sessionUpdateCache.Load(key); exists {90+ logger.Warn("Invalid JWT token claims or token invalid")
90- if now.Sub(lastUpdate.(time.Time)) < 5*time.Minute {91+ return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"})
91- logger.Debug("skipping session update", "reason", "too_recent")
92- return false
93 }92 }
94 }93 }
95-
96- sessionUpdateCache.Store(key, now)
97- logger.Debug("session update required")
98- return true
99-}
100-
101-func validateGitHubToken(c echo.Context) (*GithubUserInfo, error) {
102- requestID := c.Response().Header().Get(echo.HeaderXRequestID)
103- token := c.Request().Header.Get("Authorization")
104- if token == "" {
105- logger.Warn("missing token in validation request",
106- "request_id", requestID)
107- return nil, echo.NewHTTPError(http.StatusUnauthorized, "Token is missing")
108- }
109-
110- token = strings.TrimPrefix(token, "Bearer ")
111- logger.Debug("validating GitHub token",
112- "request_id", requestID)
113-
114- client := &http.Client{}
115- req, err := http.NewRequest("GET", "https://api.github.com/user", nil)
116- if err != nil {
117- logger.Error("failed to create GitHub API request",
118- "request_id", requestID,
119- "error", err)
120- return nil, fmt.Errorf("failed to create request: %w", err)
121- }
122-
123- req.Header.Set("Authorization", "token "+token)
124- req.Header.Set("Accept", "application/vnd.github.v3+json")
125-
126- resp, err := client.Do(req)
127- if err != nil {
128- logger.Error("GitHub API request failed",
129- "request_id", requestID,
130- "error", err)
131- return nil, fmt.Errorf("failed to send request: %w", err)
132- }
133- defer resp.Body.Close()
134-
135- if resp.StatusCode != http.StatusOK {
136- logger.Warn("invalid GitHub token",
137- "request_id", requestID,
138- "status", resp.Status)
139- return nil, echo.NewHTTPError(http.StatusUnauthorized, "Invalid token")
140- }
141-
142- body, err := io.ReadAll(resp.Body)
143- if err != nil {
144- logger.Error("failed to read GitHub response",
145- "request_id", requestID,
146- "error", err)
147- return nil, fmt.Errorf("failed to read response body: %w", err)
148- }
149-
150- var githubUser GithubUserInfo
151- if err := json.Unmarshal(body, &githubUser); err != nil {
152- logger.Error("failed to parse GitHub user data",
153- "request_id", requestID,
154- "error", err)
155- return nil, fmt.Errorf("failed to parse user data: %w", err)
156- }
157-
158- logger.Debug("GitHub user info fetched",
159- "request_id", requestID,
160- "user", githubUser.Login)
161-
162- emails, err := fetchGitHubUserEmails(token)
163- if err != nil {
164- logger.Error("failed to fetch GitHub user emails",
165- "request_id", requestID,
166- "user", githubUser.Login,
167- "error", err)
168- return nil, fmt.Errorf("failed to fetch user emails: %w", err)
169- }
170- githubUser.Emails = emails
171-
172- logger.Debug("completed GitHub user validation",
173- "request_id", requestID,
174- "user", githubUser.Login,
175- "email_count", len(emails))
176-
177- return &githubUser, nil
178 }94 }
179 95
180-func fetchGitHubUserEmails(token string) ([]string, error) {96+// Removed unused functions: validateGitHubToken, fetchGitHubUserEmails, shouldUpdateSession, sessionUpdateCache
181- logger.Debug("fetching GitHub user emails")
182-
183- client := &http.Client{}
184- req, err := http.NewRequest("GET", "https://api.github.com/user/emails", nil)
185- if err != nil {
186- logger.Error("failed to create email request",
187- "error", err)
188- return nil, fmt.Errorf("failed to create request: %w", err)
189- }
190-
191- req.Header.Set("Authorization", "token "+token)
192- req.Header.Set("Accept", "application/vnd.github.v3+json")
193-
194- resp, err := client.Do(req)
195- if err != nil {
196- logger.Error("failed to send email request",
197- "error", err)
198- return nil, fmt.Errorf("failed to send request: %w", err)
199- }
200- defer resp.Body.Close()
201-
202- if resp.StatusCode != http.StatusOK {
203- logger.Warn("failed to fetch emails",
204- "status", resp.Status)
205- return nil, fmt.Errorf("failed to fetch user emails: %s", resp.Status)
206- }
207-
208- var emailsResp []struct {
209- Email string `json:"email"`
210- }
211- if err := json.NewDecoder(resp.Body).Decode(&emailsResp); err != nil {
212- logger.Error("failed to parse email response",
213- "error", err)
214- return nil, fmt.Errorf("failed to parse email response: %w", err)
215- }
216-
217- emails := make([]string, len(emailsResp))
218- for i, e := range emailsResp {
219- emails[i] = e.Email
220- }
221-
222- logger.Debug("fetched GitHub emails successfully",
223- "email_count", len(emails))
224- return emails, nil
225-}
M internal/httpserve/router.go +12 -3
@@ -1,11 +1,14 @@
11 package httpserve
22
33 import (
4+ "net/http"
5+
46 "github.com/bnema/gordon/internal/httpserve/handlers"
57 "github.com/bnema/gordon/internal/httpserve/middleware"
68 "github.com/bnema/gordon/internal/server"
79 "github.com/charmbracelet/log"
810 _ "github.com/joho/godotenv/autoload"
11+ "github.com/labstack/echo-contrib/session"
912 "github.com/labstack/echo/v4"
1013 echomid "github.com/labstack/echo/v4/middleware"
1114 )
@@ -129,11 +132,17 @@ func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) {
129132 }
130133
131134 e.GET(adminPath+"/login", func(c echo.Context) error {
135+ // Check if already logged in, if so redirect to admin panel, otherwise render login page
136+ sess, _ := session.Get("session", c) // Use echo-contrib session
137+ if sess != nil && sess.Values != nil {
138+ if authenticated, ok := sess.Values["authenticated"].(bool); ok && authenticated {
139+ return c.Redirect(http.StatusFound, a.Config.Admin.Path)
140+ }
141+ }
142+ // If not authenticated, render the login page (which should now only contain GitHub login)
132143 return handlers.RenderTemplLoginPage(c, a)
133144 })
134- e.POST(adminPath+"/login/submit-token", func(c echo.Context) error {
135- return handlers.HandleTokenSubmission(c, a)
136- })
145+ // Removed POST /login/submit-token route
137146 e.GET(adminPath+"/login/oauth/github", func(c echo.Context) error {
138147 return handlers.StartOAuthGithub(c, a)
139148 })
@@ -1,11 +1,14 @@
1 package httpserve1 package httpserve
2 2
3 import (3 import (
4+ "net/http"
5+
4 "github.com/bnema/gordon/internal/httpserve/handlers"6 "github.com/bnema/gordon/internal/httpserve/handlers"
5 "github.com/bnema/gordon/internal/httpserve/middleware"7 "github.com/bnema/gordon/internal/httpserve/middleware"
6 "github.com/bnema/gordon/internal/server"8 "github.com/bnema/gordon/internal/server"
7 "github.com/charmbracelet/log"9 "github.com/charmbracelet/log"
8 _ "github.com/joho/godotenv/autoload"10 _ "github.com/joho/godotenv/autoload"
11+ "github.com/labstack/echo-contrib/session"
9 "github.com/labstack/echo/v4"12 "github.com/labstack/echo/v4"
10 echomid "github.com/labstack/echo/v4/middleware"13 echomid "github.com/labstack/echo/v4/middleware"
11 )14 )
@@ -129,11 +132,17 @@ func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) {
129 }132 }
130 133
131 e.GET(adminPath+"/login", func(c echo.Context) error {134 e.GET(adminPath+"/login", func(c echo.Context) error {
135+ // Check if already logged in, if so redirect to admin panel, otherwise render login page
136+ sess, _ := session.Get("session", c) // Use echo-contrib session
137+ if sess != nil && sess.Values != nil {
138+ if authenticated, ok := sess.Values["authenticated"].(bool); ok && authenticated {
139+ return c.Redirect(http.StatusFound, a.Config.Admin.Path)
140+ }
141+ }
142+ // If not authenticated, render the login page (which should now only contain GitHub login)
132 return handlers.RenderTemplLoginPage(c, a)143 return handlers.RenderTemplLoginPage(c, a)
133 })144 })
134- e.POST(adminPath+"/login/submit-token", func(c echo.Context) error {145+ // Removed POST /login/submit-token route
135- return handlers.HandleTokenSubmission(c, a)
136- })
137 e.GET(adminPath+"/login/oauth/github", func(c echo.Context) error {146 e.GET(adminPath+"/login/oauth/github", func(c echo.Context) error {
138 return handlers.StartOAuthGithub(c, a)147 return handlers.StartOAuthGithub(c, a)
139 })148 })
D internal/server/token_init.go +0 -46
@@ -1,46 +0,0 @@
1-package server
2-
3-import (
4- "crypto/rand"
5- "encoding/hex"
6- "fmt"
7-)
8-
9-// HandleNewInitialization checks if there is ANY user in the database.
10-func HandleNewTokenInitialization(a *App) (string, error) {
11- // Check if there is any user in the database
12- query := "SELECT COUNT(*) FROM user"
13- var count int
14- err := a.DB.QueryRow(query).Scan(&count)
15- if err != nil {
16- return "", fmt.Errorf("failed to check for admin user: %v", err)
17- }
18-
19- // If count is greater than 0, it means there is at least one user in the database
20- if count > 0 {
21- return "", fmt.Errorf("user already present, skipping token initialization")
22- }
23-
24- // If we reach here, it means admin does not exist, so we generate a token
25- token, err := generateRandomToken(16) // 16 bytes
26- if err != nil {
27- return "", fmt.Errorf("failed to generate token: %v", err)
28-
29-
30- // Store the token in the config file
31- a.Config.General.Token = token
32- err = a.Config.SaveConfig()
33- if err != nil {
34- return "", fmt.Errorf("failed to save config: %v", err)
35- }
36- return token, nil
37-}
38-
39-// generateRandomToken generates a random token of the given byte length
40-func generateRandomToken(length int) (string, error) {
41- bytes := make([]byte, length)
42- if _, err := rand.Read(bytes); err != nil {
43- return "", err
44- }
45- return hex.EncodeToString(bytes), nil
46-}
@@ -1,46 +0,0 @@
1-package server
2-
3-import (
4- "crypto/rand"
5- "encoding/hex"
6- "fmt"
7-)
8-
9-// HandleNewInitialization checks if there is ANY user in the database.
10-func HandleNewTokenInitialization(a *App) (string, error) {
11- // Check if there is any user in the database
12- query := "SELECT COUNT(*) FROM user"
13- var count int
14- err := a.DB.QueryRow(query).Scan(&count)
15- if err != nil {
16- return "", fmt.Errorf("failed to check for admin user: %v", err)
17- }
18-
19- // If count is greater than 0, it means there is at least one user in the database
20- if count > 0 {
21- return "", fmt.Errorf("user already present, skipping token initialization")
22- }
23-
24- // If we reach here, it means admin does not exist, so we generate a token
25- token, err := generateRandomToken(16) // 16 bytes
26- if err != nil {
27- return "", fmt.Errorf("failed to generate token: %v", err)
28-
29-
30- // Store the token in the config file
31- a.Config.General.Token = token
32- err = a.Config.SaveConfig()
33- if err != nil {
34- return "", fmt.Errorf("failed to save config: %v", err)
35- }
36- return token, nil
37-}
38-
39-// generateRandomToken generates a random token of the given byte length
40-func generateRandomToken(length int) (string, error) {
41- bytes := make([]byte, length)
42- if _, err := rand.Read(bytes); err != nil {
43- return "", err
44- }
45- return hex.EncodeToString(bytes), nil
46-}
M internal/templating/models/templ/pages/login/index.templ +9 -32
@@ -65,44 +65,21 @@ templ LoginPage(title string, buildVersion string, errorMsg string) {
6565 <div class="alert alert-error mb-4">
6666 <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
6767 <span>
68- if errorMsg == "empty_token" {
69- Please enter a token.
70- } else if errorMsg == "invalid_token" {
71- Invalid token. Please try again.
72- } else if errorMsg == "server_error" {
73- A server error occurred. Please try again later.
68+ // Removed token-specific errors. Display generic or GitHub-related errors.
69+ if errorMsg == "server_error" {
70+ A server error occurred during login. Please try again later.
71+ } else if errorMsg == "github_error" {
72+ An error occurred during GitHub authentication. Please try again.
73+ } else if errorMsg == "unauthorized" {
74+ This GitHub account is not authorized.
7475 } else {
75- An error occurred. Please try again.
76+ An unknown login error occurred. Please try again.
7677 }
7778 </span>
7879 </div>
7980 }
8081
81- <!-- Token Input Form -->
82- <div class="w-full">
83- <form action="login/submit-token" method="POST" class="mb-4">
84- <div class="form-control mb-4">
85- <label for="token" class="label">
86- <span class="label-text">Admin Token</span>
87- </label>
88- <input
89- type="text"
90- id="token"
91- name="token"
92- placeholder="Enter your admin token"
93- class="input input-bordered w-full"
94- required
95- />
96- </div>
97- <button
98- type="submit"
99- class="btn btn-primary w-full"
100- >
101- Continue with Token
102- </button>
103- </form>
104- <div class="divider">OR</div>
105- </div>
82+ <!-- Token Input Form Removed -->
10683
10784 <!-- GitHub OAuth -->
10885 <div class="w-full">
@@ -65,44 +65,21 @@ templ LoginPage(title string, buildVersion string, errorMsg string) {
65 <div class="alert alert-error mb-4">65 <div class="alert alert-error mb-4">
66 <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>66 <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
67 <span>67 <span>
68- if errorMsg == "empty_token" {68+ // Removed token-specific errors. Display generic or GitHub-related errors.
69- Please enter a token.69+ if errorMsg == "server_error" {
70- } else if errorMsg == "invalid_token" {70+ A server error occurred during login. Please try again later.
71- Invalid token. Please try again.71+ } else if errorMsg == "github_error" {
72- } else if errorMsg == "server_error" {72+ An error occurred during GitHub authentication. Please try again.
73- A server error occurred. Please try again later.73+ } else if errorMsg == "unauthorized" {
74+ This GitHub account is not authorized.
74 } else {75 } else {
75- An error occurred. Please try again.76+ An unknown login error occurred. Please try again.
76 }77 }
77 </span>78 </span>
78 </div>79 </div>
79 }80 }
80 81
81- <!-- Token Input Form -->82+ <!-- Token Input Form Removed -->
82- <div class="w-full">
83- <form action="login/submit-token" method="POST" class="mb-4">
84- <div class="form-control mb-4">
85- <label for="token" class="label">
86- <span class="label-text">Admin Token</span>
87- </label>
88- <input
89- type="text"
90- id="token"
91- name="token"
92- placeholder="Enter your admin token"
93- class="input input-bordered w-full"
94- required
95- />
96- </div>
97- <button
98- type="submit"
99- class="btn btn-primary w-full"
100- >
101- Continue with Token
102- </button>
103- </form>
104- <div class="divider">OR</div>
105- </div>
106 83
107 <!-- GitHub OAuth -->84 <!-- GitHub OAuth -->
108 <div class="w-full">85 <div class="w-full">

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