Web Client #11

Merged
Simponic merged 19 commits from web into main 2023-01-19 16:04:10 -05:00
14 changed files with 338 additions and 100 deletions
Showing only changes of commit 7b043678c1 - Show all commits

View File

@ -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

View File

@ -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 =

View File

@ -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 (
<AuthContext.Provider
value={{
signedIn,
setSignedIn,
sessionOver,
setSessionOver,
userId,
setUserId,
username,
setUsername,
}}
>
{children}
</AuthContext.Provider>
);
};

View File

@ -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: <Home />,
},
{
path: "auth-successful",
element: <AuthSuccessful />,
},
{
path: "keys",
element: <Home />,
@ -32,4 +38,8 @@ const router = createBrowserRouter([
]);
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<RouterProvider router={router} />);
root.render(
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
);

View File

@ -2,27 +2,57 @@ import { Link, Outlet } from "react-router-dom";
import logo from "./assets/chessh_sm.svg";
export const Root = () => (
<>
<div className="container">
<div className="navbar">
<div>
<Link to="/home">
<img src={logo} className="logo" />
</Link>
import { useAuthContext, DEFAULT_EXPIRY_TIME_MS } from "./context/auth_context";
export const Root = () => {
const { signedIn, setSignedIn, setSessionOver } = useAuthContext();
return (
<>
<div className="container">
<div className="navbar">
<div>
<Link to="/home">
<img src={logo} className="logo" alt="CheSSH Logo" />
</Link>
</div>
<div className="nav">
{signedIn ? (
<>
<Link className="link" to="/user">
User
</Link>
<Link className="link" to="/keys">
Keys
</Link>
<Link
className="link"
onClick={() => setSignedIn(false)}
to="/"
>
Sign Out
</Link>
</>
) : (
<>
<a
onClick={() =>
setSessionOver(
new Date(Date.now() + DEFAULT_EXPIRY_TIME_MS)
)
}
href={process.env.REACT_APP_GITHUB_OAUTH}
className="link"
>
Login w/ GitHub
</a>
</>
)}
</div>
</div>
<div className="nav">
<Link className="link" to="/user">
User
</Link>
<Link className="link" to="/keys">
Keys
</Link>
<div className="content">
<Outlet />
</div>
</div>
<div className="content">
<Outlet />
</div>
</div>
</>
);
</>
);
};

View File

@ -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 (
<>
<h1>Successful Auth</h1>
{signedIn ? <p>Hello there, {username || ""}</p> : <p>Loading...</p>}
</>
);
};

View File

@ -26,14 +26,14 @@ export const Demo = () => {
);
setRenderedPlayer(true);
}
}, [player]);
}, [renderedPlayer, player]);
return (
<div class="demo-container">
<div className="demo-container">
<h1>
Welcome to <span style={{ color: "green" }}>> CheSSH!</span>
</h1>
<div class="flex-row-around">
<div className="flex-row-around">
<p>
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 🌟
</a>
@ -49,10 +50,10 @@ export const Demo = () => {
<hr />
<div ref={player} id={demoCastElementId} />
<hr />
<div class="flex-row-around">
<div className="flex-row-around">
<h3>Would you like to play a game?</h3>
<Link className="button" to="/home">
Yes, Joshua
Yes, Joshua
</Link>
</div>
</div>

View File

@ -1,7 +1,11 @@
import { useAuthContext } from "../context/auth_context";
export const Home = () => {
const { username } = useAuthContext();
return (
<div>
<h1>Welcome home!</h1>
<h1>Welcome home, {username || "guest"}!</h1>
</div>
);
};

View File

@ -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", "");
},
})
);
}

View File

@ -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])

View File

@ -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,

View File

@ -1,3 +1,5 @@
defmodule Chessh.Web.Token do
use Joken.Config
def token_config, do: default_claims(default_exp: 12 * 60 * 60)
end

View File

@ -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

6
package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "chessh",
"lockfileVersion": 2,
"requires": true,
"packages": {}
}