1
Fork 0
mirror of https://github.com/jech/galene.git synced 2024-11-10 02:35:58 +01:00

Set the username in the server when using tokens.

This avoids the need to pass the username in the URL without
requiring the client to parse tokens.
This commit is contained in:
Juliusz Chroboczek 2022-02-20 15:32:18 +01:00
parent c4d46d20aa
commit de3a016f4d
11 changed files with 90 additions and 64 deletions

View file

@ -117,8 +117,8 @@ The `join` message requests that the sender join or leave a group:
} }
``` ```
If token-based authorisation is beling used, then the `password` field is If token-based authorisation is beling used, then the `username` and
omitted, and a `token` field is included instead. `password` fields are 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
@ -138,10 +138,11 @@ its permissions or in the recommended RTC configuration.
} }
``` ```
The `permissions` field is an array of strings that may contain the values The `username` field is the username that the server assigned to this
`present`, `op` and `record`. The `status` field is a dictionary that user. The `permissions` field is an array of strings that may contain the
contains status information about the group, in the same format as at the values `present`, `op` and `record`. The `status` field is a dictionary
`.status.json` URL above. that contains status information about the group, in the same format as at
the `.status.json` URL above.
## Maintaining group membership ## Maintaining group membership
@ -366,13 +367,24 @@ Currently defined kinds include `clearchat` (not to be confused with the
# Authorisation protocol # Authorisation protocol
In addition to username/password authentication, Galene supports
authentication using cryptographic tokens. Two flows are supported: using
an authentication server, where Galene's client requests a token from
a third-party server, and using an authentication portal, where
a third-party login portal redirects the user to Galene. Authentication
servers are somewhat simpler to implement, but authentication portals are
more flexible and avoid communicating the user's password to Galene's
Javascript code.
## Authentication server
If a group's status dictionary has a non-empty `authServer` field, then If a group's status dictionary has a non-empty `authServer` field, then
the group uses token authentication. Before joining, the client sends the group uses an authentication server. Before joining, the client sends
a POST request to the authorisation server URL containing in its body a POST request to the authorisation server URL containing in its body
a JSON dictionary of the following form: a JSON dictionary of the following form:
```javascript ```javascript
{ {
"location": "https://galene.example.org/group/groupname", "location": "https://galene.example.org/group/groupname/",
"username": username, "username": username,
"password": password "password": password
} }
@ -384,7 +396,7 @@ allowed to join, then the authorisation server replies with a signed JWT
```javascript ```javascript
{ {
"sub": username, "sub": username,
"aud": "https://galene.example.org/group/groupname", "aud": "https://galene.example.org/group/groupname/",
"permissions": ["present"], "permissions": ["present"],
"iat": now, "iat": now,
"exp": now + 30s, "exp": now + 30s,
@ -396,4 +408,12 @@ 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 use the token once, at the very beginning of the session, the tokens
issued may have a short lifetime (on the order of 30s). issued may have a short lifetime (on the order of 30s).
## Authentication portal
If a group's status dictionary has a non-empty `authPortal` field, Galene
redirects the user agent to the URL indicated by `authPortal`. The
authentication portal performs authorisation, generates a token as above,
then redirects back to the group's URL with the token stores in a URL
query parameter named `token`:
https://galene.example.org/group/groupname/?token=eyJhbG...

View file

@ -59,6 +59,10 @@ func (client *Client) Username() string {
return "RECORDING" return "RECORDING"
} }
func (client *Client) SetUsername(string) {
return
}
func (client *Client) SetPermissions(perms []string) { func (client *Client) SetPermissions(perms []string) {
return return
} }

View file

@ -100,9 +100,6 @@ func main() {
fmt.Println(s) fmt.Println(s)
} else { } else {
query := url.Values{} query := url.Values{}
if username != "" {
query.Add("username", username)
}
query.Add("token", s) query.Add("token", s)
outURL := &url.URL{ outURL := &url.URL{
Scheme: groupURL.Scheme, Scheme: groupURL.Scheme,

View file

@ -92,6 +92,7 @@ type Client interface {
Group() *Group Group() *Group
Id() string Id() string
Username() string Username() string
SetUsername(string)
Permissions() []string Permissions() []string
SetPermissions([]string) SetPermissions([]string)
Data() map[string]interface{} Data() map[string]interface{}

View file

@ -565,11 +565,12 @@ func AddClient(group string, c Client, creds ClientCredentials) (*Group, error)
clients := g.getClientsUnlocked(nil) clients := g.getClientsUnlocked(nil)
if !member("system", c.Permissions()) { if !member("system", c.Permissions()) {
perms, err := g.description.GetPermission(group, creds) username, perms, err := g.description.GetPermission(group, creds)
if err != nil { if err != nil {
return nil, err return nil, err
} }
c.SetUsername(username)
c.SetPermissions(perms) c.SetPermissions(perms)
if !member("op", perms) { if !member("op", perms) {
@ -1078,9 +1079,9 @@ func GetDescription(name string) (*Description, error) {
return &desc, nil return &desc, nil
} }
func (desc *Description) GetPermission(group string, creds ClientCredentials) ([]string, error) { func (desc *Description) GetPermission(group string, creds ClientCredentials) (string, []string, error) {
if !desc.AllowAnonymous && creds.Username == "" { if !desc.AllowAnonymous && creds.Username == "" {
return nil, ErrAnonymousNotAuthorised return "", nil, ErrAnonymousNotAuthorised
} }
if creds.Token == "" { if creds.Token == "" {
@ -1091,36 +1092,34 @@ func (desc *Description) GetPermission(group string, creds ClientCredentials) ([
if desc.AllowRecording { if desc.AllowRecording {
p = append(p, "record") p = append(p, "record")
} }
return p, nil return creds.Username, p, nil
} }
return nil, ErrNotAuthorised return "", nil, ErrNotAuthorised
} }
if found, good := matchClient(group, creds, desc.Presenter); found { if found, good := matchClient(group, creds, desc.Presenter); found {
if good { if good {
return []string{"present"}, nil return creds.Username, []string{"present"}, nil
} }
return nil, ErrNotAuthorised return "", nil, ErrNotAuthorised
} }
if found, good := matchClient(group, creds, desc.Other); found { if found, good := matchClient(group, creds, desc.Other); found {
if good { if good {
return nil, nil return creds.Username, nil, nil
} }
return nil, ErrNotAuthorised return "", nil, ErrNotAuthorised
} }
return nil, ErrNotAuthorised return "", nil, ErrNotAuthorised
} }
aud, perms, err := token.Valid( sub, aud, perms, err := token.Valid(creds.Token, desc.AuthKeys)
creds.Username, creds.Token, desc.AuthKeys,
)
if err != nil { if err != nil {
log.Printf("Token authentication: %v", err) log.Printf("Token authentication: %v", err)
return nil, ErrNotAuthorised return "", nil, ErrNotAuthorised
} }
conf, err := GetConfiguration() conf, err := GetConfiguration()
if err != nil { if err != nil {
log.Printf("Read config.json: %v", err) log.Printf("Read config.json: %v", err)
return nil, err return "", nil, err
} }
ok := false ok := false
for _, u := range aud { for _, u := range aud {
@ -1145,9 +1144,9 @@ func (desc *Description) GetPermission(group string, creds ClientCredentials) ([
} }
} }
if !ok { if !ok {
return nil, ErrNotAuthorised return "", nil, ErrNotAuthorised
} }
return perms, nil return sub, perms, nil
} }
type Status struct { type Status struct {

View file

@ -167,7 +167,7 @@ func TestPermissions(t *testing.T) {
for _, c := range badClients { for _, c := range badClients {
t.Run("bad "+c.Username, func(t *testing.T) { t.Run("bad "+c.Username, func(t *testing.T) {
p, err := d.GetPermission("test", c) _, p, err := d.GetPermission("test", c)
if err != ErrNotAuthorised { if err != ErrNotAuthorised {
t.Errorf("GetPermission %v: %v %v", c, err, p) t.Errorf("GetPermission %v: %v %v", c, err, p)
} }
@ -176,12 +176,13 @@ func TestPermissions(t *testing.T) {
for _, cp := range goodClients { for _, cp := range goodClients {
t.Run("good "+cp.c.Username, func(t *testing.T) { t.Run("good "+cp.c.Username, func(t *testing.T) {
p, err := d.GetPermission("test", cp.c) u, p, err := d.GetPermission("test", cp.c)
if err != nil { if err != nil {
t.Errorf("GetPermission %v: %v", cp.c, err) t.Errorf("GetPermission %v: %v", cp.c, err)
} else if !reflect.DeepEqual(p, cp.p) { } else if u != cp.c.Username ||
t.Errorf("%v: got %v, expected %v", !reflect.DeepEqual(p, cp.p) {
cp.c, p, cp.p) t.Errorf("%v: got %v %v, expected %v",
cp.c, u, p, cp.p)
} }
}) })
} }

View file

@ -86,6 +86,10 @@ func (c *webClient) Username() string {
return c.username return c.username
} }
func (c *webClient) SetUsername(username string) {
c.username = username
}
func (c *webClient) Permissions() []string { func (c *webClient) Permissions() []string {
return c.permissions return c.permissions
} }

View file

@ -29,9 +29,6 @@ let serverConnection;
/** @type {Object} */ /** @type {Object} */
let groupStatus = {}; let groupStatus = {};
/** @type {string} */
let username = null;
/** @type {string} */ /** @type {string} */
let token = null; let token = null;
@ -273,8 +270,11 @@ function setConnected(connected) {
} }
} }
/** @this {ServerConnection} */ /**
async function gotConnected() { * @this {ServerConnection}
* @param {string} [username]
*/
async function gotConnected(username) {
let credentials; let credentials;
if(token) { if(token) {
credentials = { credentials = {
@ -283,9 +283,9 @@ async function gotConnected() {
}; };
token = null; token = null;
} else { } else {
username = getInputElement('username').value.trim();
setConnected(true); setConnected(true);
username = getInputElement('username').value.trim();
let pw = getInputElement('password').value; let pw = getInputElement('password').value;
getInputElement('password').value = ''; getInputElement('password').value = '';
if(!groupStatus.authServer) if(!groupStatus.authServer)
@ -2139,7 +2139,7 @@ function gotUser(id, kind) {
} }
function displayUsername() { function displayUsername() {
document.getElementById('userspan').textContent = username; document.getElementById('userspan').textContent = serverConnection.username;
let op = serverConnection.permissions.indexOf('op') >= 0; let op = serverConnection.permissions.indexOf('op') >= 0;
let present = serverConnection.permissions.indexOf('present') >= 0; let present = serverConnection.permissions.indexOf('present') >= 0;
let text = ''; let text = '';
@ -3776,7 +3776,6 @@ async function start() {
setMediaChoices(false).then(e => reflectSettings()); setMediaChoices(false).then(e => reflectSettings());
if(parms.has('token')) { if(parms.has('token')) {
username = parms.get('username');
token = parms.get('token'); token = parms.get('token');
await serverConnect(); await serverConnect();
} else if(groupStatus.authPortal) { } else if(groupStatus.authPortal) {

View file

@ -10,8 +10,6 @@ import (
"github.com/golang-jwt/jwt/v4" "github.com/golang-jwt/jwt/v4"
) )
var ErrUnexpectedSub = errors.New("unexpected 'sub' field")
func parseBase64(k string, d map[string]interface{}) ([]byte, error) { func parseBase64(k string, d map[string]interface{}) ([]byte, error) {
v, ok := d[k].(string) v, ok := d[k].(string)
if !ok { if !ok {
@ -117,18 +115,23 @@ func toStringArray(a []interface{}) ([]string, bool) {
return b, true return b, true
} }
func Valid(username, token string, keys []map[string]interface{}) ([]string, []string, error) { func Valid(token string, keys []map[string]interface{}) (string, []string, []string, error) {
tok, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) { tok, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
return getKey(t.Header, keys) return getKey(t.Header, keys)
}) })
if err != nil { if err != nil {
return nil, nil, err return "", nil, nil, err
} }
claims := tok.Claims.(jwt.MapClaims) claims := tok.Claims.(jwt.MapClaims)
sub, ok := claims["sub"].(string) var sub string
if !ok || sub != username { if s, ok := claims["sub"]; ok && s != nil {
return nil, nil, ErrUnexpectedSub ss, ok := s.(string)
if !ok {
return "", nil, nil,
errors.New("invalid 'sub' field")
}
sub = ss
} }
var aud []string var aud []string
@ -139,11 +142,11 @@ func Valid(username, token string, keys []map[string]interface{}) ([]string, []s
case []interface{}: case []interface{}:
aud, ok = toStringArray(a) aud, ok = toStringArray(a)
if !ok { if !ok {
return nil, nil, return "", nil, nil,
errors.New("invalid 'aud' field") errors.New("invalid 'aud' field")
} }
default: default:
return nil, nil, return "", nil, nil,
errors.New("invalid 'aud' field") errors.New("invalid 'aud' field")
} }
} }
@ -152,14 +155,14 @@ func Valid(username, token string, keys []map[string]interface{}) ([]string, []s
if p, ok := claims["permissions"]; ok && p != nil { if p, ok := claims["permissions"]; ok && p != nil {
pp, ok := p.([]interface{}) pp, ok := p.([]interface{})
if !ok { if !ok {
return nil, nil, return "", nil, nil,
errors.New("invalid 'permissions' field") errors.New("invalid 'permissions' field")
} }
perms, ok = toStringArray(pp) perms, ok = toStringArray(pp)
if !ok { if !ok {
return nil, nil, return "", nil, nil,
errors.New("invalid 'permissions' field") errors.New("invalid 'permissions' field")
} }
} }
return aud, perms, nil return sub, aud, perms, nil
} }

View file

@ -69,11 +69,14 @@ func TestValid(t *testing.T) {
goodToken := "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqb2huIiwiYXVkIjoiaHR0cHM6Ly9nYWxlbmUub3JnOjg0NDMvZ3JvdXAvYXV0aC8iLCJwZXJtaXNzaW9ucyI6WyJwcmVzZW50Il0sImlhdCI6MTY0NTMxMDI5NCwiZXhwIjoyOTA2NzUwMjk0LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjEyMzQvIn0.6xXpgBkBMn4PSBpnwYHb-gRn_Q97Yq9DoKkAf2_6iwc" goodToken := "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqb2huIiwiYXVkIjoiaHR0cHM6Ly9nYWxlbmUub3JnOjg0NDMvZ3JvdXAvYXV0aC8iLCJwZXJtaXNzaW9ucyI6WyJwcmVzZW50Il0sImlhdCI6MTY0NTMxMDI5NCwiZXhwIjoyOTA2NzUwMjk0LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjEyMzQvIn0.6xXpgBkBMn4PSBpnwYHb-gRn_Q97Yq9DoKkAf2_6iwc"
aud, perms, err := Valid("john", goodToken, keys) sub, aud, perms, err := Valid(goodToken, keys)
if err != nil { if err != nil {
t.Errorf("Token invalid: %v", err) t.Errorf("Token invalid: %v", err)
} else { } else {
if sub != "john" {
t.Errorf("Unexpected sub: %v", sub)
}
if !reflect.DeepEqual(aud, []string{"https://galene.org:8443/group/auth/"}) { if !reflect.DeepEqual(aud, []string{"https://galene.org:8443/group/auth/"}) {
t.Errorf("Unexpected aud: %v", aud) t.Errorf("Unexpected aud: %v", aud)
} }
@ -82,14 +85,9 @@ func TestValid(t *testing.T) {
} }
} }
aud, perms, err = Valid("jack", goodToken, keys)
if err != ErrUnexpectedSub {
t.Errorf("Token should have bad username")
}
badToken := "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJzdWIiOiJqb2huIiwiYXVkIjoiaHR0cHM6Ly9nYWxlbmUub3JnOjg0NDMvZ3JvdXAvYXV0aC8iLCJwZXJtaXNzaW9ucyI6WyJwcmVzZW50Il0sImlhdCI6MTY0NTMxMDQ2OSwiZXhwIjoyOTA2NzUwNDY5LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjEyMzQvIn0." badToken := "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJzdWIiOiJqb2huIiwiYXVkIjoiaHR0cHM6Ly9nYWxlbmUub3JnOjg0NDMvZ3JvdXAvYXV0aC8iLCJwZXJtaXNzaW9ucyI6WyJwcmVzZW50Il0sImlhdCI6MTY0NTMxMDQ2OSwiZXhwIjoyOTA2NzUwNDY5LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjEyMzQvIn0."
_, _, err = Valid("john", badToken, keys) _, _, _, err = Valid(badToken, keys)
var verr *jwt.ValidationError var verr *jwt.ValidationError
if !errors.As(err, &verr) { if !errors.As(err, &verr) {
@ -98,14 +96,14 @@ func TestValid(t *testing.T) {
expiredToken := "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqb2huIiwiYXVkIjoiaHR0cHM6Ly9nYWxlbmUub3JnOjg0NDMvZ3JvdXAvYXV0aC8iLCJwZXJtaXNzaW9ucyI6WyJwcmVzZW50Il0sImlhdCI6MTY0NTMxMDMyMiwiZXhwIjoxNjQ1MzEwMzUyLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjEyMzQvIn0.jyqRhoV6iK54SvlP33Fy630aDo-sLNmKKi1kcfqs378" expiredToken := "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqb2huIiwiYXVkIjoiaHR0cHM6Ly9nYWxlbmUub3JnOjg0NDMvZ3JvdXAvYXV0aC8iLCJwZXJtaXNzaW9ucyI6WyJwcmVzZW50Il0sImlhdCI6MTY0NTMxMDMyMiwiZXhwIjoxNjQ1MzEwMzUyLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjEyMzQvIn0.jyqRhoV6iK54SvlP33Fy630aDo-sLNmKKi1kcfqs378"
_, _, err = Valid("john", expiredToken, keys) _, _, _, err = Valid(expiredToken, keys)
if !errors.As(err, &verr) { if !errors.As(err, &verr) {
t.Errorf("Token should be expired") t.Errorf("Token should be expired")
} }
noneToken := "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJzdWIiOiJqb2huIiwiYXVkIjoiaHR0cHM6Ly9nYWxlbmUub3JnOjg0NDMvZ3JvdXAvYXV0aC8iLCJwZXJtaXNzaW9ucyI6WyJwcmVzZW50Il0sImlhdCI6MTY0NTMxMDQwMSwiZXhwIjoxNjQ1MzEwNDMxLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjEyMzQvIn0." noneToken := "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJzdWIiOiJqb2huIiwiYXVkIjoiaHR0cHM6Ly9nYWxlbmUub3JnOjg0NDMvZ3JvdXAvYXV0aC8iLCJwZXJtaXNzaW9ucyI6WyJwcmVzZW50Il0sImlhdCI6MTY0NTMxMDQwMSwiZXhwIjoxNjQ1MzEwNDMxLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjEyMzQvIn0."
_, _, err = Valid("john", noneToken, keys) _, _, _, err = Valid(noneToken, keys)
if err == nil { if err == nil {
t.Errorf("Unsigned token should fail") t.Errorf("Unsigned token should fail")
} }

View file

@ -586,7 +586,7 @@ func checkGroupPermissions(w http.ResponseWriter, r *http.Request, groupname str
return false return false
} }
p, err := desc.GetPermission(groupname, _, p, err := desc.GetPermission(groupname,
group.ClientCredentials{ group.ClientCredentials{
Username: user, Username: user,
Password: pass, Password: pass,