Files
LynkedUpPro_CRM/docs/backend/01_authentication_module.md
Satyam 7694788387 feat(ProCanvas): retro game overhaul with log actions, challenges & achievements modals
- Complete ProCanvas redesign with retro sports game aesthetic
- Card-game style photo frame with tilt, shimmer, corner star accents
- Daily Missions card with weekly challenge + individual quest progress bars
- Log Action card (Door Knocked, Lead Gained, Appointment Set, Client Meeting)
- Each log action opens themed modal with relevant input fields
- Challenges modal with 6 active challenges and progress tracking
- Achievements modal with all badges, unlock status, descriptions
- Nav bar tabs (Leaderboard, Challenges, Achievements) wired to modals
- Rewards & Checkpoints with named stages (Daily Grind to Legend Run)
- Smoother Hot Streak pulse animation (2.5s float + 3s pulse rings)
- Hit The Map button with smooth pulsing glow animation
- Grid pattern overlay in Leaderboard card
- Light mode support via dark: Tailwind variants throughout
- Top 3 badges displayed on profile card
- Fixed dropdown option visibility in dark mode
2026-02-26 01:36:36 +05:30

687 lines
22 KiB
Markdown

# B1 — Authentication Module
**Module:** B1
**Depends on:** B0 (Project Overview), B2 (Database Schema — `users` table)
**Read before:** B3, B4, B5, B6, B7, B8 (every router imports `require_role` from here)
---
## 1. Overview
This module defines the complete authentication and authorization system for LynkedUpPro. It covers:
- Password hashing (bcrypt via `passlib`)
- JWT access + refresh token lifecycle
- `httpOnly` cookie storage strategy
- All 7 login flows across 7 roles
- The `get_current_user()` and `require_role()` FastAPI dependencies used by every other router
- The permission system derived from `src/utils/permissions.js`
- All auth endpoints (`/auth/login`, `/auth/refresh`, `/auth/logout`, `/auth/me`)
**No other module should re-implement any of this.** All routers import and use the dependencies defined here.
---
## 2. Files to Create
```
app/
├── routers/auth.py ← All /api/v1/auth/* endpoints
├── services/auth_service.py ← Business logic: verify password, create tokens, rotate refresh
├── utils/security.py ← JWT encode/decode, bcrypt helpers (no business logic)
└── dependencies.py ← get_db(), get_current_user(), require_role() — used everywhere
```
---
## 3. Password Hashing
Use `passlib` with `bcrypt` scheme. Never store or compare plain text passwords.
```python
# app/utils/security.py
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
"""Hash a plain-text password. Use during user creation and seed."""
return pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
"""Verify plain text against stored bcrypt hash. Use during login."""
return pwd_context.verify(plain, hashed)
```
**Seed note:** All 17 mock users have `password: "password"`. The seed script must bcrypt-hash this before inserting. The plain text `"password"` must never appear in the database.
---
## 4. JWT Tokens
### Token Types
| Type | Lifetime | Stored In | Purpose |
|------|----------|-----------|---------|
| Access token | 15 minutes | `httpOnly` cookie (`access_token`) | Authorizes API requests |
| Refresh token | 7 days | `httpOnly` cookie (`refresh_token`) | Issues new access tokens silently |
### JWT Payload (Claims)
**Access token:**
```json
{
"sub": "3f8a1c2d-...(user UUID)",
"role": "FIELD_AGENT",
"legacy_id": "e1",
"type": "access",
"iat": 1740400000,
"exp": 1740400900
}
```
**Refresh token:**
```json
{
"sub": "3f8a1c2d-...(user UUID)",
"type": "refresh",
"iat": 1740400000,
"exp": 1741004800
}
```
**Why `legacy_id` in the access token:** During the transition period, some frontend components still look up users by their mock ID (`e1`, `own_001`). Including it in the token avoids an extra DB lookup per request.
### Token Creation
```python
# app/utils/security.py
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from app.config import settings
def create_access_token(user_id: str, role: str, legacy_id: str | None = None) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {
"sub": str(user_id),
"role": role,
"type": "access",
"exp": expire,
}
if legacy_id:
payload["legacy_id"] = legacy_id
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def create_refresh_token(user_id: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days)
payload = {
"sub": str(user_id),
"type": "refresh",
"exp": expire,
}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def decode_token(token: str) -> dict:
"""Raises JWTError if invalid or expired."""
return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
```
### Refresh Token Rotation Security
Refresh tokens are rotated on every use. The hashed value of the current refresh token is stored in `users.refresh_token_hash`. On refresh:
1. Decode the incoming refresh token (verify not expired)
2. Load user from DB, get `refresh_token_hash`
3. `verify_password(incoming_token, stored_hash)` — if mismatch, **revoke all tokens** (possible reuse attack)
4. If match: issue new access + refresh token pair, update `refresh_token_hash`
```python
# app/utils/security.py
def hash_token(token: str) -> str:
"""Hash a refresh token for safe storage. Uses same bcrypt context."""
return pwd_context.hash(token)
def verify_token_hash(token: str, hashed: str) -> bool:
return pwd_context.verify(token, hashed)
```
---
## 5. Cookie Configuration
All tokens are delivered as `httpOnly` cookies. This prevents XSS attacks from stealing tokens via `document.cookie` or `localStorage`.
```python
# Shared cookie settings used in auth router
ACCESS_COOKIE_PARAMS = {
"key": "access_token",
"httponly": True,
"secure": True, # HTTPS only in production; set False in dev
"samesite": "strict", # Prevents CSRF
"max_age": 60 * 15, # 15 minutes in seconds
"path": "/",
}
REFRESH_COOKIE_PARAMS = {
"key": "refresh_token",
"httponly": True,
"secure": True,
"samesite": "strict",
"max_age": 60 * 60 * 24 * 7, # 7 days in seconds
"path": "/api/v1/auth", # Scoped — only sent to auth endpoints
}
```
**Why `path="/api/v1/auth"` for the refresh token:** Limits the refresh token cookie to auth endpoints only. It won't be sent with every API request, reducing exposure.
---
## 6. FastAPI Dependencies
These are the three shared dependencies imported by every other router.
```python
# app/dependencies.py
from fastapi import Cookie, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from jose import JWTError
from app.database import get_db
from app.models.user import User
from app.utils.security import decode_token
async def get_current_user(
access_token: str | None = Cookie(default=None),
db: AsyncSession = Depends(get_db),
) -> User:
"""
Extracts and validates the JWT from the httpOnly cookie.
Raises 401 if missing, expired, or invalid.
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
if not access_token:
raise credentials_exception
try:
payload = decode_token(access_token)
if payload.get("type") != "access":
raise credentials_exception
user_id: str = payload.get("sub")
if not user_id:
raise credentials_exception
except JWTError:
raise credentials_exception
user = await db.get(User, user_id)
if user is None or user.deleted_at is not None or not user.is_active:
raise credentials_exception
return user
def require_role(*roles: str):
"""
Returns a dependency that enforces role-based access.
Usage: current_user: User = Depends(require_role("ADMIN", "OWNER"))
"""
async def checker(current_user: User = Depends(get_current_user)) -> User:
if current_user.role not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied. Required roles: {list(roles)}",
)
return current_user
return checker
```
**Usage example in any router:**
```python
from app.dependencies import require_role
@router.get("/admin/stats")
async def get_admin_stats(
current_user: User = Depends(require_role("ADMIN", "OWNER")),
db: AsyncSession = Depends(get_db),
):
...
```
---
## 7. Login Flows by Role
The frontend sends different `type` values from `Login.jsx`. This table maps each UI login type to the DB lookup strategy.
| Frontend `loginType` | Backend strategy | DB column matched | Roles possible |
|---------------------|-----------------|-------------------|----------------|
| `customer` | `username` + `password` | `users.username` | `CUSTOMER` |
| `employee` | `emp_id` + `password` | `users.emp_id` | `FIELD_AGENT`, `ADMIN` |
| `owner` | `username` or `email` + `password` | `users.username` or `users.email` | `OWNER` |
| `contractor` | `username` or `email` + `password` | `users.username` or `users.email` | `CONTRACTOR` |
| `subcontractor` | `username` or `email` + `password` | `users.username` or `users.email` | `SUBCONTRACTOR` |
| `vendor` | `username` or `email` + `password` | `users.username` or `users.email` | `VENDOR` |
**Note on `employee` type:** The login form sends `type: "employee"` for both `FIELD_AGENT` and `ADMIN` (they use emp_id like `FA001`, `ADM01`). All other non-customer roles send their own type string and match on `username`.
### Auth Service Implementation
```python
# app/services/auth_service.py
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.utils.security import verify_password, create_access_token, create_refresh_token, hash_token
async def authenticate_user(
db: AsyncSession,
identifier: str,
password: str,
login_type: str,
) -> User | None:
"""
Finds and verifies a user. Returns User on success, None on failure.
Never raises — caller decides how to handle None.
"""
stmt = None
if login_type == "customer":
stmt = select(User).where(
User.username == identifier,
User.role == "CUSTOMER",
User.deleted_at.is_(None),
User.is_active.is_(True),
)
elif login_type == "employee":
stmt = select(User).where(
User.emp_id == identifier,
User.role.in_(["FIELD_AGENT", "ADMIN"]),
User.deleted_at.is_(None),
User.is_active.is_(True),
)
elif login_type in ("owner", "contractor", "subcontractor", "vendor"):
role_map = {
"owner": "OWNER",
"contractor": "CONTRACTOR",
"subcontractor": "SUBCONTRACTOR",
"vendor": "VENDOR",
}
stmt = select(User).where(
or_(User.username == identifier, User.email == identifier),
User.role == role_map[login_type],
User.deleted_at.is_(None),
User.is_active.is_(True),
)
else:
return None
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if user is None:
return None
if not verify_password(password, user.password_hash):
return None
return user
async def create_token_pair(db: AsyncSession, user: User) -> tuple[str, str]:
"""
Creates access + refresh tokens and persists the refresh token hash.
Returns (access_token, refresh_token).
"""
access_token = create_access_token(
user_id=str(user.id),
role=user.role,
legacy_id=user.legacy_id,
)
refresh_token = create_refresh_token(user_id=str(user.id))
# Store hashed refresh token for rotation validation
user.refresh_token_hash = hash_token(refresh_token)
user.last_login_at = datetime.now(timezone.utc)
await db.commit()
return access_token, refresh_token
```
---
## 8. API Endpoints
### 8.1 `POST /api/v1/auth/login`
**Purpose:** Authenticate user, issue tokens as `httpOnly` cookies.
**Request Body:**
```python
# app/schemas/auth.py
class LoginRequest(BaseModel):
identifier: str # username, emp_id, or email depending on type
password: str
type: Literal[
"customer", "employee", "owner",
"contractor", "subcontractor", "vendor"
]
```
**Response Body (200):**
```python
class UserPublic(BaseModel):
id: str # UUID as string
legacy_id: str | None
full_name: str
email: str
role: str # 'OWNER', 'ADMIN', 'FIELD_AGENT', etc.
company_name: str | None
xp: int | None
streak_days: int | None
achievements: list[str]
class LoginResponse(BaseModel):
user: UserPublic
message: str = "Login successful"
```
**Error Responses:**
- `401` — Invalid credentials (always generic — never reveal which field was wrong)
- `403` — Account deactivated (`is_active = false`)
- `422` — Missing or invalid request body fields
**Router Implementation:**
```python
# app/routers/auth.py
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=LoginResponse)
async def login(
request: LoginRequest,
response: Response,
db: AsyncSession = Depends(get_db),
):
user = await authenticate_user(db, request.identifier, request.password, request.type)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
)
access_token, refresh_token = await create_token_pair(db, user)
# Set httpOnly cookies
response.set_cookie(value=access_token, **ACCESS_COOKIE_PARAMS)
response.set_cookie(value=refresh_token, **REFRESH_COOKIE_PARAMS)
# Write audit log
await write_audit_log(db, actor_id=user.id, action="auth.login", resource_type="user", resource_id=str(user.id))
return LoginResponse(user=UserPublic.model_validate(user))
```
---
### 8.2 `POST /api/v1/auth/refresh`
**Purpose:** Issue a new access token using the refresh token cookie.
**Request:** No body. Reads `refresh_token` from `httpOnly` cookie automatically.
**Response (200):**
```python
class RefreshResponse(BaseModel):
message: str = "Token refreshed"
# New access_token set as cookie; not in body for security
```
**Router Implementation:**
```python
@router.post("/refresh", response_model=RefreshResponse)
async def refresh_token(
response: Response,
refresh_token: str | None = Cookie(default=None),
db: AsyncSession = Depends(get_db),
):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token",
)
if not refresh_token:
raise credentials_exception
try:
payload = decode_token(refresh_token)
if payload.get("type") != "refresh":
raise credentials_exception
user_id = payload.get("sub")
except JWTError:
raise credentials_exception
user = await db.get(User, user_id)
if user is None or not user.is_active or user.deleted_at is not None:
raise credentials_exception
# Validate token hash (detect reuse attacks)
if not user.refresh_token_hash or not verify_token_hash(refresh_token, user.refresh_token_hash):
# Possible token reuse — revoke all sessions
user.refresh_token_hash = None
await db.commit()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token reuse detected. Please log in again.",
)
# Issue new token pair
new_access, new_refresh = await create_token_pair(db, user)
response.set_cookie(value=new_access, **ACCESS_COOKIE_PARAMS)
response.set_cookie(value=new_refresh, **REFRESH_COOKIE_PARAMS)
return RefreshResponse()
```
**Error Responses:**
- `401` — No cookie, expired token, tampered token, or reuse detected
---
### 8.3 `POST /api/v1/auth/logout`
**Purpose:** Clear auth cookies and invalidate refresh token in DB.
**Request:** No body. User must be authenticated.
**Response (200):**
```python
class LogoutResponse(BaseModel):
message: str = "Logged out successfully"
```
**Router Implementation:**
```python
@router.post("/logout", response_model=LogoutResponse)
async def logout(
response: Response,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
# Invalidate refresh token in DB
current_user.refresh_token_hash = None
await db.commit()
# Clear both cookies
response.delete_cookie("access_token", path="/")
response.delete_cookie("refresh_token", path="/api/v1/auth")
await write_audit_log(db, actor_id=current_user.id, action="auth.logout", resource_type="user", resource_id=str(current_user.id))
return LogoutResponse()
```
---
### 8.4 `GET /api/v1/auth/me`
**Purpose:** Return the current user's profile. Called by the frontend on app load to restore session after page refresh.
**Request:** No body. Reads `access_token` cookie.
**Response (200):** `UserPublic` schema (same as login response)
**Router Implementation:**
```python
@router.get("/me", response_model=UserPublic)
async def get_me(current_user: User = Depends(get_current_user)):
return UserPublic.model_validate(current_user)
```
**Why this matters for the frontend:** Currently, the frontend has no session persistence — `user` state is lost on page refresh. Once `GET /auth/me` is implemented, `AuthContext` can call it on mount and restore the user from the cookie automatically.
---
## 9. Role → Post-Login Redirect Map
The frontend (`Login.jsx`) redirects to a role-specific page after successful login. The backend `LoginResponse` includes `role` so the frontend can make this decision. This table is the authoritative mapping:
| Role | Post-login Redirect |
|------|-------------------|
| `CUSTOMER` | `/portal/profile` |
| `OWNER` | `/owner/snapshot` |
| `ADMIN` | `/emp/fa/dashboard` |
| `FIELD_AGENT` | `/emp/fa/dashboard` |
| `CONTRACTOR` | `/contractor/dashboard` |
| `VENDOR` | `/vendor/dashboard` |
| `SUBCONTRACTOR` | `/subcontractor/dashboard` |
---
## 10. Permission System
The frontend's `src/utils/permissions.js` defines a `PERMISSIONS` object and a `ROLE_PERMISSIONS` map. The backend must enforce the same rules at the API layer. The canonical permission matrix:
| Permission | OWNER | ADMIN | CONTRACTOR | SUBCONTRACTOR | FIELD_AGENT | VENDOR | CUSTOMER |
|------------|-------|-------|------------|---------------|-------------|--------|----------|
| `VIEW_ALL_PROJECTS` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| `VIEW_ALL_PERSONNEL` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| `VIEW_FINANCIALS` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| `VIEW_SENSITIVE_DATA` | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| `MANAGE_USERS` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| `APPROVE_DOCUMENTS` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| `VIEW_ASSIGNED_PROJECTS` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| `UPLOAD_DOCUMENTS` | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ |
| `SUBMIT_INVOICES` | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ |
| `MANAGE_OWN_CREW` | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
| `VIEW_ASSIGNED_TASKS` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| `UPDATE_TASK_STATUS` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
Implement as a utility used inside service functions (not in routers — routers only check role, services check fine-grained permissions):
```python
# app/utils/permissions.py
ROLE_PERMISSIONS: dict[str, list[str]] = {
"OWNER": [
"VIEW_ALL_PROJECTS", "VIEW_ALL_PERSONNEL", "VIEW_FINANCIALS",
"VIEW_SENSITIVE_DATA", "MANAGE_USERS", "APPROVE_DOCUMENTS",
"VIEW_ASSIGNED_PROJECTS", "UPLOAD_DOCUMENTS", "SUBMIT_INVOICES",
"VIEW_ASSIGNED_TASKS", "UPDATE_TASK_STATUS",
],
"ADMIN": [
"VIEW_ALL_PROJECTS", "VIEW_ALL_PERSONNEL", "VIEW_FINANCIALS",
"MANAGE_USERS", "APPROVE_DOCUMENTS", "VIEW_ASSIGNED_PROJECTS",
"UPLOAD_DOCUMENTS", "SUBMIT_INVOICES", "VIEW_ASSIGNED_TASKS", "UPDATE_TASK_STATUS",
],
"CONTRACTOR": [
"VIEW_ASSIGNED_PROJECTS", "UPLOAD_DOCUMENTS",
"SUBMIT_INVOICES", "MANAGE_OWN_CREW", "VIEW_ASSIGNED_TASKS", "UPDATE_TASK_STATUS",
],
"SUBCONTRACTOR": ["VIEW_ASSIGNED_TASKS", "UPDATE_TASK_STATUS"],
"FIELD_AGENT": [],
"VENDOR": ["UPLOAD_DOCUMENTS", "SUBMIT_INVOICES"],
"CUSTOMER": [],
}
def has_permission(role: str, permission: str) -> bool:
return permission in ROLE_PERMISSIONS.get(role, [])
```
---
## 11. Sensitive Data Masking
`VIEW_SENSITIVE_DATA` is `OWNER`-only. Any endpoint that returns sensitive fields (SSN, EIN, bank account numbers) must mask them for all other roles.
```python
# app/utils/mask.py
def mask_ssn(ssn: str | None) -> str:
"""'123-45-6789''***-**-6789'"""
if not ssn:
return ""
return f"***-**-{ssn[-4:]}"
def mask_bank_account(account: str | None) -> str:
"""'9876543210''******3210'"""
if not account:
return ""
return f"{'*' * (len(account) - 4)}{account[-4:]}"
def mask_ein(ein: str | None) -> str:
"""'12-3456789''**-***6789'"""
if not ein:
return ""
return f"**-***{ein[-4:]}"
```
---
## 12. Seed Credentials
All 17 mock users are seeded with `password = "password"` (bcrypt-hashed). Use these during development and integration testing:
| Role | Login Type (UI) | Identifier | Portal |
|------|----------------|------------|--------|
| `CUSTOMER` | `customer` | `alice` | `/portal/profile` |
| `FIELD_AGENT` | `employee` | `FA001` | `/emp/fa/dashboard` |
| `FIELD_AGENT` | `employee` | `FA002` | `/emp/fa/dashboard` |
| `FIELD_AGENT` | `employee` | `FA003` | `/emp/fa/dashboard` |
| `FIELD_AGENT` | `employee` | `FA004` | `/emp/fa/dashboard` |
| `FIELD_AGENT` | `employee` | `FA005` | `/emp/fa/dashboard` |
| `ADMIN` | `employee` | `ADM01` | `/emp/fa/dashboard` |
| `ADMIN` | `employee` | `ADM02` | `/emp/fa/dashboard` |
| `ADMIN` | `employee` | `ADM03` | `/emp/fa/dashboard` |
| `OWNER` | `owner` | `justin` | `/owner/snapshot` |
| `OWNER` | `owner` | `diana` | `/owner/snapshot` |
| `CONTRACTOR` | `contractor` | `mike` | `/contractor/dashboard` |
| `SUBCONTRACTOR` | `subcontractor` | `carlos` | `/subcontractor/dashboard` |
| `VENDOR` | `vendor` | `abc_supply` | `/vendor/dashboard` |
---
## 13. Security Checklist
Before shipping auth to production:
- [ ] `SECRET_KEY` is generated with `openssl rand -hex 32` — not a guessable string
- [ ] `secure=True` on cookies (HTTPS only)
- [ ] `samesite="strict"` on cookies
- [ ] Refresh token path scoped to `/api/v1/auth`
- [ ] Login endpoint has rate limiting (e.g. 10 attempts / minute per IP)
- [ ] Failed login attempts do NOT reveal which field was wrong
- [ ] Refresh token rotation implemented (reuse = revoke all)
- [ ] Logout clears both cookies AND invalidates DB token hash
- [ ] `GET /auth/me` is the only session-restore mechanism (no `localStorage` token storage)
- [ ] Audit log entry written for every login and logout
---
*Next: Read `03_properties_module.md` or `04_users_people_module.md` — both depend on `require_role()` defined here.*