mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(api): identify session clients correctly and attribute handoff sessions (#1727)
This commit is contained in:
1 parent
5660972c71
commit
9b0b703c9d
27 files changed
+589
-269
No files matched your search
@@ -6,7 +6,6 @@ import {THE_OTHER_PLATFORM} from '@fluxer/constants/src/ExternalPlatformConstant
|
||||
import {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME, PRODUCT_NAME} from './ProductConstants';
|
||||
|
||||
export {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME, PRODUCT_NAME};
|
||||
export const DESKTOP_PRODUCT_NAME = `${PRODUCT_NAME} Desktop`;
|
||||
export const PRODUCT_API_NAME = `${PRODUCT_NAME} API`;
|
||||
export const PRODUCT_HQ_COMMUNITY_NAME = `${PRODUCT_NAME} HQ`;
|
||||
export const CANARY_RELEASE_CHANNEL_NAME = `${PRODUCT_NAME} Canary`;
|
||||
|
||||
@@ -9,7 +9,6 @@ import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import {HttpError} from '@app/features/platform/types/EndpointError';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {failureCode} from '@app/features/platform/utils/ResponseInspection';
|
||||
import {isDesktop} from '@app/features/ui/utils/NativeUtils';
|
||||
import UserSettings from '@app/features/user/state/UserSettings';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
|
||||
@@ -24,9 +23,7 @@ import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSche
|
||||
import type {AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser';
|
||||
|
||||
const logger = new Logger('AuthService');
|
||||
const getPlatformHeaderValue = (): 'web' | 'desktop' | 'mobile' => (isDesktop() ? 'desktop' : 'web');
|
||||
const withPlatformHeader = (headers?: Record<string, string>): Record<string, string> => ({
|
||||
'X-Fluxer-Platform': getPlatformHeaderValue(),
|
||||
const withAuthLocaleHeader = (headers?: Record<string, string>): Record<string, string> => ({
|
||||
'Accept-Language': UserSettings.getLocale(),
|
||||
...(headers ?? {}),
|
||||
});
|
||||
@@ -247,7 +244,7 @@ export async function login({
|
||||
try {
|
||||
const response = await http.post<LoginResponse>(Endpoints.AUTH_LOGIN, {
|
||||
body: loginBody({email, password, inviteCode}),
|
||||
headers: withPlatformHeader(captchaHeaders({captchaToken, captchaType})),
|
||||
headers: withAuthLocaleHeader(captchaHeaders({captchaToken, captchaType})),
|
||||
});
|
||||
logger.debug('Login successful', {mfa: response.body?.mfa});
|
||||
return response.body;
|
||||
@@ -268,7 +265,7 @@ export async function loginMfaTotp(code: string, ticket: string, inviteCode?: st
|
||||
try {
|
||||
const response = await http.post<TokenResponse>(Endpoints.AUTH_LOGIN_MFA_TOTP, {
|
||||
body: mfaTotpBody(code, ticket, inviteCode),
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.debug('MFA TOTP authentication successful');
|
||||
@@ -288,7 +285,7 @@ export async function loginMfaWebAuthn(
|
||||
try {
|
||||
const httpResponse = await http.post<TokenResponse>(Endpoints.AUTH_LOGIN_MFA_WEBAUTHN, {
|
||||
body: mfaWebAuthnBody(response, challenge, ticket, inviteCode),
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
const responseBody = httpResponse.body;
|
||||
logger.debug('MFA WebAuthn authentication successful');
|
||||
@@ -303,7 +300,7 @@ export async function getWebAuthnMfaOptions(ticket: string): Promise<PublicKeyCr
|
||||
try {
|
||||
const response = await http.post<PublicKeyCredentialRequestOptionsJSON>(Endpoints.AUTH_LOGIN_MFA_WEBAUTHN_OPTIONS, {
|
||||
body: ticketBody(ticket),
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.debug('WebAuthn MFA options retrieved');
|
||||
@@ -317,7 +314,7 @@ export async function getWebAuthnMfaOptions(ticket: string): Promise<PublicKeyCr
|
||||
export async function getWebAuthnAuthenticationOptions(): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
try {
|
||||
const response = await http.post<PublicKeyCredentialRequestOptionsJSON>(Endpoints.AUTH_WEBAUTHN_OPTIONS, {
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.debug('WebAuthn authentication options retrieved');
|
||||
@@ -336,7 +333,7 @@ export async function authenticateWithWebAuthn(
|
||||
try {
|
||||
const httpResponse = await http.post<TokenResponse>(Endpoints.AUTH_WEBAUTHN_AUTHENTICATE, {
|
||||
body: webAuthnBody(response, challenge, inviteCode),
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
const responseBody = httpResponse.body;
|
||||
logger.debug('WebAuthn authentication successful');
|
||||
@@ -351,7 +348,7 @@ export async function register(data: RegisterData): Promise<RegisterResponse> {
|
||||
try {
|
||||
const response = await http.post<RegisterResponse>(Endpoints.AUTH_REGISTER, {
|
||||
body: registerBody(data),
|
||||
headers: withPlatformHeader(captchaHeaders(data)),
|
||||
headers: withAuthLocaleHeader(captchaHeaders(data)),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.info('Registration successful');
|
||||
@@ -370,7 +367,7 @@ export async function getUsernameSuggestions(globalName: string): Promise<Array<
|
||||
try {
|
||||
const response = await http.post<UsernameSuggestionsResponse>(Endpoints.AUTH_USERNAME_SUGGESTIONS, {
|
||||
body: {global_name: globalName},
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.debug('Username suggestions retrieved', {count: responseBody?.suggestions?.length || 0});
|
||||
@@ -389,7 +386,7 @@ export async function forgotPassword(
|
||||
try {
|
||||
await http.post(Endpoints.AUTH_FORGOT_PASSWORD, {
|
||||
body: {email},
|
||||
headers: withPlatformHeader(captchaHeaders({captchaToken, captchaType})),
|
||||
headers: withAuthLocaleHeader(captchaHeaders({captchaToken, captchaType})),
|
||||
});
|
||||
logger.debug('Password reset email sent');
|
||||
} catch (error) {
|
||||
@@ -402,7 +399,7 @@ export async function validateResetPasswordToken(token: string): Promise<boolean
|
||||
const response = await http.get<{
|
||||
valid: boolean;
|
||||
}>(Endpoints.AUTH_VALIDATE_RESET_PASSWORD_TOKEN(token), {
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
return response.body.valid;
|
||||
} catch (error) {
|
||||
@@ -415,7 +412,7 @@ export async function resetPassword(token: string, password: string): Promise<Re
|
||||
try {
|
||||
const response = await http.post<ResetPasswordResponse>(Endpoints.AUTH_RESET_PASSWORD, {
|
||||
body: {token, password},
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.info('Password reset successful');
|
||||
@@ -430,7 +427,7 @@ export async function revertEmailChange(token: string, password: string): Promis
|
||||
try {
|
||||
const response = await http.post<TokenResponse>(Endpoints.AUTH_EMAIL_REVERT, {
|
||||
body: {token, password},
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.info('Email revert successful');
|
||||
@@ -445,7 +442,7 @@ export async function verifyEmail(token: string): Promise<VerificationResult> {
|
||||
try {
|
||||
await http.post(Endpoints.AUTH_VERIFY_EMAIL, {
|
||||
body: tokenBody(token),
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
logger.info('Email verification successful');
|
||||
return VerificationResult.SUCCESS;
|
||||
@@ -463,7 +460,7 @@ export async function verifyEmail(token: string): Promise<VerificationResult> {
|
||||
export async function resendVerificationEmail(): Promise<VerificationResult> {
|
||||
try {
|
||||
await http.post(Endpoints.AUTH_RESEND_VERIFICATION, {
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
logger.info('Verification email resent');
|
||||
return VerificationResult.SUCCESS;
|
||||
@@ -486,7 +483,7 @@ export async function authorizeIp(token: string): Promise<VerificationResult> {
|
||||
try {
|
||||
await http.post(Endpoints.AUTH_AUTHORIZE_IP, {
|
||||
body: tokenBody(token),
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
logger.info('IP authorization successful');
|
||||
return VerificationResult.SUCCESS;
|
||||
@@ -504,7 +501,7 @@ export async function authorizeIp(token: string): Promise<VerificationResult> {
|
||||
export async function resendIpAuthorization(ticket: string): Promise<void> {
|
||||
await http.post(Endpoints.AUTH_IP_AUTHORIZATION_RESEND, {
|
||||
body: ticketBody(ticket),
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -517,7 +514,7 @@ export interface IpAuthorizationPollResult {
|
||||
|
||||
export async function pollIpAuthorization(ticket: string): Promise<IpAuthorizationPollResult> {
|
||||
const response = await http.get<IpAuthorizationPollResult>(Endpoints.AUTH_IP_AUTHORIZATION_POLL(ticket), {
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
return response.body;
|
||||
}
|
||||
@@ -547,7 +544,7 @@ export async function completeDesktopHandoff({
|
||||
}): Promise<void> {
|
||||
await http.post(Endpoints.AUTH_HANDOFF_COMPLETE, {
|
||||
body: {code, user_id: userId},
|
||||
headers: withPlatformHeader({Authorization: token}),
|
||||
headers: withAuthLocaleHeader({Authorization: token}),
|
||||
auth: 'none',
|
||||
});
|
||||
}
|
||||
@@ -643,7 +640,7 @@ export async function startSso({
|
||||
};
|
||||
const response = await http.post<SsoStartResponse>(Endpoints.AUTH_SSO_START, {
|
||||
body,
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
return response.body;
|
||||
}
|
||||
@@ -651,7 +648,7 @@ export async function startSso({
|
||||
export async function completeSso({code, state}: {code: string; state: string}): Promise<SsoCompleteResponse> {
|
||||
const response = await http.post<SsoCompleteResponse>(Endpoints.AUTH_SSO_COMPLETE, {
|
||||
body: {code, state},
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
return response.body;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import type {DesktopHandoffInfoResponse} from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import type {DesktopHandoffMode} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
|
||||
import styles from '@app/features/auth/flow/HandoffApprovalFlow.module.css';
|
||||
@@ -18,11 +17,6 @@ const SIGN_IN_CODE_DESCRIPTOR = msg({
|
||||
message: 'Sign-in code',
|
||||
comment: 'Short label in the authentication handoff approval flow. Keep the tone plain and specific.',
|
||||
});
|
||||
const PRODUCT_DESKTOP_DESCRIPTOR = msg({
|
||||
message: '{productName} Desktop',
|
||||
comment:
|
||||
'Display name for the product desktop client in the authentication handoff approval flow when the raw client name is Electron. Preserve {productName}; it is inserted by code.',
|
||||
});
|
||||
const CODE_LENGTH = 12;
|
||||
const VALID_CODE_PATTERN = /^[A-Za-z0-9]{12}$/;
|
||||
|
||||
@@ -53,10 +47,6 @@ function formatLocation(location: {
|
||||
return parts.length > 0 ? parts.join(', ') : null;
|
||||
}
|
||||
|
||||
function isElectronClientLabel(label: string): boolean {
|
||||
return label.trim().toLowerCase() === 'electron';
|
||||
}
|
||||
|
||||
interface HandoffApprovalFlowProps {
|
||||
mode: DesktopHandoffMode;
|
||||
error: string | null;
|
||||
@@ -164,11 +154,6 @@ export function HandoffApprovalFlow({
|
||||
const os = clientInfo?.os ?? null;
|
||||
const location = clientInfo?.location ? formatLocation(clientInfo.location) : null;
|
||||
const hasAnyDeviceInfo = Boolean(platform || os || location);
|
||||
const platformLabel = platform
|
||||
? isElectronClientLabel(platform)
|
||||
? i18n._(PRODUCT_DESKTOP_DESCRIPTOR, {productName: PRODUCT_NAME})
|
||||
: platform
|
||||
: null;
|
||||
return (
|
||||
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--4">
|
||||
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--3">
|
||||
@@ -183,13 +168,13 @@ export function HandoffApprovalFlow({
|
||||
</p>
|
||||
{hasAnyDeviceInfo ? (
|
||||
<div className={styles.deviceCard} data-flx="auth.flow.handoff-approval-flow.device-card">
|
||||
{platformLabel ? (
|
||||
{platform ? (
|
||||
<div className={styles.deviceRow} data-flx="auth.flow.handoff-approval-flow.device-row">
|
||||
<span className={styles.deviceLabel} data-flx="auth.flow.handoff-approval-flow.device-label">
|
||||
<Trans>Platform</Trans>
|
||||
</span>
|
||||
<span className={styles.deviceValue} data-flx="auth.flow.handoff-approval-flow.device-value">
|
||||
{platformLabel}
|
||||
{platform}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -7,6 +7,8 @@ export class AuthSession {
|
||||
readonly approxLastUsedAt: Date | null;
|
||||
readonly clientOs: string | null;
|
||||
readonly clientPlatform: string | null;
|
||||
readonly clientBrowser: string | null;
|
||||
readonly clientDevice: 'mobile' | 'desktop';
|
||||
readonly clientLocation: string | null;
|
||||
readonly maskedIp: string | null;
|
||||
readonly isCurrent: boolean;
|
||||
@@ -18,6 +20,8 @@ export class AuthSession {
|
||||
this.clientInfo = data.client_info ?? null;
|
||||
this.clientOs = this.clientInfo?.os ?? null;
|
||||
this.clientPlatform = this.clientInfo?.platform ?? null;
|
||||
this.clientBrowser = this.clientInfo?.browser ?? null;
|
||||
this.clientDevice = this.clientInfo?.device ?? 'desktop';
|
||||
this.clientLocation = getLocationLabel(this.clientInfo?.location ?? null);
|
||||
this.maskedIp = data.masked_ip ?? null;
|
||||
this.isCurrent = data.current;
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
SettingsTabContent,
|
||||
SettingsTabSection,
|
||||
} from '@app/features/app/components/dialogs/shared/SettingsTabLayout';
|
||||
import {DESKTOP_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {useElementOverflow} from '@app/features/app/hooks/useTextOverflow';
|
||||
import * as AuthSessionCommands from '@app/features/auth/commands/AuthSessionCommands';
|
||||
import {DeviceRevokeModal} from '@app/features/auth/components/modals/DeviceRevokeModal';
|
||||
@@ -75,7 +74,6 @@ const SELECTED_DEVICES_FOR_LOGOUT_DESCRIPTOR = msg({
|
||||
message: '{deviceCount, plural, one {# device selected for sign out} other {# devices selected for sign out}}',
|
||||
comment: 'Unsaved-changes banner text in the devices tab. Counts selected devices that will be signed out.',
|
||||
});
|
||||
const MOBILE_DEVICE_REGEX = /iOS|Android|Windows Phone|BlackBerry|Mobile/i;
|
||||
|
||||
const StatusDot = observer(() => (
|
||||
<div aria-hidden={true} className={styles.statusDot} data-flx="user.devices-tab.status-dot.status-dot" />
|
||||
@@ -127,8 +125,7 @@ const DeviceDetailRow = ({
|
||||
const DeviceDetailsModal = observer(({authSession, isCurrent}: {authSession: AuthSession; isCurrent: boolean}) => {
|
||||
const {i18n} = useLingui();
|
||||
const clientOs = authSession.clientOs ?? i18n._(UNKNOWN_DEVICE_DESCRIPTOR);
|
||||
const clientPlatform = authSession.clientPlatform ?? i18n._(UNKNOWN_DESCRIPTOR);
|
||||
const platformLabel = authSession.clientPlatform === DESKTOP_PRODUCT_NAME ? DESKTOP_PRODUCT_NAME : clientPlatform;
|
||||
const platformLabel = authSession.clientPlatform ?? i18n._(UNKNOWN_DESCRIPTOR);
|
||||
return (
|
||||
<Modal.Root size="small" centered data-flx="user.devices-tab.device-details-modal.modal-root">
|
||||
<Modal.Header title={<Trans>Device details</Trans>} data-flx="user.devices-tab.device-details-modal.header" />
|
||||
@@ -196,8 +193,8 @@ const AuthSessionItem: React.FC<AuthSessionProps> = observer(
|
||||
({authSession, isCurrent = false, isSelected, onSelect, index, selectionMode}) => {
|
||||
const {i18n} = useLingui();
|
||||
const clientOs = authSession.clientOs ?? i18n._(UNKNOWN_DEVICE_DESCRIPTOR);
|
||||
const clientPlatform = authSession.clientPlatform ?? i18n._(UNKNOWN_DESCRIPTOR);
|
||||
const isMobile = MOBILE_DEVICE_REGEX.test(authSession.clientOs ?? '');
|
||||
const platformLabel = authSession.clientPlatform ?? i18n._(UNKNOWN_DESCRIPTOR);
|
||||
const isMobile = authSession.clientDevice === 'mobile';
|
||||
const isSelectionInteractive = Boolean(selectionMode && !isCurrent && onSelect && index !== undefined);
|
||||
const openRevokeModal = () => {
|
||||
ModalCommands.push(
|
||||
@@ -234,7 +231,6 @@ const AuthSessionItem: React.FC<AuthSessionProps> = observer(
|
||||
selected: isSelected,
|
||||
onSelect,
|
||||
});
|
||||
const platformLabel = authSession.clientPlatform === DESKTOP_PRODUCT_NAME ? DESKTOP_PRODUCT_NAME : clientPlatform;
|
||||
const metadataLine = [authSession.clientLocation, isCurrent ? null : formatAuthSessionLastUsed(authSession, i18n)]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(' · ');
|
||||
@@ -254,9 +250,15 @@ const AuthSessionItem: React.FC<AuthSessionProps> = observer(
|
||||
</div>
|
||||
<div className={styles.authSessionInfo} data-flx="user.devices-tab.auth-session-item.auth-session-info">
|
||||
<span className={styles.authSessionTitle} data-flx="user.devices-tab.auth-session-item.auth-session-title">
|
||||
{clientOs}
|
||||
<StatusDot data-flx="user.devices-tab.auth-session-item.status-dot" />
|
||||
{platformLabel}
|
||||
{authSession.clientBrowser === null ? (
|
||||
platformLabel
|
||||
) : (
|
||||
<>
|
||||
{clientOs}
|
||||
<StatusDot data-flx="user.devices-tab.auth-session-item.status-dot" />
|
||||
{platformLabel}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{metadataLine && (
|
||||
<div
|
||||
|
||||
Reference in new issue
Block a user