diff --git a/doc/CHANNELING-API.txt b/doc/CHANNELING-API.txt index bbd61b7e..b8c027fe 100644 --- a/doc/CHANNELING-API.txt +++ b/doc/CHANNELING-API.txt @@ -359,6 +359,32 @@ Additional types for session listing and notifications The Alive value is a timestamp integer in milliseconds (unix time). +User authorization and session authentication + + The channeling API supports an Authentication document to bind and existing + session to a given user. The required information to do this cannot be + received through the channeling API. It depends on the server configuration + how the Nonce and Userid are generated/validated. + + Authentication + + { + "Type": "Authentication", + "Authentication": { + "Userid": "53", + "Nonce": "nonce-for-this-session-and-userid" + } + } + + The Authentication document binds a userid to the current session. The + Nonce and Userid need to be validateable by the server. If Authentication + was successfull, a new Self document will be sent. The Nonce value can + be generated by using the REST API (sessions end point). + + There is no way to undo authentication for a session. For log out, close + the session (disconnect) and forget the token. + + Chat messages and status information The chat is used to transfer simple messages ore more complex structures diff --git a/src/app/spreed-speakfreely-server/channeling.go b/src/app/spreed-speakfreely-server/channeling.go index d1e2e26b..9f153813 100644 --- a/src/app/spreed-speakfreely-server/channeling.go +++ b/src/app/spreed-speakfreely-server/channeling.go @@ -67,10 +67,10 @@ type DataSession struct { Type string Id string Userid string `json:"Userid,omitempty"` - Ua string + Ua string `json:"Ua,omitempty"` Token string `json:"Token,omitempty"` Version string `json:"Version,omitempty"` - Rev uint64 + Rev uint64 `json:"Rev,omitempty"` Status interface{} } @@ -105,16 +105,17 @@ type DataChatMessageStatus struct { } type DataIncoming struct { - Type string - Hello *DataHello - Offer *DataOffer - Candidate *DataCandidate - Answer *DataAnswer - Bye *DataBye - Status *DataStatus - Chat *DataChat - Conference *DataConference - Alive *DataAlive + Type string + Hello *DataHello + Offer *DataOffer + Candidate *DataCandidate + Answer *DataAnswer + Bye *DataBye + Status *DataStatus + Chat *DataChat + Conference *DataConference + Alive *DataAlive + Authentication *DataAuthentication } type DataOutgoing struct { @@ -140,3 +141,8 @@ type DataAlive struct { Type string Alive uint64 } + +type DataAuthentication struct { + Type string + Authentication *SessionToken +} diff --git a/src/app/spreed-speakfreely-server/hub.go b/src/app/spreed-speakfreely-server/hub.go index 8fffbe00..09063cf2 100644 --- a/src/app/spreed-speakfreely-server/hub.go +++ b/src/app/spreed-speakfreely-server/hub.go @@ -28,6 +28,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "errors" "fmt" "github.com/gorilla/securecookie" "log" @@ -180,6 +181,22 @@ func (h *Hub) CreateSession(st *SessionToken) *Session { } +func (h *Hub) ValidateSession(id, sid string) bool { + + var decoded string + err := h.tickets.Decode("id", id, &decoded) + if err != nil { + log.Println("Session validation error", err, id, sid) + return false + } + if decoded != sid { + log.Println("Session validation failed", id, sid) + return false + } + return true + +} + func (h *Hub) EncodeSessionToken(st *SessionToken) (string, error) { return h.tickets.Encode("token", st) @@ -302,7 +319,6 @@ func (h *Hub) unregisterHandler(c *Connection) { return } session := c.Session - c.close() delete(h.connectionTable, c.Id) delete(h.sessionTable, c.Id) h.mutex.Unlock() @@ -311,6 +327,7 @@ func (h *Hub) unregisterHandler(c *Connection) { } //log.Printf("Unregister (%d) from %s: %s\n", c.Idx, c.RemoteAddr, c.Id) h.server.OnUnregister(c) + c.close() } @@ -369,3 +386,22 @@ func (h *Hub) sessionupdateHandler(s *SessionUpdate) uint64 { return rev } + +func (h *Hub) sessiontokenHandler(st *SessionToken) (string, error) { + + h.mutex.RLock() + c, ok := h.connectionTable[st.Id] + h.mutex.RUnlock() + + if !ok { + return "", errors.New("no such connection") + } + + nonce, err := c.Session.Authorize(st) + if err != nil { + return "", err + } + + return nonce, nil + +} diff --git a/src/app/spreed-speakfreely-server/main.go b/src/app/spreed-speakfreely-server/main.go index 5310362e..20ac1a39 100644 --- a/src/app/spreed-speakfreely-server/main.go +++ b/src/app/spreed-speakfreely-server/main.go @@ -343,6 +343,7 @@ func runner(runtime phoenix.Runtime) error { api.SetMux(r.PathPrefix("/api/v1/").Subrouter()) api.AddResource(&Rooms{}, "/rooms") api.AddResourceWithWrapper(&Tokens{tokenProvider}, httputils.MakeGzipHandler, "/tokens") + api.AddResource(&Sessions{hub: hub}, "/sessions/{id}/") if statsEnabled { api.AddResourceWithWrapper(&Stats{hub: hub}, httputils.MakeGzipHandler, "/stats") log.Println("Stats are enabled!") diff --git a/src/app/spreed-speakfreely-server/server.go b/src/app/spreed-speakfreely-server/server.go index 335f06c4..040a109f 100644 --- a/src/app/spreed-speakfreely-server/server.go +++ b/src/app/spreed-speakfreely-server/server.go @@ -59,7 +59,7 @@ func (s *Server) OnUnregister(c *Connection) { //log.Println("OnUnregister", c.id) if c.Hello { s.UpdateRoomConnection(c, &RoomConnectionUpdate{Id: c.Roomid}) - s.Broadcast(c, &DataSession{Type: "Left", Id: c.Id, Status: "hard"}) + s.Broadcast(c, c.Session.DataSessionLeft("hard")) } else { //log.Println("Ingoring OnUnregister because of no Hello", c.Idx) } @@ -86,13 +86,13 @@ func (s *Server) OnText(c *Connection, b Buffer) { if c.Hello && c.Roomid != msg.Hello.Id { // Room changed. s.UpdateRoomConnection(c, &RoomConnectionUpdate{Id: c.Roomid}) - s.Broadcast(c, &DataSession{Type: "Left", Id: c.Id, Status: "soft"}) + s.Broadcast(c, c.Session.DataSessionLeft("soft")) } c.Roomid = msg.Hello.Id if c.h.config.defaultRoomEnabled || !c.h.isDefaultRoomid(c.Roomid) { c.Hello = true s.UpdateRoomConnection(c, &RoomConnectionUpdate{Id: c.Roomid, Status: true}) - s.Broadcast(c, &DataSession{Type: "Joined", Id: c.Id, Ua: msg.Hello.Ua}) + s.Broadcast(c, c.Session.DataSessionJoined()) } else { c.Hello = false } @@ -109,13 +109,20 @@ func (s *Server) OnText(c *Connection, b Buffer) { if c.h.config.defaultRoomEnabled || !c.h.isDefaultRoomid(c.Roomid) { s.Users(c) } + case "Authentication": + if s.Authenticate(c, msg.Authentication.Authentication) { + s.OnRegister(c) + if c.Hello { + s.Broadcast(c, c.Session.DataSessionStatus()) + } + } case "Bye": s.Unicast(c, msg.Bye.To, msg.Bye) case "Status": //log.Println("Status", msg.Status) - rev := s.UpdateSession(c, &SessionUpdate{Types: []string{"Status"}, Status: msg.Status.Status}) + s.UpdateSession(c, &SessionUpdate{Types: []string{"Status"}, Status: msg.Status.Status}) if c.h.config.defaultRoomEnabled || !c.h.isDefaultRoomid(c.Roomid) { - s.Broadcast(c, &DataSession{Type: "Status", Id: c.Id, Status: msg.Status.Status, Rev: rev}) + s.Broadcast(c, c.Session.DataSessionStatus()) } case "Chat": // TODO(longsleep): Limit sent chat messages per incoming connection. @@ -219,6 +226,19 @@ func (s *Server) Users(c *Connection) { } +func (s *Server) Authenticate(c *Connection, st *SessionToken) bool { + + err := c.Session.Authenticate(st) + if err == nil { + log.Println("Authentication success", c.Id, c.Idx, st.Userid) + return true + } else { + log.Println("Authentication failed", err, c.Id, c.Idx, st.Userid, st.Nonce) + return false + } + +} + func (s *Server) UpdateRoomConnection(c *Connection, rcu *RoomConnectionUpdate) { rcu.Sessionid = c.Id diff --git a/src/app/spreed-speakfreely-server/session.go b/src/app/spreed-speakfreely-server/session.go index 2e044abb..c19bead0 100644 --- a/src/app/spreed-speakfreely-server/session.go +++ b/src/app/spreed-speakfreely-server/session.go @@ -22,9 +22,13 @@ package main import ( + "errors" + "github.com/gorilla/securecookie" "sync" ) +var sessionNonces *securecookie.SecureCookie + type Session struct { Id string Sid string @@ -33,6 +37,7 @@ type Session struct { Ua string UpdateRev uint64 Status interface{} + Nonce string mutex sync.RWMutex } @@ -70,13 +75,64 @@ func (s *Session) Update(update *SessionUpdate) uint64 { } -func (s *Session) Apply(st *SessionToken) { +func (s *Session) Apply(st *SessionToken) uint64 { s.mutex.Lock() defer s.mutex.Unlock() s.Id = st.Id + s.Sid = st.Sid s.Userid = st.Userid + s.UpdateRev++ + return s.UpdateRev + +} + +func (s *Session) Authorize(st *SessionToken) (string, error) { + + s.mutex.Lock() + defer s.mutex.Unlock() + + if s.Id != st.Id || s.Sid != st.Sid { + return "", errors.New("session id mismatch") + } + if s.Userid != "" { + return "", errors.New("session already authenticated") + } + + // Create authentication nonce. + var err error + s.Nonce, err = sessionNonces.Encode(s.Sid, st.Userid) + + return s.Nonce, err + +} + +func (s *Session) Authenticate(st *SessionToken) error { + + s.mutex.Lock() + defer s.mutex.Unlock() + + if s.Userid != "" { + return errors.New("session already authenticated") + } + if s.Nonce == "" || s.Nonce != st.Nonce { + return errors.New("nonce validation failed") + } + var userid string + err := sessionNonces.Decode(s.Sid, st.Nonce, &userid) + if err != nil { + return err + } + if st.Userid != userid { + return errors.New("user id mismatch") + } + + s.Nonce = "" + s.Userid = st.Userid + s.UpdateRev++ + return nil + } func (s *Session) Token() *SessionToken { @@ -98,6 +154,48 @@ func (s *Session) Data() *DataSession { } +func (s *Session) DataSessionLeft(state string) *DataSession { + + s.mutex.RLock() + defer s.mutex.RUnlock() + + return &DataSession{ + Type: "Left", + Id: s.Id, + Status: state, + } + +} + +func (s *Session) DataSessionJoined() *DataSession { + + s.mutex.RLock() + defer s.mutex.RUnlock() + + return &DataSession{ + Type: "Joined", + Id: s.Id, + Userid: s.Userid, + Ua: s.Ua, + } + +} + +func (s *Session) DataSessionStatus() *DataSession { + + s.mutex.RLock() + defer s.mutex.RUnlock() + + return &DataSession{ + Type: "Status", + Id: s.Id, + Userid: s.Userid, + Status: s.Status, + Rev: s.UpdateRev, + } + +} + type SessionUpdate struct { Id string Types []string @@ -110,4 +208,11 @@ type SessionToken struct { Id string Sid string Userid string + Nonce string `json:"Nonce,omitempty"` +} + +func init() { + // Create nonce generator. + sessionNonces = securecookie.New(securecookie.GenerateRandomKey(64), nil) + sessionNonces.MaxAge(60) } diff --git a/src/app/spreed-speakfreely-server/sessions.go b/src/app/spreed-speakfreely-server/sessions.go new file mode 100644 index 00000000..b89e3a55 --- /dev/null +++ b/src/app/spreed-speakfreely-server/sessions.go @@ -0,0 +1,95 @@ +/* + * Spreed Speak Freely. + * Copyright (C) 2013-2014 struktur AG + * + * This file is part of Spreed Speak Freely. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "encoding/json" + "github.com/gorilla/mux" + "net/http" +) + +type SessionNonce struct { + Nonce string `json:"nonce"` + Success bool `json:"success"` +} + +type Sessions struct { + hub *Hub +} + +// Patch is used to add a userid to a given session (login). +func (sessions *Sessions) Patch(request *http.Request) (int, interface{}, http.Header) { + + // Make sure to always run all the checks to make timing attacks harder. + error := false + + decoder := json.NewDecoder(request.Body) + var st SessionToken + err := decoder.Decode(&st) + if err != nil { + error = true + } + + vars := mux.Vars(request) + id, ok := vars["id"] + if !ok { + error = true + } + + // Make sure data matches request. + if id != st.Id { + error = true + } + + // Make sure that we have a Sid. + if st.Sid == "" { + error = true + } + + // Make sure that we have a user. + if st.Userid == "" { + error = true + } + + // TODO(longsleep): Validate userid. + + // Make sure Sid matches session. + if !sessions.hub.ValidateSession(st.Id, st.Sid) { + error = true + } + + var nonce string + if !error { + // FIXME(longsleep): Not running this might releal error state with a timing attack. + nonce, err = sessions.hub.sessiontokenHandler(&st) + if err != nil { + error = true + } + } + + if error { + return 403, NewApiError("session_patch_failed", "Failed to patch session"), nil + } + + return 200, &SessionNonce{Nonce: nonce, Success: true}, http.Header{"Content-Type": {"application/json"}} + +} diff --git a/static/js/mediastream/api.js b/static/js/mediastream/api.js index c3f26718..47b0001a 100644 --- a/static/js/mediastream/api.js +++ b/static/js/mediastream/api.js @@ -26,6 +26,7 @@ define(['jquery', 'underscore'], function($, _) { var Api = function(connector) { this.id = null; + this.sid = null; this.session = {}; this.connector = connector; @@ -112,6 +113,7 @@ define(['jquery', 'underscore'], function($, _) { this.connector.token = data.Token; } this.id = data.Id; + this.sid = data.Sid; this.e.triggerHandler("received.self", [data]); break; case "Offer": @@ -225,6 +227,20 @@ define(['jquery', 'underscore'], function($, _) { }; + Api.prototype.requestAuthentication = function(userid, nonce) { + + var data = { + Type: "Authentication", + Authentication: { + Userid: userid, + Nonce: nonce + } + } + + return this.send("Authentication", data); + + }; + Api.prototype.updateStatus = function(status) { var data = { diff --git a/static/js/services/mediastream.js b/static/js/services/mediastream.js index fddfea61..1a478de6 100644 --- a/static/js/services/mediastream.js +++ b/static/js/services/mediastream.js @@ -55,6 +55,55 @@ define([ console.info("Started disconnector."); }; + (function() { + + var lastNonce = null; + var lastUserid = null; + + $window.testAuthorize = function(userid) { + + console.log("Testing authorize with userid", userid); + var url = mediaStream.url.api("sessions") + "/" + api.id + "/"; + console.log("URL", url); + var data = { + Id: api.id, + Sid: api.sid, + Userid: userid + } + console.log("Data", data); + $.ajax({ + type: "PATCH", + url: url, + contentType: "application/json", + dataType: "json", + data: JSON.stringify(data), + success: function(data) { + if (data.success) { + lastNonce = data.nonce; + lastUserid = userid; + console.log("Retrieved nonce", lastNonce, lastUserid); + } + }, + error: function() { + console.log("error", arguments) + } + }); + + }; + + $window.testAuthenticate = function() { + + if (!lastNonce || !lastUserid) { + console.log("Run testAuthorize first."); + return + } + + api.requestAuthentication(lastUserid, lastNonce); + + }; + + }()) + var mediaStream = { version: version, ws: url,