From 1ecff195ac2725c688c615c1f465e43139ac92f6 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sun, 5 Feb 2023 17:42:09 -0800 Subject: [PATCH] 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! --- .gitignore | 1 + README.md | 118 +++++++++++++++++++++++++++++++++----- go.mod | 22 ++++++- go.sum | 33 +++++++++++ pkg/config/config.go | 15 +++++ pkg/handlers.go | 62 +++++++++++++++++--- pkg/jwt/jwt.go | 54 +++++++++++++++++ pkg/markdown.go | 19 ++++++ pkg/messages.go | 8 +++ pkg/pages.go | 36 ++++++++++++ pkg/websocket.go | 37 ++++++++---- web/static/css/chat.css | 11 +++- web/static/img/client.png | Bin 0 -> 9687 bytes web/static/img/server.png | Bin 0 -> 10132 bytes web/static/img/shy.png | Bin 0 -> 3494 bytes web/static/js/BareRTC.js | 110 +++++++++++++++++++++++++++++++---- web/templates/chat.html | 118 ++++++++++++++++++++++++++++++++------ 17 files changed, 577 insertions(+), 67 deletions(-) create mode 100644 .gitignore create mode 100644 pkg/jwt/jwt.go create mode 100644 pkg/markdown.go create mode 100644 web/static/img/client.png create mode 100644 web/static/img/server.png create mode 100644 web/static/img/shy.png 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 0000000000000000000000000000000000000000..2bf8c9734ae1ca9fb5737e197f143751e30c55c1 GIT binary patch literal 9687 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 literal 0 HcmV?d00001 diff --git a/web/static/img/server.png b/web/static/img/server.png new file mode 100644 index 0000000000000000000000000000000000000000..1bece9724f72c88466f409d469f14cdf86a64df8 GIT binary patch literal 10132 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 literal 0 HcmV?d00001 diff --git a/web/static/img/shy.png b/web/static/img/shy.png new file mode 100644 index 0000000000000000000000000000000000000000..ef845c2ec7cf6029df9bb1a599c2c306dfb85ed3 GIT binary patch literal 3494 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 nPWj#1{%0@y|8%_5_ -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 @@