From 9eb0364016c11e290a1d89a6207c7400dbbeea85 Mon Sep 17 00:00:00 2001
From: Juliusz Chroboczek <jch@irif.fr>
Date: Fri, 3 May 2024 19:12:12 +0200
Subject: [PATCH] Allow a single wildcard user.

Rename the fallback-users entry to wildcard-user, and only
allow a single fallback user.  This is missing the HTTP API.
---
 README                    | 25 +++++++++++++++----------
 README.API                | 23 +++++++++++++++--------
 group/description.go      | 23 ++++++++++++++---------
 group/description_test.go | 25 ++++++++++---------------
 group/group.go            | 20 +++-----------------
 webserver/api.go          | 35 -----------------------------------
 webserver/api_test.go     | 17 -----------------
 7 files changed, 57 insertions(+), 111 deletions(-)

diff --git a/README b/README
index f34f226..8d99f48 100644
--- a/README
+++ b/README
@@ -126,27 +126,32 @@ with the permission `present`:
 
     {
         "users":{
-            "jch":  {"password":"1234", "permissions": "op"}
+            "jch":  {"password": "1234", "permissions": "op"}
             "john": {"password": "secret", "permissions": "present"}
         }
     }
 
 If the group is to be publicly accessible, you may allow logins with any
-username using the `fallback-users` entry::
+username using the `wildcard-user` entry::
 
     {
         "users":{
             "jch": {"password":"1234", "permissions": "op"}
         },
-        "fallback-users": [
-            {"password": {"type": "wildcard"}, "permissions": "present"}
-        ],
+        "wildcard-user": {"password": "1234", "permissions": "present"},
         "public": true
     }
 
-The password `{"type": "wildcard"}` indicates that any password will be
-accepted.
+If you want to allow users to use any password, use a wildcard password:
 
+    {
+        "users":{
+            "jch": {"password":"1234", "permissions": "op"}
+        },
+        "wildcard-user":
+            {"password": {"type": "wildcard"}, "permissions": "present"},
+        "public": true
+    }
 
 ## Reference
 
@@ -158,9 +163,9 @@ nobody will be able to join the group.  The following fields are allowed:
  - `users`: is a dictionary that maps user names to dictionaries with
    entries `password` and `permissions`; `permissions` should be one of
    `op`, `present` or `passive`;
- - `fallback-users` is an array of dictionaries with entries `password`
-   and `permissions` that will be used for usernames with no matching
-   entry in the `users` dictionary;
+ - `wildcard-user` is a dictionaries with entries `password` and `permissions`
+   that will be used for usernames with no matching entry in the `users`
+   dictionary;
  - `authKeys`, `authServer` and `authPortal`: see *Authorisation* below;
  - `public`: if true, then the group is listed on the landing page;
  - `displayName`: a human-friendly version of the group name;
diff --git a/README.API b/README.API
index 9c7db8e..b5e9982 100644
--- a/README.API
+++ b/README.API
@@ -55,14 +55,6 @@ on-disk format but without any user definitions or cryptographic keys.
 Allowed methods are HEAD, GET, PUT and DELETE.  The only accepted
 content-type is `application/json`.
 
-### Fallback users
-
-    /galene-api/v0/.groups/groupname/.fallback-users
-
-Contains fallback user descriptions, in the same format as the
-`fallbackUsers` field of the on-disk format.  Allowed methods are PUT and
-DELETE.  The only accepted content-type is `application/json`.
-
 ### Authentication keys
 
     /galene-api/v0/.groups/groupname/.keys
@@ -97,6 +89,21 @@ will be hashed on the server.  Allowed methods are PUT, POST and DELETE.
 Accepted content-types are `application/json` for PUT and `text/plain` for
 POST.
 
+### Wildcard user
+
+    /galene-api/v0/.groups/groupname/.wildcard-user
+
+Contains a dictionary defining the wildcard user, in the same format as
+the dictionary defining an ordinary user.  Allowed methods are HEAD, GET,
+PUT and DELETE.
+
+### Wildcard user password
+
+    /galene-api/v0/.groups/groupname/.wildcard-user/.password
+
+This is analogous to the password of an ordinary user.  Allowed methods
+are PUT, POST and DELETE.
+
 ### List of stateful tokens
 
     /galene-api/v0/.groups/groupname/.users/username/.tokens/
diff --git a/group/description.go b/group/description.go
index ad50ef4..8bb2ac3 100644
--- a/group/description.go
+++ b/group/description.go
@@ -192,8 +192,8 @@ type Description struct {
 	// Users allowed to login
 	Users map[string]UserDescription `json:"users,omitempty"`
 
-	// Credentials for users with arbitrary username
-	FallbackUsers []UserDescription `json:"fallback-users,omitempty"`
+	// Credentials for user with arbitrary username
+	WildcardUser *UserDescription `json:"wildcard-user,omitempty"`
 
 	// The (public) keys used for token authentication.
 	AuthKeys []map[string]interface{} `json:"authKeys,omitempty"`
@@ -298,7 +298,7 @@ func GetSanitisedDescription(name string) (*Description, string, error) {
 
 	desc := *d
 	desc.Users = nil
-	desc.FallbackUsers = nil
+	desc.WildcardUser = nil
 	desc.AuthKeys = nil
 	return &desc, makeETag(desc.fileSize, desc.modTime), nil
 }
@@ -335,7 +335,7 @@ func DeleteDescription(name, etag string) error {
 // UpdateDescription overwrites a description if it matches a given ETag.
 // In order to create a new group, pass an empty ETag.
 func UpdateDescription(name, etag string, desc *Description) error {
-	if desc.Users != nil || desc.FallbackUsers != nil || desc.AuthKeys != nil {
+	if desc.Users != nil || desc.WildcardUser != nil || desc.AuthKeys != nil {
 		return errors.New("description is not sanitised")
 	}
 
@@ -364,7 +364,7 @@ func UpdateDescription(name, etag string, desc *Description) error {
 	newdesc := *desc
 	if old != nil {
 		newdesc.Users = old.Users
-		newdesc.FallbackUsers = old.FallbackUsers
+		newdesc.WildcardUser = old.WildcardUser
 		newdesc.AuthKeys = old.AuthKeys
 	}
 
@@ -500,8 +500,13 @@ func upgradeDescription(desc *Description) error {
 		}
 		for _, u := range ps {
 			if u.Username == "" {
-				desc.FallbackUsers = append(desc.FallbackUsers,
-					upgradeUser(u, p))
+				if desc.WildcardUser != nil {
+					log.Printf("%v: duplicate wildcard user",
+						desc.FileName)
+					continue
+				}
+				u := upgradeUser(u, p)
+				desc.WildcardUser = &u
 				continue
 			}
 			_, found := desc.Users[u.Username]
@@ -559,7 +564,7 @@ func GetDescriptionNames() ([]string, error) {
 	return names, err
 }
 
-func SetFallbackUsers(group string, users []UserDescription) error {
+func SetWildcardUser(group string, user *UserDescription) error {
 	groups.mu.Lock()
 	defer groups.mu.Unlock()
 
@@ -567,7 +572,7 @@ func SetFallbackUsers(group string, users []UserDescription) error {
 	if err != nil {
 		return err
 	}
-	desc.FallbackUsers = users
+	desc.WildcardUser = user
 	return rewriteDescriptionFile(desc.FileName, desc)
 }
 
diff --git a/group/description_test.go b/group/description_test.go
index 96be786..06fbb5c 100644
--- a/group/description_test.go
+++ b/group/description_test.go
@@ -66,9 +66,8 @@ var descJSON = `
         "james": {"password": "secret2", "permissions": "observe"},
         "peter": {"password": "secret4"}
     },
-    "fallback-users": [
+    "wildcard-user":
         {"permissions": "observe", "password": {"type":"wildcard"}}
-    ]
 }`
 
 func TestDescriptionJSON(t *testing.T) {
@@ -150,19 +149,15 @@ func TestUpgradeDescription(t *testing.T) {
 		}
 	}
 
-	if len(d1.FallbackUsers) != len(d2.FallbackUsers) {
-		t.Errorf("length not equal: %v != %v",
-			len(d1.FallbackUsers), len(d2.FallbackUsers))
-	}
-
-	for k, v1 := range d1.FallbackUsers {
-		v2 := d2.FallbackUsers[k]
-		if !reflect.DeepEqual(v1.Password, v2.Password) ||
-			!permissionsEqual(
-				v1.Permissions.Permissions(&d1),
-				v2.Permissions.Permissions(&d2),
-			) {
-			t.Errorf("%v not equal: %v != %v", k, v1, v2)
+	if d1.WildcardUser != nil || d2.WildcardUser != nil {
+		if !reflect.DeepEqual(
+			d1.WildcardUser.Password, d2.WildcardUser.Password,
+		) || !permissionsEqual(
+			d1.WildcardUser.Permissions.Permissions(&d1),
+			d2.WildcardUser.Permissions.Permissions(&d2),
+		) {
+			t.Errorf("WildcardUser not equal: %v != %v",
+				d1.WildcardUser, d2.WildcardUser)
 		}
 	}
 }
diff --git a/group/group.go b/group/group.go
index 2e899f9..191706a 100644
--- a/group/group.go
+++ b/group/group.go
@@ -942,26 +942,12 @@ func (g *Group) getPasswordPermission(creds ClientCredentials) (Permissions, err
 		}
 	}
 
-	for _, c := range desc.FallbackUsers {
-		if c.Password.Type == "wildcard" {
-			continue
-		}
-		ok, _ := c.Password.Match(creds.Password)
+	if desc.WildcardUser != nil {
+		ok, _ := desc.WildcardUser.Password.Match(creds.Password)
 		if ok {
-			return c.Permissions, nil
+			return desc.WildcardUser.Permissions, nil
 		}
 	}
-
-	for _, c := range desc.FallbackUsers {
-		if c.Password.Type != "wildcard" {
-			continue
-		}
-		ok, _ := c.Password.Match(creds.Password)
-		if ok {
-			return c.Permissions, nil
-		}
-	}
-
 	return Permissions{}, &NotAuthorisedError{}
 }
 
diff --git a/webserver/api.go b/webserver/api.go
index 7f908a6..8f8bae0 100644
--- a/webserver/api.go
+++ b/webserver/api.go
@@ -161,9 +161,6 @@ func apiGroupHandler(w http.ResponseWriter, r *http.Request, pth string) {
 	if kind == ".users" {
 		usersHandler(w, r, g, rest)
 		return
-	} else if kind == ".fallback-users" && rest == "" {
-		fallbackUsersHandler(w, r, g)
-		return
 	} else if kind == ".keys" && rest == "" {
 		keysHandler(w, r, g)
 		return
@@ -425,38 +422,6 @@ func passwordHandler(w http.ResponseWriter, r *http.Request, g, user string) {
 	return
 }
 
-func fallbackUsersHandler(w http.ResponseWriter, r *http.Request, g string) {
-	if !checkAdmin(w, r) {
-		return
-	}
-
-	if r.Method == "PUT" {
-		var users []group.UserDescription
-		done := getJSON(w, r, &users)
-		if done {
-			return
-		}
-		err := group.SetFallbackUsers(g, users)
-		if err != nil {
-			httpError(w, err)
-			return
-		}
-		w.WriteHeader(http.StatusNoContent)
-		return
-	} else if r.Method == "DELETE" {
-		err := group.SetFallbackUsers(g, nil)
-		if err != nil {
-			httpError(w, err)
-			return
-		}
-		w.WriteHeader(http.StatusNoContent)
-		return
-	}
-
-	methodNotAllowed(w, "PUT", "DELETE")
-	return
-}
-
 type jwkset = struct {
 	Keys []map[string]any `json:"keys"`
 }
diff --git a/webserver/api_test.go b/webserver/api_test.go
index 1e5418b..9c1a661 100644
--- a/webserver/api_test.go
+++ b/webserver/api_test.go
@@ -159,13 +159,6 @@ func TestApi(t *testing.T) {
 		t.Errorf("Get groups: %v %v", err, groups)
 	}
 
-	resp, err = do("PUT", "/galene-api/v0/.groups/test/.fallback-users",
-		"application/json", "", "",
-		`[{"password": "topsecret"}]`)
-	if err != nil || resp.StatusCode != http.StatusNoContent {
-		t.Errorf("Set fallback users: %v %v", err, resp.StatusCode)
-	}
-
 	resp, err = do("PUT", "/galene-api/v0/.groups/test/.keys",
 		"application/jwk-set+json", "", "",
 		`{"keys": [{
@@ -260,10 +253,6 @@ func TestApi(t *testing.T) {
 		t.Errorf("Users (after delete): %#v", desc.Users)
 	}
 
-	if len(desc.FallbackUsers) != 1 {
-		t.Errorf("Keys: %v", len(desc.AuthKeys))
-	}
-
 	if len(desc.AuthKeys) != 1 {
 		t.Errorf("Keys: %v", len(desc.AuthKeys))
 	}
@@ -344,12 +333,6 @@ func TestApi(t *testing.T) {
 		t.Errorf("Token list: %v %v", tokens, err)
 	}
 
-	resp, err = do("DELETE", "/galene-api/v0/.groups/test/.fallback-users",
-		"", "", "", "")
-	if err != nil || resp.StatusCode != http.StatusNoContent {
-		t.Errorf("Delete fallback users: %v %v", err, resp.StatusCode)
-	}
-
 	resp, err = do("DELETE", "/galene-api/v0/.groups/test/.keys",
 		"", "", "", "")
 	if err != nil || resp.StatusCode != http.StatusNoContent {
