Merge branch '11454-wb-federated-search'
authorLucas Di Pentima <ldipentima@veritasgenetics.com>
Thu, 8 Feb 2018 16:59:10 +0000 (13:59 -0300)
committerLucas Di Pentima <ldipentima@veritasgenetics.com>
Thu, 8 Feb 2018 16:59:10 +0000 (13:59 -0300)
Refs #11454

Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <ldipentima@veritasgenetics.com>

apps/workbench/app/assets/javascripts/application.js
apps/workbench/app/assets/javascripts/components/search.js
apps/workbench/app/assets/javascripts/components/sessions.js
apps/workbench/app/assets/javascripts/models/session_db.js
apps/workbench/app/views/search/index.html
apps/workbench/app/views/sessions/index.html
apps/workbench/config/application.default.yml
apps/workbench/npm_packages

index b90081f46fe9d5ccdec360165e6bc2528817d7b2..270a4c766d3152f3edd487561cc40ae4e2bdb256 100644 (file)
@@ -34,6 +34,7 @@
 //= require npm-dependencies
 //= require mithril/stream/stream
 //= require awesomplete
+//= require jssha
 //= require_tree .
 
 Es6ObjectAssign.polyfill()
index 2fe73193e7f116fcda6c2831c8c2250c2a266aee..51d352ab829b065c8ec932c10eff498262b40f1a 100644 (file)
@@ -41,6 +41,8 @@ window.SearchResultsTable = {
             collections: m('i.fa.fa-fw.fa-archive'),
             projects: m('i.fa.fa-fw.fa-folder'),
         }
+        var db = new SessionDB()
+        var sessions = db.loadActive()
         return m('table.table.table-condensed', [
             m('thead', m('tr', [
                 m('th'),
@@ -50,6 +52,13 @@ window.SearchResultsTable = {
             ])),
             m('tbody', [
                 loader.items().map(function(item) {
+                    var session = sessions[item.uuid.slice(0,5)]
+                    var tokenParam = ''
+                    // Add the salted token to search result links from federated
+                    // remote hosts.
+                    if (!session.isFromRails && session.token.indexOf('v2/') == 0) {
+                        tokenParam = '?api_token='+session.token
+                    }
                     return m('tr', [
                         m('td', [
                             item.workbenchBaseURL() &&
@@ -57,7 +66,7 @@ window.SearchResultsTable = {
                                     'data-original-title': 'show '+item.objectType.description,
                                     'data-placement': 'top',
                                     'data-toggle': 'tooltip',
-                                    href: item.workbenchBaseURL()+'/'+item.objectType.wb_path+'/'+item.uuid,
+                                    href: item.workbenchBaseURL()+'/'+item.objectType.wb_path+'/'+item.uuid+tokenParam,
                                     // Bootstrap's tooltip feature
                                     oncreate: function(vnode) { $(vnode.dom).tooltip() },
                                 }, iconsMap[item.objectType.wb_path]),
@@ -154,7 +163,6 @@ window.Search = {
         })
     },
     view: function(vnode) {
-        var sessions = vnode.state.sessionDB.loadAll()
         return m('form', {
             onsubmit: function() {
                 vnode.state.searchActive(vnode.state.searchEntered())
index e7cc5055734d4edaa2144d7eb4d704ec7e737736..231db9ba15190a8a429dd591834560ed625cc2f4 100644 (file)
@@ -6,6 +6,8 @@ $(document).on('ready', function() {
     var db = new SessionDB()
     db.checkForNewToken()
     db.fillMissingUUIDs()
+    db.migrateNonFederatedSessions()
+    db.autoLoadRemoteHosts()
 })
 
 window.SessionsTable = {
@@ -38,14 +40,20 @@ window.SessionsTable = {
                         var session = sessions[uuidPrefix]
                         return m('tr', [
                             session.token && session.user ? [
-                                m('td', m('span.label.label-success', 'logged in')),
-                                m('td', {title: session.baseURL}, uuidPrefix),
+                                m('td', session.user.is_active ?
+                                    m('span.label.label-success', 'logged in') :
+                                    m('span.label.label-warning', 'inactive')),
+                                m('td', {title: session.baseURL}, [
+                                    m('a', {
+                                        href: db.workbenchBaseURL(session) + '?api_token=' + session.token
+                                    }, uuidPrefix),
+                                ]),
                                 m('td', session.user.username),
                                 m('td', session.user.email),
                                 m('td', session.isFromRails ? null : m('button.btn.btn-xs.btn-default', {
                                     uuidPrefix: uuidPrefix,
                                     onclick: m.withAttr('uuidPrefix', db.logout),
-                                }, 'Log out ', m('span.glyphicon.glyphicon-log-out'))),
+                                }, session.listedHost ? 'Disable ':'Log out ', m('span.glyphicon.glyphicon-log-out'))),
                             ] : [
                                 m('td', m('span.label.label-default', 'logged out')),
                                 m('td', {title: session.baseURL}, uuidPrefix),
@@ -54,7 +62,7 @@ window.SessionsTable = {
                                 m('td', m('a.btn.btn-xs.btn-primary', {
                                     uuidPrefix: uuidPrefix,
                                     onclick: db.login.bind(db, session.baseURL),
-                                }, 'Log in ', m('span.glyphicon.glyphicon-log-in'))),
+                                }, session.listedHost ? 'Enable ':'Log in ', m('span.glyphicon.glyphicon-log-in'))),
                             ],
                             m('td', session.isFromRails ? null : m('button.btn.btn-xs.btn-default', {
                                 uuidPrefix: uuidPrefix,
index d5cd98d386499adf68f049980c036fbd1c4155e7..f9e17b2c366e36f2c7e9f7faf802bf22accd668a 100644 (file)
@@ -6,6 +6,7 @@ window.SessionDB = function() {
     var db = this
     Object.assign(db, {
         discoveryCache: {},
+        tokenUUIDCache: null,
         loadFromLocalStorage: function() {
             try {
                 return JSON.parse(window.localStorage.getItem('sessions')) || {}
@@ -23,7 +24,7 @@ window.SessionDB = function() {
         loadActive: function() {
             var sessions = db.loadAll()
             Object.keys(sessions).forEach(function(key) {
-                if (!sessions[key].token)
+                if (!sessions[key].token || (sessions[key].user && !sessions[key].user.is_active))
                     delete sessions[key]
             })
             return sessions
@@ -58,6 +59,8 @@ window.SessionDB = function() {
             // for the corresponding API server's base URL.  Typical
             // use:
             // sessionDB.findAPI('https://workbench.example/foo').then(sessionDB.login)
+            if (url.length === 5 && url.indexOf('.') < 0)
+                url += '.arvadosapi.com'
             if (url.indexOf('://') < 0)
                 url = 'https://' + url
             url = new URL(url)
@@ -73,7 +76,7 @@ window.SessionDB = function() {
                 })
             })
         },
-        login: function(baseURL) {
+        login: function(baseURL, fallbackLogin = true) {
             // Initiate login procedure with given API base URL (e.g.,
             // "http://api.example/").
             //
@@ -81,9 +84,57 @@ window.SessionDB = function() {
             // also call checkForNewToken() on (at least) its first
             // render. Otherwise, the login procedure can't be
             // completed.
-            document.location = baseURL + 'login?return_to=' + encodeURIComponent(document.location.href.replace(/\?.*/, '')+'?baseURL='+encodeURIComponent(baseURL))
+            var session = db.loadLocal()
+            var uuidPrefix = session.user.owner_uuid.slice(0, 5)
+            var apiHostname = new URL(session.baseURL).hostname
+            m.request(session.baseURL+'discovery/v1/apis/arvados/v1/rest').then(function(localDD) {
+                m.request(baseURL+'discovery/v1/apis/arvados/v1/rest').then(function(dd) {
+                    if (uuidPrefix in dd.remoteHosts ||
+                        (dd.remoteHostsViaDNS && apiHostname.indexOf('arvadosapi.com') >= 0)) {
+                        // Federated identity login via salted token
+                        db.saltedToken(dd.uuidPrefix).then(function(token) {
+                            m.request(baseURL+'arvados/v1/users/current', {
+                                headers: {
+                                    authorization: 'Bearer '+token,
+                                },
+                            }).then(function(user) {
+                                // Federated login successful.
+                                var remoteSession = {
+                                    user: user,
+                                    baseURL: baseURL,
+                                    token: token,
+                                    listedHost: (dd.uuidPrefix in localDD.remoteHosts),
+                                }
+                                db.save(dd.uuidPrefix, remoteSession)
+                            }).catch(function(e) {
+                                if (dd.uuidPrefix in localDD.remoteHosts) {
+                                    // If the remote system is configured to allow federated
+                                    // logins from this cluster, but rejected the salted
+                                    // token, save as a logged out session anyways.
+                                    var remoteSession = {
+                                        baseURL: baseURL,
+                                        listedHost: true,
+                                    }
+                                    db.save(dd.uuidPrefix, remoteSession)
+                                } else if (fallbackLogin) {
+                                    // Remote cluster not listed as a remote host and rejecting
+                                    // the salted token, try classic login.
+                                    db.loginClassic(baseURL)
+                                }
+                            })
+                        })
+                    } else if (fallbackLogin) {
+                        // Classic login will be used when the remote system doesn't list this
+                        // cluster as part of the federation.
+                        db.loginClassic(baseURL)
+                    }
+                })
+            })
             return false
         },
+        loginClassic: function(baseURL) {
+            document.location = baseURL + 'login?return_to=' + encodeURIComponent(document.location.href.replace(/\?.*/, '')+'?baseURL='+encodeURIComponent(baseURL))
+        },
         logout: function(k) {
             // Forget the token, but leave the other info in the db so
             // the user can log in again without providing the login
@@ -92,6 +143,18 @@ window.SessionDB = function() {
             delete sessions[k].token
             db.save(k, sessions[k])
         },
+        saltedToken: function(uuid_prefix) {
+            // Takes a cluster UUID prefix and returns a salted token to allow
+            // log into said cluster using federated identity.
+            var session = db.loadLocal()
+            return db.tokenUUID().then(function(token_uuid){
+                var shaObj = new jsSHA("SHA-1", "TEXT")
+                shaObj.setHMACKey(session.token, "TEXT")
+                shaObj.update(uuid_prefix)
+                var hmac = shaObj.getHMAC("HEX")
+                return 'v2/' + token_uuid + '/' + hmac
+            })
+        },
         checkForNewToken: function() {
             // If there's a token and baseURL in the location bar (i.e.,
             // we just landed here after a successful login), save it and
@@ -162,11 +225,93 @@ window.SessionDB = function() {
             }
             return cache
         },
+        // Return a promise with the local session token's UUID from the API server.
+        tokenUUID: function() {
+            var cache = db.tokenUUIDCache
+            if (!cache) {
+                var session = db.loadLocal()
+                return db.request(session, '/arvados/v1/api_client_authorizations', {
+                    data: {
+                        filters: JSON.stringify([['api_token', '=', session.token]]),
+                    }
+                }).then(function(resp) {
+                    var uuid = resp.items[0].uuid
+                    db.tokenUUIDCache = uuid
+                    return uuid
+                })
+            } else {
+                return new Promise(function(resolve, reject) {
+                    resolve(cache)
+                })
+            }
+        },
         request: function(session, path, opts) {
             opts = opts || {}
             opts.headers = opts.headers || {}
             opts.headers.authorization = 'OAuth2 '+ session.token
             return m.request(session.baseURL + path, opts)
         },
+        // Check non-federated remote active sessions if they should be migrated to
+        // a salted token.
+        migrateNonFederatedSessions: function() {
+            var sessions = db.loadActive()
+            Object.keys(sessions).map(function(uuidPrefix) {
+                session = sessions[uuidPrefix]
+                if (!session.isFromRails && session.token && session.token.indexOf('v2/') < 0) {
+                    // Only try the federated login
+                    db.login(session.baseURL, false)
+                }
+            })
+        },
+        // If remoteHosts is listed on the local API discovery doc, try to add any
+        // listed remote without an active session.
+        autoLoadRemoteHosts: function() {
+            var activeSessions = db.loadActive()
+            var doc = db.discoveryDoc(db.loadLocal())
+            doc.map(function(d) {
+                Object.keys(d.remoteHosts).map(function(uuidPrefix) {
+                    if (!(uuidPrefix in Object.keys(activeSessions))) {
+                        db.findAPI(d.remoteHosts[uuidPrefix]).then(function(baseURL) {
+                            db.login(baseURL, false)
+                        })
+                    }
+                })
+            })
+        },
+        // If the current logged in account is from a remote federated cluster,
+        // redirect the user to their home cluster's workbench.
+        // This is meant to avoid confusion when the user clicks through a search
+        // result on the home cluster's multi site search page, landing on the
+        // remote workbench and later trying to do another search by just clicking
+        // on the multi site search button instead of going back with the browser.
+        autoRedirectToHomeCluster: function(path = '/') {
+            var session = db.loadLocal()
+            var userUUIDPrefix = session.user.uuid.slice(0, 5)
+            // If the current user is local to the cluster, do nothing.
+            if (userUUIDPrefix == session.user.owner_uuid.slice(0, 5)) {
+                return
+            }
+            var doc = db.discoveryDoc(session)
+            doc.map(function(d) {
+                // Guess the remote host from the local discovery doc settings
+                var rHost = null
+                if (d.remoteHosts[userUUIDPrefix]) {
+                    rHost = d.remoteHosts[userUUIDPrefix]
+                } else if (d.remoteHostsViaDNS) {
+                    rHost = userUUIDPrefix + '.arvadosapi.com'
+                } else {
+                    // This should not happen: having remote user whose uuid prefix
+                    // isn't listed on remoteHosts and dns mechanism is deactivated
+                    return
+                }
+                // Get the remote cluster workbench url & redirect there.
+                db.findAPI(rHost).then(function(apiUrl) {
+                    var doc = db.discoveryDoc({baseURL: apiUrl})
+                    doc.map(function(d) {
+                        document.location = d.workbenchUrl + path
+                    })
+                })
+            })
+        },
     })
 }
index 6bcad0b1ae2c245ccd9b65afda7d3eac373088a7..c26a643d616d08b3e60396bea597dd8c1af66880 100644 (file)
@@ -2,4 +2,11 @@
 
 SPDX-License-Identifier: AGPL-3.0 -->
 
+<script type="text/javascript">
+    $(document).on('ready', function() {
+        var db = new SessionDB()
+        db.autoRedirectToHomeCluster('/search')
+    })
+</script>
+
 <div data-mount-mithril="Search"></div>
index bf23028ce7c6a75efa2a9a8fac02057bce865822..ee1f63659e88fed3bfef9a7b06d8df3938629e74 100644 (file)
@@ -2,4 +2,11 @@
 
 SPDX-License-Identifier: AGPL-3.0 -->
 
+<script type="text/javascript">
+    $(document).on('ready', function() {
+        var db = new SessionDB()
+        db.autoRedirectToHomeCluster('/sessions')
+    })
+</script>
+
 <div data-mount-mithril="SessionsTable"></div>
index 187845038ea3c48449ccd1e7d1c002657ffe6e37..76f7a3081751df228bb32b810b50208902aac8bc 100644 (file)
@@ -313,4 +313,4 @@ common:
   #
   # Link to use for Arvados Workflow Composer app, or false if not available.
   #
-  composer_url: false
\ No newline at end of file
+  composer_url: false
index c126b559fb138387b27424773cd931053f6bcc86..64f58ac4686f4686eccc18854bfc9da6a91d7432 100644 (file)
@@ -8,6 +8,7 @@
 npm 'browserify', require: false
 npm 'jquery'
 npm 'awesomplete'
+npm 'jssha'
 
 npm 'mithril'
 npm 'es6-object-assign'