1
Fork 0

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
omitted, and a `token` field is included instead.
If token-based authorisation is beling used, then the `username` and
`password` fields are 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
@ -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
`present`, `op` and `record`. The `status` field is a dictionary that
contains status information about the group, in the same format as at the
`.status.json` URL above.
The `username` field is the username that the server assigned to this
user. The `permissions` field is an array of strings that may contain the
values `present`, `op` and `record`. The `status` field is a dictionary
that contains status information about the group, in the same format as at
the `.status.json` URL above.
## Maintaining group membership
@ -366,13 +367,24 @@ Currently defined kinds include `clearchat` (not to be confused with the
# 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
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 JSON dictionary of the following form:
```javascript
{
"location": "https://galene.example.org/group/groupname",
"location": "https://galene.example.org/group/groupname/",
"username": username,
"password": password
}
@ -384,7 +396,7 @@ allowed to join, then the authorisation server replies with a signed JWT
```javascript
{
"sub": username,
"aud": "https://galene.example.org/group/groupname",
"aud": "https://galene.example.org/group/groupname/",
"permissions": ["present"],
"iat": now,
"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
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"
}
func (client *Client) SetUsername(string) {
return
}
func (client *Client) SetPermissions(perms []string) {
return
}

View File

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

View File

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

View File

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

View File

@ -167,7 +167,7 @@ func TestPermissions(t *testing.T) {
for _, c := range badClients {
t.Run("bad "+c.Username, func(t *testing.T) {
p, err := d.GetPermission("test", c)
_, p, err := d.GetPermission("test", c)
if err != ErrNotAuthorised {
t.Errorf("GetPermission %v: %v %v", c, err, p)
}
@ -176,12 +176,13 @@ func TestPermissions(t *testing.T) {
for _, cp := range goodClients {
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 {
t.Errorf("GetPermission %v: %v", cp.c, err)
} else if !reflect.DeepEqual(p, cp.p) {
t.Errorf("%v: got %v, expected %v",
cp.c, p, cp.p)
} else if u != cp.c.Username ||
!reflect.DeepEqual(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
}
func (c *webClient) SetUsername(username string) {
c.username = username
}
func (c *webClient) Permissions() []string {
return c.permissions
}

View File

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

View File

@ -10,8 +10,6 @@ import (
"github.com/golang-jwt/jwt/v4"
)
var ErrUnexpectedSub = errors.New("unexpected 'sub' field")
func parseBase64(k string, d map[string]interface{}) ([]byte, error) {
v, ok := d[k].(string)
if !ok {
@ -117,18 +115,23 @@ func toStringArray(a []interface{}) ([]string, bool) {
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) {
return getKey(t.Header, keys)
})
if err != nil {
return nil, nil, err
return "", nil, nil, err
}
claims := tok.Claims.(jwt.MapClaims)
sub, ok := claims["sub"].(string)
if !ok || sub != username {
return nil, nil, ErrUnexpectedSub
var sub string
if s, ok := claims["sub"]; ok && s != nil {
ss, ok := s.(string)
if !ok {
return "", nil, nil,
errors.New("invalid 'sub' field")
}
sub = ss
}
var aud []string
@ -139,11 +142,11 @@ func Valid(username, token string, keys []map[string]interface{}) ([]string, []s
case []interface{}:
aud, ok = toStringArray(a)
if !ok {
return nil, nil,
return "", nil, nil,
errors.New("invalid 'aud' field")
}
default:
return nil, nil,
return "", nil, nil,
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 {
pp, ok := p.([]interface{})
if !ok {
return nil, nil,
return "", nil, nil,
errors.New("invalid 'permissions' field")
}
perms, ok = toStringArray(pp)
if !ok {
return nil, nil,
return "", nil, nil,
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"
aud, perms, err := Valid("john", goodToken, keys)
sub, aud, perms, err := Valid(goodToken, keys)
if err != nil {
t.Errorf("Token invalid: %v", err)
} else {
if sub != "john" {
t.Errorf("Unexpected sub: %v", sub)
}
if !reflect.DeepEqual(aud, []string{"https://galene.org:8443/group/auth/"}) {
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."
_, _, err = Valid("john", badToken, keys)
_, _, _, err = Valid(badToken, keys)
var verr *jwt.ValidationError
if !errors.As(err, &verr) {
@ -98,14 +96,14 @@ func TestValid(t *testing.T) {
expiredToken := "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqb2huIiwiYXVkIjoiaHR0cHM6Ly9nYWxlbmUub3JnOjg0NDMvZ3JvdXAvYXV0aC8iLCJwZXJtaXNzaW9ucyI6WyJwcmVzZW50Il0sImlhdCI6MTY0NTMxMDMyMiwiZXhwIjoxNjQ1MzEwMzUyLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjEyMzQvIn0.jyqRhoV6iK54SvlP33Fy630aDo-sLNmKKi1kcfqs378"
_, _, err = Valid("john", expiredToken, keys)
_, _, _, err = Valid(expiredToken, keys)
if !errors.As(err, &verr) {
t.Errorf("Token should be expired")
}
noneToken := "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJzdWIiOiJqb2huIiwiYXVkIjoiaHR0cHM6Ly9nYWxlbmUub3JnOjg0NDMvZ3JvdXAvYXV0aC8iLCJwZXJtaXNzaW9ucyI6WyJwcmVzZW50Il0sImlhdCI6MTY0NTMxMDQwMSwiZXhwIjoxNjQ1MzEwNDMxLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjEyMzQvIn0."
_, _, err = Valid("john", noneToken, keys)
_, _, _, err = Valid(noneToken, keys)
if err == nil {
t.Errorf("Unsigned token should fail")
}

View File

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