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 ( | |||
| 92 | 92 | github.com/goccy/go-json v0.10.4 // indirect | |
| 93 | 93 | github.com/gofrs/flock v0.12.1 // indirect | |
| 94 | 94 | 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 | |
| 96 | 96 | github.com/google/go-querystring v1.1.0 // indirect | |
| 97 | 97 | github.com/google/s2a-go v0.1.8 // indirect | |
| 98 | 98 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect | |
| @@ -92,7 +92,7 @@ require ( | |||||
| 92 | github.com/goccy/go-json v0.10.4 // indirect | 92 | github.com/goccy/go-json v0.10.4 // indirect | ||
| 93 | github.com/gofrs/flock v0.12.1 // indirect | 93 | github.com/gofrs/flock v0.12.1 // indirect | ||
| 94 | github.com/golang-jwt/jwt/v4 v4.5.1 // indirect | 94 | 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 |
| 96 | github.com/google/go-querystring v1.1.0 // indirect | 96 | github.com/google/go-querystring v1.1.0 // indirect | ||
| 97 | github.com/google/s2a-go v0.1.8 // indirect | 97 | github.com/google/s2a-go v0.1.8 // indirect | ||
| 98 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect | 98 | 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 ( | |||
| 12 | 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 | 16 | fmt.Println("Starting device flow authentication...") | |
| 17 | 17 | ||
| 18 | 18 | // Request device code | |
| 19 | 19 | deviceCode, err := requestDeviceCode(a) | |
| 20 | 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 | 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 | 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 | 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 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 | + | } | |
| 39 | 48 | ||
| 40 | - | fmt.Println("Authentication successful!") | |
| 41 | - | return nil | |
| 49 | + | return token, nil | |
| 42 | 50 | } | |
| 43 | 51 | ||
| 44 | 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 | 104 | continue | |
| 97 | 105 | } | |
| 98 | 106 | ||
| 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 | |
| 101 | 111 | } | |
| 102 | 112 | ||
| 103 | - | if tokenResp["error"] == "authorization_pending" { | |
| 113 | + | if errorMsg, ok := tokenResp["error"].(string); ok && errorMsg == "authorization_pending" { | |
| 104 | 114 | // Optionally, you can print a dot to show progress without cluttering the output | |
| 105 | 115 | fmt.Print(".") | |
| 106 | 116 | 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 code | 18 | // 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 token | 37 | + | // 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 | continue | 104 | 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, nil | 108 | + | 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 output | 114 | // Optionally, you can print a dot to show progress without cluttering the output | ||
| 105 | fmt.Print(".") | 115 | fmt.Print(".") | ||
| 106 | continue | 116 | continue | ||
M internal/cli/cmd/deploy.go +1 -1
| @@ -231,7 +231,7 @@ func checkDeployConflict(a *cli.App, targetDomain string, port string) (*common. | |||
| 231 | 231 | } | |
| 232 | 232 | ||
| 233 | 233 | // 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 | 236 | // Send request | |
| 237 | 237 | client := &http.Client{ | |
| @@ -231,7 +231,7 @@ func checkDeployConflict(a *cli.App, targetDomain string, port string) (*common. | |||||
| 231 | } | 231 | } | ||
| 232 | 232 | ||||
| 233 | // Set auth header | 233 | // 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 request | 236 | // Send request | ||
| 237 | client := &http.Client{ | 237 | client := &http.Client{ | ||
M internal/cli/handler/auth.go +9 -8
| @@ -11,7 +11,7 @@ import ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | func CheckAndRefreshAuth(a *cli.App) error { | |
| 14 | - | if a.Config.General.Token == "" { | |
| 14 | + | if a.Config.General.JwtToken == "" { | |
| 15 | 15 | log.Info("No authentication token found. Initiating authentication...") | |
| 16 | 16 | return ReAuthenticate(a) | |
| 17 | 17 | } | |
| @@ -46,19 +46,20 @@ func ReAuthenticate(a *cli.App) error { | |||
| 46 | 46 | // Small delay for visual clarity | |
| 47 | 47 | time.Sleep(100 * time.Millisecond) | |
| 48 | 48 | ||
| 49 | - | err := auth.DeviceFlowAuth(a) | |
| 49 | + | token, err := auth.DeviceFlowAuth(a) | |
| 50 | + | ||
| 50 | 51 | if err != nil { | |
| 51 | 52 | return fmt.Errorf("device flow authentication failed: %w", err) | |
| 52 | 53 | } | |
| 53 | 54 | ||
| 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 | |
| 58 | 59 | } | |
| 59 | 60 | ||
| 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 | |
| 62 | 63 | ||
| 63 | 64 | // Save the new token to config file | |
| 64 | 65 | 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 clarity | 46 | // 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 token | 55 | + | // 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 config | 61 | + | // Update the JWTSecret in the config |
| 61 | - | a.Config.General.Token = newToken | 62 | + | a.Config.General.JwtToken = token |
| 62 | 63 | ||||
| 63 | // Save the new token to config file | 64 | // 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{ | |||
| 225 | 225 | // NewChunkedClient creates a new chunked client | |
| 226 | 226 | func NewChunkedClient(app *cli.App) *ChunkedClient { | |
| 227 | 227 | baseURL := app.Config.Http.BackendURL + "/api" | |
| 228 | - | token := app.Config.General.Token | |
| 228 | + | token := app.Config.General.JwtToken | |
| 229 | 229 | ||
| 230 | 230 | return &ChunkedClient{ | |
| 231 | 231 | client: &http.Client{ | |
| @@ -225,7 +225,7 @@ var clientChunkStore = &transferStore{ | |||||
| 225 | // NewChunkedClient creates a new chunked client | 225 | // 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.Token | 228 | + | 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 { | |||
| 32 | 32 | return err | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | - | if a.Config.General.Token == "" { | |
| 35 | + | if a.Config.General.JwtToken == "" { | |
| 36 | 36 | fmt.Println("Token is not set. Starting OAuth device flow...") | |
| 37 | - | err := auth.DeviceFlowAuth(a) | |
| 37 | + | _, err := auth.DeviceFlowAuth(a) | |
| 38 | 38 | if err != nil { | |
| 39 | 39 | fmt.Printf("Device flow authentication error: %v\n", err) | |
| 40 | 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 | 44 | // 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 | 46 | err = a.Config.SaveConfig() | |
| 47 | 47 | if err != nil { | |
| 48 | 48 | return err | |
| @@ -32,9 +32,9 @@ func FieldCheck(a *cli.App) error { | |||||
| 32 | return err | 32 | 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 set | 44 | // 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 err | 48 | 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 | |||
| 48 | 48 | reauthenticated := false | |
| 49 | 49 | ||
| 50 | 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 | 53 | if err != nil { | |
| 53 | 54 | return nil, fmt.Errorf("failed to create request: %w", err) | |
| 54 | 55 | } | |
| @@ -48,7 +48,8 @@ func SendHTTPRequest(a *cli.App, rp *common.RequestPayload, method string, endpo | |||||
| 48 | reauthenticated := false | 48 | 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 { | |||
| 49 | 49 | // Start the session cleaner cron job | |
| 50 | 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 | 52 | // Initialize and start the reverse proxy using the already loaded configuration | |
| 58 | 53 | p, err := httpserve.InitializeProxy(a) | |
| 59 | 54 | if err != nil { | |
| @@ -49,11 +49,6 @@ func StartServer(a *server.App, port string) error { | |||||
| 49 | // Start the session cleaner cron job | 49 | // 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 configuration | 52 | // 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 ( | |||
| 11 | 11 | "strings" | |
| 12 | 12 | "sync" | |
| 13 | 13 | ||
| 14 | + | "crypto/rand" | |
| 15 | + | "encoding/hex" | |
| 16 | + | ||
| 14 | 17 | "github.com/bnema/gordon/pkg/docker" | |
| 15 | 18 | "github.com/bnema/gordon/pkg/logger" | |
| 16 | 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 | 31 | var ( | |
| 20 | 32 | globalConfig *Config | |
| 21 | 33 | globalConfigOnce sync.Once | |
| @@ -53,9 +65,9 @@ type AdminConfig struct { | |||
| 53 | 65 | ||
| 54 | 66 | type GeneralConfig struct { | |
| 55 | 67 | StorageDir string `yaml:"storageDir"` | |
| 56 | - | Token string `yaml:"token"` | |
| 57 | 68 | LogLevel string `yaml:"logLevel"` | |
| 58 | 69 | GordonContainerID string `yaml:"-"` | |
| 70 | + | JwtToken string `yaml:"jwtToken"` | |
| 59 | 71 | } | |
| 60 | 72 | ||
| 61 | 73 | type HttpConfig struct { | |
| @@ -455,12 +467,7 @@ func loadConfigFromEnv(config *Config, printLogs bool) { | |||
| 455 | 467 | logger.Info("Using environment variable GORDON_STORAGE_DIR", "value", val) | |
| 456 | 468 | } | |
| 457 | 469 | } | |
| 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 | |
| 464 | 471 | if val := os.Getenv("GORDON_LOG_LEVEL"); val != "" { | |
| 465 | 472 | config.General.LogLevel = val | |
| 466 | 473 | if printLogs { | |
| @@ -1020,6 +1027,32 @@ func (config *Config) LoadConfig() (*Config, error) { | |||
| 1020 | 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 | 1056 | logger.Debug("LoadConfig: Completed LoadConfig function") | |
| 1024 | 1057 | return config, nil | |
| 1025 | 1058 | } | |
| @@ -1054,16 +1087,6 @@ func fileExists(filepath string) bool { | |||
| 1054 | 1087 | 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 | 1090 | // GetRunEnv returns the run environment | |
| 1068 | 1091 | func (config *BuildConfig) GetRunEnv() string { | |
| 1069 | 1092 | return config.RunEnv | |
| @@ -1078,8 +1101,9 @@ func (c *Config) GetBackendURL() string { | |||
| 1078 | 1101 | return c.Http.BackendURL | |
| 1079 | 1102 | } | |
| 1080 | 1103 | ||
| 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 | |
| 1083 | 1107 | } | |
| 1084 | 1108 | ||
| 1085 | 1109 | 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 *Config | 32 | globalConfig *Config | ||
| 21 | globalConfigOnce sync.Once | 33 | 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 = val | 472 | 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, nil | 1057 | return config, nil | ||
| 1025 | } | 1058 | } | ||
| @@ -1054,16 +1087,6 @@ func fileExists(filepath string) bool { | |||||
| 1054 | return err == nil | 1087 | 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 environment | 1090 | // GetRunEnv returns the run environment | ||
| 1068 | func (config *BuildConfig) GetRunEnv() string { | 1091 | func (config *BuildConfig) GetRunEnv() string { | ||
| 1069 | return config.RunEnv | 1092 | return config.RunEnv | ||
| @@ -1078,8 +1101,9 @@ func (c *Config) GetBackendURL() string { | |||||
| 1078 | return c.Http.BackendURL | 1101 | return c.Http.BackendURL | ||
| 1079 | } | 1102 | } | ||
| 1080 | 1103 | ||||
| 1081 | - | func (c *Config) SetToken(token string) { | 1104 | + | // GetToken returns the token |
| 1082 | - | c.General.Token = token | 1105 | + | 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 ( | |||
| 15 | 15 | "github.com/bnema/gordon/internal/server" | |
| 16 | 16 | "github.com/bnema/gordon/internal/templating/render" | |
| 17 | 17 | "github.com/bnema/gordon/pkg/logger" | |
| 18 | + | "github.com/golang-jwt/jwt/v5" | |
| 18 | 19 | "github.com/gorilla/sessions" | |
| 19 | 20 | _ "github.com/joho/godotenv/autoload" | |
| 20 | 21 | "github.com/labstack/echo-contrib/session" | |
| 21 | 22 | "github.com/labstack/echo/v4" | |
| 22 | 23 | ) | |
| 23 | 24 | ||
| 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 | |
| 43 | 29 | } | |
| 44 | 30 | ||
| 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. | |
| 73 | 34 | 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 | + | } | |
| 76 | 44 | ||
| 77 | - | // Check for error message | |
| 45 | + | // Check for error message from previous attempts (e.g., GitHub auth failure) | |
| 78 | 46 | errorMsg := c.QueryParam("error") | |
| 79 | 47 | ||
| 80 | 48 | data := map[string]interface{}{ | |
| 81 | 49 | "Title": "Login", | |
| 82 | 50 | "Error": errorMsg, | |
| 51 | + | // Add other necessary data for the template | |
| 83 | 52 | } | |
| 84 | 53 | ||
| 85 | 54 | // 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 | 56 | path := "html/login" | |
| 87 | 57 | rendererData, err := render.GetHTMLRenderer(path, "index.gohtml", a.TemplateFS, a) | |
| 88 | 58 | 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 | |
| 90 | 61 | } | |
| 91 | 62 | ||
| 92 | 63 | renderedHTML, err := rendererData.Render(data, a) | |
| 93 | 64 | 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 | |
| 95 | 67 | } | |
| 96 | 68 | ||
| 97 | - | return c.HTML(200, renderedHTML) | |
| 69 | + | return c.HTML(http.StatusOK, renderedHTML) | |
| 98 | 70 | } | |
| 99 | 71 | ||
| 100 | 72 | // StartOAuthGithub starts the Github OAuth flow. | |
| 101 | - | ||
| 102 | 73 | func StartOAuthGithub(c echo.Context, a *server.App) error { | |
| 103 | 74 | logger.Info("Attempting to get an existing session") | |
| 104 | 75 | sess, err := getSession(c) | |
| @@ -215,7 +186,7 @@ func OAuthCallback(c echo.Context, a *server.App) error { | |||
| 215 | 186 | } | |
| 216 | 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 | 190 | if err != nil { | |
| 220 | 191 | logger.Error("Failed to handle user", "error", err) | |
| 221 | 192 | return err | |
| @@ -257,7 +228,7 @@ func getSession(c echo.Context) (*sessions.Session, error) { | |||
| 257 | 228 | } | |
| 258 | 229 | ||
| 259 | 230 | // 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 | 232 | logger.Debug("Starting handleUser function", "github_login", userInfo.Login) | |
| 262 | 233 | ||
| 263 | 234 | // --- Check provider count --- | |
| @@ -272,15 +243,8 @@ func handleUser(c echo.Context, a *server.App, accessToken, browserInfo string, | |||
| 272 | 243 | if providerCount == 0 { | |
| 273 | 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 | 246 | // --- Link to Seeded Admin --- | |
| 247 | + | // No token check needed anymore, proceed directly to linking. | |
| 284 | 248 | seededUser, seededAccountID, session, err := linkProviderToSeededAdmin(a, accessToken, browserInfo, userInfo) | |
| 285 | 249 | if err != nil { | |
| 286 | 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 | 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 | } | |
| @@ -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 string | 25 | + | // --- JWT Claims --- |
| 25 | - | 26 | + | type JwtCustomClaims struct { | |
| 26 | - | // compareGordonToken compares the token from the URL query parameter with the one from the config.yml | 27 | + | 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 submission | 31 | + | // 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 form | 33 | + | // 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 compatibility | 35 | + | // 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 message | 45 | + | // 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 template | 54 | // 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 err | 59 | + | 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 err | 65 | + | 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 err | 192 | 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 update | 230 | // 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 @@ | |||
| 1 | 1 | package middleware | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "encoding/json" | |
| 5 | 4 | "fmt" | |
| 6 | - | "io" | |
| 7 | 5 | "net/http" | |
| 8 | 6 | "strings" | |
| 9 | - | "sync" | |
| 10 | - | "time" | |
| 11 | 7 | ||
| 12 | - | "github.com/bnema/gordon/internal/cli/auth" | |
| 13 | - | "github.com/bnema/gordon/internal/db" | |
| 8 | + | "errors" | |
| 9 | + | ||
| 14 | 10 | "github.com/bnema/gordon/internal/db/queries" | |
| 15 | 11 | "github.com/bnema/gordon/internal/server" | |
| 16 | 12 | "github.com/bnema/gordon/pkg/logger" | |
| 13 | + | "github.com/golang-jwt/jwt/v5" | |
| 17 | 14 | "github.com/labstack/echo/v4" | |
| 18 | 15 | ) | |
| 19 | 16 | ||
| 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 | + | } | |
| 22 | 23 | ||
| 23 | - | // middleware/require_token.go | |
| 24 | + | // RequireToken validates JWT tokens for API requests | |
| 24 | 25 | func RequireToken(a *server.App) echo.MiddlewareFunc { | |
| 25 | - | tokenCache := auth.GetTokenCache() | |
| 26 | - | ||
| 27 | 26 | return func(next echo.HandlerFunc) echo.HandlerFunc { | |
| 28 | 27 | 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"}) | |
| 35 | 32 | } | |
| 36 | 33 | ||
| 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"}) | |
| 49 | 38 | } | |
| 50 | 39 | ||
| 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"}) | |
| 58 | 43 | } | |
| 59 | 44 | ||
| 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 | + | }) | |
| 68 | 53 | ||
| 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 | + | } | |
| 74 | 63 | } | |
| 75 | 64 | ||
| 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 | + | } | |
| 82 | 71 | ||
| 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 --- | |
| 84 | 83 | ||
| 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 | + | } | |
| 88 | 89 | ||
| 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"}) | |
| 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) { | |
| 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 middleware | 1 | 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.GithubUserInfo | 17 | + | // --- JWT Claims (copied from handlers/login.go for middleware use) --- |
| 21 | - | type GithubUserInfo = db.GithubUserInfo | 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 | + | } | |||
| 22 | 23 | ||||
| 23 | - | // middleware/require_token.go | 24 | + | // 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 first | 34 | + | 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 authorized | 36 | + | 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 tokens | 40 | + | 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 authorized | 45 | + | // 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.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 --- | |||
| 84 | 83 | ||||
| 85 | - | func shouldUpdateSession(token string) bool { | 84 | + | logger.Debug("JWT token validated successfully", "account_id", claims.AccountID) |
| 86 | - | key := token | 85 | + | // 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 @@ | |||
| 1 | 1 | package httpserve | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "net/http" | |
| 5 | + | ||
| 4 | 6 | "github.com/bnema/gordon/internal/httpserve/handlers" | |
| 5 | 7 | "github.com/bnema/gordon/internal/httpserve/middleware" | |
| 6 | 8 | "github.com/bnema/gordon/internal/server" | |
| 7 | 9 | "github.com/charmbracelet/log" | |
| 8 | 10 | _ "github.com/joho/godotenv/autoload" | |
| 11 | + | "github.com/labstack/echo-contrib/session" | |
| 9 | 12 | "github.com/labstack/echo/v4" | |
| 10 | 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 | 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 | 143 | return handlers.RenderTemplLoginPage(c, a) | |
| 133 | 144 | }) | |
| 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 | |
| 137 | 146 | e.GET(adminPath+"/login/oauth/github", func(c echo.Context) error { | |
| 138 | 147 | return handlers.StartOAuthGithub(c, a) | |
| 139 | 148 | }) | |
| @@ -1,11 +1,14 @@ | |||||
| 1 | package httpserve | 1 | 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) { | |||
| 65 | 65 | <div class="alert alert-error mb-4"> | |
| 66 | 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 | 67 | <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. | |
| 74 | 75 | } else { | |
| 75 | - | An error occurred. Please try again. | |
| 76 | + | An unknown login error occurred. Please try again. | |
| 76 | 77 | } | |
| 77 | 78 | </span> | |
| 78 | 79 | </div> | |
| 79 | 80 | } | |
| 80 | 81 | ||
| 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 --> | |
| 106 | 83 | ||
| 107 | 84 | <!-- GitHub OAuth --> | |
| 108 | 85 | <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"> | ||