From d8b19da646db41e62b8c02789927ee5ed96febf7 Mon Sep 17 00:00:00 2001 From: L-Nafaryus Date: Thu, 20 Jun 2024 00:11:35 +0500 Subject: [PATCH] materia-web-client: unify style with catppuccin materia-server: fixing auth --- flake.nix | 4 +- materia-server/pyproject.toml | 4 +- .../materia_server/models/user/__init__.py | 2 +- .../src/materia_server/models/user/user.py | 24 +++++- .../src/materia_server/routers/__init__.py | 1 + .../materia_server/routers/api/__init__.py | 2 + .../materia_server/routers/api/auth/auth.py | 5 +- .../routers/api/user/__init__.py | 5 ++ .../materia_server/routers/api/user/user.py | 19 +++++ .../routers/api/{user.py => user_.py} | 0 .../src/materia_server/routers/middleware.py | 84 +++++++++++++------ .../src/materia-frontend/package-lock.json | 23 +++-- .../src/materia-frontend/package.json | 1 + .../src/materia-frontend/src/api/auth.ts | 23 +++++ .../src/materia-frontend/src/api/client.ts | 13 ++- .../src/materia-frontend/src/api/index.ts | 1 + .../src/materia-frontend/src/assets/style.css | 33 +++++--- .../materia-frontend/src/components/Error.vue | 5 ++ .../src/components/NavBar.vue | 4 +- .../src/components/Warning.vue | 5 ++ .../src/components/error/Error.vue | 5 -- .../src/materia-frontend/src/index.ts | 1 + .../src/materia-frontend/src/router.ts | 10 +-- .../src/materia-frontend/src/views/Base.vue | 16 ++-- .../src/materia-frontend/src/views/Home.vue | 6 +- .../src/views/{error => }/NotFound.vue | 2 +- .../src/views/auth/SignIn.vue | 71 ++++++++++++++++ .../src/views/auth/SignUp.vue | 50 +++++++++++ .../src/views/user/SignIn.vue | 76 ----------------- .../src/views/user/SignUp.vue | 52 ------------ .../src/materia-frontend/tailwind.config.js | 7 +- 31 files changed, 349 insertions(+), 205 deletions(-) create mode 100644 materia-server/src/materia_server/routers/api/user/__init__.py create mode 100644 materia-server/src/materia_server/routers/api/user/user.py rename materia-server/src/materia_server/routers/api/{user.py => user_.py} (100%) create mode 100644 materia-web-client/src/materia-frontend/src/api/auth.ts create mode 100644 materia-web-client/src/materia-frontend/src/components/Error.vue create mode 100644 materia-web-client/src/materia-frontend/src/components/Warning.vue delete mode 100644 materia-web-client/src/materia-frontend/src/components/error/Error.vue create mode 100644 materia-web-client/src/materia-frontend/src/index.ts rename materia-web-client/src/materia-frontend/src/views/{error => }/NotFound.vue (75%) create mode 100644 materia-web-client/src/materia-frontend/src/views/auth/SignIn.vue create mode 100644 materia-web-client/src/materia-frontend/src/views/auth/SignUp.vue delete mode 100644 materia-web-client/src/materia-frontend/src/views/user/SignIn.vue delete mode 100644 materia-web-client/src/materia-frontend/src/views/user/SignUp.vue diff --git a/flake.nix b/flake.nix index 8518cb6..71581eb 100644 --- a/flake.nix +++ b/flake.nix @@ -219,7 +219,9 @@ }; devShells.x86_64-linux.default = pkgs.mkShell { - buildInputs = with pkgs; [ postgresql redis ]; + buildInputs = with pkgs; [ postgresql redis pdm nodejs ]; + # greenlet requires libstdc++ + LD_LIBRARY_PATH = nixpkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc ]; }; }; } diff --git a/materia-server/pyproject.toml b/materia-server/pyproject.toml index 1858b45..ba47e53 100644 --- a/materia-server/pyproject.toml +++ b/materia-server/pyproject.toml @@ -46,8 +46,8 @@ materia-server = "materia_server.main:server" [tool.pdm.scripts] start-server.cmd = "python ./src/materia_server/main.py {args:start --app-mode development --log-level debug}" -db-upgrade.cmd = "alembic upgrade {args:head}" -db-downgrade.cmd = "alembic downgrade {args:base}" +db-upgrade.cmd = "alembic -c ./src/materia_server/alembic.ini upgrade {args:head}" +db-downgrade.shell = "alembic -c ./src/materia_server/alembic.ini downgrade {args:base}" db-revision.cmd = "alembic revision {args:--autogenerate}" remove-revisions.shell = "rm -v ./src/materia_server/models/migrations/versions/*.py" diff --git a/materia-server/src/materia_server/models/user/__init__.py b/materia-server/src/materia_server/models/user/__init__.py index 8be0a1f..6f85ad8 100644 --- a/materia-server/src/materia_server/models/user/__init__.py +++ b/materia-server/src/materia_server/models/user/__init__.py @@ -1 +1 @@ -from materia_server.models.user.user import User, UserCredentials +from materia_server.models.user.user import User, UserCredentials, UserIdentity diff --git a/materia-server/src/materia_server/models/user/user.py b/materia-server/src/materia_server/models/user/user.py index d04a286..29b57b4 100644 --- a/materia-server/src/materia_server/models/user/user.py +++ b/materia-server/src/materia_server/models/user/user.py @@ -3,7 +3,7 @@ from typing import Optional import time import re -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, EmailStr, ConfigDict import pydantic from sqlalchemy import BigInteger, Enum from sqlalchemy.orm import mapped_column, Mapped, relationship @@ -23,7 +23,7 @@ class User(Base): id: Mapped[UUID] = mapped_column(primary_key = True, default = uuid4) name: Mapped[str] = mapped_column(unique = True) lower_name: Mapped[str] = mapped_column(unique = True) - full_name: Mapped[str] + full_name: Mapped[Optional[str]] email: Mapped[str] is_email_private: Mapped[bool] = mapped_column(default = True) hashed_password: Mapped[str] @@ -80,5 +80,25 @@ class UserCredentials(BaseModel): password: str email: Optional[EmailStr] +class UserIdentity(BaseModel): + model_config = ConfigDict(from_attributes = True) + + name: str + lower_name: str + full_name: Optional[str] + email: str + is_email_private: bool + must_change_password: bool + + login_type: "LoginType" + + created: int + updated: int + last_login: Optional[int] + + is_active: bool + is_admin: bool + + avatar: Optional[str] from materia_server.models.repository.repository import Repository diff --git a/materia-server/src/materia_server/routers/__init__.py b/materia-server/src/materia_server/routers/__init__.py index 142b1d5..260d6f6 100644 --- a/materia-server/src/materia_server/routers/__init__.py +++ b/materia-server/src/materia_server/routers/__init__.py @@ -1 +1,2 @@ from materia_server.routers import api +from materia_server.routers import middleware diff --git a/materia-server/src/materia_server/routers/api/__init__.py b/materia-server/src/materia_server/routers/api/__init__.py index d265c79..c33d462 100644 --- a/materia-server/src/materia_server/routers/api/__init__.py +++ b/materia-server/src/materia_server/routers/api/__init__.py @@ -1,6 +1,8 @@ from fastapi import APIRouter from materia_server.routers.api import auth +from materia_server.routers.api import user router = APIRouter(prefix = "/api") router.include_router(auth.router) +router.include_router(user.router) diff --git a/materia-server/src/materia_server/routers/api/auth/auth.py b/materia-server/src/materia_server/routers/api/auth/auth.py index a8ae47a..9c17f8f 100644 --- a/materia-server/src/materia_server/routers/api/auth/auth.py +++ b/materia-server/src/materia_server/routers/api/auth/auth.py @@ -40,8 +40,9 @@ async def signup(body: user.UserCredentials, ctx: context.Context = Depends()): @router.post("/auth/signin") async def signin(body: user.UserCredentials, response: Response, ctx: context.Context = Depends()): - if (current_user := await user.User.by_name(body.name, ctx.database) or await user.User.by_email(body.email, ctx.database) if body.email else None) is None: - raise HTTPException(status_code = status.HTTP_401_UNAUTHORIZED, detail = "Invalid credentials") + if (current_user := await user.User.by_name(body.name, ctx.database)) is None: + if (current_user := await user.User.by_email(str(body.email), ctx.database)) is None: + raise HTTPException(status_code = status.HTTP_401_UNAUTHORIZED, detail = "Invalid email") if not security.validate_password(body.password, current_user.hashed_password, algo = ctx.config.security.password_hash_algo): raise HTTPException(status_code = status.HTTP_401_UNAUTHORIZED, detail = "Invalid password") diff --git a/materia-server/src/materia_server/routers/api/user/__init__.py b/materia-server/src/materia_server/routers/api/user/__init__.py new file mode 100644 index 0000000..58e9a9e --- /dev/null +++ b/materia-server/src/materia_server/routers/api/user/__init__.py @@ -0,0 +1,5 @@ +from fastapi import APIRouter +from materia_server.routers.api.user import user + +router = APIRouter() +router.include_router(user.router) diff --git a/materia-server/src/materia_server/routers/api/user/user.py b/materia-server/src/materia_server/routers/api/user/user.py new file mode 100644 index 0000000..593fa71 --- /dev/null +++ b/materia-server/src/materia_server/routers/api/user/user.py @@ -0,0 +1,19 @@ + +from typing import Optional +import uuid +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status + +from materia_server import security +from materia_server.routers import context +from materia_server.models import user +from materia_server.models import auth +from materia_server.routers.middleware import JwtMiddleware + +router = APIRouter(tags = ["user"]) + +@router.get("/user/identity", response_model = user.UserIdentity) +async def identity(request: Request, claims = Depends(JwtMiddleware()), ctx: context.Context = Depends()): + if not (current_user := await user.User.by_id(uuid.UUID(claims.sub), ctx.database)): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing user") + + return user.UserIdentity.model_validate(current_user) diff --git a/materia-server/src/materia_server/routers/api/user.py b/materia-server/src/materia_server/routers/api/user_.py similarity index 100% rename from materia-server/src/materia_server/routers/api/user.py rename to materia-server/src/materia_server/routers/api/user_.py diff --git a/materia-server/src/materia_server/routers/middleware.py b/materia-server/src/materia_server/routers/middleware.py index ebefa4a..acc5097 100644 --- a/materia-server/src/materia_server/routers/middleware.py +++ b/materia-server/src/materia_server/routers/middleware.py @@ -1,45 +1,79 @@ from typing import Optional, Sequence import uuid -from fastapi import HTTPException, Request, Response, status, Depends +from fastapi import HTTPException, Request, Response, status, Depends, Cookie +from fastapi.security.base import SecurityBase import jwt from sqlalchemy import select from pydantic import BaseModel from enum import StrEnum from http import HTTPMethod as HttpMethod -from fastapi.security import HTTPBearer, OAuth2PasswordBearer, OAuth2PasswordRequestForm, APIKeyQuery, APIKeyCookie, APIKeyHeader +from fastapi.security import HTTPBearer, OAuth2PasswordBearer, OAuth2PasswordRequestForm, APIKeyQuery, APIKeyCookie, APIKeyHeader -from materia.api.state import ConfigState, DatabaseState -from materia.api.token import TokenClaims -from materia import db +from materia_server import security +from materia_server.routers import context +from materia_server.models import user -class JwtMiddleware(HTTPBearer): - def __init__(self, auto_error: bool = True): - super().__init__(auto_error = auto_error) - self.claims: Optional[TokenClaims] = None +async def get_token_claims(token, ctx: context.Context = Depends()) -> security.TokenClaims: + try: + secret = ctx.config.oauth2.jwt_secret if ctx.config.oauth2.jwt_signing_algo in ["HS256", "HS384", "HS512"] else ctx.config.oauth2.jwt_signing_key + claims = security.validate_token(token, secret) + user_id = uuid.UUID(claims.sub) # type: ignore + except jwt.PyJWKError as _: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid token") + except ValueError as _: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid token") - async def __call__(self, request: Request, config: ConfigState = Depends(), database: DatabaseState = Depends()): - if token := request.cookies.get("token"): - pass - elif (credentials := await super().__call__(request)) and credentials.scheme == "Bearer": + if not (current_user := await user.User.by_id(user_id, ctx.database)): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing user") + + return claims + +class JwtBearer(HTTPBearer): + def __init__(self, **kwargs): + super().__init__(scheme_name = "Bearer", **kwargs) + self.claims = None + + async def __call__(self, request: Request, ctx: context.Context = Depends()): + if credentials := await super().__call__(request): token = credentials.credentials - - if not token: + else: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Missing token") + self.claims = await get_token_claims(token, ctx) + +class JwtCookie(SecurityBase): + def __init(self, *, auto_error: bool = True): + self.auto_error = auto_error + self.claims = None + + async def __call__(self, request: Request, response: Response, ctx: context.Context = Depends()): + if not (access_token := request.cookies.get(ctx.config.security.cookie_access_token_name)): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing token") + refresh_token = request.cookies.get(ctx.config.security.cookie_refresh_token_name) + + if ctx.config.oauth2.jwt_signing_algo in ["HS256", "HS384", "HS512"]: + secret = ctx.config.oauth2.jwt_secret + else: + secret = ctx.config.oauth2.jwt_signing_key + try: - self.claims = TokenClaims.verify(token, config.jwt.secret) - user_id = uuid.UUID(self.claims.sub) # type: ignore - except jwt.PyJWKError as _: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid token") - except ValueError as _: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid token") + refresh_claims = security.validate_token(refresh_token, secret) if refresh_token else None + # TODO: check expiration + except jwt.PyJWTError: + refresh_claims = None - async with database.session() as session: - if not (user := (await session.scalars(select(db.User).where(db.User.id == user_id))).first()): - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing user") + try: + access_claims = security.validate_token(access_token, secret) + # TODO: if exp then check refresh token and create new else raise + except jwt.PyJWTError as e: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Invalid token: {e}") + else: + # TODO: validate user + pass + + self.claims = access_claims - request.state.user = user WILDCARD = "*" NULL = "null" diff --git a/materia-web-client/src/materia-frontend/package-lock.json b/materia-web-client/src/materia-frontend/package-lock.json index b68c7db..8d5c481 100644 --- a/materia-web-client/src/materia-frontend/package-lock.json +++ b/materia-web-client/src/materia-frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "materia-frontend", "version": "0.0.1", "dependencies": { + "@catppuccin/tailwindcss": "^0.1.6", "autoprefixer": "^10.4.18", "axios": "^1.6.8", "pinia": "^2.1.7", @@ -484,6 +485,14 @@ "node": ">=6.9.0" } }, + "node_modules/@catppuccin/tailwindcss": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@catppuccin/tailwindcss/-/tailwindcss-0.1.6.tgz", + "integrity": "sha512-V+Y0AwZ5SSyvOVAcDl7Ng30xy+m82OKnEJ+9+kcZZ7lRyXuZrAb2GScdq9XR3v+ggt8qiZ/G4TvaC9cJ88AAXA==", + "peerDependencies": { + "tailwindcss": ">=3.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", @@ -1494,11 +1503,11 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -1859,9 +1868,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dependencies": { "to-regex-range": "^5.0.1" }, diff --git a/materia-web-client/src/materia-frontend/package.json b/materia-web-client/src/materia-frontend/package.json index 39e9ec6..d73e052 100644 --- a/materia-web-client/src/materia-frontend/package.json +++ b/materia-web-client/src/materia-frontend/package.json @@ -11,6 +11,7 @@ "type-check": "vue-tsc --build --force" }, "dependencies": { + "@catppuccin/tailwindcss": "^0.1.6", "autoprefixer": "^10.4.18", "axios": "^1.6.8", "pinia": "^2.1.7", diff --git a/materia-web-client/src/materia-frontend/src/api/auth.ts b/materia-web-client/src/materia-frontend/src/api/auth.ts new file mode 100644 index 0000000..b3b4ab7 --- /dev/null +++ b/materia-web-client/src/materia-frontend/src/api/auth.ts @@ -0,0 +1,23 @@ +import { client, type ResponseError, handle_error } from "@/api/client"; + +export interface UserCredentials { + name: string, + password: string, + email?: string +} + +export async function signup(body: UserCredentials): Promise { + return await client.post("/auth/signup", JSON.stringify(body)) + .catch(handle_error); +} + +export async function signin(body: UserCredentials): Promise { + return await client.post("/auth/signin", JSON.stringify(body)) + .catch(handle_error); +} + + +export async function signout(): Promise { + return await client.post("/auth/signout") + .catch(handle_error); +} diff --git a/materia-web-client/src/materia-frontend/src/api/client.ts b/materia-web-client/src/materia-frontend/src/api/client.ts index 15e38e0..2620180 100644 --- a/materia-web-client/src/materia-frontend/src/api/client.ts +++ b/materia-web-client/src/materia-frontend/src/api/client.ts @@ -13,12 +13,19 @@ export class HttpError extends Error { } export interface ResponseError { - status_code: number, - message: string + status: number | null, + message: string | null } export function handle_error(error: AxiosError): Promise { - return Promise.reject({ status_code: error.response?.status, message: error.response?.data }); + let message = error.response?.data?.detail || error.response?.data; + console.log(error); + // extract pydantic error message + if (error.response.status == 422) { + message = error.response?.data?.detail[1].ctx.reason; + } + + return Promise.reject({ status: error.response.status, message: message}); } const debug = import.meta.hot; diff --git a/materia-web-client/src/materia-frontend/src/api/index.ts b/materia-web-client/src/materia-frontend/src/api/index.ts index a3c42e9..465df04 100644 --- a/materia-web-client/src/materia-frontend/src/api/index.ts +++ b/materia-web-client/src/materia-frontend/src/api/index.ts @@ -1 +1,2 @@ +export * as auth from "@/api/auth"; export * as user from "@/api/user"; diff --git a/materia-web-client/src/materia-frontend/src/assets/style.css b/materia-web-client/src/materia-frontend/src/assets/style.css index 3238a2f..a517b63 100644 --- a/materia-web-client/src/materia-frontend/src/assets/style.css +++ b/materia-web-client/src/materia-frontend/src/assets/style.css @@ -4,24 +4,37 @@ @layer base { body { - background-color: rgba(40, 30, 30, 1); /*linear-gradient(rgba(36, 14, 84, 1) 80%, rgba(55, 22, 130, 1)); */ - /*background-image: url("./background.svg");*/ - background-position: left top; - background-repeat: repeat-x; + @apply bg-ctp-crust; + font-family: Inter,sans-serif; + font-weight: 400; } a { - @apply text-green-500 hover:text-green-400; + @apply text-ctp-green; } - h1 { - font-family: BioRhyme,serif; - font-weight: 700; + .input { + @apply w-full pl-3 pr-3 pt-2 pb-2 rounded border bg-ctp-mantle border-ctp-overlay0 hover:border-ctp-overlay1 focus:border-ctp-green text-ctp-text outline-none; } label { - font-family: Space Mono,monospace; - font-weight: 500; + @apply text-ctp-text; + } + + .button { + @apply pt-2 pb-2 pl-5 pr-5 rounded bg-ctp-mantle hover:bg-ctp-base text-ctp-blue; + } + + .link-button { + @apply button text-ctp-green; + } + + h1 { + @apply text-ctp-text pt-5 pb-5 border-b border-ctp-overlay0 mb-5; + } + + label { + @apply text-ctp-text; } } diff --git a/materia-web-client/src/materia-frontend/src/components/Error.vue b/materia-web-client/src/materia-frontend/src/components/Error.vue new file mode 100644 index 0000000..9470c5a --- /dev/null +++ b/materia-web-client/src/materia-frontend/src/components/Error.vue @@ -0,0 +1,5 @@ + diff --git a/materia-web-client/src/materia-frontend/src/components/NavBar.vue b/materia-web-client/src/materia-frontend/src/components/NavBar.vue index 2e379df..9c101dc 100644 --- a/materia-web-client/src/materia-frontend/src/components/NavBar.vue +++ b/materia-web-client/src/materia-frontend/src/components/NavBar.vue @@ -1,7 +1,7 @@ -
+
-
diff --git a/materia-web-client/src/materia-frontend/src/views/Home.vue b/materia-web-client/src/materia-frontend/src/views/Home.vue index d8582fe..f6c7f43 100644 --- a/materia-web-client/src/materia-frontend/src/views/Home.vue +++ b/materia-web-client/src/materia-frontend/src/views/Home.vue @@ -1,9 +1,11 @@ diff --git a/materia-web-client/src/materia-frontend/src/views/error/NotFound.vue b/materia-web-client/src/materia-frontend/src/views/NotFound.vue similarity index 75% rename from materia-web-client/src/materia-frontend/src/views/error/NotFound.vue rename to materia-web-client/src/materia-frontend/src/views/NotFound.vue index 78f9921..172a98a 100644 --- a/materia-web-client/src/materia-frontend/src/views/error/NotFound.vue +++ b/materia-web-client/src/materia-frontend/src/views/NotFound.vue @@ -1,6 +1,6 @@