diff --git a/front/src/context/auth_context.js b/front/src/context/auth_context.js index bdf789c..19747c6 100644 --- a/front/src/context/auth_context.js +++ b/front/src/context/auth_context.js @@ -1,25 +1,57 @@ -import React, { useContext, useState, createContext } from "react"; - -export const DEFAULT_EXPIRY_TIME_MS = 12 * 60 * 60 * 1000; +import { useEffect, useContext, createContext } from "react"; +import { useLocalStorage } from "../hooks/useLocalStorage"; const AuthContext = createContext({ signedIn: false, setSignedIn: () => null, sessionOver: new Date(), setSessionOver: () => null, - userId: null, - setUserId: () => null, - username: "", - setUsername: () => null, + setPlayer: () => null, + player: null, + signOut: () => 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); + const [signedIn, setSignedIn] = useLocalStorage("signedIn", false); + const [sessionOver, setSessionOver] = useLocalStorage( + "sessionOver", + Date.now() + ); + const [player, setPlayer] = useLocalStorage("player", null); + + const setDefaults = () => { + setPlayer(null); + setSessionOver(Date.now()); + setSignedIn(false); + }; + + const signOut = () => + fetch("/api/player/logout", { + method: "GET", + credentials: "same-origin", + }).then(() => setDefaults()); + + useEffect(() => { + setTimeout(() => { + setSessionOver((sessionOver) => { + if (Date.now() >= sessionOver) { + setSignedIn((signedIn) => { + if (signedIn) + alert( + "Session expired. Any further privileged requests will fail until signed in again." + ); + + return false; + }); + setPlayer(null); + } + return sessionOver; + }); + }, sessionOver - Date.now()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionOver]); return ( { setSignedIn, sessionOver, setSessionOver, - userId, - setUserId, - username, - setUsername, + signOut, + setPlayer, + player, }} > {children} diff --git a/front/src/hooks/useLocalStorage.js b/front/src/hooks/useLocalStorage.js new file mode 100644 index 0000000..4e8684a --- /dev/null +++ b/front/src/hooks/useLocalStorage.js @@ -0,0 +1,28 @@ +import { useState, useEffect } from "react"; + +const STORAGE_KEYS_PREFIX = "chessh-"; + +const useStorage = (storage, keyPrefix) => (storageKey, fallbackState) => { + if (!storageKey) + throw new Error( + `"storageKey" must be a nonempty string, but "${storageKey}" was passed.` + ); + + const storedString = storage.getItem(keyPrefix + storageKey); + let parsedObject = null; + + if (storedString !== null) parsedObject = JSON.parse(storedString); + + const [value, setValue] = useState(parsedObject ?? fallbackState); + + useEffect(() => { + storage.setItem(keyPrefix + storageKey, JSON.stringify(value)); + }, [value, storageKey]); + + return [value, setValue]; +}; + +export const useLocalStorage = useStorage( + window.localStorage, + STORAGE_KEYS_PREFIX +); diff --git a/front/src/root.jsx b/front/src/root.jsx index c21e5bb..b5f4648 100644 --- a/front/src/root.jsx +++ b/front/src/root.jsx @@ -5,18 +5,8 @@ import logo from "./assets/chessh_sm.svg"; import { useAuthContext } from "./context/auth_context"; export const Root = () => { - const { signedIn, setUserId, setSignedIn, setSessionOver } = useAuthContext(); - - const signOut = () => { - fetch("/api/player/logout", { - method: "GET", - credentials: "same-origin", - }).then(() => { - setSignedIn(false); - setUserId(null); - setSessionOver(new Date()); - }); - }; + const { signedIn, setUserId, setSignedIn, setSessionOver, signOut } = + useAuthContext(); return ( <> diff --git a/front/src/routes/auth_successful.jsx b/front/src/routes/auth_successful.jsx index 1d5a9c2..cb51573 100644 --- a/front/src/routes/auth_successful.jsx +++ b/front/src/routes/auth_successful.jsx @@ -1,33 +1,23 @@ -import { useEffect, useCallback } from "react"; +import { useEffect } from "react"; import { Link } from "react-router-dom"; -import { - useAuthContext, - DEFAULT_EXPIRY_TIME_MS, -} from "../context/auth_context"; +import { useAuthContext } from "../context/auth_context"; export const AuthSuccessful = () => { - const { - username, - userId, - sessionOver, - signedIn, - setSignedIn, - setUserId, - setUsername, - setSessionOver, - } = useAuthContext(); + const { player, setPlayer, signedIn, setSignedIn, setSessionOver } = + useAuthContext(); useEffect(() => { - fetch("/api/player/me", { + fetch("/api/player/token/me", { credentials: "same-origin", }) .then((r) => r.json()) - .then((player) => { + .then(({ player, expiration }) => { setSignedIn(!!player); - setUserId(player.id); - setUsername(player.username); + setPlayer(player); + setSessionOver(expiration); }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); if (signedIn) { @@ -35,7 +25,7 @@ export const AuthSuccessful = () => { <>

Authentication Successful

- Hello there, {username || ""}! + Hello there, {player?.username || ""}! Go Home{" "} diff --git a/front/src/routes/home.jsx b/front/src/routes/home.jsx index c8e804f..935163d 100644 --- a/front/src/routes/home.jsx +++ b/front/src/routes/home.jsx @@ -4,7 +4,10 @@ import { Link } from "react-router-dom"; import { useAuthContext } from "../context/auth_context"; export const Home = () => { - const { username, signedIn } = useAuthContext(); + const { + player: { username }, + signedIn, + } = useAuthContext(); if (signedIn) { const sshConfig = `Host chessh diff --git a/front/src/routes/keys.jsx b/front/src/routes/keys.jsx index 4f6c505..3c552a1 100644 --- a/front/src/routes/keys.jsx +++ b/front/src/routes/keys.jsx @@ -67,7 +67,7 @@ const AddKeyButton = ({ onSave }) => { "Content-Type": "application/json", }, body: JSON.stringify({ - key: key.value, + key: key.value.trim(), name: name.value, }), }) @@ -77,7 +77,6 @@ const AddKeyButton = ({ onSave }) => { if (onSave) { onSave(); } - setDefaults(); close(); } else if (d.errors) { if (typeof d.errors === "object") { @@ -158,7 +157,9 @@ const AddKeyButton = ({ onSave }) => { }; export const Keys = () => { - const { userId } = useAuthContext(); + const { + player: { id: userId }, + } = useAuthContext(); const [keys, setKeys] = useState(null); const refreshKeys = useCallback( @@ -175,9 +176,8 @@ export const Keys = () => { } }, [userId, refreshKeys]); - if (!keys) { - return

Loading...

; - } + if (!keys) return

Loading...

; + if (Array.isArray(keys)) { return ( <> diff --git a/lib/chessh/ssh/client/game/game.ex b/lib/chessh/ssh/client/game/game.ex index 2ee6dca..65b9d10 100644 --- a/lib/chessh/ssh/client/game/game.ex +++ b/lib/chessh/ssh/client/game/game.ex @@ -35,16 +35,23 @@ defmodule Chessh.SSH.Client.Game do def init([ %State{ color: color, - game: %Game{dark_player_id: dark_player_id, light_player_id: light_player_id} + game: %Game{dark_player_id: dark_player_id, light_player_id: light_player_id}, + player_session: %{player_id: player_id} } = state | tail ]) when is_nil(color) do + {is_dark, is_light} = {player_id == dark_player_id, player_id == light_player_id} + new_state = - case {is_nil(dark_player_id), is_nil(light_player_id)} do - {true, false} -> %State{state | color: :dark} - {false, true} -> %State{state | color: :light} - {_, _} -> %State{state | color: Enum.random([:light, :dark])} + if is_dark || is_light do + %State{state | color: if(is_light, do: :light, else: :dark)} + else + case {is_nil(dark_player_id), is_nil(light_player_id)} do + {true, false} -> %State{state | color: :dark} + {false, true} -> %State{state | color: :light} + {_, _} -> %State{state | color: :light} + end end init([new_state | tail]) @@ -89,18 +96,22 @@ defmodule Chessh.SSH.Client.Game do end binbo_pid = initialize_game(game_id, fen) - send(client_pid, {:send_to_ssh, Utils.clear_codes()}) new_game = Repo.get(Game, game_id) |> Repo.preload([:light_player, :dark_player]) - new_state = %State{ - state - | binbo_pid: binbo_pid, - color: if(new_game.light_player_id == player_session.player_id, do: :light, else: :dark), - game: new_game - } + player_color = + if(new_game.light_player_id == player_session.player_id, do: :light, else: :dark) - {:ok, new_state} + send(client_pid, {:send_to_ssh, Utils.clear_codes()}) + + {:ok, + %State{ + state + | binbo_pid: binbo_pid, + color: player_color, + game: new_game, + flipped: player_color == :dark + }} end def init([ diff --git a/lib/chessh/web/web.ex b/lib/chessh/web/web.ex index 1f37711..1fa5fc3 100644 --- a/lib/chessh/web/web.ex +++ b/lib/chessh/web/web.ex @@ -39,21 +39,8 @@ defmodule Chessh.Web.Endpoint do {status, body} = create_player_from_github_response(resp, github_user_api_url, github_user_agent) - case body do - %{jwt: token} -> - client_redirect_location = - Application.get_env(:chessh, Web)[:client_redirect_after_successful_sign_in] - - conn - |> put_resp_cookie("jwt", token) - |> put_resp_header("location", client_redirect_location) - |> send_resp(301, '') - - _ -> - conn - |> put_resp_content_type("application/json") - |> send_resp(status, Jason.encode!(body)) - end + conn + |> assign_jwt_and_redirect_or_encode(status, body) end put "/player/password" do @@ -115,8 +102,7 @@ defmodule Chessh.Web.Endpoint do end conn - |> put_resp_content_type("application/json") - |> send_resp(status, Jason.encode!(body)) + |> assign_jwt_and_redirect_or_encode(status, body) end get "/player/logout" do @@ -171,12 +157,15 @@ defmodule Chessh.Web.Endpoint do |> send_resp(status, Jason.encode!(body)) end - get "/player/me" do - player = get_player_from_jwt(conn) + get "/player/token/me" do + {:ok, jwt} = Token.verify_and_validate(get_jwt(conn)) + + %{"uid" => player_id, "exp" => expiration} = jwt + player = Repo.get(Player, player_id) conn |> put_resp_content_type("application/json") - |> send_resp(200, Jason.encode!(player)) + |> send_resp(200, Jason.encode!(%{player: player, expiration: expiration * 1000})) end get "/player/:id/keys" do @@ -242,19 +231,39 @@ defmodule Chessh.Web.Endpoint do ) end - defp get_player_from_jwt(conn) do + defp get_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") + if auth_header, do: auth_header, else: Map.get(fetch_cookies(conn).cookies, "jwt") + end - {:ok, %{"uid" => uid}} = Token.verify_and_validate(jwt) + defp get_player_from_jwt(conn) do + {:ok, %{"uid" => uid}} = Token.verify_and_validate(get_jwt(conn)) Repo.get(Player, uid) end + defp assign_jwt_and_redirect_or_encode(conn, status, body) do + case body do + %{jwt: token} -> + client_redirect_location = + Application.get_env(:chessh, Web)[:client_redirect_after_successful_sign_in] + + conn + |> put_resp_cookie("jwt", token) + |> put_resp_header("location", client_redirect_location) + |> send_resp(301, '') + + _ -> + conn + |> put_resp_content_type("application/json") + |> send_resp(status, Jason.encode!(body)) + end + end + defp create_player_from_github_response(resp, github_user_api_url, github_user_agent) do case resp do %{"access_token" => access_token} ->