1
Fork 0
mirror of https://github.com/jech/galene.git synced 2024-11-09 02:05:59 +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

88
README
View file

@ -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 <https://galene.org>.

View file

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

3
go.mod
View file

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

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.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=

View file

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

View file

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

View file

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

View file

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

View file

@ -205,6 +205,7 @@ function ServerConnection() {
* @property {string} [dest]
* @property {string} [username]
* @property {string} [password]
* @property {string} [token]
* @property {boolean} [privileged]
* @property {Object<string,boolean>} [permissions]
* @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} 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.
*/
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);
};

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