1
Fork 0

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.
This commit is contained in:
Juliusz Chroboczek 2024-01-02 18:36:09 +01:00
parent eb54f2a9bb
commit d887a216f0
10 changed files with 571 additions and 204 deletions

106
README
View File

@ -66,14 +66,15 @@ The server may be configured in the JSON file `data/config.json`. This
file may look as follows: file may look as follows:
{ {
"admin":[{"username":"root","password":"secret"}], "users":{"root": {"password":"secret", "permissions": "admin"}},
"canonicalHost": "galene.example.org" "canonicalHost": "galene.example.org"
} }
The fields are as follows: The fields are as follows:
- `admin` defines the users allowed to look at the `/stats.html` file; it - `users` defines the users allowed to administer the server, and has the
has the same syntax as user definitions in groups (see below). 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 - `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 allowed. This is safe if the server is on the public Internet, but not
necessarily so if it is on a private network. 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: A typical group definition file looks like this:
{ {
"op":[{"username":"jch","password":"1234"}], "users":{
"jch": {"password":"1234", "permissions": "op"}
},
"allow-recording": true, "allow-recording": true,
"allow-subgroups": true "auto-subgroups": true
} }
This defines a group with the operator (administrator) username *jch* and This defines a group with the operator (administrator) username *jch* and
password *1234*. The `allow-recording` entry says that the operator is 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 that subgroups will be created automatically. This particular group does
not allow password login for ordinary users, and is suitable if you use not allow password login for ordinary users, and is suitable if you use
invitations (see *Stateful Tokens* below) for ordinary users. invitations (see *Stateful Tokens* below) for ordinary users.
In order to allow password login for ordinary users, add a list of users In order to allow password login for ordinary users, add password entries
as the entry `presenter`: with the permission `present`:
{ {
"op": [{"username":"jch","password":"1234"}], "users":{
"presenter": [{"username":"john", "password": "secret"}] "jch": {"password":"1234", "permissions": "op"}
"john": {"password": "secret", "permissions": "present"}
}
} }
If the group is to be publicly accessible, you may allow logins with any 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"}], "users":{
"presenter": [{}], "jch": {"password":"1234", "permissions": "op"}
},
"fallback-users": [
{"password": {"type": "wildcard"}, "permissions": "present"}
],
"public": true "public": true
} }
The empty dictionary `{}` is a wildcard entry: it matches any username and The password `{"type": "wildcard"}` indicates that any password will be
any password. Setting `public` causes the group to be displayed in the accepted.
list of public groups on the landing page
## Reference ## 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`), specify at least one user definition (`op`, `presenter`, or `other`),
nobody will be able to join the group. The following fields are allowed: nobody will be able to join the group. The following fields are allowed:
- `op`, `presenter`, `other`: each of these is an array of user - `users`: is a dictionary that maps user names to dictionaries with
definitions (see *Authorisation* below) and specifies the users allowed entries `password` and `permissions`; `permissions` should be one of
to connect respectively with operator privileges, with presenter `op`, `present` or `passive`;
privileges, and as passive listeners; - `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; - `authKeys`, `authServer` and `authPortal`: see *Authorisation* below;
- `public`: if true, then the group is listed on the landing page; - `public`: if true, then the group is listed on the landing page;
- `displayName`: a human-friendly version of the group name; - `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" - `unrestricted-tokens`: if true, then ordinary users (without the "op"
privilege) are allowed to create tokens; privilege) are allowed to create tokens;
- `allow-anonymous`: if true, then users may connect with an empty username; - `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; are automatically created when first accessed;
- `autolock`: if true, the group will start locked and become locked - `autolock`: if true, the group will start locked and become locked
whenever there are no clients with operator privileges; whenever there are no clients with operator privileges;
@ -202,33 +212,34 @@ even Unix passwords).
### Password authorisation ### Password authorisation
When password authorisation is used, authorised usernames and password are When password authorisation is used, authorised usernames and password are
defined directly in the group configuration file, in the `op`, `presenter` defined directly in the group configuration file, in the `users` and
and `other` arrays. Each member of the array is a dictionary, that may `fallback-users` entries. The `users` entry is a dictionary that maps
contain the fields `username` and `password`: 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 Every user description is a dictionary with fields `password` and
specify this exact username; otherwise, any username matches; `permissions`. The `password` field may be a literal password string, or
- if `password` is present, then the entry only matches clients that a dictionary describing a hashed password or a wildcard. The
specify this exact password; otherwise, any password matches. `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. 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 ### 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 generate hashed passwords with the `galene-password-generator` utility. A
user entry with a hashed password looks like this: user entry with a hashed password looks like this:
{ "users": {
"username": "jch", "jch": {
"password": { "password": {
"type": "pbkdf2", "type": "pbkdf2",
"hash": "sha-256", "hash": "sha-256",
"key": "f591c35604e6aef572851d9c3543c812566b032b6dc083c81edd15cc24449913", "key": "f591c35604e6aef572851d9c3543c812566b032b6dc083c81edd15cc24449913",
"salt": "92bff2ace56fe38f", "salt": "92bff2ace56fe38f",
"iterations": 4096 "iterations": 4096
},
"permissions": "op"
} }
} }
### Stateful tokens ### Stateful tokens
Stateful tokens allow to temporarily grant access to a user. In order to Stateful tokens allow to temporarily grant access to a user. In order to

View File

@ -24,8 +24,11 @@ func main() {
var length int var length int
var saltLen int var saltLen int
var username string var username string
var permissions string
flag.StringVar(&username, "user", "", flag.StringVar(&username, "user", "",
"generate entry for given `username`") "generate entry for given `username`")
flag.StringVar(&permissions, "permissions", "present",
"`permissions` for user entry")
flag.StringVar(&algorithm, "hash", "pbkdf2", flag.StringVar(&algorithm, "hash", "pbkdf2",
"hashing `algorithm`") "hashing `algorithm`")
flag.IntVar(&iterations, "iterations", 4096, flag.IntVar(&iterations, "iterations", 4096,
@ -82,9 +85,14 @@ func main() {
e := json.NewEncoder(os.Stdout) e := json.NewEncoder(os.Stdout)
if username != "" { if username != "" {
creds := group.ClientPattern{ perms, err := group.NewPermissions(permissions)
Username: username, if err != nil {
Password: &p, log.Fatalf("NewPermissions: %v", err)
}
creds := make(map[string]group.UserDescription)
creds[username] = group.UserDescription{
Password: p,
Permissions: perms,
} }
err = e.Encode(creds) err = e.Encode(creds)
} else { } else {

View File

@ -27,7 +27,11 @@ type Password RawPassword
func (p Password) Match(pw string) (bool, error) { func (p Password) Match(pw string) (bool, error) {
switch p.Type { switch p.Type {
case "": case "":
return false, errors.New("missing password")
case "plain":
return p.Key == pw, nil return p.Key == pw, nil
case "wildcard":
return true, nil
case "pbkdf2": case "pbkdf2":
key, err := hex.DecodeString(p.Key) key, err := hex.DecodeString(p.Key)
if err != nil { if err != nil {
@ -64,6 +68,7 @@ func (p *Password) UnmarshalJSON(b []byte) error {
err := json.Unmarshal(b, &k) err := json.Unmarshal(b, &k)
if err == nil { if err == nil {
*p = Password{ *p = Password{
Type: "plain",
Key: k, Key: k,
} }
return nil return nil
@ -77,7 +82,7 @@ func (p *Password) UnmarshalJSON(b []byte) error {
} }
func (p Password) MarshalJSON() ([]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(p.Key)
} }
return json.Marshal(RawPassword(p)) return json.Marshal(RawPassword(p))

View File

@ -7,8 +7,13 @@ import (
"testing" "testing"
) )
var pw1 = Password{} var pw1 = Password{
var pw2 = Password{Key: "pass"} Type: "plain",
}
var pw2 = Password{
Type: "plain",
Key: "pass",
}
var pw3 = Password{ var pw3 = Password{
Type: "pbkdf2", Type: "pbkdf2",
Hash: "sha-256", Hash: "sha-256",
@ -20,11 +25,15 @@ var pw4 = Password{
Type: "bcrypt", Type: "bcrypt",
Key: "$2a$10$afOr2f33onT/nDFFyT3mbOq5FMSw1wWXfyTXQTBMbKvZpBkoD3Qwu", Key: "$2a$10$afOr2f33onT/nDFFyT3mbOq5FMSw1wWXfyTXQTBMbKvZpBkoD3Qwu",
} }
var pw5 = Password{ var pw5 = Password{}
var pw6 = Password{
Type: "bad", Type: "bad",
} }
func TestGood(t *testing.T) { 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 { if match, err := pw2.Match("pass"); err != nil || !match {
t.Errorf("pw2 doesn't match (%v)", err) t.Errorf("pw2 doesn't match (%v)", err)
} }
@ -37,9 +46,6 @@ func TestGood(t *testing.T) {
} }
func TestBad(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 { if match, err := pw2.Match("bad"); err != nil || match {
t.Errorf("pw2 matches") t.Errorf("pw2 matches")
} }
@ -49,8 +55,14 @@ func TestBad(t *testing.T) {
if match, err := pw4.Match("bad"); err != nil || match { if match, err := pw4.Match("bad"); err != nil || match {
t.Errorf("pw4 matches") 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 { 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 var pw2 Password
err = json.Unmarshal(j, &pw2) err = json.Unmarshal(j, &pw2)
if err != nil { if err != nil {
t.Fatalf("Unmarshal: %v", err) t.Errorf("Unmarshal: %v", err)
} else if !reflect.DeepEqual(pw, pw2) { } else if !reflect.DeepEqual(pw, pw2) {
t.Errorf("Expected %v, got %v", pw, pw2) t.Errorf("Expected %v, got %v", pw, pw2)
} }

View File

@ -2,6 +2,8 @@ package group
import ( import (
"encoding/json" "encoding/json"
"errors"
"log"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
@ -9,6 +11,106 @@ import (
"time" "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 // Description represents a group description together with some metadata
// about the JSON file it was deserialised from. // about the JSON file it was deserialised from.
type Description struct { type Description struct {
@ -47,14 +149,11 @@ type Description struct {
MaxHistoryAge int `json:"max-history-age,omitempty"` MaxHistoryAge int `json:"max-history-age,omitempty"`
// Time after which joining is no longer allowed // 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 // Time before which joining is not allowed
NotBefore *time.Time `json:"not-before,omitempty"` 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. // Whether recording is allowed.
AllowRecording bool `json:"allow-recording,omitempty"` AllowRecording bool `json:"allow-recording,omitempty"`
@ -62,7 +161,7 @@ type Description struct {
UnrestrictedTokens bool `json:"unrestricted-tokens,omitempty"` UnrestrictedTokens bool `json:"unrestricted-tokens,omitempty"`
// Whether subgroups are created on the fly. // 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. // Whether to lock the group when the last op logs out.
Autolock bool `json:"autolock,omitempty"` Autolock bool `json:"autolock,omitempty"`
@ -70,14 +169,11 @@ type Description struct {
// Whether to kick all users when the last op logs out. // Whether to kick all users when the last op logs out.
Autokick bool `json:"autokick,omitempty"` Autokick bool `json:"autokick,omitempty"`
// A list of logins for ops. // Users allowed to login
Op []ClientPattern `json:"op,omitempty"` Users map[string]UserDescription `json:"users,omitempty"`
// A list of logins for presenters. // Credentials for users with arbitrary username
Presenter []ClientPattern `json:"presenter,omitempty"` FallbackUsers []UserDescription `json:"fallback-users,omitempty"`
// A list of logins for non-presenting users.
Other []ClientPattern `json:"other,omitempty"`
// The (public) keys used for token authentication. // The (public) keys used for token authentication.
AuthKeys []map[string]interface{} `json:"authKeys,omitempty"` AuthKeys []map[string]interface{} `json:"authKeys,omitempty"`
@ -91,6 +187,13 @@ type Description struct {
// Codec preferences. If empty, a suitable default is chosen in // Codec preferences. If empty, a suitable default is chosen in
// the APIFromNames function. // the APIFromNames function.
Codecs []string `json:"codecs,omitempty"` 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 const DefaultMaxHistoryAge = 4 * time.Hour
@ -181,7 +284,7 @@ func readDescription(name string) (*Description, error) {
return nil, err return nil, err
} }
if isParent { if isParent {
if !desc.AllowSubgroups { if !desc.AutoSubgroups {
return nil, os.ErrNotExist return nil, os.ErrNotExist
} }
desc.Public = false desc.Public = false
@ -192,5 +295,78 @@ func readDescription(name string) (*Description, error) {
desc.fileSize = fi.Size() desc.fileSize = fi.Size()
desc.modTime = fi.ModTime() desc.modTime = fi.ModTime()
err = upgradeDescription(&desc)
if err != nil {
return nil, err
}
return &desc, nil 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
}

97
group/description_test.go Normal file
View File

@ -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)
}
}
}

View File

@ -847,43 +847,6 @@ func (g *Group) GetChatHistory() []ChatHistoryEntry {
return h 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. // Configuration represents the contents of the data/config.json file.
type Configuration struct { type Configuration struct {
// The modtime and size of the file. These are used to detect // The modtime and size of the file. These are used to detect
@ -891,10 +854,13 @@ type Configuration struct {
modTime time.Time `json:"-"` modTime time.Time `json:"-"`
fileSize int64 `json:"-"` fileSize int64 `json:"-"`
PublicServer bool `json:"publicServer"` PublicServer bool `json:"publicServer"`
CanonicalHost string `json:"canonicalHost"` CanonicalHost string `json:"canonicalHost"`
ProxyURL string `json:"proxyURL"` ProxyURL string `json:"proxyURL"`
Admin []ClientPattern `json:"admin"` Users map[string]UserDescription
// obsolete fields
Admin []ClientPattern `json:"admin"`
} }
func (conf Configuration) Zero() bool { func (conf Configuration) Zero() bool {
@ -945,52 +911,56 @@ func GetConfiguration() (*Configuration, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if conf.Admin != nil {
log.Printf("%v: field \"admin\" is obsolete, ignored", filename)
conf.Admin = nil
}
configuration.configuration = &conf configuration.configuration = &conf
return configuration.configuration, nil return configuration.configuration, nil
} }
// called locked // called locked
func (g *Group) getPasswordPermission(creds ClientCredentials) ([]string, error) { func (g *Group) getPasswordPermission(creds ClientCredentials) (Permissions, error) {
desc := g.description desc := g.description
if creds.Username == nil { if creds.Username == nil {
return nil, errors.New("username not provided") return Permissions{}, errors.New("username not provided")
} }
if !desc.AllowAnonymous && *creds.Username == "" { if desc.Users != nil {
return nil, ErrAnonymousNotAuthorised if c, found := desc.Users[*creds.Username]; found {
} ok, err := c.Password.Match(creds.Password)
if found, good := matchClient(creds, desc.Op); found { if err != nil {
if good { return Permissions{}, err
p := []string{"op", "present", "token"}
if desc.AllowRecording {
p = append(p, "record")
} }
return p, nil if ok {
} return c.Permissions, nil
return nil, &NotAuthorisedError{} } else {
} return Permissions{}, &NotAuthorisedError{}
if found, good := matchClient(creds, desc.Presenter); found {
if good {
p := []string{"present"}
if desc.UnrestrictedTokens {
p = append(p, "token")
} }
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. // 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 // called locked
func (g *Group) userExists(username string) bool { func (g *Group) userExists(username string) bool {
if username == "" { desc := g.description
if desc.Users == nil {
return false return false
} }
_, found := desc.Users[username]
desc := g.description return found
for _, ps := range [][]ClientPattern{
desc.Op, desc.Presenter, desc.Other,
} {
for _, p := range ps {
if p.Username == username {
return true
}
}
}
return false
} }
// called locked // called locked
@ -1049,11 +1010,11 @@ func (g *Group) getPermission(creds ClientCredentials) (string, []string, error)
} }
} else if creds.Username != nil { } else if creds.Username != nil {
username = *creds.Username username = *creds.Username
var err error ps, err := g.getPasswordPermission(creds)
perms, err = g.getPasswordPermission(creds)
if err != nil { if err != nil {
return "", nil, err return "", nil, err
} }
perms = ps.Permissions(desc)
} else { } else {
return "", nil, errors.New("neither username nor token provided") return "", nil, errors.New("neither username nor token provided")
} }

View File

@ -4,9 +4,9 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"reflect"
"testing" "testing"
"time" "time"
"sort"
) )
func TestGroup(t *testing.T) { func TestGroup(t *testing.T) {
@ -70,50 +70,35 @@ func TestChatHistory(t *testing.T) {
} }
} }
var descJSON = ` func permissionsEqual(a, b []string) bool {
{ // nil case
"op": [{"username": "jch","password": "topsecret"}], if len(a) == 0 && len(b) == 0 {
"max-history-age": 10, return true
"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)
} }
if len(a) != len(b) {
dd, err := json.Marshal(d) return false
if err != nil {
t.Fatalf("marshal: %v", err)
} }
aa := append([]string(nil), a...)
var ddd Description sort.Slice(aa, func(i, j int) bool {
err = json.Unmarshal([]byte(dd), &ddd) return aa[i] < aa[j]
if err != nil { })
t.Fatalf("unmarshal: %v", err) bb := append([]string(nil), b...)
} sort.Slice(bb, func(i, j int) bool {
return bb[i] < bb[j]
if !reflect.DeepEqual(d, ddd) { })
t.Errorf("Got %v, expected %v", ddd, d) for i := range aa {
if aa[i] != bb[i] {
return false
}
} }
return true
} }
var jch = "jch" var jch = "jch"
var john = "john" var john = "john"
var james = "james" var james = "james"
var paul = "paul" var paul = "paul"
var peter = "peter"
var badClients = []ClientCredentials{ var badClients = []ClientCredentials{
{Username: &jch, Password: "foo"}, {Username: &jch, Password: "foo"},
@ -136,17 +121,17 @@ var goodClients = []credPerm{
[]string{"present"}, []string{"present"},
}, },
{ {
ClientCredentials{Username: &john, Password: "secret2"}, ClientCredentials{Username: &james, Password: "secret2"},
[]string{"present"},
},
{
ClientCredentials{Username: &james, Password: "secret3"},
[]string{}, []string{},
}, },
{ {
ClientCredentials{Username: &paul, Password: "secret3"}, ClientCredentials{Username: &paul, Password: "secret3"},
[]string{}, []string{},
}, },
{
ClientCredentials{Username: &peter, Password: "secret4"},
[]string{},
},
} }
func TestPermissions(t *testing.T) { func TestPermissions(t *testing.T) {
@ -172,7 +157,7 @@ func TestPermissions(t *testing.T) {
if err != nil { if err != nil {
t.Errorf("GetPermission %v: %v", cp.c, err) t.Errorf("GetPermission %v: %v", cp.c, err)
} else if u != *cp.c.Username || } else if u != *cp.c.Username ||
!reflect.DeepEqual(p, cp.p) { !permissionsEqual(p, cp.p) {
t.Errorf("%v: got %v %v, expected %v", t.Errorf("%v: got %v %v, expected %v",
cp.c, u, p, cp.p) 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) { func TestUsernameTaken(t *testing.T) {
var g Group var g Group
err := json.Unmarshal([]byte(descJSON), &g.description) err := json.Unmarshal([]byte(descJSON), &g.description)

View File

@ -466,13 +466,24 @@ func adminMatch(username, password string) (bool, error) {
return false, err return false, err
} }
for _, cred := range conf.Admin { u, found := conf.Users[username]
if cred.Username == "" || cred.Username == username { if found {
if ok, _ := cred.Password.Match(password); ok { 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 true, nil
} }
} }
return false, nil
} }
return false, nil return false, nil
} }

View File

@ -8,8 +8,9 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/jech/galene/group"
"github.com/pion/webrtc/v3" "github.com/pion/webrtc/v3"
"github.com/jech/galene/group"
) )
func TestParseGroupName(t *testing.T) { 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) { func TestObfuscate(t *testing.T) {
id := newId() id := newId()
obfuscated, err := obfuscate(id) obfuscated, err := obfuscate(id)