diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 370a7b4..cabca9e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -6,20 +6,17 @@ "image": "mcr.microsoft.com/devcontainers/python:3-3.14-trixie", "features": { "ghcr.io/devcontainer-community/devcontainer-features/astral.sh-uv:1": {} + }, + + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "ms-python.black-formatter", + "ms-python.isort", + "charliermarsh.ruff" + ] + } } - - // Features to add to the dev container. More info: https://containers.dev/features. - // "features": {}, - - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - - // Use 'postCreateCommand' to run commands after the container is created. - // "postCreateCommand": "pip3 install --user -r requirements.txt", - - // Configure tool-specific properties. - // "customizations": {}, - - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" } diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..cc081ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:venv" +} \ No newline at end of file diff --git a/src/app.py b/src/app.py index 69a365c..bb58a14 100644 --- a/src/app.py +++ b/src/app.py @@ -1,12 +1,9 @@ from fastapi import FastAPI -from .routes import user, login -from .models import engine +from .routes import user, login, posts app = FastAPI() -@app.get("/") -async def root() -> str: - return "Hello World" app.include_router(user.router) -app.include_router(login.router) \ No newline at end of file +app.include_router(login.router) +app.include_router(posts.router) \ No newline at end of file diff --git a/src/auth/dependencies.py b/src/auth/dependencies.py index 1cd105e..5529da0 100644 --- a/src/auth/dependencies.py +++ b/src/auth/dependencies.py @@ -12,9 +12,22 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(securit with Session(engine) as session: query = select(User).where(User.username == payload["username"]).limit(1) user: User = session.exec(query).first() - if user == None: + if user is None: raise HTTPException( status_code = status.HTTP_401_UNAUTHORIZED, detail="Credenciales invalidas" ) - return user \ No newline at end of file + if user.password_version != payload["pwd_v"]: + raise HTTPException( + status_code = status.HTTP_401_UNAUTHORIZED, + detail = "Credenciales invalidas" + ) + return user + +def get_staff_user(user: User = Depends(get_current_user)) -> User: + if not user.is_staff: + raise HTTPException( + status_code = status.HTTP_403_FORBIDDEN, + detail = "This user needs to be an Staff to access this resource" + ) + return user diff --git a/src/models.py b/src/models.py index d5fc31d..90cfd62 100644 --- a/src/models.py +++ b/src/models.py @@ -16,6 +16,7 @@ class User(SQLModel, table=True): username: str = Field(index=True, unique=True) email: str = Field(index=True, unique=True) password_hash: str + password_version: int = Field(default=0) full_name: Optional[str] = None bio: Optional[str] = None avatar_url: Optional[str] = None @@ -44,6 +45,7 @@ class User(SQLModel, table=True): _HASH_ITERATIONS ) self.password_hash = f"{salt.hex()}${derived.hex()}" + self.password_version += 1 # Aumenta la versión de la contraseña def verify_password(self, password: str) -> bool: try: @@ -59,6 +61,13 @@ class User(SQLModel, table=True): ) return secrets.compare_digest(hash_hex, derived.hex()) +class Post(SQLModel, table=True): + __tablename__ = "posts" + id: int = Field(default=None, primary_key=True, sa_column_kwargs={"autoincrement": True}) + title: str + body: Optional[str] + creator_id: int = Field(foreign_key="users.id") + engine = create_engine(settings.DATABASE_URL) SQLModel.metadata.create_all(engine) diff --git a/src/pmodels.py b/src/pmodels.py index 5e6b4d4..a6d7658 100644 --- a/src/pmodels.py +++ b/src/pmodels.py @@ -24,4 +24,14 @@ class LoginSchema(BaseModel): class TokenSchema(BaseModel): access_token: str - type: str = "bearer" \ No newline at end of file + type: str = "bearer" + +class ChangePasswordSchema(BaseModel): + old_password: str + new_password: str + +class PostSchema(BaseModel): + id: Optional[int] + username: Optional[str] + title: str + body: str diff --git a/src/routes/login.py b/src/routes/login.py index bf9bb6a..45883cf 100644 --- a/src/routes/login.py +++ b/src/routes/login.py @@ -1,11 +1,12 @@ -from fastapi import APIRouter, HTTPException -from ..pmodels import LoginSchema, TokenSchema +from fastapi import APIRouter, HTTPException, Depends +from ..auth.dependencies import get_current_user +from ..pmodels import LoginSchema, TokenSchema, StatusModel, ChangePasswordSchema from ..models import engine, User from sqlmodel import Session, select from ..settings import SECRET_KEY import jwt router = APIRouter( - prefix="/login", + prefix="/api/login", tags=["Autenticación"] ) @@ -18,9 +19,19 @@ async def login(data: LoginSchema) -> TokenSchema: if user is None: raise HTTPException(status_code=401, detail="User/Password Incorrect") if user.verify_password(data.password): - encoded_jwt = jwt.encode({"username": user.username}, SECRET_KEY, algorithm="HS256") + encoded_jwt = jwt.encode({"username": user.username, "pwd_v": user.password_version}, SECRET_KEY, algorithm="HS256") return TokenSchema( access_token=encoded_jwt ) else: raise HTTPException(status_code=401, detail="User/Password Incorrect") + +@router.post("/set-password", + description = "Establece otra contraseña (requiere la anterior)", + summary = "Cambia la contraseña") +async def set_password(data: ChangePasswordSchema, user: User = Depends(get_current_user)) -> StatusModel: + if not user.verify_password(data.old_password): + return 401, StatusModel(status="Invalid Password") + + user.set_password(data.new_password) + return StatusModel() \ No newline at end of file diff --git a/src/routes/posts.py b/src/routes/posts.py new file mode 100644 index 0000000..b9c480c --- /dev/null +++ b/src/routes/posts.py @@ -0,0 +1,40 @@ +from typing import Optional +from ..models import User, Post, engine +from sqlmodel import Session, select +from ..pmodels import PostSchema, StatusModel + +from fastapi import APIRouter, Depends +from ..auth.dependencies import get_current_user + +router = APIRouter(prefix="/api/posts", tags=["Publicaciones"]) + +@router.get("") +async def get_posts(page: int = 1, user: User = Depends(get_current_user)) -> list[PostSchema]: + posts: list[PostSchema] = [] + with Session(engine) as session: + query = select(Post, User).where(Post.creator_id == User.id).limit(10).offset(10*(page-1)) + result = session.exec(query) + for post, user in result: + posts.append(PostSchema( + id=post.id, + username=user.username, + title=post.title, + body=post.body + )) + return posts + +@router.post("") +async def create_post(data: PostSchema, user: User = Depends(get_current_user)) -> PostSchema: + post = Post( + user=user.id, + title=data.title, + body=data.body + ) + with Session(engine) as session: + session.add(post) + session.commit() + return PostSchema( + username=user.username, + title=post.title, + body=post.body + ) \ No newline at end of file diff --git a/src/routes/user.py b/src/routes/user.py index e6db942..a6f3207 100644 --- a/src/routes/user.py +++ b/src/routes/user.py @@ -3,12 +3,10 @@ from ..pmodels import pUser, pUserWithPassword, StatusModel from ..models import engine, User from ..auth.dependencies import get_current_user from sqlmodel import Session, select + router = APIRouter( - prefix="/users", - tags=["users"], - dependencies=[ - Depends(get_current_user) - ], + prefix="/api/users", + tags=["Usuarios"], responses = { status.HTTP_401_UNAUTHORIZED: { "description": "Token invalido o ausente", @@ -21,15 +19,17 @@ router = APIRouter( } ) -@router.get("/") -async def get_users() -> list[pUser]: +@router.get("/", description="Obtener una lista con todos los usuarios", summary="Obtener una lista con todos los usuarios") +async def get_users(user: User = Depends(get_current_user)) -> list[pUser]: with Session(engine) as session: statement = select(User) results = session.exec(statement).all() return [user.to_dto() for user in results] -@router.get("/{user_id}") -async def get_user(user_id) -> pUser: +@router.get("/{user_id}", + description="Obtener un usuario en especifico en base a su ID", + summary = "Obtiene un usuario") +async def get_user(user_id, user: User = Depends(get_current_user)) -> pUser: with Session(engine) as session: statement = select(User).where(User.id == user_id) results = session.exec(statement).first()