Skip to content
This repository has been archived by the owner on Nov 30, 2022. It is now read-only.

Commit

Permalink
[Admin UI Backend] Login / Logout Endpoints [#242] (#277)
Browse files Browse the repository at this point in the history
Add user login/logout endpoints. Login provides a token with all scopes (for now) that can be used to help you authenticate requests.  Hitting the logout endpoint with this same token will delete the client all together.
  • Loading branch information
pattisdr authored Mar 15, 2022
1 parent 720d8fc commit 2c992e2
Show file tree
Hide file tree
Showing 7 changed files with 303 additions and 8 deletions.
52 changes: 52 additions & 0 deletions docs/fidesops/docs/guides/users.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Admin UI

We include some user-related endpoints for the Fidesops Admin UI. In this section, we'll cover:

- I need permissions to be able to create a user. So how do I create the first user?
- How do I create other users?
- How do I log in and log out?



## Creating the first user

To create the first user, run the following `make` command:

`make user`

After supplying a name and suitable password, this will create an Admin Root UI User that you can use to login and create other users.


## Logging in

`POST api/v1/login` with your username and password in the request, and you will be issued a token with all scopes (for now)
that can be used to make subsequent requests.

```json
{
"username": "test_username",
"password": "Suitablylongwithnumber8andsymbol$"
}
```


## Logging out

`POST api/v1/logout` with the user token as your Bearer Token. This token will be invalidated (by deleting the associated client).


## Creating users

`POST api/v1/user` with a token that has `user:create` scope, with your username and password in the request body.

```json
{
"username": "test_username",
"password": "Suitablylongwithnumber8andsymbol$"
}
```

## Deleting users

`DELETE api/v1/user/<user_id>` with a token that has `user:delete` scope. Additionally, you must either be the Admin Root UI User
or the user you're trying to delete.
69 changes: 69 additions & 0 deletions docs/fidesops/docs/postman/Fidesops.postman_collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -2527,6 +2527,59 @@
}
},
"response": []
},
{
"name": "User login",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"username\": \"{{username}}\",\n \"password\": \"{{password}}\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{host}}/login",
"host": [
"{{host}}"
],
"path": [
"login"
]
}
},
"response": []
},
{
"name": "User logout",
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{user_token}}",
"type": "string"
}
]
},
"method": "POST",
"header": [],
"url": {
"raw": "{{host}}/logout",
"host": [
"{{host}}"
],
"path": [
"logout"
]
}
},
"response": []
}
]
}
Expand Down Expand Up @@ -2689,6 +2742,22 @@
"key": "mailchimp_api_key",
"value": "",
"type": "string"
},
{
"key": "username",
"value": ""
},
{
"key": "password",
"value": ""
},
{
"key": "user_id",
"value": ""
},
{
"key": "user_token",
"value": ""
}
]
}
1 change: 1 addition & 0 deletions docs/fidesops/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ nav:
- Report on Privacy Requests: guides/reporting.md
- Configure OneTrust Integration: guides/onetrust.md
- Configuration Reference: guides/configuration_reference.md
- Admin UI: guides/users.md
- Deployment Guide: deployment.md
- Glossary: glossary.md
- API: api/index.md
Expand Down
58 changes: 56 additions & 2 deletions src/fidesops/api/v1/endpoints/user_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,17 @@
from fidesops.api.v1.urn_registry import V1_URL_PREFIX
from fidesops.models.client import ClientDetail, ADMIN_UI_ROOT
from fidesops.models.fidesops_user import FidesopsUser
from fidesops.schemas.user import UserCreate, UserCreateResponse
from fidesops.schemas.oauth import AccessToken
from fidesops.schemas.user import UserCreate, UserCreateResponse, UserLogin

from fidesops.util.oauth_util import verify_oauth_client
from sqlalchemy.orm import Session

from fidesops.api.v1.scope_registry import USER_CREATE, USER_DELETE
from fidesops.api.v1.scope_registry import (
USER_CREATE,
USER_DELETE,
SCOPE_REGISTRY,
)

logger = logging.getLogger(__name__)
router = APIRouter(tags=["Users"], prefix=V1_URL_PREFIX)
Expand Down Expand Up @@ -74,3 +79,52 @@ def delete_user(
logger.info(f"Deleting user with id: '{user_id}'.")

user.delete(db)


@router.post(
urls.LOGIN,
status_code=200,
response_model=AccessToken,
)
def user_login(
*, db: Session = Depends(deps.get_db), user_data: UserLogin
) -> AccessToken:
"""Login the user by creating a client if it doesn't exist, and have that client generate a token"""
user = FidesopsUser.get_by(db, field="username", value=user_data.username)

if not user:
raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="No user found.")

if not user.credentials_valid(user_data.password):
raise HTTPException(
status_code=HTTP_403_FORBIDDEN, detail="Incorrect password."
)

client: ClientDetail = user.client
if not client:
logger.info("Creating client for login")
client, _ = ClientDetail.create_client_and_secret(
db, SCOPE_REGISTRY, user_id=user.id
)

logger.info("Creating login access token")
access_code = client.create_access_code_jwe()
return AccessToken(access_token=access_code)


@router.post(
urls.LOGOUT,
status_code=204,
)
def user_logout(
*,
client: ClientDetail = Security(
verify_oauth_client,
scopes=[],
),
db: Session = Depends(deps.get_db),
) -> None:
"""Logout the user by deleting its client"""

logger.info("Logging out user.")
client.delete(db)
4 changes: 4 additions & 0 deletions src/fidesops/api/v1/urn_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,7 @@
# User URLs
USERS = "/user"
USER_DETAIL = "/user/{user_id}"

# Login URLs
LOGIN = "/login"
LOGOUT = "/logout"
7 changes: 7 additions & 0 deletions src/fidesops/schemas/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ def validate_password(cls, password: str) -> str:
return password


class UserLogin(BaseSchema):
"""Similar to UserCreate except we do not need the extra validation on username and password"""

username: str
password: str


class UserCreateResponse(BaseSchema):
"""Response after creating a FidesopsUser"""

Expand Down
120 changes: 114 additions & 6 deletions tests/api/v1/endpoints/test_user_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@

import pytest

from fidesops.api.v1.urn_registry import V1_URL_PREFIX, USERS
from fidesops.api.v1.urn_registry import V1_URL_PREFIX, USERS, LOGIN, LOGOUT
from fidesops.models.client import ClientDetail, ADMIN_UI_ROOT
from fidesops.api.v1.scope_registry import STORAGE_READ, USER_CREATE, USER_DELETE
from fidesops.api.v1.scope_registry import (
STORAGE_READ,
USER_CREATE,
USER_DELETE,
SCOPE_REGISTRY,
PRIVACY_REQUEST_READ,
)
from fidesops.models.fidesops_user import FidesopsUser
from fidesops.util.oauth_util import generate_jwe
from fidesops.util.oauth_util import generate_jwe, extract_payload
from fidesops.schemas.jwt import (
JWE_PAYLOAD_CLIENT_ID,
JWE_PAYLOAD_SCOPES,
Expand Down Expand Up @@ -227,8 +233,110 @@ def test_delete_user_as_root(self, api_client, db, generate_auth_header, user):
assert client_search is None

# Admin client who made the request is not deleted
admin_client_search = ClientDetail.get_by(
db, field="id", value=admin_client_id
)
admin_client_search = ClientDetail.get_by(db, field="id", value=admin_client_id)
assert admin_client_search is not None
admin_client_search.delete(db)


class TestUserLogin:
@pytest.fixture(scope="function")
def url(self, oauth_client: ClientDetail) -> str:
return V1_URL_PREFIX + LOGIN

def test_user_does_not_exist(self, db, url, api_client):
body = {"username": "does not exist", "password": "idonotknowmypassword"}
response = api_client.post(url, headers={}, json=body)
assert response.status_code == 404

def test_bad_login(self, db, url, user, api_client):
body = {"username": user.username, "password": "idonotknowmypassword"}
response = api_client.post(url, headers={}, json=body)
assert response.status_code == 403

def test_login_creates_client(self, db, url, user, api_client):
body = {"username": user.username, "password": "TESTdcnG@wzJeu0&%3Qe2fGo7"}

assert user.client is None # client does not exist

response = api_client.post(url, headers={}, json=body)
assert response.status_code == 200

db.refresh(user)
assert user.client is not None
assert list(response.json().keys()) == ["access_token"]
token = response.json()["access_token"]

token_data = json.loads(extract_payload(token))

assert token_data["client-id"] == user.client.id
assert token_data["scopes"] == SCOPE_REGISTRY

user.client.delete(db)

def test_login_uses_existing_client(self, db, url, user, api_client):
body = {"username": user.username, "password": "TESTdcnG@wzJeu0&%3Qe2fGo7"}

client, _ = ClientDetail.create_client_and_secret(
db, scopes=[PRIVACY_REQUEST_READ], user_id=user.id
)

response = api_client.post(url, headers={}, json=body)
assert response.status_code == 200

db.refresh(user)
assert user.client is not None
assert list(response.json().keys()) == ["access_token"]
token = response.json()["access_token"]

token_data = json.loads(extract_payload(token))

assert token_data["client-id"] == client.id
assert token_data["scopes"] == [
PRIVACY_REQUEST_READ
] # Uses scopes on existing client

client.delete(db)


class TestUserLogout:
@pytest.fixture(scope="function")
def url(self, oauth_client: ClientDetail) -> str:
return V1_URL_PREFIX + LOGOUT

def test_user_not_deleted_on_logout(self, db, url, api_client, user):
user_id = user.id
client, _ = ClientDetail.create_client_and_secret(
db, scopes=[PRIVACY_REQUEST_READ], user_id=user.id
)
client_id = client.id

payload = {
JWE_PAYLOAD_SCOPES: client.scopes,
JWE_PAYLOAD_CLIENT_ID: client.id,
JWE_ISSUED_AT: datetime.now().isoformat(),
}
auth_header = {"Authorization": "Bearer " + generate_jwe(json.dumps(payload))}
response = api_client.post(url, headers=auth_header, json={})
assert response.status_code == 204

# Verify client was deleted
client_search = ClientDetail.get_by(db, field="id", value=client_id)
assert client_search is None

# Assert user is not deleted
user_search = FidesopsUser.get_by(db, field="id", value=user_id)
assert user_search is not None

def test_logout(self, db, url, api_client, generate_auth_header, oauth_client):
oauth_client_id = oauth_client.id
auth_header = generate_auth_header([STORAGE_READ])
response = api_client.post(url, headers=auth_header, json={})
assert 204 == response.status_code

# Verify client was deleted
client_search = ClientDetail.get_by(db, field="id", value=oauth_client_id)
assert client_search is None

# Gets AuthorizationError - client does not exist, this token can't be used anymore
response = api_client.post(url, headers=auth_header, json={})
assert response.status_code == 403

0 comments on commit 2c992e2

Please sign in to comment.