User Display Name Support + Token Refresh Support (#44)

* backend api/data model improvements:
- add 'name' property to user, can be set on registration, fixes #43
- in archive user list, include 'name' and 'role' for each user
- don't include is_* property in user create/register and update
- add /auth/jwt/refresh endpoint for refreshing token, fixes #34, support for #22

* allow jwt token lifetime to be settable via JWT_LIFETIME env var (default 3600)
This commit is contained in:
Ilya Kreymer 2021-12-01 18:55:10 -08:00 committed by GitHub
parent 8bcdc8877f
commit 081d6f8519
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 75 additions and 24 deletions

View File

@ -87,7 +87,7 @@ class Archive(BaseMongoModel):
return res >= value return res >= value
def serialize_for_user(self, user: User): async def serialize_for_user(self, user: User, user_manager):
"""Serialize based on current user access""" """Serialize based on current user access"""
exclude = {"storage"} exclude = {"storage"}
@ -97,13 +97,30 @@ class Archive(BaseMongoModel):
if not self.is_crawler(user): if not self.is_crawler(user):
exclude.add("usage") exclude.add("usage")
return self.dict( result = self.dict(
exclude_unset=True, exclude_unset=True,
exclude_defaults=True, exclude_defaults=True,
exclude_none=True, exclude_none=True,
exclude=exclude, exclude=exclude,
) )
if self.is_owner(user):
keys = list(result["users"].keys())
user_list = await user_manager.get_user_names_by_ids(keys)
for archive_user in user_list:
id_ = str(archive_user["id"])
role = result["users"].get(id_)
if not role:
continue
result["users"][id_] = {
"role": role,
"name": archive_user.get("name", ""),
}
return result
# ============================================================================ # ============================================================================
class ArchiveOps: class ArchiveOps:
@ -235,7 +252,7 @@ class ArchiveOps:
# ============================================================================ # ============================================================================
def init_archives_api(app, mdb, users, email, user_dep: User): def init_archives_api(app, mdb, user_manager, email, user_dep: User):
"""Init archives api router for /archives""" """Init archives api router for /archives"""
ops = ArchiveOps(mdb, email) ops = ArchiveOps(mdb, email)
@ -281,13 +298,17 @@ def init_archives_api(app, mdb, users, email, user_dep: User):
@app.get("/archives", tags=["archives"]) @app.get("/archives", tags=["archives"])
async def get_archives(user: User = Depends(user_dep)): async def get_archives(user: User = Depends(user_dep)):
results = await ops.get_archives_for_user(user) results = await ops.get_archives_for_user(user)
return {"archives": [res.serialize_for_user(user) for res in results]} return {
"archives": [
await res.serialize_for_user(user, user_manager) for res in results
]
}
@router.get("", tags=["archives"]) @router.get("", tags=["archives"])
async def get_archive( async def get_archive(
archive: Archive = Depends(archive_dep), user: User = Depends(user_dep) archive: Archive = Depends(archive_dep), user: User = Depends(user_dep)
): ):
return archive.serialize_for_user(user) return await archive.serialize_for_user(user, user_manager)
@router.post("/invite", tags=["invites"]) @router.post("/invite", tags=["invites"])
async def invite_user( async def invite_user(
@ -301,7 +322,7 @@ def init_archives_api(app, mdb, users, email, user_dep: User):
aid=str(archive.id), created=datetime.utcnow(), role=invite.role aid=str(archive.id), created=datetime.utcnow(), role=invite.role
) )
other_user = await users.user_db.get_by_email(invite.email) other_user = await user_manager.user_db.get_by_email(invite.email)
if not other_user: if not other_user:
@ -318,14 +339,14 @@ def init_archives_api(app, mdb, users, email, user_dep: User):
if other_user.email == user.email: if other_user.email == user.email:
raise HTTPException(status_code=400, detail="Can't invite ourselves!") raise HTTPException(status_code=400, detail="Can't invite ourselves!")
if archive.users.get(str(other_user.id)): if archive.user_manager.get(str(other_user.id)):
raise HTTPException( raise HTTPException(
status_code=400, detail="User already a member of this archive." status_code=400, detail="User already a member of this archive."
) )
other_user.invites[invite_code] = invite_pending other_user.invites[invite_code] = invite_pending
await users.user_db.update(other_user) await user_manager.user_db.update(other_user)
return { return {
"invited": "existing_user", "invited": "existing_user",
@ -338,7 +359,7 @@ def init_archives_api(app, mdb, users, email, user_dep: User):
user: User = Depends(user_dep), user: User = Depends(user_dep),
): ):
other_user = await users.user_db.get_by_email(update.email) other_user = await user_manager.user_db.get_by_email(update.email)
if not other_user: if not other_user:
raise HTTPException( raise HTTPException(
status_code=400, detail="No user found for specified e-mail" status_code=400, detail="No user found for specified e-mail"
@ -359,7 +380,7 @@ def init_archives_api(app, mdb, users, email, user_dep: User):
raise HTTPException(status_code=400, detail="Invalid Invite Code") raise HTTPException(status_code=400, detail="Invalid Invite Code")
await ops.add_user_by_invite(invite, user) await ops.add_user_by_invite(invite, user)
await users.user_db.update(user) await user_manager.user_db.update(user)
return {"added": True} return {"added": True}
return ops return ops

View File

@ -36,9 +36,7 @@ def main():
current_active_user = fastapi_users.current_user(active=True) current_active_user = fastapi_users.current_user(active=True)
archive_ops = init_archives_api( archive_ops = init_archives_api(app, mdb, user_manager, email, current_active_user)
app, mdb, user_manager, email, current_active_user
)
user_manager.set_archive_ops(archive_ops) user_manager.set_archive_ops(archive_ops)
@ -70,9 +68,7 @@ def main():
archive_ops, archive_ops,
) )
coll_ops = init_collections_api( coll_ops = init_collections_api(mdb, crawls, archive_ops, crawl_manager)
mdb, crawls, archive_ops, crawl_manager
)
crawl_config_ops.set_coll_ops(coll_ops) crawl_config_ops.set_coll_ops(coll_ops)

View File

@ -11,10 +11,9 @@ from datetime import datetime
from typing import Dict, Optional from typing import Dict, Optional
from enum import IntEnum from enum import IntEnum
from pydantic import BaseModel, EmailStr, UUID4
from pydantic import BaseModel from fastapi import Request, Response, HTTPException, Depends
from fastapi import Request, HTTPException
from fastapi_users import FastAPIUsers, models, BaseUserManager from fastapi_users import FastAPIUsers, models, BaseUserManager
from fastapi_users.authentication import JWTAuthentication from fastapi_users.authentication import JWTAuthentication
@ -24,6 +23,8 @@ from fastapi_users.db import MongoDBUserDatabase
# ============================================================================ # ============================================================================
PASSWORD_SECRET = os.environ.get("PASSWORD_SECRET", uuid.uuid4().hex) PASSWORD_SECRET = os.environ.get("PASSWORD_SECRET", uuid.uuid4().hex)
JWT_LIFETIME = int(os.environ.get("JWT_LIFETIME", 3600))
# ============================================================================ # ============================================================================
class UserRole(IntEnum): class UserRole(IntEnum):
@ -49,23 +50,36 @@ class User(models.BaseUser):
Base User Model Base User Model
""" """
name: Optional[str] = ""
# ============================================================================ # ============================================================================
class UserCreate(models.BaseUserCreate): # use custom model as model.BaseUserCreate includes is_* fields which should not be set
class UserCreate(models.CreateUpdateDictModel):
""" """
User Creation Model User Creation Model
""" """
email: EmailStr
password: str
name: Optional[str] = ""
inviteToken: Optional[str] inviteToken: Optional[str]
newArchive: bool newArchive: bool
newArchiveName: Optional[str] = ""
# ============================================================================ # ============================================================================
class UserUpdate(User, models.BaseUserUpdate): class UserUpdate(User, models.CreateUpdateDictModel):
""" """
User Update Model User Update Model
""" """
password: Optional[str]
email: Optional[EmailStr]
# ============================================================================ # ============================================================================
class UserDB(User, models.BaseUserDB): class UserDB(User, models.BaseUserDB):
@ -85,6 +99,7 @@ class UserDBOps(MongoDBUserDatabase):
# ============================================================================ # ============================================================================
class UserManager(BaseUserManager[UserCreate, UserDB]): class UserManager(BaseUserManager[UserCreate, UserDB]):
""" Browsertrix UserManager """ """ Browsertrix UserManager """
user_db_model = UserDB user_db_model = UserDB
reset_password_token_secret = PASSWORD_SECRET reset_password_token_secret = PASSWORD_SECRET
verification_token_secret = PASSWORD_SECRET verification_token_secret = PASSWORD_SECRET
@ -98,6 +113,14 @@ class UserManager(BaseUserManager[UserCreate, UserDB]):
""" set archive ops """ """ set archive ops """
self.archive_ops = ops self.archive_ops = ops
async def get_user_names_by_ids(self, user_ids):
""" return list of user names for given ids """
user_ids = [UUID4(id_) for id_ in user_ids]
cursor = self.user_db.collection.find(
{"id": {"$in": user_ids}}, projection=["id", "name"]
)
return await cursor.to_list(length=1000)
# pylint: disable=no-self-use, unused-argument # pylint: disable=no-self-use, unused-argument
async def on_after_register(self, user: UserDB, request: Optional[Request] = None): async def on_after_register(self, user: UserDB, request: Optional[Request] = None):
"""callback after registeration""" """callback after registeration"""
@ -109,7 +132,7 @@ class UserManager(BaseUserManager[UserCreate, UserDB]):
if req_data.get("newArchive"): if req_data.get("newArchive"):
print(f"Creating new archive for {user.id}") print(f"Creating new archive for {user.id}")
archive_name = req_data.get("name") or f"{user.email} Archive" archive_name = req_data.get("newArchiveName") or f"{user.name}'s Archive"
await self.archive_ops.create_new_archive_for_user( await self.archive_ops.create_new_archive_for_user(
archive_name=archive_name, archive_name=archive_name,
@ -161,7 +184,9 @@ def init_user_manager(mdb, emailsender):
def init_users_api(app, user_manager): def init_users_api(app, user_manager):
""" init fastapi_users """ """ init fastapi_users """
jwt_authentication = JWTAuthentication( jwt_authentication = JWTAuthentication(
secret=PASSWORD_SECRET, lifetime_seconds=3600, tokenUrl="/auth/jwt/login" secret=PASSWORD_SECRET,
lifetime_seconds=JWT_LIFETIME,
tokenUrl="/auth/jwt/login",
) )
fastapi_users = FastAPIUsers( fastapi_users = FastAPIUsers(
@ -173,11 +198,20 @@ def init_users_api(app, user_manager):
UserDB, UserDB,
) )
auth_router = fastapi_users.get_auth_router(jwt_authentication)
@auth_router.post("/refresh")
async def refresh_jwt(
response: Response, user=Depends(fastapi_users.current_user(active=True))
):
return await jwt_authentication.get_login_response(user, response, user_manager)
app.include_router( app.include_router(
fastapi_users.get_auth_router(jwt_authentication), auth_router,
prefix="/auth/jwt", prefix="/auth/jwt",
tags=["auth"], tags=["auth"],
) )
app.include_router( app.include_router(
fastapi_users.get_register_router(), fastapi_users.get_register_router(),
prefix="/auth", prefix="/auth",