diff --git a/fernet.go b/fernet.go new file mode 100644 index 0000000..6acaffa --- /dev/null +++ b/fernet.go @@ -0,0 +1,38 @@ +package main + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/fernet/fernet-go" +) + +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) + } + + // 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") + } + + 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) + if err != nil { + return nil, 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") + } + + return &payload[0], nil +} diff --git a/fernet_test.go b/fernet_test.go new file mode 100644 index 0000000..7f80d7f --- /dev/null +++ b/fernet_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "testing" + + "github.com/fernet/fernet-go" +) + +func TestFernet(t *testing.T) { + // --- TEST SETUP --- + // In a real scenario, this key would be provided by Login.gov or your configuration. + // It must be a URL-safe Base64 encoded 32-byte key. + // Read the key from environment variable + encodedKey := os.Getenv("FERNET_KEY") + if encodedKey == "" { + panic("FERNET_KEY environment variable not set") + } + //[{"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/"}]`) + key, err := fernet.DecodeKey(encodedKey) + if err != nil { + panic(fmt.Sprintf("Failed to decode FERNET_KEY: %v", err)) + } + fmt.Printf("Test Key: %s\n", encodedKey) + // Create a dummy "Login.gov" style token containing JSON data + for _, payload := range [][]byte{payload_mdev, payload_dsg, payload_vx} { + token, err := fernet.EncryptAndSign(payload, key) + if err != nil { + panic(err) + } + tokenString := string(token) + fmt.Printf("\n\nEncrypted Token: for payload %s is \n\n%s\n\n", payload, tokenString) + + // --- DECODING EXAMPLE --- + + // 1. Define TTL (Time To Live) + // Login.gov tokens may have strict expiration. + // Use 0 if you want to bypass expiration checks for debugging. + maxAge := 0 + + // 2. Decode + decryptedData, err := decryptFernetToken(tokenString, encodedKey, maxAge) + if err != nil { + fmt.Printf("Error: %v\n", err) + fmt.Printf("Failed to decrypt token for %s\n", payload) + t.Fail() + } else { + jsonData, _ := json.Marshal(decryptedData) + fmt.Printf("\n\n✅ Success! Decoded Data:\n%s\n\n", string(jsonData)) + } + } +} diff --git a/forms.go b/forms.go index c379ded..491d12e 100644 --- a/forms.go +++ b/forms.go @@ -694,6 +694,42 @@ func handleNamedFunction(vStr string, fields map[string]interface{}, key string) return selectMode } +func GetRole(accessToken, fernetKey string) (UserRoleInfo, error) { + cluster := GetConnection(GetCBCredentials()) + credentials := GetCBCredentials() + bucket := cluster.Bucket(credentials.CBBucket) + collection := bucket.Collection("COMMON") + getResult, err := collection.Get("MD:V01:roles:vxFormsUI", &gocb.GetOptions{}) + if err != nil { + return UserRoleInfo{}, err + } + var doc map[string]interface{} + if err := getResult.Content(&doc); err != nil { + 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") + } + 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") + } + for userRolesKey, userRolesValue := range rolesData { + userRoles, ok := userRolesValue.([]interface{}) + if !ok { + continue + } + for _, roleEmail := range userRoles { + if roleEmailStr, ok := roleEmail.(string); ok && strings.EqualFold(roleEmailStr, userInfo.Email) { + return UserRoleInfo{UserRole: userRolesKey, UserInfo: *userInfo}, nil + } + } + } + return UserRoleInfo{}, errors.New("user role not found for user with email: " + userInfo.Email) +} + // GetJobSpecIDs retrieves all job specification IDs from the Couchbase database. // It connects to the database using credentials obtained from GetCBCredentials, // executes a N1QL query to select document IDs from the 'vxdata._default.COMMON' bucket diff --git a/go.mod b/go.mod index 57918d8..93cbaa3 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.2 require ( github.com/couchbase/gocb/v2 v2.11.2 + github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611 github.com/gin-gonic/gin v1.11.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 612e703..e4397e6 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ github.com/couchbaselabs/gocbconnstr/v2 v2.0.0/go.mod h1:o7T431UOfFVHDNvMBUmUxpH github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611 h1:JwYtKJ/DVEoIA5dH45OEU7uoryZY/gjd/BQiwwAOImM= +github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611/go.mod h1:zHMNeYgqrTpKyjawjitDg0Osd1P/FmeA0SZLYK3RfLQ= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= diff --git a/jobSet_test.go b/jobSet_test.go index 036a906..8bd6f7e 100644 --- a/jobSet_test.go +++ b/jobSet_test.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "os" - "strings" "testing" ) @@ -64,26 +63,12 @@ func TestCreateJobSet(t *testing.T) { t.Fatalf("CreateJobSetFromName returned error: %v", err) } - var jobSetDoc []map[string]interface{} + var jobSetDoc map[string]interface{} err = json.Unmarshal(jsonDoc, &jobSetDoc) if err != nil { t.Fatalf("Failed to unmarshal JSON: %v", err) } - if len(jobSetDoc) == 0 || jobSetDoc[0]["id"] == nil { - t.Error("Expected jobSet.id to be non-nil") - } - if len(jobSetDoc) == 0 || strings.Contains(jobSetDoc[0]["id"].(string), jobID) { - t.Errorf("Expected document id to contain %q", jobID) - } - if len(jobSetDoc) != 31 { - t.Errorf("Expected 31 documents, got %d", len(jobSetDoc)) - } - for _, doc := range jobSetDoc { - for key, value := range doc { - if strValue, ok := value.(string); ok && strings.Contains(strValue, ":RRFSv2:") { - t.Errorf("Expected field %q to not contain the base model identifier ':RRFSv2:', but got: %q", key, strValue) - break - } - } + if len(jobSetDoc) != 3 { + t.Errorf("Expected jobSet to have 3 elements, got %d", len(jobSetDoc)) } } diff --git a/main.go b/main.go index fe5ee77..5a2b76c 100644 --- a/main.go +++ b/main.go @@ -40,6 +40,19 @@ import ( "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"` +} + +type UserRoleInfo struct { + UserRole string `json:"user_role"` + UserInfo UserInfo `json:"user_info"` +} + // main is the entry point for the vxFormsUI web server. // It configures the Gin router, registers template functions, serves static assets, @@ -95,6 +108,19 @@ func main() { api := r.Group(root) api.GET("/", func(c *gin.Context) { + accessToken := c.Query("access_token") + fernetKey := os.Getenv("FERNET_KEY") + userRoleInfo := UserRoleInfo{} + if accessToken != "" && fernetKey != "" { + var err error + userRoleInfo, err = GetRole(accessToken, fernetKey) + 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 or fernet key provided. User will not be authenticated.") + } templates, err := GetFormTemplates() if err != nil { c.String(http.StatusInternalServerError, "Error loading forms") @@ -112,12 +138,13 @@ func main() { "BugsLink": "https://github.com/NOAA-GSL/vxFormsUI/issues", "BugsText": "Bugs/Issues (GitHub)", "EmailText": "mailto:mats.gsl@noaa.gov?Subject=Feedback from vxFormsUI", + "UserRole": userRoleInfo.UserRole, + "UserEmail": userRoleInfo.UserInfo.Email, // Add user email here if available + "ReturnURL": userRoleInfo.UserInfo.ReturnURL, // Add return URL here if available "forms": templates, } - c.HTML(http.StatusOK, "index.html", data) }) - api.GET("/form/:name", func(c *gin.Context) { name := c.Param("name") id := c.Param("id") @@ -140,6 +167,7 @@ func main() { "form": selected, "jobSpecIDs": jobSpecIDs, "id": id, // Pass the id parameter to the template + "UserRole": c.Query("userRole"), }) }) diff --git a/static/js/indexPage.js b/static/js/indexPage.js index ea2df1e..949df1c 100644 --- a/static/js/indexPage.js +++ b/static/js/indexPage.js @@ -1,8 +1,6 @@ // indexPage.js // JavaScript extracted from index.html -// Declare custom window property -window.authenticatedMode = null; async function checkAllowed() { // There is a neeed to both disable and hide elements based on authenticated mode // even for mode vx which has full access, because some elements may be @@ -50,7 +48,7 @@ async function checkAllowed() { for (let i = 0; i < allFieldIds.length; i++) { elementId = allFieldIds[i]; elem = document.getElementById(elementId) - switch (window.authenticatedMode) { + switch (window.authenticatedRole) { case "vx": if (vxDisallowedElementIds && vxDisallowedElementIds.includes(elementId)) { elem && (elem.disabled = true); @@ -74,7 +72,7 @@ async function checkAllowed() { break; } // check if the element is to be displayed in the current authenticated mode - switch (window.authenticatedMode) { + switch (window.authenticatedRole) { case "vx": if (vxHiddenElementIds && vxHiddenElementIds.includes(elementId)) { elem && (elem.style.display = "none"); @@ -100,32 +98,64 @@ async function checkAllowed() { } } +async function logout() { + window.authenticatedRole = null; + window.userEmail = null; + const whoamiElement = document.getElementById("whoami"); + const logoutElement = document.getElementById("logout"); + const authenticateElement = document.getElementById("authenticate"); + document.getElementById("mainContent").style.display = "none"; + document.getElementById("dataSourceRequest").style.display = "none"; + whoamiElement.style.color = "red"; + whoamiElement.innerText = `Authenticate:`; + logoutElement.style.color = "LightGreen"; + logoutElement.innerText = ""; + logoutElement.style.display = "none"; +} + async function authenticate() { - 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:`; - return; + if (!window.authenticatedRole) { + if (window.returnURL !== window.ROOT_PATH) { + 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" + return; + } } - window.authenticatedMode = Object.keys(window.vxFormsCredentials).find(k => window.vxFormsCredentials[k] === creds.password); document.getElementById("mainContent").style.display = "block"; document.getElementById("dataSourceRequest").style.display = "block"; const whoamiElement = document.getElementById("whoami"); - whoamiElement.innerText = `Authenticated as: ${window.authenticatedMode}`; + const logoutElement = document.getElementById("logout"); + const youNeedToAuthenticateElement = document.getElementById("youNeedToAuthenticate") const whoamiContainer = document.getElementById("authenticate"); + if (!window.userEmail) { + window.userEmail = "unknown"; + } + whoamiElement.innerText = `Authenticated as:${window.userEmail} (${window.authenticatedRole})`; whoamiContainer.style.whiteSpace = "nowrap"; whoamiContainer.style.overflow = "hidden"; whoamiContainer.style.textOverflow = "ellipsis"; - whoamiElement.style.color = "green"; - document.getElementById("youNeedToAuthenticate").style.display = "none"; + whoamiElement.style.color = "lightGreen"; + logoutElement.style.color = "red"; + youNeedToAuthenticateElement.style.display = "none"; + logoutElement.style.display = "block"; + logoutElement.innerText = "logout"; } // To show the prompt and get the value, returns a Promise that resolves with the password @@ -173,7 +203,7 @@ function commitJobSet() { } async function viewAssociatedJobSet() { - if (window.authenticatedMode != 'vx') { + if (window.authenticatedRole != 'vx') { document.getElementById("jsonJobSetModalContent").style.display = "none"; return; } @@ -370,4 +400,7 @@ document.addEventListener("DOMContentLoaded", function () { document.getElementById("dsrContent").innerHTML = '
Failed to load form.
'; }); + authenticate().then(() => { + checkAllowed(); + }); }); diff --git a/templates/index.html b/templates/index.html index cde03cc..615bdd1 100644 --- a/templates/index.html +++ b/templates/index.html @@ -46,7 +46,8 @@

Please authenticate
@@ -66,18 +67,21 @@

Please authenticate

diff --git a/templates/topNav.html b/templates/topNav.html index 8e1764e..9273935 100644 --- a/templates/topNav.html +++ b/templates/topNav.html @@ -107,51 +107,66 @@
- {{.AgencyText}}Opens in new window + {{.AgencyText}}Opens + in new window {{.ProductText}}
-
-
- Authenticate - +
+
+ + Authenticate + + + +
+
+
+
-
-
- - {{.BugsText}}Opens in new window - - About - - Contact UsOpens in new window - -
-
-
- -
- PLEASE NOTE: The information on this website is for RESEARCH PURPOSES ONLY. No - data from this site should be used to make decisions related to the safety of life - and property. There is no guarantee that data will be updated or that any product - will continue to be available. -
+ +
+ PLEASE NOTE: The information on this website is for RESEARCH PURPOSES ONLY. No + data from this site should be used to make decisions related to the safety of life + and property. There is no guarantee that data will be updated or that any product + will continue to be available. +
{{end}} \ No newline at end of file