From d887a216f06bff5fd2f5541bc6fcadfc2251363d Mon Sep 17 00:00:00 2001 From: Juliusz Chroboczek Date: Tue, 2 Jan 2024 18:36:09 +0100 Subject: [PATCH] Rework configuration file format. The "users" entry is now a dictionary mapping user names to passwords and permissions. In order to allow for wildcards, there is a new type of password, the wildcard password, and an extra array called "fallback-users". The field "allow-anonymous" no longer exists, this is now the default behaviour. The field "allow-subgroups" has been renamed to "auto-subgroups". We provide backwards compatibility for group definition files, but not for the config.json file, where the old "admin" array is simply ignored. --- README | 106 +++++---- .../galene-password-generator.go | 14 +- group/client.go | 7 +- group/client_test.go | 28 ++- group/description.go | 202 ++++++++++++++++-- group/description_test.go | 97 +++++++++ group/group.go | 135 +++++------- group/group_test.go | 118 ++++++---- webserver/webserver.go | 17 +- webserver/webserver_test.go | 51 ++++- 10 files changed, 571 insertions(+), 204 deletions(-) create mode 100644 group/description_test.go diff --git a/README b/README index af1ec64..9838fc1 100644 --- a/README +++ b/README @@ -66,14 +66,15 @@ The server may be configured in the JSON file `data/config.json`. This file may look as follows: { - "admin":[{"username":"root","password":"secret"}], + "users":{"root": {"password":"secret", "permissions": "admin"}}, "canonicalHost": "galene.example.org" } The fields are as follows: -- `admin` defines the users allowed to look at the `/stats.html` file; it - has the same syntax as user definitions in groups (see below). +- `users` defines the users allowed to administer the server, and has the + same syntax as user definitions in groups (see below), except that the + only meaningful permission is `"admin"`; - `publicServer`: if true, then cross-origin access to the server is allowed. This is safe if the server is on the public Internet, but not necessarily so if it is on a private network. @@ -100,38 +101,45 @@ a file `groups/teaching/networking.json` defines a group called A typical group definition file looks like this: { - "op":[{"username":"jch","password":"1234"}], + "users":{ + "jch": {"password":"1234", "permissions": "op"} + }, "allow-recording": true, - "allow-subgroups": true + "auto-subgroups": true } This defines a group with the operator (administrator) username *jch* and password *1234*. The `allow-recording` entry says that the operator is -allowed to record videos to disk, and the `allow-subgroups` entry says +allowed to record videos to disk, and the `auto-subgroups` entry says that subgroups will be created automatically. This particular group does not allow password login for ordinary users, and is suitable if you use invitations (see *Stateful Tokens* below) for ordinary users. -In order to allow password login for ordinary users, add a list of users -as the entry `presenter`: +In order to allow password login for ordinary users, add password entries +with the permission `present`: { - "op": [{"username":"jch","password":"1234"}], - "presenter": [{"username":"john", "password": "secret"}] + "users":{ + "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 and an empty password: +username using the `fallback-users` entry:: { - "op": [{"username":"jch","password":"1234"}], - "presenter": [{}], + "users":{ + "jch": {"password":"1234", "permissions": "op"} + }, + "fallback-users": [ + {"password": {"type": "wildcard"}, "permissions": "present"} + ], "public": true } -The empty dictionary `{}` is a wildcard entry: it matches any username and -any password. Setting `public` causes the group to be displayed in the -list of public groups on the landing page +The password `{"type": "wildcard"}` indicates that any password will be +accepted. ## Reference @@ -141,10 +149,12 @@ entries between `{' and `}'). All fields are optional, but unless you specify at least one user definition (`op`, `presenter`, or `other`), nobody will be able to join the group. The following fields are allowed: - - `op`, `presenter`, `other`: each of these is an array of user - definitions (see *Authorisation* below) and specifies the users allowed - to connect respectively with operator privileges, with presenter - privileges, and as passive listeners; + - `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; - `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; @@ -163,7 +173,7 @@ nobody will be able to join the group. The following fields are allowed: - `unrestricted-tokens`: if true, then ordinary users (without the "op" privilege) are allowed to create tokens; - `allow-anonymous`: if true, then users may connect with an empty username; - - `allow-subgroups`: if true, then subgroups of the form `group/subgroup` + - `auto-subgroups`: if true, then subgroups of the form `group/subgroup` are automatically created when first accessed; - `autolock`: if true, the group will start locked and become locked whenever there are no clients with operator privileges; @@ -202,33 +212,34 @@ even Unix passwords). ### Password authorisation When password authorisation is used, authorised usernames and password are -defined directly in the group configuration file, in the `op`, `presenter` -and `other` arrays. Each member of the array is a dictionary, that may -contain the fields `username` and `password`: +defined directly in the group configuration file, in the `users` and +`fallback-users` entries. The `users` entry is a dictionary that maps +user names to user descriptions; the `fallback-users` is a list of user +descriptions that are used with usernames that don't appear in `users`. - - if `username` is present, then the entry only matches clients that - specify this exact username; otherwise, any username matches; - - if `password` is present, then the entry only matches clients that - specify this exact password; otherwise, any password matches. +Every user description is a dictionary with fields `password` and +`permissions`. The `password` field may be a literal password string, or +a dictionary describing a hashed password or a wildcard. The +`permissions` field should be one of `op`, `present` or `passive`. (An +array of Galene's internal permissions is also allowed, but this is not +recommended, since internal permissions may vary from version to version). -For example, the entry +For fexample, the entry - {"username": "jch", "password": "1234"} + "users": {"jch": {"password": "1234", "permissions": "op"}} -specifies username *jch* with password *1234*, while +specifies that user "jch" may login as operator with password "1234", while - {"password": "1234"} + "fallback-users": [{"password": "1234", "permissions": "present"}] -allows any username with password *1234*, and +allows any username with password *1234*. Finally, - {} + "fallback-users": [ + {"password": {"type": "wildcard"}, "permissions": "present"} + ] allows any username with any password. -By default, empty usernames are forbidden; set the `allow-anonymous` -option to allow empty usernames. By default, recording is forbidden; -specify the `allow-recording` option to allow operators to record. - ### Hashed passwords @@ -236,17 +247,20 @@ If you don't wish to store cleartext passwords on the server, you may generate hashed passwords with the `galene-password-generator` utility. A user entry with a hashed password looks like this: - { - "username": "jch", - "password": { - "type": "pbkdf2", - "hash": "sha-256", - "key": "f591c35604e6aef572851d9c3543c812566b032b6dc083c81edd15cc24449913", - "salt": "92bff2ace56fe38f", - "iterations": 4096 + "users": { + "jch": { + "password": { + "type": "pbkdf2", + "hash": "sha-256", + "key": "f591c35604e6aef572851d9c3543c812566b032b6dc083c81edd15cc24449913", + "salt": "92bff2ace56fe38f", + "iterations": 4096 + }, + "permissions": "op" } } + ### Stateful tokens Stateful tokens allow to temporarily grant access to a user. In order to diff --git a/galene-password-generator/galene-password-generator.go b/galene-password-generator/galene-password-generator.go index 5c6f36c..d64aa09 100644 --- a/galene-password-generator/galene-password-generator.go +++ b/galene-password-generator/galene-password-generator.go @@ -24,8 +24,11 @@ func main() { var length int var saltLen int var username string + var permissions string flag.StringVar(&username, "user", "", "generate entry for given `username`") + flag.StringVar(&permissions, "permissions", "present", + "`permissions` for user entry") flag.StringVar(&algorithm, "hash", "pbkdf2", "hashing `algorithm`") flag.IntVar(&iterations, "iterations", 4096, @@ -82,9 +85,14 @@ func main() { e := json.NewEncoder(os.Stdout) if username != "" { - creds := group.ClientPattern{ - Username: username, - Password: &p, + perms, err := group.NewPermissions(permissions) + if err != nil { + log.Fatalf("NewPermissions: %v", err) + } + creds := make(map[string]group.UserDescription) + creds[username] = group.UserDescription{ + Password: p, + Permissions: perms, } err = e.Encode(creds) } else { diff --git a/group/client.go b/group/client.go index 7c4d088..b9e83e7 100644 --- a/group/client.go +++ b/group/client.go @@ -27,7 +27,11 @@ type Password RawPassword func (p Password) Match(pw string) (bool, error) { switch p.Type { case "": + return false, errors.New("missing password") + case "plain": return p.Key == pw, nil + case "wildcard": + return true, nil case "pbkdf2": key, err := hex.DecodeString(p.Key) if err != nil { @@ -64,6 +68,7 @@ func (p *Password) UnmarshalJSON(b []byte) error { err := json.Unmarshal(b, &k) if err == nil { *p = Password{ + Type: "plain", Key: k, } return nil @@ -77,7 +82,7 @@ func (p *Password) UnmarshalJSON(b []byte) error { } func (p Password) MarshalJSON() ([]byte, error) { - if p.Type == "" && p.Hash == "" && p.Salt == "" && p.Iterations == 0 { + if p.Type == "plain" && p.Hash == "" && p.Salt == "" && p.Iterations == 0 { return json.Marshal(p.Key) } return json.Marshal(RawPassword(p)) diff --git a/group/client_test.go b/group/client_test.go index 6b1253c..b2332ab 100644 --- a/group/client_test.go +++ b/group/client_test.go @@ -7,8 +7,13 @@ import ( "testing" ) -var pw1 = Password{} -var pw2 = Password{Key: "pass"} +var pw1 = Password{ + Type: "plain", +} +var pw2 = Password{ + Type: "plain", + Key: "pass", +} var pw3 = Password{ Type: "pbkdf2", Hash: "sha-256", @@ -20,11 +25,15 @@ var pw4 = Password{ Type: "bcrypt", Key: "$2a$10$afOr2f33onT/nDFFyT3mbOq5FMSw1wWXfyTXQTBMbKvZpBkoD3Qwu", } -var pw5 = Password{ +var pw5 = Password{} +var pw6 = Password{ Type: "bad", } func TestGood(t *testing.T) { + if match, err := pw1.Match(""); err != nil || !match { + t.Errorf("pw2 doesn't match (%v)", err) + } if match, err := pw2.Match("pass"); err != nil || !match { t.Errorf("pw2 doesn't match (%v)", err) } @@ -37,9 +46,6 @@ func TestGood(t *testing.T) { } func TestBad(t *testing.T) { - if match, err := pw1.Match("bad"); err != nil || match { - t.Errorf("pw1 matches") - } if match, err := pw2.Match("bad"); err != nil || match { t.Errorf("pw2 matches") } @@ -49,8 +55,14 @@ func TestBad(t *testing.T) { if match, err := pw4.Match("bad"); err != nil || match { t.Errorf("pw4 matches") } + if match, err := pw5.Match(""); err == nil || match { + t.Errorf("pw5 matches") + } if match, err := pw5.Match("bad"); err == nil || match { - t.Errorf("pw4 matches") + t.Errorf("pw5 matches") + } + if match, err := pw6.Match("bad"); err == nil || match { + t.Errorf("pw6 matches") } } @@ -71,7 +83,7 @@ func TestJSON(t *testing.T) { var pw2 Password err = json.Unmarshal(j, &pw2) if err != nil { - t.Fatalf("Unmarshal: %v", err) + t.Errorf("Unmarshal: %v", err) } else if !reflect.DeepEqual(pw, pw2) { t.Errorf("Expected %v, got %v", pw, pw2) } diff --git a/group/description.go b/group/description.go index d2c704e..7a65709 100644 --- a/group/description.go +++ b/group/description.go @@ -2,6 +2,8 @@ package group import ( "encoding/json" + "errors" + "log" "os" "path" "path/filepath" @@ -9,6 +11,106 @@ import ( "time" ) +type Permissions struct { + // non-empty for a named permissions set + name string + // only used when unnamed + permissions []string +} + +var permissionsMap = map[string][]string{ + "op": []string{"op", "present", "token"}, + "present": []string{"present"}, + "observe": []string{}, + "admin": []string{"admin"}, +} + +func NewPermissions(name string) (Permissions, error) { + _, ok := permissionsMap[name] + if !ok { + return Permissions{}, errors.New("unknown permission") + } + return Permissions{ + name: name, + }, nil +} + +func (p Permissions) Permissions(desc *Description) []string { + if p.name == "" { + return p.permissions + } + + perms := permissionsMap[p.name] + + op := false + present := false + token := false + record := false + for _, p := range perms { + switch p { + case "op": + op = true + case "present": + present = true + case "token": + token = true + case "record": + record = true + } + } + + if desc != nil && desc.AllowRecording { + if op && !record { + // copy the slice + perms = append([]string{"record"}, perms...) + } + } + + if desc != nil && desc.UnrestrictedTokens { + if present && !token { + perms = append([]string{"token"}, perms...) + } + } + + return perms +} + +func (p *Permissions) UnmarshalJSON(b []byte) error { + var a []string + err := json.Unmarshal(b, &a) + if err == nil { + *p = Permissions{ + permissions: a, + } + return nil + } + var s string + err = json.Unmarshal(b, &s) + if err == nil { + _, ok := permissionsMap[s] + if !ok { + return errors.New("Unknown permission " + s) + } + *p = Permissions{ + name: s, + } + return nil + } + return err +} + +func (p Permissions) MarshalJSON() ([]byte, error) { + if p.name != "" { + return json.Marshal(p.name) + } + return json.Marshal(p.permissions) +} + +type UserDescription struct { + Password Password `json:"password"` + Permissions Permissions `json:"permissions"` +} + // Description represents a group description together with some metadata // about the JSON file it was deserialised from. type Description struct { @@ -47,14 +149,11 @@ type Description struct { MaxHistoryAge int `json:"max-history-age,omitempty"` // Time after which joining is no longer allowed - Expires *time.Time `json:"expires"` + Expires *time.Time `json:"expires,omitempty"` // Time before which joining is not allowed NotBefore *time.Time `json:"not-before,omitempty"` - // Whether users are allowed to log in with an empty username. - AllowAnonymous bool `json:"allow-anonymous,omitempty"` - // Whether recording is allowed. AllowRecording bool `json:"allow-recording,omitempty"` @@ -62,7 +161,7 @@ type Description struct { UnrestrictedTokens bool `json:"unrestricted-tokens,omitempty"` // Whether subgroups are created on the fly. - AllowSubgroups bool `json:"allow-subgroups,omitempty"` + AutoSubgroups bool `json:"auto-subgroups,omitempty"` // Whether to lock the group when the last op logs out. Autolock bool `json:"autolock,omitempty"` @@ -70,14 +169,11 @@ type Description struct { // Whether to kick all users when the last op logs out. Autokick bool `json:"autokick,omitempty"` - // A list of logins for ops. - Op []ClientPattern `json:"op,omitempty"` + // Users allowed to login + Users map[string]UserDescription `json:"users,omitempty"` - // A list of logins for presenters. - Presenter []ClientPattern `json:"presenter,omitempty"` - - // A list of logins for non-presenting users. - Other []ClientPattern `json:"other,omitempty"` + // Credentials for users with arbitrary username + FallbackUsers []UserDescription `json:"fallback-users,omitempty"` // The (public) keys used for token authentication. AuthKeys []map[string]interface{} `json:"authKeys,omitempty"` @@ -91,6 +187,13 @@ type Description struct { // Codec preferences. If empty, a suitable default is chosen in // the APIFromNames function. Codecs []string `json:"codecs,omitempty"` + + // Obsolete fields + Op []ClientPattern `json:"op,omitempty"` + Presenter []ClientPattern `json:"presenter,omitempty"` + Other []ClientPattern `json:"other,omitempty"` + AllowSubgroups bool `json:"allow-subgroups,omitempty"` + AllowAnonymous bool `json:"allow-anonymous,omitempty"` } const DefaultMaxHistoryAge = 4 * time.Hour @@ -181,7 +284,7 @@ func readDescription(name string) (*Description, error) { return nil, err } if isParent { - if !desc.AllowSubgroups { + if !desc.AutoSubgroups { return nil, os.ErrNotExist } desc.Public = false @@ -192,5 +295,78 @@ func readDescription(name string) (*Description, error) { desc.fileSize = fi.Size() desc.modTime = fi.ModTime() + err = upgradeDescription(&desc) + if err != nil { + return nil, err + } + return &desc, nil } + +func upgradeDescription(desc *Description) error { + if desc.AllowAnonymous { + log.Printf( + "%v: field allow-anonymous is obsolete, ignored", + desc.FileName, + ) + desc.AllowAnonymous = false + } + + if desc.AllowSubgroups { + desc.AutoSubgroups = true + desc.AllowSubgroups = false + } + + upgradePassword := func(pw *Password) Password { + if pw == nil { + return Password{ + Type: "wildcard", + } + } + return *pw + } + + upgradeUser := func(u ClientPattern, p string) UserDescription { + return UserDescription{ + Password: upgradePassword(u.Password), + Permissions: Permissions{ + name: p, + }, + } + } + + upgradeUsers := func(ps []ClientPattern, p string) { + if desc.Users == nil { + desc.Users = make(map[string]UserDescription) + } + for _, u := range ps { + if u.Username == "" { + desc.FallbackUsers = append(desc.FallbackUsers, + upgradeUser(u, p)) + continue + } + _, found := desc.Users[u.Username] + if found { + log.Printf("%v: duplicate user %v, ignored", + desc.FileName, u.Username) + continue + } + desc.Users[u.Username] = upgradeUser(u, p) + } + } + + if desc.Op != nil { + upgradeUsers(desc.Op, "op") + desc.Op = nil + } + if desc.Presenter != nil { + upgradeUsers(desc.Presenter, "present") + desc.Presenter = nil + } + if desc.Other != nil { + upgradeUsers(desc.Other, "observe") + desc.Other = nil + } + + return nil +} diff --git a/group/description_test.go b/group/description_test.go new file mode 100644 index 0000000..28fe4a1 --- /dev/null +++ b/group/description_test.go @@ -0,0 +1,97 @@ +package group + +import ( + "encoding/json" + "reflect" + "testing" +) + +var descJSON = ` +{ + "max-history-age": 10, + "allow-subgroups": true, + "users": { + "jch": {"password": "topsecret", "permissions": "op"}, + "john": {"password": "secret", "permissions": "present"}, + "james": {"password": "secret2", "permissions": "observe"}, + "peter": {"password": "secret4"} + }, + "fallback-users": [ + {"permissions": "observe", "password": {"type":"wildcard"}} + ] +}` + +func TestDescriptionJSON(t *testing.T) { + var d Description + err := json.Unmarshal([]byte(descJSON), &d) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + + dd, err := json.Marshal(d) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var ddd Description + err = json.Unmarshal([]byte(dd), &ddd) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if !reflect.DeepEqual(d, ddd) { + t.Errorf("Got %v, expected %v", ddd, d) + } +} + +var obsoleteJSON = ` +{ + "op": [{"username": "jch","password": "topsecret"}], + "max-history-age": 10, + "allow-subgroups": true, + "presenter": [ + {"username": "john", "password": "secret"} + ], + "other": [ + {"username": "james", "password": "secret2"}, + {"username": "peter", "password": "secret4"}, + {} + ] +}` + +func TestUpgradeDescription(t *testing.T) { + var d1 Description + err := json.Unmarshal([]byte(descJSON), &d1) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + var d2 Description + err = json.Unmarshal([]byte(obsoleteJSON), &d2) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + err = upgradeDescription(&d2) + if err != nil { + t.Fatalf("upgradeDescription: %v", err) + } + + if d2.Op != nil || d2.Presenter != nil || d2.Other != nil { + t.Errorf("legacy field is not nil") + } + + if len(d1.Users) != len(d2.Users) { + t.Errorf("length not equal: %v != %v", + len(d1.Users), len(d2.Users)) + } + + for k, v1 := range d1.Users { + v2 := d2.Users[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) + } + } +} diff --git a/group/group.go b/group/group.go index 27e2391..6f3343c 100644 --- a/group/group.go +++ b/group/group.go @@ -847,43 +847,6 @@ func (g *Group) GetChatHistory() []ChatHistoryEntry { return h } -func matchClient(creds ClientCredentials, users []ClientPattern) (bool, bool) { - if creds.Username == nil { - return false, false - } - username := *creds.Username - - matched := false - for _, u := range users { - if u.Username == username { - matched = true - if u.Password == nil { - return true, true - } - m, _ := u.Password.Match(creds.Password) - if m { - return true, true - } - } - } - if matched { - return true, false - } - - for _, u := range users { - if u.Username == "" { - if u.Password == nil { - return true, true - } - m, _ := u.Password.Match(creds.Password) - if m { - return true, true - } - } - } - return false, false -} - // Configuration represents the contents of the data/config.json file. type Configuration struct { // The modtime and size of the file. These are used to detect @@ -891,10 +854,13 @@ type Configuration struct { modTime time.Time `json:"-"` fileSize int64 `json:"-"` - PublicServer bool `json:"publicServer"` - CanonicalHost string `json:"canonicalHost"` - ProxyURL string `json:"proxyURL"` - Admin []ClientPattern `json:"admin"` + PublicServer bool `json:"publicServer"` + CanonicalHost string `json:"canonicalHost"` + ProxyURL string `json:"proxyURL"` + Users map[string]UserDescription + + // obsolete fields + Admin []ClientPattern `json:"admin"` } func (conf Configuration) Zero() bool { @@ -945,52 +911,56 @@ func GetConfiguration() (*Configuration, error) { if err != nil { return nil, err } + if conf.Admin != nil { + log.Printf("%v: field \"admin\" is obsolete, ignored", filename) + conf.Admin = nil + } configuration.configuration = &conf return configuration.configuration, nil } // called locked -func (g *Group) getPasswordPermission(creds ClientCredentials) ([]string, error) { +func (g *Group) getPasswordPermission(creds ClientCredentials) (Permissions, error) { desc := g.description if creds.Username == nil { - return nil, errors.New("username not provided") + return Permissions{}, errors.New("username not provided") } - if !desc.AllowAnonymous && *creds.Username == "" { - return nil, ErrAnonymousNotAuthorised - } - if found, good := matchClient(creds, desc.Op); found { - if good { - p := []string{"op", "present", "token"} - if desc.AllowRecording { - p = append(p, "record") + if desc.Users != nil { + if c, found := desc.Users[*creds.Username]; found { + ok, err := c.Password.Match(creds.Password) + if err != nil { + return Permissions{}, err } - return p, nil - } - return nil, &NotAuthorisedError{} - } - if found, good := matchClient(creds, desc.Presenter); found { - if good { - p := []string{"present"} - if desc.UnrestrictedTokens { - p = append(p, "token") + if ok { + return c.Permissions, nil + } else { + return Permissions{}, &NotAuthorisedError{} } - return p, nil } - return nil, &NotAuthorisedError{} } - if found, good := matchClient(creds, desc.Other); found { - if good { - p := []string{} - if desc.UnrestrictedTokens { - p = append(p, "token") - } - return p, nil - } - return nil, &NotAuthorisedError{} + for _, c := range desc.FallbackUsers { + if c.Password.Type == "wildcard" { + continue + } + ok, _ := c.Password.Match(creds.Password) + if ok { + return c.Permissions, nil + } } - return nil, &NotAuthorisedError{} + + 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{} } // Return true if there is a user entry with the given username. @@ -1003,21 +973,12 @@ func (g *Group) UserExists(username string) bool { // called locked func (g *Group) userExists(username string) bool { - if username == "" { + desc := g.description + if desc.Users == nil { return false } - - desc := g.description - for _, ps := range [][]ClientPattern{ - desc.Op, desc.Presenter, desc.Other, - } { - for _, p := range ps { - if p.Username == username { - return true - } - } - } - return false + _, found := desc.Users[username] + return found } // called locked @@ -1049,11 +1010,11 @@ func (g *Group) getPermission(creds ClientCredentials) (string, []string, error) } } else if creds.Username != nil { username = *creds.Username - var err error - perms, err = g.getPasswordPermission(creds) + ps, err := g.getPasswordPermission(creds) if err != nil { return "", nil, err } + perms = ps.Permissions(desc) } else { return "", nil, errors.New("neither username nor token provided") } diff --git a/group/group_test.go b/group/group_test.go index 5b77c0c..5c5afc6 100644 --- a/group/group_test.go +++ b/group/group_test.go @@ -4,9 +4,9 @@ import ( "encoding/json" "errors" "fmt" - "reflect" "testing" "time" + "sort" ) func TestGroup(t *testing.T) { @@ -70,50 +70,35 @@ func TestChatHistory(t *testing.T) { } } -var descJSON = ` -{ - "op": [{"username": "jch","password": "topsecret"}], - "max-history-age": 10, - "allow-subgroups": true, - "presenter": [ - {"username": "john", "password": "secret"}, - {"username": "john", "password": "secret2"} - ], - "other": [ - {"username": "james", "password": "secret3"}, - {"username": "peter", "password": "secret4"}, - {} - ] - -}` - -func TestDescriptionJSON(t *testing.T) { - var d Description - err := json.Unmarshal([]byte(descJSON), &d) - if err != nil { - t.Fatalf("unmarshal: %v", err) +func permissionsEqual(a, b []string) bool { + // nil case + if len(a) == 0 && len(b) == 0 { + return true } - - dd, err := json.Marshal(d) - if err != nil { - t.Fatalf("marshal: %v", err) + if len(a) != len(b) { + return false } - - var ddd Description - err = json.Unmarshal([]byte(dd), &ddd) - if err != nil { - t.Fatalf("unmarshal: %v", err) - } - - if !reflect.DeepEqual(d, ddd) { - t.Errorf("Got %v, expected %v", ddd, d) + aa := append([]string(nil), a...) + sort.Slice(aa, func(i, j int) bool { + return aa[i] < aa[j] + }) + bb := append([]string(nil), b...) + sort.Slice(bb, func(i, j int) bool { + return bb[i] < bb[j] + }) + for i := range aa { + if aa[i] != bb[i] { + return false + } } + return true } var jch = "jch" var john = "john" var james = "james" var paul = "paul" +var peter = "peter" var badClients = []ClientCredentials{ {Username: &jch, Password: "foo"}, @@ -136,17 +121,17 @@ var goodClients = []credPerm{ []string{"present"}, }, { - ClientCredentials{Username: &john, Password: "secret2"}, - []string{"present"}, - }, - { - ClientCredentials{Username: &james, Password: "secret3"}, + ClientCredentials{Username: &james, Password: "secret2"}, []string{}, }, { ClientCredentials{Username: &paul, Password: "secret3"}, []string{}, }, + { + ClientCredentials{Username: &peter, Password: "secret4"}, + []string{}, + }, } func TestPermissions(t *testing.T) { @@ -172,7 +157,7 @@ func TestPermissions(t *testing.T) { if err != nil { t.Errorf("GetPermission %v: %v", cp.c, err) } else if u != *cp.c.Username || - !reflect.DeepEqual(p, cp.p) { + !permissionsEqual(p, cp.p) { t.Errorf("%v: got %v %v, expected %v", cp.c, u, p, cp.p) } @@ -181,6 +166,55 @@ func TestPermissions(t *testing.T) { } +func TestExtraPermissions(t *testing.T) { + j := ` +{ + "users": { + "jch": {"password": "topsecret", "permissions": "op"}, + "john": {"password": "secret", "permissions": "present"}, + "james": {"password": "secret2", "permissions": "observe"} + } +}` + + var d Description + err := json.Unmarshal([]byte(j), &d) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + + doit := func(u string, p []string) { + pu := d.Users[u].Permissions.Permissions(&d) + if !permissionsEqual(pu, p) { + t.Errorf("%v: expected %v, got %v", u, p, pu) + } + } + + doit("jch", []string{"op", "token", "present"}) + doit("john", []string{"present"}) + doit("james", []string{}) + + d.AllowRecording = true + d.UnrestrictedTokens = false + + doit("jch", []string{"op", "record", "token", "present"}) + doit("john", []string{"present"}) + doit("james", []string{}) + + d.AllowRecording = false + d.UnrestrictedTokens = true + + doit("jch", []string{"op", "token", "present"}) + doit("john", []string{"token", "present"}) + doit("james", []string{}) + + d.AllowRecording = true + d.UnrestrictedTokens = true + + doit("jch", []string{"op", "record", "token", "present"}) + doit("john", []string{"token", "present"}) + doit("james", []string{}) +} + func TestUsernameTaken(t *testing.T) { var g Group err := json.Unmarshal([]byte(descJSON), &g.description) diff --git a/webserver/webserver.go b/webserver/webserver.go index 55c82f0..672b782 100644 --- a/webserver/webserver.go +++ b/webserver/webserver.go @@ -466,13 +466,24 @@ func adminMatch(username, password string) (bool, error) { return false, err } - for _, cred := range conf.Admin { - if cred.Username == "" || cred.Username == username { - if ok, _ := cred.Password.Match(password); ok { + u, found := conf.Users[username] + if found { + ok, err := u.Password.Match(password) + if err != nil { + return false, err + } + if !ok { + return false, nil + } + perms := u.Permissions.Permissions(nil) + for _, p := range perms { + if p == "admin" { return true, nil } } + return false, nil } + return false, nil } diff --git a/webserver/webserver_test.go b/webserver/webserver_test.go index 150c113..e9e0185 100644 --- a/webserver/webserver_test.go +++ b/webserver/webserver_test.go @@ -8,8 +8,9 @@ import ( "path/filepath" "testing" - "github.com/jech/galene/group" "github.com/pion/webrtc/v3" + + "github.com/jech/galene/group" ) func TestParseGroupName(t *testing.T) { @@ -190,6 +191,54 @@ func TestFormatICEServer(t *testing.T) { } } +func TestMatchAdmin(t *testing.T) { + d := t.TempDir() + group.DataDirectory = d + + filename := filepath.Join(d, "config.json") + f, err := os.Create(filename) + if err != nil { + t.Fatalf("Create %v: %v", filename, err) + } + f.Write([]byte(`{ + "users": { + "root": {"password": "pwd", "permissions": "admin"}, + "notroot": {"password": "pwd"} + } + }`)) + f.Close() + + ok, err := adminMatch("jch", "pwd") + if ok || err != nil { + t.Errorf("jch: %v %v", ok, err) + } + + ok, err = adminMatch("root", "pwd") + if !ok || err != nil { + t.Errorf("root: %v %v", ok, err) + } + + ok, err = adminMatch("root", "notpwd") + if ok || err != nil { + t.Errorf("root: %v %v", ok, err) + } + + ok, err = adminMatch("root", "") + if ok || err != nil { + t.Errorf("root: %v %v", ok, err) + } + + ok, err = adminMatch("notroot", "pwd") + if ok || err != nil { + t.Errorf("notroot: %v %v", ok, err) + } + + ok, err = adminMatch("notroot", "notpwd") + if ok || err != nil { + t.Errorf("notroot: %v %v", ok, err) + } +} + func TestObfuscate(t *testing.T) { id := newId() obfuscated, err := obfuscate(id)