diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8023fbb --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/mods.htpasswd diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..db39e61 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Luxeria + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..77f96c8 --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# jitsi-token-service + +This project provides an implementation of a token provider for the +[Jitsi JWT authentication mechanism](https://github.com/jitsi/lib-jitsi-meet/blob/f5f1c314c9de875357b41784254aa73768d2bfdc/doc/tokens.md) +for _self-hosted_ Jitsi Meet installations. +In particular, it also is able to generate moderator tokens for the +[`token_affiliation`](https://github.com/jitsi-contrib/prosody-plugins/blob/127ef7e89d6d36e1a1c95897c857f86a7f485a4a/token_affiliation/README.md) +plugin. + +This effectively allows you to provide an external login system for authenticating +Jitsi moderators (without the need) + +## Configuration + +### Environment + +This section describes the environment variables which are used to configure +the token service: + +- `COOKIE_NAME` _(required)_: Name of the moderator cookie (example: `jitsi_mod_jwt`) +- `JWT_VALIDITY` _(default: `1h`)_: Duration for which the generated token + should be valid for (example: `1h30m`, `300s`) +- `JWT_SECRET` _(required)_: Secret JWT signing string, this value needs + to match the one configured in Jitsi meet (example: `my_jitsi_app_secret`) +- `JWT_ISSUER` _(required)_: JWT issuer, this value needs + to match the one configured in Jitsi meet (example: `my_jitsi_app_id`) +- `JWT_AUDIENCE` _(default: `jitsi`)_: JWT audience, this value needs + to match the one configured in Jitsi +- `JWT_LEEWAY` _(default: `1m`)_: Maximum clock drift allowed for JWT validation +- `JITSI_URL` _(required)_: Jitsi Meet base URL (without the room identifier) + (example: `https://meet.jitsi`) +- `HTML_DOCROOT` _(default: `./html`)_: Directory containing the HTML login + form template +- `HTML_TITLE` _(default: `Jitsi Moderator Login`)_: HTML title displayed to + the user on the login form +- `MODS_FILE` _(default: `./mods.htpasswd`)_: Path to the htaccess file containing + the list of all authorized moderators with their password hash +- `HTTP_ADDR` _(default: `:8080`)_: Address on which the HTTP server will listen on + +### Moderators File + +The `MODS_FILE` environment variable must point to a text file in the `htaccess` +format containing all authorized moderators with their accompanying password +hashes. Use one line per username and pasword pair. Empty lines or comment lines +(starting with `#`) are allowed. + +The password hash needs to be a [bcrypt](https://en.wikipedia.org/wiki/Bcrypt) +hash (cost 11 or higher is recommended). + +```apache +# example mods.htaccess file: +bob:$2y$12$EAZVfUzqmzqloNbfRAqY.e295IjhfHI/Ma2Tezqk8DryRZnkOT792 +alice:$2y$12$ianjnXNNHVQwiy3R15NsiO.lC04uEG7SEzP6qv/Rs1NZmNyPB6Muu +``` + +> [!NOTE] +> The moderator file username is only used for authentication and is not +> forwarded to Jitsi Meet in any form. +> Moderators may use a different username as their "Display Name" when joining +> a Jitsi Meet room. + +### Jitsi + +For details on how to configure your self-hosted instance of Jitsi, +please refer to the Jitsi documentation. +The following example illustrates what values may be relevant when using +[jitsi/docker-jitsi-meet](https://github.com/jitsi/docker-jitsi-meet): + +```shell +# Enable JWT authentication +ENABLE_AUTH=1 +AUTH_TYPE=jwt + +# JWT identifier and secret (shared between Jitsi and the token service) +JWT_APP_ID=my_jitsi_app_id +JWT_APP_SECRET=my_jitsi_app_secret # important: change this! + +# URL to this token service +TOKEN_AUTH_URL=https://auth.jitsi:8080/autologin?room={room} + +# Enable the token_affiliation Prosody plugin +XMPP_MUC_MODULES=token_affiliation +``` + +## Login Flow + +```mermaid +sequenceDiagram + participant Browser as User Agent + participant Auth as Jitsi Token Service
(auth.jitsi) + participant Jitsi as Jitsi Meet
(meet.jitsi) + + opt Moderator Login + activate Browser + + Browser->>+Auth: GET https://auth.jitsi/login + Auth-->>-Browser: Login Page + + Note over Browser: User provides
username and password + + Browser->>+Auth: POST https://auth.jitsi/login + Note over Auth: Verifies username and password + Note over Auth: Generates and signs JWT
(with `moderator:true` claim) + Auth-->>-Browser: Store JWT in cookie + + deactivate Browser + end + + activate Browser + Browser->>+Jitsi: https://meet.jitsi/ + Note over Jitsi: User is not authenticated.
Deny user to join room + Jitsi-->>-Browser: Redirect to Jitsi Token Service + + Browser->>+Auth: GET https://auth.jitsi/autologin?room= + alt Cookie provided + Note over Auth: Verifies cookie and JWT.
Reuses JWT if valid + else Cookie missing
or invalid + Note over Auth: Generates and signs new JWT
(without moderator claim) + end + Auth-->>-Browser: Redirect to Jitsi Meet (with JWT) + + Browser->>+Jitsi: https://meet.jitsi/?jwt= + Note over Jitsi: Verifies JWT signature + alt `moderator:true` + Note over Jitsi: Allow user to join and
grant moderator rights + else `moderator:false` + Note over Jitsi: Allow user to join + end + Jitsi-->>-Browser: Join room + + deactivate Browser +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..546bbdd --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/luxeria/jitsi-token-service + +go 1.22.2 + +require ( + github.com/caarlos0/env/v11 v11.0.0 + github.com/golang-jwt/jwt/v5 v5.2.1 + golang.org/x/crypto v0.22.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..856c128 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/caarlos0/env/v11 v11.0.0 h1:ZIlkOjuL3xoZS0kmUJlF74j2Qj8GMOq3CDLX/Viak8Q= +github.com/caarlos0/env/v11 v11.0.0/go.mod h1:2RC3HQu8BQqtEK3V4iHPxj0jOdWdbPpWJ6pOueeU1xM= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= diff --git a/html/login.tmpl.html b/html/login.tmpl.html new file mode 100644 index 0000000..37a57de --- /dev/null +++ b/html/login.tmpl.html @@ -0,0 +1,66 @@ + + + + + + + + {{ .Title }} + + + + +

{{ .Title }}

+ {{ if .Error }} +
+ {{ .Error }} +
+ {{ end }} +
+
+ + +
+ +
+ + +
+ + +
+ + + \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..01c4a09 --- /dev/null +++ b/main.go @@ -0,0 +1,353 @@ +package main + +import ( + "bufio" + "errors" + "fmt" + "html/template" + "log/slog" + "net/http" + "net/url" + "os" + "path" + "strings" + "time" + + "github.com/caarlos0/env/v11" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +// config contains all options why may be configured via environment variables. +// See the README for more details. +var config struct { + CookieName string `env:"COOKIE_NAME,notEmpty"` + + JWTValidity time.Duration `env:"JWT_VALIDITY" envDefault:"1h"` + JWTSecret string `env:"JWT_SECRET,notEmpty,unset"` + JWTIssuer string `env:"JWT_ISSUER,notEmpty"` + JWTAudience string `env:"JWT_AUDIENCE" envDefault:"jitsi"` + JWTLeeway time.Duration `env:"JWT_LEEWAY" envDefault:"1m"` + + JitsiURL url.URL `env:"JITSI_URL,notEmpty"` + + HTMLDocRoot string `env:"HTML_DOCROOT" envDefault:"./html"` + HTMLTitle string `env:"HTML_TITLE" envDefault:"Jitsi Moderator Login"` + + ModsFile string `env:"MODS_FILE" envDefault:"./mods.htpasswd"` + + HTTPAddr string `env:"HTTP_ADDR" envDefault:":8080"` +} + +// JitsiClaims defines the schema of the JWT generated by this program. +// Besides the standard JWT claims, it contains two Jitsi specific claims: +// "room" (restricting what rooms the JWT is valid for) and "context" (providing +// additional user context, such as their moderator status). +type JitsiClaims struct { + jwt.RegisteredClaims + + Room string `json:"room,omitempty"` + Context *Context `json:"context,omitempty"` +} + +// Context contains the user context +type Context struct { + User User `json:"user,omitempty"` +} + +// User is the user context defining if the token-presenting user should have +// moderator rights or not +type User struct { + Moderator bool `json:"moderator,omitempty"` +} + +// loadTokenFromCookie loads and verifies a JWT from a HTTP cookie. If the +// cookie and the contained JWT is valid, the raw token and its "context" claim +// are returned. If the JWT contained in the cookie is invalid, an error is returned. +// If no cookie was found in the HTTP request, http.ErrNoCookie is returned. +func loadTokenFromCookie(r *http.Request) (token string, context *Context, err error) { + c, err := r.Cookie(config.CookieName) + if err != nil { + return "", nil, err // no cookie + } + + err = c.Valid() + if err != nil { + return "", nil, err // invalid cookie + } + + claims := JitsiClaims{} + t, err := jwt.ParseWithClaims(c.Value, &claims, + func(token *jwt.Token) (interface{}, error) { + return []byte(config.JWTSecret), nil + }, + jwt.WithExpirationRequired(), + jwt.WithIssuedAt(), + jwt.WithAudience(config.JWTAudience), + jwt.WithIssuer(config.JWTIssuer), + jwt.WithLeeway(config.JWTLeeway)) + if err != nil { + return "", nil, err // invalid JWT + } + + return t.Raw, claims.Context, nil +} + +// generateToken generates and signs a new JWT. With the exception of the passed +// in context (which may be nil), all claims and the signing secret are read +// from the global config struct. +func generateToken(context *Context) (token string, err error) { + now := time.Now() + + claims := JitsiClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Audience: []string{config.JWTAudience}, + Issuer: config.JWTIssuer, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(config.JWTValidity)), + }, + Room: "*", // valid for all Jitsi rooms + Context: context, + } + + t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return t.SignedString([]byte(config.JWTSecret)) +} + +// redirectToJitsi redirects the current HTTP request back to Jitsi, presenting +// the JWT contained in rawToken if redirecting back to a room +func redirectToJitsi(w http.ResponseWriter, r *http.Request, context *Context, rawToken string) { + // Jitsi base URL + jitsiURL := url.URL{ + Scheme: config.JitsiURL.Scheme, + Host: config.JitsiURL.Host, + } + + // If a `room` query parameter was provided to us, we construct a URL + // containing the JWT, allowing the user to join the room automatically. + // If the room parameter is empty or missing, we redirect to the welcome + // page and do not present a JWT token. + room := r.URL.Query().Get("room") + if room != "" { + // Appends room name to target URL + jitsiURL.Path = room + + // Jitsi expects the JWT to be passed via query parameter (`?jwt=...`) + jitsiURL.RawQuery = url.Values{ + "jwt": []string{ + rawToken, + }, + }.Encode() + + // If a room query parameter is present, we assume the user was redirected + // here by clicking on the "Join" button. Once we redirect the user back + // to Jitsi with a valid JWT, they would have to click the "Join" + // button again. Since this would lead to a bad user experience (i.e. the + // user has to press the "Join" button twice for no apparent reason), + // we tell Jitsi via URL config override to skip the prejoin screen and + // immediately join the meeting (effectively pressing the "Join" button + // for them the second time). + jitsiURL.Fragment = `config.prejoinConfig.enabled=false` + + // BUG: Prosody (Jitsi's room and user management component) seems to flap + // the moderator status of users providing a any JWT, i.e. temporarily + // granting moderator rights even to regular users without a moderator + // token. That moderator right is taken away immediately to users without + // a moderator claim in their JWT, but still causes the web interface + // to show a "You're now a moderator" message, even though they are not. + // + // Therefore, as a workaround, we disable the "You're now a moderator" + // popup if the current JWT does not grant moderator rights to the + // current user. + isModerator := context != nil && context.User.Moderator + if !isModerator { + jitsiURL.Fragment += `&config.disabledNotifications=["notify.moderator"]` + } + } + + http.Redirect(w, r, jitsiURL.String(), http.StatusSeeOther) +} + +// htpasswd is a map containing usernames as key and bcrypt hashes as values +type htpasswd map[string]string + +// loadHTPasswd parses a htpasswd formatted file and returns a map of users +// with their corresponding password hash. +func loadHTPasswd(file string) (htpasswd, error) { + f, err := os.Open(file) + if err != nil { + return nil, err + } + defer f.Close() + + lineNo := 0 + h := htpasswd{} + scanner := bufio.NewScanner(f) + for scanner.Scan() { + lineNo++ + + // Skip empty lines or lines starting with `#` (i.e. comments) + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Expects every line to be formatted as `user:hash` + user, hash, ok := strings.Cut(line, ":") + if !ok { + return nil, fmt.Errorf("invalid htpasswd file (line %d): %s", lineNo, file) + } + h[user] = hash + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return h, nil +} + +// isAuthenticated returns true if (and only if) the provided username and +// password match an entry in the htpasswd file +func (h htpasswd) isAuthenticated(username, password string) bool { + expectedPass, ok := h[username] + if !ok { + return false + } + + err := bcrypt.CompareHashAndPassword([]byte(expectedPass), []byte(password)) + return err == nil +} + +// loginPage renders a HTML login page. An optional error message +// and HTTP status code can be passed in (e.g. if the previous login failed). +func loginPage(w http.ResponseWriter, error string, code int) { + // Load the HTML template file from the doc root folder + t, err := template.ParseFiles(path.Join(config.HTMLDocRoot, "login.tmpl.html")) + if err != nil { + slog.Error("failed to parse template", slog.Any("error", err)) + http.Error(w, "failed to parse template", http.StatusInternalServerError) + return + } + + // Render the template, passing in the .Error and .Title values + w.WriteHeader(code) + err = t.Execute(w, struct { + Title string + Error string + }{ + Title: config.HTMLTitle, + Error: error, + }) + if err != nil { + slog.Error("failed to render template", slog.Any("error", err)) + return + } +} + +// modLogin processes a moderator login request. We expect a HTTP POST request +// containing a "username" and "password" form value. Those credentials are +// checked against to the moderator file. +// If the credentials are valid, a JWT with a "Moderator" claim is +// generated, which Jitsi's `token_affiliation` plugin will use to grant +// moderator rights to the current user. +// The generated token is stored in a cookie, which is presented whenever the +// autologin callback is invoked by Jitsi. This ensures moderators do not have +// to provide their password again as long they still have a valid JWT. +func modLogin(w http.ResponseWriter, r *http.Request) { + // Load moderator password file + mods, err := loadHTPasswd(config.ModsFile) + if err != nil { + http.Error(w, "internal server error", http.StatusInternalServerError) + slog.Error("failed to load htpasswd", slog.Any("error", err)) + return + } + + // Check for valid HTTP POST request + if err := r.ParseForm(); err != nil { + http.Error(w, fmt.Sprintf("invalid request: %s", err), http.StatusInternalServerError) + return + } + + // Check username and password against mods map + user, pass := r.PostFormValue("username"), r.PostFormValue("password") + if !mods.isAuthenticated(user, pass) { + loginPage(w, "invalid username or password", http.StatusUnauthorized) + return + } + + // User is authenticated, therefore we generate a token with a "Moderator" + // context claim + slog.Info("Generating moderator token", slog.String("user", user)) + context := &Context{User: User{Moderator: true}} + token, err := generateToken(context) + if err != nil { + http.Error(w, fmt.Sprintf("unable to generate token: %s", err), http.StatusInternalServerError) + return + } + + // Store the token in a cookie. This cookie is valid for the same duration + // as the JWT. The cookie is read by the autologin callback to skip the + // creation of a regular user JWT in favor of this higher-privilege + // moderator JWT + http.SetCookie(w, &http.Cookie{ + Name: config.CookieName, + Value: token, + Path: "/autologin", + MaxAge: int(config.JWTValidity.Seconds()), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + + redirectToJitsi(w, r, context, token) +} + +// autoLogin is the callback invoked by Jitsi whenever it requires a JWT +// for the current user +func autoLogin(w http.ResponseWriter, r *http.Request) { + // If the user previously logged in as a moderator, we stored their JWT + // token in a cookie. If the cookie and the contained JWT are both still + // valid, we present that JWT to Jitsi. + // If there is no cookie or the presented token is invalid, + // we generate a new regular user token without moderator permission. + token, context, err := loadTokenFromCookie(r) + if err != nil { + if !errors.Is(err, http.ErrNoCookie) { + slog.Info("failed validate cookie token", slog.Any("error", err), slog.Any("url", r.URL)) + } + + // No valid cookie was presented, generate a regular user token + context = nil + token, err = generateToken(context) + if err != nil { + http.Error(w, fmt.Sprintf("unable to generate token: %s", err), http.StatusInternalServerError) + return + } + } + + redirectToJitsi(w, r, context, token) +} + +func main() { + // Read configuration values from environment variables + if err := env.Parse(&config); err != nil { + slog.Error("failed to parse env variables", slog.Any("error", err)) + os.Exit(1) + } + + // HTTP endpoints and server + mux := http.NewServeMux() + mux.Handle("POST /login", http.HandlerFunc(modLogin)) + mux.Handle("GET /login", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + loginPage(w, "", http.StatusOK) + })) + mux.Handle("GET /autologin", http.HandlerFunc(autoLogin)) + + slog.Info("starting HTTP server", "addr", config.HTTPAddr) + err := http.ListenAndServe(config.HTTPAddr, mux) + if !errors.Is(err, http.ErrServerClosed) { + slog.Error("listener failed", slog.Any("error", err)) + os.Exit(1) + } +}