Browse Source

Fully support auth in Web UI; persist users in localStorage (for now); add ugly ?auth=... param

Philipp Heckel 3 years ago
parent
commit
530f55c234

+ 23 - 2
server/server.go

@@ -862,7 +862,7 @@ func parseSince(r *http.Request, poll bool) (sinceTime, error) {
 func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request) error {
 	w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST")
 	w.Header().Set("Access-Control-Allow-Origin", "*")              // CORS, allow cross-origin requests
-	w.Header().Set("Access-Control-Allow-Headers", "Authorization") // CORS, allow auth
+	w.Header().Set("Access-Control-Allow-Headers", "Authorization") // CORS, allow auth via JS
 	return nil
 }
 
@@ -1091,7 +1091,7 @@ func (s *Server) withAuth(next handleFunc, perm auth.Permission) handleFunc {
 			return err
 		}
 		var user *auth.User // may stay nil if no auth header!
-		username, password, ok := r.BasicAuth()
+		username, password, ok := extractUserPass(r)
 		if ok {
 			if user, err = s.auth.Authenticate(username, password); err != nil {
 				log.Printf("authentication failed: %s", err.Error())
@@ -1108,6 +1108,27 @@ func (s *Server) withAuth(next handleFunc, perm auth.Permission) handleFunc {
 	}
 }
 
+// extractUserPass reads the username/password from the basic auth header (Authorization: Basic ...),
+// or from the ?auth=... query param. The latter is required only to support the WebSocket JavaScript
+// class, which does not support passing headers during the initial request. The auth query param
+// is effectively double base64 encoded. Its format is base64(Basic base64(user:pass)).
+func extractUserPass(r *http.Request) (username string, password string, ok bool) {
+	username, password, ok = r.BasicAuth()
+	if ok {
+		return
+	}
+	authParam := readQueryParam(r, "authorization", "auth")
+	if authParam != "" {
+		a, err := base64.RawURLEncoding.DecodeString(authParam)
+		if err != nil {
+			return
+		}
+		r.Header.Set("Authorization", string(a))
+		return r.BasicAuth()
+	}
+	return
+}
+
 // visitor creates or retrieves a rate.Limiter for the given visitor.
 // This function was taken from https://www.alexedwards.net/blog/how-to-rate-limit-http-requests (MIT).
 func (s *Server) visitor(r *http.Request) *visitor {

+ 19 - 0
server/server_test.go

@@ -657,6 +657,25 @@ func TestServer_Auth_Fail_CannotPublish(t *testing.T) {
 	require.Equal(t, 403, response.Code) // Anonymous read not allowed
 }
 
+func TestServer_Auth_ViaQuery(t *testing.T) {
+	c := newTestConfig(t)
+	c.AuthFile = filepath.Join(t.TempDir(), "user.db")
+	c.AuthDefaultRead = false
+	c.AuthDefaultWrite = false
+	s := newTestServer(t, c)
+
+	manager := s.auth.(auth.Manager)
+	require.Nil(t, manager.AddUser("ben", "some pass", auth.RoleAdmin))
+
+	u := fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(basicAuth("ben:some pass"))))
+	response := request(t, s, "GET", u, "", nil)
+	require.Equal(t, 200, response.Code)
+
+	u = fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(basicAuth("ben:WRONNNGGGG"))))
+	response = request(t, s, "GET", u, "", nil)
+	require.Equal(t, 401, response.Code)
+}
+
 /*
 func TestServer_Curl_Publish_Poll(t *testing.T) {
 	s, port := test.StartServer(t)

+ 12 - 0
server/util.go

@@ -14,12 +14,24 @@ func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
 }
 
 func readParam(r *http.Request, names ...string) string {
+	value := readHeaderParam(r, names...)
+	if value != "" {
+		return value
+	}
+	return readQueryParam(r, names...)
+}
+
+func readHeaderParam(r *http.Request, names ...string) string {
 	for _, name := range names {
 		value := r.Header.Get(name)
 		if value != "" {
 			return strings.TrimSpace(value)
 		}
 	}
+	return ""
+}
+
+func readQueryParam(r *http.Request, names ...string) string {
 	for _, name := range names {
 		value := r.URL.Query().Get(strings.ToLower(name))
 		if value != "" {

+ 8 - 15
web/src/app/Api.js

@@ -1,31 +1,32 @@
-import {topicUrlJsonPoll, fetchLinesIterator, topicUrl, topicUrlAuth} from "./utils";
+import {topicUrlJsonPoll, fetchLinesIterator, topicUrl, topicUrlAuth, maybeWithBasicAuth} from "./utils";
 
 class Api {
-    async poll(baseUrl, topic) {
+    async poll(baseUrl, topic, user) {
         const url = topicUrlJsonPoll(baseUrl, topic);
         const messages = [];
+        const headers = maybeWithBasicAuth({}, user);
         console.log(`[Api] Polling ${url}`);
-        for await (let line of fetchLinesIterator(url)) {
+        for await (let line of fetchLinesIterator(url, headers)) {
             messages.push(JSON.parse(line));
         }
         return messages;
     }
 
-    async publish(baseUrl, topic, message) {
+    async publish(baseUrl, topic, user, message) {
         const url = topicUrl(baseUrl, topic);
         console.log(`[Api] Publishing message to ${url}`);
         await fetch(url, {
             method: 'PUT',
-            body: message
+            body: message,
+            headers: maybeWithBasicAuth({}, user)
         });
     }
 
     async auth(baseUrl, topic, user) {
         const url = topicUrlAuth(baseUrl, topic);
         console.log(`[Api] Checking auth for ${url}`);
-        const headers = this.maybeAddAuthorization({}, user);
         const response = await fetch(url, {
-            headers: headers
+            headers: maybeWithBasicAuth({}, user)
         });
         if (response.status >= 200 && response.status <= 299) {
             return true;
@@ -36,14 +37,6 @@ class Api {
         }
         throw new Error(`Unexpected server response ${response.status}`);
     }
-
-    maybeAddAuthorization(headers, user) {
-        if (user) {
-            const encoded = new Buffer(`${user.username}:${user.password}`).toString('base64');
-            headers['Authorization'] = `Basic ${encoded}`;
-        }
-        return headers;
-    }
 }
 
 const api = new Api();

+ 20 - 6
web/src/app/Connection.js

@@ -1,14 +1,15 @@
-import {shortTopicUrl, topicUrlWs, topicUrlWsWithSince} from "./utils";
+import {basicAuth, encodeBase64Url, topicShortUrl, topicUrlWs} from "./utils";
 
 const retryBackoffSeconds = [5, 10, 15, 20, 30, 45];
 
 class Connection {
-    constructor(subscriptionId, baseUrl, topic, since, onNotification) {
+    constructor(subscriptionId, baseUrl, topic, user, since, onNotification) {
         this.subscriptionId = subscriptionId;
         this.baseUrl = baseUrl;
         this.topic = topic;
+        this.user = user;
         this.since = since;
-        this.shortUrl = shortTopicUrl(baseUrl, topic);
+        this.shortUrl = topicShortUrl(baseUrl, topic);
         this.onNotification = onNotification;
         this.ws = null;
         this.retryCount = 0;
@@ -18,10 +19,10 @@ class Connection {
     start() {
         // Don't fetch old messages; we do that as a poll() when adding a subscription;
         // we don't want to re-trigger the main view re-render potentially hundreds of times.
-        const wsUrl = (this.since === 0)
-            ? topicUrlWs(this.baseUrl, this.topic)
-            : topicUrlWsWithSince(this.baseUrl, this.topic, this.since.toString());
+
+        const wsUrl = this.wsUrl();
         console.log(`[Connection, ${this.shortUrl}] Opening connection to ${wsUrl}`);
+
         this.ws = new WebSocket(wsUrl);
         this.ws.onopen = (event) => {
             console.log(`[Connection, ${this.shortUrl}] Connection established`, event);
@@ -75,6 +76,19 @@ class Connection {
         this.retryTimeout = null;
         this.ws = null;
     }
+
+    wsUrl() {
+        const params = [];
+        if (this.since > 0) {
+            params.push(`since=${this.since.toString()}`);
+        }
+        if (this.user !== null) {
+            const auth = encodeBase64Url(basicAuth(this.user.username, this.user.password));
+            params.push(`auth=${auth}`);
+        }
+        const wsUrl = topicUrlWs(this.baseUrl, this.topic);
+        return (params.length === 0) ? wsUrl : `${wsUrl}?${params.join('&')}`;
+    }
 }
 
 export default Connection;

+ 4 - 3
web/src/app/ConnectionManager.js

@@ -1,11 +1,11 @@
 import Connection from "./Connection";
 
-export class ConnectionManager {
+class ConnectionManager {
     constructor() {
         this.connections = new Map();
     }
 
-    refresh(subscriptions, onNotification) {
+    refresh(subscriptions, users, onNotification) {
         console.log(`[ConnectionManager] Refreshing connections`);
         const subscriptionIds = subscriptions.ids();
         const deletedIds = Array.from(this.connections.keys()).filter(id => !subscriptionIds.includes(id));
@@ -16,8 +16,9 @@ export class ConnectionManager {
             if (added) {
                 const baseUrl = subscription.baseUrl;
                 const topic = subscription.topic;
+                const user = users.get(baseUrl);
                 const since = 0;
-                const connection = new Connection(id, baseUrl, topic, since, onNotification);
+                const connection = new Connection(id, baseUrl, topic, user, since, onNotification);
                 this.connections.set(id, connection);
                 console.log(`[ConnectionManager] Starting new connection ${id}`);
                 connection.start();

+ 20 - 13
web/src/app/Repository.js

@@ -1,7 +1,9 @@
 import Subscription from "./Subscription";
 import Subscriptions from "./Subscriptions";
+import Users from "./Users";
+import User from "./User";
 
-export class Repository {
+class Repository {
     loadSubscriptions() {
         console.log(`[Repository] Loading subscriptions from localStorage`);
         const subscriptions = new Subscriptions();
@@ -10,8 +12,7 @@ export class Repository {
             return subscriptions;
         }
         try {
-            const serializedSubscriptions = JSON.parse(serialized);
-            serializedSubscriptions.forEach(s => {
+            JSON.parse(serialized).forEach(s => {
                 const subscription = new Subscription(s.baseUrl, s.topic);
                 subscription.addNotifications(s.notifications);
                 subscriptions.add(subscription);
@@ -39,26 +40,32 @@ export class Repository {
 
     loadUsers() {
         console.log(`[Repository] Loading users from localStorage`);
+        const users = new Users();
         const serialized = localStorage.getItem('users');
         if (serialized === null) {
-            return {};
+            return users;
         }
         try {
-            return JSON.parse(serialized);
+            JSON.parse(serialized).forEach(u => {
+                users.add(new User(u.baseUrl, u.username, u.password));
+            });
+            return users;
         } catch (e) {
             console.log(`[Repository] Unable to deserialize users: ${e.message}`);
-            return {};
+            return users;
         }
     }
 
-    saveUser(baseUrl, username, password) {
+    saveUsers(users) {
         console.log(`[Repository] Saving users to localStorage`);
-        const users = this.loadUsers();
-        users[baseUrl] = {
-            username: username,
-            password: password
-        };
-        localStorage.setItem('users', users);
+        const serialized = JSON.stringify(users.map(user => {
+            return {
+                baseUrl: user.baseUrl,
+                username: user.username,
+                password: user.password
+            }
+        }));
+        localStorage.setItem('users', serialized);
     }
 }
 

+ 5 - 3
web/src/app/Subscription.js

@@ -1,6 +1,6 @@
-import {shortTopicUrl, topicUrl} from './utils';
+import {topicShortUrl, topicUrl} from './utils';
 
-export default class Subscription {
+class Subscription {
     constructor(baseUrl, topic) {
         this.id = topicUrl(baseUrl, topic);
         this.baseUrl = baseUrl;
@@ -40,6 +40,8 @@ export default class Subscription {
     }
 
     shortUrl() {
-        return shortTopicUrl(this.baseUrl, this.topic);
+        return topicShortUrl(this.baseUrl, this.topic);
     }
 }
+
+export default Subscription;

+ 9 - 0
web/src/app/User.js

@@ -0,0 +1,9 @@
+class User {
+    constructor(baseUrl, username, password) {
+        this.baseUrl = baseUrl;
+        this.username = username;
+        this.password = password;
+    }
+}
+
+export default User;

+ 36 - 0
web/src/app/Users.js

@@ -0,0 +1,36 @@
+class Users {
+    constructor() {
+        this.users = new Map();
+    }
+
+    add(user) {
+        this.users.set(user.baseUrl, user);
+        return this;
+    }
+
+    get(baseUrl) {
+        const user = this.users.get(baseUrl);
+        return (user) ? user : null;
+    }
+
+    update(user) {
+        return this.add(user);
+    }
+
+    remove(baseUrl) {
+        this.users.delete(baseUrl);
+        return this;
+    }
+
+    map(cb) {
+        return Array.from(this.users.values()).map(cb);
+    }
+
+    clone() {
+        const c = new Users();
+        c.users = new Map(this.users);
+        return c;
+    }
+}
+
+export default Users;

Some files were not shown because too many files changed in this diff