profile fix and csrf protections
This commit is contained in:
@ -10,12 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Added
|
||||
|
||||
- Electron + electron-vite desktop shell with a login view and a connected workspace.
|
||||
- Password sign-in through `POST /api/login` and optional connect-with-token from Admin ? Tokens. MFA accounts continue in-app (`api/login/mfa/{loginCode}`) until a token is issued. `loginCode` stays in the main process; a pasted Admin token skips MFA.
|
||||
- Password sign-in through `POST /api/login` and optional connect-with-token from Admin ? Tokens. MFA accounts continue in-app (`api/login/mfa/{loginCode}`) until a token is issued. `loginCode` stays in the main process; a pasted Admin token skips MFA. POSTs send session CSRF (`X-CSRF-Token`); HTTP uses Electron `net.fetch` so the PHP session cookie persists.
|
||||
- Session stored in `userData`, encrypted with `safeStorage` when the OS allows it. The renderer never receives the token.
|
||||
- Logged-in chrome matches TTP: navy header, Font Awesome 6.7.1 / Bootstrap 5.3, centered full-width search, notifications and messages dropdowns, avatar account menu. Profile settings (avatar, name, gender, newsletter, timezone, date/time, page size, dark mode) live in-app and save through `POST /api/profile/update`. Email, password, and phone open the connected site.
|
||||
- Logged-in chrome matches TTP: navy header, Font Awesome 6.7.1 / Bootstrap 5.3, centered full-width search, notifications and messages dropdowns, avatar account menu. Profile settings (avatar, gender, newsletter, timezone, date/time, page size, dark mode) live in-app and save through `POST /api/profile/update`. Email, password, and phone open the connected site.
|
||||
- After sign-in, Capsule loads `GET /api/profile` plus notifications, the messages inbox, and `GET /api/messages/recent` for the header dropdown (avatars + unread count). Inbox rows can mark read/unread or hide. Compose and reply follow `canSend`. Search uses the matching user-token endpoint. Contact and bug reports live on footer pages (`#/contact`, `#/bugreport`) instead of the dashboard. Disabled plugins show an unavailable note instead of demo data.
|
||||
- Footer chrome matches TTP copy and socials. The upper band keeps a dark-mode toggle, Privacy Policy, Terms of Service, Contact, and Report a Bug. There is no subscribe box.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Settings no longer posts a display-name field. User CP has no such option; `users.name` is leftover, and `Check::name()` rejected typical values (`Invalid name.` / `malformed input`).
|
||||
- Avatar file previews are allowed (`blob:` on `img-src`). Choosing a photo no longer shows the missing-image icon. A `{ "error": "not found" }` from the site is reported as a missing Capsule API action (the live TTP install still needs `POST /api/profile/update`).
|
||||
|
||||
@ -17,17 +17,17 @@ npm run dev
|
||||
|
||||
## How auth works
|
||||
|
||||
All HTTP runs in the **main process**. The renderer never sees the token and never talks to the site directly, so TTP?s same-origin CORS policy does not apply.
|
||||
All HTTP runs in the **main process** (`net.fetch`, so the PHP session cookie sticks). The renderer never sees the token and never talks to the site directly, so TTP's same-origin CORS policy does not apply. POSTs send `X-CSRF-Token` (and POST `token`) after a GET harvests `csrf`.
|
||||
|
||||
| Action | Endpoint | Notes |
|
||||
|--------|----------|-------|
|
||||
| Password sign-in | `POST /api/login` | `username` + `password`, `application/x-www-form-urlencoded`. Same limiter as browser login. No CSRF, no Turnstile. MFA accounts return `{ mfa }` instead of a token. |
|
||||
| Password sign-in | `POST /api/login` | `username` + `password`, `application/x-www-form-urlencoded`. Same limiter as browser login. CSRF from `GET /api/login`. No Turnstile. MFA accounts return `{ mfa }` instead of a token. |
|
||||
| MFA code / method | `POST /api/login/mfa/{loginCode}` | `auth_code` or `mfaMethodSelect`. `loginCode` stays in the main process. |
|
||||
| MFA reset | `POST /api/login/mfa/{loginCode}/reset` | Clears the chosen method so the picker shows again. |
|
||||
| Confirm identity | `GET /api/profile` | Bearer user token. Also used to hydrate username after login. `GET /api/users/find/{username}` remains available. |
|
||||
| Workspace | `GET /api/notifications`, `GET /api/messages`, `GET /api/messages/recent` | First inbox page plus the header dropdown after connect. Plugin-off responses show as unavailable. |
|
||||
| Search | `GET /api/search` | Header search. `q`, `resource`, `page`. |
|
||||
| Profile save | `POST /api/profile/update` | Name, avatar, prefs. |
|
||||
| Profile save | `POST /api/profile/update` | Avatar and prefs. |
|
||||
| Mail / notices | `POST /api/messages/?`, `POST /api/notifications/?` | View, reply, create, read, unread, delete. |
|
||||
| Contact / bugs | `POST /api/contact`, `POST /api/bugreport` | Footer pages when those plugins are enabled. |
|
||||
| Existing token | Admin ? Tokens | Personal or app token. A user token hydrates the workspace; an app token can connect but cannot call the user API. |
|
||||
|
||||
@ -10,7 +10,8 @@ import {
|
||||
normalizeSiteUrl,
|
||||
resetMfaMethod,
|
||||
selectMfaMethod,
|
||||
submitMfaCode
|
||||
submitMfaCode,
|
||||
clearCsrf
|
||||
} from './ttpClient.js'
|
||||
import { publicSession, readSession, writeLastSite, writeSession } from './sessionStore.js'
|
||||
|
||||
@ -193,6 +194,7 @@ export function registerSessionIpc() {
|
||||
}
|
||||
|
||||
clearPendingMfa()
|
||||
clearCsrf(siteUrl)
|
||||
const result = await loginWithPassword(siteUrl, username, password)
|
||||
if (result.mfa) {
|
||||
return rememberPendingMfa(siteUrl, username, result.mfa)
|
||||
@ -263,6 +265,7 @@ export function registerSessionIpc() {
|
||||
}
|
||||
|
||||
clearPendingMfa()
|
||||
clearCsrf(siteUrl)
|
||||
return persistConnection({ siteUrl, token, username, authMethod: 'token' })
|
||||
})
|
||||
|
||||
@ -299,6 +302,9 @@ export function registerSessionIpc() {
|
||||
const lastSiteUrl = pendingMfa?.siteUrl || session?.siteUrl || session?.lastSiteUrl || ''
|
||||
clearPendingMfa()
|
||||
writeLastSite(lastSiteUrl)
|
||||
if (lastSiteUrl) {
|
||||
clearCsrf(lastSiteUrl)
|
||||
}
|
||||
return publicSession(readSession())
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
/**
|
||||
* HTTP calls to a TTP site. Runs in the main process so CORS does not apply.
|
||||
* Uses Electron net.fetch so the PHP session cookie (CSRF) persists.
|
||||
*/
|
||||
|
||||
import { net, session as electronSession } from 'electron'
|
||||
|
||||
/** Session CSRF tokens keyed by canonical site URL. */
|
||||
const csrfBySite = new Map()
|
||||
|
||||
/**
|
||||
* Normalize a TTP site URL to scheme + host + optional path, no trailing slash.
|
||||
*
|
||||
@ -29,6 +35,100 @@ export function normalizeSiteUrl(raw) {
|
||||
return `${parsed.origin}${path === '/' ? '' : path}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget stored CSRF for one site, or all sites.
|
||||
*
|
||||
* @param {string} [siteUrl] - canonical site base
|
||||
* @return {void}
|
||||
*/
|
||||
export function clearCsrf(siteUrl) {
|
||||
if (siteUrl) {
|
||||
csrfBySite.delete(siteUrl)
|
||||
return
|
||||
}
|
||||
csrfBySite.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a CSRF value from a response header or JSON body.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {Response} response - fetch response
|
||||
* @param {object} [data] - parsed JSON
|
||||
* @return {void}
|
||||
*/
|
||||
function rememberCsrf(siteUrl, response, data) {
|
||||
const header = response?.headers?.get?.('x-csrf-token') || ''
|
||||
const body = typeof data?.csrf === 'string' ? data.csrf : ''
|
||||
const value = body || header
|
||||
if (value && siteUrl) {
|
||||
csrfBySite.set(siteUrl, value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the API rejected the POST for a missing or stale CSRF token.
|
||||
*
|
||||
* @param {object} data - parsed JSON
|
||||
* @return {boolean}
|
||||
*/
|
||||
function isCsrfFailure(data) {
|
||||
if (data?.error !== 'malformed input') {
|
||||
return false
|
||||
}
|
||||
const errors = data.errors
|
||||
const text = typeof errors === 'string' ? errors : JSON.stringify(errors || '')
|
||||
return text.includes('Invalid Token')
|
||||
}
|
||||
|
||||
/**
|
||||
* GET a CSRF token when this site has none yet.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {string} [token] - Bearer token
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async function primeCsrf(siteUrl, token) {
|
||||
if (!siteUrl || csrfBySite.get(siteUrl)) {
|
||||
return
|
||||
}
|
||||
if (token) {
|
||||
await ttpRequest({ siteUrl, path: '/api/profile', method: 'GET', token, skipCsrfPrime: true })
|
||||
return
|
||||
}
|
||||
await ttpRequest({ siteUrl, path: '/api/login', method: 'GET', skipCsrfPrime: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the stored CSRF token to headers and the POST body.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {Record<string, string>} headers - request headers
|
||||
* @param {Record<string, string>} [form] - urlencoded fields
|
||||
* @param {FormData|Record<string, string|Blob>} [multipart] - multipart fields
|
||||
* @return {{form?: Record<string, string>, multipart?: FormData|Record<string, string|Blob>}}
|
||||
*/
|
||||
function attachCsrf(siteUrl, headers, form, multipart) {
|
||||
const csrf = csrfBySite.get(siteUrl)
|
||||
if (!csrf) {
|
||||
return { form, multipart }
|
||||
}
|
||||
headers['X-CSRF-Token'] = csrf
|
||||
if (multipart instanceof FormData) {
|
||||
if (!multipart.has('token')) {
|
||||
multipart.append('token', csrf)
|
||||
}
|
||||
return { form, multipart }
|
||||
}
|
||||
if (multipart && typeof multipart === 'object') {
|
||||
return { form, multipart: { ...multipart, token: csrf } }
|
||||
}
|
||||
if (form && typeof form === 'object') {
|
||||
return { form: { ...form, token: csrf }, multipart }
|
||||
}
|
||||
return { form: { token: csrf, submit: '1' }, multipart }
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a TTP API path and parse JSON.
|
||||
*
|
||||
@ -40,6 +140,8 @@ export function normalizeSiteUrl(raw) {
|
||||
* @param {Record<string, string>} [options.form] - urlencoded body
|
||||
* @param {Record<string, string|Blob>} [options.multipart] - multipart fields (avatar)
|
||||
* @param {Record<string, string|number>} [options.query] - query string
|
||||
* @param {boolean} [options.skipCsrfPrime] - skip the GET that harvests CSRF
|
||||
* @param {boolean} [options.retriedCsrf] - already retried after Invalid Token
|
||||
* @return {Promise<object>} - parsed JSON
|
||||
*/
|
||||
export async function ttpRequest(options) {
|
||||
@ -47,12 +149,19 @@ export async function ttpRequest(options) {
|
||||
const path = options.path
|
||||
const method = options.method || 'GET'
|
||||
const token = options.token
|
||||
const form = options.form
|
||||
const multipart = options.multipart
|
||||
const headers = { Accept: 'application/json' }
|
||||
let url = `${siteUrl}${path}`
|
||||
let form = options.form
|
||||
let multipart = options.multipart
|
||||
let body
|
||||
|
||||
if (method === 'POST' && !options.skipCsrfPrime) {
|
||||
await primeCsrf(siteUrl, token)
|
||||
const attached = attachCsrf(siteUrl, headers, form, multipart)
|
||||
form = attached.form
|
||||
multipart = attached.multipart
|
||||
}
|
||||
|
||||
if (options.query && typeof options.query === 'object') {
|
||||
const qs = new URLSearchParams()
|
||||
Object.entries(options.query).forEach(([key, value]) => {
|
||||
@ -92,7 +201,12 @@ export async function ttpRequest(options) {
|
||||
|
||||
let response
|
||||
try {
|
||||
response = await fetch(url, { method, headers, body, redirect: 'follow' })
|
||||
response = await net.fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
session: electronSession.defaultSession
|
||||
})
|
||||
} catch {
|
||||
throw new Error('Could not reach that site.')
|
||||
}
|
||||
@ -105,6 +219,13 @@ export async function ttpRequest(options) {
|
||||
throw new Error(`The site did not return API JSON (${response.status}).`)
|
||||
}
|
||||
|
||||
rememberCsrf(siteUrl, response, data)
|
||||
|
||||
if (method === 'POST' && isCsrfFailure(data) && !options.retriedCsrf) {
|
||||
clearCsrf(siteUrl)
|
||||
return ttpRequest({ ...options, retriedCsrf: true })
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
@ -403,7 +524,7 @@ export async function getProfile(siteUrl, token) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Save name, prefs, optional avatar. POST api/profile/update.
|
||||
* Save prefs and optional avatar. POST api/profile/update.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {string} token - Bearer token
|
||||
|
||||
@ -668,10 +668,6 @@
|
||||
<label class="form-label d-block" for="settings-avatar-file">Avatar</label>
|
||||
<input id="settings-avatar-file" class="form-control" type="file" accept="image/*" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="settings-name">Display name</label>
|
||||
<input id="settings-name" class="form-control" name="name" type="text" autocomplete="name" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="settings-gender">Gender</label>
|
||||
<select id="settings-gender" class="form-select" name="gender">
|
||||
|
||||
@ -270,7 +270,6 @@ function applyProfile(body) {
|
||||
profile = {
|
||||
id: user.id,
|
||||
username: user.username || session?.username || '',
|
||||
name: user.name || '',
|
||||
email: user.email || '',
|
||||
avatar: user.avatar || '',
|
||||
avatarUrl: user.avatarUrl || '',
|
||||
@ -575,8 +574,8 @@ function renderPages() {
|
||||
bugUnavailable.hidden = bugOk
|
||||
contactUnavailable.textContent = contactOk ? '' : 'Contact is not available on this site.'
|
||||
bugUnavailable.textContent = bugOk ? '' : 'Bug reports are not available on this site.'
|
||||
if (contactOk && profile.name && !document.getElementById('contact-name').value) {
|
||||
document.getElementById('contact-name').value = profile.name || profile.username || ''
|
||||
if (contactOk && !document.getElementById('contact-name').value) {
|
||||
document.getElementById('contact-name').value = profile.username || session.username || ''
|
||||
document.getElementById('contact-email').value = profile.email || ''
|
||||
}
|
||||
if (bugOk && session.siteUrl && !document.getElementById('bug-url').value) {
|
||||
@ -652,13 +651,12 @@ function renderPages() {
|
||||
document.getElementById('messages-new').hidden = !canSendMessages || Boolean(messages.unavailable)
|
||||
document.getElementById('thread-reply').hidden = !canSendMessages
|
||||
|
||||
const displayName = profile.name || profile.username || session.username || ''
|
||||
document.getElementById('profile-name').textContent = displayName
|
||||
document.getElementById('profile-name').textContent =
|
||||
profile.username || session.username || ''
|
||||
document.getElementById('profile-registered').textContent = profile.registered || ''
|
||||
document.getElementById('profile-last-login').textContent = profile.lastLogin || ''
|
||||
document.getElementById('profile-gender').textContent = profile.gender || ''
|
||||
|
||||
document.getElementById('settings-name').value = profile.name || ''
|
||||
document.getElementById('settings-gender').value = profile.gender
|
||||
document.getElementById('settings-newsletter').checked = profile.newsletter
|
||||
document.getElementById('settings-dark').checked = profile.darkMode
|
||||
@ -1429,14 +1427,12 @@ document.getElementById('settings-form').addEventListener('submit', async (event
|
||||
profile.timeFormat = document.getElementById('settings-time').value
|
||||
profile.pageLimit = document.getElementById('settings-limit').value
|
||||
profile.darkMode = document.getElementById('settings-dark').checked
|
||||
profile.name = document.getElementById('settings-name').value
|
||||
applyTheme(profile.darkMode)
|
||||
document.getElementById('profile-gender').textContent = profile.gender
|
||||
setMessage(status, 'Saved in preview only.')
|
||||
return
|
||||
}
|
||||
const fields = {
|
||||
name: document.getElementById('settings-name').value.trim(),
|
||||
gender: document.getElementById('settings-gender').value,
|
||||
newsletter: document.getElementById('settings-newsletter').checked ? '1' : '0',
|
||||
timezone: document.getElementById('settings-timezone').value,
|
||||
|
||||
Reference in New Issue
Block a user