Skip to content
Open

update #2313

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions assets/translations/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@
"info(rich)": "Made with ❤️ by Hiddify - ${tap_source(Open Source)} (${tap_license(License)})"
},
"pages": {
"auth": {
"title": "Sign in",
"subtitle": "Sign in with Telegram to access the service",
"signIn": "Sign in with Telegram",
"signingIn": "Signing in..."
},
"home": {
"title": "Home",
"quickSettings": "Quick setting options"
Expand Down Expand Up @@ -688,9 +694,18 @@
"reconnect": "Reconnect",
"reconnectMsg": "Reconnecting for taking into account the changes..."
},
"errors": {
"unexpected": "Unexpected error",
"connection": {
"errors": {
"unexpected": "Unexpected error",
"auth": {
"unexpected": "Sign-in is temporarily unavailable, please try again later",
"notConfigured": "Sign-in is temporarily unavailable, please try again later",
"invalidToken": "Could not sign in, please try again",
"telegramLogin": "Could not sign in, please try again",
"accountBlocked": "Your account has been blocked, please contact support",
"rateLimited": "Too many attempts, please wait a minute",
"sessionExpired": "Session expired, please sign in again"
},
"connection": {
"unexpected": "Unexpected connection error",
"timeout": "Connection timeout",
"badResponse": "Bad response",
Expand Down
15 changes: 15 additions & 0 deletions assets/translations/ru.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@
"info(rich)": "Сделано с ❤️ Hiddify - ${tap_source(Открытый исходный код)} (${tap_license(Лицензия)})"
},
"pages": {
"auth": {
"title": "Вход в аккаунт",
"subtitle": "Войдите через Telegram, чтобы получить доступ к сервису",
"signIn": "Войти через Telegram",
"signingIn": "Вход..."
},
"home": {
"title": "Главная",
"quickSettings": "Опции быстрых настроек"
Expand Down Expand Up @@ -708,6 +714,15 @@
"invalidUrl": "Неверный URL",
"canceledByUser": "Отменено пользователем"
},
"auth": {
"unexpected": "Вход временно недоступен, попробуйте позже",
"notConfigured": "Вход временно недоступен, попробуйте позже",
"invalidToken": "Не удалось войти, попробуйте ещё раз",
"telegramLogin": "Не удалось войти, попробуйте ещё раз",
"accountBlocked": "Ваш аккаунт заблокирован, обратитесь в поддержку",
"rateLimited": "Слишком много попыток, подождите минуту",
"sessionExpired": "Сессия истекла, войдите снова"
},
"connectivity": {
"unexpected": "Непредвиденный сбой",
"missingVpnPermission": "Отсутствует разрешение на VPN",
Expand Down
10 changes: 10 additions & 0 deletions lib/bootstrap.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import 'package:hiddify/core/preferences/general_preferences.dart';
import 'package:hiddify/core/preferences/preferences_migration.dart';
import 'package:hiddify/core/preferences/preferences_provider.dart';
import 'package:hiddify/features/app/widget/app.dart';
import 'package:hiddify/features/auth/data/auth_data_providers.dart';
import 'package:hiddify/features/auth/notifier/auth_notifier.dart';
import 'package:hiddify/features/auto_start/notifier/auto_start_notifier.dart';
import 'package:hiddify/features/chain/model/chain_enum.dart';
import 'package:hiddify/features/chain/notifier/chain_profile_notifier.dart';
Expand Down Expand Up @@ -121,6 +123,14 @@ Future<void> lazyBootstrap(WidgetsBinding widgetsBinding, Environment env) async
}
}

// Restore a persisted auth session (tokens from secure storage) before the
// app builds so returning users land on the home screen instead of /login.
// The auth interceptor on the Cabinet Dio is installed here as well.
await _init("auth session", () async {
container.read(authInterceptorProvider);
await container.read(authNotifierProvider.notifier).restoreSession();
});

Logger.bootstrap.info("bootstrap took [${stopWatch.elapsedMilliseconds}ms]");
stopWatch.stop();

Expand Down
2 changes: 1 addition & 1 deletion lib/core/directories/directories_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class AppDirectories extends _$AppDirectories with InfraLogger {
final baseDir = await getApplicationSupportDirectory();
final workingDir = Platform.isAndroid ? await _getAndroidWorkingDirectory() : baseDir;
final tempDir = await getTemporaryDirectory();
dirs = (baseDir: baseDir, workingDir: workingDir!, tempDir: tempDir);
dirs = (baseDir: baseDir, workingDir: workingDir, tempDir: tempDir);
}

if (!dirs.baseDir.existsSync()) {
Expand Down
2 changes: 1 addition & 1 deletion lib/core/http_client/dio_http_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import 'package:hiddify/utils/custom_loggers.dart';
class DioHttpClient with InfraLogger {
final Map<String, Dio> _dio = {};
DioHttpClient({required Duration timeout, required this.userAgent, required bool debug}) {
for (var mode in ["proxy", "direct", "both"]) {
for (final mode in ["proxy", "direct", "both"]) {
_dio[mode] = Dio(
BaseOptions(
connectTimeout: timeout,
Expand Down
2 changes: 1 addition & 1 deletion lib/core/logger/logger_controller.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import 'dart:io';

import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:hiddify/core/logger/custom_logger.dart';
import 'package:hiddify/utils/custom_loggers.dart';
import 'package:loggy/loggy.dart';
import 'package:flutter/foundation.dart' show kIsWeb;

class LoggerController extends LoggyPrinter with InfraLogger {
LoggerController(this.consolePrinter, this.otherPrinters);
Expand Down
35 changes: 35 additions & 0 deletions lib/core/model/app_config.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/// Compile-time configuration for the Bedolaga Cabinet (Remnawave) backend.
///
/// These values must be provided at build time via `--dart-define`, e.g.:
/// ```
/// flutter run --dart-define=CABINET_URL=https://cabinet.example.com \
/// --dart-define=TELEGRAM_OIDC_CLIENT_ID=123456789
/// ```
///
/// Secrets/IDs are intentionally NOT hardcoded here.
class AppConfig {
const AppConfig._();

/// Base URL of the Cabinet API, e.g. `https://cabinet.example.com`.
static const String cabinetUrl = String.fromEnvironment("CABINET_URL");

/// Numeric Telegram bot ID used as OIDC client_id.
static const String telegramOidcClientId = String.fromEnvironment("TELEGRAM_OIDC_CLIENT_ID");

/// Path to the Telegram OIDC login endpoint.
static const String telegramOidcLoginPath = "/cabinet/auth/telegram/oidc";

/// Path to the refresh-token endpoint.
///
/// If the backend exposes a different refresh path than the login endpoint,
/// provide it via `--dart-define=TELEGRAM_OIDC_REFRESH_PATH=...`. Otherwise it
/// falls back to the login endpoint (which per the documented protocol is used
/// for exchanging tokens).
static const String telegramOidcRefreshPath = String.fromEnvironment(
"TELEGRAM_OIDC_REFRESH_PATH",
defaultValue: "/cabinet/auth/telegram/oidc",
);

/// Convenience guard: whether the auth config is fully provided at build time.
static bool get isConfigured => cabinetUrl.isNotEmpty && telegramOidcClientId.isNotEmpty;
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ class WindowsProtocolHandler extends ProtocolHandler {
}

String _sanitize(String value) {
value = value.replaceAll(r'%s', '%1').replaceAll(r'"', '\\"');
value = value.replaceAll('%s', '%1').replaceAll('"', '\\"');
return '"$value"';
}
}
4 changes: 4 additions & 0 deletions lib/core/router/go_router/refresh_listenable.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:hiddify/core/preferences/general_preferences.dart';
import 'package:hiddify/core/router/deep_linking/my_app_links.dart';
import 'package:hiddify/features/auth/notifier/auth_notifier.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

// For temporary storage of the link received from AppLinks.
Expand All @@ -15,6 +16,9 @@ class RefreshListenable extends ChangeNotifier {
}
});
ref.listen(Preferences.introCompleted, (_, _) => notifyListeners());
// Re-run router redirects when the auth state changes (e.g. login/logout/
// force-logout), so the user is moved to/from /login automatically.
ref.listen(authNotifierProvider, (_, _) => notifyListeners());
}
final Ref ref;
}
27 changes: 27 additions & 0 deletions lib/core/router/go_router/routing_config_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import 'package:hiddify/core/router/go_router/helper/active_breakpoint_notifier.
import 'package:hiddify/core/router/go_router/helper/custom_transition.dart';
import 'package:hiddify/core/router/go_router/refresh_listenable.dart';
import 'package:hiddify/features/about/widget/about_page.dart';
import 'package:hiddify/features/auth/notifier/auth_notifier.dart';
import 'package:hiddify/features/auth/widgets/login_page.dart';
import 'package:hiddify/features/home/widget/home_page.dart';
import 'package:hiddify/features/intro/widget/intro_page.dart';
import 'package:hiddify/features/log/overview/logs_page.dart';
Expand Down Expand Up @@ -110,6 +112,30 @@ class RoutingConfigNotifier extends _$RoutingConfigNotifier {
// Prevent showing chainOptions while hasAnyProfile == false
return '/settings';
}

// Auth gate: protect the main screens from unauthenticated users.
// While session restore is in flight, do not redirect yet (avoids a
// flash of the login page for returning users).
switch (ref.watch(authNotifierProvider)) {
case AuthLoading():
// Keep the current location while the session is being restored.
break;
case AuthLoggedOut():
if (state.matchedLocation != '/login' && state.matchedLocation != '/intro') {
return '/login';
}
break;
case AuthLoggedIn():
if (state.matchedLocation == '/login') {
return '/home';
}
break;
case AuthError():
if (state.matchedLocation != '/login' && state.matchedLocation != '/intro') {
return '/login';
}
break;
}
return null;
},
routes: <RouteBase>[
Expand Down Expand Up @@ -309,6 +335,7 @@ class RoutingConfigNotifier extends _$RoutingConfigNotifier {
],
),
GoRoute(name: 'intro', path: '/intro', builder: (_, _) => const IntroPage()),
GoRoute(name: 'login', path: '/login', builder: (_, _) => const LoginPage()),
],
);
}
Expand Down
2 changes: 1 addition & 1 deletion lib/core/theme/app_theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class AppTheme {
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: mode.trueBlack ? Colors.black : scheme.background,
scaffoldBackgroundColor: mode.trueBlack ? Colors.black : scheme.surface,
fontFamily: fontFamily,
extensions: const <ThemeExtension<dynamic>>{ConnectionButtonTheme.light},
);
Expand Down
2 changes: 1 addition & 1 deletion lib/core/widget/skeleton_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class Skeleton extends StatelessWidget {
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
shape: shape,
color: theme.hintColor.withOpacity(.16),
color: theme.hintColor.withValues(alpha: .16),
),
),
);
Expand Down
3 changes: 0 additions & 3 deletions lib/features/app_update/notifier/app_update_notifier.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:hiddify/core/app_info/app_info_provider.dart';
import 'package:hiddify/core/localization/locale_preferences.dart';
import 'package:hiddify/core/model/constants.dart';
import 'package:hiddify/core/model/environment.dart';
import 'package:hiddify/core/preferences/preferences_provider.dart';
import 'package:hiddify/core/utils/preferences_utils.dart';
import 'package:hiddify/features/app_update/data/app_update_data_providers.dart';
Expand Down Expand Up @@ -31,7 +29,6 @@ Upgrader upgrader(Ref ref) => Upgrader(
onMacOS: () => UpgraderAppcastStore(appcastURL: Constants.appCastUrl),
onWeb: () => UpgraderAppcastStore(appcastURL: Constants.appCastUrl),
),
debugLogging: false && _debugUpgrader && kDebugMode,
// durationUntilAlertAgain: const Duration(hours: 12),
messages: UpgraderMessages(code: ref.watch(localePreferencesProvider).languageCode),
);
Expand Down
119 changes: 119 additions & 0 deletions lib/features/auth/data/auth_api_client.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import 'package:dio/dio.dart';
import 'package:hiddify/core/model/app_config.dart';
import 'package:hiddify/features/auth/domain/auth_failure.dart';
import 'package:hiddify/features/auth/domain/auth_tokens.dart';
import 'package:hiddify/utils/custom_loggers.dart';

/// Typed exceptions raised by [AuthApiClient].
///
/// These are deliberately separate from the raw [DioException] so the UI layer
/// can map them to user-friendly messages without parsing HTTP status codes.
sealed class AuthException implements Exception {
const AuthException(this.failure);

final AuthFailure failure;

@override
String toString() => '$runtimeType($failure)';
}

class AuthNotConfiguredException extends AuthException {
const AuthNotConfiguredException() : super(const AuthFailure.notConfigured());
}

class AuthInvalidTokenException extends AuthException {
const AuthInvalidTokenException() : super(const AuthFailure.invalidToken());
}

class AuthAccountBlockedException extends AuthException {
const AuthAccountBlockedException() : super(const AuthFailure.accountBlocked());
}

class AuthRateLimitedException extends AuthException {
const AuthRateLimitedException() : super(const AuthFailure.rateLimited());
}

class AuthUnexpectedException extends AuthException {
AuthUnexpectedException([Object? error, StackTrace? stackTrace])
: super(AuthFailure.unexpected(error, stackTrace));
}

/// HTTP client for the Bedolaga Cabinet API (Remnawave).
///
/// Uses a dedicated plain [Dio] instance — NOT the proxy-aware
/// [DioHttpClient] used for sing-box / subscription traffic.
class AuthApiClient with InfraLogger {
AuthApiClient({
required Dio dio,
required String baseUrl,
}) : _dio = dio {
_dio.options.baseUrl = baseUrl;
_dio.options.connectTimeout = const Duration(seconds: 15);
_dio.options.receiveTimeout = const Duration(seconds: 15);
_dio.options.sendTimeout = const Duration(seconds: 15);
_dio.options.headers['Accept'] = 'application/json';
_dio.options.headers['Content-Type'] = 'application/json';
}

final Dio _dio;

/// Exchanges a Telegram OIDC `id_token` for a token pair.
Future<AuthTokens> loginWithTelegram(String idToken) async {
try {
final response = await _dio.post<Map<String, dynamic>>(
AppConfig.telegramOidcLoginPath,
data: <String, dynamic>{
'id_token': idToken,
'campaign_slug': null,
'referral_code': null,
},
);
return _parseTokens(response.data);
} on AuthException {
rethrow;
} on DioException catch (e) {
throw _mapDioException(e);
}
}

/// Refreshes an expired `access_token` using the `refresh_token`.
Future<AuthTokens> refreshTokens(String refreshToken) async {
try {
final response = await _dio.post<Map<String, dynamic>>(
AppConfig.telegramOidcRefreshPath,
data: <String, dynamic>{
'refresh_token': refreshToken,
},
);
return _parseTokens(response.data);
} on AuthException {
rethrow;
} on DioException catch (e) {
throw _mapDioException(e);
}
}

AuthTokens _parseTokens(Map<String, dynamic>? data) {
if (data == null) {
throw const AuthUnexpectedException();
}
return AuthTokens.fromJson(data);
}

AuthException _mapDioException(DioException e) {
final statusCode = e.response?.statusCode;
switch (statusCode) {
case 400:
// 400 == OIDC not configured on the backend (technical issue, not the user's).
return const AuthNotConfiguredException();
case 401:
return const AuthInvalidTokenException();
case 403:
return const AuthAccountBlockedException();
case 429:
return const AuthRateLimitedException();
default:
return AuthUnexpectedException(e.error, e.stackTrace);
}
}
}
Loading