diff --git a/SECURE_DATA_FLOW.md b/SECURE_DATA_FLOW.md new file mode 100644 index 0000000..e656cb1 --- /dev/null +++ b/SECURE_DATA_FLOW.md @@ -0,0 +1,161 @@ +# Secure Data Flow: Backend to Frontend + +## Overview + +This document explains how sensitive gin.H data is securely passed from `main.go` to `indexPage.js` when `index.html` first loads. + +## Data Flow Diagram + +```@text +1. Browser requests "/" + ↓ +2. main.go "/" handler: + - Calls getUserRoleInfo() → authenticates user via SSOP + - Generates temporary token (TempToken) with expiration + - Calls GetFormTemplates() → retrieves available forms + - Creates gin.H with non-sensitive data + TempToken + ↓ +3. Renders index.html template with gin.H data embedded + - {{.TempToken}} → embedded as window.userToken + - {{.UserEmail}} → embedded as window.userEmail + - {{.UserRole}} → embedded as window.authenticatedRole + ↓ +4. JavaScript (indexPage.js) on page load: + - Accesses window.userToken from template variable + - Uses token to fetch additional sensitive data: + * /allFieldIds?token=... + * /formElementPermissions?token=... + - All subsequent API calls include token in query params +``` + +## Security Features + +### Token Storage + +- **Location**: `window.userToken` (memory only) +- **Persistence**: Lost on page refresh (requires re-authentication) +- **Transmission**: Only over HTTPS +- **Validation**: Server validates token on every request via `checkToken()` function + +### Data Categories + +#### Template-Embedded (Non-Sensitive) + +These are embedded directly in HTML from `gin.H`: + +- `RootPath` - Application root path for routing +- `UserEmail` - User's email address +- `UserRole` - User's authentication role (vx, dsg, mdev) +- `ReturnURL` - Post-authentication redirect URL +- Static resource paths (images, logos) + +#### Token-Protected (Sensitive) + +These require the temporary token to fetch: + +- Form element permissions (which UI elements to show/hide/disable) +- Field IDs for form elements +- Form-specific data +- Job set configurations + +## Implementation Details + +### Backend (main.go) + +The "/" route endpoint: + +```go +api.GET("/", func(c *gin.Context) { + userRoleInfo, tempToken, shouldReturn := getUserRoleInfo(c) + // ... validation ... + + data := gin.H{ + "TempToken": tempToken, // Temporary token for subsequent requests + "UserRole": userRoleInfo.UserRole, + "UserEmail": userRoleInfo.UserInfo.Email, + "forms": templates, // List of available forms + // ... other data ... + } + c.HTML(http.StatusOK, "index.html", data) +}) +``` + +#### API authorization + +Each user role is authorized for a collection of API routes. The api route path and the user role are checked against an APIPathPermissionsForRole structure that is retrieved from the database. The javascript code should prevent inadvertent attempts to access a protected route (such as one that would upsert the database) but that would not prevent an attempt to access the protected route independently in the case that a token had been somehow compromised. The role / path authorization would prevent that. + +### Frontend (index.html) + +Template variables excerpt: + +```@html + +``` + +### JavaScript (indexPage.js) + +Fetching token-protected data: + +```javascript +async function checkAllowed() { + // Fetch using token stored from template variable + const [fieldIdsData, permissionsData] = await Promise.all([ + fetch(window.ROOT_PATH + `/allFieldIds?token=${encodeURIComponent(window.userToken)}`), + fetch(window.ROOT_PATH + `/formElementPermissions?token=${encodeURIComponent(window.userToken)}`) + ]); + // ... process permissions ... +} +``` + +## Token Lifetime + +1. **Generated**: When user accesses "/" after authentication +2. **Transmitted**: Embedded in HTML template at render time +3. **Available**: Accessible via `window.userToken` on page load +4. **Validated**: Server checks token on every API request +5. **Expired**: Lost when page refreshes (user must re-authenticate) + +## Security Checklist + +- ✅ Token generated server-side with expiration +- ✅ Token transmitted over HTTPS only +- ✅ Token stored in memory (not in localStorage/sessionStorage) +- ✅ Token lost on page refresh (forces re-authentication) +- ✅ Token validated on every backend request +- ✅ Non-sensitive data embedded in HTML +- ✅ Sensitive data fetched via token-protected endpoints + +## Best Practices + +1. **Never expose token in logs**: Token contains sensitive auth info +2. **Always use HTTPS**: Token must be transmitted over secure channels +3. **Short expiration time**: Token should expire in minutes (5-15 min recommended) +4. **Validate on every request**: Backend must check token validity +5. **Clear on logout**: Token should be invalidated on logout +6. **Rotate tokens**: Generate new token on each page load + +## Adding New Data + +### If data is public/non-sensitive + +1. Add to `gin.H` in "/" route +2. Embed in template: `{{.FieldName}}` +3. Access in JavaScript: `window.fieldName` + +### If data is sensitive + +1. Create new endpoint requiring token parameter +2. Validate token via `checkToken()` function +3. Return data as JSON +4. Fetch from JavaScript with `window.userToken` diff --git a/fernet.go b/fernet.go index 6acaffa..0a2c700 100644 --- a/fernet.go +++ b/fernet.go @@ -8,31 +8,74 @@ import ( "github.com/fernet/fernet-go" ) -func decryptFernetToken(accessToken, fernetKey string, maxAge int) (*UserInfo, error) { +func decryptFernetToken(accessToken, fernetKey string, maxAge int) (UserInfo, error) { // Parse the Fernet key key, err := fernet.DecodeKey(fernetKey) if err != nil { - return nil, fmt.Errorf("invalid fernet key: %w", err) + return UserInfo{}, fmt.Errorf("invalid fernet key: %w", err) } // Decrypt the token decrypted := fernet.VerifyAndDecrypt([]byte(accessToken), time.Duration(maxAge)*time.Second, []*fernet.Key{key}) if decrypted == nil { - return nil, fmt.Errorf("failed to decrypt access_token - invalid token or key") + return UserInfo{}, fmt.Errorf("failed to decrypt access_token - invalid token or key") } fmt.Printf("[DEBUG] \n\nDecrypted token: %s\n\n", string(decrypted)) // The decrypted payload is a JSON array with a single object - var payload []UserInfo - err = json.Unmarshal(decrypted, &payload) + var userInfo []UserInfo + err = json.Unmarshal(decrypted, &userInfo) if err != nil { - return nil, fmt.Errorf("failed to parse decrypted JSON: %w (raw data: %s)", err, string(decrypted)) + return UserInfo{}, fmt.Errorf("failed to parse decrypted JSON: %w (raw data: %s)", err, string(decrypted)) } - if len(payload) == 0 { - return nil, fmt.Errorf("empty payload in decrypted token") + if len(userInfo) == 0 { + return UserInfo{}, fmt.Errorf("empty payload in decrypted token") } - return &payload[0], nil + return userInfo[0], nil +} + +func generateTemporaryFernetToken(userRoleInfo UserRoleInfo, fernetKey string) (string, error) { + // Parse the Fernet key + key, err := fernet.DecodeKey(fernetKey) + if err != nil { + return "", fmt.Errorf("invalid fernet key: %w", err) + } + + // Create the payload as a JSON array with a single object + userRoleInfoData, err := json.Marshal([]UserRoleInfo{userRoleInfo}) + if err != nil { + return "", fmt.Errorf("failed to marshal user info to JSON: %w", err) + } + + fmt.Printf("[DEBUG] \n\nuserRoleInfoData for token generation: %s\n\n", string(userRoleInfoData)) + + // Encrypt the userRoleInfoData + token, err := fernet.EncryptAndSign(userRoleInfoData, key) + if err != nil { + return "", fmt.Errorf("failed to encrypt userRoleInfoData: %w", err) + } + return string(token), nil +} + +func VerifyToken(token string, maxAge time.Duration, fernetKey string) (UserRoleInfo, error) { + key, err := fernet.DecodeKey(fernetKey) + if err != nil { + return UserRoleInfo{}, fmt.Errorf("invalid fernet key: %w", err) + } + decrypted := fernet.VerifyAndDecrypt([]byte(token), maxAge, []*fernet.Key{key}) + if decrypted == nil { + return UserRoleInfo{}, fmt.Errorf("failed to decrypt token - invalid token or key") + } + var userRoleInfo []UserRoleInfo + err = json.Unmarshal(decrypted, &userRoleInfo) + if err != nil { + return UserRoleInfo{}, err + } + if len(userRoleInfo) == 0 { + return UserRoleInfo{}, fmt.Errorf("empty userRoleInfo in decrypted token") + } + return userRoleInfo[0], nil } diff --git a/fernet_test.go b/fernet_test.go index 6758be7..c576473 100644 --- a/fernet_test.go +++ b/fernet_test.go @@ -20,10 +20,9 @@ func TestFernet(t *testing.T) { t.Skip("FERNET_KEY environment variable not set, skipping test") // Skip the test if FERNET_KEY is not set return } - //[{"email": "renn.valo@noaa.gov", "given_name": "", "family_name": "", "return_url": "https://apps-dev.gsd.esrl.noaa.gov/vx/formsui/", "return_html": "https://apps-dev.gsd.esrl.noaa.gov/vx/formsui/"}] - payload_mdev := []byte(`[{"Email": "jason.english@noaa.gov", "GivenName": "", "FamilyName": "", "ReturnURL": "https://apps-dev.gsd.esrl.noaa.gov/vx/formsui/", "ReturnHTML": "https://apps-dev.gsd.esrl.noaa.gov/vx/formsui/"}]`) - payload_dsg := []byte(`[{"Email": "greg.pratt@noaa.gov", "GivenName": "", "FamilyName": "", "ReturnURL": "https://apps-dev.gsd.esrl.noaa.gov/vx/formsui/", "ReturnHTML": "https://apps-dev.gsd.esrl.noaa.gov/vx/formsui/"}]`) - payload_vx := []byte(`[{"Email": "randy.pierce@noaa.gov", "GivenName": "", "FamilyName": "", "ReturnURL": "https://apps-dev.gsd.esrl.noaa.gov/vx/formsui/", "ReturnHTML": "https://apps-dev.gsd.esrl.noaa.gov/vx/formsui/"}]`) + payload_mdev := []byte(`[{"Email": "mdev@noaa.gov", "GivenName": "", "FamilyName": "", "ReturnURL": "http://localhost:8080/vx/formsui/", "ReturnHTML": "http://localhost:8080/vx/formsui/"}]`) + payload_dsg := []byte(`[{"Email": "dsg@noaa.gov", "GivenName": "", "FamilyName": "", "ReturnURL": "http://localhost:8080/vx/formsui/", "ReturnHTML": "http://localhost:8080/vx/formsui/"}]`) + payload_vx := []byte(`[{"Email": "vx@noaa.gov", "GivenName": "", "FamilyName": "", "ReturnURL": "http://localhost:8080/vx/formsui/", "ReturnHTML": "http://localhost:8080/vx/formsui/"}]`) key, err := fernet.DecodeKey(encodedKey) if err != nil { panic(fmt.Sprintf("Failed to decode FERNET_KEY: %v", err)) diff --git a/forms.go b/forms.go index 2b909a8..27164f9 100644 --- a/forms.go +++ b/forms.go @@ -41,20 +41,29 @@ type FormTemplate struct { } type Credentials struct { - CBHost string `yaml:"cb_host"` - CBUser string `yaml:"cb_user"` - CBPassword string `yaml:"cb_password"` - CBBucket string `yaml:"cb_bucket"` - CBScope string `yaml:"cb_scope"` - CBCollection string `yaml:"cb_collection"` - Targets []string `yaml:"targets"` - VxFormsUICredentials map[string]string `yaml:"vx_formsui_credentials"` - VxFernetKey string `yaml:"vx_fernet_key_vxformsui"` + CBHost string `yaml:"cb_host"` + CBUser string `yaml:"cb_user"` + CBPassword string `yaml:"cb_password"` + CBBucket string `yaml:"cb_bucket"` + CBScope string `yaml:"cb_scope"` + CBCollection string `yaml:"cb_collection"` + Targets []string `yaml:"targets"` + VxFernetKey string `yaml:"vx_fernet_key_vxformsui"` } var ( - once sync.Once - myCredentials Credentials + credentialsOnce sync.Once + myCredentials Credentials +) + +var ( + apiPathPermissionsOnce sync.Once + apiPathPermissions map[string][]string +) + +var ( + formElementPermissionsOnce sync.Once + formElementPermissions map[string][]string ) // rootpath and rootPathOnce are used to implement the singleton pattern for the root path. @@ -117,7 +126,7 @@ func RootPath() string { // GetCBCredentials loads Couchbase credentials from a YAML file specified by the CREDENTIALS_FILE environment variable. // It uses sync.Once to ensure credentials are loaded only once per process. func GetCBCredentials() Credentials { - once.Do(func() { + credentialsOnce.Do(func() { credentialsPath := os.Getenv("CREDENTIALS_FILE") if credentialsPath == "" { log.Fatal("CREDENTIALS_FILE environment variable not set - should contain the path to the credentials.yaml file") @@ -695,7 +704,7 @@ func handleNamedFunction(vStr string, fields map[string]interface{}, key string) return selectMode } -func GetRole(accessToken string) (UserRoleInfo, error) { +func GetRole(accessToken string) (UserRoleInfo, string, error) { cluster := GetConnection(GetCBCredentials()) credentials := GetCBCredentials() fernetKey := credentials.VxFernetKey @@ -703,33 +712,59 @@ func GetRole(accessToken string) (UserRoleInfo, error) { collection := bucket.Collection("COMMON") getResult, err := collection.Get("MD:V01:roles:vxFormsUI", &gocb.GetOptions{}) if err != nil { - return UserRoleInfo{}, err + return UserRoleInfo{}, "", err } var doc map[string]interface{} if err := getResult.Content(&doc); err != nil { - return UserRoleInfo{}, err + return UserRoleInfo{}, "", err } rolesData, ok := doc["roles"].(map[string]interface{}) if !ok || rolesData == nil { - return UserRoleInfo{}, errors.New("roles document does not contain a roles map") + return UserRoleInfo{}, "", errors.New("roles document does not contain a roles map") } + // userRoleInfo is the information that is bundled in the ssop token, + // such as email and name. We will use the email to match to a role + // in the roles document, and then we will generate a new token + // for the user that includes the role information to be used by + // subsequent rest api calls to the frontend for authorization decisions. userInfo, err := decryptFernetToken(accessToken, fernetKey, 0) if err != nil { fmt.Printf("Error decrypting access token: %v You must authenticate with login.gov. \n", err) - return UserRoleInfo{}, errors.New("user role not found") + return UserRoleInfo{}, "", errors.New("user role not found") } for userRolesKey, userRolesValue := range rolesData { userRoles, ok := userRolesValue.([]interface{}) if !ok { continue } + var UserRoleInfoForToken UserRoleInfo for _, roleEmail := range userRoles { if roleEmailStr, ok := roleEmail.(string); ok && strings.EqualFold(roleEmailStr, userInfo.Email) { - return UserRoleInfo{UserRole: userRolesKey, UserInfo: *userInfo}, nil + UserRoleInfoForToken = UserRoleInfo{ + UserRole: userRolesKey, + UserInfo: userInfo, + } + userSessionToken, err := generateTemporaryFernetToken(UserRoleInfoForToken, fernetKey) + if err != nil { + log.Printf("Error generating temporary token: %v", err) + return UserRoleInfo{}, "", errors.New("failed to generate user token") + } + return UserRoleInfoForToken, userSessionToken, nil } } } - return UserRoleInfo{}, errors.New("user role not found for user with email: " + userInfo.Email) + return UserRoleInfo{}, "", errors.New("user role not found for user with email: " + userInfo.Email) +} + +func GetRoleInfoFromToken(token string, ttl time.Duration) UserRoleInfo { + credentials := GetCBCredentials() + fernetKey := credentials.VxFernetKey + userRoleInfo, err := VerifyToken(token, ttl, fernetKey) + if err != nil { + log.Printf("Error verifying token: %v", err) + return UserRoleInfo{} + } + return userRoleInfo } // GetJobSpecIDs retrieves all job specification IDs from the Couchbase database. @@ -756,11 +791,6 @@ func GetJobSpecIDs() ([]string, error) { return jobSpecIDs, nil } -func getFormsCredentials() map[string]string { - credentials := GetCBCredentials() - return credentials.VxFormsUICredentials -} - // convertToStringSlice converts a slice of interface{} to a slice of strings. func convertToStringSlice(val []interface{}) []string { result := make([]string, 0, len(val)) @@ -772,39 +802,80 @@ func convertToStringSlice(val []interface{}) []string { return result } -func GetFormElementPermissions() (map[string][]string, error) { - formElementPermissions := make(map[string][]string) - cluster := GetConnection(GetCBCredentials()) - credentials := GetCBCredentials() - bucket := cluster.Bucket(credentials.CBBucket) - collection := bucket.Collection("COMMON") - getResult, err := collection.Get("MD:V01:formElementPermissions", &gocb.GetOptions{}) +func GetAPIPathPermissions() (map[string][]string, error) { + apiPathPermissionsOnce.Do(func() { + apiPathPermissions = make(map[string][]string) + credentials := GetCBCredentials() + cluster := GetConnection(credentials) + bucket := cluster.Bucket(credentials.CBBucket) + collection := bucket.Collection("COMMON") + getResult, err := collection.Get("MD:V01:apiPathPermissions:vxFormsUI", &gocb.GetOptions{}) + if err != nil { + log.Printf("Error retrieving API path permissions: %v", err) + return + } + var doc map[string]interface{} + if err := getResult.Content(&doc); err != nil { + log.Printf("Error decoding API path permissions: %v", err) + return + } + for key, value := range doc { + if permissions, ok := value.([]interface{}); ok { + apiPathPermissions[key] = convertToStringSlice(permissions) + } + } + }) + return apiPathPermissions, nil +} + +func GetAPIPathPermissionsForRole(role string) ([]string, error) { + permissionsMap, err := GetAPIPathPermissions() if err != nil { return nil, err } - var doc map[string]interface{} - if err := getResult.Content(&doc); err != nil { - return nil, err - } - // Extract each permission field - if val, ok := doc["vxDisallowedElementIds"].([]interface{}); ok { - formElementPermissions["vxDisallowedElementIds"] = convertToStringSlice(val) - } - if val, ok := doc["dsgDisallowedElementIds"].([]interface{}); ok { - formElementPermissions["dsgDisallowedElementIds"] = convertToStringSlice(val) - } - if val, ok := doc["modelerDisallowedElementIds"].([]interface{}); ok { - formElementPermissions["modelerDisallowedElementIds"] = convertToStringSlice(val) - } - if val, ok := doc["vxHiddenElementIds"].([]interface{}); ok { - formElementPermissions["vxHiddenElementIds"] = convertToStringSlice(val) - } - if val, ok := doc["dsgHiddenElementIds"].([]interface{}); ok { - formElementPermissions["dsgHiddenElementIds"] = convertToStringSlice(val) - } - if val, ok := doc["modelerHiddenElementIds"].([]interface{}); ok { - formElementPermissions["modelerHiddenElementIds"] = convertToStringSlice(val) + if permissions, exists := permissionsMap[role]; exists { + return permissions, nil } + return nil, fmt.Errorf("no API path permissions found for role: %s", role) +} + +func GetFormElementPermissions() (map[string][]string, error) { + formElementPermissionsOnce.Do(func() { + formElementPermissions = make(map[string][]string) + credentials := GetCBCredentials() + cluster := GetConnection(credentials) + bucket := cluster.Bucket(credentials.CBBucket) + collection := bucket.Collection("COMMON") + getResult, err := collection.Get("MD:V01:formElementPermissions:vxFormsUI", &gocb.GetOptions{}) + if err != nil { + log.Printf("Error retrieving form element permissions: %v", err) + return + } + var doc map[string]interface{} + if err := getResult.Content(&doc); err != nil { + log.Printf("Error decoding form element permissions: %v", err) + return + } + // Extract each permission field + if val, ok := doc["vxDisallowedElementIds"].([]interface{}); ok { + formElementPermissions["vxDisallowedElementIds"] = convertToStringSlice(val) + } + if val, ok := doc["dsgDisallowedElementIds"].([]interface{}); ok { + formElementPermissions["dsgDisallowedElementIds"] = convertToStringSlice(val) + } + if val, ok := doc["modelerDisallowedElementIds"].([]interface{}); ok { + formElementPermissions["modelerDisallowedElementIds"] = convertToStringSlice(val) + } + if val, ok := doc["vxHiddenElementIds"].([]interface{}); ok { + formElementPermissions["vxHiddenElementIds"] = convertToStringSlice(val) + } + if val, ok := doc["dsgHiddenElementIds"].([]interface{}); ok { + formElementPermissions["dsgHiddenElementIds"] = convertToStringSlice(val) + } + if val, ok := doc["modelerHiddenElementIds"].([]interface{}); ok { + formElementPermissions["modelerHiddenElementIds"] = convertToStringSlice(val) + } + }) return formElementPermissions, nil } diff --git a/main.go b/main.go index 5a57f54..9a4f95d 100644 --- a/main.go +++ b/main.go @@ -36,21 +36,22 @@ import ( "net/http" "os" "strings" + "time" "github.com/gin-gonic/gin" ) type UserInfo struct { - Email string `json:"email"` - GivenName string `json:"given_name"` - FamilyName string `json:"family_name"` - ReturnURL string `json:"return_url"` - ReturnHTML string `json:"return_html"` + Email string `json:"Email"` //nolint:tagliatelle + GivenName string `json:"GivenName"` //nolint:tagliatelle + FamilyName string `json:"FamilyName"` //nolint:tagliatelle + ReturnURL string `json:"ReturnURL"` //nolint:tagliatelle + ReturnHTML string `json:"ReturnHTML"` //nolint:tagliatelle } type UserRoleInfo struct { - UserRole string `json:"user_role"` - UserInfo UserInfo `json:"user_info"` + UserRole string `json:"UserRole"` //nolint:tagliatelle + UserInfo UserInfo `json:"UserInfo"` //nolint:tagliatelle } // main is the entry point for the vxFormsUI web server. @@ -108,17 +109,10 @@ func main() { api := r.Group(root) api.GET("/", func(c *gin.Context) { - accessToken := c.Query("access_token") - userRoleInfo := UserRoleInfo{} - if accessToken != "" { - var err error - userRoleInfo, err = GetRole(accessToken) - if err != nil { - fmt.Printf("Error getting user role: %v\n", err) - // Continue with empty userRoleInfo, which will indicate no role/user info in the template - } - } else { - fmt.Println("no access token provided. User will not be authenticated.") + userRoleInfo, tempToken, shouldReturn := getUserRoleInfo(c) + if shouldReturn { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return } templates, err := GetFormTemplates() if err != nil { @@ -130,6 +124,7 @@ func main() { "GovLogo": RootPath() + "/static/img/icon-dot-gov.svg", "HttpsLogo": RootPath() + "/static/img/icon-https.svg", "TransparentGif": RootPath() + "/static/img/noaa_transparent.gif", + "RootPath": RootPath(), "ProductLink": RootPath() + "/", "ProductText": "vxFormsUI", "AgencyLink": "https://gsl.noaa.gov/", @@ -140,6 +135,7 @@ func main() { "UserRole": userRoleInfo.UserRole, "UserEmail": userRoleInfo.UserInfo.Email, // Add user email here if available "ReturnURL": userRoleInfo.UserInfo.ReturnURL, // Add return URL here if available + "TempToken": tempToken, // Add temporary token here if available "forms": templates, } c.HTML(http.StatusOK, "index.html", data) @@ -147,6 +143,12 @@ func main() { api.GET("/form/:name", func(c *gin.Context) { name := c.Param("name") id := c.Param("id") + token := c.Query("token") + shouldReturn := checkToken(token, "/form") + if shouldReturn { + c.String(http.StatusUnauthorized, "Unauthorized") + return + } templates, _ := GetFormTemplates() var selected FormTemplate var validNames []string @@ -167,14 +169,17 @@ func main() { "jobSpecIDs": jobSpecIDs, "id": id, // Pass the id parameter to the template "UserRole": c.Query("userRole"), + "TempToken": c.Query("tempToken"), // Pass the temporary token to the template }) }) - api.GET("/vxUiCredentials", func(c *gin.Context) { - c.JSON(http.StatusOK, getFormsCredentials()) - }) - api.GET("/allFieldIds", func(c *gin.Context) { + token := c.Query("token") + shouldReturn := checkToken(token, "/allFieldIds") + if shouldReturn { + c.HTML(http.StatusUnauthorized, "index.html", gin.H{}) + return + } fieldIds, err := GetAllFieldIds() if err == nil { // Append additional fixed field IDs not in the form definitions @@ -188,6 +193,12 @@ func main() { }) api.GET("/formElementPermissions", func(c *gin.Context) { + token := c.Query("token") + shouldReturn := checkToken(token, "/formElementPermissions") + if shouldReturn { + c.HTML(http.StatusUnauthorized, "index.html", gin.H{}) + return + } fieldIds, err := GetFormElementPermissions() if err != nil { c.String(http.StatusInternalServerError, "Error retrieving Form Element Permissions: %v", err) @@ -197,6 +208,12 @@ func main() { }) api.GET("/idExists", func(c *gin.Context) { + token := c.Query("token") + shouldReturn := checkToken(token, "/idExists") + if shouldReturn { + c.HTML(http.StatusUnauthorized, "index.html", gin.H{}) + return + } id := c.Query("id") if id == "" { c.String(http.StatusBadRequest, "Missing id") @@ -210,8 +227,14 @@ func main() { c.JSON(http.StatusOK, gin.H{"exists": exists}) }) - // These could be grouped further if desired, e.g., api.POST("/commit-json", ...) api.POST("/commit-json", func(c *gin.Context) { + token := c.Query("token") + shouldReturn := checkToken(token, "/commit-json") + if shouldReturn { + c.HTML(http.StatusUnauthorized, "index.html", gin.H{}) + return + } + var data map[string]interface{} if err := c.BindJSON(&data); err != nil { c.String(http.StatusBadRequest, "Invalid JSON") @@ -233,6 +256,12 @@ func main() { }) api.GET("/retrieve-json", func(c *gin.Context) { + token := c.Query("token") + shouldReturn := checkToken(token, "/retrieve-json") + if shouldReturn { + c.HTML(http.StatusUnauthorized, "index.html", gin.H{}) + return + } id := c.Query("id") if id == "" { c.String(http.StatusBadRequest, "Missing id") @@ -247,6 +276,12 @@ func main() { }) api.GET("/delete-document", func(c *gin.Context) { + token := c.Query("token") + shouldReturn := checkToken(token, "/delete-document") + if shouldReturn { + c.HTML(http.StatusUnauthorized, "index.html", gin.H{}) + return + } id := c.Query("id") if id == "" { c.String(http.StatusBadRequest, "Missing id") @@ -262,6 +297,12 @@ func main() { api.GET("/list-ds-ids", func(c *gin.Context) { // type is a reserved word in Go, so we use 'aType' instead + token := c.Query("token") + shouldReturn := checkToken(token, "/list-ds-ids") + if shouldReturn { + c.HTML(http.StatusUnauthorized, "index.html", gin.H{}) + return + } aType := c.Query("type") docType := c.Query("docType") subType := c.Query("subType") @@ -276,6 +317,12 @@ func main() { }) api.GET("/get-associatedSpec-ids", func(c *gin.Context) { + token := c.Query("token") + shouldReturn := checkToken(token, "/get-associatedSpec-ids") + if shouldReturn { + c.HTML(http.StatusUnauthorized, "index.html", gin.H{}) + return + } id := c.Query("id") if id == "" { c.String(http.StatusBadRequest, "Missing id") @@ -290,6 +337,12 @@ func main() { }) api.GET("/get-containingSpec-ids", func(c *gin.Context) { + token := c.Query("token") + shouldReturn := checkToken(token, "/get-containingSpec-ids") + if shouldReturn { + c.HTML(http.StatusUnauthorized, "index.html", gin.H{}) + return + } id := c.Query("id") if id == "" { c.String(http.StatusBadRequest, "Missing id") @@ -303,7 +356,13 @@ func main() { c.JSON(http.StatusOK, ids) }) - api.GET("get-selectedSpec-ids", func(c *gin.Context) { + api.GET("/get-selectedSpec-ids", func(c *gin.Context) { + token := c.Query("token") + shouldReturn := checkToken(token, "/get-selectedSpec-ids") + if shouldReturn { + c.HTML(http.StatusUnauthorized, "index.html", gin.H{}) + return + } id := c.Query("id") if id == "" { c.String(http.StatusBadRequest, "Missing id") @@ -318,6 +377,12 @@ func main() { }) api.GET("/derive-jobset", func(c *gin.Context) { + token := c.Query("token") + shouldReturn := checkToken(token, "/derive-jobset") + if shouldReturn { + c.HTML(http.StatusUnauthorized, "index.html", gin.H{}) + return + } jobId := c.Query("jobId") if jobId == "" { c.String(http.StatusBadRequest, "Missing jobId") @@ -350,6 +415,12 @@ func main() { }) api.GET("/create-jobset-from-job", func(c *gin.Context) { + token := c.Query("token") + shouldReturn := checkToken(token, "/create-jobset-from-job") + if shouldReturn { + c.HTML(http.StatusUnauthorized, "index.html", gin.H{}) + return + } jobId := c.Query("jobId") if jobId == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Missing jobId"}) @@ -392,6 +463,12 @@ func main() { }) api.POST("/commit-jobset", func(c *gin.Context) { + token := c.Query("token") + shouldReturn := checkToken(token, "/commit-jobset") + if shouldReturn { + c.HTML(http.StatusUnauthorized, "index.html", gin.H{}) + return + } var jobSet map[string]interface{} if err := c.ShouldBindJSON(&jobSet); err != nil { c.String(http.StatusBadRequest, "Invalid JSON error: %v", err) @@ -416,3 +493,93 @@ func main() { os.Exit(1) } } + +// getUserRoleInfo retrieves user role and authentication information from an access token. +// +// It extracts the "access_token" query parameter from the HTTP request and uses it to fetch +// the user's role and related information via the GetRole function. The UserRoleInfo struct +// contains a signed UserToken that can be used by the frontend to authenticate subsequent +// API requests requiring user info and role verification. +// +// Parameters: +// - c: The Gin context containing the HTTP request information. +// +// Returns: +// - UserRoleInfo: A struct containing user role, token, and user information (email, name, URLs). +// - bool: A boolean flag indicating authentication status. Returns true if authentication failed +// or was not provided (user should be considered unauthorized), false if authentication succeeded. +// +// Behavior: +// - If an access token is provided and valid, returns the UserRoleInfo populated with user data, +// a temporary token for frontend use, and false (indicating successful authentication). +// - If an access token is provided but invalid/causes an error, logs the error and returns +// empty UserRoleInfo and token, with true (indicating failed authentication). +// - If no access token is provided, logs a message and returns empty UserRoleInfo and token with true +// (indicating no authentication attempted). +func getUserRoleInfo(c *gin.Context) (UserRoleInfo, string, bool) { + accessToken := c.Query("access_token") + var temporaryToken string + var userRoleInfo UserRoleInfo + if accessToken != "" { + var err error + // a UserRoleInfo struct has a UserToken field that is a signed token containing the user's email and role info. This token can be used by the frontend to authenticate API requests that require user info/role. + userRoleInfo, temporaryToken, err = GetRole(accessToken) + if err != nil { + fmt.Printf("Error getting user role: %v\n", err) + // Continue with empty userRoleInfo, which will indicate no role/user info in the template + return userRoleInfo, "", true + } + return userRoleInfo, temporaryToken, false + } else { + fmt.Println("no access token provided. User will not be authenticated.") + return UserRoleInfo{}, "", true + } +} + +func checkToken(tmpToken, apiPath string) bool { + if tmpToken == "" { + fmt.Println("No token provided") + return true + } + if apiPath == "" { + fmt.Println("No API path provided for token check") + return true + } + + ttl := time.Duration(0) // Set to 0 for no expiration, or specify a duration in seconds as needed + userRoleInfo := GetRoleInfoFromToken(tmpToken, ttl) + if userRoleInfo == (UserRoleInfo{}) { + fmt.Println("Invalid or expired token") + return true + } + userRole := userRoleInfo.UserRole + if userRole == "" { + fmt.Println("User role not found in token") + return true + } + + ReturnUrl := userRoleInfo.UserInfo.ReturnURL + // check that the token contains a return URL that matches the root URL of the application, to prevent tokens issued for other applications from being accepted + rootURL := fmt.Sprintf("%s://%s%s/", "http", "localhost:8080", RootPath()) + if ReturnUrl != "" && ReturnUrl != rootURL { + fmt.Printf("Token return URL does not match application root URL. Token ReturnURL: %s, Application RootURL: %s\n", ReturnUrl, rootURL) + return true + } + apiPathPermissions, err := GetAPIPathPermissionsForRole(userRole) + if err != nil { + fmt.Println("Failed to retrieve form element permissions") + return true + } + permitted := false + for _, path := range apiPathPermissions { + if path == apiPath { + permitted = true + break + } + } + if !permitted { + fmt.Println("API path not permitted for user role") + return true + } + return false +} diff --git a/static/img/subtle_grunge_@2X.png b/static/img/subtle_grunge_@2X.png deleted file mode 100644 index 847384f..0000000 Binary files a/static/img/subtle_grunge_@2X.png and /dev/null differ diff --git a/static/js/form.js b/static/js/form.js index fae0465..0a2f5da 100644 --- a/static/js/form.js +++ b/static/js/form.js @@ -87,7 +87,12 @@ function handleInputChange(event) { **/ function previewOption(id, showApplyButtons = true) { // Fetch the JSON data for the selected id - fetch(window.ROOT_PATH + `/retrieve-json?id=${encodeURIComponent(id)}`) + fetch(window.ROOT_PATH + `/retrieve-json?id=${encodeURIComponent(id)}&token=${encodeURIComponent(window.userToken)}`, { + method: "GET", + headers: { + "Content-Type": "application/json" + } + }) .then((response) => response.json()) .then((data) => { previewContainer = document.getElementById("jsonPreviewContent"); @@ -388,7 +393,7 @@ function updateAssociatedSpecIds(associatedSpecIds) { if (idField && !idField.value.includes("*")) { fetch( window.ROOT_PATH + - `/get-associatedSpec-ids?id=${encodeURIComponent(idField.value)}` + `/get-associatedSpec-ids?id=${encodeURIComponent(idField.value)}&token=${encodeURIComponent(window.userToken)}` ) .then((res) => res.json()) .then((data) => { @@ -450,7 +455,7 @@ function updateContainingSpecIds(containingSpecIds) { if (idField && !idField.value.includes("*")) { fetch( window.ROOT_PATH + - `/get-containingSpec-ids?id=${encodeURIComponent(idField.value)}` + `/get-containingSpec-ids?id=${encodeURIComponent(idField.value)}&token=${encodeURIComponent(window.userToken)}` ) .then((res) => res.json()) .then((data) => { @@ -575,7 +580,7 @@ function openRetrieveModal() { subType )}&subDocType=${encodeURIComponent( subDocType - )}&subset=${encodeURIComponent(subset)}` + )}&subset=${encodeURIComponent(subset)} &token=${encodeURIComponent(window.userToken)}`, ) .then((res) => res.ok ? res.json() : res.text().then((msg) => Promise.reject(msg)) @@ -602,7 +607,7 @@ function openRetrieveModal() { idSpan.style.flex = "1"; idSpan.onclick = function () { fetch( - window.ROOT_PATH + `/retrieve-json?id=${encodeURIComponent(id)}` + window.ROOT_PATH + `/retrieve-json?id=${encodeURIComponent(id)}&token=${encodeURIComponent(window.userToken)}` ) .then((res) => res.ok @@ -658,7 +663,7 @@ function openRetrieveModal() { if (confirm(`Delete ID "${id}"?`)) { fetch( window.ROOT_PATH + - `/delete-document?id=${encodeURIComponent(id)}`, + `/delete-document?id=${encodeURIComponent(id)}&token=${encodeURIComponent(window.userToken)}`, { method: "GET" } ) .then((res) => @@ -728,7 +733,7 @@ function commitJson() { ); return; } - fetch(window.ROOT_PATH + `/idExists?id=${encodeURIComponent(id)}`, { + fetch(window.ROOT_PATH + `/idExists?id=${encodeURIComponent(id)}&token=${encodeURIComponent(window.userToken)}`, { method: "GET", }) .then((res) => @@ -745,7 +750,7 @@ function commitJson() { } } // Proceed to commit the JSON - fetch(window.ROOT_PATH + `/commit-json`, { + fetch(window.ROOT_PATH + `/commit-json?token=${encodeURIComponent(window.userToken)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: jsonText, @@ -930,7 +935,12 @@ window.applyPreviewFromId = applyPreviewFromId; */ function applyPreviewFromId(id) { // Fetch and apply preview logic here - fetch(window.ROOT_PATH + `/retrieve-json?id=${encodeURIComponent(id)}`) + fetch(window.ROOT_PATH + `/retrieve-json?id=${encodeURIComponent(id)}&token=${encodeURIComponent(window.userToken)}`, { + method: "GET", + headers: { + "Content-Type": "application/json" + } + }) .then(res => res.json()) .then(data => { previewContainer = document.getElementById("jsonPreviewContent"); @@ -955,7 +965,12 @@ function applyPreviewFromId(id) { } function getDerivedJobsetId(jobId) { - fetch(window.ROOT_PATH + `/derive-jobset?jobId=${encodeURIComponent(jobId)}`) + fetch(window.ROOT_PATH + `/derive-jobset?jobId=${encodeURIComponent(jobId)}&token=${encodeURIComponent(window.userToken)}`, { + method: "GET", + headers: { + "Content-Type": "application/json" + } + }) .then(res => res.json()) .then(data => { if (data.data) { diff --git a/static/js/indexPage.js b/static/js/indexPage.js index 22af44c..d29a71e 100644 --- a/static/js/indexPage.js +++ b/static/js/indexPage.js @@ -1,5 +1,41 @@ // indexPage.js // JavaScript extracted from index.html +// +// SECURITY PATTERN: +// ================ +// In addition to the SSOP token provided by the server for authentication, +// this file implements an additional security layer by using a temporary +// token (TempToken) to control access to sensitive data and UI elements +// on the frontend. This approach ensures that even if an attacker gains +// access to the frontend, they would still need a valid TempToken to +// retrieve critical information or interact with certain UI components, +// thereby reducing the attack surface and enhancing overall security. +// This file retrieves sensitive data from the backend using a temporary +// token provided by the server. All of the tokens are FERNET-encrypted +// and have a short expiration time to minimize security risks. +// The server generates a TempToken for the authenticated user and embeds it +// in the rendered HTML (memory only). The frontend JavaScript then uses +// this token to make authenticated requests to the backend for sensitive data, +// such as field IDs and permissions, which are used to control UI access +// dynamically based on the user's role. Subsequent rest API calls are also +// protected by the same token, which is validated on the server side for each request. +// The token flow works as follows: +// +// 1. Server (main.go "/" route) generates a temporary token (TempToken) via getUserRoleInfo() +// 2. Server renders index.html and embeds the token as a template variable: {{.TempToken}} +// 3. index.html creates window.userToken from the template variable +// 4. JavaScript (this file) uses window.userToken in fetch requests to retrieve: +// - allFieldIds: List of form field IDs to manage UI permissions +// - formElementPermissions: Role-based permissions for UI elements and rest API endpoints. +// +// SECURITY BENEFITS: +// - Token is only in memory (not in localStorage/sessionStorage) +// - Token is automatically cleared on page refresh (requires re-authentication) +// - Token is transmitted only over HTTPS +// - Token is generated server-side with expiration time +// - Token validation happens on every request via checkToken() function +// +// NOTE: window.userToken is set in index.html inline script from {{.TempToken}} template variable async function checkAllowed() { // There is a neeed to both disable and hide elements based on authenticated mode @@ -20,11 +56,11 @@ async function checkAllowed() { try { const [fieldIdsData, permissionsData] = await Promise.all([ - fetch(window.ROOT_PATH + `/allFieldIds`) + fetch(window.ROOT_PATH + `/allFieldIds?token=${encodeURIComponent(window.userToken)}`) .then( res => res.json() ), - fetch(window.ROOT_PATH + `/formElementPermissions`) + fetch(window.ROOT_PATH + `/formElementPermissions?token=${encodeURIComponent(window.userToken)}`) .then( res => res.json() ) @@ -117,28 +153,8 @@ async function logout() { async function authenticate() { if (!window.authenticatedRole) { - if (!window.returnURL && window.origin === "http://localhost:8080") { - const creds = await getPasswordModal(); - if (!window.vxFormsCredentials - || !Object.values(window.vxFormsCredentials).includes(creds.password.toLowerCase()) - || !Object.keys(window.vxFormsCredentials).includes(creds.user.toLowerCase()) - || window.vxFormsCredentials[creds.user.toLowerCase()].toLowerCase() !== creds.password.toLowerCase()) { - alert("Incorrect credentials."); - document.getElementById("youNeedToAuthenticate").style.display = "block"; - document.getElementById("mainContent").style.display = "none"; - document.getElementById("dataSourceRequest").style.display = "none"; - document.getElementById("authenticate").style.whiteSpace = "normal"; - document.getElementById("whoami").style.color = "red"; - document.getElementById("whoami").innerText = `Authenticate:`; - document.getElementById("logout").style.color = "red"; - document.getElementById("logout").innerText = ""; - return; - } - window.authenticatedRole = Object.keys(window.vxFormsCredentials).find(k => window.vxFormsCredentials[k] === creds.password); - } else { - window.location.href = "https://apps.gsl.noaa.gov/ssop/login/6" + window.location.href = window.ReturnURL || window.ROOT_PATH; // Redirect to ReturnURL or root path after successful authentication return; - } } document.getElementById("mainContent").style.display = "block"; document.getElementById("dataSourceRequest").style.display = "block"; @@ -160,32 +176,13 @@ async function authenticate() { logoutElement.innerText = "logout"; } -// To show the prompt and get the value, returns a Promise that resolves with the password -function getPasswordModal() { - return new Promise((resolve) => { - let pwdModal = document.getElementById('passwordModal'); - let pwdInput = document.getElementById('pwdInput'); - let userInput = document.getElementById('userInput'); - userInput.value = ""; // Clear previous value - pwdInput.value = ""; // Clear previous value - pwdModal.showModal(); // Opens the dialog - pwdModal.addEventListener('close', () => { - if (pwdModal.returnValue === 'default') { - resolve({ user: userInput.value, password: pwdInput.value }); - } else { - resolve(null); - } - }, { once: true }); - }); -} - function commitJobSet() { if (!confirm("Are you sure you want to commit this Job Set?")) { return; } const jobSetData = $jsontree.getJson("jsonJobSetContent"); const jobSetJsonText = JSON.stringify(jobSetData); - fetch(window.ROOT_PATH + `/commit-jobset`, { + fetch(window.ROOT_PATH + `/commit-jobset?token=${encodeURIComponent(window.userToken)}`, { method: "POST", headers: {"Content-Type": "application/json"}, body: jobSetJsonText @@ -232,7 +229,7 @@ async function viewAssociatedJobSet() { )}` + `&sourceDataURI=${encodeURIComponent( document.getElementById("sourceDataURI").value - )}` + )}&token=${encodeURIComponent(window.userToken)}` ) .then((res) => { return res.text(); @@ -295,7 +292,7 @@ async function viewAssociatedJobSet() { document.getElementById("durationDays").value )}&requestorEmail=${encodeURIComponent( document.getElementById("requestorEmail").value - )}` + )}&token=${encodeURIComponent(window.userToken)}` ) .then((res) => res.text()) .then((data) => { @@ -381,19 +378,8 @@ function showHideAdvancedForms() { } document.addEventListener("DOMContentLoaded", function () { - // get the forms password - window.vxFormsCredentials = null; - fetch(window.ROOT_PATH + `/vxUiCredentials`) - .then((res) => res.text()) - .then((data) => { - if (data) { - window.vxFormsCredentials = JSON.parse(data); - } - }) - .catch((err) => console.error("Failed to get vxforms credentials:", err)); - // Load form via fetch - fetch(window.ROOT_PATH + `/form/data_request_specification`) + fetch(window.ROOT_PATH + `/form/data_request_specification?token=${encodeURIComponent(window.userToken)}`) .then((response) => response.text()) .then((html) => { document.getElementById("dsrContent").innerHTML = html; diff --git a/templates/index.html b/templates/index.html index 615bdd1..e557b07 100644 --- a/templates/index.html +++ b/templates/index.html @@ -31,7 +31,6 @@
- {{ template "passwordModal" . }} {{ template "topNav" . }}