From 1b587f0b07f4413755d23520af13bdb30895de37 Mon Sep 17 00:00:00 2001 From: Sophie Date: Mon, 29 Apr 2024 17:31:10 -0700 Subject: [PATCH 01/17] feat: manage API tokens --- .env | 6 +- Cargo.lock | 6 +- Cargo.toml | 2 + app/package-lock.json | 59 ++++- app/package.json | 2 + .../tokens/components/CopyableToken.tsx | 43 ++++ .../features/tokens/components/TokenCard.tsx | 53 +++++ app/src/features/tokens/hooks/useApiTokens.ts | 77 +++++++ .../toolbar/components/UserButton.tsx | 13 +- .../features/toolbar/hooks/useGithubAuth.ts | 119 +++-------- app/src/index.tsx | 6 +- app/src/pages/AccountSettings.tsx | 14 -- app/src/pages/ApiTokens.tsx | 109 ++++++++++ app/src/utils/http.ts | 108 ++++++++++ app/src/utils/localStorage.ts | 5 +- .../down.sql | 1 + .../up.sql | 8 + src/api/api_token.rs | 61 ++++++ src/{api.rs => api/auth.rs} | 25 ++- src/api/mod.rs | 28 +++ src/cors.rs | 4 +- src/db/api_token.rs | 94 ++++++++ src/db/error.rs | 2 + src/db/mod.rs | 1 + src/db/user_session.rs | 23 +- src/github.rs | 2 +- src/lib.rs | 1 + src/main.rs | 201 ++++++++++++++++-- src/models.rs | 22 +- src/schema.rs | 18 +- src/util.rs | 5 + tests/db_integration.rs | 8 +- 32 files changed, 961 insertions(+), 165 deletions(-) create mode 100644 app/src/features/tokens/components/CopyableToken.tsx create mode 100644 app/src/features/tokens/components/TokenCard.tsx create mode 100644 app/src/features/tokens/hooks/useApiTokens.ts delete mode 100644 app/src/pages/AccountSettings.tsx create mode 100644 app/src/pages/ApiTokens.tsx create mode 100644 app/src/utils/http.ts create mode 100644 migrations/2024-04-29-210717_create_api_tokens/down.sql create mode 100644 migrations/2024-04-29-210717_create_api_tokens/up.sql create mode 100644 src/api/api_token.rs rename src/{api.rs => api/auth.rs} (83%) create mode 100644 src/api/mod.rs create mode 100644 src/db/api_token.rs create mode 100644 src/util.rs diff --git a/.env b/.env index ba74042..fd719f0 100644 --- a/.env +++ b/.env @@ -1,5 +1,9 @@ +# Server env POSTGRES_USER="postgres" POSTGRES_PASSWORD="localpw" POSTGRES_URI="localhost" POSTGRES_PORT="5432" -POSTGRES_DB_NAME="forc_pub" \ No newline at end of file +POSTGRES_DB_NAME="forc_pub" + +# Diesel CLI env +DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_URI}/${POSTGRES_DB_NAME}" \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 19dea3c..e261f22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -438,11 +438,13 @@ dependencies = [ "dotenvy", "hex", "nanoid", + "rand", "regex", "reqwest", "rocket", "serde", "serde_json", + "sha2", "thiserror", "tokio", "uuid", @@ -1697,9 +1699,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.6" +version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", "cpufeatures", diff --git a/Cargo.toml b/Cargo.toml index 72cd0b5..6927d76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,3 +21,5 @@ diesel = { version = "2.1.6", features = ["postgres", "uuid", "r2d2"] } dotenvy = "0.15" uuid = "1.8.0" diesel_migrations = "2.1.0" +rand = "0.8.5" +sha2 = "0.10.8" diff --git a/app/package-lock.json b/app/package-lock.json index 8f9d421..bb97485 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -20,10 +20,12 @@ "@types/node": "^16.18.91", "@types/react": "^18.2.67", "@types/react-dom": "^18.2.22", + "axios": "^1.6.8", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.22.3", "react-scripts": "5.0.1", + "typed-axios-instance": "^3.3.1", "typescript": "^4.9.5", "usehooks-ts": "^3.0.2", "web-vitals": "^2.1.4" @@ -5343,6 +5345,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-html-community": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", @@ -5678,6 +5691,29 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", + "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/axobject-query": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", @@ -15075,6 +15111,11 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -17390,11 +17431,12 @@ } }, "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.18.1.tgz", + "integrity": "sha512-qXhgeNsX15bM63h5aapNFcQid9jRF/l3ojDoDFmekDQEUufZ9U4ErVt6SjDxnHp48Ltrw616R8yNc3giJ3KvVQ==", + "peer": true, "engines": { - "node": ">=10" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -17481,6 +17523,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-axios-instance": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/typed-axios-instance/-/typed-axios-instance-3.3.1.tgz", + "integrity": "sha512-7psbeu3yncZZGJFduGXCuq0HIxLbUltPiOsInTa9Wo7k3K6kdZNd9Q/QKB61g6N4eVXiVT8Ey7WpS1aozXkniA==", + "peerDependencies": { + "axios": ">=1.0.0", + "type-fest": ">=3.0.0" + } + }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", diff --git a/app/package.json b/app/package.json index 569de55..fd85f52 100644 --- a/app/package.json +++ b/app/package.json @@ -15,10 +15,12 @@ "@types/node": "^16.18.91", "@types/react": "^18.2.67", "@types/react-dom": "^18.2.22", + "axios": "^1.6.8", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.22.3", "react-scripts": "5.0.1", + "typed-axios-instance": "^3.3.1", "typescript": "^4.9.5", "usehooks-ts": "^3.0.2", "web-vitals": "^2.1.4" diff --git a/app/src/features/tokens/components/CopyableToken.tsx b/app/src/features/tokens/components/CopyableToken.tsx new file mode 100644 index 0000000..5664f6d --- /dev/null +++ b/app/src/features/tokens/components/CopyableToken.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import IconButton from '@mui/material/IconButton'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; + +export interface CopyableProps { + token: string; +} + +async function handleCopy(value: string) { + await navigator.clipboard.writeText(value); +} + +function CopyableToken({ token }: CopyableProps) { + return ( +
+
+
{token}
+
+
+ handleCopy(token)} aria-label='copy'> + + +
+
+ ); +} + +export default CopyableToken; diff --git a/app/src/features/tokens/components/TokenCard.tsx b/app/src/features/tokens/components/TokenCard.tsx new file mode 100644 index 0000000..dc947ae --- /dev/null +++ b/app/src/features/tokens/components/TokenCard.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { Button, Card, CardHeader, IconButton } from '@mui/material'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import { Token } from '../hooks/useApiTokens'; +import CopyableToken from './CopyableToken'; + +export interface TokenCardProps { + token: Token; + handleRevoke: () => Promise; +} + +function TokenCard({ token, handleRevoke }: TokenCardProps) { + return ( +
+
+

{token.name}

+ + +
+
+ {`Created ${token.createdAt.toLocaleString()}`} +
+ {token.token && ( + <> +
+ { + 'Make sure to copy your API token now. You won’t be able to see it again!' + } +
+ + + + )} +
+ ); +} + +export default TokenCard; diff --git a/app/src/features/tokens/hooks/useApiTokens.ts b/app/src/features/tokens/hooks/useApiTokens.ts new file mode 100644 index 0000000..b323ae0 --- /dev/null +++ b/app/src/features/tokens/hooks/useApiTokens.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { SERVER_URI } from '../../../constants'; +import { useGithubAuth } from '../../toolbar/hooks/useGithubAuth'; +import axios from 'axios'; +import HTTP, { + CreateTokenResponse, + DeleteTokenResponse, + RawToken, + TokensResponse, +} from '../../../utils/http'; + +export interface Token { + id: string; + name: string; + token?: string; + createdAt: Date; +} + +function rawTokenToToken(rawToken: RawToken): Token { + return { + id: rawToken.id, + name: rawToken.name, + token: rawToken.token, + createdAt: new Date(rawToken.createdAt), + }; +} + +export function useApiTokens(): { + newToken: Token | null; + tokens: Token[]; + createToken: (name: string) => Promise; + revokeToken: (id: string) => Promise; +} { + const [githubUser] = useGithubAuth(); + const [tokens, setTokens] = useState([]); + const [newToken, setNewToken] = useState(null); + + const createToken = useCallback( + async (name: string) => { + const { data } = await HTTP.post(`/new_token`, { name }); + if (data.token) { + setNewToken(rawTokenToToken(data.token)); + } + return data; + }, + [setNewToken] + ); + + const revokeToken = useCallback( + async (id: string) => { + HTTP.delete(`/token/${id}`).then(() => { + setTokens([...tokens.filter((token) => token.id !== id)]); + if (newToken?.id === id) { + setNewToken(null); + } + }); + }, + [setTokens, tokens, newToken, setNewToken] + ); + + useEffect(() => { + if (!githubUser) { + return; + } + + HTTP.get(`/tokens`).then(({data}) => { + setTokens([ + ...data.tokens + .filter((token) => token.id !== newToken?.id) + .map(rawTokenToToken), + ]); + }); + }, [setTokens, githubUser, newToken]); + + return { newToken, tokens, createToken, revokeToken }; +} diff --git a/app/src/features/toolbar/components/UserButton.tsx b/app/src/features/toolbar/components/UserButton.tsx index 42b650f..0a03291 100644 --- a/app/src/features/toolbar/components/UserButton.tsx +++ b/app/src/features/toolbar/components/UserButton.tsx @@ -6,7 +6,6 @@ import Menu from '@mui/material/Menu/Menu'; import MenuItem from '@mui/material/MenuItem/MenuItem'; import { useNavigate } from 'react-router-dom'; import { useGithubAuth } from '../hooks/useGithubAuth'; -import { useLocalSession } from '../../../utils/localStorage'; import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import { REDIRECT_URI } from '../../../constants'; @@ -18,7 +17,6 @@ const StyledWrapper = styled.div` `; function UserButton() { - const { clearSessionId, sessionId } = useLocalSession(); const navigate = useNavigate(); const [user, logout] = useGithubAuth(); const [anchorEl, setAnchorEl] = React.useState(null); @@ -43,12 +41,11 @@ function UserButton() { ); const handleLogout = useCallback(() => { - clearSessionId(); logout(); handleNavigate('/'); - }, [handleNavigate, logout, clearSessionId]); + }, [handleNavigate, logout]); - if (user && sessionId) { + if (!!user) { return ( + + + ); + } + + return ( +
+

{'API Tokens'}

+
+

API Tokens

+ + +
+ +
+ {newToken && ( + { + await revokeToken(newToken.id); + }} + /> + )} + {tokens.map((token) => ( + revokeToken(token.id)} + /> + ))} + {!tokens.length && !newToken && ( +
+ {`You haven't generated any API tokens yet.`} +
+ )} +
+
+ ); +} + +export default ApiTokens; diff --git a/app/src/utils/http.ts b/app/src/utils/http.ts new file mode 100644 index 0000000..6f23244 --- /dev/null +++ b/app/src/utils/http.ts @@ -0,0 +1,108 @@ +import type { TypedAxios } from 'typed-axios-instance'; +import axios from 'axios'; +import { SERVER_URI } from '../constants'; + +export interface AuthenticatedUser { + fullName: string; + email?: string; + githubUrl: string; + githubLogin: string; + isAdmin: boolean; + avatarUrl?: string; +} + +export interface LoginRequest { + code: string; + } + +export interface LoginResponse { + sessionId?: string; + user?: AuthenticatedUser; + error?: string; +} + +export interface UserResponse { + user?: AuthenticatedUser; + error?: string; +} + +export interface GenericResponse { + error?: string; + } + + export interface RawToken { + id: string, + name: string, + token?: string, + createdAt: Date, + } + + export interface CreateTokenRequest { + name: string; + } + export interface CreateTokenResponse { + token?: RawToken; + error?: string; + } + + export interface DeleteTokenResponse { + error?: string; + } + + export interface TokensResponse { + tokens: RawToken[]; + error?: string; + } + +type Routes = [ + { + route: '/user'; + method: 'GET'; + jsonResponse: UserResponse; + }, + { + route: '/login'; + method: 'POST'; + jsonBody: LoginRequest; + jsonResponse: LoginResponse; + }, + { + route: '/logout'; + method: 'POST'; + jsonResponse: GenericResponse; + }, + { + route: '/new_token'; + method: 'POST'; + jsonBody: CreateTokenRequest; + jsonResponse: CreateTokenResponse; + }, + { + route: '/tokens'; + method: 'GET'; + jsonResponse: TokensResponse; + }, + { + route: '/token/[id]'; + method: 'DELETE'; + jsonResponse: DeleteTokenResponse; + } +]; + +const HTTP: TypedAxios = axios.create({ + withCredentials: true, + baseURL: SERVER_URI, +}); + +// Intercept the response and log any errors. +HTTP.interceptors.response.use(function (response) { + // Any status code that lie within the range of 2xx cause this function to trigger + if (response.data.error) { + console.log(`[${response.config.method}] API error: `, response.data.error); + } + return response; + }, function (error) { + // Any status codes that falls outside the range of 2xx cause this function to trigger + return Promise.reject(error); + }); +export default HTTP; diff --git a/app/src/utils/localStorage.ts b/app/src/utils/localStorage.ts index 2e3b72f..dde352e 100644 --- a/app/src/utils/localStorage.ts +++ b/app/src/utils/localStorage.ts @@ -12,8 +12,5 @@ export function useLocalSession() { const [githubCode, saveGithubCode] = useLocalStorage(STORAGE_GH_CODE_KEY, null); const clearGithubCode = () => clear(STORAGE_GH_CODE_KEY, saveGithubCode); - const [sessionId, saveSessionId] = useLocalStorage(STORAGE_FP_SESSION_KEY, null); - const clearSessionId = () => clear(STORAGE_FP_SESSION_KEY, saveSessionId); - - return {githubCode, saveGithubCode, clearGithubCode, sessionId, saveSessionId, clearSessionId }; + return {githubCode, saveGithubCode, clearGithubCode }; } \ No newline at end of file diff --git a/migrations/2024-04-29-210717_create_api_tokens/down.sql b/migrations/2024-04-29-210717_create_api_tokens/down.sql new file mode 100644 index 0000000..fe77ebc --- /dev/null +++ b/migrations/2024-04-29-210717_create_api_tokens/down.sql @@ -0,0 +1 @@ +DROP TABLE api_tokens diff --git a/migrations/2024-04-29-210717_create_api_tokens/up.sql b/migrations/2024-04-29-210717_create_api_tokens/up.sql new file mode 100644 index 0000000..f94e55d --- /dev/null +++ b/migrations/2024-04-29-210717_create_api_tokens/up.sql @@ -0,0 +1,8 @@ +CREATE TABLE api_tokens ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id), + friendly_name VARCHAR NOT NULL, + token BYTEA NOT NULL, + expires_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) \ No newline at end of file diff --git a/src/api/api_token.rs b/src/api/api_token.rs new file mode 100644 index 0000000..991523c --- /dev/null +++ b/src/api/api_token.rs @@ -0,0 +1,61 @@ +use crate::{models, util::sys_time_to_epoch}; +use rocket::serde::{Deserialize, Serialize}; +use std::time::{SystemTime, Duration, UNIX_EPOCH}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Token { + pub id: String, + pub name: String, + pub created_at: u64, + pub token: Option, +} + +impl From for Token { + fn from(token: models::Token) -> Self { + Token { + id: token.id.to_string(), + name: token.friendly_name, + created_at: sys_time_to_epoch(token.created_at), + // We don't return the hashed token, as it's a secret. + token: None, + } + } +} +/// The CreateToken request. +#[derive(Deserialize, Debug)] +pub struct CreateTokenRequest { + pub name: String, +} + +/// The response to a CreateToken request. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateTokenResponse { + pub token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// The CreateToken request. +#[derive(Deserialize, Debug)] +pub struct DeleteTokenRequest { + pub id: String, +} + +/// The response to a CreateToken request. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteTokenResponse { + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// The response to a CreateToken request. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TokensResponse { + pub tokens: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} diff --git a/src/api.rs b/src/api/auth.rs similarity index 83% rename from src/api.rs rename to src/api/auth.rs index 3448dba..784fe42 100644 --- a/src/api.rs +++ b/src/api/auth.rs @@ -1,7 +1,13 @@ use rocket::serde::{Deserialize, Serialize}; +use uuid::Uuid; use crate::models; +pub struct UserSessionId { + pub user_id: Uuid, + pub session_id: String, +} + #[derive(Serialize, Deserialize, Debug, Default, Clone)] #[serde(rename_all = "camelCase")] pub struct User { @@ -42,26 +48,19 @@ pub struct LoginResponse { pub error: Option, } -/// The response to a session request. +/// The response to a logout request. #[derive(Serialize)] #[serde(rename_all = "camelCase")] -pub struct SessionResponse { - pub user: Option, +pub struct LogoutResponse { #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } -/// The publish request. -#[derive(Deserialize, Debug)] -pub struct PublishRequest { - pub name: String, - pub version: String, -} - -/// The response to a publish request. +/// The response to a user GET request. #[derive(Serialize)] #[serde(rename_all = "camelCase")] -pub struct PublishResponse { +pub struct UserResponse { + pub user: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, -} +} \ No newline at end of file diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..199a558 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,28 @@ +pub mod auth; +pub mod api_token; + +use rocket::serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ApiError { + #[error("Unauthorized request")] + Unauthorized, + #[error("Missing session cookie")] + MissingCookie, +} + +/// The publish request. +#[derive(Deserialize, Debug)] +pub struct PublishRequest { + pub name: String, + pub version: String, +} + +/// The response to a publish request. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PublishResponse { + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} diff --git a/src/cors.rs b/src/cors.rs index 6e99426..fe6a213 100644 --- a/src/cors.rs +++ b/src/cors.rs @@ -17,12 +17,12 @@ impl Fairing for Cors { // Build an Access-Control-Allow-Origin * policy Response header. async fn on_response<'r>(&self, _request: &'r Request<'_>, response: &mut Response<'r>) { - response.set_header(Header::new("Access-Control-Allow-Origin", "*")); + response.set_header(Header::new("Access-Control-Allow-Origin", "http://localhost:3000")); // TODO: env var response.set_header(Header::new( "Access-Control-Allow-Methods", "POST, PATCH, PUT, DELETE, HEAD, OPTIONS, GET", )); - response.set_header(Header::new("Access-Control-Allow-Headers", "*")); + response.set_header(Header::new("Access-Control-Allow-Headers", "*, Access-Control-Request-Headers, Content-Type")); response.set_header(Header::new("Access-Control-Allow-Credentials", "true")); } } diff --git a/src/db/api_token.rs b/src/db/api_token.rs new file mode 100644 index 0000000..1a6e0d6 --- /dev/null +++ b/src/db/api_token.rs @@ -0,0 +1,94 @@ +use super::error::DatabaseError; +use super::{api, models, schema}; +use super::{string_to_uuid, Database}; +use diesel::prelude::*; +use diesel::upsert::excluded; +use std::time::{Duration, SystemTime}; +use uuid::Uuid; +use rand::{distributions::Uniform, rngs::OsRng, Rng}; +use sha2::{Digest, Sha256}; + + +/// NEVER CHANGE THE PREFIX OF EXISTING TOKENS!!! Doing so will implicitly +/// revoke all the tokens, disrupting production users. +const TOKEN_PREFIX: &str = "pub_"; +const TOKEN_LENGTH: usize = 32; + +impl Database { + /// Creates an API token for the user and returns the token. + pub fn new_token( + &self, + user_id: Uuid, + friendly_name: String, + ) -> Result<(models::Token, String), DatabaseError> { + let connection = &mut self.connection(); + + let plain_token = generate_token(); + let token = Sha256::digest(plain_token.as_bytes()).as_slice().to_vec(); + + let new_token = models::NewToken { + user_id, + friendly_name, + token, + expires_at: None, + }; + + // Insert new session + let saved_token = diesel::insert_into(schema::api_tokens::table) + .values(&new_token) + .returning(models::Token::as_returning()) + .get_result(connection) + .map_err(|_| DatabaseError::InsertTokenFailed(user_id.to_string()))?; + + Ok((saved_token, plain_token)) + } + + /// Deletes an API token for the user. + pub fn delete_token(&self, user_id: Uuid, token_id: String) -> Result<(), DatabaseError> { + let connection = &mut self.connection(); + + let token_uuid = string_to_uuid(token_id.clone())?; + + diesel::delete( + schema::api_tokens::table + .filter(schema::api_tokens::id.eq(token_uuid)) + .filter(schema::api_tokens::user_id.eq(user_id)), + ) + .execute(connection) + .map_err(|_| DatabaseError::NotFound(token_id))?; + + Ok(()) + } + + /// Fetch all tokens for the given user ID. + pub fn get_tokens_for_user(&self, user_id: Uuid) -> Result, DatabaseError> { + let connection = &mut self.connection(); + schema::api_tokens::table + .filter(schema::api_tokens::user_id.eq(user_id)) + .select(models::Token::as_returning()) + .load(connection) + .map_err(|_| DatabaseError::NotFound(user_id.to_string())) + // // TODO: fix return type + // eprintln!("res: {:?}", res); + // Err(DatabaseError::NotFound(user_id.to_string())) + } +} + +fn generate_secure_alphanumeric_string(len: usize) -> String { + const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + + OsRng + .sample_iter(Uniform::from(0..CHARS.len())) + .map(|idx| CHARS[idx] as char) + .take(len) + .collect() +} + +fn generate_token() -> String { + format!( + "{}{}", + TOKEN_PREFIX, + generate_secure_alphanumeric_string(TOKEN_LENGTH) + ) + .into() +} \ No newline at end of file diff --git a/src/db/error.rs b/src/db/error.rs index 097ecbb..8dd73dc 100644 --- a/src/db/error.rs +++ b/src/db/error.rs @@ -10,4 +10,6 @@ pub enum DatabaseError { InsertUserFailed(String), #[error("Failed to save session for user; {0}")] InsertSessionFailed(String), + #[error("Failed to save session for user; {0}")] + InsertTokenFailed(String), } diff --git a/src/db/mod.rs b/src/db/mod.rs index 3014798..510e098 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,5 +1,6 @@ mod error; mod user_session; +mod api_token; use self::error::DatabaseError; use crate::{api, models, schema}; diff --git a/src/db/user_session.rs b/src/db/user_session.rs index 30d826d..d4ca249 100644 --- a/src/db/user_session.rs +++ b/src/db/user_session.rs @@ -4,6 +4,7 @@ use super::{string_to_uuid, Database}; use diesel::prelude::*; use diesel::upsert::excluded; use std::time::{Duration, SystemTime}; +use uuid::Uuid; impl Database { /// Insert a user session into the database and return the session ID. @@ -11,7 +12,7 @@ impl Database { /// If the user does exist, update the user's full name and avatar URL if they have changed. pub fn insert_user_session( &self, - user: &api::User, + user: &api::auth::User, expires_in: u32, ) -> Result { let connection = &mut self.connection(); @@ -53,6 +54,16 @@ impl Database { Ok(saved_session.id.to_string()) } + /// Fetch a user given the user ID. + pub fn get_user(&self, user_id: Uuid) -> Result { + let connection = &mut self.connection(); + schema::users::table + .filter(schema::users::id.eq(user_id)) + .select(models::User::as_returning()) + .first::(connection) + .map_err(|_| DatabaseError::NotFound(user_id.to_string())) + } + /// Fetch a user from the database for a given session ID. pub fn get_user_for_session(&self, session_id: String) -> Result { let session_uuid = string_to_uuid(session_id.clone())?; @@ -64,4 +75,14 @@ impl Database { .first::(connection) .map_err(|_| DatabaseError::NotFound(session_id)) } + + /// Delete a session given its ID. + pub fn delete_session(&self, session_id: String) -> Result<(), DatabaseError> { + let session_uuid = string_to_uuid(session_id.clone())?; + let connection = &mut self.connection(); + diesel::delete(schema::sessions::table.filter(schema::sessions::id.eq(session_uuid))) + .execute(connection) + .map_err(|_| DatabaseError::NotFound(session_id))?; + Ok(()) + } } diff --git a/src/github.rs b/src/github.rs index 8da941e..85a8361 100644 --- a/src/github.rs +++ b/src/github.rs @@ -1,6 +1,6 @@ extern crate reqwest; -use crate::api::User; +use crate::api::auth::User; use serde::Deserialize; use std::env; use thiserror::Error; diff --git a/src/lib.rs b/src/lib.rs index 537aaa1..de38342 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,3 +4,4 @@ pub mod db; pub mod github; pub mod models; pub mod schema; +pub mod util; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index f61b992..b19bd16 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,29 +3,59 @@ #[macro_use] extern crate rocket; +use std::hash::Hash; + +use forc_pub::api::api_token::{CreateTokenRequest, CreateTokenResponse, DeleteTokenRequest, DeleteTokenResponse, TokensResponse, Token}; +use forc_pub::api::auth::LogoutResponse; use forc_pub::api::{ - LoginRequest, LoginResponse, PublishRequest, PublishResponse, SessionResponse, User, + auth::{LoginRequest, LoginResponse, UserResponse, User, UserSessionId}, + ApiError, PublishRequest, PublishResponse, }; use forc_pub::cors::Cors; use forc_pub::db::Database; use forc_pub::github::handle_login; +use rocket::http::{Cookie, CookieJar}; use rocket::{serde::json::Json, State}; +use uuid::Uuid; #[derive(Default)] struct ServerState { pub db: Database, } +impl ServerState { + pub fn get_authenticated_session(&self, cookies: &CookieJar) -> Result { + match cookies.get("session").map(|c| c.value()) { + Some(session_id) => { + let user = self + .db + .get_user_for_session(session_id.to_string()) + .map_err(|_| ApiError::Unauthorized)?; + Ok(UserSessionId { user_id : user.id, session_id: session_id.to_string()}) + } + None => Err(ApiError::MissingCookie), + } + } +} + /// The endpoint to authenticate with GitHub. #[post("/login", data = "")] -async fn login(state: &State, request: Json) -> Json { +async fn login( + state: &State, + cookies: &CookieJar<'_>, + request: Json, +) -> Json { match handle_login(request.code.clone()).await { Ok((user, expires_in)) => match state.db.insert_user_session(&user, expires_in) { - Ok(session_id) => Json(LoginResponse { - user: Some(user), - session_id: Some(session_id), - error: None, - }), + Ok(session_id) => { + cookies.add(Cookie::build("session", session_id.clone()).expires(None).finish()); + + Json(LoginResponse { + user: Some(user), + session_id: Some(session_id), + error: None, + }) + } Err(e) => Json(LoginResponse { user: None, session_id: None, @@ -40,18 +70,144 @@ async fn login(state: &State, request: Json) -> Json< } } -/// The endpoint to authenticate with GitHub. -#[get("/session?")] -async fn session(state: &State, id: String) -> Json { - match state.db.get_user_for_session(id) { - Ok(user) => Json(SessionResponse { - user: Some(User::from(user)), - error: None, +/// The endpoint to log out. +#[post("/logout")] +async fn logout( + state: &State, + cookies: &CookieJar<'_>, +) -> Json { + + match state.get_authenticated_session(cookies) { + Ok(UserSessionId { session_id, ..}) => { + match state.db.delete_session(session_id.to_string()) { + Ok(_) => { + cookies.remove(Cookie::named("session")); + Json(LogoutResponse { error: None }) + } + Err(e) => Json(LogoutResponse { + error: Some(e.to_string()), + }), + } + } + Err(_) => Json(LogoutResponse { + error: None }), - Err(error) => Json(SessionResponse { + } + + +} + +/// The endpoint to authenticate with GitHub. +#[get("/user")] +async fn user( + state: &State, + cookies: &CookieJar<'_>, +) -> Json { + // cookies.add(Cookie::build("session", id.clone()).expires(None).finish()); + + // match state.db.get_user_for_session(id) { + // Ok(user) => Json(SessionResponse { + // user: Some(User::from(user)), + // error: None, + // }), + + // Err(error) => Json(SessionResponse { + // user: None, + // error: Some(error.to_string()), + // }), + // } + + match state.get_authenticated_session(cookies) { + Ok(UserSessionId {user_id, ..}) => match state.db.get_user(user_id) { + Ok(user) => Json(UserResponse { + user: Some(User::from(user)), + error: None, + }), + + Err(error) => Json(UserResponse { + user: None, + error: Some(error.to_string()), + }), + }, + + Err(error) => Json(UserResponse { user: None, error: Some(error.to_string()), }), + + } +} + +#[post("/new_token", data = "")] +fn new_token( + state: &State, + cookies: &CookieJar<'_>, + request: Json, +) -> Json { + match state.get_authenticated_session(cookies) { + Ok(UserSessionId { user_id, ..}) => match state.db.new_token(user_id, request.name.clone()) { + Ok((token, plain_token)) => Json(CreateTokenResponse { + token: Some(Token { + // The only time we return the plain token is when it's created. + token: Some(plain_token), + ..token.into() + } + ), + error: None, + }), + Err(e) => Json(CreateTokenResponse { + token: None, + error: Some(e.to_string()), + }), + }, + + Err(e) => Json(CreateTokenResponse { + token: None, + error: Some(e.to_string()), + }), + } +} + +#[delete("/token/")] +fn delete_token( + state: &State, + cookies: &CookieJar<'_>, + id: String, +) -> Json { + match state.get_authenticated_session(cookies) { + Ok(UserSessionId { user_id, ..}) => match state.db.delete_token(user_id, id.clone()) { + Ok(_) => Json(DeleteTokenResponse { + error: None, + }), + Err(e) => Json(DeleteTokenResponse { + error: Some(e.to_string()), + }), + }, + + Err(e) => Json(DeleteTokenResponse { + error: Some(e.to_string()), + }), + } +} + +#[get("/tokens")] +fn tokens(state: &State, cookies: &CookieJar<'_>) -> Json { + match state.get_authenticated_session(cookies) { + Ok(UserSessionId { user_id, ..}) => match state.db.get_tokens_for_user(user_id) { + Ok(tokens) => Json(TokensResponse { + tokens: tokens.into_iter().map(|t| t.into()).collect(), + error: None, + }), + Err(e) => Json(TokensResponse { + tokens: vec![], + error: Some(e.to_string()), + }), + }, + + Err(e) => Json(TokensResponse { + tokens: vec![], + error: Some(e.to_string()), + }), } } @@ -86,6 +242,19 @@ fn rocket() -> _ { rocket::build() .manage(ServerState::default()) .attach(Cors) - .mount("/", routes![login, session, publish, all_options, health]) + .mount( + "/", + routes![ + login, + logout, + user, + new_token, + delete_token, + tokens, + publish, + all_options, + health + ], + ) .register("/", catchers![not_found]) } diff --git a/src/models.rs b/src/models.rs index 93c2096..599c535 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,4 +1,4 @@ -use diesel::prelude::*; +use diesel::{prelude::*, sql_types::Bytea}; use std::time::SystemTime; use uuid::Uuid; @@ -43,3 +43,23 @@ pub struct NewSession { pub user_id: Uuid, pub expires_at: SystemTime, } + +#[derive(Queryable, Selectable, Debug)] +#[diesel(table_name = crate::schema::api_tokens)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Token { + pub id: Uuid, + pub user_id: Uuid, + pub friendly_name: String, + pub expires_at: Option, + pub created_at: SystemTime, +} + +#[derive(Insertable)] +#[diesel(table_name = crate::schema::api_tokens)] +pub struct NewToken { + pub user_id: Uuid, + pub friendly_name: String, + pub token: Vec, + pub expires_at: Option, +} diff --git a/src/schema.rs b/src/schema.rs index 9f19130..3b28d84 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -1,5 +1,16 @@ // @generated automatically by Diesel CLI. +diesel::table! { + api_tokens (id) { + id -> Uuid, + user_id -> Uuid, + friendly_name -> Varchar, + token -> Bytea, + expires_at -> Nullable, + created_at -> Timestamp, + } +} + diesel::table! { sessions (id) { id -> Uuid, @@ -22,6 +33,11 @@ diesel::table! { } } +diesel::joinable!(api_tokens -> users (user_id)); diesel::joinable!(sessions -> users (user_id)); -diesel::allow_tables_to_appear_in_same_query!(sessions, users,); +diesel::allow_tables_to_appear_in_same_query!( + api_tokens, + sessions, + users, +); diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..d545f74 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,5 @@ +use std::time::{Duration, SystemTime}; + +pub fn sys_time_to_epoch(sys_time: SystemTime) -> u64 { + sys_time.duration_since(SystemTime::UNIX_EPOCH).expect("convert time to epoch").as_secs() * 1000 +} \ No newline at end of file diff --git a/tests/db_integration.rs b/tests/db_integration.rs index 61af983..2b93d93 100644 --- a/tests/db_integration.rs +++ b/tests/db_integration.rs @@ -23,8 +23,8 @@ fn clear_tables(db: &Database) { .expect("clear users table"); } -fn mock_user_1() -> api::User { - api::User { +fn mock_user_1() -> api::auth::User { + api::auth::User { github_login: TEST_LOGIN_1.to_string(), full_name: TEST_FULL_NAME_1.to_string(), email: Some(TEST_EMAIL_1.to_string()), @@ -34,8 +34,8 @@ fn mock_user_1() -> api::User { } } -fn mock_user_2() -> api::User { - api::User { +fn mock_user_2() -> api::auth::User { + api::auth::User { github_login: TEST_LOGIN_2.to_string(), ..Default::default() } From 26f83fa700dc621ecfb4c7efd0addc8a60630b01 Mon Sep 17 00:00:00 2001 From: Sophie Date: Wed, 1 May 2024 18:36:05 -0700 Subject: [PATCH 02/17] Session auth request guard --- app/package-lock.json | 50 ++++ app/package.json | 1 + app/src/features/tokens/hooks/useApiTokens.ts | 1 - .../features/toolbar/hooks/useGithubAuth.ts | 18 +- app/src/utils/http.ts | 22 +- src/api/api_token.rs | 20 +- src/api/auth.rs | 23 +- src/api/mod.rs | 36 +-- src/db/api_token.rs | 3 - src/db/error.rs | 4 +- src/db/mod.rs | 4 +- src/db/user_session.rs | 21 +- src/lib.rs | 5 +- src/main.rs | 227 ++++-------------- src/middleware/mod.rs | 1 + src/middleware/session_auth.rs | 57 +++++ src/models.rs | 2 +- tests/db_integration.rs | 9 +- 18 files changed, 224 insertions(+), 280 deletions(-) create mode 100644 src/middleware/mod.rs create mode 100644 src/middleware/session_auth.rs diff --git a/app/package-lock.json b/app/package-lock.json index bb97485..f289321 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -22,9 +22,11 @@ "@types/react-dom": "^18.2.22", "axios": "^1.6.8", "react": "^18.2.0", + "react-cookie": "^7.1.4", "react-dom": "^18.2.0", "react-router-dom": "^6.22.3", "react-scripts": "5.0.1", + "react-use-cookie": "^1.5.0", "typed-axios-instance": "^3.3.1", "typescript": "^4.9.5", "usehooks-ts": "^3.0.2", @@ -4522,6 +4524,11 @@ "@types/node": "*" } }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" + }, "node_modules/@types/eslint": { "version": "8.56.6", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.6.tgz", @@ -4575,6 +4582,15 @@ "@types/node": "*" } }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", + "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "node_modules/@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -15265,6 +15281,19 @@ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, + "node_modules/react-cookie": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-7.1.4.tgz", + "integrity": "sha512-wDxxa/HYaSXSMlyWJvJ5uZTzIVtQTPf1gMksFgwAz/2/W3lCtY8r4OChCXMPE7wax0PAdMY97UkNJedGv7KnDw==", + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.5", + "hoist-non-react-statics": "^3.3.2", + "universal-cookie": "^7.0.0" + }, + "peerDependencies": { + "react": ">= 16.3.0" + } + }, "node_modules/react-dev-utils": { "version": "12.0.1", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", @@ -15529,6 +15558,18 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-use-cookie": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/react-use-cookie/-/react-use-cookie-1.5.0.tgz", + "integrity": "sha512-zPEAmAYbRLXzpi3VD3rjYHszTo8BonuiaiLH/jYixHr6qE+Yukm2lA6AsinX1uL7/9nFSVeKBLqI4oOZYdhghQ==", + "engines": { + "node": ">=8", + "npm": ">=5" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -17618,6 +17659,15 @@ "node": ">=8" } }, + "node_modules/universal-cookie": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-7.1.4.tgz", + "integrity": "sha512-Q+DVJsdykStWRMtXr2Pdj3EF98qZHUH/fXv/gwFz/unyToy1Ek1w5GsWt53Pf38tT8Gbcy5QNsj61Xe9TggP4g==", + "dependencies": { + "@types/cookie": "^0.6.0", + "cookie": "^0.6.0" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", diff --git a/app/package.json b/app/package.json index fd85f52..5021513 100644 --- a/app/package.json +++ b/app/package.json @@ -20,6 +20,7 @@ "react-dom": "^18.2.0", "react-router-dom": "^6.22.3", "react-scripts": "5.0.1", + "react-use-cookie": "^1.5.0", "typed-axios-instance": "^3.3.1", "typescript": "^4.9.5", "usehooks-ts": "^3.0.2", diff --git a/app/src/features/tokens/hooks/useApiTokens.ts b/app/src/features/tokens/hooks/useApiTokens.ts index b323ae0..31d3ed4 100644 --- a/app/src/features/tokens/hooks/useApiTokens.ts +++ b/app/src/features/tokens/hooks/useApiTokens.ts @@ -5,7 +5,6 @@ import { useGithubAuth } from '../../toolbar/hooks/useGithubAuth'; import axios from 'axios'; import HTTP, { CreateTokenResponse, - DeleteTokenResponse, RawToken, TokensResponse, } from '../../../utils/http'; diff --git a/app/src/features/toolbar/hooks/useGithubAuth.ts b/app/src/features/toolbar/hooks/useGithubAuth.ts index 6ae0f1f..d9b414c 100644 --- a/app/src/features/toolbar/hooks/useGithubAuth.ts +++ b/app/src/features/toolbar/hooks/useGithubAuth.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useLocalSession } from '../../../utils/localStorage'; import { SERVER_URI } from '../../../constants'; +import useCookie from 'react-use-cookie'; import axios from 'axios'; import HTTP, { AuthenticatedUser, @@ -13,13 +14,15 @@ export function useGithubAuth(): [ AuthenticatedUser | null, () => Promise ] { + const [sessionId, setSessionId] = useCookie('session'); const [githubUser, setGithubUser] = useState(null); const { githubCode, saveGithubCode, clearGithubCode } = useLocalSession(); const [searchParams, setSearchParams] = useSearchParams(); const logout = useCallback(async () => { + await HTTP.post(`/logout`); + setSessionId(''); setGithubUser(null); - HTTP.post(`/logout`); }, [setGithubUser]); // If this was a redirect from Github, we have a code to log in with. @@ -43,21 +46,20 @@ export function useGithubAuth(): [ if (data.user) { setGithubUser(data.user); } - }); + }).catch(() => clearGithubCode()); }, [githubCode, setGithubUser, clearGithubCode]); - // Attempt to fetch the logged in user info. useEffect(() => { - if (!!githubUser) { + // Attempt to fetch the logged in user info if the session cookie is set and the user hasn't been fetched. + if (!!githubUser || !sessionId) { return; } HTTP.get(`/user`).then(({ data }) => { - if (data.user) { - setGithubUser(data.user); - } + setGithubUser(data.user); }); - }, [githubUser, setGithubUser]); + }, [githubUser, setGithubUser, sessionId]); return [githubUser, logout]; } + diff --git a/app/src/utils/http.ts b/app/src/utils/http.ts index 6f23244..16d3056 100644 --- a/app/src/utils/http.ts +++ b/app/src/utils/http.ts @@ -16,20 +16,14 @@ export interface LoginRequest { } export interface LoginResponse { - sessionId?: string; - user?: AuthenticatedUser; - error?: string; + sessionId: string; + user: AuthenticatedUser; } export interface UserResponse { - user?: AuthenticatedUser; - error?: string; + user: AuthenticatedUser; } -export interface GenericResponse { - error?: string; - } - export interface RawToken { id: string, name: string, @@ -45,10 +39,6 @@ export interface GenericResponse { error?: string; } - export interface DeleteTokenResponse { - error?: string; - } - export interface TokensResponse { tokens: RawToken[]; error?: string; @@ -69,7 +59,6 @@ type Routes = [ { route: '/logout'; method: 'POST'; - jsonResponse: GenericResponse; }, { route: '/new_token'; @@ -85,7 +74,6 @@ type Routes = [ { route: '/token/[id]'; method: 'DELETE'; - jsonResponse: DeleteTokenResponse; } ]; @@ -97,12 +85,10 @@ const HTTP: TypedAxios = axios.create({ // Intercept the response and log any errors. HTTP.interceptors.response.use(function (response) { // Any status code that lie within the range of 2xx cause this function to trigger - if (response.data.error) { - console.log(`[${response.config.method}] API error: `, response.data.error); - } return response; }, function (error) { // Any status codes that falls outside the range of 2xx cause this function to trigger + console.error('HTTP Error:', error); return Promise.reject(error); }); export default HTTP; diff --git a/src/api/api_token.rs b/src/api/api_token.rs index 991523c..ea35f41 100644 --- a/src/api/api_token.rs +++ b/src/api/api_token.rs @@ -32,23 +32,7 @@ pub struct CreateTokenRequest { #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateTokenResponse { - pub token: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -/// The CreateToken request. -#[derive(Deserialize, Debug)] -pub struct DeleteTokenRequest { - pub id: String, -} - -/// The response to a CreateToken request. -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DeleteTokenResponse { - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, + pub token: Token, } /// The response to a CreateToken request. @@ -56,6 +40,4 @@ pub struct DeleteTokenResponse { #[serde(rename_all = "camelCase")] pub struct TokensResponse { pub tokens: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, } diff --git a/src/api/auth.rs b/src/api/auth.rs index 784fe42..64a1204 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -3,11 +3,6 @@ use uuid::Uuid; use crate::models; -pub struct UserSessionId { - pub user_id: Uuid, - pub session_id: String, -} - #[derive(Serialize, Deserialize, Debug, Default, Clone)] #[serde(rename_all = "camelCase")] pub struct User { @@ -42,25 +37,13 @@ pub struct LoginRequest { #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct LoginResponse { - pub user: Option, - pub session_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -/// The response to a logout request. -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LogoutResponse { - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, + pub user: User, + pub session_id: String, } /// The response to a user GET request. #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct UserResponse { - pub user: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, + pub user: User, } \ No newline at end of file diff --git a/src/api/mod.rs b/src/api/mod.rs index 199a558..2ca1c1a 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,28 +1,30 @@ pub mod auth; pub mod api_token; -use rocket::serde::{Deserialize, Serialize}; +use rocket::{http::Status, response::Responder, serde::{json::Json, Deserialize, Serialize}, Request}; use thiserror::Error; +/// A wrapper for API responses that can return errors. +pub type ApiResult = Result, ApiError>; + +/// An empty response. +#[derive(Serialize)] +pub struct EmptyResponse; + #[derive(Error, Debug)] pub enum ApiError { - #[error("Unauthorized request")] - Unauthorized, - #[error("Missing session cookie")] - MissingCookie, + #[error("Database error: {0}")] + Database(#[from] crate::db::error::DatabaseError), + #[error("GitHub error: {0}")] + Github(#[from] crate::github::GithubError), } -/// The publish request. -#[derive(Deserialize, Debug)] -pub struct PublishRequest { - pub name: String, - pub version: String, +impl<'r, 'o: 'r> Responder<'r, 'o> for ApiError { + fn respond_to(self, _request: &'r Request<'_>) -> rocket::response::Result<'o> { + match self { + ApiError::Database(_) => Err(Status::InternalServerError), + ApiError::Github(_) => Err(Status::Unauthorized), + } + } } -/// The response to a publish request. -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PublishResponse { - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} diff --git a/src/db/api_token.rs b/src/db/api_token.rs index 1a6e0d6..22b1245 100644 --- a/src/db/api_token.rs +++ b/src/db/api_token.rs @@ -68,9 +68,6 @@ impl Database { .select(models::Token::as_returning()) .load(connection) .map_err(|_| DatabaseError::NotFound(user_id.to_string())) - // // TODO: fix return type - // eprintln!("res: {:?}", res); - // Err(DatabaseError::NotFound(user_id.to_string())) } } diff --git a/src/db/error.rs b/src/db/error.rs index 8dd73dc..7f2c3a5 100644 --- a/src/db/error.rs +++ b/src/db/error.rs @@ -8,8 +8,8 @@ pub enum DatabaseError { NotFound(String), #[error("Failed to save user: {0}")] InsertUserFailed(String), - #[error("Failed to save session for user; {0}")] + #[error("Failed to save session for user: {0}")] InsertSessionFailed(String), - #[error("Failed to save session for user; {0}")] + #[error("Failed to save token for user: {0}")] InsertTokenFailed(String), } diff --git a/src/db/mod.rs b/src/db/mod.rs index 510e098..ce8fbf4 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,4 +1,4 @@ -mod error; +pub mod error; mod user_session; mod api_token; @@ -19,6 +19,7 @@ pub struct Database { pub pool: DbPool, } + impl Default for Database { fn default() -> Self { Database::new() @@ -27,6 +28,7 @@ impl Default for Database { impl Database { pub fn new() -> Self { + // Create a connection pool let pool = Pool::builder() .build(ConnectionManager::::new(db_url())) diff --git a/src/db/user_session.rs b/src/db/user_session.rs index d4ca249..9b7ddaa 100644 --- a/src/db/user_session.rs +++ b/src/db/user_session.rs @@ -14,7 +14,7 @@ impl Database { &self, user: &api::auth::User, expires_in: u32, - ) -> Result { + ) -> Result { let connection = &mut self.connection(); // Insert or update a user @@ -51,7 +51,7 @@ impl Database { .get_result(connection) .map_err(|_| DatabaseError::InsertSessionFailed(user.github_login.clone()))?; - Ok(saved_session.id.to_string()) + Ok(saved_session) } /// Fetch a user given the user ID. @@ -64,6 +64,16 @@ impl Database { .map_err(|_| DatabaseError::NotFound(user_id.to_string())) } + /// Fetch a user given the user ID. + pub fn get_session(&self, session_id: Uuid) -> Result { + let connection = &mut self.connection(); + schema::sessions::table + .filter(schema::sessions::id.eq(session_id)) + .select(models::Session::as_returning()) + .first::(connection) + .map_err(|_| DatabaseError::NotFound(session_id.to_string())) + } + /// Fetch a user from the database for a given session ID. pub fn get_user_for_session(&self, session_id: String) -> Result { let session_uuid = string_to_uuid(session_id.clone())?; @@ -77,12 +87,11 @@ impl Database { } /// Delete a session given its ID. - pub fn delete_session(&self, session_id: String) -> Result<(), DatabaseError> { - let session_uuid = string_to_uuid(session_id.clone())?; + pub fn delete_session(&self, session_id: Uuid) -> Result<(), DatabaseError> { let connection = &mut self.connection(); - diesel::delete(schema::sessions::table.filter(schema::sessions::id.eq(session_uuid))) + diesel::delete(schema::sessions::table.filter(schema::sessions::id.eq(session_id))) .execute(connection) - .map_err(|_| DatabaseError::NotFound(session_id))?; + .map_err(|_| DatabaseError::NotFound(session_id.to_string()))?; Ok(()) } } diff --git a/src/lib.rs b/src/lib.rs index de38342..5a9e5c5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,8 @@ pub mod api; -pub mod cors; +pub mod middleware; pub mod db; pub mod github; pub mod models; pub mod schema; -pub mod util; \ No newline at end of file +pub mod util; +pub mod cors; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index b19bd16..c512768 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,219 +3,95 @@ #[macro_use] extern crate rocket; -use std::hash::Hash; - -use forc_pub::api::api_token::{CreateTokenRequest, CreateTokenResponse, DeleteTokenRequest, DeleteTokenResponse, TokensResponse, Token}; -use forc_pub::api::auth::LogoutResponse; +use forc_pub::api::api_token::{CreateTokenRequest, CreateTokenResponse, Token, TokensResponse}; use forc_pub::api::{ - auth::{LoginRequest, LoginResponse, UserResponse, User, UserSessionId}, - ApiError, PublishRequest, PublishResponse, + auth::{LoginRequest, LoginResponse, UserResponse}, + ApiError, ApiResult, EmptyResponse, }; use forc_pub::cors::Cors; +use forc_pub::db::error::DatabaseError; use forc_pub::db::Database; use forc_pub::github::handle_login; +use forc_pub::middleware::session_auth::{SessionAuth, SESSION_COOKIE_NAME}; +use forc_pub::util::sys_time_to_epoch; use rocket::http::{Cookie, CookieJar}; +use rocket::time::OffsetDateTime; use rocket::{serde::json::Json, State}; -use uuid::Uuid; + #[derive(Default)] -struct ServerState { +pub struct ServerState { pub db: Database, } -impl ServerState { - pub fn get_authenticated_session(&self, cookies: &CookieJar) -> Result { - match cookies.get("session").map(|c| c.value()) { - Some(session_id) => { - let user = self - .db - .get_user_for_session(session_id.to_string()) - .map_err(|_| ApiError::Unauthorized)?; - Ok(UserSessionId { user_id : user.id, session_id: session_id.to_string()}) - } - None => Err(ApiError::MissingCookie), - } - } -} - /// The endpoint to authenticate with GitHub. #[post("/login", data = "")] async fn login( - state: &State, + db: &State, cookies: &CookieJar<'_>, request: Json, -) -> Json { - match handle_login(request.code.clone()).await { - Ok((user, expires_in)) => match state.db.insert_user_session(&user, expires_in) { - Ok(session_id) => { - cookies.add(Cookie::build("session", session_id.clone()).expires(None).finish()); - - Json(LoginResponse { - user: Some(user), - session_id: Some(session_id), - error: None, - }) - } - Err(e) => Json(LoginResponse { - user: None, - session_id: None, - error: Some(e.to_string()), - }), - }, - Err(e) => Json(LoginResponse { - user: None, - session_id: None, - error: Some(e.to_string()), - }), - } +) -> ApiResult { + let (user, expires_in) = handle_login(request.code.clone()).await?; + let session = db.insert_user_session(&user, expires_in)?; + let session_id = session.id.to_string(); + cookies.add( + Cookie::build(SESSION_COOKIE_NAME, session_id.clone()) + .finish(), + ); + Ok(Json(LoginResponse { user, session_id })) } /// The endpoint to log out. #[post("/logout")] -async fn logout( - state: &State, - cookies: &CookieJar<'_>, -) -> Json { - - match state.get_authenticated_session(cookies) { - Ok(UserSessionId { session_id, ..}) => { - match state.db.delete_session(session_id.to_string()) { - Ok(_) => { - cookies.remove(Cookie::named("session")); - Json(LogoutResponse { error: None }) - } - Err(e) => Json(LogoutResponse { - error: Some(e.to_string()), - }), - } - } - Err(_) => Json(LogoutResponse { - error: None - }), - } - - +async fn logout(db: &State, auth: SessionAuth) -> ApiResult { + let session_id = auth.session_id; + let _ = db.delete_session(session_id)?; + Ok(Json(EmptyResponse)) } /// The endpoint to authenticate with GitHub. #[get("/user")] -async fn user( - state: &State, - cookies: &CookieJar<'_>, -) -> Json { - // cookies.add(Cookie::build("session", id.clone()).expires(None).finish()); - - // match state.db.get_user_for_session(id) { - // Ok(user) => Json(SessionResponse { - // user: Some(User::from(user)), - // error: None, - // }), - - // Err(error) => Json(SessionResponse { - // user: None, - // error: Some(error.to_string()), - // }), - // } - - match state.get_authenticated_session(cookies) { - Ok(UserSessionId {user_id, ..}) => match state.db.get_user(user_id) { - Ok(user) => Json(UserResponse { - user: Some(User::from(user)), - error: None, - }), - - Err(error) => Json(UserResponse { - user: None, - error: Some(error.to_string()), - }), - }, - - Err(error) => Json(UserResponse { - user: None, - error: Some(error.to_string()), - }), - - } +fn user(auth: SessionAuth) -> Json { + Json(UserResponse { + user: auth.user.into(), + }) } #[post("/new_token", data = "")] fn new_token( - state: &State, - cookies: &CookieJar<'_>, + db: &State, + auth: SessionAuth, request: Json, -) -> Json { - match state.get_authenticated_session(cookies) { - Ok(UserSessionId { user_id, ..}) => match state.db.new_token(user_id, request.name.clone()) { - Ok((token, plain_token)) => Json(CreateTokenResponse { - token: Some(Token { - // The only time we return the plain token is when it's created. - token: Some(plain_token), - ..token.into() - } - ), - error: None, - }), - Err(e) => Json(CreateTokenResponse { - token: None, - error: Some(e.to_string()), - }), +) -> ApiResult { + let user = auth.user; + let (token, plain_token) = db.new_token(user.id, request.name.clone())?; + Ok(Json(CreateTokenResponse { + token: Token { + // The only time we return the plain token is when it's created. + token: Some(plain_token), + ..token.into() }, - - Err(e) => Json(CreateTokenResponse { - token: None, - error: Some(e.to_string()), - }), - } + })) } #[delete("/token/")] fn delete_token( - state: &State, - cookies: &CookieJar<'_>, + db: &State, + auth: SessionAuth, id: String, -) -> Json { - match state.get_authenticated_session(cookies) { - Ok(UserSessionId { user_id, ..}) => match state.db.delete_token(user_id, id.clone()) { - Ok(_) => Json(DeleteTokenResponse { - error: None, - }), - Err(e) => Json(DeleteTokenResponse { - error: Some(e.to_string()), - }), - }, - - Err(e) => Json(DeleteTokenResponse { - error: Some(e.to_string()), - }), - } +) -> ApiResult { + let user_id = auth.user.id; + let _ = db.delete_token(user_id, id.clone())?; + Ok(Json(EmptyResponse)) } #[get("/tokens")] -fn tokens(state: &State, cookies: &CookieJar<'_>) -> Json { - match state.get_authenticated_session(cookies) { - Ok(UserSessionId { user_id, ..}) => match state.db.get_tokens_for_user(user_id) { - Ok(tokens) => Json(TokensResponse { - tokens: tokens.into_iter().map(|t| t.into()).collect(), - error: None, - }), - Err(e) => Json(TokensResponse { - tokens: vec![], - error: Some(e.to_string()), - }), - }, - - Err(e) => Json(TokensResponse { - tokens: vec![], - error: Some(e.to_string()), - }), - } -} - -/// The endpoint to publish a package version. -#[post("/publish", data = "")] -fn publish(request: Json) -> Json { - eprintln!("Received request: {:?}", request); - Json(PublishResponse { error: None }) +fn tokens(db: &State, auth: SessionAuth) -> ApiResult { + let user_id = auth.user.id; + let tokens = db.get_tokens_for_user(user_id)?; + Ok(Json(TokensResponse { + tokens: tokens.into_iter().map(|t| t.into()).collect(), + })) } /// Catches all OPTION requests in order to get the CORS related Fairing triggered. @@ -240,7 +116,7 @@ fn health() -> String { #[launch] fn rocket() -> _ { rocket::build() - .manage(ServerState::default()) + .manage(Database::default()) .attach(Cors) .mount( "/", @@ -251,7 +127,6 @@ fn rocket() -> _ { new_token, delete_token, tokens, - publish, all_options, health ], diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs new file mode 100644 index 0000000..066d9a7 --- /dev/null +++ b/src/middleware/mod.rs @@ -0,0 +1 @@ +pub mod session_auth; \ No newline at end of file diff --git a/src/middleware/session_auth.rs b/src/middleware/session_auth.rs new file mode 100644 index 0000000..350c89c --- /dev/null +++ b/src/middleware/session_auth.rs @@ -0,0 +1,57 @@ +use std::error; +use std::f64::consts::E; +use std::time::SystemTime; + +use crate::db::{string_to_uuid, Database}; +use crate::{api, models}; +use rocket::fairing::{Fairing, Info, Kind}; +use rocket::http::{Header, Status}; +use rocket::outcome::try_outcome; +use rocket::request::{FromRequest, Outcome}; +use rocket::response::status; +use rocket::{Data, Request, Response}; +use thiserror::Error; +use uuid::Uuid; + +pub const SESSION_COOKIE_NAME: &str = "session"; + +pub struct SessionAuth { + pub user: models::User, + pub session_id: Uuid, +} + +#[derive(Debug)] +pub enum SessionAuthError { + Missing, + Invalid, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for SessionAuth { + type Error = SessionAuthError; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + // TODO: use fairing? + // let db = try_outcome!(request.guard::().await); + + let db = request.rocket().state::().unwrap(); + if let Some(Some(session_id)) = request + .cookies() + .get(SESSION_COOKIE_NAME) + .map(|c| Uuid::parse_str(c.value()).ok()) + { + if let Ok(session) = db.get_session(session_id.clone()) { + if let Ok(user) = db.get_user_for_session(session_id.to_string()) { + if session.expires_at > SystemTime::now() { + return Outcome::Success(SessionAuth { + user: user.into(), + session_id, + }); + } + } + } + return Outcome::Failure((Status::Unauthorized, SessionAuthError::Invalid)); + } + return Outcome::Failure((Status::Unauthorized, SessionAuthError::Missing)); + } +} diff --git a/src/models.rs b/src/models.rs index 599c535..b1d656c 100644 --- a/src/models.rs +++ b/src/models.rs @@ -2,7 +2,7 @@ use diesel::{prelude::*, sql_types::Bytea}; use std::time::SystemTime; use uuid::Uuid; -#[derive(Queryable, Selectable, Debug)] +#[derive(Queryable, Selectable, Debug, Clone)] #[diesel(table_name = crate::schema::users)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct User { diff --git a/tests/db_integration.rs b/tests/db_integration.rs index 2b93d93..837a49e 100644 --- a/tests/db_integration.rs +++ b/tests/db_integration.rs @@ -49,17 +49,14 @@ fn test_multiple_user_sessions() { let user2 = mock_user_2(); let session1 = db.insert_user_session(&user1, 1000).expect("result is ok"); - Uuid::parse_str(session1.as_str()).expect("result is a valid UUID"); // Insert an existing user let session2 = db.insert_user_session(&user1, 1000).expect("result is ok"); - Uuid::parse_str(session2.as_str()).expect("result is a valid UUID"); // Insert another user let session3 = db.insert_user_session(&user2, 1000).expect("result is ok"); - Uuid::parse_str(session3.as_str()).expect("result is a valid UUID"); - let result = db.get_user_for_session(session1).expect("result is ok"); + let result = db.get_user_for_session(session1.id.to_string()).expect("result is ok"); assert_eq!(result.github_login, TEST_LOGIN_1); assert_eq!(result.full_name, TEST_FULL_NAME_1); assert_eq!(result.email.expect("is some"), TEST_EMAIL_1); @@ -67,10 +64,10 @@ fn test_multiple_user_sessions() { assert_eq!(result.github_url, TEST_URL_2); assert!(result.is_admin); - let result = db.get_user_for_session(session2).expect("result is ok"); + let result = db.get_user_for_session(session2.id.to_string()).expect("result is ok"); assert_eq!(result.github_login, TEST_LOGIN_1); - let result = db.get_user_for_session(session3).expect("result is ok"); + let result = db.get_user_for_session(session3.id.to_string()).expect("result is ok"); assert_eq!(result.github_login, TEST_LOGIN_2); clear_tables(&db); From fb49a28d0afaba110d24ef5d8c53fa91af64a313 Mon Sep 17 00:00:00 2001 From: Sophie Date: Wed, 1 May 2024 18:48:04 -0700 Subject: [PATCH 03/17] prettier --- .../features/tokens/components/TokenCard.tsx | 3 +-- app/src/features/tokens/hooks/useApiTokens.ts | 4 --- .../features/toolbar/hooks/useGithubAuth.ts | 27 ++++++++----------- app/src/pages/ApiTokens.tsx | 6 ++--- app/src/utils/localStorage.ts | 1 - 5 files changed, 15 insertions(+), 26 deletions(-) diff --git a/app/src/features/tokens/components/TokenCard.tsx b/app/src/features/tokens/components/TokenCard.tsx index dc947ae..86c0b7a 100644 --- a/app/src/features/tokens/components/TokenCard.tsx +++ b/app/src/features/tokens/components/TokenCard.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { Button, Card, CardHeader, IconButton } from '@mui/material'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import { Button } from '@mui/material'; import { Token } from '../hooks/useApiTokens'; import CopyableToken from './CopyableToken'; diff --git a/app/src/features/tokens/hooks/useApiTokens.ts b/app/src/features/tokens/hooks/useApiTokens.ts index 31d3ed4..803f258 100644 --- a/app/src/features/tokens/hooks/useApiTokens.ts +++ b/app/src/features/tokens/hooks/useApiTokens.ts @@ -1,12 +1,8 @@ import { useCallback, useEffect, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { SERVER_URI } from '../../../constants'; import { useGithubAuth } from '../../toolbar/hooks/useGithubAuth'; -import axios from 'axios'; import HTTP, { CreateTokenResponse, RawToken, - TokensResponse, } from '../../../utils/http'; export interface Token { diff --git a/app/src/features/toolbar/hooks/useGithubAuth.ts b/app/src/features/toolbar/hooks/useGithubAuth.ts index d9b414c..ce6da4b 100644 --- a/app/src/features/toolbar/hooks/useGithubAuth.ts +++ b/app/src/features/toolbar/hooks/useGithubAuth.ts @@ -1,14 +1,8 @@ import { useCallback, useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useLocalSession } from '../../../utils/localStorage'; -import { SERVER_URI } from '../../../constants'; import useCookie from 'react-use-cookie'; -import axios from 'axios'; -import HTTP, { - AuthenticatedUser, - LoginResponse, - UserResponse, -} from '../../../utils/http'; +import HTTP, { AuthenticatedUser } from '../../../utils/http'; export function useGithubAuth(): [ AuthenticatedUser | null, @@ -23,7 +17,7 @@ export function useGithubAuth(): [ await HTTP.post(`/logout`); setSessionId(''); setGithubUser(null); - }, [setGithubUser]); + }, [setGithubUser, setSessionId]); // If this was a redirect from Github, we have a code to log in with. useEffect(() => { @@ -41,16 +35,18 @@ export function useGithubAuth(): [ return; } - HTTP.post(`/login`, { code: githubCode }).then(({ data }) => { - clearGithubCode(); - if (data.user) { - setGithubUser(data.user); - } - }).catch(() => clearGithubCode()); + HTTP.post(`/login`, { code: githubCode }) + .then(({ data }) => { + clearGithubCode(); + if (data.user) { + setGithubUser(data.user); + } + }) + .catch(() => clearGithubCode()); }, [githubCode, setGithubUser, clearGithubCode]); useEffect(() => { - // Attempt to fetch the logged in user info if the session cookie is set and the user hasn't been fetched. + // Attempt to fetch the logged in user info if the session cookie is set and the user hasn't been fetched. if (!!githubUser || !sessionId) { return; } @@ -62,4 +58,3 @@ export function useGithubAuth(): [ return [githubUser, logout]; } - diff --git a/app/src/pages/ApiTokens.tsx b/app/src/pages/ApiTokens.tsx index 300fb00..ffd0734 100644 --- a/app/src/pages/ApiTokens.tsx +++ b/app/src/pages/ApiTokens.tsx @@ -1,5 +1,5 @@ -import React, { useEffect } from 'react'; -import { Token, useApiTokens } from '../features/tokens/hooks/useApiTokens'; +import React from 'react'; +import { useApiTokens } from '../features/tokens/hooks/useApiTokens'; import { useIsMobile } from '../features/toolbar/hooks/useIsMobile'; import { Button, TextField } from '@mui/material'; import TokenCard from '../features/tokens/components/TokenCard'; @@ -34,7 +34,7 @@ function ApiTokens() { size='large' style={{ float: 'right' }} onClick={async () => { - let { token } = await createToken(tokenName); + await createToken(tokenName); setTokenName(''); setShowTokenForm(false); }}> diff --git a/app/src/utils/localStorage.ts b/app/src/utils/localStorage.ts index dde352e..b503a3a 100644 --- a/app/src/utils/localStorage.ts +++ b/app/src/utils/localStorage.ts @@ -1,7 +1,6 @@ import { useLocalStorage } from "usehooks-ts"; const STORAGE_GH_CODE_KEY = 'gh_code'; -const STORAGE_FP_SESSION_KEY = 'fp_session'; export function useLocalSession() { function clear(key: string, handleSave: (value: T | null) => void) { From ed06468a2f8979000988442eef1dcd885b1c10be Mon Sep 17 00:00:00 2001 From: Sophie Date: Thu, 2 May 2024 10:09:44 -0700 Subject: [PATCH 04/17] cargo fmt --- src/api/api_token.rs | 1 - src/api/auth.rs | 3 +-- src/api/mod.rs | 14 +++++++++----- src/cors.rs | 10 ++++++++-- src/db/api_token.rs | 10 ++++------ src/db/mod.rs | 4 +--- src/db/user_session.rs | 2 +- src/lib.rs | 4 ++-- src/main.rs | 20 ++++++-------------- src/middleware/mod.rs | 2 +- src/middleware/session_auth.rs | 25 ++++++++++--------------- src/models.rs | 2 +- src/schema.rs | 6 +----- src/util.rs | 10 +++++++--- tests/db_integration.rs | 13 +++++++++---- 15 files changed, 61 insertions(+), 65 deletions(-) diff --git a/src/api/api_token.rs b/src/api/api_token.rs index ea35f41..b26215c 100644 --- a/src/api/api_token.rs +++ b/src/api/api_token.rs @@ -1,6 +1,5 @@ use crate::{models, util::sys_time_to_epoch}; use rocket::serde::{Deserialize, Serialize}; -use std::time::{SystemTime, Duration, UNIX_EPOCH}; #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] diff --git a/src/api/auth.rs b/src/api/auth.rs index 64a1204..66e3579 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -1,5 +1,4 @@ use rocket::serde::{Deserialize, Serialize}; -use uuid::Uuid; use crate::models; @@ -46,4 +45,4 @@ pub struct LoginResponse { #[serde(rename_all = "camelCase")] pub struct UserResponse { pub user: User, -} \ No newline at end of file +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 2ca1c1a..ba79a06 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,7 +1,12 @@ -pub mod auth; pub mod api_token; +pub mod auth; -use rocket::{http::Status, response::Responder, serde::{json::Json, Deserialize, Serialize}, Request}; +use rocket::{ + http::Status, + response::Responder, + serde::{json::Json, Serialize}, + Request, +}; use thiserror::Error; /// A wrapper for API responses that can return errors. @@ -20,11 +25,10 @@ pub enum ApiError { } impl<'r, 'o: 'r> Responder<'r, 'o> for ApiError { - fn respond_to(self, _request: &'r Request<'_>) -> rocket::response::Result<'o> { + fn respond_to(self, _request: &'r Request<'_>) -> rocket::response::Result<'o> { match self { ApiError::Database(_) => Err(Status::InternalServerError), ApiError::Github(_) => Err(Status::Unauthorized), } - } + } } - diff --git a/src/cors.rs b/src/cors.rs index fe6a213..33db800 100644 --- a/src/cors.rs +++ b/src/cors.rs @@ -17,12 +17,18 @@ impl Fairing for Cors { // Build an Access-Control-Allow-Origin * policy Response header. async fn on_response<'r>(&self, _request: &'r Request<'_>, response: &mut Response<'r>) { - response.set_header(Header::new("Access-Control-Allow-Origin", "http://localhost:3000")); // TODO: env var + response.set_header(Header::new( + "Access-Control-Allow-Origin", + "http://localhost:3000", + )); // TODO: env var response.set_header(Header::new( "Access-Control-Allow-Methods", "POST, PATCH, PUT, DELETE, HEAD, OPTIONS, GET", )); - response.set_header(Header::new("Access-Control-Allow-Headers", "*, Access-Control-Request-Headers, Content-Type")); + response.set_header(Header::new( + "Access-Control-Allow-Headers", + "*, Access-Control-Request-Headers, Content-Type", + )); response.set_header(Header::new("Access-Control-Allow-Credentials", "true")); } } diff --git a/src/db/api_token.rs b/src/db/api_token.rs index 22b1245..a477581 100644 --- a/src/db/api_token.rs +++ b/src/db/api_token.rs @@ -1,13 +1,12 @@ use super::error::DatabaseError; -use super::{api, models, schema}; +use super::{models, schema}; use super::{string_to_uuid, Database}; use diesel::prelude::*; -use diesel::upsert::excluded; -use std::time::{Duration, SystemTime}; -use uuid::Uuid; + use rand::{distributions::Uniform, rngs::OsRng, Rng}; use sha2::{Digest, Sha256}; +use uuid::Uuid; /// NEVER CHANGE THE PREFIX OF EXISTING TOKENS!!! Doing so will implicitly /// revoke all the tokens, disrupting production users. @@ -87,5 +86,4 @@ fn generate_token() -> String { TOKEN_PREFIX, generate_secure_alphanumeric_string(TOKEN_LENGTH) ) - .into() -} \ No newline at end of file +} diff --git a/src/db/mod.rs b/src/db/mod.rs index ce8fbf4..2eb4280 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,6 +1,6 @@ +mod api_token; pub mod error; mod user_session; -mod api_token; use self::error::DatabaseError; use crate::{api, models, schema}; @@ -19,7 +19,6 @@ pub struct Database { pub pool: DbPool, } - impl Default for Database { fn default() -> Self { Database::new() @@ -28,7 +27,6 @@ impl Default for Database { impl Database { pub fn new() -> Self { - // Create a connection pool let pool = Pool::builder() .build(ConnectionManager::::new(db_url())) diff --git a/src/db/user_session.rs b/src/db/user_session.rs index 9b7ddaa..70236e7 100644 --- a/src/db/user_session.rs +++ b/src/db/user_session.rs @@ -72,7 +72,7 @@ impl Database { .select(models::Session::as_returning()) .first::(connection) .map_err(|_| DatabaseError::NotFound(session_id.to_string())) - } + } /// Fetch a user from the database for a given session ID. pub fn get_user_for_session(&self, session_id: String) -> Result { diff --git a/src/lib.rs b/src/lib.rs index 5a9e5c5..b1d04aa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,8 @@ pub mod api; -pub mod middleware; +pub mod cors; pub mod db; pub mod github; +pub mod middleware; pub mod models; pub mod schema; pub mod util; -pub mod cors; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index c512768..07f0b42 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,18 +6,17 @@ extern crate rocket; use forc_pub::api::api_token::{CreateTokenRequest, CreateTokenResponse, Token, TokensResponse}; use forc_pub::api::{ auth::{LoginRequest, LoginResponse, UserResponse}, - ApiError, ApiResult, EmptyResponse, + ApiResult, EmptyResponse, }; use forc_pub::cors::Cors; -use forc_pub::db::error::DatabaseError; + use forc_pub::db::Database; use forc_pub::github::handle_login; use forc_pub::middleware::session_auth::{SessionAuth, SESSION_COOKIE_NAME}; -use forc_pub::util::sys_time_to_epoch; + use rocket::http::{Cookie, CookieJar}; -use rocket::time::OffsetDateTime; -use rocket::{serde::json::Json, State}; +use rocket::{serde::json::Json, State}; #[derive(Default)] pub struct ServerState { @@ -34,10 +33,7 @@ async fn login( let (user, expires_in) = handle_login(request.code.clone()).await?; let session = db.insert_user_session(&user, expires_in)?; let session_id = session.id.to_string(); - cookies.add( - Cookie::build(SESSION_COOKIE_NAME, session_id.clone()) - .finish(), - ); + cookies.add(Cookie::build(SESSION_COOKIE_NAME, session_id.clone()).finish()); Ok(Json(LoginResponse { user, session_id })) } @@ -75,11 +71,7 @@ fn new_token( } #[delete("/token/")] -fn delete_token( - db: &State, - auth: SessionAuth, - id: String, -) -> ApiResult { +fn delete_token(db: &State, auth: SessionAuth, id: String) -> ApiResult { let user_id = auth.user.id; let _ = db.delete_token(user_id, id.clone())?; Ok(Json(EmptyResponse)) diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs index 066d9a7..b6682b3 100644 --- a/src/middleware/mod.rs +++ b/src/middleware/mod.rs @@ -1 +1 @@ -pub mod session_auth; \ No newline at end of file +pub mod session_auth; diff --git a/src/middleware/session_auth.rs b/src/middleware/session_auth.rs index 350c89c..294ee5b 100644 --- a/src/middleware/session_auth.rs +++ b/src/middleware/session_auth.rs @@ -1,16 +1,14 @@ -use std::error; -use std::f64::consts::E; use std::time::SystemTime; -use crate::db::{string_to_uuid, Database}; -use crate::{api, models}; -use rocket::fairing::{Fairing, Info, Kind}; -use rocket::http::{Header, Status}; -use rocket::outcome::try_outcome; +use crate::db::Database; +use crate::models; + +use rocket::http::Status; + use rocket::request::{FromRequest, Outcome}; -use rocket::response::status; -use rocket::{Data, Request, Response}; -use thiserror::Error; + +use rocket::Request; + use uuid::Uuid; pub const SESSION_COOKIE_NAME: &str = "session"; @@ -40,13 +38,10 @@ impl<'r> FromRequest<'r> for SessionAuth { .get(SESSION_COOKIE_NAME) .map(|c| Uuid::parse_str(c.value()).ok()) { - if let Ok(session) = db.get_session(session_id.clone()) { + if let Ok(session) = db.get_session(session_id) { if let Ok(user) = db.get_user_for_session(session_id.to_string()) { if session.expires_at > SystemTime::now() { - return Outcome::Success(SessionAuth { - user: user.into(), - session_id, - }); + return Outcome::Success(SessionAuth { user, session_id }); } } } diff --git a/src/models.rs b/src/models.rs index b1d656c..f821e51 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,4 +1,4 @@ -use diesel::{prelude::*, sql_types::Bytea}; +use diesel::prelude::*; use std::time::SystemTime; use uuid::Uuid; diff --git a/src/schema.rs b/src/schema.rs index 3b28d84..6152c56 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -36,8 +36,4 @@ diesel::table! { diesel::joinable!(api_tokens -> users (user_id)); diesel::joinable!(sessions -> users (user_id)); -diesel::allow_tables_to_appear_in_same_query!( - api_tokens, - sessions, - users, -); +diesel::allow_tables_to_appear_in_same_query!(api_tokens, sessions, users,); diff --git a/src/util.rs b/src/util.rs index d545f74..549d5b7 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,5 +1,9 @@ -use std::time::{Duration, SystemTime}; +use std::time::SystemTime; pub fn sys_time_to_epoch(sys_time: SystemTime) -> u64 { - sys_time.duration_since(SystemTime::UNIX_EPOCH).expect("convert time to epoch").as_secs() * 1000 -} \ No newline at end of file + sys_time + .duration_since(SystemTime::UNIX_EPOCH) + .expect("convert time to epoch") + .as_secs() + * 1000 +} diff --git a/tests/db_integration.rs b/tests/db_integration.rs index 837a49e..4ad9a99 100644 --- a/tests/db_integration.rs +++ b/tests/db_integration.rs @@ -1,7 +1,6 @@ use diesel::RunQueryDsl as _; use forc_pub::api; use forc_pub::db::Database; -use uuid::Uuid; /// Note: Integration tests for the database module assume that the database is running and that the DATABASE_URL environment variable is set. /// This should be done by running `./scripts/start_local_db.sh` before running the tests. @@ -56,7 +55,9 @@ fn test_multiple_user_sessions() { // Insert another user let session3 = db.insert_user_session(&user2, 1000).expect("result is ok"); - let result = db.get_user_for_session(session1.id.to_string()).expect("result is ok"); + let result = db + .get_user_for_session(session1.id.to_string()) + .expect("result is ok"); assert_eq!(result.github_login, TEST_LOGIN_1); assert_eq!(result.full_name, TEST_FULL_NAME_1); assert_eq!(result.email.expect("is some"), TEST_EMAIL_1); @@ -64,10 +65,14 @@ fn test_multiple_user_sessions() { assert_eq!(result.github_url, TEST_URL_2); assert!(result.is_admin); - let result = db.get_user_for_session(session2.id.to_string()).expect("result is ok"); + let result = db + .get_user_for_session(session2.id.to_string()) + .expect("result is ok"); assert_eq!(result.github_login, TEST_LOGIN_1); - let result = db.get_user_for_session(session3.id.to_string()).expect("result is ok"); + let result = db + .get_user_for_session(session3.id.to_string()) + .expect("result is ok"); assert_eq!(result.github_login, TEST_LOGIN_2); clear_tables(&db); From 3db123bbbd787f1c06f47c87f58c1decff347560 Mon Sep 17 00:00:00 2001 From: Sophie Date: Thu, 2 May 2024 11:11:42 -0700 Subject: [PATCH 05/17] Fix cors request origin --- .env | 3 ++ .../features/toolbar/hooks/useGithubAuth.ts | 10 +++-- src/cors.rs | 37 +++++++++++++++---- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/.env b/.env index fd719f0..7981a03 100644 --- a/.env +++ b/.env @@ -5,5 +5,8 @@ POSTGRES_URI="localhost" POSTGRES_PORT="5432" POSTGRES_DB_NAME="forc_pub" +# Local env +CORS_HTTP_ORIGIN = "http://localhost:3000" + # Diesel CLI env DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_URI}/${POSTGRES_DB_NAME}" \ No newline at end of file diff --git a/app/src/features/toolbar/hooks/useGithubAuth.ts b/app/src/features/toolbar/hooks/useGithubAuth.ts index ce6da4b..0bdcc82 100644 --- a/app/src/features/toolbar/hooks/useGithubAuth.ts +++ b/app/src/features/toolbar/hooks/useGithubAuth.ts @@ -51,10 +51,12 @@ export function useGithubAuth(): [ return; } - HTTP.get(`/user`).then(({ data }) => { - setGithubUser(data.user); - }); - }, [githubUser, setGithubUser, sessionId]); + HTTP.get(`/user`) + .then(({ data }) => { + setGithubUser(data.user); + }) + .catch(() => setSessionId('')); + }, [githubUser, setGithubUser, setSessionId, sessionId]); return [githubUser, logout]; } diff --git a/src/cors.rs b/src/cors.rs index 33db800..e672b11 100644 --- a/src/cors.rs +++ b/src/cors.rs @@ -1,10 +1,34 @@ +use std::env; + +use dotenvy::dotenv; +use regex::Regex; use rocket::fairing::{Fairing, Info, Kind}; -use rocket::http::Header; +use rocket::http::{Header, HeaderMap}; use rocket::{Request, Response}; // Build an open cors module so this server can be used accross many locations on the web. pub struct Cors; +fn get_allowed_origin(headers: &HeaderMap<'_>) -> Option { + dotenv().ok(); + + if let Some(req_origin) = headers.get_one("Origin") { + // If the environment variable CORS_HTTP_ORIGIN is set, only allow that origin. + if let Some(env_origin) = env::var("CORS_HTTP_ORIGIN").ok() { + if req_origin == env_origin.as_str() { + return Some(env_origin); + } + } + + // If the request origin matches the allowed regex, allow only the request origin. + let re = Regex::new(r"^https://forc(((.pub)|((-pub)(-git-[a-zA-Z0-9-]+-fuel-labs)?\.vercel\.app)))$").unwrap(); + if re.is_match(req_origin) { + return Some(req_origin.to_string()); + } + } + None +} + // Build Cors Fairing. #[rocket::async_trait] impl Fairing for Cors { @@ -15,12 +39,11 @@ impl Fairing for Cors { } } - // Build an Access-Control-Allow-Origin * policy Response header. - async fn on_response<'r>(&self, _request: &'r Request<'_>, response: &mut Response<'r>) { - response.set_header(Header::new( - "Access-Control-Allow-Origin", - "http://localhost:3000", - )); // TODO: env var + // Build an Access-Control-Allow-Origin policy Response header. + async fn on_response<'r>(&self, request: &'r Request<'_>, response: &mut Response<'r>) { + if let Some(origin) = get_allowed_origin(request.headers()) { + response.set_header(Header::new("Access-Control-Allow-Origin", origin)); + } response.set_header(Header::new( "Access-Control-Allow-Methods", "POST, PATCH, PUT, DELETE, HEAD, OPTIONS, GET", From d13dc3e146dd4d54ad7a31a75ac859be04115d88 Mon Sep 17 00:00:00 2001 From: Sophie Date: Thu, 2 May 2024 11:12:36 -0700 Subject: [PATCH 06/17] cargo fmt --- src/cors.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cors.rs b/src/cors.rs index e672b11..e9432e7 100644 --- a/src/cors.rs +++ b/src/cors.rs @@ -21,7 +21,10 @@ fn get_allowed_origin(headers: &HeaderMap<'_>) -> Option { } // If the request origin matches the allowed regex, allow only the request origin. - let re = Regex::new(r"^https://forc(((.pub)|((-pub)(-git-[a-zA-Z0-9-]+-fuel-labs)?\.vercel\.app)))$").unwrap(); + let re = Regex::new( + r"^https://forc(((.pub)|((-pub)(-git-[a-zA-Z0-9-]+-fuel-labs)?\.vercel\.app)))$", + ) + .unwrap(); if re.is_match(req_origin) { return Some(req_origin.to_string()); } From dc7d19d4be07b1fe793ce6ff33776d9740d69686 Mon Sep 17 00:00:00 2001 From: Sophie Date: Thu, 2 May 2024 11:15:18 -0700 Subject: [PATCH 07/17] clippy --- src/cors.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cors.rs b/src/cors.rs index e9432e7..21fa235 100644 --- a/src/cors.rs +++ b/src/cors.rs @@ -14,7 +14,7 @@ fn get_allowed_origin(headers: &HeaderMap<'_>) -> Option { if let Some(req_origin) = headers.get_one("Origin") { // If the environment variable CORS_HTTP_ORIGIN is set, only allow that origin. - if let Some(env_origin) = env::var("CORS_HTTP_ORIGIN").ok() { + if let Ok(env_origin) = env::var("CORS_HTTP_ORIGIN") { if req_origin == env_origin.as_str() { return Some(env_origin); } From 7597850fcfcd368bcd6859b5651d78da247ebfc4 Mon Sep 17 00:00:00 2001 From: Sophie Date: Thu, 2 May 2024 11:44:23 -0700 Subject: [PATCH 08/17] Reuse db connection in request --- src/db/api_token.rs | 21 ++++++++------------- src/db/mod.rs | 11 +++++++++-- src/db/user_session.rs | 32 +++++++++++++------------------- src/main.rs | 10 +++++----- src/middleware/session_auth.rs | 10 ++++++++-- tests/db_integration.rs | 16 +++++++++------- 6 files changed, 52 insertions(+), 48 deletions(-) diff --git a/src/db/api_token.rs b/src/db/api_token.rs index a477581..1cf54a2 100644 --- a/src/db/api_token.rs +++ b/src/db/api_token.rs @@ -1,5 +1,5 @@ use super::error::DatabaseError; -use super::{models, schema}; +use super::{models, schema, DbConn}; use super::{string_to_uuid, Database}; use diesel::prelude::*; @@ -13,15 +13,13 @@ use uuid::Uuid; const TOKEN_PREFIX: &str = "pub_"; const TOKEN_LENGTH: usize = 32; -impl Database { +impl DbConn { /// Creates an API token for the user and returns the token. pub fn new_token( - &self, + &mut self, user_id: Uuid, friendly_name: String, ) -> Result<(models::Token, String), DatabaseError> { - let connection = &mut self.connection(); - let plain_token = generate_token(); let token = Sha256::digest(plain_token.as_bytes()).as_slice().to_vec(); @@ -36,16 +34,14 @@ impl Database { let saved_token = diesel::insert_into(schema::api_tokens::table) .values(&new_token) .returning(models::Token::as_returning()) - .get_result(connection) + .get_result(self.inner()) .map_err(|_| DatabaseError::InsertTokenFailed(user_id.to_string()))?; Ok((saved_token, plain_token)) } /// Deletes an API token for the user. - pub fn delete_token(&self, user_id: Uuid, token_id: String) -> Result<(), DatabaseError> { - let connection = &mut self.connection(); - + pub fn delete_token(&mut self, user_id: Uuid, token_id: String) -> Result<(), DatabaseError> { let token_uuid = string_to_uuid(token_id.clone())?; diesel::delete( @@ -53,19 +49,18 @@ impl Database { .filter(schema::api_tokens::id.eq(token_uuid)) .filter(schema::api_tokens::user_id.eq(user_id)), ) - .execute(connection) + .execute(self.inner()) .map_err(|_| DatabaseError::NotFound(token_id))?; Ok(()) } /// Fetch all tokens for the given user ID. - pub fn get_tokens_for_user(&self, user_id: Uuid) -> Result, DatabaseError> { - let connection = &mut self.connection(); + pub fn get_tokens_for_user(&mut self, user_id: Uuid) -> Result, DatabaseError> { schema::api_tokens::table .filter(schema::api_tokens::user_id.eq(user_id)) .select(models::Token::as_returning()) - .load(connection) + .load(self.inner()) .map_err(|_| DatabaseError::NotFound(user_id.to_string())) } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 2eb4280..fddabf2 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -25,6 +25,13 @@ impl Default for Database { } } +pub struct DbConn(DbConnection); +impl DbConn { + pub fn inner(&mut self) -> &mut PgConnection { + &mut self.0 + } +} + impl Database { pub fn new() -> Self { // Create a connection pool @@ -44,8 +51,8 @@ impl Database { } /// Get a connection from the pool. - pub fn connection(&self) -> DbConnection { - self.pool.get().expect("db connection") + pub fn conn(&self) -> DbConn { + DbConn(self.pool.get().expect("db connection")) } } diff --git a/src/db/user_session.rs b/src/db/user_session.rs index 70236e7..7344ede 100644 --- a/src/db/user_session.rs +++ b/src/db/user_session.rs @@ -1,22 +1,20 @@ use super::error::DatabaseError; -use super::{api, models, schema}; +use super::{api, models, schema, DbConn}; use super::{string_to_uuid, Database}; use diesel::prelude::*; use diesel::upsert::excluded; use std::time::{Duration, SystemTime}; use uuid::Uuid; -impl Database { +impl DbConn { /// Insert a user session into the database and return the session ID. /// If the user doesn't exist, insert the user as well. /// If the user does exist, update the user's full name and avatar URL if they have changed. pub fn insert_user_session( - &self, + &mut self, user: &api::auth::User, expires_in: u32, ) -> Result { - let connection = &mut self.connection(); - // Insert or update a user let new_user = models::NewUser { full_name: user.full_name.clone(), @@ -36,7 +34,7 @@ impl Database { schema::users::full_name.eq(excluded(schema::users::full_name)), schema::users::avatar_url.eq(excluded(schema::users::avatar_url)), )) - .get_result(connection) + .get_result(self.inner()) .map_err(|_| DatabaseError::InsertUserFailed(user.github_login.clone()))?; let new_session = models::NewSession { @@ -48,49 +46,45 @@ impl Database { let saved_session = diesel::insert_into(schema::sessions::table) .values(&new_session) .returning(models::Session::as_returning()) - .get_result(connection) + .get_result(self.inner()) .map_err(|_| DatabaseError::InsertSessionFailed(user.github_login.clone()))?; Ok(saved_session) } /// Fetch a user given the user ID. - pub fn get_user(&self, user_id: Uuid) -> Result { - let connection = &mut self.connection(); + pub fn get_user(&mut self, user_id: Uuid) -> Result { schema::users::table .filter(schema::users::id.eq(user_id)) .select(models::User::as_returning()) - .first::(connection) + .first::(self.inner()) .map_err(|_| DatabaseError::NotFound(user_id.to_string())) } /// Fetch a user given the user ID. - pub fn get_session(&self, session_id: Uuid) -> Result { - let connection = &mut self.connection(); + pub fn get_session(&mut self, session_id: Uuid) -> Result { schema::sessions::table .filter(schema::sessions::id.eq(session_id)) .select(models::Session::as_returning()) - .first::(connection) + .first::(self.inner()) .map_err(|_| DatabaseError::NotFound(session_id.to_string())) } /// Fetch a user from the database for a given session ID. - pub fn get_user_for_session(&self, session_id: String) -> Result { + pub fn get_user_for_session(&mut self, session_id: String) -> Result { let session_uuid = string_to_uuid(session_id.clone())?; - let connection = &mut self.connection(); schema::sessions::table .inner_join(schema::users::table) .filter(schema::sessions::id.eq(session_uuid)) .select(models::User::as_returning()) - .first::(connection) + .first::(self.inner()) .map_err(|_| DatabaseError::NotFound(session_id)) } /// Delete a session given its ID. - pub fn delete_session(&self, session_id: Uuid) -> Result<(), DatabaseError> { - let connection = &mut self.connection(); + pub fn delete_session(&mut self, session_id: Uuid) -> Result<(), DatabaseError> { diesel::delete(schema::sessions::table.filter(schema::sessions::id.eq(session_id))) - .execute(connection) + .execute(self.inner()) .map_err(|_| DatabaseError::NotFound(session_id.to_string()))?; Ok(()) } diff --git a/src/main.rs b/src/main.rs index 07f0b42..27496d0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,7 +31,7 @@ async fn login( request: Json, ) -> ApiResult { let (user, expires_in) = handle_login(request.code.clone()).await?; - let session = db.insert_user_session(&user, expires_in)?; + let session = db.conn().insert_user_session(&user, expires_in)?; let session_id = session.id.to_string(); cookies.add(Cookie::build(SESSION_COOKIE_NAME, session_id.clone()).finish()); Ok(Json(LoginResponse { user, session_id })) @@ -41,7 +41,7 @@ async fn login( #[post("/logout")] async fn logout(db: &State, auth: SessionAuth) -> ApiResult { let session_id = auth.session_id; - let _ = db.delete_session(session_id)?; + let _ = db.conn().delete_session(session_id)?; Ok(Json(EmptyResponse)) } @@ -60,7 +60,7 @@ fn new_token( request: Json, ) -> ApiResult { let user = auth.user; - let (token, plain_token) = db.new_token(user.id, request.name.clone())?; + let (token, plain_token) = db.conn().new_token(user.id, request.name.clone())?; Ok(Json(CreateTokenResponse { token: Token { // The only time we return the plain token is when it's created. @@ -73,14 +73,14 @@ fn new_token( #[delete("/token/")] fn delete_token(db: &State, auth: SessionAuth, id: String) -> ApiResult { let user_id = auth.user.id; - let _ = db.delete_token(user_id, id.clone())?; + let _ = db.conn().delete_token(user_id, id.clone())?; Ok(Json(EmptyResponse)) } #[get("/tokens")] fn tokens(db: &State, auth: SessionAuth) -> ApiResult { let user_id = auth.user.id; - let tokens = db.get_tokens_for_user(user_id)?; + let tokens = db.conn().get_tokens_for_user(user_id)?; Ok(Json(TokensResponse { tokens: tokens.into_iter().map(|t| t.into()).collect(), })) diff --git a/src/middleware/session_auth.rs b/src/middleware/session_auth.rs index 294ee5b..2e2045d 100644 --- a/src/middleware/session_auth.rs +++ b/src/middleware/session_auth.rs @@ -22,6 +22,7 @@ pub struct SessionAuth { pub enum SessionAuthError { Missing, Invalid, + DatabaseConnection, } #[rocket::async_trait] @@ -29,10 +30,15 @@ impl<'r> FromRequest<'r> for SessionAuth { type Error = SessionAuthError; async fn from_request(request: &'r Request<'_>) -> Outcome { - // TODO: use fairing? + // TODO: use fairing for db connection? // let db = try_outcome!(request.guard::().await); - let db = request.rocket().state::().unwrap(); + let mut db = match request.rocket().state::() { + Some(db) => { + db.conn() + }, + None => return Outcome::Failure((Status::InternalServerError, SessionAuthError::DatabaseConnection)), + }; if let Some(Some(session_id)) = request .cookies() .get(SESSION_COOKIE_NAME) diff --git a/tests/db_integration.rs b/tests/db_integration.rs index 4ad9a99..072ad4d 100644 --- a/tests/db_integration.rs +++ b/tests/db_integration.rs @@ -1,6 +1,6 @@ use diesel::RunQueryDsl as _; use forc_pub::api; -use forc_pub::db::Database; +use forc_pub::db::{Database, DbConn}; /// Note: Integration tests for the database module assume that the database is running and that the DATABASE_URL environment variable is set. /// This should be done by running `./scripts/start_local_db.sh` before running the tests. @@ -12,13 +12,15 @@ const TEST_URL_1: &str = "url1.url"; const TEST_URL_2: &str = "url2.url"; const TEST_LOGIN_2: &str = "foobar"; -fn clear_tables(db: &Database) { - let connection = &mut db.connection(); +fn clear_tables(db: &mut DbConn) { + diesel::delete(forc_pub::schema::api_tokens::table) + .execute(db.inner()) + .expect("clear api_tokens table"); diesel::delete(forc_pub::schema::sessions::table) - .execute(connection) + .execute(db.inner()) .expect("clear sessions table"); diesel::delete(forc_pub::schema::users::table) - .execute(connection) + .execute(db.inner()) .expect("clear users table"); } @@ -42,7 +44,7 @@ fn mock_user_2() -> api::auth::User { #[test] fn test_multiple_user_sessions() { - let db = Database::default(); + let db = &mut Database::default().conn(); let user1 = mock_user_1(); let user2 = mock_user_2(); @@ -75,5 +77,5 @@ fn test_multiple_user_sessions() { .expect("result is ok"); assert_eq!(result.github_login, TEST_LOGIN_2); - clear_tables(&db); + clear_tables(db); } From d1c1dab3ffa46e7bc3a55523c71597319383b92b Mon Sep 17 00:00:00 2001 From: Sophie Date: Thu, 2 May 2024 12:15:21 -0700 Subject: [PATCH 09/17] add api_tokens test --- Cargo.lock | 53 +++++++++++++++++++++++++++++ Cargo.toml | 1 + src/db/api_token.rs | 61 ++++++++++++++++++++-------------- src/db/user_session.rs | 11 +++--- src/lib.rs | 1 - src/main.rs | 4 +-- src/{ => middleware}/cors.rs | 0 src/middleware/mod.rs | 1 + src/middleware/session_auth.rs | 20 +++++------ tests/db_integration.rs | 52 +++++++++++++++++++++++------ 10 files changed, 150 insertions(+), 54 deletions(-) rename src/{ => middleware}/cors.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index e261f22..fb82881 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -444,6 +444,7 @@ dependencies = [ "rocket", "serde", "serde_json", + "serial_test", "sha2", "thiserror", "tokio", @@ -482,6 +483,7 @@ checksum = "38390104763dc37a5145a53c29c63c1290b5d316d6086ec32c293f6736051bb0" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -504,6 +506,17 @@ version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04909a7a7e4633ae6c4a9ab280aeb86da1236243a77b694a49eacd659a4bd3ac" +[[package]] +name = "futures-executor" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7acc85df6714c176ab5edf386123fafe217be88c0840ec11f199441134a074e2" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.25" @@ -1582,6 +1595,15 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b4b9743ed687d4b4bcedf9ff5eaa7398495ae14e61cba0a295704edbc7decde" +[[package]] +name = "scc" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec96560eea317a9cc4e0bb1f6a2c93c09a19b8c4fc5cb3fcc0ec1c094cd783e2" +dependencies = [ + "sdd", +] + [[package]] name = "schannel" version = "0.1.23" @@ -1622,6 +1644,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sdd" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84345e4c9bd703274a082fb80caaa99b7612be48dfaa1dd9266577ec412309d" + [[package]] name = "security-framework" version = "2.10.0" @@ -1697,6 +1725,31 @@ dependencies = [ "serde", ] +[[package]] +name = "serial_test" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4b487fe2acf240a021cf57c6b2b4903b1e78ca0ecd862a71b71d2a51fed77d" +dependencies = [ + "futures", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82fe9db325bcef1fbcde82e078a5cc4efdf787e96b3b9cf45b50b529f2083d67" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.57", +] + [[package]] name = "sha2" version = "0.10.8" diff --git a/Cargo.toml b/Cargo.toml index 6927d76..d128183 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,4 @@ uuid = "1.8.0" diesel_migrations = "2.1.0" rand = "0.8.5" sha2 = "0.10.8" +serial_test = "3.1.1" diff --git a/src/db/api_token.rs b/src/db/api_token.rs index 1cf54a2..f1696b1 100644 --- a/src/db/api_token.rs +++ b/src/db/api_token.rs @@ -1,11 +1,9 @@ use super::error::DatabaseError; +use super::string_to_uuid; use super::{models, schema, DbConn}; -use super::{string_to_uuid, Database}; use diesel::prelude::*; - use rand::{distributions::Uniform, rngs::OsRng, Rng}; use sha2::{Digest, Sha256}; - use uuid::Uuid; /// NEVER CHANGE THE PREFIX OF EXISTING TOKENS!!! Doing so will implicitly @@ -13,15 +11,43 @@ use uuid::Uuid; const TOKEN_PREFIX: &str = "pub_"; const TOKEN_LENGTH: usize = 32; +/// A plain-text API token. +#[derive(Debug)] +pub struct PlainToken(String); + +impl PlainToken { + pub fn hash(&self) -> Vec { + Sha256::digest(self.0.as_bytes()).as_slice().to_vec() + } + + pub fn new() -> Self { + const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + + let secure_alphanumeric_string = OsRng + .sample_iter(Uniform::from(0..CHARS.len())) + .map(|idx| CHARS[idx] as char) + .take(TOKEN_LENGTH) + .collect::(); + + Self(format!("{}{}", TOKEN_PREFIX, secure_alphanumeric_string)) + } +} + +impl Into for PlainToken { + fn into(self) -> String { + self.0 + } +} + impl DbConn { /// Creates an API token for the user and returns the token. pub fn new_token( &mut self, user_id: Uuid, friendly_name: String, - ) -> Result<(models::Token, String), DatabaseError> { - let plain_token = generate_token(); - let token = Sha256::digest(plain_token.as_bytes()).as_slice().to_vec(); + ) -> Result<(models::Token, PlainToken), DatabaseError> { + let plain_token = PlainToken::new(); + let token = plain_token.hash(); let new_token = models::NewToken { user_id, @@ -56,7 +82,10 @@ impl DbConn { } /// Fetch all tokens for the given user ID. - pub fn get_tokens_for_user(&mut self, user_id: Uuid) -> Result, DatabaseError> { + pub fn get_tokens_for_user( + &mut self, + user_id: Uuid, + ) -> Result, DatabaseError> { schema::api_tokens::table .filter(schema::api_tokens::user_id.eq(user_id)) .select(models::Token::as_returning()) @@ -64,21 +93,3 @@ impl DbConn { .map_err(|_| DatabaseError::NotFound(user_id.to_string())) } } - -fn generate_secure_alphanumeric_string(len: usize) -> String { - const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - - OsRng - .sample_iter(Uniform::from(0..CHARS.len())) - .map(|idx| CHARS[idx] as char) - .take(len) - .collect() -} - -fn generate_token() -> String { - format!( - "{}{}", - TOKEN_PREFIX, - generate_secure_alphanumeric_string(TOKEN_LENGTH) - ) -} diff --git a/src/db/user_session.rs b/src/db/user_session.rs index 7344ede..318eec9 100644 --- a/src/db/user_session.rs +++ b/src/db/user_session.rs @@ -1,6 +1,5 @@ use super::error::DatabaseError; use super::{api, models, schema, DbConn}; -use super::{string_to_uuid, Database}; use diesel::prelude::*; use diesel::upsert::excluded; use std::time::{Duration, SystemTime}; @@ -71,14 +70,16 @@ impl DbConn { } /// Fetch a user from the database for a given session ID. - pub fn get_user_for_session(&mut self, session_id: String) -> Result { - let session_uuid = string_to_uuid(session_id.clone())?; + pub fn get_user_for_session( + &mut self, + session_id: Uuid, + ) -> Result { schema::sessions::table .inner_join(schema::users::table) - .filter(schema::sessions::id.eq(session_uuid)) + .filter(schema::sessions::id.eq(session_id)) .select(models::User::as_returning()) .first::(self.inner()) - .map_err(|_| DatabaseError::NotFound(session_id)) + .map_err(|_| DatabaseError::NotFound(session_id.to_string())) } /// Delete a session given its ID. diff --git a/src/lib.rs b/src/lib.rs index b1d04aa..c633861 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,4 @@ pub mod api; -pub mod cors; pub mod db; pub mod github; pub mod middleware; diff --git a/src/main.rs b/src/main.rs index 27496d0..4478824 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use forc_pub::api::{ auth::{LoginRequest, LoginResponse, UserResponse}, ApiResult, EmptyResponse, }; -use forc_pub::cors::Cors; +use forc_pub::middleware::cors::Cors; use forc_pub::db::Database; use forc_pub::github::handle_login; @@ -64,7 +64,7 @@ fn new_token( Ok(Json(CreateTokenResponse { token: Token { // The only time we return the plain token is when it's created. - token: Some(plain_token), + token: Some(plain_token.into()), ..token.into() }, })) diff --git a/src/cors.rs b/src/middleware/cors.rs similarity index 100% rename from src/cors.rs rename to src/middleware/cors.rs diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs index b6682b3..c67a8ad 100644 --- a/src/middleware/mod.rs +++ b/src/middleware/mod.rs @@ -1 +1,2 @@ +pub mod cors; pub mod session_auth; diff --git a/src/middleware/session_auth.rs b/src/middleware/session_auth.rs index 2e2045d..4f4f0ee 100644 --- a/src/middleware/session_auth.rs +++ b/src/middleware/session_auth.rs @@ -1,14 +1,9 @@ -use std::time::SystemTime; - use crate::db::Database; use crate::models; - use rocket::http::Status; - use rocket::request::{FromRequest, Outcome}; - use rocket::Request; - +use std::time::SystemTime; use uuid::Uuid; pub const SESSION_COOKIE_NAME: &str = "session"; @@ -34,10 +29,13 @@ impl<'r> FromRequest<'r> for SessionAuth { // let db = try_outcome!(request.guard::().await); let mut db = match request.rocket().state::() { - Some(db) => { - db.conn() - }, - None => return Outcome::Failure((Status::InternalServerError, SessionAuthError::DatabaseConnection)), + Some(db) => db.conn(), + None => { + return Outcome::Failure(( + Status::InternalServerError, + SessionAuthError::DatabaseConnection, + )) + } }; if let Some(Some(session_id)) = request .cookies() @@ -45,7 +43,7 @@ impl<'r> FromRequest<'r> for SessionAuth { .map(|c| Uuid::parse_str(c.value()).ok()) { if let Ok(session) = db.get_session(session_id) { - if let Ok(user) = db.get_user_for_session(session_id.to_string()) { + if let Ok(user) = db.get_user_for_session(session_id) { if session.expires_at > SystemTime::now() { return Outcome::Success(SessionAuth { user, session_id }); } diff --git a/tests/db_integration.rs b/tests/db_integration.rs index 072ad4d..7e47b6f 100644 --- a/tests/db_integration.rs +++ b/tests/db_integration.rs @@ -1,6 +1,7 @@ use diesel::RunQueryDsl as _; use forc_pub::api; use forc_pub::db::{Database, DbConn}; +use serial_test::serial; /// Note: Integration tests for the database module assume that the database is running and that the DATABASE_URL environment variable is set. /// This should be done by running `./scripts/start_local_db.sh` before running the tests. @@ -11,6 +12,8 @@ const TEST_EMAIL_1: &str = "alice@bob.com"; const TEST_URL_1: &str = "url1.url"; const TEST_URL_2: &str = "url2.url"; const TEST_LOGIN_2: &str = "foobar"; +const TEST_TOKEN_NAME_1: &str = "test token 1"; +const TEST_TOKEN_NAME_2: &str = "test token 2"; fn clear_tables(db: &mut DbConn) { diesel::delete(forc_pub::schema::api_tokens::table) @@ -42,8 +45,9 @@ fn mock_user_2() -> api::auth::User { } } +#[serial] #[test] -fn test_multiple_user_sessions() { +fn test_user_sessions() { let db = &mut Database::default().conn(); let user1 = mock_user_1(); @@ -57,9 +61,7 @@ fn test_multiple_user_sessions() { // Insert another user let session3 = db.insert_user_session(&user2, 1000).expect("result is ok"); - let result = db - .get_user_for_session(session1.id.to_string()) - .expect("result is ok"); + let result = db.get_user_for_session(session1.id).expect("result is ok"); assert_eq!(result.github_login, TEST_LOGIN_1); assert_eq!(result.full_name, TEST_FULL_NAME_1); assert_eq!(result.email.expect("is some"), TEST_EMAIL_1); @@ -67,15 +69,45 @@ fn test_multiple_user_sessions() { assert_eq!(result.github_url, TEST_URL_2); assert!(result.is_admin); - let result = db - .get_user_for_session(session2.id.to_string()) - .expect("result is ok"); + let result = db.get_user_for_session(session2.id).expect("result is ok"); assert_eq!(result.github_login, TEST_LOGIN_1); - let result = db - .get_user_for_session(session3.id.to_string()) - .expect("result is ok"); + let result = db.get_user_for_session(session3.id).expect("result is ok"); assert_eq!(result.github_login, TEST_LOGIN_2); clear_tables(db); } + +#[test] +#[serial] +fn test_api_tokens() { + let db = &mut Database::default().conn(); + + let session = db.insert_user_session(&mock_user_1(), 1000).expect("result is ok"); + let user = db.get_user_for_session(session.id).expect("result is ok"); + + // Insert tokens + let (token1, plain_token1) = db.new_token(user.id, TEST_TOKEN_NAME_1.into()).expect("result is ok"); + let (token2, plain_token2) = db.new_token(user.id, TEST_TOKEN_NAME_2.into()).expect("result is ok"); + + assert_eq!(token1.friendly_name, TEST_TOKEN_NAME_1); + assert_eq!(token1.expires_at, None); + assert_eq!(token2.friendly_name, TEST_TOKEN_NAME_2); + assert_eq!(token2.expires_at, None); + + // Get tokens + let tokens = db.get_tokens_for_user(user.id).expect("result is ok"); + assert_eq!(tokens.len(), 2); + + // Delete tokens + let _ = db.delete_token(user.id, token1.id.into()).expect("result is ok"); + let tokens = db.get_tokens_for_user(user.id).expect("result is ok"); + assert_eq!(tokens.len(), 1); + let _ = db.delete_token(user.id, token2.id.into()).expect("result is ok"); + let tokens = db.get_tokens_for_user(user.id).expect("result is ok"); + assert_eq!(tokens.len(), 0); + + // TODO: test validating a plain token + + clear_tables(db); +} \ No newline at end of file From 45384af6ac7c4a6133ae2c9a0511dd19604baed6 Mon Sep 17 00:00:00 2001 From: Sophie Date: Thu, 2 May 2024 12:50:22 -0700 Subject: [PATCH 10/17] Test token hashing --- app/src/pages/ApiTokens.tsx | 2 ++ src/api/api_token.rs | 4 +-- src/api/mod.rs | 1 + src/api/publish.rs | 8 ++++++ src/db/api_token.rs | 41 ++++++++++++++++++++++------ src/db/mod.rs | 2 +- src/main.rs | 18 ++++++++++--- src/middleware/mod.rs | 1 + src/middleware/token_auth.rs | 52 ++++++++++++++++++++++++++++++++++++ src/models.rs | 6 ++--- tests/db_integration.rs | 28 +++++++++++++------ 11 files changed, 137 insertions(+), 26 deletions(-) create mode 100644 src/api/publish.rs create mode 100644 src/middleware/token_auth.rs diff --git a/app/src/pages/ApiTokens.tsx b/app/src/pages/ApiTokens.tsx index ffd0734..488ec43 100644 --- a/app/src/pages/ApiTokens.tsx +++ b/app/src/pages/ApiTokens.tsx @@ -3,6 +3,8 @@ import { useApiTokens } from '../features/tokens/hooks/useApiTokens'; import { useIsMobile } from '../features/toolbar/hooks/useIsMobile'; import { Button, TextField } from '@mui/material'; import TokenCard from '../features/tokens/components/TokenCard'; +import { useGithubAuth } from '../features/toolbar/hooks/useGithubAuth'; +import { useNavigate } from 'react-router-dom'; function ApiTokens() { const [tokenName, setTokenName] = React.useState(''); diff --git a/src/api/api_token.rs b/src/api/api_token.rs index b26215c..3ed5e0f 100644 --- a/src/api/api_token.rs +++ b/src/api/api_token.rs @@ -10,8 +10,8 @@ pub struct Token { pub token: Option, } -impl From for Token { - fn from(token: models::Token) -> Self { +impl From for Token { + fn from(token: models::ApiToken) -> Self { Token { id: token.id.to_string(), name: token.friendly_name, diff --git a/src/api/mod.rs b/src/api/mod.rs index ba79a06..cae4c6a 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,5 +1,6 @@ pub mod api_token; pub mod auth; +pub mod publish; use rocket::{ http::Status, diff --git a/src/api/publish.rs b/src/api/publish.rs new file mode 100644 index 0000000..336b585 --- /dev/null +++ b/src/api/publish.rs @@ -0,0 +1,8 @@ +use rocket::serde::Deserialize; + +/// The publish request. +#[derive(Deserialize, Debug)] +pub struct PublishRequest { + pub name: String, + pub version: String, +} diff --git a/src/db/api_token.rs b/src/db/api_token.rs index f1696b1..30b04e0 100644 --- a/src/db/api_token.rs +++ b/src/db/api_token.rs @@ -15,6 +15,12 @@ const TOKEN_LENGTH: usize = 32; #[derive(Debug)] pub struct PlainToken(String); +impl Default for PlainToken { + fn default() -> Self { + Self::new() + } +} + impl PlainToken { pub fn hash(&self) -> Vec { Sha256::digest(self.0.as_bytes()).as_slice().to_vec() @@ -33,9 +39,15 @@ impl PlainToken { } } -impl Into for PlainToken { - fn into(self) -> String { - self.0 +impl From for PlainToken { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From for String { + fn from(val: PlainToken) -> Self { + val.0 } } @@ -45,11 +57,11 @@ impl DbConn { &mut self, user_id: Uuid, friendly_name: String, - ) -> Result<(models::Token, PlainToken), DatabaseError> { + ) -> Result<(models::ApiToken, PlainToken), DatabaseError> { let plain_token = PlainToken::new(); let token = plain_token.hash(); - let new_token = models::NewToken { + let new_token = models::NewApiToken { user_id, friendly_name, token, @@ -59,7 +71,7 @@ impl DbConn { // Insert new session let saved_token = diesel::insert_into(schema::api_tokens::table) .values(&new_token) - .returning(models::Token::as_returning()) + .returning(models::ApiToken::as_returning()) .get_result(self.inner()) .map_err(|_| DatabaseError::InsertTokenFailed(user_id.to_string()))?; @@ -85,11 +97,24 @@ impl DbConn { pub fn get_tokens_for_user( &mut self, user_id: Uuid, - ) -> Result, DatabaseError> { + ) -> Result, DatabaseError> { schema::api_tokens::table .filter(schema::api_tokens::user_id.eq(user_id)) - .select(models::Token::as_returning()) + .select(models::ApiToken::as_returning()) .load(self.inner()) .map_err(|_| DatabaseError::NotFound(user_id.to_string())) } + + /// Fetch an API token given the plaintext token. + pub fn get_token( + &mut self, + plain_token: PlainToken, + ) -> Result { + let hashed = plain_token.hash(); + schema::api_tokens::table + .filter(schema::api_tokens::token.eq(hashed)) + .select(models::ApiToken::as_returning()) + .first::(self.inner()) + .map_err(|_| DatabaseError::NotFound("API Token".to_string())) + } } diff --git a/src/db/mod.rs b/src/db/mod.rs index fddabf2..6824487 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,4 +1,4 @@ -mod api_token; +pub mod api_token; pub mod error; mod user_session; diff --git a/src/main.rs b/src/main.rs index 4478824..8603d02 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,18 +4,17 @@ extern crate rocket; use forc_pub::api::api_token::{CreateTokenRequest, CreateTokenResponse, Token, TokensResponse}; +use forc_pub::api::publish::PublishRequest; use forc_pub::api::{ auth::{LoginRequest, LoginResponse, UserResponse}, ApiResult, EmptyResponse, }; -use forc_pub::middleware::cors::Cors; - use forc_pub::db::Database; use forc_pub::github::handle_login; +use forc_pub::middleware::cors::Cors; use forc_pub::middleware::session_auth::{SessionAuth, SESSION_COOKIE_NAME}; - +use forc_pub::middleware::token_auth::TokenAuth; use rocket::http::{Cookie, CookieJar}; - use rocket::{serde::json::Json, State}; #[derive(Default)] @@ -86,6 +85,16 @@ fn tokens(db: &State, auth: SessionAuth) -> ApiResult })) } +#[post("/publish", data = "")] +fn publish(request: Json, auth: TokenAuth) -> ApiResult { + println!( + "Publishing: {:?} for token: {:?}", + request, auth.token.friendly_name + ); + + Ok(Json(EmptyResponse)) +} + /// Catches all OPTION requests in order to get the CORS related Fairing triggered. #[options("/<_..>")] fn all_options() { @@ -118,6 +127,7 @@ fn rocket() -> _ { user, new_token, delete_token, + publish, tokens, all_options, health diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs index c67a8ad..ff75b40 100644 --- a/src/middleware/mod.rs +++ b/src/middleware/mod.rs @@ -1,2 +1,3 @@ pub mod cors; pub mod session_auth; +pub mod token_auth; diff --git a/src/middleware/token_auth.rs b/src/middleware/token_auth.rs new file mode 100644 index 0000000..ac65e91 --- /dev/null +++ b/src/middleware/token_auth.rs @@ -0,0 +1,52 @@ +use crate::db::api_token::PlainToken; +use crate::db::Database; +use crate::models; +use rocket::http::Status; +use rocket::request::{FromRequest, Outcome}; +use rocket::Request; + + + +pub const SESSION_COOKIE_NAME: &str = "session"; + +pub struct TokenAuth { + pub token: models::ApiToken, +} + +#[derive(Debug)] +pub enum TokenAuthError { + Missing, + Invalid, + DatabaseConnection, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for TokenAuth { + type Error = TokenAuthError; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + // TODO: use fairing for db connection? + // let db = try_outcome!(request.guard::().await); + + let mut db = match request.rocket().state::() { + Some(db) => db.conn(), + None => { + return Outcome::Failure(( + Status::InternalServerError, + TokenAuthError::DatabaseConnection, + )) + } + }; + + if let Some(auth_header) = request.headers().get_one("Authorization") { + if auth_header.starts_with("Bearer ") { + let token = auth_header.trim_start_matches("Bearer "); + if let Ok(token) = db.get_token(PlainToken::from(token.to_string())) { + return Outcome::Success(TokenAuth { token }); + } + } + return Outcome::Failure((Status::Unauthorized, TokenAuthError::Invalid)); + } + return Outcome::Failure((Status::Unauthorized, TokenAuthError::Missing)); + } +} diff --git a/src/models.rs b/src/models.rs index f821e51..d6cbdd6 100644 --- a/src/models.rs +++ b/src/models.rs @@ -44,10 +44,10 @@ pub struct NewSession { pub expires_at: SystemTime, } -#[derive(Queryable, Selectable, Debug)] +#[derive(Queryable, Selectable, Debug, PartialEq, Eq)] #[diesel(table_name = crate::schema::api_tokens)] #[diesel(check_for_backend(diesel::pg::Pg))] -pub struct Token { +pub struct ApiToken { pub id: Uuid, pub user_id: Uuid, pub friendly_name: String, @@ -57,7 +57,7 @@ pub struct Token { #[derive(Insertable)] #[diesel(table_name = crate::schema::api_tokens)] -pub struct NewToken { +pub struct NewApiToken { pub user_id: Uuid, pub friendly_name: String, pub token: Vec, diff --git a/tests/db_integration.rs b/tests/db_integration.rs index 7e47b6f..0481ffa 100644 --- a/tests/db_integration.rs +++ b/tests/db_integration.rs @@ -83,31 +83,43 @@ fn test_user_sessions() { fn test_api_tokens() { let db = &mut Database::default().conn(); - let session = db.insert_user_session(&mock_user_1(), 1000).expect("result is ok"); + let session = db + .insert_user_session(&mock_user_1(), 1000) + .expect("result is ok"); let user = db.get_user_for_session(session.id).expect("result is ok"); // Insert tokens - let (token1, plain_token1) = db.new_token(user.id, TEST_TOKEN_NAME_1.into()).expect("result is ok"); - let (token2, plain_token2) = db.new_token(user.id, TEST_TOKEN_NAME_2.into()).expect("result is ok"); + let (token1, plain_token1) = db + .new_token(user.id, TEST_TOKEN_NAME_1.into()) + .expect("result is ok"); + let (token2, plain_token2) = db + .new_token(user.id, TEST_TOKEN_NAME_2.into()) + .expect("result is ok"); assert_eq!(token1.friendly_name, TEST_TOKEN_NAME_1); assert_eq!(token1.expires_at, None); assert_eq!(token2.friendly_name, TEST_TOKEN_NAME_2); assert_eq!(token2.expires_at, None); + // Test token hashing + assert_eq!(token1, db.get_token(plain_token1).expect("test token 1")); + assert_eq!(token2, db.get_token(plain_token2).expect("test token 2")); + // Get tokens let tokens = db.get_tokens_for_user(user.id).expect("result is ok"); assert_eq!(tokens.len(), 2); // Delete tokens - let _ = db.delete_token(user.id, token1.id.into()).expect("result is ok"); + db + .delete_token(user.id, token1.id.into()) + .expect("result is ok"); let tokens = db.get_tokens_for_user(user.id).expect("result is ok"); assert_eq!(tokens.len(), 1); - let _ = db.delete_token(user.id, token2.id.into()).expect("result is ok"); + db + .delete_token(user.id, token2.id.into()) + .expect("result is ok"); let tokens = db.get_tokens_for_user(user.id).expect("result is ok"); assert_eq!(tokens.len(), 0); - // TODO: test validating a plain token - clear_tables(db); -} \ No newline at end of file +} From 55c54fe1210e377864ce8cf7488cad94da5f43a5 Mon Sep 17 00:00:00 2001 From: Sophie Date: Thu, 2 May 2024 12:54:42 -0700 Subject: [PATCH 11/17] cargo fmt --- src/middleware/token_auth.rs | 2 -- tests/db_integration.rs | 6 ++---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/middleware/token_auth.rs b/src/middleware/token_auth.rs index ac65e91..d9a5431 100644 --- a/src/middleware/token_auth.rs +++ b/src/middleware/token_auth.rs @@ -5,8 +5,6 @@ use rocket::http::Status; use rocket::request::{FromRequest, Outcome}; use rocket::Request; - - pub const SESSION_COOKIE_NAME: &str = "session"; pub struct TokenAuth { diff --git a/tests/db_integration.rs b/tests/db_integration.rs index 0481ffa..0e47330 100644 --- a/tests/db_integration.rs +++ b/tests/db_integration.rs @@ -110,13 +110,11 @@ fn test_api_tokens() { assert_eq!(tokens.len(), 2); // Delete tokens - db - .delete_token(user.id, token1.id.into()) + db.delete_token(user.id, token1.id.into()) .expect("result is ok"); let tokens = db.get_tokens_for_user(user.id).expect("result is ok"); assert_eq!(tokens.len(), 1); - db - .delete_token(user.id, token2.id.into()) + db.delete_token(user.id, token2.id.into()) .expect("result is ok"); let tokens = db.get_tokens_for_user(user.id).expect("result is ok"); assert_eq!(tokens.len(), 0); From 869f07e970d7bcb48dfe4489ee44512eb3dd3b82 Mon Sep 17 00:00:00 2001 From: Sophie Date: Thu, 2 May 2024 12:55:15 -0700 Subject: [PATCH 12/17] prettier --- app/src/pages/ApiTokens.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/pages/ApiTokens.tsx b/app/src/pages/ApiTokens.tsx index 488ec43..ffd0734 100644 --- a/app/src/pages/ApiTokens.tsx +++ b/app/src/pages/ApiTokens.tsx @@ -3,8 +3,6 @@ import { useApiTokens } from '../features/tokens/hooks/useApiTokens'; import { useIsMobile } from '../features/toolbar/hooks/useIsMobile'; import { Button, TextField } from '@mui/material'; import TokenCard from '../features/tokens/components/TokenCard'; -import { useGithubAuth } from '../features/toolbar/hooks/useGithubAuth'; -import { useNavigate } from 'react-router-dom'; function ApiTokens() { const [tokenName, setTokenName] = React.useState(''); From d9eb6b111f4154f9984c78e0d4b9dba58f01c28c Mon Sep 17 00:00:00 2001 From: Sophie Date: Fri, 3 May 2024 09:49:09 -0700 Subject: [PATCH 13/17] plain token tests --- src/db/api_token.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/db/api_token.rs b/src/db/api_token.rs index 30b04e0..3a04ac9 100644 --- a/src/db/api_token.rs +++ b/src/db/api_token.rs @@ -118,3 +118,21 @@ impl DbConn { .map_err(|_| DatabaseError::NotFound("API Token".to_string())) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_plain_token_new() { + let token = PlainToken::new(); + assert!(token.0.starts_with(TOKEN_PREFIX)); + assert_eq!(token.hash(), Sha256::digest(token.0.as_bytes()).as_slice()); + } + + #[test] + fn test_plain_token_from() { + let token = PlainToken::from("123456".to_string()); + assert_eq!(token.hash(), Sha256::digest(token.0.as_bytes()).as_slice()); + } +} From b97122a5afac51c11b301add231d1788e542e648 Mon Sep 17 00:00:00 2001 From: Sophie Date: Fri, 3 May 2024 10:11:03 -0700 Subject: [PATCH 14/17] test get_allowed_origins --- src/middleware/cors.rs | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/middleware/cors.rs b/src/middleware/cors.rs index 21fa235..65132b4 100644 --- a/src/middleware/cors.rs +++ b/src/middleware/cors.rs @@ -3,6 +3,7 @@ use std::env; use dotenvy::dotenv; use regex::Regex; use rocket::fairing::{Fairing, Info, Kind}; +use rocket::http::hyper::header; use rocket::http::{Header, HeaderMap}; use rocket::{Request, Response}; @@ -12,7 +13,7 @@ pub struct Cors; fn get_allowed_origin(headers: &HeaderMap<'_>) -> Option { dotenv().ok(); - if let Some(req_origin) = headers.get_one("Origin") { + if let Some(req_origin) = headers.get_one(header::ORIGIN.as_str()) { // If the environment variable CORS_HTTP_ORIGIN is set, only allow that origin. if let Ok(env_origin) = env::var("CORS_HTTP_ORIGIN") { if req_origin == env_origin.as_str() { @@ -58,3 +59,41 @@ impl Fairing for Cors { response.set_header(Header::new("Access-Control-Allow-Credentials", "true")); } } + +#[cfg(test)] +mod tests { + use rocket::http::hyper::header; + + use super::*; + + #[test] + fn test_get_allowed_origin() { + let mut headers = HeaderMap::new(); + + let test_cases = vec![ + ("https://forc.pub", true), + ("https://forc-pub.vercel.app", true), + ("https://forc-pub-git-api-tokens-fuel-labs.vercel.app", true), + ("https://forc.pub/", false), + ("https://forc.pub/tokens", false), + ("https://forc.com.pub", false), + ("https://forc-spub.vercel.app", false), + ]; + + env::remove_var("CORS_HTTP_ORIGIN"); + test_cases.iter().for_each(|(origin, expected)| { + headers.add(Header::new(header::ORIGIN.as_str(), *origin)); + match expected { + true => assert_eq!(get_allowed_origin(&headers), Some(origin.to_string())), + false => assert!(get_allowed_origin(&headers).is_none()), + } + headers.remove(header::ORIGIN.as_str()); + }); + + // Test with CORS_HTTP_ORIGIN set. + let origin = "http://localhost:3000"; + env::set_var("CORS_HTTP_ORIGIN", origin); + headers.add(Header::new(header::ORIGIN.as_str(), origin)); + assert_eq!(get_allowed_origin(&headers), Some(origin.to_string())) + } +} From b1a1f9e52fbb3484ab936922cef3ea8ea2fe36e7 Mon Sep 17 00:00:00 2001 From: Sophie Date: Fri, 3 May 2024 10:18:58 -0700 Subject: [PATCH 15/17] use header constants --- app/src/features/toolbar/hooks/useGithubAuth.ts | 2 +- src/middleware/cors.rs | 17 +++++++++-------- src/middleware/session_auth.rs | 2 +- src/middleware/token_auth.rs | 5 ++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/src/features/toolbar/hooks/useGithubAuth.ts b/app/src/features/toolbar/hooks/useGithubAuth.ts index 0bdcc82..c64d305 100644 --- a/app/src/features/toolbar/hooks/useGithubAuth.ts +++ b/app/src/features/toolbar/hooks/useGithubAuth.ts @@ -8,7 +8,7 @@ export function useGithubAuth(): [ AuthenticatedUser | null, () => Promise ] { - const [sessionId, setSessionId] = useCookie('session'); + const [sessionId, setSessionId] = useCookie('fp_session'); const [githubUser, setGithubUser] = useState(null); const { githubCode, saveGithubCode, clearGithubCode } = useLocalSession(); const [searchParams, setSearchParams] = useSearchParams(); diff --git a/src/middleware/cors.rs b/src/middleware/cors.rs index 65132b4..c52f60e 100644 --- a/src/middleware/cors.rs +++ b/src/middleware/cors.rs @@ -1,11 +1,11 @@ -use std::env; - use dotenvy::dotenv; use regex::Regex; +use reqwest::header::ACCESS_CONTROL_ALLOW_ORIGIN; use rocket::fairing::{Fairing, Info, Kind}; use rocket::http::hyper::header; use rocket::http::{Header, HeaderMap}; use rocket::{Request, Response}; +use std::env; // Build an open cors module so this server can be used accross many locations on the web. pub struct Cors; @@ -46,24 +46,25 @@ impl Fairing for Cors { // Build an Access-Control-Allow-Origin policy Response header. async fn on_response<'r>(&self, request: &'r Request<'_>, response: &mut Response<'r>) { if let Some(origin) = get_allowed_origin(request.headers()) { - response.set_header(Header::new("Access-Control-Allow-Origin", origin)); + response.set_header(Header::new(ACCESS_CONTROL_ALLOW_ORIGIN.as_str(), origin)); } response.set_header(Header::new( - "Access-Control-Allow-Methods", + header::ACCESS_CONTROL_ALLOW_METHODS.as_str(), "POST, PATCH, PUT, DELETE, HEAD, OPTIONS, GET", )); response.set_header(Header::new( - "Access-Control-Allow-Headers", + header::ACCESS_CONTROL_ALLOW_HEADERS.as_str(), "*, Access-Control-Request-Headers, Content-Type", )); - response.set_header(Header::new("Access-Control-Allow-Credentials", "true")); + response.set_header(Header::new( + header::ACCESS_CONTROL_ALLOW_CREDENTIALS.as_str(), + "true", + )); } } #[cfg(test)] mod tests { - use rocket::http::hyper::header; - use super::*; #[test] diff --git a/src/middleware/session_auth.rs b/src/middleware/session_auth.rs index 4f4f0ee..9caf84c 100644 --- a/src/middleware/session_auth.rs +++ b/src/middleware/session_auth.rs @@ -6,7 +6,7 @@ use rocket::Request; use std::time::SystemTime; use uuid::Uuid; -pub const SESSION_COOKIE_NAME: &str = "session"; +pub const SESSION_COOKIE_NAME: &str = "fp_session"; pub struct SessionAuth { pub user: models::User, diff --git a/src/middleware/token_auth.rs b/src/middleware/token_auth.rs index d9a5431..a15654b 100644 --- a/src/middleware/token_auth.rs +++ b/src/middleware/token_auth.rs @@ -1,12 +1,11 @@ use crate::db::api_token::PlainToken; use crate::db::Database; use crate::models; +use rocket::http::hyper::header; use rocket::http::Status; use rocket::request::{FromRequest, Outcome}; use rocket::Request; -pub const SESSION_COOKIE_NAME: &str = "session"; - pub struct TokenAuth { pub token: models::ApiToken, } @@ -36,7 +35,7 @@ impl<'r> FromRequest<'r> for TokenAuth { } }; - if let Some(auth_header) = request.headers().get_one("Authorization") { + if let Some(auth_header) = request.headers().get_one(header::AUTHORIZATION.as_str()) { if auth_header.starts_with("Bearer ") { let token = auth_header.trim_start_matches("Bearer "); if let Ok(token) = db.get_token(PlainToken::from(token.to_string())) { From 0a62aa49d898e4cbf6b32efc68a39ec528c8f9cb Mon Sep 17 00:00:00 2001 From: Sophie Date: Mon, 6 May 2024 11:47:12 -0700 Subject: [PATCH 16/17] clippy --- src/middleware/cors.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/middleware/cors.rs b/src/middleware/cors.rs index c52f60e..7e7c99b 100644 --- a/src/middleware/cors.rs +++ b/src/middleware/cors.rs @@ -71,7 +71,7 @@ mod tests { fn test_get_allowed_origin() { let mut headers = HeaderMap::new(); - let test_cases = vec![ + let test_cases = [ ("https://forc.pub", true), ("https://forc-pub.vercel.app", true), ("https://forc-pub-git-api-tokens-fuel-labs.vercel.app", true), From 71107f4d788dcaf791e319584b9deda9dcd51863 Mon Sep 17 00:00:00 2001 From: Sophie Date: Mon, 6 May 2024 20:06:49 -0700 Subject: [PATCH 17/17] fix typo in env file --- .env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env b/.env index 7981a03..ab29ada 100644 --- a/.env +++ b/.env @@ -6,7 +6,7 @@ POSTGRES_PORT="5432" POSTGRES_DB_NAME="forc_pub" # Local env -CORS_HTTP_ORIGIN = "http://localhost:3000" +CORS_HTTP_ORIGIN="http://localhost:3000" # Diesel CLI env DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_URI}/${POSTGRES_DB_NAME}" \ No newline at end of file