Web Client #11
@ -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
|
@ -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 =
|
||||
|
104
front/src/context/auth_context.js
Normal file
104
front/src/context/auth_context.js
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
|
@ -2,22 +2,51 @@ 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 (
|
||||
<>
|
||||
<div className="container">
|
||||
<div className="navbar">
|
||||
<div>
|
||||
<Link to="/home">
|
||||
<img src={logo} className="logo" />
|
||||
<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="content">
|
||||
@ -26,3 +55,4 @@ export const Root = () => (
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
38
front/src/routes/auth_successful.jsx
Normal file
38
front/src/routes/auth_successful.jsx
Normal 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>}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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') {
|
||||
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", "");
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
@ -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])
|
||||
|
@ -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,
|
||||
|
@ -1,3 +1,5 @@
|
||||
defmodule Chessh.Web.Token do
|
||||
use Joken.Config
|
||||
|
||||
def token_config, do: default_claims(default_exp: 12 * 60 * 60)
|
||||
end
|
||||
|
@ -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))
|
||||
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
6
package-lock.json
generated
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "chessh",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
Loading…
Reference in New Issue
Block a user