Skip to content

Commit 8c7f727

Browse files
longshuicytcnichol
andauthored
446 gui to manage api keys (#465)
* add new routes placeholder * add list and delete endpoint; inline docs * codegen and pytest * ugly ui? * onstart clear user keys * delete works but pytest doesn't work yet * pytest all works * codegen * redux * reducer and types * write the table * correctly wire in the apikey and error handling * wire in the delete modal of apikeys * wire in the creation api key * everything working in the gui * formatting --------- Co-authored-by: toddn <tcnichol@illinois.edu>
1 parent dc39e85 commit 8c7f727

File tree

17 files changed

+1194
-678
lines changed

17 files changed

+1194
-678
lines changed

backend/app/models/users.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
from typing import Optional
21
from datetime import datetime
2+
from typing import Optional
3+
34
from passlib.context import CryptContext
45
from pydantic import Field, EmailStr, BaseModel
56
from pymongo import MongoClient
@@ -43,6 +44,15 @@ class UserAPIKey(MongoModel):
4344
"""API keys can have a reference name (e.g. 'Uploader script')"""
4445

4546
key: str
47+
name: str
48+
user: EmailStr
49+
created: datetime = Field(default_factory=datetime.utcnow)
50+
expires: Optional[datetime] = None
51+
52+
53+
class UserAPIKeyOut(MongoModel):
54+
# don't show the raw key
55+
name: str
4656
user: EmailStr
4757
created: datetime = Field(default_factory=datetime.utcnow)
4858
expires: Optional[datetime] = None

backend/app/routers/users.py

Lines changed: 84 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,120 @@
1+
from datetime import timedelta
2+
from secrets import token_urlsafe
13
from typing import List
4+
25
from bson import ObjectId
36
from fastapi import APIRouter, HTTPException, Depends
4-
from pymongo import MongoClient
5-
from datetime import datetime, timedelta
67
from itsdangerous.url_safe import URLSafeSerializer
7-
from itsdangerous.exc import BadSignature
8-
from secrets import token_urlsafe
8+
from pymongo import MongoClient, DESCENDING
99

1010
from app import dependencies
1111
from app.config import settings
1212
from app.keycloak_auth import get_current_username
13-
from app.models.users import UserOut, UserAPIKey
13+
from app.models.users import UserOut, UserAPIKey, UserAPIKeyOut
1414

1515
router = APIRouter()
1616

1717

18-
@router.get("", response_model=List[UserOut])
19-
async def get_users(
20-
db: MongoClient = Depends(dependencies.get_db), skip: int = 0, limit: int = 2
18+
@router.get("/keys", response_model=List[UserAPIKeyOut])
19+
async def generate_user_api_key(
20+
db: MongoClient = Depends(dependencies.get_db),
21+
current_user=Depends(get_current_username),
22+
skip: int = 0,
23+
limit: int = 10,
2124
):
22-
users = []
23-
for doc in await db["users"].find().skip(skip).limit(limit).to_list(length=limit):
24-
users.append(UserOut(**doc))
25-
return users
26-
27-
28-
@router.get("/{user_id}", response_model=UserOut)
29-
async def get_user(user_id: str, db: MongoClient = Depends(dependencies.get_db)):
30-
if (user := await db["users"].find_one({"_id": ObjectId(user_id)})) is not None:
31-
return UserOut.from_mongo(user)
32-
raise HTTPException(status_code=404, detail=f"User {user_id} not found")
25+
"""List all api keys that user has created
3326
27+
Arguments:
28+
skip: number of page to skip
29+
limit: number to limit per page
30+
"""
31+
apikeys = []
32+
for doc in (
33+
await db["user_keys"]
34+
.find({"user": current_user})
35+
.sort([("created", DESCENDING)])
36+
.skip(skip)
37+
.limit(limit)
38+
.to_list(length=limit)
39+
):
40+
apikeys.append(UserAPIKeyOut.from_mongo(doc))
3441

35-
@router.get("/username/{username}", response_model=UserOut)
36-
async def get_user_by_name(
37-
username: str, db: MongoClient = Depends(dependencies.get_db)
38-
):
39-
if (user := await db["users"].find_one({"email": username})) is not None:
40-
return UserOut.from_mongo(user)
41-
raise HTTPException(status_code=404, detail=f"User {username} not found")
42+
return apikeys
4243

4344

4445
@router.post("/keys", response_model=str)
4546
async def generate_user_api_key(
47+
name: str,
4648
mins: int = settings.local_auth_expiration,
4749
db: MongoClient = Depends(dependencies.get_db),
4850
current_user=Depends(get_current_username),
4951
):
5052
"""Generate an API key that confers the user's privileges.
5153
5254
Arguments:
53-
mins -- number of minutes before expiration (0 for no expiration)
55+
name: name of the api key
56+
mins: number of minutes before expiration (0 for no expiration)
5457
"""
5558
serializer = URLSafeSerializer(settings.local_auth_secret, salt="api_key")
5659
unique_key = token_urlsafe(16)
5760
hashed_key = serializer.dumps({"user": current_user, "key": unique_key})
5861

59-
user_key = UserAPIKey(user=current_user, key=unique_key)
62+
user_key = UserAPIKey(user=current_user, key=unique_key, name=name)
6063
if mins > 0:
6164
user_key.expires = user_key.created + timedelta(minutes=mins)
6265
db["user_keys"].insert_one(user_key.to_mongo())
6366

6467
return hashed_key
68+
69+
70+
@router.delete("/keys/{key_id}", response_model=UserAPIKeyOut)
71+
async def generate_user_api_key(
72+
key_id: str,
73+
db: MongoClient = Depends(dependencies.get_db),
74+
current_user=Depends(get_current_username),
75+
):
76+
"""Delete API keys given ID
77+
78+
Arguments:
79+
key_id: id of the apikey
80+
"""
81+
apikey_doc = await db["user_keys"].find_one({"_id": ObjectId(key_id)})
82+
if apikey_doc is not None:
83+
apikey = UserAPIKeyOut.from_mongo(apikey_doc)
84+
85+
# Only allow user to delete their own key
86+
if apikey.user == current_user:
87+
await db["user_keys"].delete_one({"_id": ObjectId(key_id)})
88+
return apikey
89+
else:
90+
raise HTTPException(
91+
status_code=403, detail=f"API key {key_id} not allowed to be deleted."
92+
)
93+
else:
94+
raise HTTPException(status_code=404, detail=f"API key {key_id} not found.")
95+
96+
97+
@router.get("", response_model=List[UserOut])
98+
async def get_users(
99+
db: MongoClient = Depends(dependencies.get_db), skip: int = 0, limit: int = 2
100+
):
101+
users = []
102+
for doc in await db["users"].find().skip(skip).limit(limit).to_list(length=limit):
103+
users.append(UserOut(**doc))
104+
return users
105+
106+
107+
@router.get("/{user_id}", response_model=UserOut)
108+
async def get_user(user_id: str, db: MongoClient = Depends(dependencies.get_db)):
109+
if (user := await db["users"].find_one({"_id": ObjectId(user_id)})) is not None:
110+
return UserOut.from_mongo(user)
111+
raise HTTPException(status_code=404, detail=f"User {user_id} not found")
112+
113+
114+
@router.get("/username/{username}", response_model=UserOut)
115+
async def get_user_by_name(
116+
username: str, db: MongoClient = Depends(dependencies.get_db)
117+
):
118+
if (user := await db["users"].find_one({"email": username})) is not None:
119+
return UserOut.from_mongo(user)
120+
raise HTTPException(status_code=404, detail=f"User {username} not found")

backend/app/tests/test_apikey.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from fastapi.testclient import TestClient
2+
3+
from app.config import settings
4+
from app.tests.utils import create_apikey
5+
6+
7+
def test_create_apikey(client: TestClient, headers: dict):
8+
hashed_key = create_apikey(client, headers)
9+
assert hashed_key is not None
10+
11+
12+
def test_list_apikeys(client: TestClient, headers: dict):
13+
response = client.get(f"{settings.API_V2_STR}/users/keys", headers=headers)
14+
assert response.status_code == 200
15+
16+
17+
def test_delete_apikeys(client: TestClient, headers: dict):
18+
create_apikey(client, headers)
19+
get_response = client.get(f"{settings.API_V2_STR}/users/keys", headers=headers)
20+
key_id = get_response.json()[0].get("id")
21+
delete_response = client.delete(
22+
f"{settings.API_V2_STR}/users/keys/{key_id}", headers=headers
23+
)
24+
assert delete_response.status_code == 200

backend/app/tests/utils.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import os
2+
23
from fastapi.testclient import TestClient
4+
35
from app.config import settings
46

57
"""These are standard JSON entries to be used for creating test resources."""
@@ -69,7 +71,6 @@
6971
"bibtex": [],
7072
}
7173

72-
7374
"""CONVENIENCE FUNCTIONS FOR COMMON ACTIONS REQUIRED BY TESTS."""
7475

7576

@@ -95,6 +96,16 @@ def get_user_token(client: TestClient, headers: dict, email: str = user_alt["ema
9596
return {"Authorization": "Bearer " + token}
9697

9798

99+
def create_apikey(client: TestClient, headers: dict):
100+
"""create user generated API key"""
101+
response = client.post(
102+
f"{settings.API_V2_STR}/users/keys?name=pytest&mins=30", headers=headers
103+
)
104+
assert response.status_code == 200
105+
assert response.json() is not None
106+
return response.json()
107+
108+
98109
def create_group(client: TestClient, headers: dict):
99110
"""Creates a test group (creator will be auto-added to members) and returns the JSON."""
100111
response = client.post(

frontend/src/actions/user.js

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { V2 } from "../openapi";
22
import Cookies from "universal-cookie";
33
import config from "../app.config";
4+
import { handleErrors } from "./common";
45

56
const cookies = new Cookies();
67

@@ -126,25 +127,61 @@ export function fetchAllUsers(skip = 0, limit = 101) {
126127
});
127128
})
128129
.catch((reason) => {
129-
dispatch(fetchAllUsers((skip = 0), (limit = 21)));
130+
dispatch(fetchAllUsers(skip, limit));
131+
});
132+
};
133+
}
134+
135+
export const LIST_API_KEYS = "LIST_API_KEYS";
136+
137+
export function listApiKeys(skip = 0, limit = 10) {
138+
return (dispatch) => {
139+
return V2.UsersService.generateUserApiKeyApiV2UsersKeysGet(skip, limit)
140+
.then((json) => {
141+
dispatch({
142+
type: LIST_API_KEYS,
143+
apiKeys: json,
144+
receivedAt: Date.now(),
145+
});
146+
})
147+
.catch((reason) => {
148+
dispatch(handleErrors(reason, listApiKeys(skip, limit)));
130149
});
131150
};
132151
}
133152

134153
export const GENERATE_API_KEY = "GENERATE_API_KEY";
135154

136-
export function generateApiKey(minutes = 30) {
155+
export function generateApiKey(name = "", minutes = 30) {
137156
return (dispatch) => {
138-
return V2.UsersService.generateUserApiKeyApiV2UsersKeysPost(minutes)
157+
return V2.UsersService.generateUserApiKeyApiV2UsersKeysPost(name, minutes)
139158
.then((json) => {
140159
dispatch({
141160
type: GENERATE_API_KEY,
161+
hashedKey: json,
162+
receivedAt: Date.now(),
163+
});
164+
})
165+
.catch((reason) => {
166+
dispatch(handleErrors(reason, generateApiKey(name, minutes)));
167+
});
168+
};
169+
}
170+
171+
export const DELETE_API_KEY = "DELETE_API_KEY";
172+
173+
export function deleteApiKey(keyId) {
174+
return (dispatch) => {
175+
return V2.UsersService.generateUserApiKeyApiV2UsersKeysKeyIdDelete(keyId)
176+
.then((json) => {
177+
dispatch({
178+
type: DELETE_API_KEY,
142179
apiKey: json,
143180
receivedAt: Date.now(),
144181
});
145182
})
146183
.catch((reason) => {
147-
dispatch(generateApiKey((minutes = 30)));
184+
dispatch(handleErrors(reason, deleteApiKey(keyId)));
148185
});
149186
};
150187
}

0 commit comments

Comments
 (0)