diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f38dc2 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +settings.toml diff --git a/README.md b/README.md index 6fb0861..1a31351 100644 --- a/README.md +++ b/README.md @@ -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 `
` 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 diff --git a/go.mod b/go.mod index 44baf68..d46505f 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index cebc06e..2041716 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/config/config.go b/pkg/config/config.go index 82c9759..1350969 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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 } diff --git a/pkg/handlers.go b/pkg/handlers.go index 41b7b39..c492f71 100644 --- a/pkg/handlers.go +++ b/pkg/handlers.go @@ -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. diff --git a/pkg/jwt/jwt.go b/pkg/jwt/jwt.go new file mode 100644 index 0000000..97327e8 --- /dev/null +++ b/pkg/jwt/jwt.go @@ -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 +} diff --git a/pkg/markdown.go b/pkg/markdown.go new file mode 100644 index 0000000..1896473 --- /dev/null +++ b/pkg/markdown.go @@ -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)) +} diff --git a/pkg/messages.go b/pkg/messages.go index d6819c2..26872eb 100644 --- a/pkg/messages.go +++ b/pkg/messages.go @@ -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"` } diff --git a/pkg/pages.go b/pkg/pages.go index 66fddd6..c8c51df 100644 --- a/pkg/pages.go +++ b/pkg/pages.go @@ -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{ diff --git a/pkg/websocket.go b/pkg/websocket.go index 1e70246..43a477e 100644 --- a/pkg/websocket.go +++ b/pkg/websocket.go @@ -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 { diff --git a/web/static/css/chat.css b/web/static/css/chat.css index e14dced..e6da13a 100644 --- a/web/static/css/chat.css +++ b/web/static/css/chat.css @@ -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; } diff --git a/web/static/img/client.png b/web/static/img/client.png new file mode 100644 index 0000000..2bf8c97 Binary files /dev/null and b/web/static/img/client.png differ diff --git a/web/static/img/server.png b/web/static/img/server.png new file mode 100644 index 0000000..1bece97 Binary files /dev/null and b/web/static/img/server.png differ diff --git a/web/static/img/shy.png b/web/static/img/shy.png new file mode 100644 index 0000000..ef845c2 Binary files /dev/null and b/web/static/img/shy.png differ diff --git a/web/static/js/BareRTC.js b/web/static/js/BareRTC.js index 6c86c1c..efef292 100644 --- a/web/static/js/BareRTC.js +++ b/web/static/js/BareRTC.js @@ -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++; + } } }, diff --git a/web/templates/chat.html b/web/templates/chat.html index 95f6d7c..2e1a65b 100644 --- a/web/templates/chat.html +++ b/web/templates/chat.html @@ -111,25 +111,39 @@