Skip to content

Commit 8080b23

Browse files
Fix email security problem, switch to host located in settings (#20)
* Merge tests in fix brach * Fixtures, "forgot password" tests init, tests refactoring * email tests * fix path, fix security * deploy fix * add check tokens test
1 parent 4b27563 commit 8080b23

9 files changed

Lines changed: 78 additions & 25 deletions

File tree

.github/workflows/build_and_publish.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ jobs:
8787
--env EMAIL_PASS='${{ secrets.EMAIL_PASS }}' \
8888
--env SMTP_HOST='smtp.gmail.com' \
8989
--env SMTP_PORT='587' \
90+
--env HOST='${{ secrets.HOST }}' \
9091
--name ${{ env.CONTAITER_NAME }} \
9192
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:test
9293
@@ -131,5 +132,6 @@ jobs:
131132
--env ENABLED_AUTH_METHODS='${{ secrets.ENABLED_AUTH_METHODS }}' \
132133
--env SMTP_HOST='smtp.gmail.com' \
133134
--env SMTP_PORT='587' \
135+
--env HOST='${{ secrets.HOST }}' \
134136
--name ${{ env.CONTAITER_NAME }} \
135137
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

auth_backend/auth_plugins/auth_method.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@
66
from fastapi import APIRouter
77

88
from datetime import datetime
9-
from auth_backend.base import Base
9+
from auth_backend.base import Base, Token
1010

1111

12-
class Session(Base):
12+
class Session(Token):
1313
expires: datetime
1414
id: int
1515
user_id: int
16-
token: str
1716

1817

1918
AUTH_METHODS: dict[str, type[AuthMethodMeta]] = {}

auth_backend/auth_plugins/email.py

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
import hashlib
22
import random
33
import string
4+
5+
from fastapi import Request
46
from fastapi_sqlalchemy import db
5-
from fastapi import Request, HTTPException
67
from pydantic import validator, constr
8+
from sqlalchemy import func
79
from starlette.responses import JSONResponse
810

11+
from auth_backend.base import Base, ResponseModel
912
from auth_backend.exceptions import AlreadyExists, AuthFailed, ObjectNotFound, SessionExpired
1013
from auth_backend.models.db import AuthMethod
1114
from auth_backend.models.db import UserSession, User
1215
from auth_backend.settings import get_settings
1316
from auth_backend.utils.smtp import send_confirmation_email
1417
from .auth_method import AuthMethodMeta, Session
15-
from auth_backend.base import Base, ResponseModel
16-
from sqlalchemy import func
1718

1819
settings = get_settings()
1920

@@ -24,8 +25,12 @@ class EmailLogin(Base):
2425

2526
@validator('email')
2627
def check_email(cls, v):
28+
restricted: set[str] = {'"', '#', '&', "'", '(', ')', '*', ',', '/', ';', '<', '>', '?',
29+
'[', '\\', ']', '^', '`', '{', '|', '}', '~', '\n', '\r'}
2730
if "@" not in v:
2831
raise ValueError()
32+
if set(v) & restricted:
33+
raise ValueError()
2934
return v
3035

3136

@@ -51,12 +56,12 @@ def __init__(self):
5156
async def login(user_inp: EmailLogin) -> Session:
5257
query = (
5358
db.session.query(AuthMethod)
54-
.filter(
59+
.filter(
5560
func.lower(AuthMethod.value) == user_inp.email.lower(),
5661
AuthMethod.param == "email",
5762
AuthMethod.auth_method == Email.get_name(),
5863
)
59-
.one_or_none()
64+
.one_or_none()
6065
)
6166
if not query:
6267
raise AuthFailed(error="Incorrect login or password")
@@ -66,7 +71,7 @@ async def login(user_inp: EmailLogin) -> Session:
6671
error="Registration wasn't completed. Try to registrate again and do not forget to approve your email"
6772
)
6873
if secrets.get("email").lower() != user_inp.email.lower() or not Email.validate_password(
69-
user_inp.password, secrets.get("hashed_password"), secrets.get("salt")
74+
user_inp.password, secrets.get("hashed_password"), secrets.get("salt")
7075
):
7176
raise AuthFailed(error="Incorrect login or password")
7277
db.session.add(user_session := UserSession(user_id=query.user.id, token=random_string()))
@@ -120,22 +125,22 @@ async def _get_user_by_token_and_id(id: int, token: str) -> User | None:
120125
return user
121126

122127
@staticmethod
123-
async def register(user_inp: EmailRegister, request: Request) -> ResponseModel | JSONResponse:
128+
async def register(user_inp: EmailRegister) -> ResponseModel | JSONResponse:
124129
confirmation_token: str = random_string()
125130
auth_method: AuthMethod = (
126131
db.session.query(AuthMethod)
127-
.filter(
132+
.filter(
128133
AuthMethod.param == "email",
129134
func.lower(AuthMethod.value) == user_inp.email.lower(),
130135
AuthMethod.auth_method == Email.get_name(),
131136
)
132-
.one_or_none()
137+
.one_or_none()
133138
)
134139
if auth_method:
135140
await Email._change_confirmation_link(auth_method.user, confirmation_token)
136141
send_confirmation_email(
137142
to_addr=user_inp.email,
138-
link=f"{request.client.host}/email/approve?token={confirmation_token}",
143+
link=f"{settings.HOST}/email/approve?token={confirmation_token}",
139144
)
140145
return ResponseModel(status="Success", message="Email confirmation link sent")
141146
if user_inp.user_id and user_inp.token:
@@ -147,7 +152,7 @@ async def register(user_inp: EmailRegister, request: Request) -> ResponseModel |
147152
await Email._add_to_db(user_inp, confirmation_token, user)
148153
send_confirmation_email(
149154
to_addr=user_inp.email,
150-
link=f"{request.client.host}/email/approve?token={confirmation_token}",
155+
link=f"{settings.HOST}/email/approve?token={confirmation_token}",
151156
)
152157
return JSONResponse(
153158
status_code=201, content=ResponseModel(status="Success", message="Email confirmation link sent").json()
@@ -167,23 +172,23 @@ def validate_password(password: str, hashed_password: str, salt: str):
167172
async def approve_email(token: str) -> object:
168173
auth_method = (
169174
db.session.query(AuthMethod)
170-
.filter(
175+
.filter(
171176
AuthMethod.value == token,
172177
AuthMethod.param == "confirmation_token",
173178
AuthMethod.auth_method == Email.get_name(),
174179
)
175-
.one_or_none()
180+
.one_or_none()
176181
)
177182
if not auth_method:
178183
return JSONResponse(status_code=403, content=ResponseModel(status="Error", message="Incorrect link").json())
179184
confirmed = (
180185
db.session.query(AuthMethod)
181-
.filter(
186+
.filter(
182187
AuthMethod.auth_method == Email.get_name(),
183188
AuthMethod.param == "confirmed",
184189
AuthMethod.user_id == auth_method.user_id,
185190
)
186-
.one()
191+
.one()
187192
)
188193
confirmed.value = True
189194
db.session.flush()

auth_backend/base.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from pydantic import BaseModel
1+
from pydantic import BaseModel, constr
22

33

44
class Base(BaseModel):
@@ -15,3 +15,7 @@ class Config:
1515
class ResponseModel(Base):
1616
status: str
1717
message: str
18+
19+
20+
class Token(Base):
21+
token: constr(min_length=1)

auth_backend/routes/base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from auth_backend.auth_plugins.auth_method import AUTH_METHODS
66
from auth_backend.settings import get_settings
7-
from .logout import logout_router
7+
from .user_session import logout_router
88

99
settings = get_settings()
1010

@@ -15,6 +15,7 @@
1515
DBSessionMiddleware,
1616
db_url=settings.DB_DSN,
1717
session_args={"autocommit": True},
18+
engine_args={"pool_pre_ping": True}
1819
)
1920

2021
app.add_middleware(
Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@
44
from fastapi_sqlalchemy import db
55
from starlette.responses import JSONResponse
66

7-
from auth_backend.base import ResponseModel
8-
from auth_backend.exceptions import SessionExpired
9-
7+
from auth_backend.base import ResponseModel, Token
108
from auth_backend.exceptions import AuthFailed
9+
from auth_backend.exceptions import SessionExpired, ObjectNotFound
1110
from auth_backend.models.db import UserSession
1211

1312
logout_router = APIRouter(prefix="", tags=["Logout"])
@@ -23,3 +22,13 @@ async def logout(token: str) -> JSONResponse:
2322
session.expires = datetime.utcnow()
2423
db.session.flush()
2524
return JSONResponse(status_code=200, content=ResponseModel(status="Success", message="Logout successful").dict())
25+
26+
27+
@logout_router.post("/{user_id}/token", response_model=ResponseModel)
28+
async def check_token(user_id: int, token: Token) -> JSONResponse:
29+
session = db.session.query(UserSession).filter(UserSession.user_id == user_id, UserSession.token == token.token).one_or_none()
30+
if not session:
31+
return JSONResponse(status_code=404, content=ResponseModel(status="Error", message="Session not found").json())
32+
if session.expired:
33+
raise SessionExpired(token.token)
34+
return JSONResponse(status_code=200, content=ResponseModel(status="Success", message="Session found and exists").json())

auth_backend/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ class Settings(BaseSettings):
77
DB_DSN: PostgresDsn
88

99
EMAIL: str | None
10+
HOST: str = "localhost"
1011
EMAIL_PASS: str = None
1112
SMTP_HOST: str = 'smtp.gmail.com'
1213
SMTP_PORT: int = 587

tests/test_routes/test_login.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,30 @@ def test_incorrect_data(client: TestClient, dbsession: Session):
7777
dbsession.delete(row)
7878
dbsession.delete(dbsession.query(User).filter(User.id == id).one())
7979
dbsession.flush()
80+
81+
82+
83+
def test_check_token(client: TestClient, user, dbsession: Session):
84+
user_id, body, login = user["user_id"], user["body"], user["login_json"]
85+
86+
response = client.post(f"/{user_id}/token", json={"token": login["token"] + "2"})
87+
assert response.status_code == status.HTTP_404_NOT_FOUND
88+
89+
response = client.post(f"/{user_id}/token", json={"token": login["token"]})
90+
assert response.status_code == status.HTTP_200_OK
91+
92+
response = client.post(f"/logout?token={login['token']}")
93+
assert response.status_code == status.HTTP_200_OK
94+
95+
response = client.post(f"/{user_id}/token", json={"token": login["token"]})
96+
assert response.status_code == status.HTTP_403_FORBIDDEN
97+
98+
99+
def test_invalid_check_tokens(client: TestClient, user):
100+
user_id, body, login = user["user_id"], user["body"], user["login_json"]
101+
response = client.post(f"/{user_id}/token", json={"token": ""})
102+
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
103+
104+
response = client.post(f"/{user_id}/token")
105+
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
106+

tests/test_routes/test_registration.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,17 @@ def test_invalid_email(client: TestClient):
2424
"password": "&%@#$@322îïíīįì3@##EFWed}efvef{}{}{}[èéêëēėę'"
2525
}
2626
body4 = {
27-
"email": f"EmailFor+#&*|_ Sur{datetime.datetime.utcnow()}e@mail.gtg",
27+
"email": f"EmailFor+ _Sur{datetime.datetime.utcnow()}e@mail.gtg",
2828
"password": "string2222"
2929
}
3030
body5 = {
3131
"email": f"Email For Sure {datetime.datetime.utcnow()} @ mail. gtg",
3232
"password": "string"
3333
}
34+
body6 = {
35+
"email": f"roman@dyakov.space\nContent-Type: text/html; charset=utf-8;\n\nАхаха,лох<!---",
36+
"password": "string"
37+
}
3438
response = client.post(url, json=body1)
3539
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
3640
response = client.post(url, json=body2)
@@ -41,7 +45,8 @@ def test_invalid_email(client: TestClient):
4145
assert response.status_code == status.HTTP_201_CREATED
4246
response = client.post(url, json=body5)
4347
assert response.status_code == status.HTTP_201_CREATED
44-
48+
response = client.post(url, json=body6)
49+
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
4550

4651

4752
def test_main_scenario(client: TestClient, dbsession: Session):

0 commit comments

Comments
 (0)