diff --git a/backend/app/models/users.py b/backend/app/models/users.py index 21fb5322b..cadb7c900 100644 --- a/backend/app/models/users.py +++ b/backend/app/models/users.py @@ -1,5 +1,6 @@ -from typing import Optional from datetime import datetime +from typing import Optional + from passlib.context import CryptContext from pydantic import Field, EmailStr, BaseModel from pymongo import MongoClient @@ -43,6 +44,15 @@ class UserAPIKey(MongoModel): """API keys can have a reference name (e.g. 'Uploader script')""" key: str + name: str + user: EmailStr + created: datetime = Field(default_factory=datetime.utcnow) + expires: Optional[datetime] = None + + +class UserAPIKeyOut(MongoModel): + # don't show the raw key + name: str user: EmailStr created: datetime = Field(default_factory=datetime.utcnow) expires: Optional[datetime] = None diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 9c2ee94cc..478b6b14e 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -1,48 +1,50 @@ +from datetime import timedelta +from secrets import token_urlsafe from typing import List + from bson import ObjectId from fastapi import APIRouter, HTTPException, Depends -from pymongo import MongoClient -from datetime import datetime, timedelta from itsdangerous.url_safe import URLSafeSerializer -from itsdangerous.exc import BadSignature -from secrets import token_urlsafe +from pymongo import MongoClient, DESCENDING from app import dependencies from app.config import settings from app.keycloak_auth import get_current_username -from app.models.users import UserOut, UserAPIKey +from app.models.users import UserOut, UserAPIKey, UserAPIKeyOut router = APIRouter() -@router.get("", response_model=List[UserOut]) -async def get_users( - db: MongoClient = Depends(dependencies.get_db), skip: int = 0, limit: int = 2 +@router.get("/keys", response_model=List[UserAPIKeyOut]) +async def generate_user_api_key( + db: MongoClient = Depends(dependencies.get_db), + current_user=Depends(get_current_username), + skip: int = 0, + limit: int = 10, ): - users = [] - for doc in await db["users"].find().skip(skip).limit(limit).to_list(length=limit): - users.append(UserOut(**doc)) - return users - - -@router.get("/{user_id}", response_model=UserOut) -async def get_user(user_id: str, db: MongoClient = Depends(dependencies.get_db)): - if (user := await db["users"].find_one({"_id": ObjectId(user_id)})) is not None: - return UserOut.from_mongo(user) - raise HTTPException(status_code=404, detail=f"User {user_id} not found") + """List all api keys that user has created + Arguments: + skip: number of page to skip + limit: number to limit per page + """ + apikeys = [] + for doc in ( + await db["user_keys"] + .find({"user": current_user}) + .sort([("created", DESCENDING)]) + .skip(skip) + .limit(limit) + .to_list(length=limit) + ): + apikeys.append(UserAPIKeyOut.from_mongo(doc)) -@router.get("/username/{username}", response_model=UserOut) -async def get_user_by_name( - username: str, db: MongoClient = Depends(dependencies.get_db) -): - if (user := await db["users"].find_one({"email": username})) is not None: - return UserOut.from_mongo(user) - raise HTTPException(status_code=404, detail=f"User {username} not found") + return apikeys @router.post("/keys", response_model=str) async def generate_user_api_key( + name: str, mins: int = settings.local_auth_expiration, db: MongoClient = Depends(dependencies.get_db), current_user=Depends(get_current_username), @@ -50,15 +52,69 @@ async def generate_user_api_key( """Generate an API key that confers the user's privileges. Arguments: - mins -- number of minutes before expiration (0 for no expiration) + name: name of the api key + mins: number of minutes before expiration (0 for no expiration) """ serializer = URLSafeSerializer(settings.local_auth_secret, salt="api_key") unique_key = token_urlsafe(16) hashed_key = serializer.dumps({"user": current_user, "key": unique_key}) - user_key = UserAPIKey(user=current_user, key=unique_key) + user_key = UserAPIKey(user=current_user, key=unique_key, name=name) if mins > 0: user_key.expires = user_key.created + timedelta(minutes=mins) db["user_keys"].insert_one(user_key.to_mongo()) return hashed_key + + +@router.delete("/keys/{key_id}", response_model=UserAPIKeyOut) +async def generate_user_api_key( + key_id: str, + db: MongoClient = Depends(dependencies.get_db), + current_user=Depends(get_current_username), +): + """Delete API keys given ID + + Arguments: + key_id: id of the apikey + """ + apikey_doc = await db["user_keys"].find_one({"_id": ObjectId(key_id)}) + if apikey_doc is not None: + apikey = UserAPIKeyOut.from_mongo(apikey_doc) + + # Only allow user to delete their own key + if apikey.user == current_user: + await db["user_keys"].delete_one({"_id": ObjectId(key_id)}) + return apikey + else: + raise HTTPException( + status_code=403, detail=f"API key {key_id} not allowed to be deleted." + ) + else: + raise HTTPException(status_code=404, detail=f"API key {key_id} not found.") + + +@router.get("", response_model=List[UserOut]) +async def get_users( + db: MongoClient = Depends(dependencies.get_db), skip: int = 0, limit: int = 2 +): + users = [] + for doc in await db["users"].find().skip(skip).limit(limit).to_list(length=limit): + users.append(UserOut(**doc)) + return users + + +@router.get("/{user_id}", response_model=UserOut) +async def get_user(user_id: str, db: MongoClient = Depends(dependencies.get_db)): + if (user := await db["users"].find_one({"_id": ObjectId(user_id)})) is not None: + return UserOut.from_mongo(user) + raise HTTPException(status_code=404, detail=f"User {user_id} not found") + + +@router.get("/username/{username}", response_model=UserOut) +async def get_user_by_name( + username: str, db: MongoClient = Depends(dependencies.get_db) +): + if (user := await db["users"].find_one({"email": username})) is not None: + return UserOut.from_mongo(user) + raise HTTPException(status_code=404, detail=f"User {username} not found") diff --git a/backend/app/tests/test_apikey.py b/backend/app/tests/test_apikey.py new file mode 100644 index 000000000..a525f03ab --- /dev/null +++ b/backend/app/tests/test_apikey.py @@ -0,0 +1,24 @@ +from fastapi.testclient import TestClient + +from app.config import settings +from app.tests.utils import create_apikey + + +def test_create_apikey(client: TestClient, headers: dict): + hashed_key = create_apikey(client, headers) + assert hashed_key is not None + + +def test_list_apikeys(client: TestClient, headers: dict): + response = client.get(f"{settings.API_V2_STR}/users/keys", headers=headers) + assert response.status_code == 200 + + +def test_delete_apikeys(client: TestClient, headers: dict): + create_apikey(client, headers) + get_response = client.get(f"{settings.API_V2_STR}/users/keys", headers=headers) + key_id = get_response.json()[0].get("id") + delete_response = client.delete( + f"{settings.API_V2_STR}/users/keys/{key_id}", headers=headers + ) + assert delete_response.status_code == 200 diff --git a/backend/app/tests/utils.py b/backend/app/tests/utils.py index c7c45d32b..cef6bf3df 100644 --- a/backend/app/tests/utils.py +++ b/backend/app/tests/utils.py @@ -1,5 +1,7 @@ import os + from fastapi.testclient import TestClient + from app.config import settings """These are standard JSON entries to be used for creating test resources.""" @@ -69,7 +71,6 @@ "bibtex": [], } - """CONVENIENCE FUNCTIONS FOR COMMON ACTIONS REQUIRED BY TESTS.""" @@ -95,6 +96,16 @@ def get_user_token(client: TestClient, headers: dict, email: str = user_alt["ema return {"Authorization": "Bearer " + token} +def create_apikey(client: TestClient, headers: dict): + """create user generated API key""" + response = client.post( + f"{settings.API_V2_STR}/users/keys?name=pytest&mins=30", headers=headers + ) + assert response.status_code == 200 + assert response.json() is not None + return response.json() + + def create_group(client: TestClient, headers: dict): """Creates a test group (creator will be auto-added to members) and returns the JSON.""" response = client.post( diff --git a/frontend/src/actions/user.js b/frontend/src/actions/user.js index 2841f3879..8838ae907 100644 --- a/frontend/src/actions/user.js +++ b/frontend/src/actions/user.js @@ -1,6 +1,7 @@ import { V2 } from "../openapi"; import Cookies from "universal-cookie"; import config from "../app.config"; +import { handleErrors } from "./common"; const cookies = new Cookies(); @@ -126,25 +127,61 @@ export function fetchAllUsers(skip = 0, limit = 101) { }); }) .catch((reason) => { - dispatch(fetchAllUsers((skip = 0), (limit = 21))); + dispatch(fetchAllUsers(skip, limit)); + }); + }; +} + +export const LIST_API_KEYS = "LIST_API_KEYS"; + +export function listApiKeys(skip = 0, limit = 10) { + return (dispatch) => { + return V2.UsersService.generateUserApiKeyApiV2UsersKeysGet(skip, limit) + .then((json) => { + dispatch({ + type: LIST_API_KEYS, + apiKeys: json, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch(handleErrors(reason, listApiKeys(skip, limit))); }); }; } export const GENERATE_API_KEY = "GENERATE_API_KEY"; -export function generateApiKey(minutes = 30) { +export function generateApiKey(name = "", minutes = 30) { return (dispatch) => { - return V2.UsersService.generateUserApiKeyApiV2UsersKeysPost(minutes) + return V2.UsersService.generateUserApiKeyApiV2UsersKeysPost(name, minutes) .then((json) => { dispatch({ type: GENERATE_API_KEY, + hashedKey: json, + receivedAt: Date.now(), + }); + }) + .catch((reason) => { + dispatch(handleErrors(reason, generateApiKey(name, minutes))); + }); + }; +} + +export const DELETE_API_KEY = "DELETE_API_KEY"; + +export function deleteApiKey(keyId) { + return (dispatch) => { + return V2.UsersService.generateUserApiKeyApiV2UsersKeysKeyIdDelete(keyId) + .then((json) => { + dispatch({ + type: DELETE_API_KEY, apiKey: json, receivedAt: Date.now(), }); }) .catch((reason) => { - dispatch(generateApiKey((minutes = 30))); + dispatch(handleErrors(reason, deleteApiKey(keyId))); }); }; } diff --git a/frontend/src/components/ApiKeys/ApiKey.tsx b/frontend/src/components/ApiKeys/ApiKey.tsx new file mode 100644 index 000000000..a9b3769e7 --- /dev/null +++ b/frontend/src/components/ApiKeys/ApiKey.tsx @@ -0,0 +1,214 @@ +import React, { useEffect, useState } from "react"; +import { Box, Button, ButtonGroup, Grid, IconButton } from "@mui/material"; +import { RootState } from "../../types/data"; +import { useDispatch, useSelector } from "react-redux"; +import { ArrowBack, ArrowForward } from "@material-ui/icons"; +import Paper from "@mui/material/Paper"; +import Table from "@mui/material/Table"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import TableCell from "@mui/material/TableCell"; +import TableBody from "@mui/material/TableBody"; +import TableContainer from "@mui/material/TableContainer"; +import Layout from "../Layout"; +import { MainBreadcrumbs } from "../navigation/BreadCrumb"; + +import { + deleteApiKey as deleteApiKeyAction, + listApiKeys as listApiKeysAction, +} from "../../actions/user"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { theme } from "../../theme"; +import { parseDate } from "../../utils/common"; +import { ActionModal } from "../dialog/ActionModal"; +import VpnKeyIcon from "@mui/icons-material/VpnKey"; +import { CreateApiKeyModal } from "./CreateApiKeyModal"; + +export function ApiKeys() { + // Redux connect equivalent + const dispatch = useDispatch(); + const listApiKeys = (skip: number | undefined, limit: number | undefined) => + dispatch(listApiKeysAction(skip, limit)); + const deleteApiKey = (keyId: string) => dispatch(deleteApiKeyAction(keyId)); + + const apiKeys = useSelector((state: RootState) => state.user.apiKeys); + + // TODO add option to determine limit number; default show 5 tokens each time + const [currPageNum, setCurrPageNum] = useState(0); + const [limit] = useState(5); + const [skip, setSkip] = useState(0); + const [prevDisabled, setPrevDisabled] = useState(true); + const [nextDisabled, setNextDisabled] = useState(false); + const [selectedApikey, setSelectApikey] = useState(""); + const [deleteApikeyConfirmOpen, setDeleteApikeyConfirmOpen] = useState(false); + const [createApiKeyModalOpen, setCreateApiKeyModalOpen] = useState(false); + + // for breadcrumb + const paths = [ + { + name: "Explore", + url: "/", + }, + ]; + + // component did mount + useEffect(() => { + listApiKeys(skip, limit); + }, []); + + useEffect(() => { + // disable flipping if reaches the last page + if (apiKeys.length < limit) setNextDisabled(true); + else setNextDisabled(false); + }, [apiKeys]); + + useEffect(() => { + if (skip !== null && skip !== undefined) { + listApiKeys(skip, limit); + if (skip === 0) setPrevDisabled(true); + else setPrevDisabled(false); + } + }, [skip]); + + const previous = () => { + if (currPageNum - 1 >= 0) { + setSkip((currPageNum - 1) * limit); + setCurrPageNum(currPageNum - 1); + } + }; + const next = () => { + if (apiKeys.length === limit) { + setSkip((currPageNum + 1) * limit); + setCurrPageNum(currPageNum + 1); + } + }; + + return ( + + {/*breadcrumb*/} + + + + + + + + +
+ + {/*action modal*/} + { + deleteApiKey(selectedApikey); + setDeleteApikeyConfirmOpen(false); + }} + handleActionCancel={() => { + setDeleteApikeyConfirmOpen(false); + }} + /> + {/*create api key modal*/} + + {/*api key table*/} + + + + + API Key Name + Created at + Expired at + + + + + {apiKeys.map((apiKey) => { + return ( + + + {apiKey.name} + + + {parseDate(apiKey.created)} + + + {apiKey.expires === null + ? "Never" + : parseDate(apiKey.expires)} + + + { + setSelectApikey(apiKey.id); + setDeleteApikeyConfirmOpen(true); + }} + > + + + + + ); + })} + +
+ + + + + + +
+
+
+ ); +} diff --git a/frontend/src/components/users/ApiKeyModal.tsx b/frontend/src/components/ApiKeys/CreateApiKeyModal.tsx similarity index 63% rename from frontend/src/components/users/ApiKeyModal.tsx rename to frontend/src/components/ApiKeys/CreateApiKeyModal.tsx index b03ffb5fc..dd3295860 100644 --- a/frontend/src/components/users/ApiKeyModal.tsx +++ b/frontend/src/components/ApiKeys/CreateApiKeyModal.tsx @@ -6,12 +6,12 @@ import { DialogContent, DialogTitle, FormControl, - InputLabel, MenuItem, Select, } from "@mui/material"; import { generateApiKey as generateApiKeyAction, + listApiKeys as listApiKeysAction, resetApiKey as resetApiKeyAction, } from "../../actions/user"; import { useDispatch, useSelector } from "react-redux"; @@ -19,30 +19,43 @@ import { RootState } from "../../types/data"; import { ClowderMetadataTextField } from "../styledComponents/ClowderMetadataTextField"; import { ClowderFootnote } from "../styledComponents/ClowderFootnote"; import { CopyToClipboard } from "react-copy-to-clipboard"; +import { ClowderInputLabel } from "../styledComponents/ClowderInputLabel"; +import { ClowderInput } from "../styledComponents/ClowderInput"; type ApiKeyModalProps = { + skip: number | undefined; + limit: number; apiKeyModalOpen: boolean; setApiKeyModalOpen: any; }; -export const ApiKeyModal = (props: ApiKeyModalProps) => { - const { apiKeyModalOpen, setApiKeyModalOpen } = props; +export const CreateApiKeyModal = (props: ApiKeyModalProps) => { + const { skip, limit, apiKeyModalOpen, setApiKeyModalOpen } = props; const dispatch = useDispatch(); - const generateApiKey = (minutes: number) => - dispatch(generateApiKeyAction(minutes)); + const generateApiKey = (name: string, minutes: number) => + dispatch(generateApiKeyAction(name, minutes)); + const listApiKeys = (skip: number | undefined, limit: number | undefined) => + dispatch(listApiKeysAction(skip, limit)); const resetApiKey = () => dispatch(resetApiKeyAction()); - const apiKey = useSelector((state: RootState) => state.user.apiKey); + const hashedKey = useSelector((state: RootState) => state.user.hashedKey); + const [name, setName] = useState(""); const [minutes, setMinutes] = useState(30); const handleClose = () => { - resetApiKey(); setApiKeyModalOpen(false); + + // fetch latest api key list + listApiKeys(skip, limit); + resetApiKey(); + // reset + setName(""); + setMinutes(30); }; const handleGenerate = () => { - generateApiKey(minutes); + generateApiKey(name, minutes); }; const handleExpirationChange = (e) => { @@ -52,7 +65,7 @@ export const ApiKeyModal = (props: ApiKeyModalProps) => { return ( Your API Key - {apiKey ? ( + {hashedKey ? ( <> @@ -60,13 +73,13 @@ export const ApiKeyModal = (props: ApiKeyModalProps) => { this again. - + @@ -75,14 +88,26 @@ export const ApiKeyModal = (props: ApiKeyModalProps) => { ) : ( <> - Your API key will expire - After + Name + { + setName(event.target.value); + }} + defaultValue={name} + /> + + + + Expire after +