Commit 03811db3 authored by Juliusz Chroboczek's avatar Juliusz Chroboczek

Implement token authentication.

parent b4d1ef39
......@@ -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
......@@ -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
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.
## Client Authorisation
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"}
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>.
......
......@@ -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).
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
......
......@@ -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=
......
......@@ -92,6 +92,7 @@ type ClientCredentials struct {
System bool
Username string
Password string
Token string
}
type Client interface {
......
......@@ -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,
}
......
......@@ -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 {
......
......@@ -271,13 +271,25 @@ function setConnected(connected) {
}
/** @this {ServerConnection} */
function gotConnected() {
async function gotConnected() {
username = getInputElement('username').value.trim();
setConnected(true);
try {
let pw = getInputElement('password').value;
getInputElement('password').value = '';
this.join(group, username, pw);
let credentials;
if(!groupStatus.authServer)
credentials = pw;
else
credentials = {
type: 'authServer',
authServer: groupStatus.authServer,
location: location.href,
password: pw,
};
try {
await this.join(group, username, credentials);
} catch(e) {
console.error(e);
displayError(e);
......
......@@ -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);
};
......
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
}
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")
}
}
......@@ -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"))
}
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment