JWT Token-based Authentication

* Add support for JWT tokens to authenticate users from your external app.
* JWT backed users can have profile pictures, profile URLs, and operator
  status (admin). Note that no operator features exist yet.
* Add WelcomeMessages to settings.toml for default ChatServer messages to
  write to each public channel directed at a new user logging in.
* Markdown support for chat messages!
This commit is contained in:
Noah 2023-02-05 17:42:09 -08:00
parent 8f60bdba0e
commit 1ecff195ac
17 changed files with 577 additions and 67 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
settings.toml

118
README.md
View File

@ -20,6 +20,7 @@ Some important features it still needs:
* JWT authentication, and admin user permissions (kick/ban/etc.)
* Support for profile URLs, custom avatar image URLs, custom profile fields to show in-app
* See who all is looking at your camera right now, and kick them off.
* Lots of UI cleanup.
# Configuration
@ -27,41 +28,128 @@ Some important features it still needs:
Work in progress. On first run it will create the settings.toml file for you:
```toml
WebsiteURL = "http://localhost:8080"
[JWT]
Enabled = false
SecretKey = ""
Enabled = true
Strict = true
SecretKey = "change me"
[[PublicChannels]]
ID = "lobby"
Name = "Lobby"
Icon = "fa fa-gavel"
WelcomeMessages = ["Welcome to the chat server!", "Please follow the basic rules:\n\n1. Have fun\n2. Be kind"]
[[PublicChannels]]
ID = "offtopic"
Name = "Off Topic"
WelcomeMessages = ["Welcome to the Off Topic channel!"]
```
A description of the config directives includes:
* **JWT**: settings for JWT [Authentication](#authentication).
* Enabled (bool): activate the JWT token authentication feature.
* Strict (bool): if true, **only** valid signed JWT tokens may log in. If false, users with no/invalid token can enter their own username without authentication.
* SecretKey (string): the JWT signing secret shared with your back-end app.
* **PublicChannels**: list the public channels and their configuration. The default channel will be the first one listed.
* ID (string): an arbitrary 'username' for the chat channel, like "lobby".
* Name (string): the user friendly name for the channel, like "Off Topic"
* Icon (string, optional): CSS class names for FontAwesome icon for the channel, like "fa fa-message"
* WelcomeMessages ([]string, optional): messages that are delivered by ChatServer to the user when they connect to the server. Useful to give an introduction to each channel, list its rules, etc.
# Authentication
BareRTC supports custom (user-defined) authentication with your app in the form
of JSON Web Tokens (JWTs). Configure a shared Secret Key in the ChatRTC settings
and have your app create a signed JWT with the same key and the following custom
claims:
BareRTC supports custom (user-defined) authentication with your app in the form of JSON Web Tokens (JWTs). JWTs will allow your existing app to handle authentication for users by signing a token that vouches for them, and the BareRTC app will trust your signed token.
```json
The workflow is as follows:
1. Your existing app already has the user logged-in and you trust who they are. To get them into the chat room, your server signs a JWT token using a secret key that both it and BareRTC knows.
2. Your server redirects the user to your BareRTC website sending the JWT token as a `jwt` parameter, either in the query string (GET) or POST request.
* e.g. you send them to `https://chat.example.com/?jwt=TOKEN`
* If the JWT token is too long to fit in a query string, you may create a `<form>` with `method="POST"` that posts the `jwt` as a form field.
3. The BareRTC server will parse and validate the token using the shared Secret Key that only it and your back-end website knows.
There are JWT libraries available for most programming languages.
Configure a shared secret key (random text string) in both the BareRTC settings and in your app, and your app will sign a JWT including claims that look like the following (using signing method HS264):
```javascript
// JSON Web Token "claims" expected by BareRTC
{
"username": "Soandso",
"icon": "https://path/to/square/icon.png",
"admin": false,
// Custom claims
"sub": "username", // Username for chat (standard JWT claim)
"op": true, // User will have admin/operator permissions.
"img": "/static/photos/username.jpg", // user picture URL
"url": "/u/username", // user profile URL
// Standard JWT claims that we support:
"iss": "my own app", // Issuer name
"exp": 1675645084, // Expires at (time): 5 minutes out is plenty!
"nbf": 1675644784, // Not Before (time)
"iat": 1675644784, // Issued At (time)
}
```
This feature is not hooked up yet. JWT authenticated users sent by your app is the primary supported userbase and will bring many features such as:
An example how to sign your JWT tokens in Go (using [golang-jwt](https://github.com/golang-jwt/jwt)):
* Admin user permissions: you tell us who the admin is and they can moderate the chat room.
* User profile URLs that can be opened from the Who List.
* Custom avatar image URLs for your users.
* Extra profile fields/icons that you can customize the display with.
```golang
import "github.com/golang-jwt/jwt/v4"
// JWT signing key - keep it a secret on your back-end shared between
// your app and BareRTC, do not use it in front-end javascript code or
// where a user can find it.
const SECRET = "change me"
// Your custom JWT claims.
type CustomClaims struct {
// Custom claims used by BareRTC.
Avatar string `json:"img"` // URI to user profile picture
ProfileURL string `json:"url"` // URI to user's profile page
IsAdmin bool `json:"op"` // give operator permission
// Standard JWT claims
jwt.RegisteredClaims
}
// Assuming your internal User struct looks anything at all like:
type User struct {
Username string
IsAdmin bool
ProfilePicture string // like "/static/photos/username.jpg"
}
// Create a JWT token for this user.
func SignForUser(user User) string {
claims := CustomClaims{
// Custom claims
ProfileURL: "/users/" + user.Username,
Avatar: user.ProfilePicture,
IsAdmin: user.IsAdmin,
// Standard claims
Subject: user.Username, // their chat username!
ExpiresAt: time.Now().Add(5 * time.Minute),
IssuedAt: time.Now(),
NotBefore: time.Now(),
Issuer: "my own app",
ID: user.ID,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, err := token.SignedString(SECRET)
if err != nil {
panic(err)
}
return tokenstr
}
```
## JWT Strict Mode
You can enable JWT authentication in a mixed mode: users presenting a valid token will get a profile picture and operator status (if applicable) and users who don't have a JWT token are asked to pick their own username and don't get any special flair.
In strict mode (default/recommended), only a valid JWT token can sign a user into the chat room. Set `[JWT]/Strict=false` in your settings.toml to disable strict JWT verification and allow "guest users" to log in. Note that this can have the same caveats as [running without authentication](#running-without-authentication) and is not a recommended use case.
## Running Without Authentication

22
go.mod
View File

@ -2,14 +2,30 @@ module git.kirsle.net/apps/barertc
go 1.19
require git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b
require (
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b
github.com/BurntSushi/toml v1.2.1
github.com/golang-jwt/jwt/v4 v4.4.3
github.com/microcosm-cc/bluemonday v1.0.22
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629
nhooyr.io/websocket v1.8.7
)
require (
github.com/BurntSushi/toml v1.2.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/klauspost/compress v1.10.3 // indirect
github.com/russross/blackfriday v1.5.2 // indirect
github.com/sergi/go-diff v1.3.1 // indirect
github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480 // indirect
github.com/shurcooL/highlight_go v0.0.0-20191220051317-782971ddf21b // indirect
github.com/shurcooL/octicon v0.0.0-20191102190552-cbb32d6a785c // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d // indirect
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e // indirect
golang.org/x/crypto v0.5.0 // indirect
golang.org/x/net v0.5.0 // indirect
golang.org/x/sys v0.4.0 // indirect
golang.org/x/term v0.4.0 // indirect
nhooyr.io/websocket v1.8.7 // indirect
)

33
go.sum
View File

@ -2,6 +2,8 @@ git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b h1:TDxEEWOJqMzsu9JW8/Qg
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b/go.mod h1:jl+Qr58W3Op7OCxIYIT+b42jq8xFncJXzPufhrvza7Y=
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
@ -13,19 +15,46 @@ github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GO
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8=
github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/microcosm-cc/bluemonday v1.0.22 h1:p2tT7RNzRdCi0qmwxG+HbqD6ILkmwter1ZwVZn1oTxA=
github.com/microcosm-cc/bluemonday v1.0.22/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629 h1:86e54L0i3pH3dAIA8OxBbfLrVyhoGpnNk1iJCigAWYs=
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0=
github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480 h1:KaKXZldeYH73dpQL+Nr38j1r5BgpAYQjYvENOUpIZDQ=
github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU=
github.com/shurcooL/highlight_go v0.0.0-20191220051317-782971ddf21b h1:rBIwpb5ggtqf0uZZY5BPs1sL7njUMM7I8qD2jiou70E=
github.com/shurcooL/highlight_go v0.0.0-20191220051317-782971ddf21b/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag=
github.com/shurcooL/octicon v0.0.0-20191102190552-cbb32d6a785c h1:p3w+lTqXulfa3aDeycxmcLJDNxyUB89gf2/XqqK3eO0=
github.com/shurcooL/octicon v0.0.0-20191102190552-cbb32d6a785c/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d h1:yKm7XZV6j9Ev6lojP2XaIshpT4ymkqhMeSghO5Ps00E=
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE=
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e h1:qpG93cPwA5f7s/ZPBJnGOYQNK/vKsaDaseuKT5Asee8=
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@ -35,6 +64,8 @@ github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVM
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -45,7 +76,9 @@ golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxb
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g=
nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=

View File

@ -14,9 +14,12 @@ import (
type Config struct {
JWT struct {
Enabled bool
Strict bool
SecretKey string
}
WebsiteURL string
PublicChannels []Channel
}
@ -31,6 +34,9 @@ type Channel struct {
ID string // Like "lobby"
Name string // Like "Main Chat Room"
Icon string `toml:",omitempty"` // CSS class names for room icon (optional)
// ChatServer messages to send to the user immediately upon connecting.
WelcomeMessages []string
}
// Current loaded configuration.
@ -40,18 +46,27 @@ var Current = DefaultConfig()
// settings.toml file to disk.
func DefaultConfig() Config {
var c = Config{
WebsiteURL: "https://www.example.com",
PublicChannels: []Channel{
{
ID: "lobby",
Name: "Lobby",
Icon: "fa fa-gavel",
WelcomeMessages: []string{
"Welcome to the chat server!",
"Please follow the basic rules:\n\n1. Have fun\n2. Be kind",
},
},
{
ID: "offtopic",
Name: "Off Topic",
WelcomeMessages: []string{
"Welcome to the Off Topic channel!",
},
},
},
}
c.JWT.Strict = true
return c
}

View File

@ -5,12 +5,45 @@ import (
"strings"
"time"
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/jwt"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/util"
)
// OnLogin handles "login" actions from the client.
func (s *Server) OnLogin(sub *Subscriber, msg Message) {
// Using a JWT token for authentication?
var claims = &jwt.Claims{}
if msg.JWTToken != "" || (config.Current.JWT.Enabled && config.Current.JWT.Strict) {
parsed, ok, err := jwt.ParseAndValidate(msg.JWTToken)
if err != nil {
log.Error("Error parsing JWT token in WebSocket login: %s", err)
sub.ChatServer("Your authentication has expired. Please go back and launch the chat room again.")
return
}
// Sanity check the username.
if msg.Username != parsed.Subject {
log.Error("JWT login had a different username: %s vs %s", parsed.Subject, msg.Username)
sub.ChatServer("Your authentication username did not match the expected username. Please go back and launch the chat room again.")
return
}
// Strict enforcement?
if config.Current.JWT.Strict && !ok {
log.Error("JWT enforcement is strict and user did not pass JWT checks")
sub.ChatServer("Server side authentication is required. Please go back and launch the chat room from your logged-in account.")
return
}
claims = parsed
msg.Username = claims.Subject
sub.JWTClaims = claims
}
log.Info("JWT claims: %+v", claims)
// Ensure the username is unique, or rename it.
var duplicate bool
for _, other := range s.IterSubscribers() {
@ -30,6 +63,7 @@ func (s *Server) OnLogin(sub *Subscriber, msg Message) {
// Use their username.
sub.Username = msg.Username
sub.authenticated = true
log.Debug("OnLogin: %s joins the room", sub.Username)
// Tell everyone they joined.
@ -44,6 +78,18 @@ func (s *Server) OnLogin(sub *Subscriber, msg Message) {
// Send the WhoList to everybody.
s.SendWhoList()
// Send the initial ChatServer messages to the public channels.
for _, channel := range config.Current.PublicChannels {
for _, msg := range channel.WelcomeMessages {
sub.SendJSON(Message{
Channel: channel.ID,
Action: ActionError,
Username: "ChatServer",
Message: RenderMarkdown(msg),
})
}
}
}
// OnMessage handles a chat message posted by the user.
@ -54,18 +100,23 @@ func (s *Server) OnMessage(sub *Subscriber, msg Message) {
return
}
// Translate their message as Markdown syntax.
markdown := RenderMarkdown(msg.Message)
if markdown == "" {
return
}
// Message to be echoed to the channel.
var message = Message{
Action: ActionMessage,
Channel: msg.Channel,
Username: sub.Username,
Message: msg.Message,
Message: markdown,
}
// Is this a DM?
if strings.HasPrefix(msg.Channel, "@") {
// Echo the message only to both parties.
// message.Channel = "@" + sub.Username
s.SendTo(sub.Username, message)
message.Channel = "@" + sub.Username
s.SendTo(msg.Channel, message)
@ -73,12 +124,7 @@ func (s *Server) OnMessage(sub *Subscriber, msg Message) {
}
// Broadcast a chat message to the room.
s.Broadcast(Message{
Action: ActionMessage,
Channel: msg.Channel,
Username: sub.Username,
Message: msg.Message,
})
s.Broadcast(message)
}
// OnMe handles current user state updates.

54
pkg/jwt/jwt.go Normal file
View File

@ -0,0 +1,54 @@
package jwt
import (
"encoding/json"
"errors"
"html/template"
"git.kirsle.net/apps/barertc/pkg/config"
"github.com/golang-jwt/jwt/v4"
)
// Custom JWT Claims.
type Claims struct {
// Custom claims.
IsAdmin bool `json:"op"`
Avatar string `json:"img"`
ProfileURL string `json:"url"`
// Standard claims. Notes:
// subject = username
jwt.RegisteredClaims
}
// ToJSON serializes the claims to JavaScript.
func (c Claims) ToJSON() template.JS {
data, _ := json.Marshal(c)
return template.JS(data)
}
// ParseAndValidate returns the Claims, a boolean authOK, and any errors.
func ParseAndValidate(tokenStr string) (*Claims, bool, error) {
// Handle a JWT authentication token.
var (
claims = &Claims{}
authOK bool
)
if tokenStr != "" {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(config.Current.JWT.SecretKey), nil
})
if err != nil {
return nil, false, err
}
if parsed, ok := token.Claims.(*Claims); ok && token.Valid {
claims = parsed
authOK = true
} else {
return nil, false, errors.New("claims did not parse OK")
}
}
return claims, authOK, nil
}

19
pkg/markdown.go Normal file
View File

@ -0,0 +1,19 @@
package barertc
import (
"strings"
"github.com/microcosm-cc/bluemonday"
"github.com/shurcooL/github_flavored_markdown"
)
// Rendermarkdown from untrusted sources.
func RenderMarkdown(input string) string {
// Render Markdown to HTML.
html := github_flavored_markdown.Markdown([]byte(input))
// Sanitize the HTML from any nasties.
p := bluemonday.UGCPolicy()
safened := p.SanitizeBytes(html)
return strings.TrimSpace(string(safened))
}

View File

@ -6,6 +6,9 @@ type Message struct {
Username string `json:"username,omitempty"`
Message string `json:"message,omitempty"`
// JWT token for `login` actions.
JWTToken string `json:"jwt,omitempty"`
// WhoList for `who` actions
WhoList []WhoList `json:"whoList,omitempty"`
@ -45,4 +48,9 @@ const (
type WhoList struct {
Username string `json:"username"`
VideoActive bool `json:"videoActive"`
// JWT auth extra settings.
Operator bool `json:"op"`
Avatar string `json:"avatar"`
ProfileURL string `json:"profileURL"`
}

View File

@ -1,10 +1,12 @@
package barertc
import (
"fmt"
"html/template"
"net/http"
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/jwt"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/util"
)
@ -15,6 +17,35 @@ func IndexPage() http.HandlerFunc {
// Load the template, TODO: once on server startup.
tmpl := template.New("index")
// Handle a JWT authentication token.
var (
tokenStr = r.FormValue("jwt")
claims = &jwt.Claims{}
authOK bool
)
if tokenStr != "" {
parsed, ok, err := jwt.ParseAndValidate(tokenStr)
if err != nil {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(
fmt.Sprintf("Error parsing your JWT token: %s", err),
))
return
}
authOK = ok
claims = parsed
}
// Are we enforcing strict JWT authentication?
if config.Current.JWT.Enabled && config.Current.JWT.Strict && !authOK {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(
fmt.Sprintf("Authentication denied. Please go back and try again."),
))
return
}
// Variables to give to the front-end page.
var values = map[string]interface{}{
// A cache-busting hash for JS and CSS includes.
@ -22,6 +53,11 @@ func IndexPage() http.HandlerFunc {
// The current website settings.
"Config": config.Current,
// Authentication settings.
"JWTTokenString": tokenStr,
"JWTAuthOK": authOK,
"JWTClaims": claims,
}
tmpl.Funcs(template.FuncMap{

View File

@ -9,6 +9,7 @@ import (
"strings"
"time"
"git.kirsle.net/apps/barertc/pkg/jwt"
"git.kirsle.net/apps/barertc/pkg/log"
"nhooyr.io/websocket"
)
@ -16,14 +17,16 @@ import (
// Subscriber represents a connected WebSocket session.
type Subscriber struct {
// User properties
ID int // ID assigned by server
Username string
VideoActive bool
conn *websocket.Conn
ctx context.Context
cancel context.CancelFunc
messages chan []byte
closeSlow func()
ID int // ID assigned by server
Username string
VideoActive bool
JWTClaims *jwt.Claims
authenticated bool // has passed the login step
conn *websocket.Conn
ctx context.Context
cancel context.CancelFunc
messages chan []byte
closeSlow func()
}
// ReadLoop spawns a goroutine that reads from the websocket connection.
@ -228,6 +231,10 @@ func (s *Server) Broadcast(msg Message) {
s.subscribersMu.RLock()
defer s.subscribersMu.RUnlock()
for _, sub := range s.IterSubscribers(true) {
if !sub.authenticated {
continue
}
sub.SendJSON(Message{
Action: msg.Action,
Channel: msg.Channel,
@ -263,10 +270,20 @@ func (s *Server) SendWhoList() {
)
for _, sub := range subscribers {
users = append(users, WhoList{
if !sub.authenticated {
continue
}
who := WhoList{
Username: sub.Username,
VideoActive: sub.VideoActive,
})
}
if sub.JWTClaims != nil {
who.Operator = sub.JWTClaims.IsAdmin
who.Avatar = sub.JWTClaims.Avatar
who.ProfileURL = sub.JWTClaims.ProfileURL
}
users = append(users, who)
}
for _, sub := range subscribers {

View File

@ -9,6 +9,15 @@ body {
float: right;
}
.cursor-default {
cursor: default;
}
/* Bulma override */
.media-content {
overflow: hidden !important;
}
/************************
* Main CSS Grid Layout *
************************/
@ -25,7 +34,7 @@ body {
display: grid;
column-gap: 10px;
row-gap: 10px;
grid-template-columns: 260px 1fr 260px;
grid-template-columns: 260px 1fr 280px;
grid-template-rows: 1fr auto;
}

BIN
web/static/img/client.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

BIN
web/static/img/server.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

BIN
web/static/img/shy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -17,6 +17,14 @@ const app = Vue.createApp({
// Website configuration provided by chat.html template.
config: {
channels: PublicChannels,
website: WebsiteURL,
},
// User JWT settings if available.
jwt: {
token: UserJWTToken,
valid: UserJWTValid,
claims: UserJWTClaims
},
channel: "lobby",
@ -31,6 +39,7 @@ const app = Vue.createApp({
// Who List for the room.
whoList: [],
whoMap: {}, // map username to wholist entry
// My video feed.
webcam: {
@ -106,6 +115,15 @@ const app = Vue.createApp({
this.ChatServer("Welcome to BareRTC!")
// Auto login with JWT token?
// TODO: JWT validation on the WebSocket as well.
if (this.jwt.valid && this.jwt.claims.sub) {
this.username = this.jwt.claims.sub;
}
// Scrub JWT token from query string parameters.
history.pushState(null, "", location.href.split("?")[0]);
if (!this.username) {
this.loginModal.visible = true;
} else {
@ -151,6 +169,10 @@ const app = Vue.createApp({
return this.channel;
},
isDM() {
// Is the current channel a DM?
return this.channel.indexOf("@") === 0;
},
},
methods: {
signIn() {
@ -204,10 +226,12 @@ const app = Vue.createApp({
// WhoList updates.
onWho(msg) {
this.whoList = msg.whoList;
this.whoMap = {};
// If we had a camera open with any of these and they have gone
// off camera, close our side of the connection.
for (let row of this.whoList) {
this.whoMap[row.username] = row;
if (this.WebRTC.streams[row.username] != undefined &&
row.videoActive !== true) {
this.closeVideo(row.username);
@ -252,6 +276,25 @@ const app = Vue.createApp({
});
},
// User logged in or out.
onPresence(msg) {
// TODO: make a dedicated leave event
if (msg.message.indexOf("has exited the room!") > -1) {
// Clean up data about this user.
this.onUserExited(msg);
}
// Push it to the history of all public channels.
for (let channel of this.config.channels) {
this.pushHistory({
channel: channel.ID,
action: msg.action,
username: msg.username,
message: msg.message,
});
}
},
// Dial the WebSocket connection.
dial() {
console.log("Dialing WebSocket...");
@ -259,6 +302,9 @@ const app = Vue.createApp({
const conn = new WebSocket(`${proto}://${location.host}/ws`);
conn.addEventListener("close", ev => {
// Lost connection to server - scrub who list.
this.onWho({whoList: []});
this.ws.connected = false;
this.ChatClient(`WebSocket Disconnected code: ${ev.code}, reason: ${ev.reason}`);
@ -276,6 +322,7 @@ const app = Vue.createApp({
this.ws.conn.send(JSON.stringify({
action: "login",
username: this.username,
jwt: this.jwt.token,
}));
});
@ -300,16 +347,7 @@ const app = Vue.createApp({
this.onMessage(msg);
break;
case "presence":
// TODO: make a dedicated leave event
if (msg.message.indexOf("has exited the room!") > -1) {
// Clean up data about this user.
this.onUserExited(msg);
}
this.pushHistory({
action: msg.action,
username: msg.username,
message: msg.message,
});
this.onPresence(msg);
break;
case "ring":
this.onRing(msg);
@ -325,9 +363,10 @@ const app = Vue.createApp({
break;
case "error":
this.pushHistory({
channel: msg.channel,
username: msg.username || 'Internal Server Error',
message: msg.message,
isChatClient: true,
isChatServer: true,
});
default:
console.error("Unexpected action: %s", JSON.stringify(msg));
@ -506,6 +545,9 @@ const app = Vue.createApp({
this.channel = typeof(channel) === "string" ? channel : channel.ID;
this.scrollHistory();
this.channels[this.channel].unread = 0;
// Responsive CSS: switch back to chat panel upon selecting a channel.
this.openChatPanel();
},
hasUnread(channel) {
if (this.channels[channel] == undefined) {
@ -517,6 +559,47 @@ const app = Vue.createApp({
let channel = "@" + user.username;
this.initHistory(channel);
this.setChannel(channel);
// Responsive CSS: switch back to chat panel upon opening a DM.
this.openChatPanel();
},
openProfile(user) {
let url = this.profileURLForUsername(user.username);
if (url) {
window.open(url);
}
},
avatarURL(user) {
// Resolve the avatar URL of this user.
if (user.avatar.match(/^https?:/i)) {
return user.avatar;
} else if (user.avatar.indexOf("/") === 0) {
return this.config.website.replace(/\/+$/, "") + user.avatar;
}
return "";
},
avatarForUsername(username) {
if (this.whoMap[username] != undefined && this.whoMap[username].avatar) {
return this.avatarURL(this.whoMap[username]);
}
return null;
},
profileURLForUsername(username) {
if (!username) return;
username = username.replace(/^@/, "");
if (this.whoMap[username] != undefined && this.whoMap[username].profileURL) {
let url = this.whoMap[username].profileURL;
if (url.match(/^https?:/i)) {
return url;
} else if (url.indexOf("/") === 0) {
// Subdirectory relative to our WebsiteURL
return this.config.website.replace(/\/+$/, "") + url;
} else {
this.ChatClient("Didn't know how to open profile URL: " + url);
}
return url;
}
return null;
},
leaveDM() {
// Validate we're in a DM currently.
@ -675,7 +758,10 @@ const app = Vue.createApp({
// Mark unread notifiers if this is not our channel.
if (this.channel !== channel) {
this.channels[channel].unread++;
// Don't notify about presence broadcasts.
if (action !== "presence") {
this.channels[channel].unread++;
}
}
},

View File

@ -111,25 +111,39 @@
<div class="card grid-card">
<header class="card-header has-background-link">
<div class="columns is-mobile card-header-title has-text-light">
<div class="column is-narrow mobile-only">
<div class="column is-narrow mobile-only pr-0">
<!-- Responsive mobile button to pan to Left Column -->
<button type="button"
class="button is-success"
:class="{'is-small': isDM}"
@click="openChannelsPanel">
<i class="fa fa-message"></i>
<i v-if="isDM" class="fa fa-arrow-left"></i>
<i v-else class="fa fa-message"></i>
</button>
</div>
<div class="column">[[channelName]]</div>
<div class="column">
[[channelName]]
</div>
<div class="column is-narrow">
<!-- If a DM thread and the user has a profile URL -->
<button type="button"
v-if="this.channel.indexOf('@') === 0 && profileURLForUsername(this.channel)"
class="button is-small is-outlined is-light mr-1"
@click="openProfile({username: this.channel})">
<i class="fa fa-user"></i>
</button>
<!-- DMs: Leave convo button -->
<button type="button"
v-if="channel.indexOf('@') === 0"
class="float-right button is-small is-warning is-outline"
class="float-right button is-small is-warning is-outlined"
@click="leaveDM()">
<i class="fa fa-trash"></i>
</button>
</div>
<div class="column is-narrow mobile-only">
<!-- Who List button, only shown on public channel view -->
<div v-if="!isDM" class="column is-narrow mobile-only">
<!-- Responsive mobile button to pan to Right Column -->
<button type="button"
class="button is-success"
@ -155,21 +169,59 @@
</div>
<div class="card-content" id="chatHistory">
<div v-for="(msg, i) in chatHistory" v-bind:key="i">
<div>
<label class="label"
:class="{'has-text-success is-dark': msg.isChatServer,
'has-text-warning is-dark': msg.isAdmin,
'has-text-danger': msg.isChatClient}">
[[msg.username]]
</label>
<!-- No history? -->
<div v-if="chatHistory.length === 0">
<em v-if="isDM">
Starting a direct message chat with [[channel]]. Type a message and say hello!
</em>
<em v-else>
There are no messages in this channel yet.
</em>
</div>
<div v-for="(msg, i) in chatHistory" v-bind:key="i" class="mb-2">
<div class="media mb-0">
<div class="media-left">
<a :href="profileURLForUsername(msg.username)" @click.prevent="openProfile({username: msg.username})"
:class="{'cursor-default': !profileURLForUsername(msg.username)}">
<figure class="image is-24x24">
<img v-if="msg.isChatServer"
src="/static/img/server.png">
<img v-else-if="msg.isChatClient"
src="/static/img/client.png">
<img v-else-if="avatarForUsername(msg.username)"
:src="avatarForUsername(msg.username)">
<img v-else src="/static/img/shy.png">
</figure>
</a>
</div>
<div class="media-content">
<div class="columns is-mobile">
<div class="column">
<label class="label"
:class="{'has-text-success is-dark': msg.isChatServer,
'has-text-warning is-dark': msg.isAdmin,
'has-text-danger': msg.isChatClient}">
[[msg.username]]
</label>
</div>
<div class="column is-narrow"
v-if="!(msg.isChatServer || msg.isChatClient || msg.username === username)">
<button type="button"
class="button is-dark is-outlined is-small"
@click="openDMs({username: msg.username})">
<i class="fa fa-message"></i>
</button>
</div>
</div>
</div>
</div>
<div v-if="msg.action === 'presence'">
<em>[[msg.message]]</em>
</div>
<div v-else>
[[msg.message]]
<div v-else class="content">
<div v-html="msg.message"></div>
</div>
</div>
@ -234,17 +286,43 @@
<ul class="menu-list">
<li v-for="(u, i) in whoList" v-bind:key="i">
<div class="columns is-mobile">
<div class="column">[[ u.username ]]</div>
<div class="column is-narrow">
<!-- Avatar URL if available -->
<div class="column is-narrow pr-0"
v-if="u.avatar">
<img :src="avatarURL(u)"
width="24" height="24"
:alt="'Avatar image for ' + u.username">
</div>
<div class="column pr-0"
:class="{'pl-1': u.avatar}">
<i class="fa fa-gavel has-text-warning-dark"
v-if="u.op"
title="Operator"></i>
[[ u.username ]]
</div>
<div class="column is-narrow pl-0">
<!-- Profile button -->
<button type="button"
v-if="u.profileURL"
class="button is-small px-2 py-1"
@click="openProfile(u)"
title="Open profile page">
<i class="fa fa-user"></i>
</button>
<!-- DM button -->
<button type="button"
class="button is-small px-2 py-1"
@click="openDMs(u)"
title="Start direct message thread"
:disabled="u.username === username">
<i class="fa fa-message"></i>
</button>
<button type="button" class="button is-small"
<!-- Video button -->
<button type="button" class="button is-small px-2 py-1"
:disabled="!u.videoActive"
title="Open video stream"
@click="openVideo(u)">
<i class="fa fa-video"></i>
</button>
@ -262,6 +340,10 @@
<script type="text/javascript">
const PublicChannels = {{.Config.GetChannels}};
const WebsiteURL = "{{.Config.WebsiteURL}}";
const UserJWTToken = {{.JWTTokenString}};
const UserJWTValid = {{if .JWTAuthOK}}true{{else}}false{{end}};
const UserJWTClaims = {{.JWTClaims.ToJSON}};
</script>
<script src="/static/js/vue-3.2.45.js"></script>