Skip to content

Commit cec69ea

Browse files
authored
feat: login and registration (#195)
1 parent 8cd3a6e commit cec69ea

22 files changed

+887
-15
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ dmypy.json
145145
# Pyre type checker
146146
.pyre/
147147

148+
149+
# register stuff
150+
run.txt
148151
# VScode
149152
.vscode/
150153
app/.vscode/

README.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ virtualenv env
3535
pip install -r requirements.txt
3636
# Copy app\config.py.example to app\config.py.
3737
# Edit the variables' values.
38+
# Rendering JWT_KEY:
39+
python -c "import secrets; from pathlib import Path; f = Path('app/config.py'); f.write_text(f.read_text().replace('JWT_KEY_PLACEHOLDER', secrets.token_hex(32), 1));"
3840
uvicorn app.main:app --reload
3941
```
4042

@@ -45,12 +47,8 @@ source venv/bin/activate
4547
pip install -r requirements.txt
4648
cp app/config.py.example app/config.py
4749
# Edit the variables' values.
48-
uvicorn app.main:app --reload
49-
```
50-
### Running tests
51-
```shell
52-
python -m pytest --cov-report term-missing --cov=app tests
53-
```
50+
# Rendering JWT_KEY:
51+
python -c "import secrets; from pathlib import Path; f = Path('app/config.py'); f.write_text(f.read_text().replace('JWT_KEY_PLACEHOLDER', secrets.token_hex(32), 1));"
5452

5553
## Contributing
5654
View [contributing guidelines](https://github.com/PythonFreeCourse/calendar/blob/master/CONTRIBUTING.md).

app/config.py.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ email_conf = ConnectionConfig(
5252
USE_CREDENTIALS=True,
5353
)
5454

55+
56+
# security
57+
JWT_KEY = "JWT_KEY_PLACEHOLDER"
58+
JWT_ALGORITHM = "HS256"
59+
JWT_MIN_EXP = 60 * 24 * 7
5560
templates = Jinja2Templates(directory=os.path.join("app", "templates"))
5661

5762
# application name

app/database/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class User(Base):
3131
avatar = Column(String, default="profile.png")
3232
telegram_id = Column(String, unique=True)
3333
is_active = Column(Boolean, default=False)
34+
is_manager = Column(Boolean, default=False)
3435
language_id = Column(Integer, ForeignKey("languages.id"))
3536

3637
owned_events = relationship(
@@ -47,6 +48,12 @@ class User(Base):
4748
def __repr__(self):
4849
return f'<User {self.id}>'
4950

51+
@staticmethod
52+
async def get_by_username(db: Session, username: str) -> User:
53+
"""query database for a user by unique username"""
54+
return db.query(User).filter(
55+
User.username == username).first()
56+
5057

5158
class Event(Base):
5259
__tablename__ = "events"

app/database/schemas.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from typing import Optional, Union
2+
from pydantic import BaseModel, validator, EmailStr, EmailError
3+
4+
5+
EMPTY_FIELD_STRING = 'field is required'
6+
MIN_FIELD_LENGTH = 3
7+
MAX_FIELD_LENGTH = 20
8+
9+
10+
def fields_not_empty(field: Optional[str]) -> Union[ValueError, str]:
11+
"""Global function to validate fields are not empty."""
12+
if not field:
13+
raise ValueError(EMPTY_FIELD_STRING)
14+
return field
15+
16+
17+
class UserBase(BaseModel):
18+
"""
19+
Validating fields types
20+
Returns a User object without sensitive information
21+
"""
22+
username: str
23+
email: str
24+
full_name: str
25+
description: Optional[str] = None
26+
27+
class Config:
28+
orm_mode = True
29+
30+
31+
class UserCreate(UserBase):
32+
"""Validating fields types"""
33+
password: str
34+
confirm_password: str
35+
36+
"""
37+
Calling to field_not_empty validaion function,
38+
for each required field.
39+
"""
40+
_fields_not_empty_username = validator(
41+
'username', allow_reuse=True)(fields_not_empty)
42+
_fields_not_empty_full_name = validator(
43+
'full_name', allow_reuse=True)(fields_not_empty)
44+
_fields_not_empty_password = validator(
45+
'password', allow_reuse=True)(fields_not_empty)
46+
_fields_not_empty_confirm_password = validator(
47+
'confirm_password', allow_reuse=True)(fields_not_empty)
48+
_fields_not_empty_email = validator(
49+
'email', allow_reuse=True)(fields_not_empty)
50+
51+
@validator('confirm_password')
52+
def passwords_match(
53+
cls, confirm_password: str,
54+
values: UserBase) -> Union[ValueError, str]:
55+
"""Validating passwords fields identical."""
56+
if 'password' in values and confirm_password != values['password']:
57+
raise ValueError("doesn't match to password")
58+
return confirm_password
59+
60+
@validator('username')
61+
def username_length(cls, username: str) -> Union[ValueError, str]:
62+
"""Validating username length is legal"""
63+
if not (MIN_FIELD_LENGTH < len(username) < MAX_FIELD_LENGTH):
64+
raise ValueError("must contain between 3 to 20 charactars")
65+
return username
66+
67+
@validator('password')
68+
def password_length(cls, password: str) -> Union[ValueError, str]:
69+
"""Validating username length is legal"""
70+
if not (MIN_FIELD_LENGTH < len(password) < MAX_FIELD_LENGTH):
71+
raise ValueError("must contain between 3 to 20 charactars")
72+
return password
73+
74+
@validator('email')
75+
def confirm_mail(cls, email: str) -> Union[ValueError, str]:
76+
"""Validating email is valid mail address."""
77+
try:
78+
EmailStr.validate(email)
79+
return email
80+
except EmailError:
81+
raise ValueError("address is not valid")
82+
83+
84+
class User(UserBase):
85+
"""
86+
Validating fields types
87+
Returns a User object without sensitive information
88+
"""
89+
id: int
90+
is_active: bool

app/internal/security/__init__.py

Whitespace-only changes.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from starlette.requests import Request
2+
3+
from app.dependencies import get_db
4+
from app.internal.security.ouath2 import (
5+
Depends, Session, check_jwt_token, get_authorization_cookie)
6+
7+
8+
async def is_logged_in(
9+
request: Request, db: Session = Depends(get_db),
10+
jwt: str = Depends(get_authorization_cookie)) -> bool:
11+
"""
12+
A dependency function protecting routes for only logged in user
13+
"""
14+
await check_jwt_token(db, jwt)
15+
return True
16+
17+
18+
async def is_manager(
19+
request: Request, db: Session = Depends(get_db),
20+
jwt: str = Depends(get_authorization_cookie)) -> bool:
21+
"""
22+
A dependency function protecting routes for only logged in manager
23+
"""
24+
await check_jwt_token(db, jwt, manager=True)
25+
return True

app/internal/security/ouath2.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
from datetime import datetime, timedelta
2+
from typing import Union
3+
4+
from passlib.context import CryptContext
5+
from fastapi import Depends, HTTPException
6+
from fastapi.security import OAuth2PasswordBearer
7+
import jwt
8+
from jwt.exceptions import InvalidSignatureError
9+
from sqlalchemy.orm import Session
10+
from starlette.requests import Request
11+
from starlette.responses import RedirectResponse
12+
from starlette.status import HTTP_401_UNAUTHORIZED
13+
from . import schema
14+
15+
from app.config import JWT_ALGORITHM, JWT_KEY, JWT_MIN_EXP
16+
from app.database.models import User
17+
18+
19+
pwd_context = CryptContext(schemes=["bcrypt"])
20+
oauth_schema = OAuth2PasswordBearer(tokenUrl="/login")
21+
22+
23+
def get_hashed_password(password: str) -> str:
24+
"""Hashing user password"""
25+
return pwd_context.hash(password)
26+
27+
28+
def verify_password(plain_password: str, hashed_password: str) -> bool:
29+
"""Verifying password and hashed password are equal"""
30+
return pwd_context.verify(plain_password, hashed_password)
31+
32+
33+
async def authenticate_user(
34+
db: Session, new_user: schema.LoginUser,
35+
) -> Union[schema.LoginUser, bool]:
36+
"""Verifying user is in database and password is correct"""
37+
db_user = await User.get_by_username(db=db, username=new_user.username)
38+
if db_user and verify_password(new_user.password, db_user.password):
39+
return schema.LoginUser(
40+
user_id=db_user.id, is_manager=db_user.is_manager,
41+
username=new_user.username, password=db_user.password)
42+
return False
43+
44+
45+
def create_jwt_token(
46+
user: schema.LoginUser, jwt_min_exp: int = JWT_MIN_EXP,
47+
jwt_key: str = JWT_KEY) -> str:
48+
"""Creating jwt-token out of user unique data"""
49+
expiration = datetime.utcnow() + timedelta(minutes=jwt_min_exp)
50+
jwt_payload = {
51+
"sub": user.username,
52+
"user_id": user.user_id,
53+
"is_manager": user.is_manager,
54+
"exp": expiration}
55+
jwt_token = jwt.encode(
56+
jwt_payload, jwt_key, algorithm=JWT_ALGORITHM)
57+
return jwt_token
58+
59+
60+
async def check_jwt_token(
61+
db: Session,
62+
token: str = Depends(oauth_schema), path: bool = None,
63+
manager: bool = False) -> User:
64+
"""
65+
Check whether JWT token is correct.
66+
Returns jwt payloads if correct.
67+
Raises HTTPException if fails to decode.
68+
"""
69+
try:
70+
jwt_payload = jwt.decode(
71+
token, JWT_KEY, algorithms=JWT_ALGORITHM)
72+
if not manager:
73+
return True
74+
if jwt_payload.get("is_manager"):
75+
return True
76+
raise HTTPException(
77+
status_code=HTTP_401_UNAUTHORIZED,
78+
headers=path,
79+
detail="You don't have a permition to enter this page")
80+
except InvalidSignatureError:
81+
raise HTTPException(
82+
status_code=HTTP_401_UNAUTHORIZED,
83+
headers=path,
84+
detail="Your token is incorrect. Please log in again")
85+
except jwt.ExpiredSignatureError:
86+
raise HTTPException(
87+
status_code=HTTP_401_UNAUTHORIZED,
88+
headers=path,
89+
detail="Your token has expired. Please log in again")
90+
except jwt.DecodeError:
91+
raise HTTPException(
92+
status_code=HTTP_401_UNAUTHORIZED,
93+
headers=path,
94+
detail="Your token is incorrect. Please log in again")
95+
96+
97+
async def get_authorization_cookie(request: Request) -> str:
98+
"""
99+
Extracts jwt from HTTPONLY cookie, if exists.
100+
Raises HTTPException if not.
101+
"""
102+
if 'Authorization' in request.cookies:
103+
return request.cookies['Authorization']
104+
raise HTTPException(
105+
status_code=HTTP_401_UNAUTHORIZED,
106+
headers=request.url.path,
107+
detail="Please log in to enter this page")
108+
109+
110+
async def auth_exception_handler(
111+
request: Request,
112+
exc: HTTP_401_UNAUTHORIZED) -> RedirectResponse:
113+
"""
114+
Whenever HTTP_401_UNAUTHORIZED is raised,
115+
redirecting to login route, with original requested url,
116+
and details for why original request failed.
117+
"""
118+
paramas = f"?next={exc.headers}&message={exc.detail}"
119+
url = f"/login{paramas}"
120+
response = RedirectResponse(url=url)
121+
response.delete_cookie('Authorization')
122+
return response

app/internal/security/schema.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from typing import Optional
2+
3+
from pydantic import BaseModel
4+
5+
6+
class LoginUser(BaseModel):
7+
"""
8+
Validating fields types
9+
Returns a User object for signing in.
10+
"""
11+
user_id: Optional[int]
12+
is_manager: Optional[bool]
13+
username: str
14+
password: str
15+
16+
class Config:
17+
orm_mode = True

app/main.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
from fastapi import Depends, FastAPI, Request
2-
from fastapi.staticfiles import StaticFiles
3-
from sqlalchemy.orm import Session
4-
51
from app import config
62
from app.database import engine, models
73
from app.dependencies import get_db, logger, MEDIA_PATH, STATIC_PATH, templates
84
from app.internal import daily_quotes, json_data_loader
95

106
from app.internal.languages import set_ui_language
7+
from app.internal.security.ouath2 import auth_exception_handler
118
from app.routers.salary import routes as salary
9+
from fastapi import Depends, FastAPI, Request
10+
from fastapi.staticfiles import StaticFiles
11+
from starlette.status import HTTP_401_UNAUTHORIZED
12+
from sqlalchemy.orm import Session
1213

1314

1415
def create_tables(engine, psql_environment):
@@ -29,6 +30,7 @@ def create_tables(engine, psql_environment):
2930
app.mount("/media", StaticFiles(directory=MEDIA_PATH), name="media")
3031
app.logger = logger
3132

33+
app.add_exception_handler(HTTP_401_UNAUTHORIZED, auth_exception_handler)
3234

3335
json_data_loader.load_to_db(next(get_db()))
3436
# This MUST come before the app.routers imports.
@@ -38,8 +40,8 @@ def create_tables(engine, psql_environment):
3840
from app.routers import ( # noqa: E402
3941

4042
agenda, calendar, categories, celebrity, currency, dayview,
41-
email, event, export, four_o_four, invitation, profile, search,
42-
weekview, telegram, whatsapp,
43+
email, event, export, four_o_four, invitation, login, logout, profile,
44+
register, search, telegram, weekview, whatsapp,
4345
)
4446

4547
json_data_loader.load_to_db(next(get_db()))
@@ -57,7 +59,10 @@ def create_tables(engine, psql_environment):
5759
export.router,
5860
four_o_four.router,
5961
invitation.router,
62+
login.router,
63+
logout.router,
6064
profile.router,
65+
register.router,
6166
salary.router,
6267
search.router,
6368
telegram.router,

0 commit comments

Comments
 (0)