1
Fork 0
mirror of https://github.com/jech/galene.git synced 2024-11-22 16:45:58 +01:00

Implement token authentication.

This commit is contained in:
Juliusz Chroboczek 2021-10-29 23:37:05 +02:00
parent b4d1ef398f
commit 03811db37d
12 changed files with 433 additions and 28 deletions

84
README
View file

@ -91,9 +91,10 @@ optional, but unless you specify at least one user definition (`op`,
following fields are allowed: following fields are allowed:
- `op`, `presenter`, `other`: each of these is an array of user - `op`, `presenter`, `other`: each of these is an array of user
definitions (see below) and specifies the users allowed to connect definitions (see *Authorisation* below) and specifies the users allowed
respectively with operator privileges, with presenter privileges, and to connect respectively with operator privileges, with presenter
as passive listeners; privileges, and as passive listeners;
- `authServer` and `authKeys`: see *Authorisation* below;
- `public`: if true, then the group is visible on the landing page; - `public`: if true, then the group is visible on the landing page;
- `displayName`: a human-friendly version of the group name; - `displayName`: a human-friendly version of the group name;
- `description`: a human-readable description of the group; this is - `description`: a human-readable description of the group; this is
@ -132,30 +133,52 @@ Supported audio codecs include `"opus"`, `"g722"`, `"pcmu"` and `"pcma"`.
Only Opus can be recorded to disk. There is no good reason to use Only Opus can be recorded to disk. There is no good reason to use
anything except Opus. anything except Opus.
A user definition is a dictionary with the following fields:
- `username`: the username of the user; if omitted, any username is ## Client Authorisation
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, 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"} {"username": "jch", "password": "1234"}
specifies user *jch* with password *1234*, while specifies username *jch* with password *1234*, while
{"password": "1234"} {"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 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: 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 # Further information
Galène's web page is at <https://galene.org>. Galène's web page is at <https://galene.org>.

View file

@ -39,6 +39,11 @@ configuration file. Each object has the following fields:
- `locked`: true if the group is locked; - `locked`: true if the group is locked;
- `clientCount`: the number of clients currently in the group. - `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 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, status of a single group. If the group has not been marked as public,
then the fields `locked` and `clientCount` are omitted. 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 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 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 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 Currently defined kinds include `clearchat` (not to be confused with the
`clearchat` user message), `lock`, `unlock`, `record`, `unrecord`, `clearchat` user message), `lock`, `unlock`, `record`, `unrecord`,
`subgroups` and `setdata`. `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).

3
go.mod
View file

@ -1,9 +1,10 @@
module github.com/jech/galene module github.com/jech/galene
go 1.13 go 1.15
require ( require (
github.com/at-wat/ebml-go v0.16.0 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/gorilla/websocket v1.4.2
github.com/jech/cert v0.0.0-20210819231831-aca735647728 github.com/jech/cert v0.0.0-20210819231831-aca735647728
github.com/jech/samplebuilder v0.0.0-20220125212352-4553ed6f9a6c github.com/jech/samplebuilder v0.0.0-20220125212352-4553ed6f9a6c

2
go.sum
View file

@ -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.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 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/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.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/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=

View file

@ -92,6 +92,7 @@ type ClientCredentials struct {
System bool System bool
Username string Username string
Password string Password string
Token string
} }
type Client interface { type Client interface {

View file

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"log" "log"
"net/url"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
@ -15,6 +16,8 @@ import (
"github.com/pion/ice/v2" "github.com/pion/ice/v2"
"github.com/pion/sdp/v3" "github.com/pion/sdp/v3"
"github.com/pion/webrtc/v3" "github.com/pion/webrtc/v3"
"github.com/jech/galene/token"
) )
var Directory, DataDirectory string var Directory, DataDirectory string
@ -960,6 +963,12 @@ type Description struct {
// A list of logins for non-presenting users. // A list of logins for non-presenting users.
Other []ClientPattern `json:"other,omitempty"` 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 // 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"`
@ -1062,6 +1071,7 @@ func (desc *Description) GetPermission(group string, creds ClientCredentials) (C
if !desc.AllowAnonymous && creds.Username == "" { if !desc.AllowAnonymous && creds.Username == "" {
return p, ErrAnonymousNotAuthorised return p, ErrAnonymousNotAuthorised
} }
if found, good := matchClient(group, creds, desc.Op); found { if found, good := matchClient(group, creds, desc.Op); found {
if good { if good {
p.Op = true p.Op = true
@ -1086,6 +1096,52 @@ func (desc *Description) GetPermission(group string, creds ClientCredentials) (C
} }
return p, ErrNotAuthorised 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 return p, ErrNotAuthorised
} }
@ -1093,6 +1149,7 @@ type Status struct {
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"displayName,omitempty"` DisplayName string `json:"displayName,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
AuthServer string `json:"authServer,omitempty"`
Locked bool `json:"locked,omitempty"` Locked bool `json:"locked,omitempty"`
ClientCount *int `json:"clientCount,omitempty"` ClientCount *int `json:"clientCount,omitempty"`
} }
@ -1102,6 +1159,7 @@ func (g *Group) Status (authentified bool) Status {
d := Status{ d := Status{
Name: g.name, Name: g.name,
DisplayName: desc.DisplayName, DisplayName: desc.DisplayName,
AuthServer: desc.AuthServer,
Description: desc.Description, Description: desc.Description,
} }

View file

@ -113,6 +113,7 @@ type clientMessage struct {
Dest string `json:"dest,omitempty"` Dest string `json:"dest,omitempty"`
Username string `json:"username,omitempty"` Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"` Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
Privileged bool `json:"privileged,omitempty"` Privileged bool `json:"privileged,omitempty"`
Permissions *group.ClientPermissions `json:"permissions,omitempty"` Permissions *group.ClientPermissions `json:"permissions,omitempty"`
Status *group.Status `json:"status,omitempty"` Status *group.Status `json:"status,omitempty"`
@ -1332,6 +1333,7 @@ func handleClientMessage(c *webClient, m clientMessage) error {
group.ClientCredentials{ group.ClientCredentials{
Username: m.Username, Username: m.Username,
Password: m.Password, Password: m.Password,
Token: m.Token,
}, },
) )
if err != nil { if err != nil {

View file

@ -271,13 +271,25 @@ function setConnected(connected) {
} }
/** @this {ServerConnection} */ /** @this {ServerConnection} */
function gotConnected() { async function gotConnected() {
username = getInputElement('username').value.trim(); username = getInputElement('username').value.trim();
setConnected(true); 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 { try {
let pw = getInputElement('password').value; await this.join(group, username, credentials);
getInputElement('password').value = '';
this.join(group, username, pw);
} catch(e) { } catch(e) {
console.error(e); console.error(e);
displayError(e); displayError(e);

View file

@ -205,6 +205,7 @@ function ServerConnection() {
* @property {string} [dest] * @property {string} [dest]
* @property {string} [username] * @property {string} [username]
* @property {string} [password] * @property {string} [password]
* @property {string} [token]
* @property {boolean} [privileged] * @property {boolean} [privileged]
* @property {Object<string,boolean>} [permissions] * @property {Object<string,boolean>} [permissions]
* @property {Object<string,any>} [status] * @property {Object<string,any>} [status]
@ -416,19 +417,52 @@ ServerConnection.prototype.connect = async function(url) {
* *
* @param {string} group - The name of the group to join. * @param {string} group - The name of the group to join.
* @param {string} username - the username to join as. * @param {string} username - the username to join as.
* @param {string} password - the password. * @param {string|Object} credentials - password or authServer.
* @param {Object<string,any>} [data] - the initial associated data. * @param {Object<string,any>} [data] - the initial associated data.
*/ */
ServerConnection.prototype.join = function(group, username, password, data) { ServerConnection.prototype.join = async function(group, username, credentials, data) {
let m = { let m = {
type: 'join', type: 'join',
kind: 'join', kind: 'join',
group: group, group: group,
username: username, 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) if(data)
m.data = data; m.data = data;
this.send(m); this.send(m);
}; };

138
token/token.go Normal file
View file

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

54
token/token_test.go Normal file
View file

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

View file

@ -86,9 +86,13 @@ func Serve(address string, dataDir string) error {
return err 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", 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) { func notFound(w http.ResponseWriter) {
@ -172,7 +176,7 @@ func (fh *fileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
cspHeader(w) cspHeader(w, "")
p := r.URL.Path p := r.URL.Path
// this ensures any leading .. are removed by path.Clean below // this ensures any leading .. are removed by path.Clean below
if !strings.HasPrefix(p, "/") { if !strings.HasPrefix(p, "/") {
@ -314,7 +318,8 @@ func groupHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
cspHeader(w) status := g.Status(false)
cspHeader(w, status.AuthServer)
serveFile(w, r, filepath.Join(StaticRoot, "galene.html")) serveFile(w, r, filepath.Join(StaticRoot, "galene.html"))
} }