Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion backend/app/models/users.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
112 changes: 84 additions & 28 deletions backend/app/routers/users.py
Original file line number Diff line number Diff line change
@@ -1,64 +1,120 @@
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),
):
"""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")
24 changes: 24 additions & 0 deletions backend/app/tests/test_apikey.py
Original file line number Diff line number Diff line change
@@ -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
13 changes: 12 additions & 1 deletion backend/app/tests/utils.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand Down Expand Up @@ -69,7 +71,6 @@
"bibtex": [],
}


"""CONVENIENCE FUNCTIONS FOR COMMON ACTIONS REQUIRED BY TESTS."""


Expand All @@ -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(
Expand Down
45 changes: 41 additions & 4 deletions frontend/src/actions/user.js
Original file line number Diff line number Diff line change
@@ -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();

Expand Down Expand Up @@ -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)));
});
};
}
Expand Down
Loading