From 03811db37d65e517393bc2a082e1917753c8d06a Mon Sep 17 00:00:00 2001 From: Juliusz Chroboczek Date: Fri, 29 Oct 2021 23:37:05 +0200 Subject: [PATCH] Implement token authentication. --- README | 88 +++++++++++++++++++++----- README.PROTOCOL | 42 +++++++++++++ go.mod | 3 +- go.sum | 2 + group/client.go | 1 + group/group.go | 58 +++++++++++++++++ rtpconn/webclient.go | 2 + static/galene.js | 20 ++++-- static/protocol.js | 40 +++++++++++- token/token.go | 138 +++++++++++++++++++++++++++++++++++++++++ token/token_test.go | 54 ++++++++++++++++ webserver/webserver.go | 13 ++-- 12 files changed, 433 insertions(+), 28 deletions(-) create mode 100644 token/token.go create mode 100644 token/token_test.go diff --git a/README b/README index 49000d9..5e890ff 100644 --- a/README +++ b/README @@ -91,9 +91,10 @@ optional, but unless you specify at least one user definition (`op`, following fields are allowed: - `op`, `presenter`, `other`: each of these is an array of user - definitions (see below) and specifies the users allowed to connect - respectively with operator privileges, with presenter privileges, and - as passive listeners; + definitions (see *Authorisation* below) and specifies the users allowed + to connect respectively with operator privileges, with presenter + privileges, and as passive listeners; + - `authServer` and `authKeys`: see *Authorisation* below; - `public`: if true, then the group is visible on the landing page; - `displayName`: a human-friendly version of the group name; - `description`: a human-readable description of the group; this is @@ -131,31 +132,53 @@ Supported video codecs include: Supported audio codecs include `"opus"`, `"g722"`, `"pcmu"` and `"pcma"`. Only Opus can be recorded to disk. There is no good reason to use anything except Opus. - -A user definition is a dictionary with the following fields: - - `username`: the username of the user; if omitted, any username is - allowed; - - `password`: if omitted, then no password is required. Otherwise, this - can either be a string, specifying a plain text password, or - a dictionary generated by the `galene-password-generator` utility. - -For example, + +## Client Authorisation + +Galene implements two authorisation methods: a simple username/password +authorisation scheme that is built into the Galene server, and +a token-based mechanism that relies on an external server. The simple +mechanism is intended to be used in standalone installations, while the +server-based mechanism is designed to allow easy integration with an +existing authorisation infrastructure (such as LDAP, OAuth2, or 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`: + + - 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. + +For example, the entry {"username": "jch", "password": "1234"} -specifies user *jch* with password *1234*, while +specifies username *jch* with password *1234*, while {"password": "1234"} -specifies that any (non-empty) username will do, and +allows any username with password *1234*, and {} -allows any (non-empty) 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 If you don't wish to store cleartext passwords on the server, you may -generate hashed password 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: { @@ -170,6 +193,39 @@ user entry with a hashed password looks like this: } +### Authorisation servers + +Galene is able to delegate authorisation decisions to an external +authorisation server. This makes it possible to integrate Galene with an +existing authentication and authorisation infrastructure, such as LDAP, +OAuth2 or even Unix passwords. + +When an authorisation server is used, the group configuration file +specifies the URL of the authorisation server and one or more public keys +in JWK format: + + { + "authServer": "https://auth.example.org", + "authKeys": [{ + "kty": "oct", + "alg": "HS256", + "k": "MYz3IfCq4Yq-UmPdNqWEOdPl4C_m9imHHs9uveDUJGQ", + "kid": "20211030" + }, { + "kty": "EC", + "alg": "ES256", + "crv": "P-256", + "x": "dElK9qBNyCpRXdvJsn4GdjrFzScSzpkz_I0JhKbYC88", + "y": "pBhVb37haKvwEoleoW3qxnT4y5bK35_RTP7_RmFKR6Q", + "kid": "20211101" + }] + } + +The `kid` field serves to distinguish among multiple keys, and must match +the value provided by the authorisation server. If the server doesn't +provide a `kid`, the first key with a matching `alg` field will be used. + + # Further information Galène's web page is at . diff --git a/README.PROTOCOL b/README.PROTOCOL index 5fc8857..40da45c 100644 --- a/README.PROTOCOL +++ b/README.PROTOCOL @@ -39,6 +39,11 @@ configuration file. Each object has the following fields: - `locked`: true if the group is locked; - `clientCount`: the number of clients currently in the group. +If token-based authorisation is in use for the group, then the dictionary +contains the following additional field: + + - `authServer`: the URL of the authorisation server. + A client may also fetch the URL `/group/name/.status.json` to retrieve the status of a single group. If the group has not been marked as public, then the fields `locked` and `clientCount` are omitted. @@ -112,6 +117,9 @@ The `join` message requests that the sender join or leave a group: } ``` +If token-based authorisation is beling used, then the `password` field is +omitted, and a `token` field is included instead. + When the sender has effectively joined the group, the peer will send a 'joined' message of kind 'join'; it may then send a 'joined' message of kind 'change' at any time, in order to inform the client of a change in @@ -355,3 +363,37 @@ Finally, a group action requests that the server act on the current group. Currently defined kinds include `clearchat` (not to be confused with the `clearchat` user message), `lock`, `unlock`, `record`, `unrecord`, `subgroups` and `setdata`. + +# Authorisation protocol + +If a group's status dictionary has a non-empty `authServer` field, then +the group uses token authentication. Before joining, the client sends +a POST request to the authorisation server URL containing in its body +a JSON dictionary of the following form: +```javascript +{ + "location": "https://galene.example.org/group/groupname", + "username": username, + "password": password +} +``` +If the user is not allowed to join the group, then the authorisation +server replies with a code of 403 ("not authorised"). If the user is +allowed to join, then the authorisation server replies with a signed JWT +(a "JWS") the body of which has the following form: +```javascript +{ + "sub": username, + "aud": "https://galene.example.org/group/groupname", + "permissions": ["present": true], + "iat": now, + "exp": now + 30s, + "iss": authorisation server URL +} +``` +The `permissions` field contains the permissions granted to the client, in +the same format as in the `joined` message. Since the client will only +use the token once, at the very beginning of the session, the tokens +issued may have a short lifetime (on the order of 30s). + + diff --git a/go.mod b/go.mod index 790761e..53545b0 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,10 @@ module github.com/jech/galene -go 1.13 +go 1.15 require ( github.com/at-wat/ebml-go v0.16.0 + github.com/golang-jwt/jwt/v4 v4.2.0 github.com/gorilla/websocket v1.4.2 github.com/jech/cert v0.0.0-20210819231831-aca735647728 github.com/jech/samplebuilder v0.0.0-20220125212352-4553ed6f9a6c diff --git a/go.sum b/go.sum index 8cfd46b..c7dd849 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/golang-jwt/jwt/v4 v4.2.0 h1:besgBTC8w8HjP6NzQdxwKH9Z5oQMZ24ThTrHp3cZ8eU= +github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= diff --git a/group/client.go b/group/client.go index fdadc91..78330f5 100644 --- a/group/client.go +++ b/group/client.go @@ -92,6 +92,7 @@ type ClientCredentials struct { System bool Username string Password string + Token string } type Client interface { diff --git a/group/group.go b/group/group.go index fd01374..42b21ec 100644 --- a/group/group.go +++ b/group/group.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "log" + "net/url" "os" "path" "path/filepath" @@ -15,6 +16,8 @@ import ( "github.com/pion/ice/v2" "github.com/pion/sdp/v3" "github.com/pion/webrtc/v3" + + "github.com/jech/galene/token" ) var Directory, DataDirectory string @@ -960,6 +963,12 @@ type Description struct { // A list of logins for non-presenting users. Other []ClientPattern `json:"other,omitempty"` + // The URL of the authentication server. + AuthServer string `json:"authServer"` + + // The (public) keys of the authentication server + AuthKeys []map[string]interface{} `json:"authKeys"` + // Codec preferences. If empty, a suitable default is chosen in // the APIFromNames function. Codecs []string `json:"codecs,omitempty"` @@ -1062,6 +1071,7 @@ func (desc *Description) GetPermission(group string, creds ClientCredentials) (C if !desc.AllowAnonymous && creds.Username == "" { return p, ErrAnonymousNotAuthorised } + if found, good := matchClient(group, creds, desc.Op); found { if good { p.Op = true @@ -1086,6 +1096,52 @@ func (desc *Description) GetPermission(group string, creds ClientCredentials) (C } return p, ErrNotAuthorised } + + if desc.AuthServer != "" && creds.Token != "" { + aud, perms, err := token.Valid( + creds.Username, creds.Token, + desc.AuthKeys, desc.AuthServer, + ) + if err != nil { + log.Printf("Token authentication: %v", err) + return p, ErrNotAuthorised + } + conf, err := GetConfiguration() + if err != nil { + log.Printf("Read config.json: %v", err) + return p, err + } + ok := false + for _, u := range aud { + url, err := url.Parse(u) + if err != nil { + log.Printf("Token URL: %v", err) + continue + } + // if canonicalHost is not set, we allow tokens + // for any domain name. Hopefully different + // servers use distinct keys. + if conf.CanonicalHost != "" { + if !strings.EqualFold( + url.Host, conf.CanonicalHost, + ) { + continue + } + } + if url.Path == path.Join("/group", group)+"/" { + ok = true + break + } + } + if !ok { + return p, ErrNotAuthorised + } + p.Op, _ = perms["op"].(bool) + p.Present, _ = perms["present"].(bool) + p.Record, _ = perms["record"].(bool) + return p, nil + } + return p, ErrNotAuthorised } @@ -1093,6 +1149,7 @@ type Status struct { Name string `json:"name"` DisplayName string `json:"displayName,omitempty"` Description string `json:"description,omitempty"` + AuthServer string `json:"authServer,omitempty"` Locked bool `json:"locked,omitempty"` ClientCount *int `json:"clientCount,omitempty"` } @@ -1102,6 +1159,7 @@ func (g *Group) Status (authentified bool) Status { d := Status{ Name: g.name, DisplayName: desc.DisplayName, + AuthServer: desc.AuthServer, Description: desc.Description, } diff --git a/rtpconn/webclient.go b/rtpconn/webclient.go index 532cd7b..7b70014 100644 --- a/rtpconn/webclient.go +++ b/rtpconn/webclient.go @@ -113,6 +113,7 @@ type clientMessage struct { Dest string `json:"dest,omitempty"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` + Token string `json:"token,omitempty"` Privileged bool `json:"privileged,omitempty"` Permissions *group.ClientPermissions `json:"permissions,omitempty"` Status *group.Status `json:"status,omitempty"` @@ -1332,6 +1333,7 @@ func handleClientMessage(c *webClient, m clientMessage) error { group.ClientCredentials{ Username: m.Username, Password: m.Password, + Token: m.Token, }, ) if err != nil { diff --git a/static/galene.js b/static/galene.js index 6480189..ff18ef4 100644 --- a/static/galene.js +++ b/static/galene.js @@ -271,13 +271,25 @@ function setConnected(connected) { } /** @this {ServerConnection} */ -function gotConnected() { +async function gotConnected() { username = getInputElement('username').value.trim(); setConnected(true); + + let pw = getInputElement('password').value; + getInputElement('password').value = ''; + let credentials; + if(!groupStatus.authServer) + credentials = pw; + else + credentials = { + type: 'authServer', + authServer: groupStatus.authServer, + location: location.href, + password: pw, + }; + try { - let pw = getInputElement('password').value; - getInputElement('password').value = ''; - this.join(group, username, pw); + await this.join(group, username, credentials); } catch(e) { console.error(e); displayError(e); diff --git a/static/protocol.js b/static/protocol.js index 77436a1..5ab9459 100644 --- a/static/protocol.js +++ b/static/protocol.js @@ -205,6 +205,7 @@ function ServerConnection() { * @property {string} [dest] * @property {string} [username] * @property {string} [password] + * @property {string} [token] * @property {boolean} [privileged] * @property {Object} [permissions] * @property {Object} [status] @@ -416,19 +417,52 @@ ServerConnection.prototype.connect = async function(url) { * * @param {string} group - The name of the group to join. * @param {string} username - the username to join as. - * @param {string} password - the password. + * @param {string|Object} credentials - password or authServer. * @param {Object} [data] - the initial associated data. */ -ServerConnection.prototype.join = function(group, username, password, data) { +ServerConnection.prototype.join = async function(group, username, credentials, data) { let m = { type: 'join', kind: 'join', group: group, username: username, - password: password, }; + if((typeof credentials) === 'string') { + m.password = credentials; + } else { + switch(credentials.type) { + case 'password': + m.password = credentials.password; + break; + case 'token': + m.token = credentials.token; + break; + case 'authServer': + let r = await fetch(credentials.authServer, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + location: credentials.location, + username: username, + password: credentials.password, + }), + }); + if(!r.ok) + throw new Error( + `The authorisation server said: ${r.status} ${r.statusText}`, + ); + m.token = await r.text(); + break; + default: + throw new Error(`Unknown credentials type ${credentials.type}`); + } + } + if(data) m.data = data; + this.send(m); }; diff --git a/token/token.go b/token/token.go new file mode 100644 index 0000000..d7e546e --- /dev/null +++ b/token/token.go @@ -0,0 +1,138 @@ +package token + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "encoding/base64" + "errors" + "math/big" + + "github.com/golang-jwt/jwt/v4" +) + +func parseBase64(k string, d map[string]interface{}) ([]byte, error) { + v, ok := d[k].(string) + if !ok { + return nil, errors.New("key " + k + " not found") + } + vv, err := base64.RawURLEncoding.DecodeString(v) + if err != nil { + return nil, err + } + return vv, nil +} + +func parseKey(key map[string]interface{}) (interface{}, error) { + kty, ok := key["kty"].(string) + if !ok { + return nil, errors.New("kty not found") + } + alg, ok := key["alg"].(string) + if !ok { + return nil, errors.New("alg not found") + } + + switch kty { + case "oct": + var length int + switch(alg) { + case "HS256": + length = 32 + case "HS384": + length = 48 + case "HS512": + length = 64 + default: + return nil, errors.New("unknown alg") + } + k, err := parseBase64("k", key) + if err != nil { + return nil, err + } + if len(k) != length { + return nil, errors.New("bad length for key") + } + return k, nil + case "EC": + if alg != "ES256" { + return nil, errors.New("uknown alg") + } + crv, ok := key["crv"].(string) + if !ok { + return nil, errors.New("crv not found") + } + if crv != "P-256" { + return nil, errors.New("unknown crv") + } + curve := elliptic.P256() + xbytes, err := parseBase64("x", key) + if err != nil { + return nil, err + } + var x big.Int + x.SetBytes(xbytes) + ybytes, err := parseBase64("y", key) + if err != nil { + return nil, err + } + var y big.Int + y.SetBytes(ybytes) + if !curve.IsOnCurve(&x, &y) { + return nil, errors.New("key is not on curve") + } + return &ecdsa.PublicKey{ + Curve: curve, + X: &x, + Y: &y, + }, nil + default: + return nil, errors.New("unknown key type") + } +} + +func getKey(header map[string]interface{}, keys []map[string]interface{}) (interface{}, error) { + alg, _ := header["alg"].(string) + kid, _ := header["kid"].(string) + for _, k := range keys { + kid2, _ := k["kid"].(string) + alg2, _ := k["alg"].(string) + if (kid == "" || kid == kid2) && alg == alg2 { + return parseKey(k) + } + } + return nil, errors.New("key not found") +} + +func Valid(username, token string, keys []map[string]interface{}, issuer string) ([]string, map[string]interface{}, error) { + tok, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) { + return getKey(t.Header, keys) + }) + if err != nil { + return nil, nil, err + } + claims := tok.Claims.(jwt.MapClaims) + + sub, ok := claims["sub"].(string) + if !ok || sub != username { + return nil, nil, errors.New("invalid 'sub' field") + } + iss, ok := claims["iss"].(string) + if !ok || iss != issuer { + return nil, nil, errors.New("invalid 'iss' field") + } + aud, ok := claims["aud"] + var res []string + if ok { + switch aud := aud.(type) { + case string: + res = []string{aud} + case []string: + res = aud + } + } + perms, ok := claims["permissions"].(map[string]interface{}) + if !ok { + return nil, nil, errors.New("invalid 'permissions' field") + } + return res, perms, nil +} diff --git a/token/token_test.go b/token/token_test.go new file mode 100644 index 0000000..438b421 --- /dev/null +++ b/token/token_test.go @@ -0,0 +1,54 @@ +package token + +import ( + "crypto/ecdsa" + "encoding/json" + "testing" +) + +func TestHS256(t *testing.T) { + key := `{ + "kty":"oct", + "alg":"HS256", + "k":"4S9YZLHK1traIaXQooCnPfBw_yR8j9VEPaAMWAog_YQ" + }` + var j map[string]interface{} + err := json.Unmarshal([]byte(key), &j) + if err != nil { + t.Fatalf("Unmarshal: %v", err) + } + k, err := parseKey(j) + if err != nil { + t.Fatalf("parseKey: %v", err) + } + kk, ok := k.([]byte) + if !ok || len(kk) != 32 { + t.Errorf("parseKey: got %v", kk) + } +} + +func TestES256(t *testing.T) { + key := `{ + "kty":"EC", + "alg":"ES256", + "crv":"P-256", + "x":"dElK9qBNyCpRXdvJsn4GdjrFzScSzpkz_I0JhKbYC88", + "y":"pBhVb37haKvwEoleoW3qxnT4y5bK35_RTP7_RmFKR6Q" + }` + var j map[string]interface{} + err := json.Unmarshal([]byte(key), &j) + if err != nil { + t.Fatalf("Unmarshal: %v", err) + } + k, err := parseKey(j) + if err != nil { + t.Fatalf("parseKey: %v", err) + } + kk, ok := k.(*ecdsa.PublicKey) + if !ok || kk.Params().Name != "P-256" { + t.Errorf("parseKey: got %v", kk) + } + if !kk.IsOnCurve(kk.X, kk.Y) { + t.Errorf("point is not on curve") + } +} diff --git a/webserver/webserver.go b/webserver/webserver.go index 42bd5a4..f3afa3c 100644 --- a/webserver/webserver.go +++ b/webserver/webserver.go @@ -86,9 +86,13 @@ func Serve(address string, dataDir string) error { return err } -func cspHeader(w http.ResponseWriter) { +func cspHeader(w http.ResponseWriter, connect string) { + c := "connect-src ws: wss: 'self';" + if connect != "" { + c = "connect-src " + connect + " ws: wss: 'self';" + } w.Header().Add("Content-Security-Policy", - "connect-src ws: wss: 'self'; img-src data: 'self'; media-src blob: 'self'; default-src 'self'") + c + " img-src data: 'self'; media-src blob: 'self'; default-src 'self'") } func notFound(w http.ResponseWriter) { @@ -172,7 +176,7 @@ func (fh *fileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - cspHeader(w) + cspHeader(w, "") p := r.URL.Path // this ensures any leading .. are removed by path.Clean below if !strings.HasPrefix(p, "/") { @@ -314,7 +318,8 @@ func groupHandler(w http.ResponseWriter, r *http.Request) { return } - cspHeader(w) + status := g.Status(false) + cspHeader(w, status.AuthServer) serveFile(w, r, filepath.Join(StaticRoot, "galene.html")) }