diff --git a/.env.example b/.env.example index eec80f9..0eb803c 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,10 @@ NODE_ID=aUniqueString +REACT_APP_GITHUB_OAUTH=https://github.com/login/oauth/authorize?client_id=CLIENT_ID_HERE&redirect_uri=http://localhost:8080/oauth/redirect +CLIENT_REDIRECT_AFTER_OAUTH=http://localhost:3000/auth-successful + GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= GITHUB_USER_AGENT= + +JWT_SECRET=aVerySecretJwtSigningSecret \ No newline at end of file diff --git a/config/runtime.exs b/config/runtime.exs index ec1e7dc..88a1696 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -3,7 +3,12 @@ import Config config :chessh, Web, github_client_id: System.get_env("GITHUB_CLIENT_ID"), github_client_secret: System.get_env("GITHUB_CLIENT_SECRET"), - github_user_agent: System.get_env("GITHUB_USER_AGENT") + github_user_agent: System.get_env("GITHUB_USER_AGENT"), + client_redirect_after_successful_sign_in: + System.get_env("CLIENT_REDIRECT_AFTER_OAUTH", "http://localhost:3000") + +config :joken, + default_signer: System.get_env("JWT_SECRET") if config_env() == :prod do database_url = diff --git a/front/src/context/auth_context.js b/front/src/context/auth_context.js new file mode 100644 index 0000000..bce9e0f --- /dev/null +++ b/front/src/context/auth_context.js @@ -0,0 +1,104 @@ +import React, { useContext, useState, createContext, useEffect } from "react"; + +export const DEFAULT_EXPIRY_TIME_MS = 12 * 60 * 60 * 1000; + +const AuthContext = createContext({ + signedIn: false, + setSignedIn: () => null, + sessionOver: new Date(), + setSessionOver: () => null, + userId: null, + setUserId: () => null, + username: "", + setUsername: () => null, +}); + +export const useAuthContext = () => useContext(AuthContext); + +export const AuthProvider = ({ children }) => { + const [signedIn, setSignedIn] = useState(false); + const [sessionOver, setSessionOver] = useState(new Date()); + const [userId, setUserId] = useState(null); + const [username, setUsername] = useState(null); + + useEffect(() => { + if (!signedIn) { + setUsername(null); + setUserId(null); + } + }, [signedIn]); + + useEffect(() => { + if (userId) { + localStorage.setItem("userId", userId.toString()); + } + }, [userId]); + + useEffect(() => { + if (username) { + localStorage.setItem("username", username); + } + }, [username]); + + useEffect(() => { + let expiry = localStorage.getItem("expiry"); + if (expiry) { + expiry = new Date(expiry); + if (Date.now() < expiry.getTime()) { + setSignedIn(true); + setSessionOver(expiry); + // We don't have access to the JWT token as it is an HTTP only cookie - + // so we store user info in local storage + ((username) => { + if (username) { + setUsername(username); + } + })(localStorage.getItem("username")); + + ((id) => { + if (id) { + setUserId(parseInt(id, 10)); + } + })(localStorage.getItem("userId")); + } + } + }, []); + + useEffect(() => { + localStorage.setItem("expiry", sessionOver.toISOString()); + setTimeout(() => { + setSessionOver((sessionOver) => { + if (Date.now() >= sessionOver.getTime()) { + setSignedIn((signedIn) => { + if (signedIn) { + alert( + "Session expired. Any further privileged requests will fail until signed in again." + ); + ["userId", "userName"].map((x) => localStorage.removeItem(x)); + return false; + } + return signedIn; + }); + } + return sessionOver; + }); + }, sessionOver.getTime() - Date.now()); + }, [sessionOver]); + + return ( + + {children} + + ); +}; diff --git a/front/src/index.js b/front/src/index.js index 788830c..9a651c8 100644 --- a/front/src/index.js +++ b/front/src/index.js @@ -2,9 +2,11 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { createBrowserRouter, RouterProvider } from "react-router-dom"; +import { AuthProvider } from "./context/auth_context"; import { Root } from "./root"; import { Demo } from "./routes/demo"; import { Home } from "./routes/home"; +import { AuthSuccessful } from "./routes/auth_successful"; import "./index.css"; @@ -23,6 +25,10 @@ const router = createBrowserRouter([ path: "user", element: , }, + { + path: "auth-successful", + element: , + }, { path: "keys", element: , @@ -32,4 +38,8 @@ const router = createBrowserRouter([ ]); const root = ReactDOM.createRoot(document.getElementById("root")); -root.render(); +root.render( + + + +); diff --git a/front/src/root.jsx b/front/src/root.jsx index 52505a5..be08b13 100644 --- a/front/src/root.jsx +++ b/front/src/root.jsx @@ -2,27 +2,57 @@ import { Link, Outlet } from "react-router-dom"; import logo from "./assets/chessh_sm.svg"; -export const Root = () => ( - <> -
-
-
- - - +import { useAuthContext, DEFAULT_EXPIRY_TIME_MS } from "./context/auth_context"; + +export const Root = () => { + const { signedIn, setSignedIn, setSessionOver } = useAuthContext(); + return ( + <> +
+
+
+ + CheSSH Logo + +
+
+ {signedIn ? ( + <> + + User + + + Keys + + setSignedIn(false)} + to="/" + > + Sign Out + + + ) : ( + <> + + setSessionOver( + new Date(Date.now() + DEFAULT_EXPIRY_TIME_MS) + ) + } + href={process.env.REACT_APP_GITHUB_OAUTH} + className="link" + > + Login w/ GitHub + + + )} +
-
- - User - - - Keys - +
+
-
- -
-
- -); + + ); +}; diff --git a/front/src/routes/auth_successful.jsx b/front/src/routes/auth_successful.jsx new file mode 100644 index 0000000..78f9d56 --- /dev/null +++ b/front/src/routes/auth_successful.jsx @@ -0,0 +1,38 @@ +import { useEffect, useCallback } from "react"; +import { useAuthContext } from "../context/auth_context"; + +export const AuthSuccessful = () => { + const { + username, + signedIn, + setSignedIn, + setSessionOver, + setUserId, + setUsername, + } = useAuthContext(); + + const fetchMyself = useCallback( + () => + fetch("/api/player/me", { + credentials: "same-origin", + }) + .then((r) => r.json()) + .then((player) => { + setSignedIn(!!player); + setUserId(player.id); + setUsername(player.username); + }), + [setSessionOver, setSignedIn, setUserId, setUsername] + ); + + useEffect(() => { + fetchMyself(); + }, [fetchMyself]); + + return ( + <> +

Successful Auth

+ {signedIn ?

Hello there, {username || ""}

:

Loading...

} + + ); +}; diff --git a/front/src/routes/demo.jsx b/front/src/routes/demo.jsx index ca07758..951ed91 100644 --- a/front/src/routes/demo.jsx +++ b/front/src/routes/demo.jsx @@ -26,14 +26,14 @@ export const Demo = () => { ); setRenderedPlayer(true); } - }, [player]); + }, [renderedPlayer, player]); return ( -
+

Welcome to > CheSSH!

-
+

CheSSH is a multiplayer, scalable, free, open source, and potentially passwordless game of Chess over the SSH protocol. @@ -42,6 +42,7 @@ export const Demo = () => { className="button gold" href="https://github.com/Simponic/chessh" target="_blank" + rel="noreferrer" > 🌟 Star 🌟 @@ -49,10 +50,10 @@ export const Demo = () => {



-
+

Would you like to play a game?

- Yes, Joshua ↝ + Yes, Joshua ⇒
diff --git a/front/src/routes/home.jsx b/front/src/routes/home.jsx index 542471e..d5964e0 100644 --- a/front/src/routes/home.jsx +++ b/front/src/routes/home.jsx @@ -1,7 +1,11 @@ +import { useAuthContext } from "../context/auth_context"; + export const Home = () => { + const { username } = useAuthContext(); + return (
-

Welcome home!

+

Welcome home, {username || "guest"}!

); }; diff --git a/front/src/setupProxy.js b/front/src/setupProxy.js index 363f7d5..d60785a 100644 --- a/front/src/setupProxy.js +++ b/front/src/setupProxy.js @@ -1,12 +1,15 @@ -const { createProxyMiddleware } = require('http-proxy-middleware'); +const { createProxyMiddleware } = require("http-proxy-middleware"); -module.exports = function(app) { - if (process.env.NODE_ENV != 'production') { +module.exports = function (app) { + if (process.env.NODE_ENV != "production") { app.use( - '/api', + "/api", createProxyMiddleware({ - target: 'http://localhost:8080', + target: "http://localhost:8080", changeOrigin: true, + pathRewrite: (path, _req) => { + return path.replace("/api", ""); + }, }) ); } diff --git a/lib/chessh/schema/player.ex b/lib/chessh/schema/player.ex index d9b2a9e..f12ad9e 100644 --- a/lib/chessh/schema/player.ex +++ b/lib/chessh/schema/player.ex @@ -21,6 +21,15 @@ defmodule Chessh.Player do timestamps() end + defimpl Jason.Encoder, for: Chessh.Player do + def encode(value, opts) do + Jason.Encode.map( + Map.take(value, [:id, :github_id, :username, :created_at, :updated_at]), + opts + ) + end + end + def authentications_changeset(player, attrs) do player |> cast(attrs, [:authentications]) diff --git a/lib/chessh/ssh/daemon.ex b/lib/chessh/ssh/daemon.ex index e122f9a..6be6732 100644 --- a/lib/chessh/ssh/daemon.ex +++ b/lib/chessh/ssh/daemon.ex @@ -47,12 +47,12 @@ defmodule Chessh.SSH.Daemon do :disconnect end - x -> + authed_or_disconnect -> PlayerSession.update_sessions_and_player_satisfies(username, fn _player -> - x + authed_or_disconnect end) - x + authed_or_disconnect end end @@ -92,7 +92,7 @@ defmodule Chessh.SSH.Daemon do def handle_info(_, state), do: {:noreply, state} defp on_disconnect(_reason) do - Logger.debug("#{inspect(self())} disconnected") + Logger.info("#{inspect(self())} disconnected") Repo.delete_all( from(p in PlayerSession, diff --git a/lib/chessh/web/token.ex b/lib/chessh/web/token.ex index 8ddafe2..c0ac740 100644 --- a/lib/chessh/web/token.ex +++ b/lib/chessh/web/token.ex @@ -1,3 +1,5 @@ defmodule Chessh.Web.Token do use Joken.Config + + def token_config, do: default_claims(default_exp: 12 * 60 * 60) end diff --git a/lib/chessh/web/web.ex b/lib/chessh/web/web.ex index 2377629..12f4c67 100644 --- a/lib/chessh/web/web.ex +++ b/lib/chessh/web/web.ex @@ -36,59 +36,27 @@ defmodule Chessh.Web.Endpoint do end {status, body} = - case resp do - %{"access_token" => access_token} -> - case :httpc.request( - :get, - {String.to_charlist(github_user_api_url), - [ - {'Authorization', String.to_charlist("Bearer #{access_token}")}, - {'User-Agent', github_user_agent} - ]}, - [], - [] - ) do - {:ok, {{_, 200, 'OK'}, _, user_details}} -> - %{"login" => username, "id" => github_id} = - Jason.decode!(String.Chars.to_string(user_details)) + create_player_from_github_response(resp, github_user_api_url, github_user_agent) - %Player{id: id} = - Repo.insert!(%Player{github_id: github_id, username: username}, - on_conflict: [set: [github_id: github_id]], - conflict_target: :github_id - ) + case body do + %{jwt: token} -> + client_redirect_location = + Application.get_env(:chessh, Web)[:client_redirect_after_successful_sign_in] - {200, - %{ - success: true, - jwt: - Token.generate_and_sign!(%{ - "uid" => id - }) - }} + conn + |> put_resp_cookie("jwt", token) + |> put_resp_header("location", client_redirect_location) + |> send_resp(301, '') - _ -> - {400, %{errors: "Access token was incorrect. Try again."}} - end - - _ -> - {400, %{errors: "Failed to retrieve token from GitHub. Try again."}} - end - - conn - |> put_resp_content_type("application/json") - |> send_resp(status, Jason.encode!(body)) + _ -> + conn + |> put_resp_content_type("application/json") + |> send_resp(status, Jason.encode!(body)) + end end put "/player/password" do - jwt = - Enum.find_value(conn.req_headers, fn {header, value} -> - if header === "authorization", do: value - end) - - {:ok, %{"uid" => uid}} = Token.verify_and_validate(jwt) - - player = Repo.get(Player, uid) + player = get_player_from_jwt(conn) {status, body} = case conn.body_params do @@ -151,17 +119,13 @@ defmodule Chessh.Web.Endpoint do end post "/player/keys" do - jwt = - Enum.find_value(conn.req_headers, fn {header, value} -> - if header === "authorization", do: value - end) - - {:ok, %{"uid" => uid}} = Token.verify_and_validate(jwt) + player = get_player_from_jwt(conn) {status, body} = case conn.body_params do %{"key" => key, "name" => name} -> - case Key.changeset(%Key{}, %{player_id: uid, key: key, name: name}) |> Repo.insert() do + case Key.changeset(%Key{}, %{player_id: player.id, key: key, name: name}) + |> Repo.insert() do {:ok, _new_key} -> { 200, @@ -191,6 +155,14 @@ defmodule Chessh.Web.Endpoint do |> send_resp(status, Jason.encode!(body)) end + get "/player/me" do + player = get_player_from_jwt(conn) + + conn + |> put_resp_content_type("application/json") + |> send_resp(200, Jason.encode!(player)) + end + get "/player/:id/keys" do %{"id" => player_id} = conn.path_params @@ -198,22 +170,17 @@ defmodule Chessh.Web.Endpoint do conn |> put_resp_content_type("application/json") - |> send_resp(200, Jason.encode!(%{keys: keys})) + |> send_resp(200, Jason.encode!(keys)) end delete "/keys/:id" do - jwt = - Enum.find_value(conn.req_headers, fn {header, value} -> - if header === "authorization", do: value - end) - - {:ok, %{"uid" => uid}} = Token.verify_and_validate(jwt) + player = get_player_from_jwt(conn) %{"id" => key_id} = conn.path_params key = Repo.get(Key, key_id) {status, body} = - if key && uid == key.player_id do + if key && player.id == key.player_id do case Repo.delete(key) do {:ok, _} -> {200, %{success: true}} @@ -258,4 +225,58 @@ defmodule Chessh.Web.Endpoint do fn key -> Application.get_env(:chessh, Web)[key] end ) end + + defp get_player_from_jwt(conn) do + auth_header = + Enum.find_value(conn.req_headers, fn {header, value} -> + if header === "authorization", do: value + end) + + jwt = if auth_header, do: auth_header, else: Map.get(fetch_cookies(conn).cookies, "jwt") + + {:ok, %{"uid" => uid}} = Token.verify_and_validate(jwt) + + Repo.get(Player, uid) + end + + defp create_player_from_github_response(resp, github_user_api_url, github_user_agent) do + case resp do + %{"access_token" => access_token} -> + case :httpc.request( + :get, + {String.to_charlist(github_user_api_url), + [ + {'Authorization', String.to_charlist("Bearer #{access_token}")}, + {'User-Agent', github_user_agent} + ]}, + [], + [] + ) do + {:ok, {{_, 200, 'OK'}, _, user_details}} -> + %{"login" => username, "id" => github_id} = + Jason.decode!(String.Chars.to_string(user_details)) + + %Player{id: id} = + Repo.insert!(%Player{github_id: github_id, username: username}, + on_conflict: [set: [github_id: github_id]], + conflict_target: :github_id + ) + + {200, + %{ + success: true, + jwt: + Token.generate_and_sign!(%{ + "uid" => id + }) + }} + + _ -> + {400, %{errors: "Access token was incorrect. Try again."}} + end + + _ -> + {400, %{errors: "Failed to retrieve token from GitHub. Try again."}} + end + end end diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e333729 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "chessh", + "lockfileVersion": 2, + "requires": true, + "packages": {} +}