From f00d2eca6d5ca3e47bf3c874b2ebcd882f51d3b8 Mon Sep 17 00:00:00 2001 From: Babak Alizadeh Date: Mon, 15 Sep 2025 21:50:09 +0330 Subject: [PATCH] progress in login/register reload password --- hesabixAPI/adapters/api/v1/auth.py | 90 ++++++ hesabixAPI/adapters/api/v1/schemas.py | 39 +++ hesabixAPI/adapters/db/models/__init__.py | 9 + hesabixAPI/adapters/db/models/api_key.py | 28 ++ hesabixAPI/adapters/db/models/captcha.py | 20 ++ .../adapters/db/models/password_reset.py | 21 ++ hesabixAPI/adapters/db/models/user.py | 24 ++ .../adapters/db/repositories/api_key_repo.py | 27 ++ .../db/repositories/password_reset_repo.py | 30 ++ .../adapters/db/repositories/user_repo.py | 30 ++ hesabixAPI/app/core/auth_dependency.py | 39 +++ hesabixAPI/app/core/error_handlers.py | 83 +++++ hesabixAPI/app/core/i18n.py | 89 ++++++ hesabixAPI/app/core/i18n_catalog.py | 17 ++ hesabixAPI/app/core/responses.py | 16 + hesabixAPI/app/core/security.py | 46 +++ hesabixAPI/app/core/settings.py | 9 + hesabixAPI/app/main.py | 25 +- hesabixAPI/app/services/api_key_service.py | 51 ++++ hesabixAPI/app/services/auth_service.py | 161 ++++++++++ hesabixAPI/app/services/captcha_service.py | 97 ++++++ hesabixAPI/hesabix_api.egg-info/PKG-INFO | 5 + hesabixAPI/hesabix_api.egg-info/SOURCES.txt | 20 ++ hesabixAPI/hesabix_api.egg-info/requires.txt | 5 + hesabixAPI/locales/fa/LC_MESSAGES/messages.mo | Bin 0 -> 1494 bytes hesabixAPI/locales/fa/LC_MESSAGES/messages.po | 61 ++++ hesabixAPI/migrations/env.py | 1 + .../20250915_000001_init_auth_tables.py | 86 ++++++ hesabixAPI/pyproject.toml | 7 +- hesabixUI/hesabix_ui/lib/core/api_client.dart | 14 + hesabixUI/hesabix_ui/lib/core/auth_store.dart | 52 ++++ hesabixUI/hesabix_ui/lib/l10n/app_en.arb | 12 +- hesabixUI/hesabix_ui/lib/l10n/app_fa.arb | 12 +- .../lib/l10n/app_localizations.dart | 18 ++ .../lib/l10n/app_localizations_en.dart | 9 + .../lib/l10n/app_localizations_fa.dart | 9 + hesabixUI/hesabix_ui/lib/main.dart | 16 +- .../hesabix_ui/lib/pages/login_page.dart | 288 ++++++++++++++++-- .../hesabix_ui/lib/widgets/error_notice.dart | 51 ++++ .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 4 + hesabixUI/hesabix_ui/pubspec.lock | 120 ++++++++ hesabixUI/hesabix_ui/pubspec.yaml | 2 + .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + 46 files changed, 1709 insertions(+), 43 deletions(-) create mode 100644 hesabixAPI/adapters/api/v1/auth.py create mode 100644 hesabixAPI/adapters/api/v1/schemas.py create mode 100644 hesabixAPI/adapters/db/models/__init__.py create mode 100644 hesabixAPI/adapters/db/models/api_key.py create mode 100644 hesabixAPI/adapters/db/models/captcha.py create mode 100644 hesabixAPI/adapters/db/models/password_reset.py create mode 100644 hesabixAPI/adapters/db/models/user.py create mode 100644 hesabixAPI/adapters/db/repositories/api_key_repo.py create mode 100644 hesabixAPI/adapters/db/repositories/password_reset_repo.py create mode 100644 hesabixAPI/adapters/db/repositories/user_repo.py create mode 100644 hesabixAPI/app/core/auth_dependency.py create mode 100644 hesabixAPI/app/core/error_handlers.py create mode 100644 hesabixAPI/app/core/i18n.py create mode 100644 hesabixAPI/app/core/i18n_catalog.py create mode 100644 hesabixAPI/app/core/responses.py create mode 100644 hesabixAPI/app/core/security.py create mode 100644 hesabixAPI/app/services/api_key_service.py create mode 100644 hesabixAPI/app/services/auth_service.py create mode 100644 hesabixAPI/app/services/captcha_service.py create mode 100644 hesabixAPI/locales/fa/LC_MESSAGES/messages.mo create mode 100644 hesabixAPI/locales/fa/LC_MESSAGES/messages.po create mode 100644 hesabixAPI/migrations/versions/20250915_000001_init_auth_tables.py create mode 100644 hesabixUI/hesabix_ui/lib/core/auth_store.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/error_notice.dart diff --git a/hesabixAPI/adapters/api/v1/auth.py b/hesabixAPI/adapters/api/v1/auth.py new file mode 100644 index 0000000..7104ef8 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/auth.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from app.core.responses import success_response +from app.services.captcha_service import create_captcha +from app.services.auth_service import register_user, login_user, create_password_reset, reset_password +from .schemas import RegisterRequest, LoginRequest, ForgotPasswordRequest, ResetPasswordRequest, CreateApiKeyRequest +from app.core.auth_dependency import get_current_user, AuthContext +from app.services.api_key_service import list_personal_keys, create_personal_key, revoke_key + + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/captcha", summary="Generate numeric captcha") +def generate_captcha(db: Session = Depends(get_db)) -> dict: + captcha_id, image_base64, ttl = create_captcha(db) + return success_response({ + "captcha_id": captcha_id, + "image_base64": image_base64, + "ttl_seconds": ttl, + }) + + +@router.post("/register", summary="Register new user") +def register(payload: RegisterRequest, db: Session = Depends(get_db)) -> dict: + user_id = register_user( + db=db, + first_name=payload.first_name, + last_name=payload.last_name, + email=payload.email, + mobile=payload.mobile, + password=payload.password, + captcha_id=payload.captcha_id, + captcha_code=payload.captcha_code, + ) + return success_response({"user_id": user_id}) + + +@router.post("/login", summary="Login with email or mobile") +def login(request: Request, payload: LoginRequest, db: Session = Depends(get_db)) -> dict: + user_agent = request.headers.get("User-Agent") + ip = request.client.host if request.client else None + api_key, expires_at, user = login_user( + db=db, + identifier=payload.identifier, + password=payload.password, + captcha_id=payload.captcha_id, + captcha_code=payload.captcha_code, + device_id=payload.device_id, + user_agent=user_agent, + ip=ip, + ) + return success_response({"api_key": api_key, "expires_at": expires_at, "user": user}) + + +@router.post("/forgot-password", summary="Create password reset token") +def forgot_password(payload: ForgotPasswordRequest, db: Session = Depends(get_db)) -> dict: + # In production do not return token; send via email/SMS. Here we return for dev/testing. + token = create_password_reset(db=db, identifier=payload.identifier, captcha_id=payload.captcha_id, captcha_code=payload.captcha_code) + return success_response({"ok": True, "token": token if token else None}) + + +@router.post("/reset-password", summary="Reset password with token") +def reset_password_endpoint(payload: ResetPasswordRequest, db: Session = Depends(get_db)) -> dict: + reset_password(db=db, token=payload.token, new_password=payload.new_password, captcha_id=payload.captcha_id, captcha_code=payload.captcha_code) + return success_response({"ok": True}) + + +@router.get("/api-keys", summary="List personal API keys") +def list_keys(ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict: + items = list_personal_keys(db, ctx.user.id) + return success_response(items) + + +@router.post("/api-keys", summary="Create personal API key") +def create_key(payload: CreateApiKeyRequest, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict: + id_, api_key = create_personal_key(db, ctx.user.id, payload.name, payload.scopes, None) + return success_response({"id": id_, "api_key": api_key}) + + +@router.delete("/api-keys/{key_id}", summary="Revoke API key") +def delete_key(key_id: int, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict: + revoke_key(db, ctx.user.id, key_id) + return success_response({"ok": True}) + + diff --git a/hesabixAPI/adapters/api/v1/schemas.py b/hesabixAPI/adapters/api/v1/schemas.py new file mode 100644 index 0000000..a3a1471 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/schemas.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from pydantic import BaseModel, EmailStr, Field + + +class CaptchaSolve(BaseModel): + captcha_id: str = Field(..., min_length=8) + captcha_code: str = Field(..., min_length=3, max_length=8) + + +class RegisterRequest(CaptchaSolve): + first_name: str | None = Field(default=None, max_length=100) + last_name: str | None = Field(default=None, max_length=100) + email: EmailStr | None = None + mobile: str | None = Field(default=None, max_length=32) + password: str = Field(..., min_length=8, max_length=128) + + +class LoginRequest(CaptchaSolve): + identifier: str = Field(..., min_length=3, max_length=255) + password: str = Field(..., min_length=8, max_length=128) + device_id: str | None = Field(default=None, max_length=100) + + +class ForgotPasswordRequest(CaptchaSolve): + identifier: str = Field(..., min_length=3, max_length=255) + + +class ResetPasswordRequest(CaptchaSolve): + token: str = Field(..., min_length=16) + new_password: str = Field(..., min_length=8, max_length=128) + + +class CreateApiKeyRequest(BaseModel): + name: str | None = Field(default=None, max_length=100) + scopes: str | None = Field(default=None, max_length=500) + expires_at: str | None = None # ISO string; parse server-side if provided + + diff --git a/hesabixAPI/adapters/db/models/__init__.py b/hesabixAPI/adapters/db/models/__init__.py new file mode 100644 index 0000000..af4b100 --- /dev/null +++ b/hesabixAPI/adapters/db/models/__init__.py @@ -0,0 +1,9 @@ +from adapters.db.session import Base # re-export Base for Alembic + +# Import models to register with SQLAlchemy metadata +from .user import User # noqa: F401 +from .api_key import ApiKey # noqa: F401 +from .captcha import Captcha # noqa: F401 +from .password_reset import PasswordReset # noqa: F401 + + diff --git a/hesabixAPI/adapters/db/models/api_key.py b/hesabixAPI/adapters/db/models/api_key.py new file mode 100644 index 0000000..991131d --- /dev/null +++ b/hesabixAPI/adapters/db/models/api_key.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column + +from adapters.db.session import Base + + +class ApiKey(Base): + __tablename__ = "api_keys" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False) + key_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False) + key_type: Mapped[str] = mapped_column(String(16), nullable=False) # "session" | "personal" + name: Mapped[str | None] = mapped_column(String(100), nullable=True) + scopes: Mapped[str | None] = mapped_column(String(500), nullable=True) + device_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True) + ip: Mapped[str | None] = mapped_column(String(64), nullable=True) + expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + diff --git a/hesabixAPI/adapters/db/models/captcha.py b/hesabixAPI/adapters/db/models/captcha.py new file mode 100644 index 0000000..cec072a --- /dev/null +++ b/hesabixAPI/adapters/db/models/captcha.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, DateTime +from sqlalchemy.orm import Mapped, mapped_column + +from adapters.db.session import Base + + +class Captcha(Base): + __tablename__ = "captchas" + + id: Mapped[str] = mapped_column(String(40), primary_key=True) + code_hash: Mapped[str] = mapped_column(String(128), nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + attempts: Mapped[int] = mapped_column(default=0, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + diff --git a/hesabixAPI/adapters/db/models/password_reset.py b/hesabixAPI/adapters/db/models/password_reset.py new file mode 100644 index 0000000..d752ccb --- /dev/null +++ b/hesabixAPI/adapters/db/models/password_reset.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column + +from adapters.db.session import Base + + +class PasswordReset(Base): + __tablename__ = "password_resets" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False) + token_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + diff --git a/hesabixAPI/adapters/db/models/user.py b/hesabixAPI/adapters/db/models/user.py new file mode 100644 index 0000000..ebecd93 --- /dev/null +++ b/hesabixAPI/adapters/db/models/user.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, DateTime, Boolean +from sqlalchemy.orm import Mapped, mapped_column + +from adapters.db.session import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + email: Mapped[str | None] = mapped_column(String(255), unique=True, index=True, nullable=True) + mobile: Mapped[str | None] = mapped_column(String(32), unique=True, index=True, nullable=True) + first_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + last_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + diff --git a/hesabixAPI/adapters/db/repositories/api_key_repo.py b/hesabixAPI/adapters/db/repositories/api_key_repo.py new file mode 100644 index 0000000..9c6aa0c --- /dev/null +++ b/hesabixAPI/adapters/db/repositories/api_key_repo.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from adapters.db.models.api_key import ApiKey + + +class ApiKeyRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def create_session_key(self, *, user_id: int, key_hash: str, device_id: str | None, user_agent: str | None, ip: str | None, expires_at: datetime | None) -> ApiKey: + obj = ApiKey(user_id=user_id, key_hash=key_hash, key_type="session", name=None, scopes=None, device_id=device_id, user_agent=user_agent, ip=ip, expires_at=expires_at) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def get_by_hash(self, key_hash: str) -> Optional[ApiKey]: + stmt = select(ApiKey).where(ApiKey.key_hash == key_hash) + return self.db.execute(stmt).scalars().first() + + diff --git a/hesabixAPI/adapters/db/repositories/password_reset_repo.py b/hesabixAPI/adapters/db/repositories/password_reset_repo.py new file mode 100644 index 0000000..4b60837 --- /dev/null +++ b/hesabixAPI/adapters/db/repositories/password_reset_repo.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from adapters.db.models.password_reset import PasswordReset + + +class PasswordResetRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def create(self, *, user_id: int, token_hash: str, expires_at: datetime) -> PasswordReset: + obj = PasswordReset(user_id=user_id, token_hash=token_hash, expires_at=expires_at) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def get_by_hash(self, token_hash: str) -> Optional[PasswordReset]: + stmt = select(PasswordReset).where(PasswordReset.token_hash == token_hash) + return self.db.execute(stmt).scalars().first() + + def mark_used(self, pr: PasswordReset) -> None: + pr.used_at = datetime.utcnow() + self.db.add(pr) + self.db.commit() diff --git a/hesabixAPI/adapters/db/repositories/user_repo.py b/hesabixAPI/adapters/db/repositories/user_repo.py new file mode 100644 index 0000000..f358131 --- /dev/null +++ b/hesabixAPI/adapters/db/repositories/user_repo.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from adapters.db.models.user import User + + +class UserRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def get_by_email(self, email: str) -> Optional[User]: + stmt = select(User).where(User.email == email) + return self.db.execute(stmt).scalars().first() + + def get_by_mobile(self, mobile: str) -> Optional[User]: + stmt = select(User).where(User.mobile == mobile) + return self.db.execute(stmt).scalars().first() + + def create(self, *, email: str | None, mobile: str | None, password_hash: str, first_name: str | None, last_name: str | None) -> User: + user = User(email=email, mobile=mobile, password_hash=password_hash, first_name=first_name, last_name=last_name) + self.db.add(user) + self.db.commit() + self.db.refresh(user) + return user + + diff --git a/hesabixAPI/app/core/auth_dependency.py b/hesabixAPI/app/core/auth_dependency.py new file mode 100644 index 0000000..da7b1a9 --- /dev/null +++ b/hesabixAPI/app/core/auth_dependency.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import Optional + +from fastapi import Depends, Header +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from adapters.db.repositories.api_key_repo import ApiKeyRepository +from adapters.db.models.user import User +from app.core.security import hash_api_key +from app.core.responses import ApiError + + +class AuthContext: + def __init__(self, user: User, api_key_id: int) -> None: + self.user = user + self.api_key_id = api_key_id + + +def get_current_user(authorization: Optional[str] = Header(default=None), db: Session = Depends(get_db)) -> AuthContext: + if not authorization or not authorization.startswith("ApiKey "): + raise ApiError("UNAUTHORIZED", "Missing or invalid API key", http_status=401) + + api_key = authorization[len("ApiKey ") :].strip() + key_hash = hash_api_key(api_key) + repo = ApiKeyRepository(db) + obj = repo.get_by_hash(key_hash) + if not obj or obj.revoked_at is not None: + raise ApiError("UNAUTHORIZED", "Invalid API key", http_status=401) + + from adapters.db.models.user import User + user = db.get(User, obj.user_id) + if not user or not user.is_active: + raise ApiError("UNAUTHORIZED", "Invalid API key", http_status=401) + + return AuthContext(user=user, api_key_id=obj.id) + + diff --git a/hesabixAPI/app/core/error_handlers.py b/hesabixAPI/app/core/error_handlers.py new file mode 100644 index 0000000..6f12dc0 --- /dev/null +++ b/hesabixAPI/app/core/error_handlers.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import FastAPI, Request, HTTPException +from fastapi.exceptions import RequestValidationError +from starlette.responses import JSONResponse + + +def _translate_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse: + translator = getattr(request.state, "translator", None) + if translator is None: + # fallback + return JSONResponse( + status_code=422, + content={"success": False, "error": {"code": "VALIDATION_ERROR", "message": "Validation error", "details": exc.errors()}}, + ) + + details: list[dict[str, Any]] = [] + for err in exc.errors(): + type_ = err.get("type") + loc = err.get("loc", []) + ctx = err.get("ctx", {}) or {} + msg = err.get("msg", "") + + if type_ == "string_too_short": + msg = translator.t("STRING_TOO_SHORT") + min_len = ctx.get("min_length") + if min_len is not None: + msg = f"{msg} (حداقل {min_len})" + elif type_ == "string_too_long": + msg = translator.t("STRING_TOO_LONG") + max_len = ctx.get("max_length") + if max_len is not None: + msg = f"{msg} (حداکثر {max_len})" + elif type_ in {"missing", "value_error.missing"}: + msg = translator.t("FIELD_REQUIRED") + elif type_ in {"value_error.email", "email", "value_error.email"}: + msg = translator.t("INVALID_EMAIL") + + details.append({"loc": loc, "msg": msg, "type": type_}) + + return JSONResponse( + status_code=422, + content={ + "success": False, + "error": { + "code": "VALIDATION_ERROR", + "message": translator.t("VALIDATION_ERROR"), + "details": details, + }, + }, + ) + + +def _translate_http_exception(request: Request, exc: HTTPException) -> JSONResponse: + translator = getattr(request.state, "translator", None) + detail = exc.detail + status_code = exc.status_code or 400 + if isinstance(detail, dict) and isinstance(detail.get("error"), dict): + error = detail["error"] + code = error.get("code") + message = error.get("message") + if translator is not None and isinstance(code, str): + localized = translator.t(code, default=message if isinstance(message, str) else None) + detail["error"]["message"] = localized + return JSONResponse(status_code=status_code, content=detail) + # fallback generic shape + message = "" + if isinstance(detail, str): + message = detail + elif isinstance(detail, dict) and "detail" in detail: + message = str(detail["detail"]) + if translator is not None: + message = translator.t("HTTP_ERROR", default=message) + return JSONResponse(status_code=status_code, content={"success": False, "error": {"code": "HTTP_ERROR", "message": message}}) + + +def register_error_handlers(app: FastAPI) -> None: + app.add_exception_handler(RequestValidationError, _translate_validation_error) + app.add_exception_handler(HTTPException, _translate_http_exception) + + diff --git a/hesabixAPI/app/core/i18n.py b/hesabixAPI/app/core/i18n.py new file mode 100644 index 0000000..b4f3854 --- /dev/null +++ b/hesabixAPI/app/core/i18n.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import Any, Callable + +from fastapi import Request +from .i18n_catalog import get_gettext_translation + + +SUPPORTED_LOCALES: tuple[str, ...] = ("fa", "en") +DEFAULT_LOCALE: str = "en" + + +def negotiate_locale(accept_language: str | None) -> str: + if not accept_language: + return DEFAULT_LOCALE + parts = [p.strip() for p in accept_language.split(",") if p.strip()] + for part in parts: + lang = part.split(";")[0].strip().lower() + base = lang.split("-")[0] + if lang in SUPPORTED_LOCALES: + return lang + if base in SUPPORTED_LOCALES: + return base + return DEFAULT_LOCALE + + +class Translator: + def __init__(self, locale: str) -> None: + self.locale = locale if locale in SUPPORTED_LOCALES else DEFAULT_LOCALE + self._gt = get_gettext_translation(self.locale) + + _catalog: dict[str, dict[str, str]] = { + "en": { + "OK": "OK", + "INVALID_CAPTCHA": "Invalid captcha code.", + "INVALID_CREDENTIALS": "Invalid credentials.", + "IDENTIFIER_REQUIRED": "Identifier is required.", + "INVALID_IDENTIFIER": "Identifier must be a valid email or mobile number.", + "EMAIL_IN_USE": "Email is already in use.", + "MOBILE_IN_USE": "Mobile number is already in use.", + "ACCOUNT_DISABLED": "Your account is disabled.", + "RESET_TOKEN_INVALID_OR_EXPIRED": "Reset token is invalid or expired.", + "VALIDATION_ERROR": "Validation error", + "STRING_TOO_SHORT": "String is too short", + "STRING_TOO_LONG": "String is too long", + "FIELD_REQUIRED": "Field is required", + "INVALID_EMAIL": "Invalid email address", + "HTTP_ERROR": "Request failed", + }, + "fa": { + "OK": "باشه", + "INVALID_CAPTCHA": "کد امنیتی نامعتبر است.", + "INVALID_CREDENTIALS": "ایمیل/موبایل یا رمز عبور نادرست است.", + "IDENTIFIER_REQUIRED": "شناسه ورود الزامی است.", + "INVALID_IDENTIFIER": "شناسه باید ایمیل یا شماره موبایل معتبر باشد.", + "EMAIL_IN_USE": "این ایمیل قبلاً استفاده شده است.", + "MOBILE_IN_USE": "این شماره موبایل قبلاً استفاده شده است.", + "ACCOUNT_DISABLED": "حساب کاربری شما غیرفعال است.", + "RESET_TOKEN_INVALID_OR_EXPIRED": "توکن بازنشانی نامعتبر یا منقضی شده است.", + "VALIDATION_ERROR": "خطای اعتبارسنجی", + "STRING_TOO_SHORT": "رشته خیلی کوتاه است", + "STRING_TOO_LONG": "رشته خیلی بلند است", + "FIELD_REQUIRED": "فیلد الزامی است", + "INVALID_EMAIL": "ایمیل نامعتبر است", + "HTTP_ERROR": "درخواست ناموفق بود", + }, + } + + def t(self, key: str, default: str | None = None) -> str: + # 1) gettext domain (if present) + try: + if self._gt is not None: + msg = self._gt.gettext(key) + if msg and msg != key: + return msg + except Exception: + pass + # 2) in-memory catalog fallback + catalog = self._catalog.get(self.locale) or {} + if key in catalog: + return catalog[key] + return default or key + + +async def locale_dependency(request: Request) -> Translator: + lang = negotiate_locale(request.headers.get("Accept-Language")) + return Translator(lang) + + diff --git a/hesabixAPI/app/core/i18n_catalog.py b/hesabixAPI/app/core/i18n_catalog.py new file mode 100644 index 0000000..e640b9b --- /dev/null +++ b/hesabixAPI/app/core/i18n_catalog.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import gettext +import os +from functools import lru_cache +from typing import Optional + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) +LOCALES_DIR = os.path.join(BASE_DIR, 'locales') + + +@lru_cache(maxsize=32) +def get_gettext_translation(locale: str, domain: str = 'messages') -> Optional[gettext.NullTranslations]: + try: + return gettext.translation(domain=domain, localedir=LOCALES_DIR, languages=[locale], fallback=True) + except Exception: + return None diff --git a/hesabixAPI/app/core/responses.py b/hesabixAPI/app/core/responses.py new file mode 100644 index 0000000..154c51a --- /dev/null +++ b/hesabixAPI/app/core/responses.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException, status + + +def success_response(data: Any) -> dict[str, Any]: + return {"success": True, "data": data} + + +class ApiError(HTTPException): + def __init__(self, code: str, message: str, http_status: int = status.HTTP_400_BAD_REQUEST) -> None: + super().__init__(status_code=http_status, detail={"success": False, "error": {"code": code, "message": message}}) + + diff --git a/hesabixAPI/app/core/security.py b/hesabixAPI/app/core/security.py new file mode 100644 index 0000000..1d3f469 --- /dev/null +++ b/hesabixAPI/app/core/security.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import hashlib +import hmac +import os +import secrets +from datetime import datetime, timedelta + +from argon2 import PasswordHasher + +from app.core.settings import get_settings + + +_ph = PasswordHasher() + + +def hash_password(password: str) -> str: + return _ph.hash(password) + + +def verify_password(password: str, password_hash: str) -> bool: + try: + _ph.verify(password_hash, password) + return True + except Exception: + return False + + +def generate_api_key(prefix: str = "ak_live_", length: int = 32) -> tuple[str, str]: + """Return (public_key, key_hash). Store only key_hash in DB.""" + secret = secrets.token_urlsafe(length) + api_key = f"{prefix}{secret}" + settings = get_settings() + key_hash = hashlib.sha256(f"{settings.captcha_secret}:{api_key}".encode("utf-8")).hexdigest() + return api_key, key_hash + + +def consteq(a: str, b: str) -> bool: + return hmac.compare_digest(a, b) + + +def hash_api_key(api_key: str) -> str: + settings = get_settings() + return hashlib.sha256(f"{settings.captcha_secret}:{api_key}".encode("utf-8")).hexdigest() + + diff --git a/hesabixAPI/app/core/settings.py b/hesabixAPI/app/core/settings.py index 1f71ecf..3ab1f37 100644 --- a/hesabixAPI/app/core/settings.py +++ b/hesabixAPI/app/core/settings.py @@ -23,6 +23,15 @@ class Settings(BaseSettings): # Logging log_level: str = "INFO" + # Captcha / Security + captcha_length: int = 5 + captcha_ttl_seconds: int = 180 + captcha_secret: str = "change_me_captcha" + reset_password_ttl_seconds: int = 3600 + + # CORS + cors_allowed_origins: list[str] = ["*"] + @property def mysql_dsn(self) -> str: return ( diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py index fa2e028..cf7e5e5 100644 --- a/hesabixAPI/app/main.py +++ b/hesabixAPI/app/main.py @@ -1,8 +1,12 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware from app.core.settings import get_settings from app.core.logging import configure_logging from adapters.api.v1.health import router as health_router +from adapters.api.v1.auth import router as auth_router +from app.core.i18n import negotiate_locale, Translator +from app.core.error_handlers import register_error_handlers def create_app() -> FastAPI: @@ -15,7 +19,26 @@ def create_app() -> FastAPI: debug=settings.debug, ) + application.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_allowed_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + @application.middleware("http") + async def add_locale(request: Request, call_next): + lang = negotiate_locale(request.headers.get("Accept-Language")) + request.state.locale = lang + request.state.translator = Translator(lang) + response = await call_next(request) + return response + application.include_router(health_router, prefix=settings.api_v1_prefix) + application.include_router(auth_router, prefix=settings.api_v1_prefix) + + register_error_handlers(application) @application.get("/") def read_root() -> dict[str, str]: diff --git a/hesabixAPI/app/services/api_key_service.py b/hesabixAPI/app/services/api_key_service.py new file mode 100644 index 0000000..c8e332e --- /dev/null +++ b/hesabixAPI/app/services/api_key_service.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from sqlalchemy.orm import Session + +from adapters.db.repositories.api_key_repo import ApiKeyRepository +from app.core.security import generate_api_key + + +def list_personal_keys(db: Session, user_id: int) -> list[dict]: + repo = ApiKeyRepository(db) + from adapters.db.models.api_key import ApiKey + stmt = db.query(ApiKey).filter(ApiKey.user_id == user_id, ApiKey.key_type == "personal") + items: list[dict] = [] + for row in stmt.all(): + items.append({ + "id": row.id, + "name": row.name, + "scopes": row.scopes, + "created_at": row.created_at, + "expires_at": row.expires_at, + "revoked_at": row.revoked_at, + }) + return items + + +def create_personal_key(db: Session, user_id: int, name: str | None, scopes: str | None, expires_at: Optional[datetime]) -> tuple[int, str]: + api_key, key_hash = generate_api_key(prefix="ak_personal_") + repo = ApiKeyRepository(db) + obj = repo.create_session_key(user_id=user_id, key_hash=key_hash, device_id=None, user_agent=None, ip=None, expires_at=expires_at) + obj.key_type = "personal" + obj.name = name + obj.scopes = scopes + db.add(obj) + db.commit() + return obj.id, api_key + + +def revoke_key(db: Session, user_id: int, key_id: int) -> None: + from adapters.db.models.api_key import ApiKey + obj = db.get(ApiKey, key_id) + if not obj or obj.user_id != user_id: + from app.core.responses import ApiError + raise ApiError("NOT_FOUND", "Key not found", http_status=404) + obj.revoked_at = datetime.utcnow() + db.add(obj) + db.commit() + + diff --git a/hesabixAPI/app/services/auth_service.py b/hesabixAPI/app/services/auth_service.py new file mode 100644 index 0000000..d05afc6 --- /dev/null +++ b/hesabixAPI/app/services/auth_service.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Optional + +import phonenumbers +from sqlalchemy.orm import Session + +from adapters.db.repositories.user_repo import UserRepository +from adapters.db.repositories.api_key_repo import ApiKeyRepository +from app.core.security import hash_password, verify_password, generate_api_key, consteq +from app.core.settings import get_settings +from app.services.captcha_service import validate_captcha +from adapters.db.repositories.password_reset_repo import PasswordResetRepository +import hashlib + + +def _normalize_email(email: str | None) -> str | None: + return email.lower().strip() if email else None + + +def _normalize_mobile(mobile: str | None) -> str | None: + if not mobile: + return None + # Try parse as international; fallback no region + try: + num = phonenumbers.parse(mobile, None) + if not phonenumbers.is_valid_number(num): + return None + return phonenumbers.format_number(num, phonenumbers.PhoneNumberFormat.E164) + except Exception: + return None + + +def _detect_identifier(identifier: str) -> tuple[str, str | None, str | None]: + identifier = identifier.strip() + if "@" in identifier: + return "email", _normalize_email(identifier), None + mobile = _normalize_mobile(identifier) + return ("mobile", None, mobile) if mobile else ("invalid", None, None) + + +def register_user(*, db: Session, first_name: str | None, last_name: str | None, email: str | None, mobile: str | None, password: str, captcha_id: str, captcha_code: str) -> int: + if not validate_captcha(db, captcha_id, captcha_code): + from app.core.responses import ApiError + raise ApiError("INVALID_CAPTCHA", "Invalid captcha code") + + email_n = _normalize_email(email) + mobile_n = _normalize_mobile(mobile) + if not email_n and not mobile_n: + from app.core.responses import ApiError + raise ApiError("IDENTIFIER_REQUIRED", "Email or mobile is required") + + repo = UserRepository(db) + if email_n and repo.get_by_email(email_n): + from app.core.responses import ApiError + raise ApiError("EMAIL_IN_USE", "Email is already in use") + if mobile_n and repo.get_by_mobile(mobile_n): + from app.core.responses import ApiError + raise ApiError("MOBILE_IN_USE", "Mobile is already in use") + + pwd_hash = hash_password(password) + user = repo.create(email=email_n, mobile=mobile_n, password_hash=pwd_hash, first_name=first_name, last_name=last_name) + return user.id + + +def login_user(*, db: Session, identifier: str, password: str, captcha_id: str, captcha_code: str, device_id: str | None, user_agent: str | None, ip: str | None) -> tuple[str, datetime | None, dict]: + if not validate_captcha(db, captcha_id, captcha_code): + from app.core.responses import ApiError + raise ApiError("INVALID_CAPTCHA", "Invalid captcha code") + + kind, email, mobile = _detect_identifier(identifier) + if kind == "invalid": + from app.core.responses import ApiError + raise ApiError("INVALID_IDENTIFIER", "Identifier must be a valid email or mobile number") + + repo = UserRepository(db) + user = repo.get_by_email(email) if email else repo.get_by_mobile(mobile) # type: ignore[arg-type] + if not user or not verify_password(password, user.password_hash): + from app.core.responses import ApiError + raise ApiError("INVALID_CREDENTIALS", "Invalid credentials") + if not user.is_active: + from app.core.responses import ApiError + raise ApiError("ACCOUNT_DISABLED", "Your account is disabled") + + settings = get_settings() + api_key, key_hash = generate_api_key() + expires_at = None # could be set from settings later + api_repo = ApiKeyRepository(db) + api_repo.create_session_key(user_id=user.id, key_hash=key_hash, device_id=device_id, user_agent=user_agent, ip=ip, expires_at=expires_at) + + user_data = { + "id": user.id, + "first_name": user.first_name, + "last_name": user.last_name, + "email": user.email, + "mobile": user.mobile, + } + return api_key, expires_at, user_data + + +def _hash_reset_token(token: str) -> str: + settings = get_settings() + return hashlib.sha256(f"{settings.captcha_secret}:{token}".encode("utf-8")).hexdigest() + + +def create_password_reset(*, db: Session, identifier: str, captcha_id: str, captcha_code: str) -> str: + if not validate_captcha(db, captcha_id, captcha_code): + from app.core.responses import ApiError + raise ApiError("INVALID_CAPTCHA", "Invalid captcha code") + + kind, email, mobile = _detect_identifier(identifier) + if kind == "invalid": + from app.core.responses import ApiError + raise ApiError("INVALID_IDENTIFIER", "Identifier must be a valid email or mobile number") + + repo = UserRepository(db) + user = repo.get_by_email(email) if email else repo.get_by_mobile(mobile) # type: ignore[arg-type] + # Always respond OK to avoid user enumeration; but skip creation if user not found + if not user: + return "" + + settings = get_settings() + from secrets import token_urlsafe + token = token_urlsafe(32) + token_hash = _hash_reset_token(token) + expires_at = datetime.utcnow() + timedelta(seconds=settings.reset_password_ttl_seconds) + pr_repo = PasswordResetRepository(db) + pr_repo.create(user_id=user.id, token_hash=token_hash, expires_at=expires_at) + return token + + +def reset_password(*, db: Session, token: str, new_password: str, captcha_id: str, captcha_code: str) -> None: + if not validate_captcha(db, captcha_id, captcha_code): + from app.core.responses import ApiError + raise ApiError("INVALID_CAPTCHA", "Invalid captcha code") + + pr_repo = PasswordResetRepository(db) + token_hash = _hash_reset_token(token) + pr = pr_repo.get_by_hash(token_hash) + if not pr or pr.expires_at < datetime.utcnow() or pr.used_at is not None: + from app.core.responses import ApiError + raise ApiError("RESET_TOKEN_INVALID_OR_EXPIRED", "Reset token is invalid or expired") + + # Update user password + user_repo = UserRepository(db) + user = user_repo.db.get(type(user_repo).db.registry.mapped_classes['User'], pr.user_id) # not ideal, fallback to direct get + # Safer: direct session get + from adapters.db.models.user import User + user = user_repo.db.get(User, pr.user_id) + if not user: + from app.core.responses import ApiError + raise ApiError("RESET_TOKEN_INVALID_OR_EXPIRED", "Reset token is invalid or expired") + user.password_hash = hash_password(new_password) + user_repo.db.add(user) + user_repo.db.commit() + + pr_repo.mark_used(pr) + + + diff --git a/hesabixAPI/app/services/captcha_service.py b/hesabixAPI/app/services/captcha_service.py new file mode 100644 index 0000000..699af72 --- /dev/null +++ b/hesabixAPI/app/services/captcha_service.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import base64 +import io +import os +import secrets +from datetime import datetime, timedelta +from typing import Tuple + +from PIL import Image, ImageDraw, ImageFont, ImageFilter +from sqlalchemy.orm import Session + +from adapters.db.models.captcha import Captcha +from app.core.settings import get_settings +import hashlib + + +def _generate_numeric_code(length: int) -> str: + return "".join(str(secrets.randbelow(10)) for _ in range(length)) + + +def _hash_code(code: str, secret: str) -> str: + return hashlib.sha256(f"{secret}:{code}".encode("utf-8")).hexdigest() + + +def _render_image(code: str, width: int = 140, height: int = 48) -> Image.Image: + bg_color = (245, 246, 248) + img = Image.new("RGB", (width, height), bg_color) + draw = ImageDraw.Draw(img) + + try: + font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" + font = ImageFont.truetype(font_path, 28) + except Exception: + font = ImageFont.load_default() + + # Noise lines + for _ in range(3): + xy = [(secrets.randbelow(width), secrets.randbelow(height)) for _ in range(2)] + draw.line(xy, fill=(200, 205, 210), width=1) + + # measure text + try: + bbox = draw.textbbox((0, 0), code, font=font) + text_w = bbox[2] - bbox[0] + text_h = bbox[3] - bbox[1] + except Exception: + # fallback approximate + text_w, text_h = (len(code) * 16, 24) + + x = (width - text_w) // 2 + y = (height - text_h) // 2 + # Slight jitter per character + avg_char_w = max(1, text_w // max(1, len(code))) + for idx, ch in enumerate(code): + cx = x + idx * avg_char_w + secrets.randbelow(3) + cy = y + secrets.randbelow(3) + draw.text((cx, cy), ch, font=font, fill=(60, 70, 80)) + + img = img.filter(ImageFilter.SMOOTH) + return img + + +def create_captcha(db: Session) -> tuple[str, str, int]: + settings = get_settings() + code = _generate_numeric_code(settings.captcha_length) + code_hash = _hash_code(code, settings.captcha_secret) + captcha_id = f"cpt_{secrets.token_hex(8)}" + expires_at = datetime.utcnow() + timedelta(seconds=settings.captcha_ttl_seconds) + + obj = Captcha(id=captcha_id, code_hash=code_hash, expires_at=expires_at, attempts=0) + db.add(obj) + db.commit() + + image = _render_image(code) + buf = io.BytesIO() + image.save(buf, format="PNG") + image_base64 = base64.b64encode(buf.getvalue()).decode("ascii") + return captcha_id, image_base64, settings.captcha_ttl_seconds + + +def validate_captcha(db: Session, captcha_id: str, code: str) -> bool: + settings = get_settings() + obj = db.get(Captcha, captcha_id) + if obj is None: + return False + if obj.expires_at < datetime.utcnow(): + return False + provided_hash = _hash_code(code.strip(), settings.captcha_secret) + if secrets.compare_digest(provided_hash, obj.code_hash): + return True + obj.attempts += 1 + db.add(obj) + db.commit() + return False + + diff --git a/hesabixAPI/hesabix_api.egg-info/PKG-INFO b/hesabixAPI/hesabix_api.egg-info/PKG-INFO index 0d6670c..9a4497e 100644 --- a/hesabixAPI/hesabix_api.egg-info/PKG-INFO +++ b/hesabixAPI/hesabix_api.egg-info/PKG-INFO @@ -10,9 +10,14 @@ Requires-Dist: uvicorn[standard]>=0.30.0 Requires-Dist: sqlalchemy>=2.0.30 Requires-Dist: pymysql>=1.1.0 Requires-Dist: pydantic>=2.7.0 +Requires-Dist: email-validator>=2.0.0.post2 Requires-Dist: pydantic-settings>=2.3.0 Requires-Dist: structlog>=24.1.0 Requires-Dist: alembic>=1.13.2 +Requires-Dist: argon2-cffi>=23.1.0 +Requires-Dist: pillow>=10.3.0 +Requires-Dist: phonenumbers>=8.13.40 +Requires-Dist: Babel>=2.15.0 Provides-Extra: dev Requires-Dist: pytest>=8.2.0; extra == "dev" Requires-Dist: httpx>=0.27.0; extra == "dev" diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt index 6881bf6..40fea28 100644 --- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt +++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt @@ -3,19 +3,39 @@ pyproject.toml adapters/__init__.py adapters/api/__init__.py adapters/api/v1/__init__.py +adapters/api/v1/auth.py adapters/api/v1/health.py +adapters/api/v1/schemas.py adapters/db/__init__.py adapters/db/session.py +adapters/db/models/__init__.py +adapters/db/models/api_key.py +adapters/db/models/captcha.py +adapters/db/models/password_reset.py +adapters/db/models/user.py +adapters/db/repositories/api_key_repo.py +adapters/db/repositories/password_reset_repo.py +adapters/db/repositories/user_repo.py app/__init__.py app/main.py app/core/__init__.py +app/core/auth_dependency.py +app/core/error_handlers.py +app/core/i18n.py +app/core/i18n_catalog.py app/core/logging.py +app/core/responses.py +app/core/security.py app/core/settings.py +app/services/api_key_service.py +app/services/auth_service.py +app/services/captcha_service.py hesabix_api.egg-info/PKG-INFO hesabix_api.egg-info/SOURCES.txt hesabix_api.egg-info/dependency_links.txt hesabix_api.egg-info/requires.txt hesabix_api.egg-info/top_level.txt migrations/env.py +migrations/versions/20250915_000001_init_auth_tables.py tests/__init__.py tests/test_health.py \ No newline at end of file diff --git a/hesabixAPI/hesabix_api.egg-info/requires.txt b/hesabixAPI/hesabix_api.egg-info/requires.txt index 062a7a0..a3e070b 100644 --- a/hesabixAPI/hesabix_api.egg-info/requires.txt +++ b/hesabixAPI/hesabix_api.egg-info/requires.txt @@ -3,9 +3,14 @@ uvicorn[standard]>=0.30.0 sqlalchemy>=2.0.30 pymysql>=1.1.0 pydantic>=2.7.0 +email-validator>=2.0.0.post2 pydantic-settings>=2.3.0 structlog>=24.1.0 alembic>=1.13.2 +argon2-cffi>=23.1.0 +pillow>=10.3.0 +phonenumbers>=8.13.40 +Babel>=2.15.0 [dev] pytest>=8.2.0 diff --git a/hesabixAPI/locales/fa/LC_MESSAGES/messages.mo b/hesabixAPI/locales/fa/LC_MESSAGES/messages.mo new file mode 100644 index 0000000000000000000000000000000000000000..1dbf568044c964f627554121bec06feea586dabc GIT binary patch literal 1494 zcma))T~E_c7{`x_iCKIL+=4myk{C^4U`TMpi?S7{bnEOo;$2gqj2hfXR}5T2##rLK zQm^!Wu;}I(@hv8P2yady;UoA7O!R-cx}l=6N%z}x&hzqro~MW1UC#-gi+FqSzT^g; z-V+`2yFS=w!2$3*I0lBmyI>EP1}}hiV6TDaU_Sx7!54vD15d*K1YQC^gFNmKV`YAx2CYqFz zbVFCCr36k}6;#cT?k9asDP5Bjlekw6MNLeSBX(*^)pX)Vh`OvM0_jO7_rRKS1$kbW zv7Ee}%SPy;l{aVYRl!`gLz=amb6g>w&)D zvwh+laXBt^Bo7UQL(yE;wX(>vwhTG0wdxKoFPV0BjLt2ZPTq3wr1c45wByICEm)2q zW#@A9b~Y2CqqDXfBJ4G5vI^U#`#V5`ZL`KfnX)}^lT|4Dcu-~yZ=HQ$6|X?y&}7vC z!YT)4uLu*^#9N@=2HW-uaC#g#A*RCW-WD>o5QKW`+@ICq2JGS&34$2H>JU)#O07E7 zEAnfvR2iBKY6i{tT@3PK zxZ`aVD55Zc2IjC!5yZ None: + op.create_table( + "users", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("mobile", sa.String(length=32), nullable=True), + sa.Column("first_name", sa.String(length=100), nullable=True), + sa.Column("last_name", sa.String(length=100), nullable=True), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")), + ) + op.create_index("ix_users_email", "users", ["email"], unique=True) + op.create_index("ix_users_mobile", "users", ["mobile"], unique=True) + + op.create_table( + "api_keys", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("key_hash", sa.String(length=128), nullable=False), + sa.Column("key_type", sa.String(length=16), nullable=False), + sa.Column("name", sa.String(length=100), nullable=True), + sa.Column("scopes", sa.String(length=500), nullable=True), + sa.Column("device_id", sa.String(length=100), nullable=True), + sa.Column("user_agent", sa.String(length=255), nullable=True), + sa.Column("ip", sa.String(length=64), nullable=True), + sa.Column("expires_at", sa.DateTime(), nullable=True), + sa.Column("last_used_at", sa.DateTime(), nullable=True), + sa.Column("revoked_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + ) + op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=True) + op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"], unique=False) + + op.create_table( + "captchas", + sa.Column("id", sa.String(length=40), primary_key=True), + sa.Column("code_hash", sa.String(length=128), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("attempts", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + ) + + op.create_table( + "password_resets", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("token_hash", sa.String(length=128), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("used_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + ) + op.create_index("ix_password_resets_token_hash", "password_resets", ["token_hash"], unique=True) + op.create_index("ix_password_resets_user_id", "password_resets", ["user_id"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_password_resets_user_id", table_name="password_resets") + op.drop_index("ix_password_resets_token_hash", table_name="password_resets") + op.drop_table("password_resets") + + op.drop_table("captchas") + + op.drop_index("ix_api_keys_user_id", table_name="api_keys") + op.drop_index("ix_api_keys_key_hash", table_name="api_keys") + op.drop_table("api_keys") + + op.drop_index("ix_users_mobile", table_name="users") + op.drop_index("ix_users_email", table_name="users") + op.drop_table("users") + + diff --git a/hesabixAPI/pyproject.toml b/hesabixAPI/pyproject.toml index 2a2ca57..98e8036 100644 --- a/hesabixAPI/pyproject.toml +++ b/hesabixAPI/pyproject.toml @@ -14,9 +14,14 @@ dependencies = [ "sqlalchemy>=2.0.30", "pymysql>=1.1.0", "pydantic>=2.7.0", + "email-validator>=2.0.0.post2", "pydantic-settings>=2.3.0", "structlog>=24.1.0", - "alembic>=1.13.2" + "alembic>=1.13.2", + "argon2-cffi>=23.1.0", + "pillow>=10.3.0", + "phonenumbers>=8.13.40", + "Babel>=2.15.0" ] [project.optional-dependencies] diff --git a/hesabixUI/hesabix_ui/lib/core/api_client.dart b/hesabixUI/hesabix_ui/lib/core/api_client.dart index 436ced3..49994ee 100644 --- a/hesabixUI/hesabix_ui/lib/core/api_client.dart +++ b/hesabixUI/hesabix_ui/lib/core/api_client.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import '../config/app_config.dart'; +import 'auth_store.dart'; class ApiClientOptions { final Duration connectTimeout; @@ -19,11 +20,16 @@ class ApiClientOptions { class ApiClient { final Dio _dio; static Locale? _currentLocale; + static AuthStore? _authStore; static void setCurrentLocale(Locale locale) { _currentLocale = locale; } + static void bindAuthStore(AuthStore store) { + _authStore = store; + } + ApiClient._(this._dio); factory ApiClient({String? baseUrl, ApiClientOptions options = const ApiClientOptions()}) { @@ -47,6 +53,14 @@ class ApiClient { if (lang != null && lang.isNotEmpty) { options.headers['Accept-Language'] = lang; } + final apiKey = _authStore?.apiKey; + if (apiKey != null && apiKey.isNotEmpty) { + options.headers['Authorization'] = 'ApiKey $apiKey'; + } + final deviceId = _authStore?.deviceId; + if (deviceId != null && deviceId.isNotEmpty) { + options.headers['X-Device-Id'] = deviceId; + } if (kDebugMode) { // ignore: avoid_print print('[API][REQ] ${options.method} ${options.uri}'); diff --git a/hesabixUI/hesabix_ui/lib/core/auth_store.dart b/hesabixUI/hesabix_ui/lib/core/auth_store.dart new file mode 100644 index 0000000..8dd2dcb --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/core/auth_store.dart @@ -0,0 +1,52 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; + +class AuthStore with ChangeNotifier { + static const _kApiKey = 'auth_api_key'; + static const _kDeviceId = 'device_id'; + + final FlutterSecureStorage _secure = const FlutterSecureStorage(); + String? _apiKey; + String? _deviceId; + + String? get apiKey => _apiKey; + String get deviceId => _deviceId ?? ''; + + Future load() async { + final prefs = await SharedPreferences.getInstance(); + _deviceId = prefs.getString(_kDeviceId); + if (_deviceId == null || _deviceId!.isEmpty) { + _deviceId = const Uuid().v4(); + await prefs.setString(_kDeviceId, _deviceId!); + } + + if (kIsWeb) { + _apiKey = prefs.getString(_kApiKey); + } else { + _apiKey = await _secure.read(key: _kApiKey); + _apiKey ??= prefs.getString(_kApiKey); + } + notifyListeners(); + } + + Future saveApiKey(String? key) async { + final prefs = await SharedPreferences.getInstance(); + _apiKey = key; + if (key == null) { + await _secure.delete(key: _kApiKey); + await prefs.remove(_kApiKey); + } else { + if (kIsWeb) { + await prefs.setString(_kApiKey, key); + } else { + await _secure.write(key: _kApiKey, value: key); + await prefs.setString(_kApiKey, key); + } + } + notifyListeners(); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb index 5fa8315..03d4ae2 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb @@ -8,8 +8,7 @@ "loginFailed": "Login failed. Please try again.", "homeWelcome": "Signed in successfully!", "language": "Language", - "requiredField": "is required" - , + "requiredField": "is required", "register": "Register", "forgotPassword": "Forgot password", "firstName": "First name", @@ -17,8 +16,7 @@ "email": "Email", "mobile": "Mobile number", "registerSuccess": "Registration successful.", - "forgotSent": "Reset link sent to your email." - , + "forgotSent": "Reset link sent to your email.", "identifier": "Email or mobile", "theme": "Theme", "system": "System", @@ -26,6 +24,10 @@ "dark": "Dark", "welcomeTitle": "Hesabix Cloud Accounting", "welcomeSubtitle": "Smart, secure, and always available accounting for your business.", - "brandTagline": "Manage your finances anywhere, anytime with confidence." + "brandTagline": "Manage your finances anywhere, anytime with confidence.", + "captcha": "Captcha", + "refresh": "Refresh" + , + "captchaRequired": "Captcha is required." } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb index 5addae1..80a23e9 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb @@ -8,8 +8,7 @@ "loginFailed": "ورود ناموفق بود. لطفاً دوباره تلاش کنید.", "homeWelcome": "ورود موفقیت‌آمیز!", "language": "زبان", - "requiredField": "ضروری است" - , + "requiredField": "ضروری است", "register": "عضویت", "forgotPassword": "فراموشی رمز", "firstName": "نام", @@ -17,8 +16,7 @@ "email": "ایمیل", "mobile": "شماره موبایل", "registerSuccess": "عضویت با موفقیت انجام شد.", - "forgotSent": "لینک بازیابی به ایمیل ارسال شد." - , + "forgotSent": "لینک بازیابی به ایمیل ارسال شد.", "identifier": "ایمیل یا شماره موبایل", "theme": "تم", "system": "سیستمی", @@ -26,6 +24,10 @@ "dark": "تیره", "welcomeTitle": "حسابداری ابری حسابیکس", "welcomeSubtitle": "حسابداری هوشمند، امن و همیشه در دسترس برای کسب‌وکار شما.", - "brandTagline": "مدیریت مالی هرجا و هر زمان با اطمینان." + "brandTagline": "مدیریت مالی هرجا و هر زمان با اطمینان.", + "captcha": "کد امنیتی", + "refresh": "تازه‌سازی" + , + "captchaRequired": "کد امنیتی الزامی است." } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart index f7a75e5..1ea9309 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart @@ -247,6 +247,24 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Manage your finances anywhere, anytime with confidence.'** String get brandTagline; + + /// No description provided for @captcha. + /// + /// In en, this message translates to: + /// **'Captcha'** + String get captcha; + + /// No description provided for @refresh. + /// + /// In en, this message translates to: + /// **'Refresh'** + String get refresh; + + /// No description provided for @captchaRequired. + /// + /// In en, this message translates to: + /// **'Captcha is required.'** + String get captchaRequired; } class _AppLocalizationsDelegate diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart index d9eba62..a947a56 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart @@ -84,4 +84,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get brandTagline => 'Manage your finances anywhere, anytime with confidence.'; + + @override + String get captcha => 'Captcha'; + + @override + String get refresh => 'Refresh'; + + @override + String get captchaRequired => 'Captcha is required.'; } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart index 26f503b..21e48da 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart @@ -83,4 +83,13 @@ class AppLocalizationsFa extends AppLocalizations { @override String get brandTagline => 'مدیریت مالی هرجا و هر زمان با اطمینان.'; + + @override + String get captcha => 'کد امنیتی'; + + @override + String get refresh => 'تازه‌سازی'; + + @override + String get captchaRequired => 'کد امنیتی الزامی است.'; } diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart index 5dbdda5..6411a6c 100644 --- a/hesabixUI/hesabix_ui/lib/main.dart +++ b/hesabixUI/hesabix_ui/lib/main.dart @@ -11,6 +11,7 @@ import 'core/locale_controller.dart'; import 'core/api_client.dart'; import 'theme/theme_controller.dart'; import 'theme/app_theme.dart'; +import 'core/auth_store.dart'; void main() { runApp(const MyApp()); @@ -26,6 +27,7 @@ class MyApp extends StatefulWidget { class _MyAppState extends State { LocaleController? _controller; ThemeController? _themeController; + AuthStore? _authStore; @override void initState() { @@ -51,12 +53,23 @@ class _MyAppState extends State { }); }); }); + + final store = AuthStore(); + store.load().then((_) { + setState(() { + _authStore = store + ..addListener(() { + setState(() {}); + }); + ApiClient.bindAuthStore(store); + }); + }); } // Root of application with GoRouter @override Widget build(BuildContext context) { - if (_controller == null || _themeController == null) { + if (_controller == null || _themeController == null || _authStore == null) { return const MaterialApp( home: Scaffold(body: Center(child: CircularProgressIndicator())), ); @@ -74,6 +87,7 @@ class _MyAppState extends State { builder: (context, state) => LoginPage( localeController: controller, themeController: themeController, + authStore: _authStore!, ), ), GoRoute( diff --git a/hesabixUI/hesabix_ui/lib/pages/login_page.dart b/hesabixUI/hesabix_ui/lib/pages/login_page.dart index eb79440..6c39398 100644 --- a/hesabixUI/hesabix_ui/lib/pages/login_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/login_page.dart @@ -1,18 +1,24 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; +import 'package:dio/dio.dart'; import '../core/api_client.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; import '../core/locale_controller.dart'; -import '../widgets/language_switcher.dart'; -import '../widgets/theme_mode_switcher.dart'; import '../theme/theme_controller.dart'; import '../widgets/auth_footer.dart'; +import '../core/auth_store.dart'; +import '../widgets/error_notice.dart'; class LoginPage extends StatefulWidget { final LocaleController localeController; final ThemeController? themeController; - const LoginPage({super.key, required this.localeController, this.themeController}); + final AuthStore authStore; + const LoginPage({super.key, required this.localeController, this.themeController, required this.authStore}); @override State createState() => _LoginPageState(); @@ -21,8 +27,12 @@ class LoginPage extends StatefulWidget { class _LoginPageState extends State with SingleTickerProviderStateMixin { // Login final _formKey = GlobalKey(); - final _usernameCtrl = TextEditingController(); + final _identifierCtrl = TextEditingController(); final _passwordCtrl = TextEditingController(); + final _loginCaptchaCtrl = TextEditingController(); + String? _loginCaptchaId; + Uint8List? _loginCaptchaImage; + Timer? _loginCaptchaTimer; bool _loadingLogin = false; String? _errorText; @@ -33,29 +43,115 @@ class _LoginPageState extends State with SingleTickerProviderStateMix final _emailCtrl = TextEditingController(); final _mobileCtrl = TextEditingController(); final _registerPasswordCtrl = TextEditingController(); + final _registerCaptchaCtrl = TextEditingController(); + String? _registerCaptchaId; + Uint8List? _registerCaptchaImage; bool _loadingRegister = false; + Timer? _registerCaptchaTimer; + String? _registerErrorText; // Forgot password final _forgotKey = GlobalKey(); - final _forgotEmailCtrl = TextEditingController(); + final _forgotIdentifierCtrl = TextEditingController(); + final _forgotCaptchaCtrl = TextEditingController(); + String? _forgotCaptchaId; + Uint8List? _forgotCaptchaImage; bool _loadingForgot = false; + Timer? _forgotCaptchaTimer; + String? _forgotErrorText; @override void dispose() { - _usernameCtrl.dispose(); + _identifierCtrl.dispose(); _passwordCtrl.dispose(); _firstNameCtrl.dispose(); _lastNameCtrl.dispose(); _emailCtrl.dispose(); _mobileCtrl.dispose(); _registerPasswordCtrl.dispose(); - _forgotEmailCtrl.dispose(); + _registerCaptchaCtrl.dispose(); + _forgotIdentifierCtrl.dispose(); + _loginCaptchaCtrl.dispose(); + _forgotCaptchaCtrl.dispose(); + _loginCaptchaTimer?.cancel(); + _registerCaptchaTimer?.cancel(); + _forgotCaptchaTimer?.cancel(); super.dispose(); } + Future _refreshCaptcha(String scope) async { + final api = ApiClient(); + final res = await api.post>('/api/v1/auth/captcha'); + final data = res.data!['data'] as Map; + final id = data['captcha_id'] as String; + final imgB64 = data['image_base64'] as String; + final bytes = base64Decode(imgB64); + final ttl = (data['ttl_seconds'] as num?)?.toInt(); + setState(() { + if (scope == 'login') _loginCaptchaId = id; + if (scope == 'register') _registerCaptchaId = id; + if (scope == 'forgot') _forgotCaptchaId = id; + if (scope == 'login') _loginCaptchaImage = bytes; + if (scope == 'register') _registerCaptchaImage = bytes; + if (scope == 'forgot') _forgotCaptchaImage = bytes; + }); + if (ttl != null && ttl > 0) { + final delay = Duration(seconds: ttl); + if (scope == 'login') { + _loginCaptchaTimer?.cancel(); + _loginCaptchaTimer = Timer(delay, () => _refreshCaptcha('login')); + } else if (scope == 'register') { + _registerCaptchaTimer?.cancel(); + _registerCaptchaTimer = Timer(delay, () => _refreshCaptcha('register')); + } else if (scope == 'forgot') { + _forgotCaptchaTimer?.cancel(); + _forgotCaptchaTimer = Timer(delay, () => _refreshCaptcha('forgot')); + } + } + } + + @override + void initState() { + super.initState(); + // پیش‌بارگذاری کپچا برای هر سه تب + _refreshCaptcha('login'); + _refreshCaptcha('register'); + _refreshCaptcha('forgot'); + } + + String _extractErrorMessage(Object e, AppLocalizations t) { + try { + if (e is DioException) { + final data = e.response?.data; + if (data is Map && data['error'] is Map && data['error']['message'] is String) { + return data['error']['message'] as String; + } + if (data is Map && data['detail'] is List) { + final details = data['detail'] as List; + if (details.isNotEmpty && details.first is Map && (details.first as Map)['msg'] is String) { + return (details.first as Map)['msg'] as String; + } + } + } + } catch (_) {} + return t.loginFailed; + } + + void _showSnack(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); + } + Future _onSubmit() async { final form = _formKey.currentState; + final t = AppLocalizations.of(context); if (form == null || !form.validate()) return; + if ((_loginCaptchaCtrl.text.trim().isEmpty) || (_loginCaptchaId == null)) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.captchaRequired))); + return; + } setState(() { _loadingLogin = true; @@ -64,20 +160,30 @@ class _LoginPageState extends State with SingleTickerProviderStateMix try { final api = ApiClient(); - await api.post>( - '/user/login', + final res = await api.post>( + '/api/v1/auth/login', data: { - 'username': _usernameCtrl.text.trim(), + 'identifier': _identifierCtrl.text.trim(), 'password': _passwordCtrl.text, + 'captcha_id': _loginCaptchaId, + 'captcha_code': _loginCaptchaCtrl.text.trim(), + 'device_id': widget.authStore.deviceId, }, ); + final data = res.data?['data'] as Map?; + final apiKey = data?['api_key'] as String?; + if (apiKey != null && apiKey.isNotEmpty) { + await widget.authStore.saveApiKey(apiKey); + } if (!mounted) return; - // روی موفقیت ساده به / هدایت می‌کنیم. در آینده توکن/پروفایل ذخیره می‌شود. + _showSnack(t.homeWelcome); context.go('/'); } catch (e) { + final msg = _extractErrorMessage(e, AppLocalizations.of(context)); + _showSnack(msg); setState(() { - _errorText = 'ورود ناموفق بود. لطفاً دوباره تلاش کنید.'; + _errorText = msg; }); } finally { if (mounted) { @@ -85,6 +191,7 @@ class _LoginPageState extends State with SingleTickerProviderStateMix _loadingLogin = false; }); } + _refreshCaptcha('login'); } } @@ -96,25 +203,37 @@ class _LoginPageState extends State with SingleTickerProviderStateMix setState(() => _loadingRegister = true); try { final api = ApiClient(); + if (_emailCtrl.text.trim().isEmpty && _mobileCtrl.text.trim().isEmpty) { + final msg = '${t.email} / ${t.mobile} ${t.requiredField}'; + setState(() { _registerErrorText = msg; }); + _showSnack(msg); + return; + } await api.post>( - '/user/register', + '/api/v1/auth/register', data: { 'first_name': _firstNameCtrl.text.trim(), 'last_name': _lastNameCtrl.text.trim(), - 'email': _emailCtrl.text.trim(), - 'mobile': _mobileCtrl.text.trim(), + 'email': _emailCtrl.text.trim().isEmpty ? null : _emailCtrl.text.trim(), + 'mobile': _mobileCtrl.text.trim().isEmpty ? null : _mobileCtrl.text.trim(), 'password': _registerPasswordCtrl.text, + 'captcha_id': _registerCaptchaId, + 'captcha_code': _registerCaptchaCtrl.text.trim(), }, ); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.registerSuccess))); - DefaultTabController.of(context)?.animateTo(0); - } catch (_) { + setState(() { _registerErrorText = null; }); + _showSnack(t.registerSuccess); + DefaultTabController.of(context).animateTo(0); + } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.loginFailed))); + final msg = _extractErrorMessage(e, AppLocalizations.of(context)); + setState(() { _registerErrorText = msg; }); + _showSnack(msg); } finally { if (mounted) setState(() => _loadingRegister = false); + _refreshCaptcha('register'); } } @@ -127,19 +246,24 @@ class _LoginPageState extends State with SingleTickerProviderStateMix try { final api = ApiClient(); await api.post>( - '/user/forgot-password', + '/api/v1/auth/forgot-password', data: { - 'email': _forgotEmailCtrl.text.trim(), + 'identifier': _forgotIdentifierCtrl.text.trim(), + 'captcha_id': _forgotCaptchaId, + 'captcha_code': _forgotCaptchaCtrl.text.trim(), }, ); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.forgotSent))); - } catch (_) { + _showSnack(t.forgotSent); + } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.loginFailed))); + final msg = _extractErrorMessage(e, AppLocalizations.of(context)); + setState(() { _forgotErrorText = msg; }); + _showSnack(msg); } finally { if (mounted) setState(() => _loadingForgot = false); + _refreshCaptcha('forgot'); } } @@ -196,7 +320,7 @@ class _LoginPageState extends State with SingleTickerProviderStateMix crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextFormField( - controller: _usernameCtrl, + controller: _identifierCtrl, decoration: InputDecoration(labelText: t.identifier), validator: (v) => (v == null || v.trim().isEmpty) ? '${t.identifier} ${t.requiredField}' : null, textInputAction: TextInputAction.next, @@ -209,9 +333,42 @@ class _LoginPageState extends State with SingleTickerProviderStateMix validator: (v) => (v == null || v.isEmpty) ? '${t.password} ${t.requiredField}' : null, onFieldSubmitted: (_) => _onSubmit(), ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _loginCaptchaCtrl, + decoration: InputDecoration(labelText: t.captcha), + validator: (v) => (v == null || v.trim().isEmpty) ? '${t.captcha} ${t.requiredField}' : null, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + const SizedBox(width: 8), + if (_loginCaptchaImage != null) + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.memory( + _loginCaptchaImage!, + height: 40, + width: 120, + fit: BoxFit.contain, + ), + ) + else + const SizedBox(height: 40, width: 120), + const SizedBox(width: 8), + IconButton( + onPressed: () => _refreshCaptcha('login'), + icon: const Icon(Icons.refresh), + tooltip: t.refresh, + ), + ], + ), const SizedBox(height: 16), if (_errorText != null) - Text(_errorText!, style: const TextStyle(color: Colors.red)), + ErrorNotice(message: _errorText!, onClose: () => setState(() => _errorText = null)), const SizedBox(height: 12), FilledButton( onPressed: _loadingLogin ? null : _onSubmit, @@ -268,7 +425,43 @@ class _LoginPageState extends State with SingleTickerProviderStateMix validator: (v) => (v == null || v.isEmpty) ? '${t.password} ${t.requiredField}' : null, onFieldSubmitted: (_) => _onRegister(), ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _registerCaptchaCtrl, + decoration: InputDecoration(labelText: t.captcha), + validator: (v) => (v == null || v.trim().isEmpty) ? '${t.captcha} ${t.requiredField}' : null, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + const SizedBox(width: 8), + if (_registerCaptchaImage != null) + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.memory( + _registerCaptchaImage!, + height: 40, + width: 120, + fit: BoxFit.contain, + ), + ) + else + const SizedBox(height: 40, width: 120), + const SizedBox(width: 8), + IconButton( + onPressed: () => _refreshCaptcha('register'), + icon: const Icon(Icons.refresh), + tooltip: t.refresh, + ), + ], + ), const SizedBox(height: 16), + if (_registerErrorText != null) + ErrorNotice(message: _registerErrorText!, onClose: () => setState(() => _registerErrorText = null)), + const SizedBox(height: 12), FilledButton( onPressed: _loadingRegister ? null : _onRegister, child: _loadingRegister @@ -288,13 +481,48 @@ class _LoginPageState extends State with SingleTickerProviderStateMix crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextFormField( - controller: _forgotEmailCtrl, - decoration: InputDecoration(labelText: t.email), - keyboardType: TextInputType.emailAddress, - validator: (v) => (v == null || v.trim().isEmpty) ? '${t.email} ${t.requiredField}' : null, + controller: _forgotIdentifierCtrl, + decoration: InputDecoration(labelText: t.identifier), + validator: (v) => (v == null || v.trim().isEmpty) ? '${t.identifier} ${t.requiredField}' : null, onFieldSubmitted: (_) => _onForgot(), ), const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _forgotCaptchaCtrl, + decoration: InputDecoration(labelText: t.captcha), + validator: (v) => (v == null || v.trim().isEmpty) ? '${t.captcha} ${t.requiredField}' : null, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + const SizedBox(width: 8), + if (_forgotCaptchaImage != null) + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.memory( + _forgotCaptchaImage!, + height: 40, + width: 120, + fit: BoxFit.contain, + ), + ) + else + const SizedBox(height: 40, width: 120), + const SizedBox(width: 8), + IconButton( + onPressed: () => _refreshCaptcha('forgot'), + icon: const Icon(Icons.refresh), + tooltip: t.refresh, + ), + ], + ), + const SizedBox(height: 12), + if (_forgotErrorText != null) + ErrorNotice(message: _forgotErrorText!, onClose: () => setState(() => _forgotErrorText = null)), + const SizedBox(height: 12), FilledButton( onPressed: _loadingForgot ? null : _onForgot, child: _loadingForgot diff --git a/hesabixUI/hesabix_ui/lib/widgets/error_notice.dart b/hesabixUI/hesabix_ui/lib/widgets/error_notice.dart new file mode 100644 index 0000000..4b9115c --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/error_notice.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +class ErrorNotice extends StatelessWidget { + final String message; + final VoidCallback? onClose; + + const ErrorNotice({super.key, required this.message, this.onClose}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final bg = cs.errorContainer; + final fg = cs.onErrorContainer; + return Semantics( + container: true, + liveRegion: true, + label: 'error', + child: Container( + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: cs.error.withOpacity(0.4)), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error_outline, color: cs.error, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: TextStyle(color: fg, height: 1.3), + ), + ), + if (onClose != null) + IconButton( + tooltip: MaterialLocalizations.of(context).closeButtonLabel, + icon: Icon(Icons.close, color: fg), + onPressed: onClose, + visualDensity: VisualDensity.compact, + splashRadius: 16, + ), + ], + ), + ), + ); + } +} + + diff --git a/hesabixUI/hesabix_ui/linux/flutter/generated_plugin_registrant.cc b/hesabixUI/hesabix_ui/linux/flutter/generated_plugin_registrant.cc index e71a16d..d0e7f79 100644 --- a/hesabixUI/hesabix_ui/linux/flutter/generated_plugin_registrant.cc +++ b/hesabixUI/hesabix_ui/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); } diff --git a/hesabixUI/hesabix_ui/linux/flutter/generated_plugins.cmake b/hesabixUI/hesabix_ui/linux/flutter/generated_plugins.cmake index 2e1de87..b29e9ba 100644 --- a/hesabixUI/hesabix_ui/linux/flutter/generated_plugins.cmake +++ b/hesabixUI/hesabix_ui/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/hesabixUI/hesabix_ui/macos/Flutter/GeneratedPluginRegistrant.swift b/hesabixUI/hesabix_ui/macos/Flutter/GeneratedPluginRegistrant.swift index 724bb2a..37af1fe 100644 --- a/hesabixUI/hesabix_ui/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/hesabixUI/hesabix_ui/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,12 @@ import FlutterMacOS import Foundation +import flutter_secure_storage_macos +import path_provider_foundation import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/hesabixUI/hesabix_ui/pubspec.lock b/hesabixUI/hesabix_ui/pubspec.lock index 6202ff9..bd4efb2 100644 --- a/hesabixUI/hesabix_ui/pubspec.lock +++ b/hesabixUI/hesabix_ui/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" cupertino_icons: dependency: "direct main" description: @@ -89,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -107,6 +123,54 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_test: dependency: "direct dev" description: flutter @@ -141,6 +205,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" leak_tracker: dependency: transitive description: @@ -221,6 +293,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db" + url: "https://pub.dev" + source: hosted + version: "2.2.18" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + url: "https://pub.dev" + source: hosted + version: "2.4.2" path_provider_linux: dependency: transitive description: @@ -330,6 +426,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" stack_trace: dependency: transitive description: @@ -378,6 +482,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" vector_math: dependency: transitive description: @@ -402,6 +514,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + url: "https://pub.dev" + source: hosted + version: "5.14.0" xdg_directories: dependency: transitive description: diff --git a/hesabixUI/hesabix_ui/pubspec.yaml b/hesabixUI/hesabix_ui/pubspec.yaml index f860a6c..6e20c23 100644 --- a/hesabixUI/hesabix_ui/pubspec.yaml +++ b/hesabixUI/hesabix_ui/pubspec.yaml @@ -39,6 +39,8 @@ dependencies: dio: ^5.7.0 go_router: ^14.2.7 shared_preferences: ^2.3.2 + flutter_secure_storage: ^9.2.2 + uuid: ^4.4.2 dev_dependencies: flutter_test: diff --git a/hesabixUI/hesabix_ui/windows/flutter/generated_plugin_registrant.cc b/hesabixUI/hesabix_ui/windows/flutter/generated_plugin_registrant.cc index 8b6d468..0c50753 100644 --- a/hesabixUI/hesabix_ui/windows/flutter/generated_plugin_registrant.cc +++ b/hesabixUI/hesabix_ui/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); } diff --git a/hesabixUI/hesabix_ui/windows/flutter/generated_plugins.cmake b/hesabixUI/hesabix_ui/windows/flutter/generated_plugins.cmake index b93c4c3..4fc759c 100644 --- a/hesabixUI/hesabix_ui/windows/flutter/generated_plugins.cmake +++ b/hesabixUI/hesabix_ui/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST