-
Notifications
You must be signed in to change notification settings - Fork 52
Feature/login #195
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature/login #195
Changes from 51 commits
43b3f1b
d211377
2f0501a
a5a1a04
5d2c52e
eafbe37
56d0ca3
00d9e7d
c9c777e
bb2b52d
a32c9c4
deee638
7ca40e1
1bdbc54
eed52e2
b793e2c
46820e1
68e40fe
b7e7e60
e3c620a
b6b7441
d1849bb
8d7f135
84db349
d9ae477
d7465b5
9d735b1
4f5a127
4392974
b9ee9d3
94eb29a
97d8aff
8b54f67
b46d679
73f53c1
c15a451
c3cad9d
befd5f9
063489f
da12351
cc4fdae
0ae794a
7a47985
e7bc55a
8fbe36a
f203c38
bbb241b
b00439d
84a6fa7
5f3e3e1
5d47833
2488679
7d5395c
8f59086
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -143,6 +143,9 @@ dmypy.json | |
| # Pyre type checker | ||
| .pyre/ | ||
|
|
||
|
|
||
| # register stuff | ||
| run.txt | ||
| # VScode | ||
| .vscode/ | ||
| app/.vscode/ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| from typing import Optional, Union | ||
| from pydantic import BaseModel, validator, EmailStr, EmailError | ||
|
|
||
|
|
||
| EMPTY_FIELD_STRING = 'field is required' | ||
| MIN_FIELD_LENGTH = 3 | ||
| MAX_FIELD_LENGTH = 20 | ||
|
|
||
|
|
||
| def fields_not_empty(field: Optional[str]) -> Union[ValueError, str]: | ||
| """Global function to validate fields are not empty.""" | ||
| if not field: | ||
| raise ValueError(EMPTY_FIELD_STRING) | ||
| return field | ||
|
|
||
|
|
||
| class UserBase(BaseModel): | ||
| """ | ||
| Validating fields types | ||
| Returns a User object without sensitive information | ||
| """ | ||
| username: str | ||
| email: str | ||
| full_name: str | ||
| description: Optional[str] = None | ||
|
|
||
| class Config: | ||
| orm_mode = True | ||
|
|
||
|
|
||
| class UserCreate(UserBase): | ||
| """Validating fields types""" | ||
| password: str | ||
| confirm_password: str | ||
|
|
||
| """ | ||
| Calling to field_not_empty validaion function, | ||
| for each required field. | ||
| """ | ||
| _fields_not_empty_username = validator( | ||
| 'username', allow_reuse=True)(fields_not_empty) | ||
| _fields_not_empty_full_name = validator( | ||
| 'full_name', allow_reuse=True)(fields_not_empty) | ||
| _fields_not_empty_password = validator( | ||
| 'password', allow_reuse=True)(fields_not_empty) | ||
| _fields_not_empty_confirm_password = validator( | ||
| 'confirm_password', allow_reuse=True)(fields_not_empty) | ||
| _fields_not_empty_email = validator( | ||
| 'email', allow_reuse=True)(fields_not_empty) | ||
|
|
||
| @validator('confirm_password') | ||
| def passwords_match( | ||
| cls, confirm_password: str, | ||
| values: UserBase) -> Union[ValueError, str]: | ||
| """Validating passwords fields identical.""" | ||
| if 'password' in values and confirm_password != values['password']: | ||
| raise ValueError("doesn't match to password") | ||
| return confirm_password | ||
|
|
||
| @validator('username') | ||
| def username_length(cls, username: str) -> Union[ValueError, str]: | ||
| """Validating username length is legal""" | ||
| if not (MIN_FIELD_LENGTH < len(username) < MAX_FIELD_LENGTH): | ||
| raise ValueError("must contain between 3 to 20 charactars") | ||
| return username | ||
|
|
||
| @validator('password') | ||
| def password_length(cls, password: str) -> Union[ValueError, str]: | ||
| """Validating username length is legal""" | ||
| if not (MIN_FIELD_LENGTH < len(password) < MAX_FIELD_LENGTH): | ||
| raise ValueError("must contain between 3 to 20 charactars") | ||
| return password | ||
|
|
||
| @validator('email') | ||
| def confirm_mail(cls, email: str) -> Union[ValueError, str]: | ||
| """Validating email is valid mail address.""" | ||
| try: | ||
| EmailStr.validate(email) | ||
| return email | ||
| except EmailError: | ||
| raise ValueError("address is not valid") | ||
|
|
||
|
|
||
| class User(UserBase): | ||
| """ | ||
| Validating fields types | ||
| Returns a User object without sensitive information | ||
| """ | ||
| id: int | ||
| is_active: bool |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| from typing import Union | ||
|
|
||
| from app.dependencies import get_db | ||
| from app.database.models import User | ||
| from app.internal.security.ouath2 import ( | ||
| Depends, Session, check_jwt_token, | ||
| get_authorization_cookie, HTTP_401_UNAUTHORIZED, | ||
| HTTPException) | ||
| from starlette.requests import Request | ||
|
|
||
|
|
||
| async def get_user( | ||
| db: Session, user_id: int, username: str, path: str) -> User: | ||
| ''' | ||
| Helper for dependency functions. | ||
| Recives user details from decrypted jwt token, | ||
| and check them against the database. | ||
| returns User base model instance if succeeded, | ||
| raises HTTPException if fails. | ||
| ''' | ||
| db_user = await User.get_by_username(db, username=username) | ||
| if db_user and db_user.id == user_id: | ||
| return db_user | ||
| else: | ||
| raise HTTPException( | ||
|
||
| status_code=HTTP_401_UNAUTHORIZED, | ||
| headers=path, | ||
| detail="Your token is incorrect. Please log in again") | ||
|
|
||
|
|
||
| async def is_logged_in( | ||
| request: Request, db: Session = Depends(get_db), | ||
| jwt: str = Depends(get_authorization_cookie)) -> bool: | ||
| """ | ||
| A dependency function protecting routes for only logged in user | ||
| """ | ||
| await check_jwt_token(db, jwt) | ||
| return True | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will this function always return
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this function activates check_jwt_token function. That function will raise an exception if validation fails. so yes..
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So catch the exception and return |
||
|
|
||
|
|
||
| async def logged_in_user( | ||
|
||
| request: Request, | ||
| db: Session = Depends(get_db), | ||
| jwt: str = Depends(get_authorization_cookie)) -> User: | ||
| """ | ||
| A dependency function protecting routes for only logged in user. | ||
| Returns logged in User object. | ||
| """ | ||
| username, user_id = await check_jwt_token(db, jwt) | ||
| return await get_user(db, user_id, username, request.url.path) | ||
|
|
||
|
|
||
| async def optional_logged_in_user( | ||
|
||
| request: Request, | ||
| db: Session = Depends(get_db)) -> Union[User, type(None)]: | ||
|
||
| """ | ||
| A dependency function. | ||
| Returns logged in User object if exists. | ||
| None if not. | ||
| """ | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prefer a docstring that matches the guidelines in PEP 257: """Return the currently logged in user.
Return logged in User object if exists, None if not.
A dependency function.
""" |
||
| if 'Authorization' in request.cookies: | ||
| jwt = request.cookies['Authorization'] | ||
| else: | ||
| return None | ||
| username, user_id = await check_jwt_token(db, jwt) | ||
| return await get_user(db, user_id, username, request.url.path) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| from datetime import datetime, timedelta | ||
| from typing import Union | ||
|
|
||
| from app.config import JWT_ALGORITHM, JWT_KEY, JWT_MIN_EXP | ||
| from app.database.models import User | ||
| from passlib.context import CryptContext | ||
| from fastapi import Depends, HTTPException | ||
| from fastapi.security import OAuth2PasswordBearer | ||
| import jwt | ||
| from jwt.exceptions import InvalidSignatureError | ||
| from sqlalchemy.orm import Session | ||
| from starlette.requests import Request | ||
| from starlette.responses import RedirectResponse | ||
| from starlette.status import HTTP_401_UNAUTHORIZED | ||
| from . import schema | ||
|
|
||
|
|
||
| pwd_context = CryptContext(schemes=["bcrypt"]) | ||
| oauth_schema = OAuth2PasswordBearer(tokenUrl="/login") | ||
|
|
||
|
|
||
| def get_hashed_password(password: str) -> str: | ||
| """Hashing user password""" | ||
| return pwd_context.hash(password) | ||
|
|
||
|
|
||
| def verify_password(plain_password: str, hashed_password: str) -> bool: | ||
| """Verifying password and hashed password are equal""" | ||
| return pwd_context.verify(plain_password, hashed_password) | ||
yammesicka marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
|
|
||
| async def authenticate_user( | ||
| db: Session, new_user: schema.LoginUser | ||
| ) -> Union[schema.LoginUser, bool]: | ||
| """Verifying user is in database and password is correct""" | ||
| db_user = await User.get_by_username(db=db, username=new_user.username) | ||
| if db_user and verify_password(new_user.password, db_user.password): | ||
| return schema.LoginUser( | ||
| user_id=db_user.id, | ||
| username=new_user.username, password=db_user.password) | ||
| return False | ||
|
|
||
|
|
||
| def create_jwt_token( | ||
| user: schema.LoginUser, jwt_min_exp: int = JWT_MIN_EXP, | ||
| jwt_key: str = JWT_KEY) -> str: | ||
| """Creating jwt-token out of user unique data""" | ||
| expiration = datetime.utcnow() + timedelta(minutes=jwt_min_exp) | ||
| jwt_payload = { | ||
| "sub": user.username, | ||
| "user_id": user.user_id, | ||
| "exp": expiration} | ||
| jwt_token = jwt.encode( | ||
| jwt_payload, jwt_key, algorithm=JWT_ALGORITHM) | ||
| return jwt_token | ||
|
|
||
|
|
||
| async def check_jwt_token( | ||
| db: Session, | ||
| token: str = Depends(oauth_schema), path: bool = None) -> User: | ||
| """ | ||
| Check whether JWT token is correct. | ||
| Returns jwt payloads if correct. | ||
| Raises HTTPException if fails to decode. | ||
| """ | ||
| try: | ||
| jwt_payload = jwt.decode( | ||
| token, JWT_KEY, algorithms=JWT_ALGORITHM) | ||
| return jwt_payload.get("sub"), jwt_payload.get("user_id") | ||
| except InvalidSignatureError: | ||
| raise HTTPException( | ||
| status_code=HTTP_401_UNAUTHORIZED, | ||
| headers=path, | ||
| detail="Your token is incorrect. Please log in again") | ||
| except jwt.ExpiredSignatureError: | ||
| raise HTTPException( | ||
| status_code=HTTP_401_UNAUTHORIZED, | ||
| headers=path, | ||
| detail="Your token has expired. Please log in again") | ||
| except jwt.DecodeError: | ||
| raise HTTPException( | ||
| status_code=HTTP_401_UNAUTHORIZED, | ||
| headers=path, | ||
| detail="Your token is incorrect. Please log in again") | ||
|
|
||
|
|
||
| async def get_authorization_cookie(request: Request) -> str: | ||
| """ | ||
| Extracts jwt from HTTPONLY cookie, if exists. | ||
| Raises HTTPException if not. | ||
| """ | ||
| if 'Authorization' in request.cookies: | ||
| return request.cookies['Authorization'] | ||
| raise HTTPException( | ||
| status_code=HTTP_401_UNAUTHORIZED, | ||
| headers=request.url.path, | ||
| detail="Please log in to enter this page") | ||
|
|
||
|
|
||
| async def auth_exception_handler( | ||
| request: Request, | ||
| exc: HTTP_401_UNAUTHORIZED) -> RedirectResponse: | ||
| """ | ||
| Whenever HTTP_401_UNAUTHORIZED is raised, | ||
| redirecting to login route, with original requested url, | ||
| and details for why original request failed. | ||
| """ | ||
| paramas = f"?next={exc.headers}&message={exc.detail}" | ||
| url = f"/login{paramas}" | ||
yammesicka marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| response = RedirectResponse(url=url) | ||
| response.delete_cookie('Authorization') | ||
| return response | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| from typing import Optional | ||
|
|
||
| from pydantic import BaseModel | ||
|
|
||
|
|
||
| class LoginUser(BaseModel): | ||
| """ | ||
| Validating fields types | ||
| Returns a User object for signing in. | ||
| """ | ||
| user_id: Optional[int] | ||
| username: str | ||
| password: str | ||
|
|
||
| class Config: | ||
| orm_mode = True |

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe some problems in this CR could be resolved with better naming.
We should also rethink a bit about the design: there are some functions that it's hard to tell why they're different one from another.