OS-1 : insecure UI websocket.
- notes on authentication of UI web socket connection.
- new classes: UiSessionToken, UiTokenService.
- UiExtensionManager now implements UiTokenService.
- UiWebSocket now expects an authentication event from the client
- websocket.js now sends authentication event as first event
- (fix websocket Jasmine test)
Change-Id: I4303c67f57fc618e911be244091f00bcc2823c91
diff --git a/core/api/src/main/java/org/onosproject/ui/UiSessionToken.java b/core/api/src/main/java/org/onosproject/ui/UiSessionToken.java
new file mode 100644
index 0000000..b4b3d49
--- /dev/null
+++ b/core/api/src/main/java/org/onosproject/ui/UiSessionToken.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2017-present Open Networking Laboratory
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.onosproject.ui;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+/**
+ * Encapsulates a session token pertaining to a Web UI websocket connection.
+ */
+public class UiSessionToken {
+
+ private final String token;
+
+ /**
+ * Creates a session token from the given string.
+ *
+ * @param token the token in string form
+ * @throws NullPointerException if token is null
+ */
+ public UiSessionToken(String token) {
+ this.token = checkNotNull(token);
+ }
+
+ @Override
+ public String toString() {
+ return token;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+
+ UiSessionToken that = (UiSessionToken) o;
+ return token.equals(that.token);
+ }
+
+ @Override
+ public int hashCode() {
+ return token.hashCode();
+ }
+}
diff --git a/core/api/src/main/java/org/onosproject/ui/UiTokenService.java b/core/api/src/main/java/org/onosproject/ui/UiTokenService.java
new file mode 100644
index 0000000..37e209d
--- /dev/null
+++ b/core/api/src/main/java/org/onosproject/ui/UiTokenService.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2017-present Open Networking Laboratory
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.onosproject.ui;
+
+/**
+ * Service for handling UI session tokens.
+ */
+public interface UiTokenService {
+
+ /**
+ * Issues a session token. The service will generate a new token,
+ * publish it in the distributed map of valid UI session tokens, and
+ * return it to the caller.
+ *
+ * @param username the username to be associated with the token.
+ * @return the token
+ */
+ UiSessionToken issueToken(String username);
+
+ /**
+ * Revokes the specified session token. The service will remove the token
+ * from the distributed map of valid UI session tokens.
+ *
+ * @param token the token to be revoked
+ */
+ void revokeToken(UiSessionToken token);
+
+ /**
+ * Returns true if the specified token is currently in the distributed
+ * map of valid UI session tokens.
+ *
+ * @param token the token to check
+ * @return true, if the token is currently valid; false otherwise
+ */
+ boolean isTokenValid(UiSessionToken token);
+}
diff --git a/web/gui/doc/notes-websocket.md b/web/gui/doc/notes-websocket.md
new file mode 100644
index 0000000..6b90d73
--- /dev/null
+++ b/web/gui/doc/notes-websocket.md
@@ -0,0 +1,76 @@
+# UI Web Socket Session Establishment
+
+(1) Web client accesses index.html but is redirected to login page for
+ basic authentication.
+
+(2) `MainIndexResource` (protected page, user is now authenticated) requests
+ a token to be generated by the `UiTokenService`.
+
+(3) `UiTokenService` generates token, adds it to distributed map as
+ entry `{token -> username}`, and returns token to `MainIndexResource`.
+
+(4) `MainIndexResource` embeds username and token in `index.html`.
+
+(5) Web client opens web socket connection (promoted from http). Note that
+ the `UiWebSocket` instance is not marked as "authenticated" yet...
+
+
+(6) `UiWebSocket` sends bootstrap data (list of ONOS cluster node IPs)
+
+(7) Web client sends initial message "uiAuthenticate", along with username
+ and authentication token (picked up from `index.html`).
+
+(8) `UiWebsocket` verifies that token is valid via the `UiTokenService`, and
+ marks itself as "authenticated".
+
+(9) Subsequent `onMessage()` calls to `UiWebSocket` only proceed if
+ "authenticated" is true.
+
+(10) User logs out of ONOS UI, generates onClose() call.
+
+(11) `UiWebSocket` requests the token be revoked.
+
+(12) `UiTokenService` unmaps the token from the distributed map.
+
+
+```
+ WebClient MainIndex UiToken WebSocket
+ ----+---- ----+---- ---+--- ----+----
+ | login* | | | * basic
+(1) o------------------>| | | auth'n
+ | | issueToken(usr) | |
+(2) | o----------------->| |
+ | | o- map token in |
+(3) | | tkn | distrib. map |
+ | index.html(tkn) |<-----------------o |
+(4) |<------------------o | |
+ | | | onOpen |
+(5) o-------------------------------------------------------->|
+ | bootstrapData | | |
+(6) |<--------------------------------------------------------o
+ | | | |
+ | | | onMsg(usr,tkn) |
+(7) o-------------------------------------------------------->|
+ | | | isValid(tkn) |
+(8) | | |<-----------------o
+ | | o----------------->|
+ | | | o- mark socket
+ | | | | valid
+ | | | |
+ | | | onMsg(...) |
+(9) o-------------------------------------------------------->|
+ | | | o- only processed
+ | | | | if socket valid
+
+ : : : :
+
+ | | | onClose |
+(10) o-------------------------------------------------------->|
+ | | | revoke(tkn) |
+(11) | | |<-----------------o
+(12) | | o- unmap token in |
+ | | | distrib. map |
+ | | | |
+```
+
+
diff --git a/web/gui/src/main/java/org/onosproject/ui/impl/MainIndexResource.java b/web/gui/src/main/java/org/onosproject/ui/impl/MainIndexResource.java
index 8713e9e..854c010 100644
--- a/web/gui/src/main/java/org/onosproject/ui/impl/MainIndexResource.java
+++ b/web/gui/src/main/java/org/onosproject/ui/impl/MainIndexResource.java
@@ -22,6 +22,8 @@
import org.onosproject.rest.AbstractInjectionResource;
import org.onosproject.ui.UiExtensionService;
import org.onosproject.ui.UiPreferencesService;
+import org.onosproject.ui.UiSessionToken;
+import org.onosproject.ui.UiTokenService;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
@@ -67,8 +69,12 @@
public Response getMainIndex() throws IOException {
ClassLoader classLoader = getClass().getClassLoader();
UiExtensionService service;
+ UiTokenService tokens;
+
try {
service = get(UiExtensionService.class);
+ tokens = get(UiTokenService.class);
+
} catch (ServiceNotFoundException e) {
return Response.ok(classLoader.getResourceAsStream(NOT_READY)).build();
}
@@ -84,9 +90,17 @@
int p2e = split(index, p2s, INJECT_CSS_END);
int p3s = split(index, p2e, null);
+
// FIXME: use global opaque auth token to allow secure failover
+
+ // for now, just use the user principal name...
String userName = ctx.getUserPrincipal().getName();
- String auth = "var onosAuth='" + userName + "';\n";
+
+ // get a session token to use for UI-web-socket authentication
+ UiSessionToken token = tokens.issueToken(userName);
+
+ String auth = "var onosUser='" + userName + "',\n" +
+ " onosAuth='" + token + "';\n";
StreamEnumeration streams =
new StreamEnumeration(of(stream(index, 0, p0s),
diff --git a/web/gui/src/main/java/org/onosproject/ui/impl/UiExtensionManager.java b/web/gui/src/main/java/org/onosproject/ui/impl/UiExtensionManager.java
index 5e36357..9a016cf 100644
--- a/web/gui/src/main/java/org/onosproject/ui/impl/UiExtensionManager.java
+++ b/web/gui/src/main/java/org/onosproject/ui/impl/UiExtensionManager.java
@@ -50,6 +50,8 @@
import org.onosproject.ui.UiExtensionService;
import org.onosproject.ui.UiMessageHandlerFactory;
import org.onosproject.ui.UiPreferencesService;
+import org.onosproject.ui.UiSessionToken;
+import org.onosproject.ui.UiTokenService;
import org.onosproject.ui.UiTopo2OverlayFactory;
import org.onosproject.ui.UiTopoMap;
import org.onosproject.ui.UiTopoMapFactory;
@@ -62,6 +64,8 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.math.BigInteger;
+import java.security.SecureRandom;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -83,11 +87,13 @@
@Component(immediate = true)
@Service
public class UiExtensionManager
- implements UiExtensionService, UiPreferencesService, SpriteService {
+ implements UiExtensionService, UiPreferencesService, SpriteService,
+ UiTokenService {
private static final ClassLoader CL = UiExtensionManager.class.getClassLoader();
private static final String ONOS_USER_PREFERENCES = "onos-ui-user-preferences";
+ private static final String ONOS_SESSION_TOKENS = "onos-ui-session-tokens";
private static final String CORE = "core";
private static final String GUI_ADDED = "guiAdded";
private static final String GUI_REMOVED = "guiRemoved";
@@ -120,6 +126,12 @@
private final MapEventListener<String, ObjectNode> prefsListener =
new InternalPrefsListener();
+ // Session tokens
+ private ConsistentMap<UiSessionToken, String> tokensConsistentMap;
+ private Map<UiSessionToken, String> tokens;
+ private final SessionTokenGenerator tokenGen =
+ new SessionTokenGenerator();
+
private final ObjectMapper mapper = new ObjectMapper();
private final ExecutorService eventHandlingExecutor =
@@ -216,7 +228,7 @@
JsonNodeFactory.class, LinkedHashMap.class,
TextNode.class, BooleanNode.class,
LongNode.class, DoubleNode.class, ShortNode.class,
- IntNode.class, NullNode.class);
+ IntNode.class, NullNode.class, UiSessionToken.class);
prefsConsistentMap = storageService.<String, ObjectNode>consistentMapBuilder()
.withName(ONOS_USER_PREFERENCES)
@@ -225,6 +237,14 @@
.build();
prefsConsistentMap.addListener(prefsListener);
prefs = prefsConsistentMap.asJavaMap();
+
+ tokensConsistentMap = storageService.<UiSessionToken, String>consistentMapBuilder()
+ .withName(ONOS_SESSION_TOKENS)
+ .withSerializer(serializer)
+ .withRelaxedReadConsistency()
+ .build();
+ tokens = tokensConsistentMap.asJavaMap();
+
register(core);
log.info("Started");
}
@@ -325,6 +345,55 @@
return key.split(SLASH)[IDX_KEY];
}
+
+ // =====================================================================
+ // UiTokenService
+
+ @Override
+ public UiSessionToken issueToken(String username) {
+ UiSessionToken token = new UiSessionToken(tokenGen.nextSessionId());
+ tokens.put(token, username);
+ log.debug("UiSessionToken issued: {}", token);
+ return token;
+ }
+
+ @Override
+ public void revokeToken(UiSessionToken token) {
+ if (token != null) {
+ tokens.remove(token);
+ log.debug("UiSessionToken revoked: {}", token);
+ }
+ }
+
+ @Override
+ public boolean isTokenValid(UiSessionToken token) {
+ return token != null && tokens.containsKey(token);
+ }
+
+ private final class SessionTokenGenerator {
+ private final SecureRandom random = new SecureRandom();
+
+ /*
+ This works by choosing 130 bits from a cryptographically secure
+ random bit generator, and encoding them in base-32.
+
+ 128 bits is considered to be cryptographically strong, but each
+ digit in a base 32 number can encode 5 bits, so 128 is rounded up
+ to the next multiple of 5.
+
+ This encoding is compact and efficient, with 5 random bits per
+ character. Compare this to a random UUID, which only has 3.4 bits
+ per character in standard layout, and only 122 random bits in total.
+
+ Note that SecureRandom objects are expensive to initialize, so
+ we'll want to keep it around and re-use it.
+ */
+
+ private String nextSessionId() {
+ return new BigInteger(130, random).toString(32);
+ }
+ }
+
// Auxiliary listener to preference map events.
private class InternalPrefsListener
implements MapEventListener<String, ObjectNode> {
@@ -340,7 +409,7 @@
private ObjectNode jsonPrefs() {
ObjectNode json = mapper.createObjectNode();
- prefs.entrySet().forEach(e -> json.set(keyName(e.getKey()), e.getValue()));
+ prefs.forEach((key, value) -> json.set(keyName(key), value));
return json;
}
}
diff --git a/web/gui/src/main/java/org/onosproject/ui/impl/UiWebSocket.java b/web/gui/src/main/java/org/onosproject/ui/impl/UiWebSocket.java
index 8bb6244..6c4fc1e 100644
--- a/web/gui/src/main/java/org/onosproject/ui/impl/UiWebSocket.java
+++ b/web/gui/src/main/java/org/onosproject/ui/impl/UiWebSocket.java
@@ -29,6 +29,8 @@
import org.onosproject.ui.UiExtensionService;
import org.onosproject.ui.UiMessageHandler;
import org.onosproject.ui.UiMessageHandlerFactory;
+import org.onosproject.ui.UiSessionToken;
+import org.onosproject.ui.UiTokenService;
import org.onosproject.ui.UiTopo2OverlayFactory;
import org.onosproject.ui.UiTopoLayoutService;
import org.onosproject.ui.UiTopoOverlayFactory;
@@ -57,6 +59,9 @@
private static final String EVENT = "event";
private static final String PAYLOAD = "payload";
private static final String UNKNOWN = "unknown";
+ private static final String AUTHENTICATION = "authentication";
+ private static final String TOKEN = "token";
+ private static final String ERROR = "error";
private static final String ID = "id";
private static final String IP = "ip";
@@ -87,6 +92,9 @@
private TopoOverlayCache overlayCache;
private Topo2OverlayCache overlay2Cache;
+ private UiSessionToken sessionToken;
+
+
/**
* Creates a new web-socket for serving data to the Web UI.
*
@@ -102,8 +110,8 @@
UiTopoLayoutService layoutService = directory.get(UiTopoLayoutService.class);
sharedModel.injectJsonifier(t2json);
-
topoSession = new UiTopoSession(this, t2json, sharedModel, layoutService);
+ sessionToken = null;
}
@Override
@@ -192,6 +200,11 @@
@Override
public synchronized void onClose(int closeCode, String message) {
+ tokenService().revokeToken(sessionToken);
+ log.info("Session token revoked");
+
+ sessionToken = null;
+
topoSession.destroy();
destroyHandlersAndOverlays();
log.info("GUI client disconnected [close-code={}, message={}]",
@@ -210,13 +223,20 @@
try {
ObjectNode message = (ObjectNode) mapper.reader().readTree(data);
String type = message.path(EVENT).asText(UNKNOWN);
- UiMessageHandler handler = handlers.get(type);
- if (handler != null) {
- log.debug("RX message: {}", message);
- handler.process(message);
+
+ if (sessionToken == null) {
+ authenticate(type, message);
+
} else {
- log.warn("No GUI message handler for type {}", type);
+ UiMessageHandler handler = handlers.get(type);
+ if (handler != null) {
+ log.debug("RX message: {}", message);
+ handler.process(message);
+ } else {
+ log.warn("No GUI message handler for type {}", type);
+ }
}
+
} catch (Exception e) {
log.warn("Unable to parse GUI message {} due to {}", data, e);
log.debug("Boom!!!", e);
@@ -277,6 +297,31 @@
log.debug("#handlers = {}, #overlays = {}", handlers.size(), overlayCache.size());
}
+ private void authenticate(String type, ObjectNode message) {
+ if (!AUTHENTICATION.equals(type)) {
+ log.warn("Non-Authenticated Web Socket: {}", message);
+ return;
+ }
+
+ String tokstr = message.path(PAYLOAD).path(TOKEN).asText(UNKNOWN);
+ UiSessionToken token = new UiSessionToken(tokstr);
+
+ if (tokenService().isTokenValid(token)) {
+ sessionToken = token;
+ log.info("Session token authenticated");
+ log.debug("WebSocket authenticated: {}", message);
+ } else {
+ log.warn("Invalid Authentication Token: {}", message);
+ sendMessage(ERROR, notAuthorized(token));
+ }
+ }
+
+ private ObjectNode notAuthorized(UiSessionToken token) {
+ return mapper.createObjectNode()
+ .put("message", "invalid authentication token")
+ .put("badToken", token.toString());
+ }
+
private void registerOverlays(UiExtension ext) {
UiTopoOverlayFactory overlayFactory = ext.topoOverlayFactory();
if (overlayFactory != null) {
@@ -350,4 +395,11 @@
sendMessage(BOOTSTRAP, payload);
}
+ private UiTokenService tokenService() {
+ UiTokenService service = directory.get(UiTokenService.class);
+ if (service == null) {
+ log.error("Unable to reference UiTokenService");
+ }
+ return service;
+ }
}
diff --git a/web/gui/src/main/webapp/app/fw/mast/mast.js b/web/gui/src/main/webapp/app/fw/mast/mast.js
index 4d8b5fa..6483703 100644
--- a/web/gui/src/main/webapp/app/fw/mast/mast.js
+++ b/web/gui/src/main/webapp/app/fw/mast/mast.js
@@ -84,8 +84,8 @@
ns.toggleNav();
};
- // onosAuth is a global set via the index.html generated source
- $scope.user = onosAuth || '(no one)';
+ // onosUser is a global set via the index.html generated source
+ $scope.user = onosUser || '(no one)';
$scope.helpTip = 'Show help page for current view';
$scope.directTo = function () {
diff --git a/web/gui/src/main/webapp/app/fw/remote/websocket.js b/web/gui/src/main/webapp/app/fw/remote/websocket.js
index 55c7b40..5922c60 100644
--- a/web/gui/src/main/webapp/app/fw/remote/websocket.js
+++ b/web/gui/src/main/webapp/app/fw/remote/websocket.js
@@ -38,10 +38,9 @@
nextListenerId = 1, // internal ID for open listeners
loggedInUser = null; // name of logged-in user
- // =======================
- // === Bootstrap Handler
-
+ // built-in handlers
var builtinHandlers = {
+
bootstrap: function (data) {
$log.debug('bootstrap data', data);
loggedInUser = data.user;
@@ -53,6 +52,18 @@
// TODO: add connect info to masthead somewhere
}
});
+ },
+
+ error: function (data) {
+ var m = data.message || 'error from server';
+ $log.error(m, data);
+
+ // Unrecoverable error - throw up the veil...
+ vs && vs.show([
+ 'Oops!',
+ 'Server reports error...',
+ m
+ ]);
}
};
@@ -183,7 +194,7 @@
// Currently supported opts:
// wsport: web socket port (other than default 8181)
- // host: if defined, is the host address to use
+ // host: if defined, is the host address to use
function createWebSocket(opts, _host_) {
var wsport = (opts && opts.wsport) || null;
@@ -198,6 +209,8 @@
ws.onopen = handleOpen;
ws.onmessage = handleMessage;
ws.onclose = handleClose;
+
+ sendEvent('authentication', {token: onosAuth});
}
// Note: Wsock logs an error if the new WebSocket call fails
return url;
diff --git a/web/gui/src/main/webapp/tests/app/fw/remote/websocket-spec.js b/web/gui/src/main/webapp/tests/app/fw/remote/websocket-spec.js
index fc7aa09..69898ef 100644
--- a/web/gui/src/main/webapp/tests/app/fw/remote/websocket-spec.js
+++ b/web/gui/src/main/webapp/tests/app/fw/remote/websocket-spec.js
@@ -121,7 +121,10 @@
payload: { mock: 'thing' }
};
wss.sendEvent(fakeEvent.event, fakeEvent.payload);
- expect(mockWebSocket.send).not.toHaveBeenCalled();
+ // on opening the socket, a single authentication event should have
+ // been sent already...
+ expect(mockWebSocket.send.calls.count()).toEqual(1);
+
wss.createWebSocket({ wsport: 1234 });
mockWebSocket.onopen();
expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(fakeEvent));
diff --git a/web/gui/src/main/webapp/tests/server.mock.js b/web/gui/src/main/webapp/tests/server.mock.js
index 3ecdbdb..3d10b11 100644
--- a/web/gui/src/main/webapp/tests/server.mock.js
+++ b/web/gui/src/main/webapp/tests/server.mock.js
@@ -1,2 +1,3 @@
-onosAuth = '';
+onosUser = 'onos';
+onosAuth = 'foo';
userPrefs = {};
\ No newline at end of file