# Phase 2 — Authentication + Security **Status**: PENDING **Depends on**: Phase 1 complete **Next phase**: `phase-03-cfg-parser-process.md` --- ## Goal Working JWT login/logout, BCrypt password verification, the full `AswgAuthority` enum (37 permissions), JWT middleware enforcing auth on all `/api/**` routes, user CRUD service, and the auth + user REST endpoints. After this phase: `POST /api/v1/auth` returns a JWT token; all other `/api/**` endpoints return 401 without a valid token. --- ## Java Source Files to Read `` = `E:\TestScript\ARMA-Server-Web-Gui\src\main\java\pl\bartlomiejstepien\armaserverwebgui\` | File | What to extract | |------|----------------| | `application/auth/AuthService.java` | authenticate(), logout() logic | | `application/auth/JwtToken.java` | JWT payload fields | | `application/security/AswgAuthority.java` | **All 37 authority code strings** | | `application/security/jwt/JwtService.java` | createJwt(), validateJwt(), blacklisting logic | | `application/security/jwt/filter/JwtFilter.java` | Permit-all path list | | `application/security/jwt/cleaner/InvalidJwtCleaner.java` | Scheduled cleanup logic | | `application/security/AuthenticationFacade.java` | getCurrentUser() pattern | | `application/security/AswgAuthenticationEntryPoint.java` | 401 response shape | | `application/config/SecurityConfig.java` | Dual enabled/disabled modes, permit-all paths | | `domain/user/UserService.java` | All method signatures | | `domain/user/UserServiceImpl.java` | Full implementation | | `domain/user/dto/` (all files) | DTO field names | | `web/AuthRestController.java` | Exact paths, request/response JSON | | `web/UserRestController.java` | Exact paths, request/response JSON | | `web/request/PasswordChangeRequest.java` | Fields | --- ## Output Files to Create ``` src/application/__init__.py src/application/security/__init__.py src/application/security/jwt_service.py src/application/security/auth_service.py src/application/security/permissions.py (AswgAuthority enum + has_permission factory) src/application/security/authentication.py (get_current_user dependency) src/application/security/password.py (BCrypt wrapper via passlib) src/application/middleware/__init__.py src/application/middleware/jwt_middleware.py src/domain/__init__.py src/domain/user/__init__.py src/domain/user/user_service.py src/domain/user/user_loader_service.py src/domain/user/user_session_service.py src/domain/user/models.py src/web/__init__.py src/web/schemas/__init__.py src/web/schemas/common.py (BaseSchema with camelCase alias, RestErrorResponse) src/web/schemas/auth.py src/web/schemas/users.py src/web/auth_router.py src/web/user_router.py ``` Also update `src/main.py`: - Register `auth_router` and `user_router` - Add `JwtMiddleware` - Create default user on startup in `lifespan` --- ## Implementation Notes ### src/web/schemas/common.py — BaseSchema (used by ALL schemas in all phases) ```python from pydantic import BaseModel, ConfigDict from pydantic.alias_generators import to_camel class BaseSchema(BaseModel): model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) class RestErrorResponse(BaseSchema): code: str message: str ``` Every schema in every phase must inherit `BaseSchema` to preserve camelCase JSON for the Angular frontend. ### src/application/security/permissions.py Extract all 37 codes from `AswgAuthority.java`. Example: ```python from enum import StrEnum from fastapi import Depends, HTTPException class AswgAuthority(StrEnum): SERVER_START_STOP = "SERVER_START_STOP" GENERAL_SETTINGS_VIEW = "GENERAL_SETTINGS_VIEW" # ... all 37 values def has_permission(authority: AswgAuthority): async def _check(current_user=Depends(get_current_user)): user_codes = {a.code for a in current_user.authorities} if authority.value not in user_codes: raise HTTPException(status_code=403) return current_user return Depends(_check) ``` Usage: `@router.get("/properties", dependencies=[has_permission(AswgAuthority.GENERAL_SETTINGS_VIEW)])` ### src/application/security/jwt_service.py Algorithm: `HS256`. JWT payload fields (from `JwtToken.java`): `sub` (username), `authorities` (list of code strings), `iss`, `iat`, `exp`. Blacklisting: on logout, insert token into `invalid_jwt_token` table. On every validation, check the table. Scheduled cleanup (`InvalidJwtCleaner` equivalent): delete rows where `expiration_datetime < now()`. ### src/application/middleware/jwt_middleware.py Permit-all paths from `SecurityConfig.java` / `JwtFilter.java`: ```python PERMIT_ALL_EXACT = { ("POST", "/api/v1/auth"), ("POST", "/api/v1/auth/logout"), ("GET", "/api/v1/actuator/health"), ("GET", "/api/v1/actuator/info"), } PERMIT_ALL_PREFIX = ["/api/v1/ws/", "/api/v1/logging/logs-sse"] ``` When `settings.security_enabled = False`: skip all JWT checks, attach a superuser principal to `request.state.user`. On JWT failure return JSON matching `AswgAuthenticationEntryPoint`: - Expired token: `{"code":"AUTH_TOKEN_EXPIRED","message":"..."}` HTTP 401 - Missing token: `{"code":"AUTH_TOKEN_REQUIRED","message":"..."}` HTTP 401 - Bad token: `{"code":"BAD_AUTH_TOKEN","message":"..."}` HTTP 401 ### Auth REST endpoints (from `AuthRestController.java`) ``` POST /api/v1/auth body: {username, password} → {token: "..."} 200 GET /api/v1/auth/myself (authenticated) → UserProfileResponse 200 POST /api/v1/auth/logout (authenticated) → 200 (no body) ``` ### User REST endpoints (from `UserRestController.java`) ``` GET /api/v1/users → list[UserResponse] POST /api/v1/users body: CreateUserRequest → UserResponse 201 PUT /api/v1/users/{id} body: UpdateUserRequest → UserResponse 200 DELETE /api/v1/users/{id} → 200 (no body) POST /api/v1/users/{id}/password-change body: {password} → 200 (no body) ``` ### Default user creation in lifespan (src/main.py update) ```python async with SessionLocal() as session: user_repo = UserRepository(session) existing = await user_repo.find_by_username(settings.default_user_username) if not existing: hashed = password_service.encode(settings.default_user_password) await user_repo.create(username=settings.default_user_username, password=hashed, authorities=list(AswgAuthority)) elif settings.default_user_reset: hashed = password_service.encode(settings.default_user_password) await user_repo.update_password(existing.id, hashed) ``` --- ## Completion Checklist - [ ] `AswgAuthority` enum has exactly 37 permission codes (count against Java source) - [ ] `has_permission()` factory works as a `Depends()` - [ ] JWT is created and validated with `HS256` - [ ] Expired and blacklisted tokens are rejected with correct error JSON - [ ] JWT middleware blocks all `/api/**` without a valid token - [ ] Permit-all paths respond without token (health, login, logout, SSE, WS) - [ ] `security_enabled=False` bypasses all auth checks - [ ] `POST /api/v1/auth` → JWT for valid credentials, 401 for bad - [ ] `GET /api/v1/auth/myself` → current user profile JSON - [ ] `POST /api/v1/auth/logout` → blacklists token - [ ] All user CRUD endpoints return correct status codes - [ ] Default user created on first startup - [ ] Password reset works when `aswg.default-user.reset=true` ## Contract for Phase 3 Phase 3 imports: - `from src.application.security.permissions import has_permission, AswgAuthority` - `from src.application.security.authentication import CurrentUser, get_current_user` - `from src.web.schemas.common import BaseSchema, RestErrorResponse`