Skip to content

Commit

Permalink
Merge pull request #15 from JohnGrubba/dev
Browse files Browse the repository at this point in the history
Session Management
  • Loading branch information
JohnGrubba authored Jul 23, 2024
2 parents 7412087 + faebd98 commit 30a7c25
Show file tree
Hide file tree
Showing 16 changed files with 368 additions and 29 deletions.
4 changes: 3 additions & 1 deletion config/configtemplate.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@
"issuer_name": "EZAuth",
"issuer_image_url": "",
"qr_endpoint": true
}
},
"allow_add_fields_on_signup": [],
"allow_add_fields_patch_user": []
},
"security": {
"allow_origins": [
Expand Down
6 changes: 6 additions & 0 deletions docs/configuration/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ Make sure that all parameters are set correctly before starting the service.
| `account_features.2fa.issuer_name` | **Datatype:** String <br> **Default:** `"EZAuth"` <br> How the two factor code will be titled in the users 2FA App. (Mostly the App Name) |
| `account_features.2fa.issuer_image_url` | **Datatype:** String <br> **Default:** `""` <br> URL for an optional Image which will be displayed in the 2FA App. |
| `account_features.2fa.qr_endpoint` | **Datatype:** Boolean <br> **Default:** `true` <br> Enable or disable QR Code Generation Endpoint for 2FA Login. This can be useful if you don't want to use any libraries on the client Side. |
| `account_features.allow_add_fields_on_signup` | **Datatype:** List <br> **Default:** `[]` <br> Allow those additional fields on signup. Leave empty if not sure. |
| `account_features.allow_add_fields_patch_user` | **Datatype:** List <br> **Default:** `[]` <br> Allow those additional fields to be set when modifying user. Leave empty if not sure. The entries here extend already set `account_features.allow_add_fields_on_signup` fields. |

!!! Note "Additional Fields"
The `allow_add_fields_on_signup` makes it possible to add custom fields to the signup process. If you don't set the fields that are allowed here on signup, you can't update them later, except you also have them in `allow_add_fields_patch_user`.


### Security Configuration

Expand Down
59 changes: 57 additions & 2 deletions src/api/conftest.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import pytest
import datetime
import uuid
import bcrypt
from tools import users_collection
from tools import users_collection, sessions_collection
from bson import ObjectId


@pytest.fixture
Expand All @@ -15,7 +17,60 @@ def fixtureuser() -> dict:
"email": "[email protected]",
"username": "FixtureUser",
"createdAt": datetime.datetime.now(),
"test": True,
}
users_collection.insert_one(user_data)
result = users_collection.insert_one(user_data)
user_data["password"] = "Kennwort1!"
user_data["_id"] = str(result.inserted_id)
return user_data


@pytest.fixture
def fixtureuser2() -> dict:
users_collection.find_one_and_delete({"email": "[email protected]"})
hashed_pswd = bcrypt.hashpw("Kennwort1!".encode("utf-8"), bcrypt.gensalt(5)).decode(
"utf-8"
)
user_data = {
"password": hashed_pswd,
"email": "[email protected]",
"username": "FixtureUser1",
"createdAt": datetime.datetime.now(),
}
result = users_collection.insert_one(user_data)
user_data["password"] = "Kennwort1!"
user_data["_id"] = str(result.inserted_id)
return user_data


@pytest.fixture
def fixturesessiontoken_user(fixtureuser) -> tuple[str, dict]:
sessions_collection.delete_many({})
# Generate a new session token
session_token = str(uuid.uuid4())
# Persist the session
session_id = sessions_collection.insert_one(
{
"session_token": session_token,
"user_id": ObjectId(fixtureuser["_id"]),
"createdAt": datetime.datetime.now(),
"device_information": {},
}
)
return session_token, fixtureuser, session_id.inserted_id


@pytest.fixture
def fixturesessiontoken_user2(fixtureuser2) -> tuple[str, dict]:
# Generate a new session token
session_token = str(uuid.uuid4())
# Persist the session
session_id = sessions_collection.insert_one(
{
"session_token": session_token,
"user_id": ObjectId(fixtureuser2["_id"]),
"createdAt": datetime.datetime.now(),
"device_information": {},
}
)
return session_token, fixtureuser2, session_id.inserted_id
18 changes: 9 additions & 9 deletions src/api/dependencies/authenticated.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ async def get_pub_user_dep(
session_token (str, optional): Session Token. Defaults to Cookie(default=None, alias=SessionConfig.auto_cookie_name).
Raises:
HTTPException: _description_
HTTPException: _description_
HTTPException: _description_
HTTPException: No Session Token
HTTPException: No Session Found
HTTPException: No User for Session Found
Returns:
User: User Dictionary
Expand Down Expand Up @@ -44,9 +44,9 @@ async def get_user_dep(
session_token (str, optional): Session Token. Defaults to Cookie(default=None, alias=SessionConfig.auto_cookie_name).
Raises:
HTTPException: _description_
HTTPException: _description_
HTTPException: _description_
HTTPException: No Session Token
HTTPException: No Session Found
HTTPException: No User for Session Found
Returns:
User: User Dictionary
Expand Down Expand Up @@ -74,9 +74,9 @@ async def get_dangerous_user_dep(
session_token (str, optional): Session Token. Defaults to Cookie(default=None, alias=SessionConfig.auto_cookie_name).
Raises:
HTTPException: _description_
HTTPException: _description_
HTTPException: _description_
HTTPException: No Session Token
HTTPException: No Session Found
HTTPException: No User for Session Found
Returns:
User: User Dictionary
Expand Down
4 changes: 3 additions & 1 deletion src/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from api.profile import router as profileRouter
from api.twofactor import router as twofactorRouter
from api.oauth_providers import router as oauthRouter
from api.sessions import router as sessionsRouter
import logging
from tools import SecurityConfig

Expand Down Expand Up @@ -44,7 +45,8 @@ async def up():
app.include_router(router)
app.include_router(signupRouter)
app.include_router(loginRouter)
app.include_router(internalRouter)
app.include_router(profileRouter)
app.include_router(sessionsRouter)
app.include_router(twofactorRouter)
app.include_router(oauthRouter)
app.include_router(internalRouter)
14 changes: 12 additions & 2 deletions src/api/model.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
from pydantic import BaseModel, field_validator, EmailStr, SecretStr, ConfigDict
from typing import Optional
from pydantic import BaseModel, field_validator, EmailStr, SecretStr, ConfigDict, Field
from typing import Optional, List
import re
import bcrypt


class SessionDetailResponse(BaseModel):
id: str = Field(alias="_id")
device_information: dict
createdAt: str


class SessionListResponseModel(BaseModel):
sessions: List[SessionDetailResponse]


class ConfirmEmailRequest(BaseModel):
code: int | str

Expand Down
95 changes: 95 additions & 0 deletions src/api/profile_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
from fastapi.testclient import TestClient
from crud.user import get_dangerous_user

from .main import app

client = TestClient(app)


# Unauthorized Route Test
def test_unauthorized_profile():
response = client.get("/profile")
assert response.status_code == 401


def test_get_profile_invalid_session():
client.cookies.set("session", "invalid")
response = client.get("/profile")
assert response.status_code == 401


# Successfull tests
def test_get_profile(fixturesessiontoken_user):
client.cookies.set("session", fixturesessiontoken_user[0])
response = client.get("/profile")
resp_json = response.json()
assert response.status_code == 200
assert resp_json.get("email") == fixturesessiontoken_user[1]["email"]
assert resp_json.get("username") == fixturesessiontoken_user[1]["username"]
assert resp_json.get("createdAt") is not None
assert resp_json.get("password") is None
assert resp_json.get("_id") is None


def test_update_username_valid(fixturesessiontoken_user):
client.cookies.set("session", fixturesessiontoken_user[0])
response = client.patch("/profile", json={"username": "newusername"})
resp_json = response.json()
assert response.status_code == 200
assert resp_json.get("email") == fixturesessiontoken_user[1]["email"]
assert resp_json.get("username") == "newusername"
assert resp_json.get("createdAt") is not None


def test_update_email(fixturesessiontoken_user):
client.cookies.set("session", fixturesessiontoken_user[0])
# E-Mail should not be updateable through this endpoint
response = client.patch("/profile", json={"email": "[email protected]"})
resp_json = response.json()
assert response.status_code == 200
assert resp_json.get("email") == fixturesessiontoken_user[1]["email"]
assert resp_json.get("username") == fixturesessiontoken_user[1]["username"]
assert resp_json.get("createdAt") is not None


def test_update_password(fixturesessiontoken_user):
client.cookies.set("session", fixturesessiontoken_user[0])
# Password should not be updateable through this endpoint
response = client.patch("/profile", json={"password": "Kennwort2!"})
assert response.status_code == 200
assert (
get_dangerous_user(fixturesessiontoken_user[1]["_id"])["password"]
!= "Kennwort2!"
)


def test_update_createdAt(fixturesessiontoken_user):
client.cookies.set("session", fixturesessiontoken_user[0])
# Password should not be updateable through this endpoint
response = client.patch("/profile", json={"createdAt": "1921"})
assert response.status_code == 200
assert get_dangerous_user(fixturesessiontoken_user[1]["_id"])["createdAt"] != "1921"


def test_update_additional_data_valid(fixturesessiontoken_user):
client.cookies.set("session", fixturesessiontoken_user[0])
# Data that is not existent, but updateable should be possible
response = client.patch("/profile", json={"test2": "1921"})
assert response.status_code == 200
assert get_dangerous_user(fixturesessiontoken_user[1]["_id"]).get("test2") == "1921"


def test_update_nonexistent_data(fixturesessiontoken_user):
client.cookies.set("session", fixturesessiontoken_user[0])
# Additional data should not be updateable through this endpoint
response = client.patch("/profile", json={"sas": "1921"})
assert response.status_code == 200
assert get_dangerous_user(fixturesessiontoken_user[1]["_id"]).get("sas") is None


def test_update_additional_existent_data(fixturesessiontoken_user):
client.cookies.set("session", fixturesessiontoken_user[0])
# Data that is additional but already exists should also be updateable
response = client.patch("/profile", json={"test": "Ya"})
assert response.status_code == 200
assert get_dangerous_user(fixturesessiontoken_user[1]["_id"]).get("test") == "Ya"
41 changes: 41 additions & 0 deletions src/api/sessions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from fastapi import APIRouter, Depends, Response, HTTPException
from api.dependencies.authenticated import get_user_dep
from tools import bson_to_json
from crud.sessions import get_user_sessions, delete_session, get_session_by_id
from api.model import SessionListResponseModel

router = APIRouter(
prefix="/sessions",
tags=["Sessions"],
dependencies=[Depends(get_user_dep)],
)


@router.get("", response_model=SessionListResponseModel)
async def sessions_list(user: dict = Depends(get_user_dep)):
"""
# Get Sessions
## Description
This endpoint is used to get the sessions of the user.
"""
sesss = get_user_sessions(user["_id"])
sesss = [bson_to_json(sess) for sess in sesss]
return {"sessions": sesss}


@router.delete("/{session_id}")
async def delete_other_session(session_id: str, user: dict = Depends(get_user_dep)):
"""
# Delete Session
## Description
This endpoint is used to delete a session.
"""
sess_to_delete = get_session_by_id(session_id)
if not sess_to_delete:
raise HTTPException(status_code=404, detail="Session not found.")
if sess_to_delete["user_id"] != user["_id"]:
raise HTTPException(status_code=404, detail="Session not found.")
delete_session(sess_to_delete["session_token"])
return Response(status_code=204)
36 changes: 36 additions & 0 deletions src/api/sessions_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from fastapi.testclient import TestClient

from .main import app


client = TestClient(app)


# SUCCESSFUL TESTS
def test_get_sessions(fixturesessiontoken_user):
client.cookies.set("session", fixturesessiontoken_user[0])
response = client.get("/sessions")
resp_js = response.json()
assert response.status_code == 200
assert isinstance(resp_js.get("sessions"), list)
assert len(resp_js.get("sessions")) == 1
# Don't leak out session_token
assert resp_js.get("sessions")[0].get("session_token") is None


def test_delete_session(fixturesessiontoken_user):
client.cookies.set("session", fixturesessiontoken_user[0])
response = client.delete("/sessions/" + str(fixturesessiontoken_user[2]))
assert response.status_code == 204


def test_delete_session_otheracc(fixturesessiontoken_user, fixturesessiontoken_user2):
client.cookies.set("session", fixturesessiontoken_user[0])
response = client.delete("/sessions/" + str(fixturesessiontoken_user2[2]))
assert response.status_code == 404


def test_delete_session_nonexistent(fixturesessiontoken_user):
client.cookies.set("session", fixturesessiontoken_user[0])
response = client.delete("/sessions/test")
assert response.status_code == 404
7 changes: 7 additions & 0 deletions src/api/signup_test.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from fastapi.testclient import TestClient
from tools import users_collection

from .main import app

Expand Down Expand Up @@ -37,6 +38,12 @@ def test_create_account_additional_data():
assert response.status_code == 200
assert resp_js.get("session_token") is not None
assert int(resp_js.get("expires")) is not None
# Check which aditional data was saved
usr = users_collection.find_one({"email": "[email protected]"})
# Check which data should be saved (testing_conf.json)
assert usr.get("test") is True
assert usr.get("test2") is None
assert usr.get("test3") is None


# MISSING DATA TESTS
Expand Down
Loading

0 comments on commit 30a7c25

Please sign in to comment.