feat: add Java→Python migration plan with 9 self-contained phase files
Converts Spring Boot 4.0.3 ARMA Server Web GUI to FastAPI/Python. Each phase file is fully self-contained: lists Java source files to read, output files to create, implementation patterns, REST endpoint contracts, and a completion checklist. A future agent can execute any single phase without rescanning the Java project. Phases: - 01: Foundation — SQLAlchemy models, Alembic, settings, base schemas - 02: Auth & Users — JWT middleware, RBAC, user CRUD - 03: CFG parser + server process — server.cfg round-trip, start/stop - 04: Server settings — general/network/logging/security/difficulty - 05: Mod management — mod CRUD, presets, settings, WebSocket progress - 06: Steam integration — SteamCMD queue, Workshop API, python-a2s - 07: Missions, CDLC, Discord, APScheduler jobs - 08: Middleware & polish — global exception handler, SPA redirect, structlog - 09: Testing — pytest-asyncio, respx, 80% coverage target
This commit is contained in:
198
phases/phase-02-auth-security.md
Normal file
198
phases/phase-02-auth-security.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# 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
|
||||
|
||||
`<JAVA_SRC>` = `E:\TestScript\ARMA-Server-Web-Gui\src\main\java\pl\bartlomiejstepien\armaserverwebgui\`
|
||||
|
||||
| File | What to extract |
|
||||
|------|----------------|
|
||||
| `<JAVA_SRC>application/auth/AuthService.java` | authenticate(), logout() logic |
|
||||
| `<JAVA_SRC>application/auth/JwtToken.java` | JWT payload fields |
|
||||
| `<JAVA_SRC>application/security/AswgAuthority.java` | **All 37 authority code strings** |
|
||||
| `<JAVA_SRC>application/security/jwt/JwtService.java` | createJwt(), validateJwt(), blacklisting logic |
|
||||
| `<JAVA_SRC>application/security/jwt/filter/JwtFilter.java` | Permit-all path list |
|
||||
| `<JAVA_SRC>application/security/jwt/cleaner/InvalidJwtCleaner.java` | Scheduled cleanup logic |
|
||||
| `<JAVA_SRC>application/security/AuthenticationFacade.java` | getCurrentUser() pattern |
|
||||
| `<JAVA_SRC>application/security/AswgAuthenticationEntryPoint.java` | 401 response shape |
|
||||
| `<JAVA_SRC>application/config/SecurityConfig.java` | Dual enabled/disabled modes, permit-all paths |
|
||||
| `<JAVA_SRC>domain/user/UserService.java` | All method signatures |
|
||||
| `<JAVA_SRC>domain/user/UserServiceImpl.java` | Full implementation |
|
||||
| `<JAVA_SRC>domain/user/dto/` (all files) | DTO field names |
|
||||
| `<JAVA_SRC>web/AuthRestController.java` | Exact paths, request/response JSON |
|
||||
| `<JAVA_SRC>web/UserRestController.java` | Exact paths, request/response JSON |
|
||||
| `<JAVA_SRC>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`
|
||||
Reference in New Issue
Block a user