47 lines
6.6 KiB
Markdown
47 lines
6.6 KiB
Markdown
# Laravel/Inertia OTP-авторизация и claim промокодов (MLH / Hacktoberfest)
|
||
|
||
Реверс Laravel+Inertia+Vite сайтов (www.mlh.com) и Next.js API-фронтов (hacktoberfest.com → hacktoberfest-api.mlh.com). Проверено E2E: OTP sign-in, OAuth, offers, claim 2 из 4 кодов.
|
||
|
||
## 1. Распознавание стека и чтение props
|
||
|
||
- Страница Inertia: весь стейт в `<script id="..." data-page="app" type="application/json">{...}</script>`. Парсить regex `type="application/json">(.*?)</script>` + `html.unescape()` ПЕРЕД `json.loads` — атрибут HTML-экранирован.
|
||
- Ключевые props: `component`, `version`, `isSignedIn`, `currentUser`, `errors`, `flash`, `apiBaseUrl`, `authBaseUrl`.
|
||
- Vite-манифест: `https://<host>/vite/.vite/manifest.json` → маппинг `pages/...tsx -> assets/...js`. Искать по имени компонента из props (напр. `Events/Partners/PromoCodes/Redeem.tsx` → `Redeem-*.js`) — в чанке видны точные URL, data-testid кнопок и payload форм.
|
||
|
||
## 2. Inertia POST — правила
|
||
|
||
- Заголовки: `X-Inertia: true`, `X-Inertia-Version: <version из data-page JSON>` (НЕ из data-version атрибута — его может не быть), `X-CSRF-Token: <urldecode(XSRF-TOKEN cookie)>`, `Referer: <URL страницы>`, `Content-Type: application/json`.
|
||
- Ответ на POST = ЧИСТЫЙ JSON (component+props), не HTML — парсить `json.loads(resp)`, а не искать `<script>`.
|
||
- HTTP 409 = version conflict: GET страницу заново, взять свежий version. ПЕРЕД ретраем проверить, не konsumировано ли действие (POST мог пройти до 409 на повторе) — читать signed/claimed state с главной.
|
||
- PITFALL «silent no-op»: корректный на вид POST может вернуть 200 с тем же компонентом, `promoCode: null`, `errors: []`, `flash: null` — действие НЕ зарегистрировано, сервер молча игнорирует. Если после POST state не изменился — НЕ долбить HTTP, а идти в браузер (п.4).
|
||
|
||
## 3. MLH OTP sign-in (флоу октября 2026, компонент Auth/Session/New)
|
||
|
||
1. GET `https://www.mlh.com/signin` → version + XSRF из data-page.
|
||
2. POST `/signin` `{email, return_to, cf-turnstile-response}` → в props `otpId` — ЭТО ULID-СТРОКА, regex `"otpId":\s*"?([A-Za-z0-9_\-]+)`; числовой `(\d+)` схватит только «01».
|
||
3. GET `/signin/<otpId>/otp` — ОБЯЗАТЕЛЬНО перед POST, чтобы взять свежий version (иначе 409).
|
||
4. Код из письма (Gmail IMAP): subject «One-Time Login Code», в теле `Login Code is NNNNNN` (regex `Login Code is\s*(\d{6})`). Magic-link «Sign in (» — legacy-путь, теперь ведёт на OTP-страницу.
|
||
5. POST `https://www.mlh.com/signin/<otpId>/otp` `{otp_id, otp, email, return_to, cf-turnstile-response}` → signed. Проверять GET `/` → props `isSignedIn:true`; куки сохранить (cookie string в JSON-стейт).
|
||
|
||
Turnstile MLH: sitekey `0x4AAAAAAARi-8xgLgfKOXIb` (публичный, в HTML). **siteverify ОТБИВАЕТ plain-токены sidecar'а (invalid-input-response) — только real_page (camoufox) solve**; action для signin = `authentication`, для телефона = `phone_verification`.
|
||
|
||
## 4. OAuth и browser-claim (работает там, где HTTP no-op)
|
||
|
||
- Raw approve формы OAuth (`hacktoberfest-api.mlh.com/oauth/mlh`) через urllib = 422 (не угадать скрытые поля Inertia-формы). РАБОЧИЙ ПУТЬ: Camoufox headless → `ctx.add_cookies([{name,value,domain:'.mlh.com',path:'/'}])` из сохранённой сессии (в т.ч. `mlh_token`) → goto OAuth start → `wait_for_load_state('networkidle')` → клик `button[type=submit]` на authorize-странице → landing `hacktoberfest.com/my/`.
|
||
- Токен API: localStorage ключ `hacktoberfest.session` → JSON → refreshToken → POST `https://hacktoberfest-api.mlh.com/auth/refresh` → accessToken; `Authorization: Bearer <accessToken>` на `/api/me/offers`, `/api/me`, `/api/me/fests`.
|
||
- Claim промокода: только КЛИКОМ в браузере по `[data-testid="claim-promo-code-button"]` (href=redeemUrl, method post, data {token}). После клика код читать из `page.inner_text('body')` — секция «Just claimed! ... Promo Code <CODE>»; в props промокод может остаться null даже после успеха.
|
||
|
||
## 5. Offers/redeem структура (MLH events)
|
||
|
||
- Redeem-страница: `https://www.mlh.com<redeemUrl>/redeem?token=<token>`; props: `token`, `redeemUrl`, `validLink`, `userMismatch`, `redemptionLimitExceeded`, `unmetClaimRequirements`, `claimRequirements`, `promoCode`, `previouslyClaimedPromoCodes`, `sponsor{name,slug}`.
|
||
- `unmetClaimRequirements=[]` → можно клеймить. Непустой → сначала закрыть гейты:
|
||
- `verified_phone`: POST `/api/phone_numbers` `{phone_number, channel:'sms', 'cf-turnstile-response'}` (turnstile action `phone_verification`) → POST `/api/phone_numbers/<id>/confirm_verification` `{code}` (6 цифр из SMS). Нужен реальный приём SMS.
|
||
- `github_oauth`: привязка GitHub-аккаунта к MLH (SocialProfiles); `.tech` дополнительно требует `github_account_age` (акк GH ≥30 дней — см. mlh_account_age_30_days в Redeem-чанке).
|
||
- Claimed-коды видны на `https://www.mlh.com/account/promo_codes` (component Account/PromoCodes, props `sponsors`, `totalCount`).
|
||
|
||
## 6. Сессии и переиспользование
|
||
|
||
- Стейт: `hf_mlh_sessions.json` (cookies string на акк) — переиспользуется и в urllib (http.cookiejar, domain `.mlh.com`) и в Camoufox (add_cookies). Сессия живёт днями; при `signed:False` — полный OTP-прогон заново (hf_reauth2.py паттерн).
|
||
- Долгие прогоны (captcha real_page 30-90с + IMAP wait) — только background с логом; foreground упрётся в timeout инструмента.
|
||
- Артефакты сессии-первоисточника: `C:/Users/User/tmp/hf_reauth2.py` (OTP), `hf_oauth_browser.py` (Camoufox OAuth), `hf_redeem_props.json`, `hf_codes_final.json`, `hf_mlh_sessions.json`.
|