diff --git a/Protocol.md b/Protocol.md index 7e5ea21..37126ba 100644 --- a/Protocol.md +++ b/Protocol.md @@ -94,6 +94,20 @@ Just a keep-alive message to prevent the WebSocket connection from closing. } ``` +When the server sends a ping to a user who authenticated via JWT token, it will also provide a refreshed JWT token with an updated expiration date (in case the user is disconnected, so they can log back in gracefully), along with a mapping of the chat moderation rules in their token. Example: + +```javascript +{ + "action": "ping", + "jwt": "new.jwt.token", + "jwtRules": { + "IsRedCamRule": false, + "IsNoVideoRule": false, + //... + } +} +``` + ## Error Sent by: Server. diff --git a/docs/API.md b/docs/API.md index 913c259..cc041de 100644 --- a/docs/API.md +++ b/docs/API.md @@ -50,6 +50,46 @@ The return schema looks like: } ``` +## POST /api/amend-jwt + +This endpoint allows your main website to amend the JWT token for a user who may already be logged into the chat room. + +For example, you can post a new JWT token for them that has a new nickname, avatar image URL, or provides new chat moderation rules that you want to take effect immediately for them in the chat room, rather than wait for them to log in again with a new JWT token. + +The `Username` parameter should target their current username (which they may be currently logged into chat with), and the JWTToken is a new token to be delivered to them which they will update their page with (to apply new chat rules, etc.). + +Notes: + +* If the Username is not currently online, the endpoint will return a Not Found error saying such. +* If the Username did not log in via a JWT token originally, the new JWT token will not be given to them. +* The new JWT token must be correctly signed with your secret key, the same as a normal login JWT token. +* It is possible to change the user's username with this endpoint: the "Username" parameter targets their current (old) username, and the JWT token may contain a new username. + * **Important:** It is up to your main website to ensure the new username will not conflict with somebody else already in the chat room. It is undefined behavior if their username is set to one identical to another user on chat. + * Changing their username may disrupt Direct Message threads other people have open with them: their web page will show the old username as offline, and they would need to find and start a new DM thread to continue chatting. So it is not recommended to update their username with this API although it is possible to. + +When the username is found on chat (and was JWT authenticated originally), the chat server will: + +1. Send them a 'ping' message with the new JWT token and rules struct. +2. Send them a 'me' message with their (new?) username. +3. Broadcast a Who List update so any change to their nickname, profile picture, VIP status, etc. will update for everybody. + +```json +{ + "APIKey": "from settings.toml", + "Username": "alice", + "JWTToken": "new.jwt.token" +} +``` + +The return schema looks like: + +```json +{ + "OK": true, + "Error": "error string, omitted if none" +} +``` + ## POST /api/shutdown Shut down (and hopefully, reboot) the chat server. It is equivalent to the `/shutdown` operator command issued in chat, but callable from your web application. It is also used as part of deadlock detection on the BareBot chatbot. diff --git a/pkg/api.go b/pkg/api.go index 6738919..b5a3c6f 100644 --- a/pkg/api.go +++ b/pkg/api.go @@ -198,7 +198,6 @@ func (s *Server) Authenticate() http.HandlerFunc { func (s *Server) ShutdownAPI() http.HandlerFunc { type request struct { APIKey string - Claims jwt.Claims } type result struct { @@ -273,6 +272,127 @@ func (s *Server) ShutdownAPI() http.HandlerFunc { }) } +// Amend JWT (/api/amend-jwt) token for a user who may be logged into chat. +// +// This endpoint allows your website to send updates to a user's JWT token, such +// as to change chat moderator rules or to swap their profile picture URL or +// change their nickname/username. +// +// It is a POST request with a json body containing the following schema: +// +// { +// "APIKey": "from settings.toml", +// "Username": "alice", +// "JWTToken": "new.jwt.token" +// } +// +// The "Username" should match the username they currently would have in the +// chat room, and the JWT token is a new auth token to replace their current +// JWT with. You can change any properties in the new JWT token. For example, +// if you include a new username in the JWT token, the chat server will rename +// them (this will probably interrupt their DM threads people have open with +// them, however). +// +// The return schema looks like: +// +// { +// "OK": true, +// "Error": "error string, omitted if none", +// } +func (s *Server) AmendJWT() http.HandlerFunc { + type request struct { + APIKey string + Username string + JWTToken string + } + + type result struct { + OK bool + Error string `json:",omitempty"` + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // JSON writer for the response. + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + + // Parse the request. + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusBadRequest) + enc.Encode(result{ + Error: "Only POST methods allowed", + }) + return + } else if r.Header.Get("Content-Type") != "application/json" { + w.WriteHeader(http.StatusBadRequest) + enc.Encode(result{ + Error: "Only application/json content-types allowed", + }) + return + } + + defer r.Body.Close() + + // Parse the request payload. + var ( + params request + dec = json.NewDecoder(r.Body) + ) + if err := dec.Decode(¶ms); err != nil { + w.WriteHeader(http.StatusBadRequest) + enc.Encode(result{ + Error: err.Error(), + }) + return + } + + // Validate the API key. + if params.APIKey != config.Current.AdminAPIKey { + w.WriteHeader(http.StatusUnauthorized) + enc.Encode(result{ + Error: "Authentication denied.", + }) + return + } + + // Validate the newly given JWT token. + var claims *jwt.Claims + if parsed, ok, err := jwt.ParseAndValidate(params.JWTToken); err != nil || !ok { + w.WriteHeader(http.StatusUnauthorized) + enc.Encode(result{ + Error: "Invalid JWT token.", + }) + return + } else { + claims = parsed + } + + // Look for this username on chat. + sub, err := s.GetSubscriber(params.Username) + if err != nil { + w.WriteHeader(http.StatusNotFound) + enc.Encode(result{ + Error: fmt.Sprintf("Username %s is not logged in", params.Username), + }) + return + } + + // If the subscriber is JWT authenticated, send them the new token. + if sub.JWTClaims != nil { + sub.JWTClaims = claims + sub.SendPing() + sub.SendMe() + s.SendWhoList() + } + + // Send the response. + enc.Encode(result{ + OK: true, + }) + }) +} + // BlockList (/api/blocklist) allows your website to pre-sync mute lists between your // user accounts, so that when they see each other in chat they will pre-emptively mute // or boot one another. diff --git a/pkg/messages/messages.go b/pkg/messages/messages.go index 217af7b..ed1cd2a 100644 --- a/pkg/messages/messages.go +++ b/pkg/messages/messages.go @@ -64,6 +64,9 @@ type Message struct { // Sent on `echo` actions to condense multiple messages into one packet. Messages []Message `json:"messages,omitempty"` + // Sent on `ping` messages (along with updated JWTToken) + JWTRules map[string]bool `json:"jwtRules,omitempty"` + // WebRTC negotiation messages: proxy their signaling messages // between the two users to negotiate peer connection. Candidate string `json:"candidate,omitempty"` // candidate diff --git a/pkg/ping.go b/pkg/ping.go new file mode 100644 index 0000000..35a87ce --- /dev/null +++ b/pkg/ping.go @@ -0,0 +1,25 @@ +package barertc + +import ( + "git.kirsle.net/apps/barertc/pkg/log" + "git.kirsle.net/apps/barertc/pkg/messages" +) + +// SendPing delivers the Ping message to connected subscribers. +func (sub *Subscriber) SendPing() { + // Send a ping, and a refreshed JWT token if the user sent one. + var token string + if sub.JWTClaims != nil { + if jwt, err := sub.JWTClaims.ReSign(); err != nil { + log.Error("ReSign JWT token for %s#%d: %s", sub.Username, sub.ID, err) + } else { + token = jwt + } + } + + sub.SendJSON(messages.Message{ + Action: messages.ActionPing, + JWTToken: token, + JWTRules: sub.JWTClaims.Rules.ToDict(), + }) +} diff --git a/pkg/polling_api.go b/pkg/polling_api.go index ca443e8..14afafc 100644 --- a/pkg/polling_api.go +++ b/pkg/polling_api.go @@ -188,17 +188,7 @@ func (s *Server) PollingAPI() http.HandlerFunc { // JWT once in a while. Equivalent to the WebSockets pinger channel. if time.Since(sub.lastPollJWT) > PingInterval { sub.lastPollJWT = time.Now() - - if sub.JWTClaims != nil { - if jwt, err := sub.JWTClaims.ReSign(); err != nil { - log.Error("ReSign JWT token for %s#%d: %s", sub.Username, sub.ID, err) - } else { - sub.SendJSON(messages.Message{ - Action: messages.ActionPing, - JWTToken: jwt, - }) - } - } + sub.SendPing() } enc.Encode(sub.FlushPollResponse()) diff --git a/pkg/server.go b/pkg/server.go index 1d0f831..fd6934c 100644 --- a/pkg/server.go +++ b/pkg/server.go @@ -59,6 +59,7 @@ func (s *Server) Setup() error { mux.Handle("/api/block/now", s.BlockNow()) mux.Handle("/api/disconnect/now", s.DisconnectNow()) mux.Handle("/api/authenticate", s.Authenticate()) + mux.Handle("/api/amend-jwt", s.AmendJWT()) mux.Handle("/api/shutdown", s.ShutdownAPI()) mux.Handle("/api/profile", s.UserProfile()) mux.Handle("/api/message/history", s.MessageHistory()) diff --git a/pkg/websocket.go b/pkg/websocket.go index c347969..6b66fa6 100644 --- a/pkg/websocket.go +++ b/pkg/websocket.go @@ -8,7 +8,6 @@ import ( "git.kirsle.net/apps/barertc/pkg/config" "git.kirsle.net/apps/barertc/pkg/log" - "git.kirsle.net/apps/barertc/pkg/messages" "git.kirsle.net/apps/barertc/pkg/util" "nhooyr.io/websocket" ) @@ -52,20 +51,7 @@ func (s *Server) WebSocket() http.HandlerFunc { return } case <-pinger.C: - // Send a ping, and a refreshed JWT token if the user sent one. - var token string - if sub.JWTClaims != nil { - if jwt, err := sub.JWTClaims.ReSign(); err != nil { - log.Error("ReSign JWT token for %s#%d: %s", sub.Username, sub.ID, err) - } else { - token = jwt - } - } - - sub.SendJSON(messages.Message{ - Action: messages.ActionPing, - JWTToken: token, - }) + sub.SendPing() case <-ctx.Done(): pinger.Stop() return diff --git a/src/App.vue b/src/App.vue index 4c2a8e1..50efe73 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1418,6 +1418,7 @@ export default { onUnwatch: this.onUnwatch, onBlock: this.onBlock, onCut: this.onCut, + onPing: this.onPing, bulkMuteUsers: this.bulkMuteUsers, focusMessageBox: () => { @@ -1460,6 +1461,25 @@ export default { // Send the server our current status and video setting. this.sendMe(true); }, + onPing(msg) { + // Updated JWT chat moderation rules? + if (msg.jwtRules) { + this.jwt.rules = msg.jwtRules; + + // Enforce them now. + if (this.jwt.rules.IsNoBroadcastRule && this.webcam.active) { + this.stopVideo(); + } + + if (this.jwt.rules.IsNoVideoRule) { + this.closeOpenVideos(); + } + + if (this.jwt.rules.IsRedCamRule && this.webcam.active && !this.webcam.nsfw) { + this.webcam.nsfw = true; + } + } + }, /** * Front-end web app concerns. diff --git a/src/lib/ChatClient.js b/src/lib/ChatClient.js index 9bd527a..3ab8c93 100644 --- a/src/lib/ChatClient.js +++ b/src/lib/ChatClient.js @@ -30,6 +30,7 @@ class ChatClient { onUnwatch, onBlock, onCut, + onPing, // Misc function registrations for callback. onLoggedIn, // connection is fully established (first 'me' echo from server). @@ -62,6 +63,7 @@ class ChatClient { this.onUnwatch = onUnwatch; this.onBlock = onBlock; this.onCut = onCut; + this.onPing = onPing; this.onLoggedIn = onLoggedIn; this.onNewJWT = onNewJWT; @@ -234,6 +236,7 @@ class ChatClient { if (msg.jwt) { this.onNewJWT(msg.jwt); } + this.onPing(msg); // Reset disconnect retry counter: if we were on long enough to get // a ping, we're well connected and can reconnect no matter how many