1
0
mirror of https://github.com/GNS3/gns3-server synced 2025-01-13 01:20:58 +00:00

Secure users API and handle manual password recovery.

This commit is contained in:
grossmj 2021-04-19 09:40:04 +09:30
parent c03226e368
commit e28452f09a
7 changed files with 92 additions and 39 deletions

View File

@ -44,6 +44,10 @@ async def get_user_from_token(
async def get_current_active_user(current_user: schemas.User = Depends(get_user_from_token)) -> schemas.User: async def get_current_active_user(current_user: schemas.User = Depends(get_user_from_token)) -> schemas.User:
# Super admin is always authorized
if current_user.is_superadmin:
return current_user
if not current_user.is_active: if not current_user.is_active:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,

View File

@ -45,7 +45,10 @@ router = APIRouter()
@router.get("", response_model=List[schemas.User]) @router.get("", response_model=List[schemas.User])
async def get_users(users_repo: UsersRepository = Depends(get_repository(UsersRepository))) -> List[schemas.User]: async def get_users(
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
current_user: schemas.User = Depends(get_current_active_user)
) -> List[schemas.User]:
""" """
Get all users. Get all users.
""" """
@ -55,7 +58,9 @@ async def get_users(users_repo: UsersRepository = Depends(get_repository(UsersRe
@router.post("", response_model=schemas.User, status_code=status.HTTP_201_CREATED) @router.post("", response_model=schemas.User, status_code=status.HTTP_201_CREATED)
async def create_user( async def create_user(
user_create: schemas.UserCreate, users_repo: UsersRepository = Depends(get_repository(UsersRepository)) user_create: schemas.UserCreate,
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
current_user: schemas.User = Depends(get_current_active_user)
) -> schemas.User: ) -> schemas.User:
""" """
Create a new user. Create a new user.
@ -70,9 +75,11 @@ async def create_user(
return await users_repo.create_user(user_create) return await users_repo.create_user(user_create)
@router.get("/{user_id}", response_model=schemas.User) @router.get("/{user_id}",response_model=schemas.User)
async def get_user( async def get_user(
user_id: UUID, users_repo: UsersRepository = Depends(get_repository(UsersRepository)) user_id: UUID,
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
current_user: schemas.User = Depends(get_current_active_user)
) -> schemas.User: ) -> schemas.User:
""" """
Get an user. Get an user.
@ -86,9 +93,10 @@ async def get_user(
@router.put("/{user_id}", response_model=schemas.User) @router.put("/{user_id}", response_model=schemas.User)
async def update_user( async def update_user(
user_id: UUID, user_id: UUID,
user_update: schemas.UserUpdate, user_update: schemas.UserUpdate,
users_repo: UsersRepository = Depends(get_repository(UsersRepository)), users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
current_user: schemas.User = Depends(get_current_active_user)
) -> schemas.User: ) -> schemas.User:
""" """
Update an user. Update an user.
@ -111,7 +119,7 @@ async def delete_user(
""" """
if current_user.is_superadmin: if current_user.is_superadmin:
raise ControllerForbiddenError("The super user cannot be deleted") raise ControllerForbiddenError("The super admin cannot be deleted")
success = await users_repo.delete_user(user_id) success = await users_repo.delete_user(user_id)
if not success: if not success:

View File

@ -37,6 +37,7 @@ class User(BaseTable):
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
is_superadmin = Column(Boolean, default=False) is_superadmin = Column(Boolean, default=False)
@event.listens_for(User.__table__, 'after_create') @event.listens_for(User.__table__, 'after_create')
def create_default_super_admin(target, connection, **kw): def create_default_super_admin(target, connection, **kw):
@ -49,4 +50,4 @@ def create_default_super_admin(target, connection, **kw):
) )
connection.execute(stmt) connection.execute(stmt)
connection.commit() connection.commit()
log.info("Default super admin account added") log.info("The default super admin account has been created in the database")

View File

@ -26,6 +26,10 @@ import gns3server.db.models as models
from gns3server import schemas from gns3server import schemas
from gns3server.services import auth_service from gns3server.services import auth_service
import logging
log = logging.getLogger(__name__)
class UsersRepository(BaseRepository): class UsersRepository(BaseRepository):
def __init__(self, db_session: AsyncSession) -> None: def __init__(self, db_session: AsyncSession) -> None:
@ -93,6 +97,13 @@ class UsersRepository(BaseRepository):
user = await self.get_user_by_username(username) user = await self.get_user_by_username(username)
if not user: if not user:
return None return None
# Allow user to be authenticated if hashed password in the db is null
# this is useful for manual password recovery like:
# sqlite3 gns3_controller.db "UPDATE users SET hashed_password = null WHERE username = 'admin';"
if user.hashed_password is None:
log.warning(f"User '{username}' has been authenticated without a password "
f"configured. Please set a new password.")
return user
if not self._auth_service.verify_password(password, user.hashed_password): if not self._auth_service.verify_password(password, user.hashed_password):
return None return None
return user return user

View File

@ -37,7 +37,7 @@ class UserCreate(UserBase):
""" """
username: str = Field(..., min_length=3, regex="[a-zA-Z0-9_-]+$") username: str = Field(..., min_length=3, regex="[a-zA-Z0-9_-]+$")
password: SecretStr = Field(..., min_length=7, max_length=100) password: SecretStr = Field(..., min_length=6, max_length=100)
class UserUpdate(UserBase): class UserUpdate(UserBase):
@ -45,7 +45,7 @@ class UserUpdate(UserBase):
Properties to update an user. Properties to update an user.
""" """
password: Optional[SecretStr] = Field(None, min_length=7, max_length=100) password: Optional[SecretStr] = Field(None, min_length=6, max_length=100)
class User(DateTimeModelMixin, UserBase): class User(DateTimeModelMixin, UserBase):

View File

@ -19,15 +19,16 @@ import pytest
from typing import Optional from typing import Optional
from fastapi import FastAPI, HTTPException, status from fastapi import FastAPI, HTTPException, status
from sqlalchemy import update
from httpx import AsyncClient from httpx import AsyncClient
from jose import jwt from jose import jwt
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from gns3server.db.repositories.users import UsersRepository from gns3server.db.repositories.users import UsersRepository
from gns3server.services import auth_service from gns3server.services import auth_service
from gns3server.services.authentication import DEFAULT_JWT_SECRET_KEY
from gns3server.config import Config from gns3server.config import Config
from gns3server.schemas.controller.users import User from gns3server.schemas.controller.users import User
import gns3server.db.models as models
pytestmark = pytest.mark.asyncio pytestmark = pytest.mark.asyncio
@ -38,7 +39,7 @@ class TestUserRoutes:
new_user = {"username": "user1", "email": "user1@email.com", "password": "test_password"} new_user = {"username": "user1", "email": "user1@email.com", "password": "test_password"}
response = await client.post(app.url_path_for("create_user"), json=new_user) response = await client.post(app.url_path_for("create_user"), json=new_user)
assert response.status_code != status.HTTP_404_NOT_FOUND assert response.status_code == status.HTTP_201_CREATED
async def test_users_can_register_successfully( async def test_users_can_register_successfully(
self, self,
@ -188,18 +189,18 @@ class TestUserLogin:
async def test_user_can_login_successfully_and_receives_valid_token( async def test_user_can_login_successfully_and_receives_valid_token(
self, self,
app: FastAPI, app: FastAPI,
client: AsyncClient, unauthorized_client: AsyncClient,
test_user: User, test_user: User,
config: Config config: Config
) -> None: ) -> None:
jwt_secret = config.settings.Controller.jwt_secret_key jwt_secret = config.settings.Controller.jwt_secret_key
client.headers["content-type"] = "application/x-www-form-urlencoded" unauthorized_client.headers["content-type"] = "application/x-www-form-urlencoded"
login_data = { login_data = {
"username": test_user.username, "username": test_user.username,
"password": "user1_password", "password": "user1_password",
} }
res = await client.post(app.url_path_for("login"), data=login_data) res = await unauthorized_client.post(app.url_path_for("login"), data=login_data)
assert res.status_code == status.HTTP_200_OK assert res.status_code == status.HTTP_200_OK
# check that token exists in response and has user encoded within it # check that token exists in response and has user encoded within it
@ -224,19 +225,19 @@ class TestUserLogin:
async def test_user_with_wrong_creds_doesnt_receive_token( async def test_user_with_wrong_creds_doesnt_receive_token(
self, self,
app: FastAPI, app: FastAPI,
client: AsyncClient, unauthorized_client: AsyncClient,
test_user: User, test_user: User,
username: str, username: str,
password: str, password: str,
status_code: int, status_code: int,
) -> None: ) -> None:
client.headers["content-type"] = "application/x-www-form-urlencoded" unauthorized_client.headers["content-type"] = "application/x-www-form-urlencoded"
login_data = { login_data = {
"username": username, "username": username,
"password": password, "password": password,
} }
res = await client.post(app.url_path_for("login"), data=login_data) res = await unauthorized_client.post(app.url_path_for("login"), data=login_data)
assert res.status_code == status_code assert res.status_code == status_code
assert "access_token" not in res.json() assert "access_token" not in res.json()
@ -259,11 +260,11 @@ class TestUserMe:
async def test_user_cannot_access_own_data_if_not_authenticated( async def test_user_cannot_access_own_data_if_not_authenticated(
self, app: FastAPI, self, app: FastAPI,
client: AsyncClient, unauthorized_client: AsyncClient,
test_user: User, test_user: User,
) -> None: ) -> None:
res = await client.get(app.url_path_for("get_current_active_user")) res = await unauthorized_client.get(app.url_path_for("get_current_active_user"))
assert res.status_code == status.HTTP_401_UNAUTHORIZED assert res.status_code == status.HTTP_401_UNAUTHORIZED
@ -284,11 +285,31 @@ class TestSuperAdmin:
async def test_cannot_delete_super_admin( async def test_cannot_delete_super_admin(
self, self,
app: FastAPI, app: FastAPI,
admin_client: AsyncClient, client: AsyncClient,
db_session: AsyncSession db_session: AsyncSession
) -> None: ) -> None:
user_repo = UsersRepository(db_session) user_repo = UsersRepository(db_session)
admin_in_db = await user_repo.get_user_by_username("admin") admin_in_db = await user_repo.get_user_by_username("admin")
res = await admin_client.delete(app.url_path_for("delete_user", user_id=admin_in_db.user_id)) res = await client.delete(app.url_path_for("delete_user", user_id=admin_in_db.user_id))
assert res.status_code == status.HTTP_403_FORBIDDEN assert res.status_code == status.HTTP_403_FORBIDDEN
async def test_admin_can_login_after_password_recovery(
self,
app: FastAPI,
unauthorized_client: AsyncClient,
db_session: AsyncSession
) -> None:
# set the admin password to null in the database
query = update(models.User).where(models.User.username == "admin").values(hashed_password=None)
await db_session.execute(query)
await db_session.commit()
unauthorized_client.headers["content-type"] = "application/x-www-form-urlencoded"
login_data = {
"username": "admin",
"password": "whatever",
}
res = await unauthorized_client.post(app.url_path_for("login"), data=login_data)
assert res.status_code == status.HTTP_200_OK

View File

@ -94,7 +94,7 @@ async def db_session(db_engine):
@pytest.fixture @pytest.fixture
async def client(app: FastAPI, db_session: AsyncSession) -> AsyncClient: async def base_client(app: FastAPI, db_session: AsyncSession) -> AsyncClient:
async def _get_test_db(): async def _get_test_db():
try: try:
@ -108,8 +108,8 @@ async def client(app: FastAPI, db_session: AsyncSession) -> AsyncClient:
app=app, app=app,
base_url="http://test-api", base_url="http://test-api",
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"}
) as client: ) as async_client:
yield client yield async_client
@pytest.fixture @pytest.fixture
@ -147,25 +147,33 @@ async def test_compute(db_session: AsyncSession) -> Compute:
@pytest.fixture @pytest.fixture
def authorized_client(client: AsyncClient, test_user: User) -> AsyncClient: def unauthorized_client(base_client: AsyncClient, test_user: User) -> AsyncClient:
return base_client
access_token = auth_service.create_access_token(test_user.username)
client.headers = {
**client.headers,
"Authorization": f"Bearer {access_token}",
}
return client
@pytest.fixture @pytest.fixture
async def admin_client(client: AsyncClient) -> AsyncClient: def authorized_client(base_client: AsyncClient, test_user: User) -> AsyncClient:
# user "admin" is automatically created when the users table is created access_token = auth_service.create_access_token(test_user.username)
access_token = auth_service.create_access_token("admin") base_client.headers = {
client.headers = { **base_client.headers,
**client.headers,
"Authorization": f"Bearer {access_token}", "Authorization": f"Bearer {access_token}",
} }
return client return base_client
@pytest.fixture
async def client(base_client: AsyncClient) -> AsyncClient:
# The super admin is automatically created when the users table is created
# this account that can access all endpoints without restrictions.
access_token = auth_service.create_access_token("admin")
base_client.headers = {
**base_client.headers,
"Authorization": f"Bearer {access_token}",
}
return base_client
@pytest.fixture @pytest.fixture
def controller_config_path(tmpdir): def controller_config_path(tmpdir):