From b1cb5d55a1b046e45f8c5649cf28cbd196708650 Mon Sep 17 00:00:00 2001 From: Vincent Fretin Date: Sun, 11 Jul 2021 09:09:48 +0200 Subject: [PATCH 1/2] add securing-janus-with-jwt doc --- docs/securing-janus-with-jwt.md | 161 ++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 docs/securing-janus-with-jwt.md diff --git a/docs/securing-janus-with-jwt.md b/docs/securing-janus-with-jwt.md new file mode 100644 index 0000000..193f2ef --- /dev/null +++ b/docs/securing-janus-with-jwt.md @@ -0,0 +1,161 @@ +# Securing janus with JWT + +You'll find in this document some notes on how to secure the access to the rooms with JWT in a Phoenix/Elixir app. This is not a full tutorial, some Elixir code is missing (creating a room table, graphql mutation to create a room and query to get the token for example). I wrote this documentation while implementing it in my app. -- Vincent Fretin + +## janus side + +Generate a private/public key: + + ssh-keygen -t rsa -b 2048 -f key.pem -N '' + openssl rsa -in key.pem -outform PEM -pubout -out public.pem + openssl rsa -in key.pem -outform DER -out key.der + openssl rsa -in key.der -inform DER -RSAPublicKey_out -outform DER -out public.der + +Janus side in `janus.plugin.sfu.cfg`, specify `auth_key` to the public key in DER format + + auth_key = /path/to/public.der + +## backend side + +The backend is a simple [Phoenix](https://phoenixframework.org/)/[Elixir](https://elixir-lang.org) app with [phx_gen_auth](https://github.com/aaronrenner/phx_gen_auth) with a room table associated to the user table. +[Absinthe](https://absinthe-graphql.org) is used to have a graphql endpoint to create a room and to get the JWT for the room. This code is not available. I describe the changes I made from here. + +Add guardian dependency to `mix.exs`: + + {:guardian, "~> 2.0"}, + +Execute: + + mix deps.get + +This will add those new dependencies: + + guardian 2.1.1 + jose 1.11.1 + +create `lib/myapp_web/perms_token.ex` +``` +defmodule MyAppWeb.PermsToken do + @moduledoc """ + generate token for creator of the room "123": + MyAppWeb.PermsToken.token_for_perms(%{user_id: 1, kick_users: true, join_hub: true, room_ids: ["123"]}) + generate token for another authenticated participant for room "123": + MyAppWeb.PermsToken.token_for_perms(%{user_id: 2, kick_users: false, join_hub: true, room_ids: ["123"]}}) + generate token for anonymous: + MyAppWeb.PermsToken.token_for_perms(%{kick_users: false, join_hub: true, room_ids: ["123"]}}) + + """ + use Guardian, otp_app: :myapp, secret_fetcher: MyAppWeb.PermsTokenSecretFetcher, allowed_algos: ["RS512"] + + def subject_for_token(_resource, %{"user_id" => user_id}) do + {:ok, to_string(user_id)} + end + + def subject_for_token(_resource, _claims) do + {:ok, "anon"} + end + + # we don't use the inverse + def resource_from_claims(_), do: nil + + def token_for_perms(perms) do + {:ok, token, _claims} = + MyAppWeb.PermsToken.encode_and_sign( + # PermsTokens do not have a resource associated with them + nil, + perms |> Map.put(:aud, :webrtc_auth), + ttl: {5, :minutes}, + allowed_drift: 60 * 1000 + ) + token + end +end + +defmodule MyAppWeb.PermsTokenSecretFetcher do + def fetch_signing_secret(mod, _opts) do + {:ok, Application.get_env(:myapp, mod)[:perms_key] |> JOSE.JWK.from_pem_file()} + end + + def fetch_verifying_secret(mod, _token_headers, _opts) do + {:ok, Application.get_env(:myapp, mod)[:perms_key] |> JOSE.JWK.from_pem_file()} + end +end +``` + +Start the backend with the environment variable pointing to the private key in PEM format: + + AUTH_KEY_PRIVATE=/path/to/key.pem mix phx.server + +in `config/config.exs` (for development) + + config :myapp, MyAppWeb.PermsToken, perms_key: System.get_env("AUTH_KEY_PRIVATE") + +in `config/releases.exs` or `config/runtime.exs` (for production): + + config :meetingrooms, MyAppWeb.PermsToken, perms_key: System.get_env("AUTH_KEY_PRIVATE") || + raise """ + environment variable AUTH_KEY_PRIVATE is missing. + """ + +Do your security logic and generate a token like this for example: + + MyAppWeb.PermsToken.token_for_perms( + %{user_id: user.id, kick_users: user.id == room.user_id, join_hub: true, room_ids: [room.slug]}) + +See what the different claims mean in the [janus-plugin-sfu api documentation](https://github.com/mozilla/janus-plugin-sfu/pull/86/files). + +Send the generated token to the frontend via graphql, a REST api or websocket. The `callSomeAPItoGetTheToken` function below is for example a fetch request to get the token for a room if the user is allowed to access the room. + +# frontend side + +On the frontend side, get the JWT for the room and set it with `setJoinToken`. +In the `adapter-ready` listener: + + const permsToken = await callSomeAPItoGetTheToken(); + adapter.setJoinToken(permsToken); + +The room should now be secure. + +But wait, this is not enough. +The JWT is valid here 5 minutes. If participant A joins the room and participant B joins the room 5 minutes later, B will hear A, but A won't hear B because A couldn't subscribe to B's audio because of an expired token (`addOccupant/createSubscriber` called after receiving a `join` event in naf-janus-adapter). You will get in janus logs +``` +[WARN] Rejecting join from 0x7fb91400fc50 to room my-room as user 3196610745608672. Error: ExpiredSignature +``` + +You need to renew the JWT regularly, like every 4 minutes, you can hard code the 4 minutes in the code below (`const nextRefresh = 4 * 60 * 1000;`) or check the `exp` in the JWT with [jwt-decode](https://www.npmjs.com/package/jwt-decode) and subtract a margin like 1 min (in case you change the ttl of the JWT in your backend later) . Here is an example: +``` +import jwt_decode from "jwt-decode"; + +const refreshPermsToken = async () => { + const permsToken = await callSomeAPItoGetTheToken(); + NAF.connection.adapter.setJoinToken(permsToken); + delayedRefreshPermsToken(permsToken); +} + +const delayedRefreshPermsToken = (permsToken) => { + // This doesn't validate the token + const decoded = jwt_decode(permsToken); + // Refresh the token 1 minute before it expires. + const nextRefresh = new Date(decoded.exp * 1000 - 60 * 1000) - new Date(); + setTimeout(async () => { + if (!NAF.connection.adapter) { + // we disconnected + return; + } + await refreshPermsToken(); + }, nextRefresh); +} +document.addEventListener('DOMContentLoaded', () => { + const scene = document.querySelector('a-scene'); + scene.addEventListener('adapter-ready', async ({ detail: adapter }) => { + const clientId = getYourClientId(); + adapter.setClientId(clientId); + const permsToken = await callSomeAPItoGetTheToken(); + adapter.setJoinToken(permsToken); + delayedRefreshPermsToken(permsToken); + // do your thing with getUserMedia and adapter.setLocalMediaStream + }); +}); +``` + +Now you have a full working solution. \ No newline at end of file From 27d2604b859ef8d7b806cc3ab7594dd2261c4195 Mon Sep 17 00:00:00 2001 From: Vincent Fretin Date: Sun, 11 Jul 2021 09:47:32 +0200 Subject: [PATCH 2/2] add link to doc 'Securing janus with JWT' --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4186f32..6d110f7 100644 --- a/README.md +++ b/README.md @@ -90,4 +90,5 @@ scene.addEventListener('adapter-ready', ({ detail: adapter }) => { ## Janus SFU deployment -[how to deploy janus with the janus sfu plugin on Ubuntu 20.04](docs/janus-deployment.md) +- [How to deploy janus with the janus sfu plugin on Ubuntu 20.04](docs/janus-deployment.md) +- [Securing janus with JWT](docs/securing-janus-with-jwt.md)