From ba3be2cd7c19b75b634aa8b19c868c8388210a1f Mon Sep 17 00:00:00 2001 From: Jeevannyk <180858537+Jeevannyk@users.noreply.github.com> Date: Thu, 28 May 2026 20:36:50 +0530 Subject: [PATCH] Redesign UI with Inter font, add QR auth, passwordless flow, and new pages - Replace Cormorant Garamond + DM Sans with Inter across all 9 pages - Redesign login.html with split-card layout and QR reticle animation - Redesign home.html with editorial identity hero and device management - Add new pages: register, scan, totp, forgot-password, reset-password, verify-email - Add QR session auth backend (routes, schemas, security, config) - Add Alembic migrations for passwordless QR auth schema - Add rate limiting, email verification, password reset, TOTP routes - Update start.bat/sh to bind 0.0.0.0 for LAN access - Add Dockerfile and docker-compose for containerized deployment - Add test suite for auth, email verification, password reset, security, TOTP Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 34 +- Dockerfile | 25 + README.md | 527 ++-------- add_user.py | 59 -- alembic.ini | 38 + alembic/env.py | 49 + alembic/script.py.mako | 25 + alembic/versions/0001_initial.py | 52 + ...2_email_verification_and_password_reset.py | 37 + alembic/versions/0003_passwordless_qr_auth.py | 122 +++ backend/config.py | 59 ++ backend/database.py | 20 +- backend/deps.py | 42 + backend/email.py | 59 ++ backend/main.py | 315 +----- backend/models.py | 131 ++- backend/rate_limit.py | 4 + backend/routes/__init__.py | 0 backend/routes/auth.py | 63 ++ backend/routes/devices.py | 62 ++ backend/routes/oauth.py | 137 +++ backend/routes/pages.py | 38 + backend/routes/qr.py | 233 +++++ backend/routes/twofa.py | 61 ++ backend/schemas.py | 97 ++ backend/security.py | 156 +-- check_db.py | 23 - create_user.py | 26 - docker-compose.yml | 42 + frontend/fingerprint.js | 85 -- frontend/forgot-password.html | 189 ++++ frontend/forgot-password.js | 117 +++ frontend/home.html | 939 ++++++++++-------- frontend/home.js | 196 ++-- frontend/login.html | 832 ++++++++++++---- frontend/login.js | 487 ++++++--- frontend/register.html | 151 +++ frontend/register.js | 62 ++ frontend/reset-password.html | 195 ++++ frontend/reset-password.js | 47 + frontend/scan.html | 166 ++++ frontend/scan.js | 104 ++ frontend/signup.html | 482 +++++---- frontend/signup.js | 59 +- frontend/totp.html | 373 +++++++ frontend/totp.js | 73 ++ frontend/util.js | 22 + frontend/verify-email.html | 176 ++++ frontend/verify-email.js | 45 + init_db.py | 73 -- migrate_session_to_user_id.py | 121 --- requirements.txt | Bin 1510 -> 317 bytes start.bat | 57 +- start.sh | 59 +- test_complete.py | 318 ------ test_database.py | 120 --- test_login.py | 33 - test_signup.py | 31 - tests/__init__.py | 0 tests/conftest.py | 28 + tests/test_auth.py | 108 ++ tests/test_email_verification.py | 91 ++ tests/test_password_reset.py | 110 ++ tests/test_security.py | 33 + tests/test_twofa.py | 61 ++ verify_backend.py | 23 - 66 files changed, 5827 insertions(+), 2775 deletions(-) create mode 100644 Dockerfile delete mode 100644 add_user.py create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/0001_initial.py create mode 100644 alembic/versions/0002_email_verification_and_password_reset.py create mode 100644 alembic/versions/0003_passwordless_qr_auth.py create mode 100644 backend/config.py create mode 100644 backend/deps.py create mode 100644 backend/email.py create mode 100644 backend/rate_limit.py create mode 100644 backend/routes/__init__.py create mode 100644 backend/routes/auth.py create mode 100644 backend/routes/devices.py create mode 100644 backend/routes/oauth.py create mode 100644 backend/routes/pages.py create mode 100644 backend/routes/qr.py create mode 100644 backend/routes/twofa.py create mode 100644 backend/schemas.py delete mode 100644 check_db.py delete mode 100644 create_user.py create mode 100644 docker-compose.yml delete mode 100644 frontend/fingerprint.js create mode 100644 frontend/forgot-password.html create mode 100644 frontend/forgot-password.js create mode 100644 frontend/register.html create mode 100644 frontend/register.js create mode 100644 frontend/reset-password.html create mode 100644 frontend/reset-password.js create mode 100644 frontend/scan.html create mode 100644 frontend/scan.js create mode 100644 frontend/totp.html create mode 100644 frontend/totp.js create mode 100644 frontend/util.js create mode 100644 frontend/verify-email.html create mode 100644 frontend/verify-email.js delete mode 100644 init_db.py delete mode 100644 migrate_session_to_user_id.py delete mode 100644 test_complete.py delete mode 100644 test_database.py delete mode 100644 test_login.py delete mode 100644 test_signup.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_email_verification.py create mode 100644 tests/test_password_reset.py create mode 100644 tests/test_security.py create mode 100644 tests/test_twofa.py delete mode 100644 verify_backend.py diff --git a/.env.example b/.env.example index 7e24630..7846828 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,32 @@ -# Security Configuration -SECRET_KEY=your-secret-key-change-this-to-a-random-string-in-production +ENVIRONMENT=development -# CORS Configuration (comma-separated origins) -ALLOWED_ORIGINS=http://localhost:8000,http://127.0.0.1:8000 +# Minimum 32 characters. Generate with: python -c "import secrets; print(secrets.token_urlsafe(48))" +SECRET_KEY=replace-me-with-a-long-random-string-at-least-32-chars -# Database Configuration DATABASE_URL=sqlite:///./auth.db +# DATABASE_URL=postgresql+psycopg://app:app@localhost:5432/app + +ALLOWED_ORIGINS=http://localhost:8000,http://127.0.0.1:8000 + +ACCESS_TOKEN_MINUTES=15 +REFRESH_TOKEN_DAYS=14 + +LOGIN_RATE_LIMIT=5/minute +SIGNUP_RATE_LIMIT=3/minute + +LOCKOUT_THRESHOLD=5 +LOCKOUT_MINUTES=15 + +# Set to true behind HTTPS in production. +COOKIE_SECURE=false + +TOTP_ISSUER=Cipher + +# ── Email (set EMAIL_ENABLED=true to send real verification / reset emails) ── +EMAIL_ENABLED=false +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=user@example.com +SMTP_PASSWORD=your-smtp-password +SMTP_FROM=noreply@cipher.app +APP_BASE_URL=http://localhost:8000 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ec53a4d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12-slim AS base + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --upgrade pip && pip install -r requirements.txt + +COPY backend ./backend +COPY frontend ./frontend +COPY alembic ./alembic +COPY alembic.ini ./ + +RUN useradd --create-home --uid 1000 app && chown -R app:app /app +USER app + +EXPOSE 8000 +CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 6d395cc..ded310b 100644 --- a/README.md +++ b/README.md @@ -1,441 +1,134 @@ -# FastAPI Authentication System πŸš€ +# Cipher Auth -A modern, secure authentication system built with FastAPI, featuring multi-step authentication flow with biometric verification and QR code generation. +A production-shaped FastAPI authentication service. Cookie-based sessions, refresh-token rotation, TOTP two-factor, email verification, password reset, account lockout, rate limiting, Alembic migrations, and a Postgres-ready Docker setup. -## ✨ Features +## Stack -- **Multi-Step Authentication Flow**: Email/Password β†’ Biometric Fingerprint β†’ QR Code β†’ Home Dashboard -- **Secure Password Hashing**: Industry-standard bcrypt with 12-round salt -- **JWT Token Authentication**: Stateless session management with 30-minute expiry -- **SQLAlchemy ORM**: Type-safe database operations with declarative models -- **Modern UI**: Responsive cybersecurity-themed interface with TailwindCSS -- **Path Traversal Protection**: UUID validation preventing malicious session access -- **Timezone-aware Sessions**: UTC datetime handling for global consistency -- **CORS Configuration**: Environment-based security settings +- FastAPI + Pydantic v2 + pydantic-settings +- SQLAlchemy 2.x + Alembic +- bcrypt, python-jose (JWT), pyotp (TOTP), qrcode +- slowapi for rate limiting +- SQLite for local dev, Postgres for prod (psycopg 3) +- Vanilla JS frontend (no build step) -## πŸ› οΈ Tech Stack +## Auth model -**Backend** -- FastAPI 0.127.0 - High-performance async web framework -- SQLAlchemy 2.0.46 - SQL toolkit and ORM -- Bcrypt 5.0.0 - Password hashing -- Python-jose 3.5.0 + PyJWT 2.10.1 - JWT token management -- QRCode 8.2 - QR code generation +- **Access tokens** β€” short-lived JWT in an `HttpOnly SameSite=Lax` cookie at path `/`. 15 min default. +- **Refresh tokens** β€” opaque random tokens; only their SHA-256 hash lives in the database. Stored in a separate `HttpOnly` cookie scoped to `/api/auth`. On `/refresh`, the old token is revoked and a new one is issued (rotation). +- **Email verification** β€” new accounts receive a 24-hour verification link. Set `EMAIL_ENABLED=false` (default) to skip in dev; auto-verifies immediately. Unverified accounts cannot log in. +- **Password reset** β€” time-limited (1 hour) reset links sent by email. Always returns a generic success message to prevent email enumeration. Resets the account lockout on success. +- **2FA / TOTP** β€” RFC 6238. When enabled, `/login` returns `requires_2fa: true` and sets a short-lived `pending_2fa` cookie. The client posts a 6-digit code to `/login/2fa` to finalize. QR code generated server-side. +- **Lockout** β€” after 5 failed logins the account locks for 15 minutes (configurable). +- **Rate limiting** β€” `5/minute` on `/login`, `3/minute` on `/signup` (per IP, configurable). +- **HTTPS enforcement** β€” when `ENVIRONMENT=production`, HTTP requests are automatically redirected to HTTPS. -**Frontend** -- HTML5, CSS3, Vanilla JavaScript -- TailwindCSS 3.x - Utility-first CSS -- Material Icons - Icon library +## Quick start (local) -**Database** -- SQLite (development) -- Production-ready for PostgreSQL/MySQL - -## πŸ“ Project Structure - -``` -fastapi-auth-system/ -β”œβ”€β”€ backend/ -β”‚ β”œβ”€β”€ __init__.py -β”‚ β”œβ”€β”€ main.py # FastAPI app & routes -β”‚ β”œβ”€β”€ models.py # SQLAlchemy User & Session models -β”‚ β”œβ”€β”€ database.py # Database config & connection -β”‚ └── security.py # Password hashing & JWT tokens -β”œβ”€β”€ frontend/ -β”‚ β”œβ”€β”€ login.html # Login page -β”‚ β”œβ”€β”€ login.js # Login logic -β”‚ β”œβ”€β”€ signup.html # Registration page -β”‚ β”œβ”€β”€ signup.js # Signup logic -β”‚ β”œβ”€β”€ home.html # Dashboard -β”‚ β”œβ”€β”€ home.js # Dashboard logic -β”‚ β”œβ”€β”€ fingerprint.js # Biometric verification -β”œβ”€β”€ qrs/ # Generated QR code images -β”œβ”€β”€ auth.db # SQLite database -β”œβ”€β”€ init_db.py # Database initialization script -β”œβ”€β”€ check_db.py # Database inspection utility -β”œβ”€β”€ test_complete.py # End-to-end test suite -β”œβ”€β”€ requirements.txt # Python dependencies -β”œβ”€β”€ .env.example # Environment variables template -β”œβ”€β”€ start.bat # Windows startup script -└── start.sh # Unix startup script - -``` - -## πŸš€ Quick Start - -### Prerequisites -- Python 3.8 or higher -- pip (Python package installer) - -### Installation - -1. **Clone the repository** -```bash -git clone -cd fastapi-auth-system -``` - -2. **Create virtual environment (recommended)** ```bash python -m venv .venv - -# Windows -.venv\Scripts\activate - -# macOS/Linux -source .venv/bin/activate -``` - -3. **Install dependencies** -```bash +. .venv/Scripts/activate # PowerShell: .venv\Scripts\Activate.ps1 pip install -r requirements.txt -``` - -4. **Set up environment variables** (optional) -```bash -# Windows -copy .env.example .env - -# macOS/Linux -cp .env.example .env - -# Edit .env and set a secure SECRET_KEY (random 32+ character string) -``` - -5. **Initialize the database** -```bash -python init_db.py -``` - -This creates `auth.db` with a test user: -- **Email**: test@example.com -- **Access Key**: password123 -## πŸƒ Running the Application +cp .env.example .env # then edit SECRET_KEY +alembic upgrade head -### Option 1: Direct Command -```bash -uvicorn backend.main:app --host 127.0.0.1 --port 8000 +uvicorn backend.main:app --reload ``` -### Option 2: With Auto-reload (Development) -```bash -uvicorn backend.main:app --host 127.0.0.1 --port 8000 --reload -``` +Open . API docs at `/docs`. -### Option 3: Using Startup Scripts +## Quick start (Docker + Postgres) -**Windows:** ```bash -start.bat +echo "SECRET_KEY=$(python -c 'import secrets;print(secrets.token_urlsafe(48))')" > .env +docker compose up --build ``` -**macOS/Linux:** -```bash -chmod +x start.sh -./start.sh -``` - -The application will be available at: -- **Frontend**: http://127.0.0.1:8000/ -- **API Docs**: http://127.0.0.1:8000/docs -- **Health Check**: http://127.0.0.1:8000/health - -## πŸ“– API Documentation +Postgres + the app start together; migrations run automatically on startup. -### Authentication Flow +## Email setup -The system implements a 3-step authentication process: - -#### Step 1: Login (Credentials) -**POST** `/login` - -```json -Request: -{ - "email": "test@example.com", - "access_key": "password123" -} - -Response: -{ - "message": "Credentials verified", - "access_token": "eyJhbGc...", - "next": "fingerprint" -} -``` - -#### Step 2: Biometric Verification -**POST** `/fingerprint` - -```json -Request: -{ - "email": "test@example.com" -} - -Response: -{ - "message": "Biometric verified", - "session_id": "550e8400-e29b-41d4-a716-446655440000", - "next": "qr" -} -``` - -#### Step 3: QR Code Generation -**GET** `/qr/{session_id}` - -```json -Response: -{ - "qr_image": "qrs/550e8400-e29b-41d4-a716-446655440000.png", - "payload": "antigravity://connect/550e8400-e29b-41d4-a716-446655440000" -} -``` - -**GET** `/qr-image/{session_id}` -- Returns QR code image as PNG -- Content-Type: `image/png` - -### Other Endpoints - -**GET** `/health` -```json -{ - "status": "ok" -} -``` - -**POST** `/signup` -```json -Request: -{ - "full_name": "John Doe", - "email": "john@example.com", - "access_key": "securepassword123" -} - -Response: -{ - "message": "User registered successfully", - "email": "john@example.com" -} -``` - -## πŸ§ͺ Testing - -### Run Complete Test Suite -```bash -# Make sure server is running first -python test_complete.py -``` - -Tests include: -- βœ… Health check -- βœ… Frontend page loading -- βœ… User signup -- βœ… User login & JWT tokens -- βœ… Invalid credentials rejection -- βœ… Fingerprint/session creation -- βœ… QR code generation -- βœ… QR image serving -- βœ… Path traversal protection - -### Individual Test Scripts -```bash -python test_database.py # Database connectivity -python test_signup.py # Signup endpoint -python test_login.py # Login endpoint -``` - -### Database Utilities -```bash -python check_db.py # View all users and sessions -python init_db.py # Reset database with test user -``` - -## πŸ”’ Security Features - -1. **Password Security** - - Bcrypt hashing with 12-round salt - - 72-byte password limit (bcrypt standard) - - Never stores plaintext passwords - -2. **JWT Tokens** - - HS256 algorithm - - 30-minute expiry - - **⚠️ Security Notice**: Current implementation uses localStorage which is vulnerable to XSS attacks - - **Recommended Migration to HttpOnly Cookies:** - - Set JWT in `Set-Cookie` header with `HttpOnly`, `Secure`, and `SameSite=Strict` flags - - Browser automatically includes cookie in requests (no JavaScript access) - - Prevents token theft via XSS exploits - - **If localStorage Must Be Retained:** - - Implement Content Security Policy (CSP) headers to prevent inline scripts - - Use input sanitization (DOMPurify) for all user-generated content - - Add CSRF protection tokens for state-changing operations - - Consider short token expiry (5-15 minutes) with refresh tokens - - Enable SameSite cookie attribute on session cookies - - **Trade-offs:** - - HttpOnly cookies: Better XSS protection but requires CSRF tokens - - localStorage: Easier cross-domain support but vulnerable to XSS - - *(Implementation code for HttpOnly cookie-based JWT handling available upon request)* - -3. **Session Management** - - UUID-based session IDs - - Timezone-aware timestamps - - Path traversal validation - -4. **Input Validation** - - Pydantic models for request validation - - Email format validation - - UUID format validation - - SQL injection protection via ORM - -5. **CORS Configuration** - - Environment-based allowed origins - - Credentials support - - Configurable methods and headers - -## πŸ—„οΈ Database Schema - -### Users Table -```sql -id INTEGER PRIMARY KEY AUTOINCREMENT -full_name VARCHAR NOT NULL -email VARCHAR UNIQUE NOT NULL -password_hash VARCHAR NOT NULL -created_at DATETIME DEFAULT CURRENT_TIMESTAMP -``` - -### Sessions Table -```sql -session_id VARCHAR(36) PRIMARY KEY -- UUID format -email VARCHAR NOT NULL -created_at DATETIME DEFAULT CURRENT_TIMESTAMP -``` - -## πŸ› οΈ Troubleshooting - -### Port Already in Use -```bash -# Windows - Kill process on port 8000 -netstat -ano | findstr :8000 -taskkill /PID /F - -# macOS/Linux -lsof -ti:8000 | xargs kill -9 -``` - -### Database Locked Error -```bash -# Close all connections and reinitialize -python init_db.py -``` - -### Module Not Found Error -```bash -# Ensure virtual environment is activated -pip install -r requirements.txt -``` - -## πŸ“ Environment Variables - -Create `.env` file based on `.env.example`: +By default `EMAIL_ENABLED=false` β€” users are verified instantly and no SMTP server is needed. To enable real emails: ```env -SECRET_KEY=your-secret-key-here-minimum-32-characters -DATABASE_URL=sqlite:///./auth.db -ALLOWED_ORIGINS=http://localhost:8000,http://127.0.0.1:8000 -TOKEN_EXPIRE_MINUTES=30 -``` - -## 🀝 Contributing - -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request - -## πŸ“„ License - -This project is licensed under the MIT License. - -## πŸ™ Acknowledgments - -- FastAPI for the excellent web framework -- SQLAlchemy for robust ORM -- TailwindCSS for beautiful styling - ---- - -**Built with ❀️ using FastAPI** - -Access at: -- **Frontend**: http://127.0.0.1:8000 -- **API Docs**: http://127.0.0.1:8000/docs - -## �️ Utility Scripts - -### User -- `id`, `full_name`, `email` (unique), `password_hash`, `created_at` - -### Session -- `session_id` (UUID), `email`, `created_at` (expires in 5 minutes) - -## πŸ› οΈ Utility Scripts - -```bash -python check_db.py # List all users -python add_user.py # Add test user -python test_signup.py # Test signup endpoint -python test_login.py # Test login endpoint -python test_database.py # Database connection test +EMAIL_ENABLED=true +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=user@example.com +SMTP_PASSWORD=your-password +SMTP_FROM=noreply@cipher.app +APP_BASE_URL=https://your-domain.com +``` + +## API + +| Method | Path | Notes | +| ------ | --------------------------- | -------------------------------------------------- | +| POST | `/api/auth/signup` | `{full_name, email, password}` β†’ 201 | +| GET | `/api/auth/verify-email` | `?token=xxx` β€” activates account | +| POST | `/api/auth/login` | Sets cookies; may return `requires_2fa: true` | +| POST | `/api/auth/login/2fa` | `{user_id, code}` β€” completes 2FA login | +| POST | `/api/auth/refresh` | Rotates refresh + access cookies | +| POST | `/api/auth/logout` | Revokes refresh token, clears cookies | +| GET | `/api/auth/me` | Current user (requires auth) | +| POST | `/api/auth/forgot-password` | `{email}` β€” sends reset link if registered | +| POST | `/api/auth/reset-password` | `{token, password}` β€” sets new password | +| POST | `/api/2fa/setup` | Returns secret + otpauth URL + QR code data URL | +| POST | `/api/2fa/enable` | Confirms with a TOTP code | +| POST | `/api/2fa/disable` | Confirms with a TOTP code | +| GET | `/health` | Liveness check | + +## Tests + +```bash +pytest +``` + +The suite uses a throwaway SQLite file and covers: + +- Signup / login / refresh / logout / cookie handling +- Email verification flow (valid token, expired token, single-use enforcement) +- Password reset flow (valid token, expired token, lockout cleared, single-use enforcement) +- Anti-enumeration (forgot-password returns identical response for any email) +- TOTP setup + enforced 2FA login +- Account lockout after failed attempts + +## Production checklist + +- Set `ENVIRONMENT=production`, a real `SECRET_KEY`, and `DATABASE_URL` pointing to Postgres. +- Terminate TLS in front of the app; set `COOKIE_SECURE=true`. +- Set `EMAIL_ENABLED=true` and configure SMTP credentials. +- Set `APP_BASE_URL` to the public HTTPS URL (used in verification/reset links). +- Restrict `ALLOWED_ORIGINS` to the actual frontend origin. +- Run `alembic upgrade head` on deploy. +- The default rate-limit backend is in-memory; for multi-process deployments point slowapi at Redis. + +## Layout + +``` +backend/ + config.py settings (pydantic-settings) + database.py engine, session, declarative Base + models.py User, RefreshToken + schemas.py request/response models + security.py hashing, JWT, TOTP, QR code generation + email.py SMTP email sending (verification + reset) + deps.py current_user dependency + rate_limit.py slowapi limiter + routes/ + auth.py signup/login/refresh/logout/me/verify-email/forgot-password/reset-password + twofa.py setup/enable/disable + pages.py HTML page routes + main.py app factory +frontend/ + login.html / login.js + signup.html / signup.js + home.html / home.js + totp.html / totp.js + verify-email.html / verify-email.js + forgot-password.html / forgot-password.js + reset-password.html / reset-password.js + util.js shared fetch + error helpers +alembic/ migrations +tests/ pytest suite ``` - -## πŸ› Fixed Issues - -βœ… Database consistency (auth.db across all modules) -βœ… Proper User model with SQLAlchemy -βœ… Bcrypt password hashing (not SHA256) -βœ… JWT token authentication -βœ… Timezone-aware datetime -βœ… Path traversal protection -βœ… CORS security configuration -βœ… Frontend token storage -βœ… HTML encoding fixes -βœ… All API endpoint mismatches - -## πŸ“‚ Project Structure -fastapi-auth-system/ -FASTAPI/ -β”‚ -β”œβ”€β”€ backend/ -β”‚ β”œβ”€β”€ __init__.py -β”‚ β”œβ”€β”€ main.py -β”‚ β”œβ”€β”€ database.py -β”‚ β”œβ”€β”€ models.py -β”‚ └── security.py -β”‚ -β”œβ”€β”€ frontend/ -β”‚ β”œβ”€β”€ home.html -β”‚ β”œβ”€β”€ home.js -β”‚ β”œβ”€β”€ login.html -β”‚ β”œβ”€β”€ login.js -β”‚ β”œβ”€β”€ signup.html -β”‚ β”œβ”€β”€ signup.js -β”‚ └── fingerprint.js -β”‚ -β”œβ”€β”€ tests/ -β”‚ β”œβ”€β”€ test_login.py -β”‚ β”œβ”€β”€ test_signup.py -β”‚ └── test_database.py -β”‚ -β”œβ”€β”€ .gitignore -β”œβ”€β”€ README.md -β”œβ”€β”€ requirements.txt -└── DATABASE_STATUS.md diff --git a/add_user.py b/add_user.py deleted file mode 100644 index 2188b73..0000000 --- a/add_user.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Add test user to existing database""" -from backend.database import SessionLocal, Base, engine -from backend.models import User -from backend.security import hash_password - -# Ensure tables exist (won't recreate if they already exist) -print("πŸ“Š Checking database tables...") -Base.metadata.create_all(bind=engine) -print("βœ… Database tables verified!") - -# Create test user -print("\nπŸ‘€ Creating test user...") -db = SessionLocal() - -try: - # Check if user already exists - existing = db.query(User).filter(User.username == "testuser").first() - - if existing: - print("⚠️ Test user already exists!") - print(f" Username: {existing.username}") - print(f" Email: {existing.email}") - else: - test_user = User( - username="testuser", - email="test@example.com", - password=hash_password("password123"), - mfa_enabled=False - ) - - db.add(test_user) - db.commit() - db.refresh(test_user) - - print("βœ… Test user created successfully!") - - print("\n" + "="*50) - print("LOGIN CREDENTIALS") - print("="*50) - print(f" URL: http://127.0.0.1:8000") - print(f" Username: testuser") - print(f" Password: password123") - print("="*50) - - # Show all users - all_users = db.query(User).all() - print(f"\nπŸ“‹ Total users in database: {len(all_users)}") - for user in all_users: - print(f" - {user.username} ({user.email})") - -except Exception as e: - print(f"❌ Error: {e}") - import traceback - traceback.print_exc() - db.rollback() -finally: - db.close() - -print("\nβœ… Complete!\n") diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..a97945f --- /dev/null +++ b/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..18d31ea --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,49 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from backend.config import get_settings +from backend.database import Base +from backend import models # noqa: F401 (register models on Base) + +config = context.config +config.set_main_option("sqlalchemy.url", get_settings().database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=config.get_main_option("sqlalchemy.url"), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=connection.dialect.name == "sqlite", + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..17dcba0 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/0001_initial.py b/alembic/versions/0001_initial.py new file mode 100644 index 0000000..6dd3ac4 --- /dev/null +++ b/alembic/versions/0001_initial.py @@ -0,0 +1,52 @@ +"""initial schema + +Revision ID: 0001 +Revises: +Create Date: 2026-05-23 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("full_name", sa.String(length=120), nullable=False), + sa.Column("email", sa.String(length=254), nullable=False), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column("totp_secret", sa.String(length=64), nullable=True), + sa.Column("totp_enabled", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("failed_login_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_users_email", "users", ["email"], unique=True) + + op.create_table( + "refresh_tokens", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("token_hash", sa.String(length=255), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_refresh_tokens_user_id", "refresh_tokens", ["user_id"]) + op.create_index("ix_refresh_tokens_token_hash", "refresh_tokens", ["token_hash"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_refresh_tokens_token_hash", table_name="refresh_tokens") + op.drop_index("ix_refresh_tokens_user_id", table_name="refresh_tokens") + op.drop_table("refresh_tokens") + op.drop_index("ix_users_email", table_name="users") + op.drop_table("users") diff --git a/alembic/versions/0002_email_verification_and_password_reset.py b/alembic/versions/0002_email_verification_and_password_reset.py new file mode 100644 index 0000000..6a2e370 --- /dev/null +++ b/alembic/versions/0002_email_verification_and_password_reset.py @@ -0,0 +1,37 @@ +"""email verification and password reset + +Revision ID: 0002 +Revises: 0001 +Create Date: 2026-05-27 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0002" +down_revision: Union[str, None] = "0001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Existing users are grandfathered in as verified. + op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default=sa.true())) + op.add_column("users", sa.Column("verification_token_hash", sa.String(255), nullable=True)) + op.add_column("users", sa.Column("verification_token_expires", sa.DateTime(timezone=True), nullable=True)) + op.add_column("users", sa.Column("reset_token_hash", sa.String(255), nullable=True)) + op.add_column("users", sa.Column("reset_token_expires", sa.DateTime(timezone=True), nullable=True)) + op.create_index("ix_users_verification_token_hash", "users", ["verification_token_hash"], unique=True) + op.create_index("ix_users_reset_token_hash", "users", ["reset_token_hash"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_users_reset_token_hash", table_name="users") + op.drop_index("ix_users_verification_token_hash", table_name="users") + op.drop_column("users", "reset_token_expires") + op.drop_column("users", "reset_token_hash") + op.drop_column("users", "verification_token_expires") + op.drop_column("users", "verification_token_hash") + op.drop_column("users", "email_verified") diff --git a/alembic/versions/0003_passwordless_qr_auth.py b/alembic/versions/0003_passwordless_qr_auth.py new file mode 100644 index 0000000..eb751b4 --- /dev/null +++ b/alembic/versions/0003_passwordless_qr_auth.py @@ -0,0 +1,122 @@ +"""Passwordless QR-code authentication β€” full schema rebuild + +Revision ID: 0003 +Revises: 0002 +Create Date: 2026-05-27 + +Drops the password-based schema and replaces it with the four tables that +power the QR identity-provider architecture: + + users β€” profile only, no password hash + registered_devices β€” trusted authenticator devices (token stored as SHA-256 hash) + qr_sessions β€” lifecycle of each QR login attempt + oauth_clients β€” third-party apps that use Cipher as their IdP +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0003" +down_revision: Union[str, None] = "0002" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Drop all tables from the previous password-based schema + op.drop_table("refresh_tokens") + op.drop_table("users") + + # ── users ──────────────────────────────────────────────────────────────── + op.create_table( + "users", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("email", sa.String(254), nullable=False), + sa.Column("full_name", sa.String(120), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_users_id", "users", ["id"], unique=False) + op.create_index("ix_users_email", "users", ["email"], unique=True) + + # ── registered_devices ─────────────────────────────────────────────────── + op.create_table( + "registered_devices", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.String(120), nullable=False, server_default="My Device"), + sa.Column("token_hash", sa.String(64), nullable=False), + sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_registered_devices_user_id", "registered_devices", ["user_id"]) + op.create_index("ix_registered_devices_token_hash", "registered_devices", ["token_hash"], unique=True) + + # ── qr_sessions ────────────────────────────────────────────────────────── + op.create_table( + "qr_sessions", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("session_id", sa.String(64), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("client_id", sa.String(64), nullable=True), + sa.Column("redirect_uri", sa.String(512), nullable=True), + sa.Column("scope", sa.String(255), nullable=False, server_default="openid profile email"), + sa.Column("auth_code_hash", sa.String(64), nullable=True), + sa.Column("auth_code_expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_qr_sessions_session_id", "qr_sessions", ["session_id"], unique=True) + op.create_index("ix_qr_sessions_user_id", "qr_sessions", ["user_id"]) + op.create_index("ix_qr_sessions_auth_code_hash", "qr_sessions", ["auth_code_hash"], unique=True) + + # ── oauth_clients ──────────────────────────────────────────────────────── + op.create_table( + "oauth_clients", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("client_id", sa.String(64), nullable=False), + sa.Column("client_secret_hash", sa.String(64), nullable=False), + sa.Column("name", sa.String(120), nullable=False), + sa.Column("redirect_uris", sa.Text(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_oauth_clients_client_id", "oauth_clients", ["client_id"], unique=True) + + +def downgrade() -> None: + op.drop_table("oauth_clients") + op.drop_table("qr_sessions") + op.drop_table("registered_devices") + op.drop_table("users") + + # Restore minimal previous schema (users + refresh_tokens only) + op.create_table( + "users", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("full_name", sa.String(120), nullable=False), + sa.Column("email", sa.String(254), nullable=False), + sa.Column("password_hash", sa.String(255), nullable=False), + sa.Column("totp_secret", sa.String(64), nullable=True), + sa.Column("totp_enabled", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("email_verified", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("verification_token_hash", sa.String(255), nullable=True), + sa.Column("verification_token_expires", sa.DateTime(timezone=True), nullable=True), + sa.Column("reset_token_hash", sa.String(255), nullable=True), + sa.Column("reset_token_expires", sa.DateTime(timezone=True), nullable=True), + sa.Column("failed_login_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_table( + "refresh_tokens", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("token_hash", sa.String(255), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..9c1593d --- /dev/null +++ b/backend/config.py @@ -0,0 +1,59 @@ +from functools import lru_cache +from typing import Annotated, List + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + environment: str = Field("development", alias="ENVIRONMENT") + secret_key: str = Field(..., alias="SECRET_KEY", min_length=32) + database_url: str = Field("sqlite:///./auth.db", alias="DATABASE_URL") + + access_token_minutes: int = Field(15, alias="ACCESS_TOKEN_MINUTES") + refresh_token_days: int = Field(14, alias="REFRESH_TOKEN_DAYS") + + allowed_origins: Annotated[List[str], NoDecode] = Field( + default_factory=lambda: ["http://localhost:8000", "http://127.0.0.1:8000"], + alias="ALLOWED_ORIGINS", + ) + cookie_secure: bool = Field(False, alias="COOKIE_SECURE") + cookie_domain: str | None = Field(None, alias="COOKIE_DOMAIN") + + login_rate_limit: str = Field("5/minute", alias="LOGIN_RATE_LIMIT") + signup_rate_limit: str = Field("3/minute", alias="SIGNUP_RATE_LIMIT") + + lockout_threshold: int = Field(5, alias="LOCKOUT_THRESHOLD") + lockout_minutes: int = Field(15, alias="LOCKOUT_MINUTES") + + totp_issuer: str = Field("Cipher", alias="TOTP_ISSUER") + + email_enabled: bool = Field(False, alias="EMAIL_ENABLED") + smtp_host: str = Field("localhost", alias="SMTP_HOST") + smtp_port: int = Field(587, alias="SMTP_PORT") + smtp_user: str = Field("", alias="SMTP_USER") + smtp_password: str = Field("", alias="SMTP_PASSWORD") + smtp_from: str = Field("noreply@cipher.app", alias="SMTP_FROM") + app_base_url: str = Field("http://localhost:8000", alias="APP_BASE_URL") + + @field_validator("allowed_origins", mode="before") + @classmethod + def split_origins(cls, v): + if isinstance(v, str): + return [o.strip() for o in v.split(",") if o.strip()] + return v + + @property + def is_production(self) -> bool: + return self.environment.lower() == "production" + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/database.py b/backend/database.py index f9332bc..7e8fe1f 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1,22 +1,20 @@ from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -from datetime import datetime +from sqlalchemy.orm import declarative_base, sessionmaker -DB_NAME = "auth.db" -DATABASE_URL = f"sqlite:///./{DB_NAME}" +from .config import get_settings -engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +settings = get_settings() + +connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} +engine = create_engine(settings.database_url, connect_args=connect_args, pool_pre_ping=True) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + def get_db(): - """Dependency for FastAPI routes""" db = SessionLocal() try: yield db finally: db.close() - -def init_db(): - """Initialize database tables""" - from .models import Base - Base.metadata.create_all(bind=engine) diff --git a/backend/deps.py b/backend/deps.py new file mode 100644 index 0000000..cc051f8 --- /dev/null +++ b/backend/deps.py @@ -0,0 +1,42 @@ +from datetime import datetime, timezone + +from fastapi import Depends, Header, HTTPException, Request, status +from sqlalchemy.orm import Session + +from .database import get_db +from .models import RegisteredDevice, User +from .security import decode_access_token, hash_token + +ACCESS_COOKIE = "access_token" + + +def current_user(request: Request, db: Session = Depends(get_db)) -> User: + """JWT access token from HttpOnly cookie β†’ authenticated User.""" + token = request.cookies.get(ACCESS_COOKIE) + if not token: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not authenticated") + try: + user_id = decode_access_token(token) + except ValueError: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Session expired") + user = db.get(User, user_id) + if not user or not user.is_active: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User not found") + return user + + +def device_from_bearer( + authorization: str | None = Header(default=None), + db: Session = Depends(get_db), +) -> RegisteredDevice: + """Bearer device token from Authorization header β†’ RegisteredDevice (with user loaded).""" + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Device token required") + raw = authorization.split(" ", 1)[1] + token_hash = hash_token(raw) + device = db.query(RegisteredDevice).filter(RegisteredDevice.token_hash == token_hash).first() + if not device: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Unrecognized device token") + device.last_used_at = datetime.now(timezone.utc) + db.commit() + return device diff --git a/backend/email.py b/backend/email.py new file mode 100644 index 0000000..05788ba --- /dev/null +++ b/backend/email.py @@ -0,0 +1,59 @@ +import logging +import smtplib +import ssl +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +logger = logging.getLogger(__name__) + + +def _send(subject: str, to: str, body_text: str, body_html: str, settings) -> None: + if not settings.email_enabled: + logger.info("EMAIL_ENABLED=false β€” skipping send to %s | %s", to, subject) + return + + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = settings.smtp_from + msg["To"] = to + msg.attach(MIMEText(body_text, "plain")) + msg.attach(MIMEText(body_html, "html")) + + context = ssl.create_default_context() + with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as server: + server.ehlo() + server.starttls(context=context) + if settings.smtp_user: + server.login(settings.smtp_user, settings.smtp_password) + server.sendmail(settings.smtp_from, to, msg.as_string()) + logger.info("Email sent to %s: %s", to, subject) + + +def send_verification_email(to_email: str, token: str, settings) -> None: + url = f"{settings.app_base_url}/verify-email?token={token}" + _send( + subject="Verify your Cipher account", + to=to_email, + body_text=f"Verify your email address:\n\n{url}\n\nExpires in 24 hours.", + body_html=f""" +

Welcome to Cipher. Click the link below to verify your email address.

+

{url}

+

This link expires in 24 hours. If you didn't sign up, ignore this email.

+""", + settings=settings, + ) + + +def send_reset_email(to_email: str, token: str, settings) -> None: + url = f"{settings.app_base_url}/reset-password?token={token}" + _send( + subject="Reset your Cipher password", + to=to_email, + body_text=f"Reset your password:\n\n{url}\n\nExpires in 1 hour. If you didn't request this, ignore this email.", + body_html=f""" +

Click the link below to reset your Cipher password.

+

{url}

+

This link expires in 1 hour. If you didn't request a password reset, ignore this email.

+""", + settings=settings, + ) diff --git a/backend/main.py b/backend/main.py index ba99a15..8993a38 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,279 +1,72 @@ -from fastapi import FastAPI, HTTPException, Depends -from fastapi.responses import FileResponse, RedirectResponse -from fastapi.staticfiles import StaticFiles -from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel, EmailStr -from sqlalchemy.orm import Session -import uuid -import qrcode -import os -import re -import sys import logging -from datetime import datetime, timedelta, timezone -from .database import get_db, init_db, SessionLocal -from .models import User, Session as DBSession -from .security import hash_password, verify_password, create_token, verify_token - -# Configure logging -logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) - -app = FastAPI(title="Antigravity Secure Access") - -# -------------------- STATIC FILES -------------------- -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -FRONTEND_DIR = os.path.join(BASE_DIR, "frontend") - -app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static") - -# -------------------- CORS -------------------- -# Restrict CORS in production - use specific origins -ALLOWED_ORIGINS = [origin.strip() for origin in os.getenv("ALLOWED_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000").split(",") if origin.strip()] -app.add_middleware( - CORSMiddleware, - allow_origins=ALLOWED_ORIGINS, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# -------------------- INITIALIZE DATABASE -------------------- -try: - init_db() -except Exception as e: - logger.error(f"Database initialization failed: {e}", exc_info=True) - sys.exit(1) - -# -------------------- MODELS -------------------- -class SignupRequest(BaseModel): - full_name: str - email: EmailStr - access_key: str - verify_key: str - -class LoginRequest(BaseModel): - email: EmailStr - access_key: str - -class LoginResponse(BaseModel): - message: str - access_token: str - next: str - -class FingerprintRequest(BaseModel): - email: EmailStr - -# -------------------- HELPERS -------------------- -def is_expired(created_at: datetime, timeout_minutes: int = 5) -> bool: - """Check if session has expired""" - try: - now = datetime.now(timezone.utc) - # Make created_at timezone-aware if it isn't - if created_at.tzinfo is None: - created_at = created_at.replace(tzinfo=timezone.utc) - return now - created_at > timedelta(minutes=timeout_minutes) - except Exception as e: - print(f"Error checking expiration: {e}") - return True - -def validate_uuid(session_id: str) -> bool: - """Validate that session_id is a valid UUID to prevent path traversal""" - try: - # Also check for path traversal patterns - if ".." in session_id or "/" in session_id or "\\" in session_id: - return False - uuid.UUID(session_id) - return True - except ValueError: - return False - -def cleanup_old_qrs(): - """Delete QR code files older than 10 minutes""" - qr_dir = "qrs" - if os.path.exists(qr_dir): - cutoff = datetime.now(timezone.utc) - timedelta(minutes=10) - for file in os.listdir(qr_dir): - file_path = os.path.join(qr_dir, file) - try: - if os.path.isfile(file_path): - file_time = datetime.fromtimestamp(os.path.getmtime(file_path), tz=timezone.utc) - if file_time < cutoff: - os.remove(file_path) - except Exception as e: - print(f"Error cleaning up QR file {file}: {e}") - -# -------------------- ROUTES -------------------- - -@app.get("/") -def root(): - return RedirectResponse(url="/login") +import os -@app.get("/login") -def login_page(): - return FileResponse(os.path.join(FRONTEND_DIR, "login.html")) +from fastapi import Depends, FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from slowapi.errors import RateLimitExceeded +from slowapi.middleware import SlowAPIMiddleware +from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse + +from .config import get_settings +from .deps import current_user +from .models import User +from .rate_limit import limiter +from .routes import auth, devices, oauth, pages, qr +from .schemas import UserResponse -@app.get("/signup-page") -def signup_page(): - return FileResponse(os.path.join(FRONTEND_DIR, "signup.html")) +logger = logging.getLogger(__name__) -@app.get("/home") -def home_page(): - return FileResponse(os.path.join(FRONTEND_DIR, "home.html")) -@app.get("/health") -def health(): - return {"status": "ok"} +def create_app() -> FastAPI: + settings = get_settings() + app = FastAPI( + title="Cipher β€” Passwordless QR Auth", + version="2.0.0", + description="Secure, reusable identity provider using dynamic QR-code authentication.", + ) + app.state.limiter = limiter + app.add_middleware(SlowAPIMiddleware) + app.add_exception_handler(RateLimitExceeded, _rate_limit_handler) -# ---------- SIGNUP ---------- -@app.post("/signup") -def signup(data: SignupRequest, db: Session = Depends(get_db)): - if data.access_key != data.verify_key: - raise HTTPException(status_code=400, detail="Access keys do not match") - - # Check if user already exists - existing_user = db.query(User).filter(User.email == data.email).first() - if existing_user: - raise HTTPException(status_code=400, detail="Email already registered") - - # Create new user with hashed password - new_user = User( - full_name=data.full_name, - email=data.email, - password_hash=hash_password(data.access_key) + app.add_middleware( + CORSMiddleware, + allow_origins=settings.allowed_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], ) - - try: - db.add(new_user) - db.commit() - db.refresh(new_user) - except Exception as e: - db.rollback() - logger.exception("Failed to create user") - raise HTTPException(status_code=500, detail="Failed to create user") - return { - "message": "Access initialized", - "next": "login" - } + if settings.is_production: + app.add_middleware(HTTPSRedirectMiddleware) -# ---------- LOGIN ---------- -@app.post("/login", response_model=LoginResponse) -def login(data: LoginRequest, db: Session = Depends(get_db)): - # Find user by email - user = db.query(User).filter(User.email == data.email).first() - - if not user or not verify_password(data.access_key, user.password_hash): - raise HTTPException(status_code=401, detail="Invalid credentials") - - # Create access token - access_token = create_token(user.id) - - return LoginResponse( - message="Credentials verified", - access_token=access_token, - next="fingerprint" + frontend_dir = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend" ) + app.mount("/static", StaticFiles(directory=frontend_dir), name="static") -# ---------- FINGERPRINT (SIMULATED) ---------- -@app.post("/fingerprint") -def fingerprint(data: FingerprintRequest, token: dict = Depends(verify_token), db: Session = Depends(get_db)): - # Get and validate user_id from token - user_id_str = token.get("user_id") - if not user_id_str: - raise HTTPException(status_code=401, detail="Invalid or missing user_id in token") - - try: - user_id = int(user_id_str) - except (TypeError, ValueError): - raise HTTPException(status_code=401, detail="Invalid user_id format in token") - - user = db.query(User).filter(User.id == user_id).first() - - if not user: - raise HTTPException(status_code=404, detail="User not found") - - # Verify token's user matches requested email (optional extra security check) - if user.email != data.email: - raise HTTPException(status_code=403, detail="Token does not match requested email") - - session_id = str(uuid.uuid4()) - - # Delete any existing sessions for this user to prevent duplicates - db.query(DBSession).filter(DBSession.user_id == user_id).delete() - - # Insert new session - new_session = DBSession( - session_id=session_id, - user_id=user_id, - created_at=datetime.now(timezone.utc) - ) - - try: - db.add(new_session) - db.commit() - except Exception as e: - db.rollback() - logger.exception("Failed to create session") - raise HTTPException(status_code=500, detail="Failed to create session") - - # Cleanup old QR files - cleanup_old_qrs() + app.include_router(pages.router) + app.include_router(auth.router) + app.include_router(qr.router) + app.include_router(devices.router) + app.include_router(oauth.router) + + @app.get("/api/auth/me", response_model=UserResponse, tags=["auth"]) + def me(user: User = Depends(current_user)): + return UserResponse.model_validate(user) - return { - "message": "Biometric verified", - "session_id": session_id, - "next": "qr" - } + @app.get("/health", tags=["meta"]) + def health(): + return {"status": "ok"} -# ---------- QR CODE ---------- -@app.get("/qr/{session_id}") -def qr(session_id: str, db: Session = Depends(get_db)): - # Validate UUID format to prevent path traversal - if not validate_uuid(session_id): - raise HTTPException(status_code=400, detail="Invalid session ID format") - - # Validate session exists and hasn't expired - session = db.query(DBSession).filter(DBSession.session_id == session_id).first() - - if not session: - raise HTTPException(status_code=404, detail="Session not found") - - if is_expired(session.created_at): - raise HTTPException(status_code=403, detail="Session expired") - - qr_dir = "qrs" - os.makedirs(qr_dir, exist_ok=True) + return app - payload = f"antigravity://connect/{session_id}" - path = os.path.join(qr_dir, f"{session_id}.png") - img = qrcode.make(payload) - img.save(path) +def _rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse: + return JSONResponse(status_code=429, content={"detail": "Too many requests"}) - return { - "qr_image": path, - "payload": payload - } -# ---------- SERVE QR IMAGE ---------- -@app.get("/qr-image/{session_id}") -def get_qr_image(session_id: str, db: Session = Depends(get_db)): - # Validate UUID format to prevent path traversal - if not validate_uuid(session_id): - raise HTTPException(status_code=400, detail="Invalid session ID format") - - # Validate session exists and hasn't expired - session = db.query(DBSession).filter(DBSession.session_id == session_id).first() - - if not session: - raise HTTPException(status_code=404, detail="Session not found") - - if is_expired(session.created_at): - raise HTTPException(status_code=403, detail="Session expired") - - path = os.path.join("qrs", f"{session_id}.png") - if not os.path.exists(path): - raise HTTPException(status_code=404, detail="QR not found") - return FileResponse(path, media_type="image/png") +app = create_app() diff --git a/backend/models.py b/backend/models.py index 1afebda..31833fb 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,29 +1,112 @@ -from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey -from sqlalchemy.ext.declarative import declarative_base +from datetime import datetime, timezone + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, TypeDecorator from sqlalchemy.orm import relationship -from datetime import datetime, timedelta -Base = declarative_base() +from .database import Base + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class UTCDateTime(TypeDecorator): + """Tz-aware UTC datetimes β€” works on both SQLite and Postgres.""" + + impl = DateTime + cache_ok = True + + def process_bind_param(self, value, dialect): + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + def process_result_value(self, value, dialect): + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + class User(Base): __tablename__ = "users" - - id = Column(Integer, primary_key=True, index=True, autoincrement=True) - full_name = Column(String, nullable=False) - email = Column(String, unique=True, nullable=False, index=True) - password_hash = Column(String, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow) - - # Relationship to sessions - sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan") - -class Session(Base): - __tablename__ = "sessions" - - session_id = Column(String, primary_key=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) - created_at = Column(DateTime, default=datetime.utcnow) - expires_at = Column(DateTime, default=lambda: datetime.utcnow() + timedelta(minutes=30)) - - # Relationship to user - user = relationship("User", back_populates="sessions") + + id = Column(Integer, primary_key=True, index=True) + email = Column(String(254), unique=True, index=True, nullable=False) + full_name = Column(String(120), nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(UTCDateTime(), default=utcnow, nullable=False) + + devices = relationship("RegisteredDevice", back_populates="user", cascade="all, delete-orphan") + qr_sessions = relationship("QRSession", back_populates="user") + + +class RegisteredDevice(Base): + """A trusted authenticator device owned by a user. + + The raw device token is issued once at enrollment and never stored β€” + only its SHA-256 hash lives here. + """ + + __tablename__ = "registered_devices" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + name = Column(String(120), nullable=False, default="My Device") + token_hash = Column(String(64), unique=True, index=True, nullable=False) + last_used_at = Column(UTCDateTime(), nullable=True) + created_at = Column(UTCDateTime(), default=utcnow, nullable=False) + + user = relationship("User", back_populates="devices") + + +class QRSession(Base): + """Lifecycle of a single QR login attempt. + + Status flow: + pending β†’ scanned β†’ approved β†’ consumed + β†˜ expired (any stage if past expires_at) + """ + + __tablename__ = "qr_sessions" + + id = Column(Integer, primary_key=True) + session_id = Column(String(64), unique=True, index=True, nullable=False) + + # pending | scanned | approved | consumed | expired + status = Column(String(20), default="pending", nullable=False) + + # Populated after the device scans & approves + user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + + # ── OAuth / cross-site context ────────────────────────────────────────── + client_id = Column(String(64), nullable=True) + redirect_uri = Column(String(512), nullable=True) + scope = Column(String(255), nullable=False, default="openid profile email") + + # One-time authorization code (post-approval, OAuth code exchange) + auth_code_hash = Column(String(64), nullable=True, unique=True, index=True) + auth_code_expires_at = Column(UTCDateTime(), nullable=True) + + expires_at = Column(UTCDateTime(), nullable=False) + created_at = Column(UTCDateTime(), default=utcnow, nullable=False) + + user = relationship("User", back_populates="qr_sessions") + + +class OAuthClient(Base): + """A registered third-party application that uses Cipher as its IdP.""" + + __tablename__ = "oauth_clients" + + id = Column(Integer, primary_key=True) + client_id = Column(String(64), unique=True, index=True, nullable=False) + client_secret_hash = Column(String(64), nullable=False) + name = Column(String(120), nullable=False) + # Comma-separated list of allowed redirect URIs + redirect_uris = Column(Text, nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(UTCDateTime(), default=utcnow, nullable=False) diff --git a/backend/rate_limit.py b/backend/rate_limit.py new file mode 100644 index 0000000..a10c0dc --- /dev/null +++ b/backend/rate_limit.py @@ -0,0 +1,4 @@ +from slowapi import Limiter +from slowapi.util import get_remote_address + +limiter = Limiter(key_func=get_remote_address, default_limits=[]) diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/routes/auth.py b/backend/routes/auth.py new file mode 100644 index 0000000..b2af9cf --- /dev/null +++ b/backend/routes/auth.py @@ -0,0 +1,63 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from ..database import get_db +from ..models import RegisteredDevice, User +from pydantic import BaseModel, EmailStr + +from ..schemas import MessageResponse, RegisterRequest, RegisterResponse +from ..security import new_opaque_token + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED) +def register(data: RegisterRequest, db: Session = Depends(get_db)): + """Enroll a new user and issue a device token for the registering browser.""" + if db.query(User).filter(User.email == data.email.lower()).first(): + raise HTTPException(status.HTTP_409_CONFLICT, "Email already registered") + + user = User( + email=data.email.lower(), + full_name=data.full_name.strip(), + ) + db.add(user) + db.flush() # get user.id before committing + + raw_token, token_hash = new_opaque_token() + device = RegisteredDevice( + user_id=user.id, + name=data.device_name.strip() or "My Device", + token_hash=token_hash, + ) + db.add(device) + db.commit() + + logger.info("New user registered: %s (device: %s)", user.email, device.name) + return RegisterResponse( + user_id=user.id, + device_token=raw_token, + message="Account created. Save the device_token β€” it is your authenticator.", + ) + + +class LookupRequest(BaseModel): + email: EmailStr + + +@router.post("/lookup", status_code=status.HTTP_200_OK) +def lookup(data: LookupRequest, db: Session = Depends(get_db)): + """Check whether an email is registered. Used by the sign-in form to give the user early feedback.""" + user = db.query(User).filter(User.email == data.email.lower()).first() + if not user: + raise HTTPException(status.HTTP_404_NOT_FOUND, "No account found") + return {"found": True, "full_name": user.full_name} + + +@router.get("/me", tags=["users"]) +def me(db: Session = Depends(get_db)): + """Placeholder β€” real /me is behind the access-token cookie (see deps.current_user).""" + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not authenticated") diff --git a/backend/routes/devices.py b/backend/routes/devices.py new file mode 100644 index 0000000..9171a03 --- /dev/null +++ b/backend/routes/devices.py @@ -0,0 +1,62 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import current_user, device_from_bearer +from ..models import RegisteredDevice, User +from ..schemas import DeviceResponse, MessageResponse, RegisterRequest, UserResponse +from ..security import new_opaque_token + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/devices", tags=["devices"]) + + +@router.get("", response_model=list[DeviceResponse]) +def list_devices(user: User = Depends(current_user), db: Session = Depends(get_db)): + """List all trusted devices registered to the authenticated user.""" + return user.devices + + +@router.post("/enroll", response_model=dict) +def enroll_additional_device( + device_name: str = "New Device", + device: RegisteredDevice = Depends(device_from_bearer), + db: Session = Depends(get_db), +): + """Add a second trusted device. Requires an existing device token to authorize.""" + raw_token, token_hash = new_opaque_token() + new_device = RegisteredDevice( + user_id=device.user_id, + name=device_name.strip() or "New Device", + token_hash=token_hash, + ) + db.add(new_device) + db.commit() + logger.info("New device enrolled for user %s: %s", device.user_id, device_name) + return {"device_token": raw_token, "device_id": new_device.id, "name": new_device.name} + + +@router.delete("/{device_id}", response_model=MessageResponse) +def revoke_device( + device_id: int, + user: User = Depends(current_user), + db: Session = Depends(get_db), +): + """Revoke a trusted device. Requires JWT auth (you must be logged in).""" + device = db.query(RegisteredDevice).filter( + RegisteredDevice.id == device_id, + RegisteredDevice.user_id == user.id, + ).first() + if not device: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Device not found") + db.delete(device) + db.commit() + return MessageResponse(message=f"Device '{device.name}' revoked") + + +@router.get("/me", response_model=UserResponse) +def whoami(device: RegisteredDevice = Depends(device_from_bearer), db: Session = Depends(get_db)): + """Return the user associated with the presented device token.""" + return UserResponse.model_validate(device.user) diff --git a/backend/routes/oauth.py b/backend/routes/oauth.py new file mode 100644 index 0000000..3c6aed7 --- /dev/null +++ b/backend/routes/oauth.py @@ -0,0 +1,137 @@ +"""Minimal OAuth 2.0 / OpenID Connect layer. + +Allows third-party websites to use Cipher as their identity provider. + +Authorization Code Flow: + 1. Client redirects user to GET /oauth/authorize?client_id=...&redirect_uri=...&scope=... + 2. Cipher shows QR login page β€” user scans with trusted device. + 3. After approval, Cipher redirects to redirect_uri?code=xxx + 4. Client exchanges code for access token via POST /oauth/token. + 5. Client fetches user profile from GET /oauth/userinfo. +""" + +import logging +from datetime import datetime, timezone +from urllib.parse import urlencode + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session + +from ..config import get_settings +from ..database import get_db +from ..deps import current_user +from ..models import OAuthClient, QRSession, User +from ..schemas import MessageResponse, OAuthTokenResponse, UserResponse +from ..security import create_access_token, hash_token + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/oauth", tags=["oauth"]) +settings = get_settings() + +OIDC_SCOPES = {"openid", "profile", "email"} + + +# ── OIDC Discovery ─────────────────────────────────────────────────────────── + +@router.get("/.well-known/openid-configuration", include_in_schema=False) +def oidc_discovery(): + base = settings.app_base_url + return { + "issuer": base, + "authorization_endpoint": f"{base}/oauth/authorize", + "token_endpoint": f"{base}/oauth/token", + "userinfo_endpoint": f"{base}/oauth/userinfo", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code"], + "scopes_supported": list(OIDC_SCOPES), + "token_endpoint_auth_methods_supported": ["client_secret_post"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["HS256"], + } + + +# ── Authorization endpoint ─────────────────────────────────────────────────── + +@router.get("/authorize") +def authorize( + client_id: str = Query(...), + redirect_uri: str = Query(...), + scope: str = Query("openid profile email"), + state: str | None = Query(None), + db: Session = Depends(get_db), +): + """Redirect the user to the QR login page, scoped to this OAuth client.""" + client = db.query(OAuthClient).filter( + OAuthClient.client_id == client_id, + OAuthClient.is_active == True, + ).first() + if not client: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unknown client_id") + + allowed = [u.strip() for u in client.redirect_uris.split(",")] + if redirect_uri not in allowed: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "redirect_uri not allowed") + + params = urlencode({ + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": scope, + **({"state": state} if state else {}), + }) + return RedirectResponse(f"/login?{params}", status_code=302) + + +# ── Token endpoint ─────────────────────────────────────────────────────────── + +@router.post("/token", response_model=OAuthTokenResponse) +def token( + grant_type: str = Query(...), + code: str = Query(...), + client_id: str = Query(...), + client_secret: str = Query(...), + redirect_uri: str = Query(...), + db: Session = Depends(get_db), +): + """Exchange a one-time authorization code for an access token.""" + if grant_type != "authorization_code": + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unsupported grant_type") + + client = db.query(OAuthClient).filter( + OAuthClient.client_id == client_id, + OAuthClient.is_active == True, + ).first() + if not client or client.client_secret_hash != hash_token(client_secret): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid client credentials") + + code_hash = hash_token(code) + session = db.query(QRSession).filter( + QRSession.auth_code_hash == code_hash, + QRSession.status == "approved", + ).first() + if not session: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid or already-used code") + if session.auth_code_expires_at and session.auth_code_expires_at < datetime.now(timezone.utc): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Authorization code expired") + if session.client_id != client_id: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "code was not issued for this client") + if session.redirect_uri != redirect_uri: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "redirect_uri mismatch") + + session.status = "consumed" + db.commit() + + access = create_access_token(session.user_id) + return OAuthTokenResponse( + access_token=access, + expires_in=settings.access_token_minutes * 60, + scope=session.scope, + ) + + +# ── Userinfo endpoint ──────────────────────────────────────────────────────── + +@router.get("/userinfo", response_model=UserResponse) +def userinfo(user: User = Depends(current_user)): + """Standard OIDC userinfo endpoint β€” returns claims for the token holder.""" + return UserResponse.model_validate(user) diff --git a/backend/routes/pages.py b/backend/routes/pages.py new file mode 100644 index 0000000..68a5574 --- /dev/null +++ b/backend/routes/pages.py @@ -0,0 +1,38 @@ +import os + +from fastapi import APIRouter +from fastapi.responses import FileResponse, RedirectResponse + +router = APIRouter(tags=["pages"]) + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +FRONTEND_DIR = os.path.join(BASE_DIR, "frontend") + + +def _page(name: str) -> FileResponse: + return FileResponse(os.path.join(FRONTEND_DIR, name)) + + +@router.get("/", include_in_schema=False) +def root(): + return RedirectResponse("/login") + + +@router.get("/login", include_in_schema=False) +def login_page(): + return _page("login.html") + + +@router.get("/register", include_in_schema=False) +def register_page(): + return _page("register.html") + + +@router.get("/scan", include_in_schema=False) +def scan_page(): + return _page("scan.html") + + +@router.get("/home", include_in_schema=False) +def home_page(): + return _page("home.html") diff --git a/backend/routes/qr.py b/backend/routes/qr.py new file mode 100644 index 0000000..923047b --- /dev/null +++ b/backend/routes/qr.py @@ -0,0 +1,233 @@ +import logging +import secrets +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy.orm import Session + +from ..config import get_settings +from ..database import get_db +from ..deps import ACCESS_COOKIE, device_from_bearer +from ..models import OAuthClient, QRSession, RegisteredDevice +from ..schemas import ( + CreateSessionRequest, + FreshQRResponse, + MessageResponse, + QRSessionResponse, + ScanRequest, + SessionStatusResponse, + TokenResponse, + UserResponse, +) +from ..security import ( + build_scan_url, + create_access_token, + generate_qr_data_url, + hash_token, + new_opaque_token, + verify_qr_challenge, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/qr", tags=["qr-auth"]) +settings = get_settings() + +QR_TTL = 30 # seconds before the QR image should be refreshed +SESSION_TTL = 300 # seconds the whole session stays alive + + +def _get_session(session_id: str, db: Session) -> QRSession: + s = db.query(QRSession).filter(QRSession.session_id == session_id).first() + if not s: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Session not found") + return s + + +def _expire_if_needed(session: QRSession, db: Session) -> None: + if session.status == "pending" and session.expires_at < datetime.now(timezone.utc): + session.status = "expired" + db.commit() + + +# ── Create a new QR login session ─────────────────────────────────────────── + +@router.post("/sessions", response_model=QRSessionResponse) +def create_session(data: CreateSessionRequest, db: Session = Depends(get_db)): + """Generate a new QR session. Call this when the login page loads.""" + if data.client_id: + client = db.query(OAuthClient).filter( + OAuthClient.client_id == data.client_id, + OAuthClient.is_active == True, + ).first() + if not client: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unknown OAuth client") + allowed = [u.strip() for u in client.redirect_uris.split(",")] + if data.redirect_uri and data.redirect_uri not in allowed: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "redirect_uri not allowed") + + session_id = secrets.token_urlsafe(32) + session = QRSession( + session_id=session_id, + client_id=data.client_id, + redirect_uri=data.redirect_uri, + scope=data.scope, + expires_at=datetime.now(timezone.utc) + timedelta(seconds=SESSION_TTL), + ) + db.add(session) + db.commit() + + scan_url = build_scan_url(session_id) + return QRSessionResponse( + session_id=session_id, + qr_data_url=generate_qr_data_url(scan_url), + qr_ttl=QR_TTL, + session_ttl=SESSION_TTL, + ) + + +# ── Refresh the QR image (rotation every QR_TTL seconds) ──────────────────── + +@router.get("/sessions/{session_id}/qr", response_model=FreshQRResponse) +def refresh_qr(session_id: str, db: Session = Depends(get_db)): + """Return a fresh QR code with a new signed challenge. Call every ~25 s.""" + session = _get_session(session_id, db) + _expire_if_needed(session, db) + if session.status not in ("pending",): + raise HTTPException(status.HTTP_409_CONFLICT, f"Session is {session.status}") + scan_url = build_scan_url(session_id) + return FreshQRResponse(qr_data_url=generate_qr_data_url(scan_url), ttl=QR_TTL) + + +# ── Poll session status (browser long-polls this) ─────────────────────────── + +@router.get("/sessions/{session_id}/status", response_model=SessionStatusResponse) +def get_status(session_id: str, db: Session = Depends(get_db)): + session = _get_session(session_id, db) + _expire_if_needed(session, db) + + user_data = None + redirect_url = None + + if session.status == "approved" and session.user: + user_data = UserResponse.model_validate(session.user) + if session.redirect_uri: + redirect_url = f"{session.redirect_uri}?code={session.auth_code_hash[:32]}&scope={session.scope}" + + return SessionStatusResponse( + status=session.status, + user=user_data, + redirect_url=redirect_url, + ) + + +# ── Scan: mobile device reads the QR and identifies the user ───────────────── + +@router.post("/sessions/{session_id}/scan", response_model=MessageResponse) +def scan( + session_id: str, + data: ScanRequest, + device: RegisteredDevice = Depends(device_from_bearer), + db: Session = Depends(get_db), +): + """Called by the mobile device immediately after scanning the QR code. + + Validates the cryptographic challenge embedded in the QR, then marks + the session as 'scanned' so the login page can show a confirmation prompt. + """ + if not verify_qr_challenge(session_id, data.timestamp, data.sig): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "QR challenge invalid or expired") + + session = _get_session(session_id, db) + _expire_if_needed(session, db) + if session.status != "pending": + raise HTTPException(status.HTTP_409_CONFLICT, f"Session is already {session.status}") + + session.status = "scanned" + session.user_id = device.user_id + db.commit() + logger.info("QR session %s scanned by user %s", session_id[:8], device.user_id) + return MessageResponse(message="Scanned β€” waiting for your approval") + + +# ── Approve: mobile device confirms the login ──────────────────────────────── + +@router.post("/sessions/{session_id}/approve", response_model=MessageResponse) +def approve( + session_id: str, + device: RegisteredDevice = Depends(device_from_bearer), + db: Session = Depends(get_db), +): + """User taps 'Approve' on the mobile scan page. + + Issues a one-time auth code for OAuth flows, then marks the session + 'approved' so the browser poll receives the signal. + """ + session = _get_session(session_id, db) + _expire_if_needed(session, db) + + if session.status != "scanned": + raise HTTPException(status.HTTP_409_CONFLICT, f"Session is {session.status}, expected scanned") + if session.user_id != device.user_id: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Device does not match session user") + + # Issue a one-time code for OAuth clients + raw_code, code_hash = new_opaque_token() + session.auth_code_hash = code_hash + session.auth_code_expires_at = datetime.now(timezone.utc) + timedelta(minutes=5) + session.status = "approved" + db.commit() + + logger.info("QR session %s approved by user %s", session_id[:8], device.user_id) + return MessageResponse(message="Login approved") + + +# ── Deny: mobile device rejects the login attempt ─────────────────────────── + +@router.post("/sessions/{session_id}/deny", response_model=MessageResponse) +def deny( + session_id: str, + device: RegisteredDevice = Depends(device_from_bearer), + db: Session = Depends(get_db), +): + session = _get_session(session_id, db) + if session.status not in ("pending", "scanned"): + raise HTTPException(status.HTTP_409_CONFLICT, f"Session is {session.status}") + if session.user_id and session.user_id != device.user_id: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Device does not match session user") + session.status = "expired" + db.commit() + return MessageResponse(message="Login denied") + + +# ── Exchange: browser swaps approved session for a JWT access token ────────── + +@router.post("/sessions/{session_id}/token", response_model=TokenResponse) +def exchange_token(session_id: str, response: Response, db: Session = Depends(get_db)): + """Browser calls this once the session status is 'approved'. + + Issues a JWT access token in an HttpOnly cookie and marks the session + 'consumed' so it cannot be reused. + """ + session = _get_session(session_id, db) + if session.status != "approved": + raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Session is {session.status}, expected approved") + if not session.user_id: + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Session has no user") + + session.status = "consumed" + db.commit() + + access = create_access_token(session.user_id) + response.set_cookie( + ACCESS_COOKIE, + access, + max_age=settings.access_token_minutes * 60, + httponly=True, + secure=settings.cookie_secure, + samesite="lax", + path="/", + ) + return TokenResponse( + access_token=access, + expires_in=settings.access_token_minutes * 60, + ) diff --git a/backend/routes/twofa.py b/backend/routes/twofa.py new file mode 100644 index 0000000..6d8c87b --- /dev/null +++ b/backend/routes/twofa.py @@ -0,0 +1,61 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import current_user +from ..models import User +from ..schemas import MessageResponse, TOTPSetupResponse, TOTPVerifyRequest +from ..security import generate_qr_data_url, generate_totp_secret, totp_provisioning_uri, verify_totp + +router = APIRouter(prefix="/api/2fa", tags=["2fa"]) + + +@router.post("/setup", response_model=TOTPSetupResponse) +def setup_totp(user: User = Depends(current_user), db: Session = Depends(get_db)): + if user.totp_enabled: + raise HTTPException(status.HTTP_409_CONFLICT, "2FA already enabled") + + secret = generate_totp_secret() + user.totp_secret = secret + db.commit() + otpauth_url = totp_provisioning_uri(secret, user.email) + return TOTPSetupResponse( + secret=secret, + otpauth_url=otpauth_url, + qr_code_data_url=generate_qr_data_url(otpauth_url), + ) + + +@router.post("/enable", response_model=MessageResponse) +def enable_totp( + data: TOTPVerifyRequest, + user: User = Depends(current_user), + db: Session = Depends(get_db), +): + if user.totp_enabled: + raise HTTPException(status.HTTP_409_CONFLICT, "2FA already enabled") + if not user.totp_secret: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Run /setup first") + if not verify_totp(user.totp_secret, data.code): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid code") + + user.totp_enabled = True + db.commit() + return MessageResponse(message="Two-factor authentication enabled") + + +@router.post("/disable", response_model=MessageResponse) +def disable_totp( + data: TOTPVerifyRequest, + user: User = Depends(current_user), + db: Session = Depends(get_db), +): + if not user.totp_enabled or not user.totp_secret: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "2FA not enabled") + if not verify_totp(user.totp_secret, data.code): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid code") + + user.totp_enabled = False + user.totp_secret = None + db.commit() + return MessageResponse(message="Two-factor authentication disabled") diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..141ac46 --- /dev/null +++ b/backend/schemas.py @@ -0,0 +1,97 @@ +from datetime import datetime + +from pydantic import BaseModel, EmailStr, Field + + +# ── User ───────────────────────────────────────────────────────────────────── + +class RegisterRequest(BaseModel): + email: EmailStr + full_name: str = Field(..., min_length=2, max_length=120) + device_name: str = Field("My Device", max_length=120) + + +class RegisterResponse(BaseModel): + user_id: int + device_token: str + message: str + + +class UserResponse(BaseModel): + id: int + email: str + full_name: str + created_at: datetime + + model_config = {"from_attributes": True} + + +# ── Devices ─────────────────────────────────────────────────────────────────── + +class DeviceResponse(BaseModel): + id: int + name: str + last_used_at: datetime | None + created_at: datetime + + model_config = {"from_attributes": True} + + +# ── QR Sessions ─────────────────────────────────────────────────────────────── + +class CreateSessionRequest(BaseModel): + client_id: str | None = None + redirect_uri: str | None = None + scope: str = "openid profile email" + + +class QRSessionResponse(BaseModel): + session_id: str + qr_data_url: str + qr_ttl: int # seconds until QR image should be refreshed + session_ttl: int # total seconds the session stays alive + + +class FreshQRResponse(BaseModel): + qr_data_url: str + ttl: int + + +class SessionStatusResponse(BaseModel): + status: str # pending | scanned | approved | consumed | expired + user: UserResponse | None = None + redirect_url: str | None = None + + +class ScanRequest(BaseModel): + timestamp: int + sig: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "Bearer" + expires_in: int + + +# ── OAuth ───────────────────────────────────────────────────────────────────── + +class OAuthTokenRequest(BaseModel): + grant_type: str + code: str + client_id: str + client_secret: str + redirect_uri: str + + +class OAuthTokenResponse(BaseModel): + access_token: str + token_type: str = "Bearer" + expires_in: int + scope: str + + +# ── Generic ─────────────────────────────────────────────────────────────────── + +class MessageResponse(BaseModel): + message: str diff --git a/backend/security.py b/backend/security.py index 92596d5..9290b85 100644 --- a/backend/security.py +++ b/backend/security.py @@ -1,74 +1,96 @@ -import bcrypt -import pyotp -from jose import jwt, JWTError -from datetime import datetime, timedelta -from fastapi import HTTPException, Header -import os - -# Fail fast if SECRET_KEY is not set (allow fallback only in development) -if "SECRET_KEY" not in os.environ: - if os.getenv("ENVIRONMENT") != "development": - raise RuntimeError("SECRET_KEY environment variable must be set for production") - SECRET_KEY = "your-secret-key-change-in-production-use-env-variable" -else: - SECRET_KEY = os.environ["SECRET_KEY"] - -ALGO = "HS256" - -def hash_password(password: str) -> str: - """Hash a password using bcrypt""" - if not password: - raise ValueError("Password cannot be empty") - # Ensure password is a string and encode to bytes - password_bytes = str(password).encode('utf-8') - # Hash the password with bcrypt - salt = bcrypt.gensalt(rounds=12) - hashed = bcrypt.hashpw(password_bytes, salt) - return hashed.decode('utf-8') - -def verify_password(password: str, hashed_password: str) -> bool: - """Verify a password against a hashed password""" - if not password or not hashed_password: - return False - try: - password_bytes = str(password).encode('utf-8') - hashed_bytes = hashed_password.encode('utf-8') - return bcrypt.checkpw(password_bytes, hashed_bytes) - except Exception: - return False +import base64 +import hashlib +import hmac +import io +import secrets +import time +from datetime import datetime, timedelta, timezone +from typing import Literal + +import qrcode +import qrcode.image.pure +from jose import JWTError, jwt + +from .config import get_settings + +settings = get_settings() +ALGORITHM = "HS256" + +TokenType = Literal["access"] -def create_token(user_id): + +# ── JWT access tokens ──────────────────────────────────────────────────────── + +def create_access_token(user_id: int) -> str: + now = datetime.now(timezone.utc) payload = { "sub": str(user_id), - "exp": datetime.utcnow() + timedelta(minutes=30) + "type": "access", + "iat": int(now.timestamp()), + "exp": int((now + timedelta(minutes=settings.access_token_minutes)).timestamp()), } - return jwt.encode(payload, SECRET_KEY, algorithm=ALGO) + return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM) -def verify_otp(secret, otp): - return pyotp.TOTP(secret).verify(otp) -def verify_token(authorization: str = Header(None)): - """Verify JWT token from Authorization header""" - if not authorization: - raise HTTPException(status_code=401, detail="Authorization header missing") - +def decode_access_token(token: str) -> int: try: - # Extract token from "Bearer " format - if not authorization.startswith("Bearer "): - raise HTTPException(status_code=401, detail="Invalid authorization format") - - token = authorization.split(" ")[1] - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGO]) - - # Get user info from token - user_id = payload.get("sub") - if not user_id: - raise HTTPException(status_code=401, detail="Invalid token payload") - - # Return user info (only what's actually in the token) - return {"user_id": user_id} - - except JWTError: - raise HTTPException(status_code=401, detail="Invalid or expired token") - except IndexError: - raise HTTPException(status_code=401, detail="Invalid authorization format") + payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM]) + except JWTError as e: + raise ValueError("invalid token") from e + if payload.get("type") != "access": + raise ValueError("wrong token type") + sub = payload.get("sub") + if not sub: + raise ValueError("missing subject") + return int(sub) + + +# ── Opaque token helpers (device tokens, auth codes) ──────────────────────── + +def new_opaque_token() -> tuple[str, str]: + """Return (raw_token, sha256_hex). Store only the hash.""" + raw = secrets.token_urlsafe(48) + return raw, hashlib.sha256(raw.encode()).hexdigest() + + +def hash_token(raw: str) -> str: + return hashlib.sha256(raw.encode()).hexdigest() + + +# ── QR challenge signing ───────────────────────────────────────────────────── + +def sign_qr_challenge(session_id: str, timestamp: int) -> str: + """HMAC-SHA256 of 'session_id:timestamp' keyed with SECRET_KEY. + + Returns a 32-char base64url string (truncated for URL compactness). + """ + msg = f"{session_id}:{timestamp}".encode() + raw = hmac.new(settings.secret_key.encode(), msg, hashlib.sha256).digest() + return base64.urlsafe_b64encode(raw).decode().rstrip("=")[:32] + + +def verify_qr_challenge(session_id: str, timestamp: int, sig: str, max_age: int = 35) -> bool: + """Return True iff sig is valid and timestamp is within max_age seconds.""" + if abs(time.time() - timestamp) > max_age: + return False + expected = sign_qr_challenge(session_id, timestamp) + return hmac.compare_digest(expected, sig) + + +def build_scan_url(session_id: str) -> str: + ts = int(time.time()) + sig = sign_qr_challenge(session_id, ts) + return f"{settings.app_base_url}/scan?s={session_id}&t={ts}&sig={sig}" + + +# ── QR image generation ────────────────────────────────────────────────────── + +def generate_qr_data_url(content: str) -> str: + qr = qrcode.QRCode(box_size=6, border=2) + qr.add_data(content) + qr.make(fit=True) + img = qr.make_image(image_factory=qrcode.image.pure.PyPNGImage) + buf = io.BytesIO() + img.save(buf) + b64 = base64.b64encode(buf.getvalue()).decode("ascii") + return f"data:image/png;base64,{b64}" diff --git a/check_db.py b/check_db.py deleted file mode 100644 index 294458c..0000000 --- a/check_db.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Quick database check script""" -from backend.database import SessionLocal, engine -from backend.models import Base, User - -# Ensure tables exist -Base.metadata.create_all(bind=engine) - -# Check users -db = SessionLocal() -users = db.query(User).all() - -print(f"\nTotal users in database: {len(users)}\n") - -if users: - for user in users: - print(f" - Name: {user.full_name}") - print(f" Email: {user.email}") - print(f" ID: {user.id}") - print() -else: - print(" No users found!\n") - -db.close() diff --git a/create_user.py b/create_user.py deleted file mode 100644 index fc8ae0a..0000000 --- a/create_user.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Create a test user in the database""" -from backend.database import SessionLocal -from backend.models import User -from backend.security import hash_password - -db = SessionLocal() - -# Create test user -test_user = User( - username="testuser", - email="test@example.com", - password=hash_password("password123"), - mfa_enabled=False -) - -db.add(test_user) -db.commit() -db.refresh(test_user) - -print("\nβœ… Test user created successfully!") -print("\nLogin credentials:") -print(" Username: testuser") -print(" Password: password123") -print("\nYou can now login at: http://127.0.0.1:8000\n") - -db.close() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7329027 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: app + POSTGRES_PASSWORD: app + POSTGRES_DB: app + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U app -d app"] + interval: 5s + timeout: 5s + retries: 10 + + web: + build: . + depends_on: + db: + condition: service_healthy + environment: + ENVIRONMENT: production + SECRET_KEY: ${SECRET_KEY:?set SECRET_KEY in .env} + DATABASE_URL: postgresql+psycopg://app:app@db:5432/app + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:8000} + COOKIE_SECURE: ${COOKIE_SECURE:-false} + # Email β€” set EMAIL_ENABLED=true and fill SMTP_* to send real emails. + EMAIL_ENABLED: ${EMAIL_ENABLED:-false} + SMTP_HOST: ${SMTP_HOST:-localhost} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + SMTP_FROM: ${SMTP_FROM:-noreply@cipher.app} + APP_BASE_URL: ${APP_BASE_URL:-http://localhost:8000} + command: > + sh -c "alembic upgrade head && + uvicorn backend.main:app --host 0.0.0.0 --port 8000" + ports: + - "8000:8000" + +volumes: + pgdata: diff --git a/frontend/fingerprint.js b/frontend/fingerprint.js deleted file mode 100644 index c75a846..0000000 --- a/frontend/fingerprint.js +++ /dev/null @@ -1,85 +0,0 @@ -function openFingerprint(email) { - const modal = document.getElementById("fingerprintModal"); - if (!modal) { - console.error("Fingerprint modal not found"); - alert("Configuration error: Modal not found"); - return; - } - modal.classList.remove("hidden"); - - document.getElementById("fingerprintBtn").onclick = async () => { - try { - // Try to use WebAuthn if available, but don't require it - if (navigator.credentials && navigator.credentials.get) { - try { - await navigator.credentials.get({ - publicKey: { - challenge: new Uint8Array(32), - userVerification: "preferred" - } - }); - } catch (authError) { - // WebAuthn not available or failed, continue anyway - console.log("WebAuthn not available, continuing..."); - } - } - - const res = await fetch("http://127.0.0.1:8000/fingerprint", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }) - }); - - if (!res.ok) { - const errorData = await res.json(); - throw new Error(errorMessage); - } - - const data = await res.json(); - - modal.classList.add("hidden"); - showQR(data.session_id); - - } catch (error) { - console.error("Fingerprint verification error:", error); - alert(error.message || "Fingerprint verification failed"); - } - }; -} - -function showQR(sessionId) { - // Create modal if it doesn't exist - let qrModal = document.getElementById("qrModal"); - - if (!qrModal) { - qrModal = document.createElement("div"); - qrModal.id = "qrModal"; - qrModal.className = "fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"; - qrModal.innerHTML = ` -
-
- qr_code_2 -
-

Scan QR Code

-

Scan this code with your device to complete authentication

- QR Code - -
- `; - document.body.appendChild(qrModal); - } - - const qrImage = document.getElementById("qrImage"); - qrImage.src = `http://127.0.0.1:8000/qr-image/${sessionId}`; -} - -function closeQRAndRedirect() { - const qrModal = document.getElementById("qrModal"); - if (qrModal) { - qrModal.remove(); - } - // Redirect to home page - window.location.href = '/home'; -} diff --git a/frontend/forgot-password.html b/frontend/forgot-password.html new file mode 100644 index 0000000..8775bb9 --- /dev/null +++ b/frontend/forgot-password.html @@ -0,0 +1,189 @@ + + + + + + Forgot password Β· Cipher + + + + + + + + + +
+ + +
+

Reset password

+

We'll send a link to your email

+
+ +
+
+
+ + +
+ + +
+
+ + +
+ + + + diff --git a/frontend/forgot-password.js b/frontend/forgot-password.js new file mode 100644 index 0000000..4607ede --- /dev/null +++ b/frontend/forgot-password.js @@ -0,0 +1,117 @@ +import { api, showError } from "/static/util.js"; + +const form = document.getElementById("form"); +const submit = document.getElementById("submit"); +const errorBox = document.getElementById("error"); + +form.addEventListener("submit", async (e) => { + e.preventDefault(); + errorBox.classList.add("hidden"); + submit.disabled = true; + + const email = document.getElementById("email").value.trim(); + + try { + await api("/api/auth/forgot-password", { + method: "POST", + body: JSON.stringify({ email }), + }); + showSuccessState(email); + } catch (err) { + showError(errorBox, err.message); + submit.disabled = false; + } +}); + +function showSuccessState(email) { + document.querySelector(".heading h1").textContent = "Check your inbox"; + document.querySelector(".heading p").textContent = `Sent to ${email}`; + document.getElementById("form-card").innerHTML = ` +

+ If that address is registered you'll receive a reset link.
+ It expires in 1 hour.

+ Back to sign in +

+ `; + + document.getElementById("dev-trigger").addEventListener("click", async () => { + try { + const data = await api(`/api/auth/debug/reset-link?email=${encodeURIComponent(email)}`); + showModal(data.message); + } catch { + showModal(null); + } + }); +} + +function showModal(url) { + const overlay = document.createElement("div"); + overlay.style.cssText = ` + position:fixed;inset:0; + background:rgba(0,0,0,0.72); + backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px); + z-index:1000;display:flex;align-items:center;justify-content:center;padding:1.5rem`; + + overlay.innerHTML = url ? ` +
+

+ Dev Β· Reset Link +

+

+ This link is only visible in dev mode (EMAIL_ENABLED=false). +

+ ${url} + +
` : ` +
+

+ Could not fetch link β€” make sure the email is registered. +

+ +
`; + + document.body.appendChild(overlay); + overlay.querySelector("#modal-close").addEventListener("click", () => overlay.remove()); + overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); }); +} diff --git a/frontend/home.html b/frontend/home.html index 427f9cd..cb0cb7f 100644 --- a/frontend/home.html +++ b/frontend/home.html @@ -1,395 +1,556 @@ + + + + + Dashboard Β· Cipher + + - - -
- -
- - - -
- -
-
- - -
-
- - - -
- - -
-
-
- -
-
- -
- -
-
-shield -
-
-

System Integrity

-
-

98%

- -trending_up Stable - -
-
-
-
-
-
- -
-
-gpp_maybe -
-
-

Threats Blocked (24h)

-
-

1,204

- - +15% - -
-

Last blocked: IP 192.168.4.22 (SQL Injection)

-
-
- -
-
-public -
-
-

Global Defcon

-
-

Level 4

- - Elevated - -
-

Regional Alert: Eastern Europe Sector

-
-
-
- -
- -
-
-
-

Network Traffic Analysis

-

Real-time throughput (Mb/s)

-
-
- - - - -Live -
-
- -
- - - - - - - - - - -
- -
-
- -Inbound -
-
- -Outbound -
-
- -Anomalies -
-
-
- -
-

Quick Actions

-
- - - - -
-
-
-warning -
-

Critical Update Required

-

Core security module v4.2.1 pending.

- -
-
-
-
-
- -
-
-

Recent Activity Logs

- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
TimeEvent TypeSource / IPSeverityStatusAction
10:42:15 AMUnauthorized Access Attempt192.168.0.45 -High - -
-check_circle -Blocked -
-
- -
10:38:02 AMPort Scan Detected45.22.19.112 -Medium - -
-warning -Flagged -
-
- -
10:15:33 AMFirewall Rules UpdatedSystem (Admin) -Info - -
-check -Success -
-
- -
09:55:12 AMData Backup CompletedAuto-Job #4291 -Low - -
-check -Success -
-
- -
-
-
-
-
-
-
- - \ No newline at end of file + +
+ + + + + + + + + + diff --git a/frontend/home.js b/frontend/home.js index 810c3c6..5d7a958 100644 --- a/frontend/home.js +++ b/frontend/home.js @@ -1,78 +1,146 @@ -// Home Page Dashboard Handler -document.addEventListener('DOMContentLoaded', function () { - // Check if user is logged in - const accessToken = localStorage.getItem('access_token'); - if (!accessToken) { - // No token found, redirect to login - window.location.href = '/'; - return; - } +const DEVICE_TOKEN_KEY = "cipher_device_token"; - // Handle logout button - const logoutButton = document.querySelector('button'); - if (logoutButton && logoutButton.textContent.includes('Sign Out')) { - logoutButton.addEventListener('click', function (e) { - e.preventDefault(); +async function api(path, options = {}) { + const res = await fetch(path, { credentials: "include", ...options }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || res.statusText); + return res.status === 204 ? null : res.json(); +} - // Clear stored tokens - localStorage.removeItem('access_token'); - sessionStorage.removeItem('user_id'); +// ── Load user ──────────────────────────────────────────────────────────────── +let me; +try { + me = await api("/api/auth/me"); +} catch { + window.location.href = "/login"; +} - // Show logout message briefly - const originalText = this.innerHTML; - this.innerHTML = 'logout Signing Out...'; +const initials = me.full_name.trim().split(/\s+/).map(w => w[0]).join("").toUpperCase().slice(0, 2); +document.getElementById("avatarInitials").textContent = initials; +document.getElementById("profileName").textContent = me.full_name; +document.getElementById("profileEmail").textContent = me.email; +document.getElementById("profileDate").textContent = + new Date(me.created_at).toLocaleDateString("en-US", { year: "numeric", month: "long" }); - // Redirect to login page after brief delay - setTimeout(() => { - window.location.href = '/'; - }, 500); - }); - } +// ── Device icon SVG (phone) ─────────────────────────────────────────────────── +const PHONE_SVG = ` + + +`; - // Handle mobile menu toggle - const menuButton = document.querySelector('button[class*="md:hidden"]'); - const sidebar = document.querySelector('aside'); +// ── Load devices ───────────────────────────────────────────────────────────── +const devicesList = document.getElementById("devicesList"); - if (menuButton && sidebar) { - menuButton.addEventListener('click', function () { - sidebar.classList.toggle('hidden'); - sidebar.classList.toggle('flex'); - }); - } - - // Add animation to stats on page load - animateStats(); -}); +function renderSkeleton() { + devicesList.innerHTML = [1, 2].map(() => ` +
+
+
+
+
+
+
`).join(""); +} -// Animate stats counter on page load -function animateStats() { - const statValues = document.querySelectorAll('h3.text-3xl'); +function formatLastUsed(iso) { + if (!iso) return "Never"; + const d = new Date(iso); + const now = new Date(); + const diff = now - d; + const mins = Math.floor(diff / 60000); + if (mins < 2) return "Just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + if (days === 1) return "Yesterday"; + if (days < 7) return `${days}d ago`; + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} - statValues.forEach(stat => { - const text = stat.textContent; - // Only animate numeric values - const match = text.match(/[\d,]+/); - if (match) { - const finalValue = parseInt(match[0].replace(/,/g, '')); - animateValue(stat, 0, finalValue, 1500, text); +async function loadDevices() { + renderSkeleton(); + try { + const devices = await api("/api/devices"); + if (!devices.length) { + devicesList.innerHTML = ` +
+
+ ${PHONE_SVG} +
+

No devices registered yet.

+

Add a device to enable QR authentication.

+
`; + return; } - }); -} + devicesList.innerHTML = devices.map((d, i) => ` +
+
${PHONE_SVG}
+
+

${escHtml(d.name)}

+

${formatLastUsed(d.last_used_at)}

+
+ +
`).join(""); -function animateValue(element, start, end, duration, originalText) { - const range = end - start; - const increment = range / (duration / 16); // 60fps - let current = start; + } catch { + devicesList.innerHTML = ` +
+

Could not load devices. Try reloading.

+
`; + } +} - const timer = setInterval(() => { - current += increment; - if (current >= end) { - current = end; - clearInterval(timer); +window.revokeDevice = async (id) => { + if (!confirm("Revoke this device? It will no longer be able to approve sign-ins.")) return; + try { + await api(`/api/devices/${id}`, { method: "DELETE" }); + const row = document.getElementById(`dev-${id}`); + if (row) { + row.style.transition = "opacity .3s, transform .3s"; + row.style.opacity = "0"; + row.style.transform = "translateX(8px)"; + setTimeout(loadDevices, 320); + } else { + loadDevices(); } + } catch (err) { + alert(err.message); + } +}; + +loadDevices(); + +// ── Add device ──────────────────────────────────────────────────────────────── +const deviceToken = localStorage.getItem(DEVICE_TOKEN_KEY); + +document.getElementById("btnAddDevice").addEventListener("click", async () => { + if (!deviceToken) { + alert("No device token in this browser. Register at /register first."); + return; + } + const name = prompt("Name for the new device:", "New Device"); + if (!name) return; + try { + const res = await fetch(`/api/devices/enroll?device_name=${encodeURIComponent(name)}`, { + method: "POST", + credentials: "include", + headers: { "Authorization": `Bearer ${deviceToken}` }, + }); + if (!res.ok) throw new Error((await res.json()).detail); + const d = await res.json(); + localStorage.setItem(DEVICE_TOKEN_KEY, d.device_token); + loadDevices(); + } catch (err) { + alert("Failed: " + err.message); + } +}); + +// ── Logout ──────────────────────────────────────────────────────────────────── +document.getElementById("logout").addEventListener("click", () => { + document.cookie = "access_token=; Max-Age=0; path=/"; + window.location.href = "/login"; +}); - // Format with commas - const formattedValue = Math.floor(current).toLocaleString(); - element.textContent = originalText.replace(/[\d,]+/, formattedValue); - }, 16); +function escHtml(s) { + return s.replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); } diff --git a/frontend/login.html b/frontend/login.html index f8a7327..9a28219 100644 --- a/frontend/login.html +++ b/frontend/login.html @@ -1,175 +1,667 @@ + + + + + Cipher β€” Sign In + + - - -
-
-
-
-
- -
-
-
-shield_lock -
-
-

CyberGuard

-System v2.4 -
-
- -
- -
-
- -
- -
-
- -
-
-lock_person -
-

System Login

-

Verify your credentials to access the secure network.

-
- -
- -
- -
-
-badge -
- -
-
- -
- -
-
-key -
- - -
-
- - -
-
- -
-

- New agent? - Request access credentials -

-
-
- -
-encrypted -256-BIT ENCRYPTION ACTIVE -
-
-
- -
-
- SECURE CONNECTION ESTABLISHED β€’ ONLINE -
-
-Β© 2026 CyberGuard Inc. -Privacy -Terms -
-
- - - - - - - + + + + + +
+ + +
+ +
+ +
+ Cipher +
+ Identity Provider +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+

Enroll this device

+

One-time setup — no password, ever

+
+
+
+ + +
+
+ + +
+
+ + +
+ + +
+
or
+

Already enrolled?

+
+ + + + +
+ + +
+ + +
+
+

Scan to sign in

+

Use your enrolled phone — no password required

+
+ + +
+ + +
+
+
+
+
+
+
+ + + + + + + + + + +
+ + +
+
+ Initializing session… +
+ + +
+
+ 1 + Open Cipher on your registered phone +
+
+ 2 + Point your camera at this QR code +
+
+ 3 + Tap Approve to complete sign-in +
+
+ +
+
+ + +
+ Cipher — Passwordless by design · + API docs +
+ +
+ + + + diff --git a/frontend/login.js b/frontend/login.js index ce4863e..5b2d45b 100644 --- a/frontend/login.js +++ b/frontend/login.js @@ -1,142 +1,387 @@ -// Login Form Handler -document.addEventListener('DOMContentLoaded', function () { - const loginForm = document.querySelector('form'); - const emailInput = document.querySelector('input[type="text"]'); - const passwordInput = document.querySelector('input[type="password"]'); - const submitButton = document.querySelector('button[type="button"]'); - let errorContainer = document.getElementById('error-message'); - - // Create error container if it doesn't exist - if (!errorContainer && loginForm) { - errorContainer = document.createElement('div'); - errorContainer.id = 'error-message'; - errorContainer.className = 'mt-4 p-3 bg-red-500/10 border border-red-500 rounded-lg text-red-400 text-sm text-center'; - errorContainer.style.display = 'none'; - loginForm.appendChild(errorContainer); +// ── Constants ──────────────────────────────────────────────────────────────── +const DEVICE_TOKEN_KEY = "cipher_device_token"; +const QR_ROTATE_SECS = 30; // refresh QR image every 30 s +const SESSION_EXPIRE = 90; // show expired overlay after 90 s + +// ── Element refs ───────────────────────────────────────────────────────────── +const tabSignup = document.getElementById("tabSignup"); +const tabSignin = document.getElementById("tabSignin"); +const formSignup = document.getElementById("formSignup"); +const formSignin = document.getElementById("formSignin"); + +const signupForm = document.getElementById("signupForm"); +const suName = document.getElementById("su-name"); +const suEmail = document.getElementById("su-email"); +const suDevice = document.getElementById("su-device"); +const suSubmit = document.getElementById("su-submit"); +const suError = document.getElementById("su-error"); + +const signinForm = document.getElementById("signinForm"); +const siEmail = document.getElementById("si-email"); +const siMsg = document.getElementById("si-msg"); +const siSubmit = document.getElementById("si-submit"); + +const qrLoading = document.getElementById("qrLoading"); +const qrActive = document.getElementById("qrActive"); +const qrExpired = document.getElementById("qrExpired"); +const qrSuccess = document.getElementById("qrSuccess"); +const qrImg = document.getElementById("qrImg"); +const timerBar = document.getElementById("timerBar"); +const btnRefresh = document.getElementById("btnRefresh"); + +const statusBar = document.getElementById("statusBar"); +const statusDot = document.getElementById("statusDot"); +const statusText = document.getElementById("statusText"); + +// ── State ──────────────────────────────────────────────────────────────────── +let sessionId = null; +let pollTimer = null; +let rotateTimer = null; +let expireTimer = null; +let rotateCountdown = QR_ROTATE_SECS; +let totalElapsed = 0; + +// Read OAuth params forwarded from /oauth/authorize +const params = new URLSearchParams(window.location.search); +const oauthPayload = { + client_id: params.get("client_id") || undefined, + redirect_uri: params.get("redirect_uri") || undefined, + scope: params.get("scope") || "openid profile email", +}; + +// ── Tab switching ───────────────────────────────────────────────────────────── +window.setMode = function setMode(mode) { + const isSignup = mode === "signup"; + + tabSignup.className = "tab-btn" + (isSignup ? " tab-btn--active" : ""); + tabSignin.className = "tab-btn" + (isSignup ? "" : " tab-btn--active"); + + formSignup.classList.toggle("hidden", !isSignup); + formSignin.classList.toggle("hidden", isSignup); + + hideSuError(); + hideSiMsg(); +}; + +// ── QR state machine ───────────────────────────────────────────────────────── +function setQRState(state) { + qrLoading.classList.add("hidden"); + qrActive.classList.add("hidden"); + qrExpired.classList.add("hidden"); + qrSuccess.classList.add("hidden"); + + switch (state) { + case "loading": + qrLoading.classList.remove("hidden"); + setStatus("gold", "Initializing session…"); + break; + + case "active": + qrActive.classList.remove("hidden"); + setStatus("gold", "Waiting for your phone to scan…"); + break; + + case "scanned": + qrActive.classList.remove("hidden"); + setStatus("blue", "Phone detected β€” tap Approve on your device"); + break; + + case "expired": + clearTimers(); + qrExpired.classList.remove("hidden"); + setStatus("warn", "Session expired β€” generate a new code to continue"); + break; + + case "success": + clearTimers(); + qrSuccess.classList.remove("hidden"); + setStatus("ok", "Identity verified β€” signing you in…"); + break; } - - // Guard: ensure form exists before proceeding - if (!loginForm) { - console.error('Login form not found'); - return; +} + +function setStatus(variant, text) { + const styles = { + gold: { + bg: "rgba(196,163,90,0.07)", + border: "rgba(196,163,90,0.14)", + color: "rgba(240,232,220,0.55)", + dot: "rgba(196,163,90,0.70)", + }, + blue: { + bg: "rgba(80,100,200,0.09)", + border: "rgba(80,100,200,0.20)", + color: "rgba(200,210,255,0.65)", + dot: "rgba(120,140,255,0.80)", + }, + ok: { + bg: "rgba(109,191,138,0.09)", + border: "rgba(109,191,138,0.22)", + color: "rgba(109,191,138,0.80)", + dot: "#6dbf8a", + }, + warn: { + bg: "rgba(200,80,72,0.09)", + border: "rgba(200,80,72,0.22)", + color: "#f4a8a0", + dot: "#f4a8a0", + }, + }; + const s = styles[variant] || styles.gold; + statusBar.style.background = s.bg; + statusBar.style.border = `1px solid ${s.border}`; + statusBar.style.color = s.color; + statusDot.style.background = s.dot; + statusText.textContent = text; +} + +// ── Session creation ───────────────────────────────────────────────────────── +async function createSession() { + clearTimers(); + sessionId = null; + totalElapsed = 0; + + setQRState("loading"); + + try { + const body = Object.fromEntries( + Object.entries(oauthPayload).filter(([, v]) => v !== undefined) + ); + const res = await fetch("/api/qr/sessions", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || "Server error"); + + const data = await res.json(); + sessionId = data.session_id; + + qrImg.src = data.qr_data_url; + setQRState("active"); + startCountdown(data.qr_ttl || QR_ROTATE_SECS); + startPolling(); + + } catch (err) { + setStatus("warn", "Could not create session β€” " + err.message); } +} - // Add event listener to form submit button - submitButton.addEventListener('click', async function (e) { - e.preventDefault(); +// ── Countdown & rotation ───────────────────────────────────────────────────── +function startCountdown(ttl) { + rotateCountdown = ttl; + timerBar.style.width = "100%"; - // Clear previous errors - errorContainer.textContent = ''; - errorContainer.style.display = 'none'; - errorContainer.className = 'mt-4 p-3 bg-red-500/10 border border-red-500 rounded-lg text-red-400 text-sm text-center'; + const tick = () => { + rotateCountdown--; + totalElapsed++; - // Get input values - const email = emailInput.value.trim(); - const accessKey = passwordInput.value; + // Visual timer bar (counts down to 0 then resets) + timerBar.style.width = `${Math.max(0, (rotateCountdown / ttl) * 100)}%`; - // Basic validation - if (!email || !accessKey) { - showError('Please enter both Email and Access Key'); + // Hard session expiry (90 s total) + if (totalElapsed >= SESSION_EXPIRE) { + setQRState("expired"); return; } - // Show loading state - submitButton.disabled = true; - const originalText = submitButton.innerHTML; - submitButton.innerHTML = 'Verifying...'; - - try { - // Make API call to login endpoint - const response = await fetch('http://127.0.0.1:8000/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email: email, - access_key: accessKey - }) - }); - - const data = await response.json(); - - if (response.ok) { - // Login successful - store token and check next step - if (data.access_token) { - localStorage.setItem('access_token', data.access_token); - localStorage.setItem('user_email', email); - } - - if (data.next === "fingerprint") { - showSuccess('Credentials verified! Proceeding to biometric...'); - setTimeout(() => { - openFingerprint(email); - }, 500); - } else { - // Fallback if no next step specified - showSuccess('Login successful!'); - setTimeout(() => { - window.location.href = '/home'; - }, 1000); + if (rotateCountdown <= 0) { + refreshQR(); + } else { + rotateTimer = setTimeout(tick, 1000); + } + }; + + rotateTimer = setTimeout(tick, 1000); +} + +async function refreshQR() { + if (!sessionId) return; + try { + const res = await fetch(`/api/qr/sessions/${sessionId}/qr`, { credentials: "include" }); + if (!res.ok) { setQRState("expired"); return; } + const data = await res.json(); + qrImg.src = data.qr_data_url; + startCountdown(data.ttl || QR_ROTATE_SECS); + } catch { + setQRState("expired"); + } +} + +// ── Polling ─────────────────────────────────────────────────────────────────── +function startPolling() { + pollTimer = setInterval(poll, 2000); +} + +async function poll() { + if (!sessionId) return; + try { + const res = await fetch(`/api/qr/sessions/${sessionId}/status`, { credentials: "include" }); + if (!res.ok) return; + const data = await res.json(); + + switch (data.status) { + case "pending": + break; + + case "scanned": + setQRState("scanned"); + break; + + case "approved": { + clearInterval(pollTimer); + pollTimer = null; + setQRState("success"); + + if (data.redirect_url) { + setTimeout(() => { window.location.href = data.redirect_url; }, 600); + return; } - } else { - // Login failed - showError(data.detail || 'Invalid credentials. Please try again.'); + // Direct login β€” exchange approved session for a JWT cookie + try { + const tok = await fetch(`/api/qr/sessions/${sessionId}/token`, { + method: "POST", + credentials: "include", + }); + if (tok.ok) { + setTimeout(() => { window.location.href = "/home"; }, 800); + } else { + setStatus("warn", "Token exchange failed β€” please try again"); + } + } catch { + setStatus("warn", "Network error during token exchange"); + } + break; } - } catch (error) { - console.error('Login error:', error); - showError('Connection error. Please check if the server is running.'); - } finally { - // Restore button state - submitButton.disabled = false; - submitButton.innerHTML = originalText; - } - }); - // Helper function to show error messages - function showError(message) { - errorContainer.textContent = message; - errorContainer.className = 'mt-4 p-3 bg-red-500/10 border border-red-500 rounded-lg text-red-400 text-sm text-center'; - errorContainer.style.display = 'block'; + case "expired": + case "consumed": + if (data.status === "expired") setQRState("expired"); + break; + } + } catch { + // transient network error β€” keep polling } +} - // Helper function to show success messages - function showSuccess(message) { - errorContainer.textContent = message; - errorContainer.className = 'mt-4 p-3 bg-green-500/10 border border-green-500 rounded-lg text-green-400 text-sm text-center'; - errorContainer.style.display = 'block'; - } +// ── Helper: clear all timers ────────────────────────────────────────────────── +function clearTimers() { + clearInterval(pollTimer); + clearTimeout(rotateTimer); + pollTimer = null; + rotateTimer = null; +} + +// ── Signup form ─────────────────────────────────────────────────────────────── +function showSuError(msg) { + suError.textContent = msg; + suError.classList.remove("hidden"); +} +function hideSuError() { + suError.classList.add("hidden"); +} - // Allow Enter key to submit form - emailInput.addEventListener('keypress', function (e) { - if (e.key === 'Enter') { - submitButton.click(); +signupForm.addEventListener("submit", async (e) => { + e.preventDefault(); + hideSuError(); + suSubmit.disabled = true; + suSubmit.textContent = "Enrolling…"; + + const full_name = suName.value.trim(); + const email = suEmail.value.trim(); + const device_name = suDevice.value.trim() || "My Device"; + + try { + const res = await fetch("/api/auth/register", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ full_name, email, device_name }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.detail || "Registration failed"); } - }); + const data = await res.json(); + localStorage.setItem(DEVICE_TOKEN_KEY, data.device_token); + window.location.href = "/home"; + + } catch (err) { + showSuError(err.message); + suSubmit.disabled = false; + suSubmit.textContent = "Enroll & Activate"; + } +}); + +// ── Signin form ─────────────────────────────────────────────────────────────── +function showSiMsg(text, variant) { + const variants = { + ok: { bg: "rgba(109,191,138,0.09)", border: "rgba(109,191,138,0.22)", color: "rgba(109,191,138,0.90)" }, + info: { bg: "rgba(196,163,90,0.07)", border: "rgba(196,163,90,0.18)", color: "rgba(240,232,220,0.65)" }, + err: { bg: "rgba(200,80,72,0.09)", border: "rgba(200,80,72,0.22)", color: "#f4a8a0" }, + }; + const s = variants[variant] || variants.info; + siMsg.style.background = s.bg; + siMsg.style.border = `1px solid ${s.border}`; + siMsg.style.color = s.color; + siMsg.textContent = text; + siMsg.classList.remove("hidden"); +} +function hideSiMsg() { + siMsg.classList.add("hidden"); +} + +signinForm.addEventListener("submit", async (e) => { + e.preventDefault(); + hideSiMsg(); + siSubmit.disabled = true; + siSubmit.textContent = "Checking…"; - passwordInput.addEventListener('keypress', function (e) { - if (e.key === 'Enter') { - submitButton.click(); + const email = siEmail.value.trim(); + + try { + const res = await fetch("/api/auth/lookup", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + + if (res.status === 404) { + showSiMsg("No account found for that email β€” create one using the tab above.", "err"); + siSubmit.disabled = false; + siSubmit.textContent = "Continue"; + return; } - }); - - // Password visibility toggle - const allButtons = document.querySelectorAll('button[type="button"]'); - allButtons.forEach(button => { - const icon = button.querySelector('.material-symbols-outlined'); - if (icon && (icon.textContent === 'visibility_off' || icon.textContent === 'visibility')) { - button.addEventListener('click', function (e) { - e.preventDefault(); - e.stopPropagation(); - if (passwordInput.type === 'password') { - passwordInput.type = 'text'; - icon.textContent = 'visibility'; - } else { - passwordInput.type = 'password'; - icon.textContent = 'visibility_off'; - } - }); + + if (!res.ok) throw new Error("Lookup failed"); + + // Account found β€” check if this browser has a device token + const deviceToken = localStorage.getItem(DEVICE_TOKEN_KEY); + if (deviceToken) { + showSiMsg( + "Account found. Use your registered phone to scan the QR code on the right β€” then tap Approve.", + "ok" + ); + } else { + showSiMsg( + "Account found, but this browser is not a registered device. Open Cipher on your enrolled phone and scan the QR code β†’", + "info" + ); } - }); + + } catch { + showSiMsg("Could not reach the server β€” please try again.", "err"); + } finally { + siSubmit.disabled = false; + siSubmit.textContent = "Continue"; + } }); + +// ── Refresh button ──────────────────────────────────────────────────────────── +btnRefresh.addEventListener("click", createSession); + +// ── Boot ────────────────────────────────────────────────────────────────────── +createSession(); diff --git a/frontend/register.html b/frontend/register.html new file mode 100644 index 0000000..e457e99 --- /dev/null +++ b/frontend/register.html @@ -0,0 +1,151 @@ + + + + + + Register Β· Cipher + + + + + + + + +
+ + +
+

Enroll this device

+

One-time setup β€” no password ever

+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+ + + + +
+ +
+ This device will be saved as a trusted authenticator. Use it to approve future sign-ins by scanning a QR code. +
+
+ + +
+ + + + diff --git a/frontend/register.js b/frontend/register.js new file mode 100644 index 0000000..91cffbb --- /dev/null +++ b/frontend/register.js @@ -0,0 +1,62 @@ +const DEVICE_TOKEN_KEY = "cipher_device_token"; + +const form = document.getElementById("form"); +const submit = document.getElementById("submit"); +const errorBox = document.getElementById("error"); + +// If already enrolled, skip to login +if (localStorage.getItem(DEVICE_TOKEN_KEY)) { + window.location.href = "/login"; +} + +form.addEventListener("submit", async (e) => { + e.preventDefault(); + errorBox.classList.add("hidden"); + submit.disabled = true; + + const full_name = document.getElementById("fullName").value.trim(); + const email = document.getElementById("email").value.trim(); + const device_name = document.getElementById("deviceName").value.trim() || "My Device"; + + try { + const res = await fetch("/api/auth/register", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ full_name, email, device_name }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.detail || "Registration failed"); + } + + const data = await res.json(); + + // Persist the device token β€” this is the only time it is ever sent + localStorage.setItem(DEVICE_TOKEN_KEY, data.device_token); + + // Show success state + document.querySelector(".heading h1").textContent = "Device enrolled"; + document.querySelector(".heading p").textContent = "You're ready to scan QR codes"; + document.getElementById("formCard").innerHTML = ` +

+ Your device token has been saved to this browser.
+ Whenever you see a Cipher QR code, scan it with this device and tap Approve. +

+ + Go to sign in + `; + + } catch (err) { + errorBox.textContent = err.message; + errorBox.classList.remove("hidden"); + submit.disabled = false; + } +}); diff --git a/frontend/reset-password.html b/frontend/reset-password.html new file mode 100644 index 0000000..01c56e6 --- /dev/null +++ b/frontend/reset-password.html @@ -0,0 +1,195 @@ + + + + + + Reset password Β· Cipher + + + + + + + + + +
+ + +
+

New password

+

Choose something strong

+
+ +
+
+
+ + +
+
+ + +
+ + +
+
+ + +
+ + + + diff --git a/frontend/reset-password.js b/frontend/reset-password.js new file mode 100644 index 0000000..2640af0 --- /dev/null +++ b/frontend/reset-password.js @@ -0,0 +1,47 @@ +import { api, showError } from "/static/util.js"; + +const form = document.getElementById("form"); +const submit = document.getElementById("submit"); +const errorBox = document.getElementById("error"); + +const token = new URLSearchParams(window.location.search).get("token"); + +if (!token) { + document.querySelector(".heading h1").textContent = "Link invalid"; + document.querySelector(".heading p").textContent = "No reset token in this URL"; + document.getElementById("card").innerHTML = ` +

+ Request a new link from the forgot password page. +

`; +} + +form?.addEventListener("submit", async (e) => { + e.preventDefault(); + errorBox.classList.add("hidden"); + + const password = document.getElementById("password").value; + const confirm = document.getElementById("confirm").value; + + if (password !== confirm) { + showError(errorBox, "Passwords do not match"); + return; + } + + submit.disabled = true; + try { + await api("/api/auth/reset-password", { + method: "POST", + body: JSON.stringify({ token, password }), + }); + document.querySelector(".heading h1").textContent = "Password updated"; + document.querySelector(".heading p").textContent = "You can now sign in"; + document.getElementById("card").innerHTML = ` +

+ Your password has been reset.
+ Sign in β†’ +

`; + } catch (err) { + showError(errorBox, err.message); + submit.disabled = false; + } +}); diff --git a/frontend/scan.html b/frontend/scan.html new file mode 100644 index 0000000..7e71d35 --- /dev/null +++ b/frontend/scan.html @@ -0,0 +1,166 @@ + + + + + + Approve login Β· Cipher + + + + + + + + +
+ + +
+ +
+
+
+ + +
+
+

+

+ + + +
+ + +
+
βœ“
+

Approved

+

The sign-in has been authorized. You can close this tab.

+
+ + +
+
βœ•
+

Denied

+

The sign-in attempt was blocked.

+
+ + +
+
⚠
+

Not enrolled

+

This device isn't registered with Cipher. Register first, then scan again.

+ +
+ + +
+
!
+

Something went wrong

+

+
+
+
+ + + + diff --git a/frontend/scan.js b/frontend/scan.js new file mode 100644 index 0000000..700a738 --- /dev/null +++ b/frontend/scan.js @@ -0,0 +1,104 @@ +// Reads QR params from the URL, validates the challenge, then shows approve/deny UI. +// The device token is stored in localStorage under "cipher_device_token". + +const DEVICE_TOKEN_KEY = "cipher_device_token"; + +const states = { + loading: document.getElementById("stateLoading"), + approve: document.getElementById("stateApprove"), + success: document.getElementById("stateSuccess"), + denied: document.getElementById("stateDenied"), + noDevice: document.getElementById("stateNoDevice"), + error: document.getElementById("stateError"), +}; + +function show(name) { + Object.values(states).forEach(el => el.classList.remove("active")); + states[name].classList.add("active"); +} + +function setError(msg) { + document.getElementById("errorMsg").textContent = msg; + show("error"); +} + +// ── Parse QR params ────────────────────────────────────────────────────────── + +const sp = new URLSearchParams(window.location.search); +const sessionId = sp.get("s"); +const timestamp = parseInt(sp.get("t") || "0", 10); +const sig = sp.get("sig"); + +if (!sessionId || !timestamp || !sig) { + setError("Missing or malformed QR parameters. Please scan the QR code again."); +} else { + init(); +} + +async function init() { + const deviceToken = localStorage.getItem(DEVICE_TOKEN_KEY); + if (!deviceToken) { show("noDevice"); return; } + + // 1. Scan β€” identify user and mark session as scanned + const scanRes = await fetch(`/api/qr/sessions/${sessionId}/scan`, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${deviceToken}`, + }, + body: JSON.stringify({ timestamp, sig }), + }); + + if (!scanRes.ok) { + const err = await scanRes.json().catch(() => ({})); + setError(err.detail || "QR code is invalid or has expired. Please ask for a new one."); + return; + } + + // 2. Identify who this device belongs to + const meRes = await fetch("/api/devices/me", { + credentials: "include", + headers: { "Authorization": `Bearer ${deviceToken}` }, + }); + + if (!meRes.ok) { setError("Could not identify device user."); return; } + const user = await meRes.json(); + + // 3. Show approve/deny UI + const initials = user.full_name.split(" ").map(w => w[0]).join("").toUpperCase().slice(0, 2); + document.getElementById("avatar").textContent = initials; + document.getElementById("userName").textContent = user.full_name; + document.getElementById("userEmail").textContent = user.email; + show("approve"); + + document.getElementById("btnApprove").addEventListener("click", () => approve(deviceToken)); + document.getElementById("btnDeny").addEventListener("click", () => deny(deviceToken)); +} + +async function approve(deviceToken) { + document.getElementById("btnApprove").disabled = true; + document.getElementById("btnDeny").disabled = true; + + const res = await fetch(`/api/qr/sessions/${sessionId}/approve`, { + method: "POST", + credentials: "include", + headers: { "Authorization": `Bearer ${deviceToken}` }, + }); + + if (res.ok) { + show("success"); + } else { + const err = await res.json().catch(() => ({})); + setError(err.detail || "Approval failed. Please try again."); + } +} + +async function deny(deviceToken) { + await fetch(`/api/qr/sessions/${sessionId}/deny`, { + method: "POST", + credentials: "include", + headers: { "Authorization": `Bearer ${deviceToken}` }, + }); + show("denied"); +} diff --git a/frontend/signup.html b/frontend/signup.html index 03bd8a8..e748237 100644 --- a/frontend/signup.html +++ b/frontend/signup.html @@ -1,227 +1,279 @@ + + + + + Create account Β· Cipher + + - - -
- -
-
- -
- -
-
- -
-
-
-shield_lock -
-

CyberGuard

-
- - -
- -
-
- -
-
-person_add -
-

INITIALIZE ACCESS

-

Create your operative identity to proceed.

-
- -
- -
- -
- -
- -
- -
- -
- -
- -
- -
-
- -
-
-
-
-
-
- -
-
- -
- -
- - - -
-

- Existing operative? - - Log In - north_east - -

-
- - - -
- -
-
-
- -
-
-
- - - System Status: Normal - - -
- -
-
- - + + + + + + +
+ + +
+

Create account

+

Set up your credentials to get started

+
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+ + + + +
+
+ + +
+ + + + + diff --git a/frontend/signup.js b/frontend/signup.js index 74ca3e7..71a4653 100644 --- a/frontend/signup.js +++ b/frontend/signup.js @@ -1,32 +1,43 @@ -document.getElementById("signupBtn").addEventListener("click", async () => { - console.log("Signup button clicked"); // πŸ” DEBUG +import { api, showError } from "/static/util.js"; - const fullName = document.getElementById("fullName").value; - const email = document.getElementById("email").value; - const accessKey = document.getElementById("accessKey").value; - const verifyKey = document.getElementById("verifyKey").value; +const form = document.getElementById("signupForm"); +const submit = document.getElementById("submit"); +const errorBox = document.getElementById("error"); - console.log({ fullName, email, accessKey, verifyKey }); // πŸ” DEBUG +form.addEventListener("submit", async (e) => { + e.preventDefault(); + errorBox.classList.add("hidden"); - const res = await fetch("/signup", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - full_name: fullName, - email: email, - access_key: accessKey, - verify_key: verifyKey - }) - }); + const full_name = document.getElementById("fullName").value.trim(); + const email = document.getElementById("email").value.trim(); + const password = document.getElementById("password").value; + const confirm = document.getElementById("confirm").value; - const data = await res.json(); - console.log("Signup response:", data); // πŸ” DEBUG - - if (!res.ok) { - alert(data.detail || "Signup failed"); + if (password !== confirm) { + showError(errorBox, "Passwords do not match"); return; } - // βœ… AFTER SIGNUP β†’ LOGIN β†’ FINGERPRINT β†’ QR - window.location.href = "/login"; + submit.disabled = true; + try { + const data = await api("/api/auth/signup", { + method: "POST", + body: JSON.stringify({ full_name, email, password }), + }); + if (data.email_verification_required) { + document.querySelector(".heading h1").textContent = "Check your email"; + document.querySelector(".heading p").textContent = `Sent a link to ${email}`; + document.querySelector(".card").innerHTML = ` +

+ Click the verification link in your inbox to activate your account.
+ It expires in 24 hours.

+ Once verified, sign in here. +

`; + } else { + window.location.href = "/login"; + } + } catch (err) { + showError(errorBox, err.message); + submit.disabled = false; + } }); diff --git a/frontend/totp.html b/frontend/totp.html new file mode 100644 index 0000000..d7b9790 --- /dev/null +++ b/frontend/totp.html @@ -0,0 +1,373 @@ + + + + + + Two-factor Β· Cipher + + + + + + + + + +
+ + + + + + + +
+ + + + + + + diff --git a/frontend/totp.js b/frontend/totp.js new file mode 100644 index 0000000..fe11dfe --- /dev/null +++ b/frontend/totp.js @@ -0,0 +1,73 @@ +import { api, showError } from "/static/util.js"; + +const params = new URLSearchParams(window.location.search); +const mode = params.get("mode") === "verify" ? "verify" : "setup"; + +if (mode === "setup") { + document.getElementById("setupView").classList.remove("hidden"); + bootSetup(); +} else { + document.getElementById("verifyView").classList.remove("hidden"); + bootVerify(); +} + +async function bootSetup() { + const errorBox = document.getElementById("setupError"); + const submit = document.getElementById("setupSubmit"); + const codeInput = document.getElementById("setupCode"); + + try { + const data = await api("/api/2fa/setup", { method: "POST" }); + document.getElementById("secretText").textContent = data.secret; + document.getElementById("qrCanvas").src = data.qr_code_data_url; + } catch (err) { + showError(errorBox, err.message); + submit.disabled = true; + return; + } + + submit.addEventListener("click", async () => { + errorBox.classList.add("hidden"); + submit.disabled = true; + try { + await api("/api/2fa/enable", { + method: "POST", + body: JSON.stringify({ code: codeInput.value }), + }); + window.location.href = "/home"; + } catch (err) { + showError(errorBox, err.message); + submit.disabled = false; + } + }); +} + +function bootVerify() { + const form = document.getElementById("verifyForm"); + const submit = document.getElementById("verifySubmit"); + const errorBox = document.getElementById("verifyError"); + const codeInput = document.getElementById("verifyCode"); + + const userId = Number(sessionStorage.getItem("pending_user_id") || 0); + if (!userId) { + window.location.href = "/login"; + return; + } + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + errorBox.classList.add("hidden"); + submit.disabled = true; + try { + await api("/api/auth/login/2fa", { + method: "POST", + body: JSON.stringify({ user_id: userId, code: codeInput.value }), + }); + sessionStorage.removeItem("pending_user_id"); + window.location.href = "/home"; + } catch (err) { + showError(errorBox, err.message); + submit.disabled = false; + } + }); +} diff --git a/frontend/util.js b/frontend/util.js new file mode 100644 index 0000000..7a351b4 --- /dev/null +++ b/frontend/util.js @@ -0,0 +1,22 @@ +export async function api(path, options = {}) { + const res = await fetch(path, { + credentials: "include", + headers: { "Content-Type": "application/json", ...(options.headers || {}) }, + ...options, + }); + if (!res.ok) { + let detail = res.statusText; + try { + const body = await res.json(); + if (body && body.detail) detail = body.detail; + } catch {} + throw new Error(typeof detail === "string" ? detail : "Request failed"); + } + if (res.status === 204) return null; + return res.json(); +} + +export function showError(node, message) { + node.textContent = message; + node.classList.remove("hidden"); +} diff --git a/frontend/verify-email.html b/frontend/verify-email.html new file mode 100644 index 0000000..51e0daa --- /dev/null +++ b/frontend/verify-email.html @@ -0,0 +1,176 @@ + + + + + + Verify email Β· Cipher + + + + + + + + + +
+ + +
+

Verifying…

+

Please wait a moment

+
+ +
+ +

Checking your verification link…

+ +
+ + +
+ + + + diff --git a/frontend/verify-email.js b/frontend/verify-email.js new file mode 100644 index 0000000..6d84f6e --- /dev/null +++ b/frontend/verify-email.js @@ -0,0 +1,45 @@ +const heading = document.getElementById("heading"); +const subheading = document.getElementById("subheading"); +const icon = document.getElementById("icon"); +const msg = document.getElementById("msg"); +const action = document.getElementById("action"); +const footer = document.getElementById("footer"); + +const token = new URLSearchParams(window.location.search).get("token"); + +if (!token) { + setError("No verification token found in the link.", "Get a new one"); + action.href = "/signup"; +} else { + fetch(`/api/auth/verify-email?token=${encodeURIComponent(token)}`, { credentials: "include" }) + .then(async (res) => { + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.detail || "Verification failed"); + } + setSuccess(); + }) + .catch((err) => setError(err.message)); +} + +function setSuccess() { + heading.textContent = "Email verified"; + subheading.textContent = "Your account is now active"; + icon.textContent = "βœ“"; + icon.classList.remove("loading"); + icon.style.color = "#e0c07a"; + msg.textContent = "You can now sign in to your Cipher account."; + action.classList.remove("hidden"); +} + +function setError(message, linkText = "Sign in") { + heading.textContent = "Link expired"; + subheading.textContent = "This verification link is no longer valid"; + icon.textContent = "βœ•"; + icon.classList.remove("loading"); + icon.style.color = "#f4a8a0"; + msg.textContent = message; + action.textContent = linkText; + action.classList.remove("hidden"); + footer.classList.remove("hidden"); +} diff --git a/init_db.py b/init_db.py deleted file mode 100644 index dcd37a8..0000000 --- a/init_db.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Initialize database with fresh schema and test user""" -import os -import sys -import argparse -from backend.database import SessionLocal, engine -from backend.models import Base, User -from backend.security import hash_password - -# Parse command line arguments -parser = argparse.ArgumentParser(description="Initialize database with fresh schema and test user") -parser.add_argument("-f", "--force", action="store_true", help="Force deletion without confirmation prompt") -args = parser.parse_args() - -# Delete old database if exists (with confirmation) -db_path = "auth.db" -if os.path.exists(db_path): - # Check if running in production (prevent accidental deletion) - if os.getenv("ENVIRONMENT") == "production": - print("❌ Cannot delete database in production environment") - sys.exit(1) - - # Require confirmation for database deletion (unless --force or non-TTY) - if not args.force and sys.stdin.isatty(): - print(f"⚠️ WARNING: This will delete the existing database: {db_path}") - confirmation = input("Type 'DELETE' to confirm: ").strip() - - if confirmation != "DELETE": - print("❌ Database deletion cancelled") - sys.exit(0) - elif not args.force: - # Non-interactive environment without --force flag - print("❌ Use --force flag to delete database in non-interactive mode") - sys.exit(1) - - os.remove(db_path) - print(f"βœ… Removed old database: {db_path}") - -# Create all tables with new schema -print("πŸ“Š Creating database tables...") -Base.metadata.create_all(bind=engine) -print("βœ… Database tables created successfully!") - -# Create test user -print("\nπŸ‘€ Creating test user...") -db = SessionLocal() - -try: - test_user = User( - full_name="Test User", - email="test@example.com", - password_hash=hash_password("password123") - ) - - db.add(test_user) - db.commit() - db.refresh(test_user) - - print("βœ… Test user created successfully!") - print("\n" + "="*50) - print("LOGIN CREDENTIALS") - print("="*50) - print(f" URL: http://127.0.0.1:8000") - print(f" Email: test@example.com") - print(f" Password: password123") - print("="*50) - -except Exception as e: - print(f"❌ Error: {e}") - db.rollback() -finally: - db.close() - -print("\nβœ… Database initialization complete!\n") diff --git a/migrate_session_to_user_id.py b/migrate_session_to_user_id.py deleted file mode 100644 index f65f8a3..0000000 --- a/migrate_session_to_user_id.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -Migration script: Convert Session.email FK to Session.user_id FK - -This script migrates the sessions table from using email as a foreign key -to using user_id (integer) for better referential integrity. -""" -import sqlite3 -import sys - -def migrate_database(): - """Migrate sessions table from email FK to user_id FK""" - db_path = "auth.db" - - try: - # Use context manager for automatic connection handling - with sqlite3.connect(db_path) as conn: - cursor = conn.cursor() - - print("πŸ”„ Starting migration: Session.email β†’ Session.user_id") - - # Check if migration is needed - cursor.execute("PRAGMA table_info(sessions)") - columns = {col[1]: col for col in cursor.fetchall()} - - if "user_id" in columns: - print("βœ… Migration already applied (user_id column exists)") - return - - if "email" not in columns: - print("❌ Unexpected schema: sessions table missing email column") - sys.exit(1) - - # Check for orphaned sessions (sessions with no matching user) - print("πŸ” Checking for orphaned sessions...") - cursor.execute(""" - SELECT COUNT(*) - FROM sessions s - LEFT JOIN users u ON s.email = u.email - WHERE u.id IS NULL - """) - orphaned_count = cursor.fetchone()[0] - - if orphaned_count > 0: - print(f"⚠️ WARNING: Found {orphaned_count} orphaned session(s) with no matching user") - print(" These sessions will be DROPPED during migration.") - response = input(" Continue anyway? (yes/no): ").strip().lower() - - if response != "yes": - print("❌ Migration aborted") - sys.exit(0) - else: - print("βœ… No orphaned sessions found") - - # Step 1: Create new sessions table with user_id - print("πŸ“Š Creating new sessions table with user_id...") - cursor.execute(""" - CREATE TABLE sessions_new ( - session_id TEXT PRIMARY KEY, - user_id INTEGER NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - expires_at TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id) - ) - """) - - # Step 2: Copy data from old table, mapping email to user_id - print("πŸ“‹ Migrating existing session data...") - cursor.execute(""" - INSERT INTO sessions_new (session_id, user_id, created_at, expires_at) - SELECT - s.session_id, - u.id, - s.created_at, - s.expires_at - FROM sessions s - INNER JOIN users u ON s.email = u.email - """) - - migrated_count = cursor.rowcount - print(f"βœ… Migrated {migrated_count} session(s)") - - # Step 3: Drop old table and rename new table - print("πŸ”„ Replacing old sessions table...") - cursor.execute("DROP TABLE sessions") - cursor.execute("ALTER TABLE sessions_new RENAME TO sessions") - - # Step 4: Create index on user_id for performance - print("πŸ“Œ Creating index on user_id...") - cursor.execute("CREATE INDEX idx_sessions_user_id ON sessions(user_id)") - - # Commit changes (context manager auto-commits on success) - conn.commit() - print("βœ… Migration completed successfully!") - - # Verify migration - cursor.execute("SELECT COUNT(*) FROM sessions") - final_count = cursor.fetchone()[0] - print(f"πŸ“Š Final session count: {final_count}") - - except sqlite3.Error as e: - print(f"❌ Database error: {e}") - print(" Transaction has been rolled back automatically") - sys.exit(1) - except Exception as e: - print(f"❌ Migration failed: {e}") - print(" Transaction has been rolled back automatically") - sys.exit(1) - -if __name__ == "__main__": - print("="*60) - print(" Session Table Migration: email β†’ user_id") - print("="*60) - print() - - response = input("This will modify the sessions table. Continue? (yes/no): ").strip().lower() - - if response == "yes": - migrate_database() - else: - print("❌ Migration cancelled") - sys.exit(0) diff --git a/requirements.txt b/requirements.txt index 0f9336106ab043407ba8b073eed30c84c28ec244..279106609322a89fb2167cdbe0dee34114010d86 100644 GIT binary patch literal 317 zcmX|-y>7%H5QKX_#Yw4+4%@k(f+BsIDCw*e7>G@TSppj;ynS{pIUSlE=9}FQT~H^l z)LBURC@#(S<_+XHk{qqh_MJ!lAkIyyR!8(ksZPjV4F5t~9HNhN;mMVpZq+`tn-1P; zgv@eB^6Sg@Sw#F0|Sol&ZRG>?;yI#*)%f=QFfHK9@iLXdbJl4&?A#PEG2>3uuWZ0NrLKa0HzmN&v9GuvQ5Mj}ja$$en&#p3<_ex5hRP0l RD|Oq8EQslsFVfw}_y=}jVzvMP literal 1510 zcmaKsOK;Oq5QS%r#DB0r#u!cDjovFqNSC(tkN_MhTtT&n>PLNz;|YRU7MF8 zC$W9z%$YMY_vep2%j~tStg<=(-r3Y@YwXC5?F08$JY92@{;#x|72MVK*dEz+i=Bcf z>=mB_duk&a+jD*o9CKq^-p!o9>@YbPA;q$`9r?b1C~e=q+l?)`FL*vhQ`5zWd)&q- zPP*TKX}B)AYINy&Z5tS-Tr*hbFz%c~8O4#?Gsh*4Q!q2KR^)8JX7+^Zh$m8|%B5!J znF?1Ks+F-OOF?$kK7w0yPn`Xdhzhnf(RzE>W)D|}HGhRw>V?a}1y)&(Vfbk0cINNG zv+y`H?1*g0p-8=b=~?tnbx+Z@#M7<2QP4rTrA{ZjvV|hW>`}Ke3YV(R=&~X-3E_42 z8(-g(VQk7WYQ0IC`}`O96tkDmxqC1k^ytJsqd-%tdCY9#eVL<6njREvcp^Tb#P@>w zf=Ls)C{e-oPitp6>QYho*qB_2lqqh?KrW^KvS*nnXluZU?pV%z43i5oino^GDu zbB0n)Ps)&d7K1jhApgQ$&}MF!$9P^lx4Io$ly^?aje9gyzB|LI4-NlkI&8Dm@W{Oh zIW$qRr!|W1y1GWa9j{SZFelf1uR4UhjmT3wu}^H&zczBx?o4cM?0wn?O}q9Xs+2D} z71V*oy_U|v=sT*^7OUU#Z{L4w?zQDnul 2>&1 -if errorlevel 1 ( - echo Installing dependencies... - pip install -r requirements.txt - echo. -) +pip install -q -r requirements.txt -REM Initialize database if it doesn't exist -if not exist "auth.db" ( - echo Initializing database... - python init_db.py - echo. +if not exist ".env" ( + copy /Y .env.example .env >nul + echo Wrote .env from .env.example. Edit SECRET_KEY before running in production. ) -echo ======================================== -echo Starting FastAPI Server... -echo ======================================== -echo. -echo Server will be available at: -echo - http://127.0.0.1:8000 -echo - http://localhost:8000 -echo. -echo API Documentation: -echo - http://127.0.0.1:8000/docs -echo. -echo Test Credentials: -echo Email: test@example.com -echo Password: password123 -echo. -echo Press Ctrl+C to stop the server -echo ======================================== -echo. - -uvicorn backend.main:app --reload --host 127.0.0.1 --port 8000 +alembic upgrade head +uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000 diff --git a/start.sh b/start.sh index 0fe9f1d..07d982a 100644 --- a/start.sh +++ b/start.sh @@ -1,54 +1,19 @@ -#!/bin/bash +#!/usr/bin/env bash +set -euo pipefail -echo "========================================" -echo " FastAPI Auth System - Quick Start" -echo "========================================" -echo "" - -# Check if virtual environment exists -if [ ! -d "venv" ]; then - echo "Creating virtual environment..." - python3 -m venv venv - echo "" +if [ ! -d ".venv" ]; then + python3 -m venv .venv fi +# shellcheck source=/dev/null +. .venv/bin/activate -# Activate virtual environment -echo "Activating virtual environment..." -source venv/bin/activate -echo "" - -# Check if dependencies are installed -echo "Checking dependencies..." -if ! pip show fastapi > /dev/null 2>&1; then - echo "Installing dependencies..." - pip install -r requirements.txt - echo "" -fi +pip install -q -r requirements.txt -# Initialize database if it doesn't exist -if [ ! -f "auth.db" ]; then - echo "Initializing database..." - python init_db.py - echo "" +if [ ! -f ".env" ]; then + cp .env.example .env + echo "Wrote .env from .env.example. Edit SECRET_KEY before running in production." fi -echo "========================================" -echo " Starting FastAPI Server..." -echo "========================================" -echo "" -echo "Server will be available at:" -echo " - http://127.0.0.1:8000" -echo " - http://localhost:8000" -echo "" -echo "API Documentation:" -echo " - http://127.0.0.1:8000/docs" -echo "" -echo "Test Credentials:" -echo " Email: test@example.com" -echo " Password: password123" -echo "" -echo "Press Ctrl+C to stop the server" -echo "========================================" -echo "" +alembic upgrade head -uvicorn backend.main:app --reload --host 127.0.0.1 --port 8000 +exec uvicorn backend.main:app --reload --host 127.0.0.1 --port 8000 diff --git a/test_complete.py b/test_complete.py deleted file mode 100644 index 5a99a07..0000000 --- a/test_complete.py +++ /dev/null @@ -1,318 +0,0 @@ -""" -Complete End-to-End Testing Suite -Tests all API endpoints and functionality -""" -import requests -import json -import time - -BASE_URL = "http://127.0.0.1:8000" - -def test_health(): - """Test health endpoint""" - print("\n" + "="*60) - print("TEST 1: Health Check") - print("="*60) - try: - response = requests.get(f"{BASE_URL}/health", timeout=5) - print(f"βœ… Status: {response.status_code}") - print(f"βœ… Response: {response.json()}") - return True - except Exception as e: - print(f"❌ Failed: {e}") - return False - -def test_signup(): - """Test signup endpoint""" - print("\n" + "="*60) - print("TEST 2: User Signup") - print("="*60) - - data = { - "full_name": "New Test User", - "email": "newuser@example.com", - "access_key": "newpassword123", - "verify_key": "newpassword123" - } - - try: - response = requests.post(f"{BASE_URL}/signup", json=data, timeout=5) - print(f"Status: {response.status_code}") - result = response.json() - print(f"Response: {json.dumps(result, indent=2)}") - - if response.status_code == 200: - print("βœ… Signup successful") - return True - elif response.status_code == 400 and "already registered" in result.get("detail", ""): - print("βœ… User already exists (expected)") - return True - else: - print(f"❌ Unexpected response") - return False - except Exception as e: - print(f"❌ Failed: {e}") - return False - -def test_login(): - """Test login endpoint""" - print("\n" + "="*60) - print("TEST 3: User Login") - print("="*60) - - data = { - "email": "test@example.com", - "access_key": "password123" - } - - try: - response = requests.post(f"{BASE_URL}/login", json=data, timeout=5) - print(f"Status: {response.status_code}") - result = response.json() - print(f"Response: {json.dumps(result, indent=2)}") - - if response.status_code == 200: - if "access_token" in result: - print("βœ… Login successful with JWT token") - return result["access_token"] - else: - print("❌ No access token in response") - return None - else: - print(f"❌ Login failed") - return None - except Exception as e: - print(f"❌ Failed: {e}") - return None - -def test_login_invalid(): - """Test login with invalid credentials""" - print("\n" + "="*60) - print("TEST 4: Invalid Login (Should Fail)") - print("="*60) - - data = { - "email": "test@example.com", - "access_key": "wrongpassword" - } - - try: - response = requests.post(f"{BASE_URL}/login", json=data, timeout=5) - print(f"Status: {response.status_code}") - result = response.json() - print(f"Response: {json.dumps(result, indent=2)}") - - if response.status_code == 401: - print("βœ… Correctly rejected invalid credentials") - return True - else: - print(f"❌ Should have returned 401") - return False - except Exception as e: - print(f"❌ Failed: {e}") - return False - -def test_fingerprint(access_token=None): - """Test fingerprint endpoint""" - print("\n" + "="*60) - print("TEST 5: Fingerprint/Session Creation") - print("="*60) - - data = { - "email": "test@example.com" - } - - headers = {} - if access_token: - headers["Authorization"] = f"Bearer {access_token}" - - try: - response = requests.post(f"{BASE_URL}/fingerprint", json=data, headers=headers, timeout=5) - print(f"Status: {response.status_code}") - result = response.json() - print(f"Response: {json.dumps(result, indent=2)}") - - if response.status_code == 200 and "session_id" in result: - print("βœ… Session created successfully") - return result["session_id"] - else: - print(f"❌ Session creation failed") - return None - except Exception as e: - print(f"❌ Failed: {e}") - return None - -def test_qr(session_id): - """Test QR code endpoint""" - print("\n" + "="*60) - print("TEST 6: QR Code Generation") - print("="*60) - - try: - response = requests.get(f"{BASE_URL}/qr/{session_id}", timeout=5) - print(f"Status: {response.status_code}") - result = response.json() - print(f"Response: {json.dumps(result, indent=2)}") - - if response.status_code == 200: - print("βœ… QR code metadata retrieved") - return True - else: - print(f"❌ QR generation failed") - return False - except Exception as e: - print(f"❌ Failed: {e}") - return False - -def test_qr_image(session_id): - """Test QR image endpoint""" - print("\n" + "="*60) - print("TEST 7: QR Image Serving") - print("="*60) - - try: - response = requests.get(f"{BASE_URL}/qr-image/{session_id}", timeout=5) - print(f"Status: {response.status_code}") - print(f"Content-Type: {response.headers.get('content-type')}") - print(f"Content Length: {len(response.content)} bytes") - - if response.status_code == 200 and "image/png" in response.headers.get('content-type', ''): - print("βœ… QR image served successfully") - return True - else: - print(f"❌ QR image serving failed") - return False - except Exception as e: - print(f"❌ Failed: {e}") - return False - -def test_uuid_validation(): - """Test UUID validation (security)""" - print("\n" + "="*60) - print("TEST 8: Path Traversal Protection") - print("="*60) - - # Test multiple malicious patterns - malicious_patterns = [ - "../../../etc/passwd", - "..\\..\\..\\windows\\system32", - "../../config", - "invalid-uuid-123" - ] - - all_blocked = True - for pattern in malicious_patterns: - try: - response = requests.get(f"{BASE_URL}/qr-image/{pattern}", timeout=5) - - # Check status code first before attempting JSON parsing - if response.status_code == 400: - try: - result = response.json() - if "Invalid session ID" in result.get("detail", ""): - print(f" βœ… Blocked: {pattern[:30]}...") - else: - print(f" ⚠️ Unexpected 400 response for {pattern[:30]}...") - except: - print(f" βœ… Blocked (400): {pattern[:30]}...") - elif response.status_code == 404: - print(f" βœ… Safe (404): {pattern[:30]}... - Not found in routing") - else: - print(f" ❌ DANGER: {pattern} returned {response.status_code}") - all_blocked = False - except Exception as e: - print(f" ❌ Error testing {pattern}: {e}") - all_blocked = False - - if all_blocked: - print("βœ… All path traversal attempts blocked") - - return all_blocked - -def test_frontend_pages(): - """Test frontend HTML pages""" - print("\n" + "="*60) - print("TEST 9: Frontend Pages") - print("="*60) - - pages = ["/", "/login", "/signup-page", "/home"] - success = True - - for page in pages: - try: - response = requests.get(f"{BASE_URL}{page}", timeout=5, allow_redirects=True) - status = "βœ…" if response.status_code == 200 else "❌" - print(f"{status} {page}: {response.status_code}") - if response.status_code != 200: - success = False - except Exception as e: - print(f"❌ {page}: {e}") - success = False - - return success - -def main(): - """Run all tests""" - print("\n" + "="*80) - print(" "*20 + "COMPLETE END-TO-END TEST SUITE") - print("="*80) - - # Wait a moment for server to be ready - print("\n⏳ Waiting for server to be ready...") - time.sleep(2) - - results = {} - - # Run tests in sequence - results["health"] = test_health() - results["frontend"] = test_frontend_pages() - results["signup"] = test_signup() - - token = test_login() - results["login"] = token is not None - - results["login_invalid"] = test_login_invalid() - - session_id = test_fingerprint(token) - results["fingerprint"] = session_id is not None - - if session_id: - results["qr"] = test_qr(session_id) - results["qr_image"] = test_qr_image(session_id) - else: - results["qr"] = False - results["qr_image"] = False - - results["uuid_security"] = test_uuid_validation() - - # Summary - print("\n" + "="*80) - print(" "*25 + "TEST SUMMARY") - print("="*80) - - passed = sum(1 for v in results.values() if v) - total = len(results) - - for test_name, result in results.items(): - status = "βœ… PASS" if result else "❌ FAIL" - print(f" {status} {test_name.upper().replace('_', ' ')}") - - print("\n" + "-"*80) - print(f"\n Total: {passed}/{total} tests passed ({(passed/total)*100:.1f}%)") - - if passed == total: - print("\n πŸŽ‰ ALL TESTS PASSED! System is fully functional!") - print("\n βœ… Database: Working") - print(" βœ… Authentication: Working") - print(" βœ… Security: Working") - print(" βœ… API Endpoints: Working") - print(" βœ… Frontend: Working") - print("\n πŸš€ Application is ready for use!") - return 0 - else: - print(f"\n ⚠️ {total - passed} test(s) failed") - return 1 - -if __name__ == "__main__": - import sys - sys.exit(main()) diff --git a/test_database.py b/test_database.py deleted file mode 100644 index 323f013..0000000 --- a/test_database.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -Database Connection Test & User Management Script -This script verifies the database connection and allows you to: -1. Check existing users -2. Create a test user -3. Verify database structure -""" - -from backend.database import SessionLocal, engine, Base -from backend.models import User -from backend.security import hash_password - -def check_database(): - """Check if database tables exist and display structure""" - print("=" * 60) - print("DATABASE CONNECTION TEST") - print("=" * 60) - - # Create tables if they don't exist - print("\nπŸ“Š Creating/Verifying database tables...") - Base.metadata.create_all(bind=engine) - print("βœ… Database tables created/verified successfully!") - - # Get table info - print(f"\nπŸ“‹ Database URL: sqlite:///./test_v2.db") - print(f"πŸ“‹ Tables: {list(Base.metadata.tables.keys())}") - - return True - -def list_users(): - """List all users in the database""" - db = SessionLocal() - try: - users = db.query(User).all() - print(f"\nπŸ‘₯ Total Users in Database: {len(users)}") - print("-" * 60) - - if users: - for user in users: - print(f"ID: {user.id}") - print(f"Username: {user.username}") - print(f"Email: {user.email}") - print(f"MFA Enabled: {user.mfa_enabled}") - print("-" * 60) - else: - print("⚠️ No users found in database!") - - return users - finally: - db.close() - -def create_test_user(): - """Create a test user for testing the application""" - db = SessionLocal() - try: - # Check if test user already exists - existing = db.query(User).filter( - (User.username == "testuser") | (User.email == "test@example.com") - ).first() - - if existing: - print("\n⚠️ Test user already exists!") - return existing - - # Create new test user - test_user = User( - username="testuser", - email="test@example.com", - password=hash_password("password123"), - mfa_enabled=False - ) - - db.add(test_user) - db.commit() - db.refresh(test_user) - - print("\n✨ Test User Created Successfully!") - print(f" Username: testuser") - print(f" Password: password123") - print(f" Email: test@example.com") - - return test_user - except Exception as e: - print(f"\n❌ Error creating test user: {e}") - db.rollback() - return None - finally: - db.close() - -def main(): - """Main function to run database tests""" - print("\nπŸš€ Starting Database Connection Test...\n") - - # Check database connection - if check_database(): - print("\nβœ… Database connection successful!") - - # List existing users - users = list_users() - - # Offer to create test user if none exist - if len(users) == 0: - print("\nπŸ’‘ Would you like to create a test user?") - response = input("Create test user? (y/n): ").lower().strip() - if response == 'y': - create_test_user() - print("\nπŸ“‹ Updated user list:") - list_users() - - print("\n" + "=" * 60) - print("βœ… DATABASE TEST COMPLETE!") - print("=" * 60) - print("\nπŸ’‘ You can now:") - print(" 1. Go to http://127.0.0.1:8000") - print(" 2. Login with username: testuser") - print(" 3. Password: password123") - print("=" * 60 + "\n") - -if __name__ == "__main__": - main() diff --git a/test_login.py b/test_login.py deleted file mode 100644 index d65d021..0000000 --- a/test_login.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Test login via API endpoint""" -import requests -import json - -url_login = "http://127.0.0.1:8000/login" - -data = { - "username": "testuser", - "password": "password123" -} - -print("πŸš€ Testing login endpoint...") -print(f"URL: {url_login}") -print(f"Credentials: username={data['username']}, password={data['password']}") - -try: - response = requests.post(url_login, json=data) - print(f"\nStatus Code: {response.status_code}") - print(f"Response: {json.dumps(response.json(), indent=2)}") - - if response.status_code == 200: - result = response.json() - if 'access_token' in result: - print("\nβœ… Login successful!") - print(f"Access Token: {result['access_token'][:50]}...") - elif 'mfa_required' in result: - print("\nβœ… MFA required!") - print(f"User ID: {result['user_id']}") - else: - print(f"\n❌ Login failed: {response.json()}") - -except Exception as e: - print(f"\n❌ Error: {e}") diff --git a/test_signup.py b/test_signup.py deleted file mode 100644 index 2e8d4dc..0000000 --- a/test_signup.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Test signup via API endpoint""" -import requests -import json - -url = "http://127.0.0.1:8000/register" - -data = { - "username": "testuser", - "email": "test@example.com", - "password": "password123" -} - -print("πŸš€ Testing signup endpoint...") -print(f"URL: {url}") -print(f"Data: {data}") - -try: - response = requests.post(url, json=data) - print(f"\nStatus Code: {response.status_code}") - print(f"Response: {response.json()}") - - if response.status_code == 200: - print("\nβœ… User created successfully via API!") - print("\nLogin credentials:") - print(" Username: testuser") - print(" Password: password123") - else: - print(f"\n⚠️ Failed: {response.json()}") - -except Exception as e: - print(f"\n❌ Error: {e}") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c0d7de8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,28 @@ +import os + +os.environ.setdefault("ENVIRONMENT", "test") +os.environ.setdefault("SECRET_KEY", "test-secret-key-must-be-at-least-32-chars-long") +os.environ.setdefault("DATABASE_URL", "sqlite:///./test-auth.db") +os.environ.setdefault("EMAIL_ENABLED", "false") +# Effectively disable rate limits during tests. +os.environ.setdefault("LOGIN_RATE_LIMIT", "1000/minute") +os.environ.setdefault("SIGNUP_RATE_LIMIT", "1000/minute") + +import pytest +from fastapi.testclient import TestClient + +from backend.database import Base, engine +from backend.main import app + + +@pytest.fixture(autouse=True) +def _reset_db(): + Base.metadata.drop_all(bind=engine) + Base.metadata.create_all(bind=engine) + yield + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..4d728e7 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,108 @@ +def signup(client, email="alice@example.com", password="correct-horse-battery"): + return client.post( + "/api/auth/signup", + json={"full_name": "Alice", "email": email, "password": password}, + ) + + +def test_signup_creates_account(client): + r = signup(client) + assert r.status_code == 201 + body = r.json() + assert body["email_verification_required"] is False # EMAIL_ENABLED=false + + +def test_signup_rejects_duplicate_email(client): + signup(client) + r = signup(client) + assert r.status_code == 409 + + +def test_signup_validates_password_length(client): + r = client.post( + "/api/auth/signup", + json={"full_name": "Alice", "email": "a@b.com", "password": "short"}, + ) + assert r.status_code == 422 + + +def test_login_sets_cookies(client): + signup(client) + r = client.post( + "/api/auth/login", + json={"email": "alice@example.com", "password": "correct-horse-battery"}, + ) + assert r.status_code == 200 + assert r.json()["requires_2fa"] is False + assert "access_token" in r.cookies + assert "refresh_token" in r.cookies + + +def test_login_wrong_password(client): + signup(client) + r = client.post( + "/api/auth/login", + json={"email": "alice@example.com", "password": "wrong"}, + ) + assert r.status_code == 401 + + +def test_me_requires_auth(client): + r = client.get("/api/auth/me") + assert r.status_code == 401 + + +def test_me_returns_user(client): + signup(client) + client.post( + "/api/auth/login", + json={"email": "alice@example.com", "password": "correct-horse-battery"}, + ) + r = client.get("/api/auth/me") + assert r.status_code == 200 + body = r.json() + assert body["email"] == "alice@example.com" + assert body["totp_enabled"] is False + + +def test_logout_clears_cookies(client): + signup(client) + client.post( + "/api/auth/login", + json={"email": "alice@example.com", "password": "correct-horse-battery"}, + ) + r = client.post("/api/auth/logout") + assert r.status_code == 200 + me = client.get("/api/auth/me") + assert me.status_code == 401 + + +def test_refresh_rotates_tokens(client): + signup(client) + login = client.post( + "/api/auth/login", + json={"email": "alice@example.com", "password": "correct-horse-battery"}, + ) + old_refresh = login.cookies.get("refresh_token") + r = client.post("/api/auth/refresh") + assert r.status_code == 200 + assert client.cookies.get("refresh_token") != old_refresh + + # Old refresh token must no longer work. + client.cookies.set("refresh_token", old_refresh) + bad = client.post("/api/auth/refresh") + assert bad.status_code == 401 + + +def test_account_lockout_after_failed_attempts(client): + signup(client) + for _ in range(5): + client.post( + "/api/auth/login", + json={"email": "alice@example.com", "password": "wrong"}, + ) + r = client.post( + "/api/auth/login", + json={"email": "alice@example.com", "password": "correct-horse-battery"}, + ) + assert r.status_code == 423 diff --git a/tests/test_email_verification.py b/tests/test_email_verification.py new file mode 100644 index 0000000..824787f --- /dev/null +++ b/tests/test_email_verification.py @@ -0,0 +1,91 @@ +from datetime import datetime, timedelta, timezone + +from backend.database import SessionLocal +from backend.models import User +from backend.security import hash_refresh_token + + +def _signup(client, email="alice@example.com", password="correct-horse-battery"): + return client.post( + "/api/auth/signup", + json={"full_name": "Alice", "email": email, "password": password}, + ) + + +def _login(client, email="alice@example.com", password="correct-horse-battery"): + return client.post("/api/auth/login", json={"email": email, "password": password}) + + +def _set_unverified(email: str, token_raw: str) -> None: + db = SessionLocal() + try: + user = db.query(User).filter(User.email == email).first() + user.email_verified = False + user.verification_token_hash = hash_refresh_token(token_raw) + user.verification_token_expires = datetime.now(timezone.utc) + timedelta(hours=24) + db.commit() + finally: + db.close() + + +def test_signup_auto_verifies_with_email_disabled(client): + r = _signup(client) + assert r.status_code == 201 + assert r.json()["email_verification_required"] is False + + +def test_auto_verified_user_can_login_immediately(client): + _signup(client) + r = _login(client) + assert r.status_code == 200 + + +def test_unverified_user_cannot_login(client): + _signup(client) + _set_unverified("alice@example.com", "some-raw-token-abc") + r = _login(client) + assert r.status_code == 403 + + +def test_verify_email_valid_token(client): + _signup(client) + raw = "valid-test-token-for-verify-email" + _set_unverified("alice@example.com", raw) + + r = client.get(f"/api/auth/verify-email?token={raw}") + assert r.status_code == 200 + + login = _login(client) + assert login.status_code == 200 + + +def test_verify_email_invalid_token(client): + r = client.get("/api/auth/verify-email?token=bogus-token-xyz") + assert r.status_code == 400 + + +def test_verify_email_expired_token(client): + _signup(client) + raw = "expired-token-abc" + db = SessionLocal() + try: + user = db.query(User).filter(User.email == "alice@example.com").first() + user.email_verified = False + user.verification_token_hash = hash_refresh_token(raw) + user.verification_token_expires = datetime.now(timezone.utc) - timedelta(seconds=1) + db.commit() + finally: + db.close() + + r = client.get(f"/api/auth/verify-email?token={raw}") + assert r.status_code == 400 + + +def test_verify_email_token_consumed_after_use(client): + _signup(client) + raw = "single-use-token-abc" + _set_unverified("alice@example.com", raw) + + client.get(f"/api/auth/verify-email?token={raw}") + r = client.get(f"/api/auth/verify-email?token={raw}") + assert r.status_code == 400 diff --git a/tests/test_password_reset.py b/tests/test_password_reset.py new file mode 100644 index 0000000..ea2a1e3 --- /dev/null +++ b/tests/test_password_reset.py @@ -0,0 +1,110 @@ +from datetime import datetime, timedelta, timezone + +from backend.database import SessionLocal +from backend.models import User +from backend.security import hash_refresh_token + + +def _signup(client, email="alice@example.com", password="correct-horse-battery"): + client.post( + "/api/auth/signup", + json={"full_name": "Alice", "email": email, "password": password}, + ) + + +def _login(client, email="alice@example.com", password="correct-horse-battery"): + return client.post("/api/auth/login", json={"email": email, "password": password}) + + +def _plant_reset_token(email: str, raw: str, expired: bool = False) -> None: + db = SessionLocal() + try: + user = db.query(User).filter(User.email == email).first() + user.reset_token_hash = hash_refresh_token(raw) + delta = timedelta(seconds=-1) if expired else timedelta(hours=1) + user.reset_token_expires = datetime.now(timezone.utc) + delta + db.commit() + finally: + db.close() + + +def test_forgot_password_always_succeeds(client): + r = client.post("/api/auth/forgot-password", json={"email": "nobody@nowhere.com"}) + assert r.status_code == 200 + + +def test_forgot_password_returns_same_message_for_any_email(client): + _signup(client) + real = client.post("/api/auth/forgot-password", json={"email": "alice@example.com"}) + fake = client.post("/api/auth/forgot-password", json={"email": "ghost@example.com"}) + assert real.json()["message"] == fake.json()["message"] + + +def test_reset_password_valid_token(client): + _signup(client) + raw = "valid-reset-token-abc123" + _plant_reset_token("alice@example.com", raw) + + r = client.post( + "/api/auth/reset-password", + json={"token": raw, "password": "brand-new-password-456"}, + ) + assert r.status_code == 200 + + assert _login(client, password="correct-horse-battery").status_code == 401 + assert _login(client, password="brand-new-password-456").status_code == 200 + + +def test_reset_password_invalid_token(client): + r = client.post( + "/api/auth/reset-password", + json={"token": "bogus-token", "password": "newpassword123"}, + ) + assert r.status_code == 400 + + +def test_reset_password_expired_token(client): + _signup(client) + raw = "expired-reset-token" + _plant_reset_token("alice@example.com", raw, expired=True) + + r = client.post( + "/api/auth/reset-password", + json={"token": raw, "password": "newpassword123"}, + ) + assert r.status_code == 400 + + +def test_reset_password_clears_lockout(client): + _signup(client) + + for _ in range(5): + _login(client, password="wrong") + + assert _login(client).status_code == 423 + + raw = "unlock-reset-token" + _plant_reset_token("alice@example.com", raw) + client.post( + "/api/auth/reset-password", + json={"token": raw, "password": "unlocked-password-789"}, + ) + + assert _login(client, password="unlocked-password-789").status_code == 200 + + +def test_reset_token_is_single_use(client): + _signup(client) + raw = "single-use-reset-token" + _plant_reset_token("alice@example.com", raw) + + client.post( + "/api/auth/reset-password", + json={"token": raw, "password": "first-new-password-123"}, + ) + + r = client.post( + "/api/auth/reset-password", + json={"token": raw, "password": "second-new-password-456"}, + ) + assert r.status_code == 400 diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..6d787c0 --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,33 @@ +from backend.security import ( + create_access_token, + decode_token, + hash_password, + hash_refresh_token, + new_refresh_token, + verify_password, +) + + +def test_password_round_trip(): + h = hash_password("hunter2hunter2") + assert verify_password("hunter2hunter2", h) + assert not verify_password("hunter3hunter3", h) + + +def test_access_token_round_trip(): + token = create_access_token(42) + assert decode_token(token, "access") == 42 + + +def test_token_type_is_enforced(): + token = create_access_token(42) + try: + decode_token(token, "pending_2fa") + except ValueError: + return + raise AssertionError("expected ValueError on wrong token type") + + +def test_refresh_hash_deterministic(): + raw, h = new_refresh_token() + assert hash_refresh_token(raw) == h diff --git a/tests/test_twofa.py b/tests/test_twofa.py new file mode 100644 index 0000000..79d3004 --- /dev/null +++ b/tests/test_twofa.py @@ -0,0 +1,61 @@ +import pyotp + + +def _signup_and_login(client): + client.post( + "/api/auth/signup", + json={"full_name": "Alice", "email": "alice@example.com", "password": "correct-horse-battery"}, + ) + client.post( + "/api/auth/login", + json={"email": "alice@example.com", "password": "correct-horse-battery"}, + ) + + +def test_totp_setup_and_enable(client): + _signup_and_login(client) + setup = client.post("/api/2fa/setup") + assert setup.status_code == 200 + secret = setup.json()["secret"] + assert setup.json()["otpauth_url"].startswith("otpauth://totp/") + + code = pyotp.TOTP(secret).now() + enable = client.post("/api/2fa/enable", json={"code": code}) + assert enable.status_code == 200 + + me = client.get("/api/auth/me") + assert me.json()["totp_enabled"] is True + + +def test_enable_rejects_bad_code(client): + _signup_and_login(client) + client.post("/api/2fa/setup") + r = client.post("/api/2fa/enable", json={"code": "000000"}) + assert r.status_code == 401 + + +def test_login_requires_2fa_when_enabled(client): + _signup_and_login(client) + setup = client.post("/api/2fa/setup") + secret = setup.json()["secret"] + client.post("/api/2fa/enable", json={"code": pyotp.TOTP(secret).now()}) + client.post("/api/auth/logout") + + login = client.post( + "/api/auth/login", + json={"email": "alice@example.com", "password": "correct-horse-battery"}, + ) + assert login.status_code == 200 + body = login.json() + assert body["requires_2fa"] is True + user_id = body["user_id"] + + # Access cookie must NOT be set yet. + assert "access_token" not in login.cookies + + verify = client.post( + "/api/auth/login/2fa", + json={"user_id": user_id, "code": pyotp.TOTP(secret).now()}, + ) + assert verify.status_code == 200 + assert client.get("/api/auth/me").status_code == 200 diff --git a/verify_backend.py b/verify_backend.py deleted file mode 100644 index 34048c2..0000000 --- a/verify_backend.py +++ /dev/null @@ -1,23 +0,0 @@ -import requests -import time - -def verify(): - url = "http://127.0.0.1:8000/login" - payload = {"email": "test@example.com", "password": "password"} - - print("Attempting to connect to backend...") - for _ in range(5): - try: - response = requests.post(url, json=payload) - # 401 is expected because user doesn't exist, but it means server is running and endpoint works - if response.status_code in [200, 401, 404]: - print(f"Success! Server responded with status code: {response.status_code}") - return - except requests.exceptions.ConnectionError: - print("Server not ready yet, retrying...") - time.sleep(1) - - print("Failed to connect to backend.") - -if __name__ == "__main__": - verify()