[Auth] Allow for configuring a custom server (#726)

## Description
Users can now tap on the onboarding screen **7 times** to bring up a
page where they can configure the endpoint the app should be connecting
to.


![self-host](https://github.com/ente-io/ente/assets/1161789/10f61f6d-0fb3-4f5b-889e-806ca7607525)



## Tests
- [x] Verified that production flows are working as expected
- [x] Verified that configuring the endpoint to a local instance lets
you
  - [x] Connect to that instance 
  - [x] Create an account
  - [x] Add a key
  - [x] Modify a key
  - [x] Logout and log back in
This commit is contained in:
Vishnu Mohandas 2024-03-07 13:24:30 +05:30 committed by GitHub
commit 90bbc54bb5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 318 additions and 178 deletions

View file

@ -6,6 +6,7 @@ import 'dart:typed_data';
import 'package:bip39/bip39.dart' as bip39;
import 'package:ente_auth/core/constants.dart';
import 'package:ente_auth/core/event_bus.dart';
import 'package:ente_auth/events/endpoint_updated_event.dart';
import 'package:ente_auth/events/signed_in_event.dart';
import 'package:ente_auth/events/signed_out_event.dart';
import 'package:ente_auth/models/key_attributes.dart';
@ -42,6 +43,7 @@ class Configuration {
static const userIDKey = "user_id";
static const hasMigratedSecureStorageKey = "has_migrated_secure_storage";
static const hasOptedForOfflineModeKey = "has_opted_for_offline_mode";
static const endPointKey = "endpoint";
final List<String> onlineSecureKeys = [
keyKey,
secretKeyKey,
@ -317,7 +319,12 @@ class Configuration {
}
String getHttpEndpoint() {
return endpoint;
return _preferences.getString(endPointKey) ?? endpoint;
}
Future<void> setHttpEndpoint(String endpoint) async {
await _preferences.setString(endPointKey, endpoint);
Bus.instance.fire(EndpointUpdatedEvent());
}
String? getToken() {

View file

@ -2,28 +2,24 @@ import 'dart:io';
import 'package:dio/dio.dart';
import 'package:ente_auth/core/configuration.dart';
import 'package:ente_auth/core/constants.dart';
import 'package:ente_auth/core/event_bus.dart';
import 'package:ente_auth/events/endpoint_updated_event.dart';
import 'package:fk_user_agent/fk_user_agent.dart';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
int kConnectTimeout = 15000;
class Network {
// apiEndpoint points to the Ente server's API endpoint
static const apiEndpoint = String.fromEnvironment(
"endpoint",
defaultValue: kDefaultProductionEndpoint,
);
late Dio _dio;
late Dio _enteDio;
Future<void> init() async {
await FkUserAgent.init();
final packageInfo = await PackageInfo.fromPlatform();
final preferences = await SharedPreferences.getInstance();
final endpoint = Configuration.instance.getHttpEndpoint();
_dio = Dio(
BaseOptions(
connectTimeout: kConnectTimeout,
@ -34,10 +30,10 @@ class Network {
},
),
);
_dio.interceptors.add(RequestIdInterceptor());
_enteDio = Dio(
BaseOptions(
baseUrl: apiEndpoint,
baseUrl: endpoint,
connectTimeout: kConnectTimeout,
headers: {
HttpHeaders.userAgentHeader: FkUserAgent.userAgent,
@ -46,7 +42,13 @@ class Network {
},
),
);
_enteDio.interceptors.add(EnteRequestInterceptor(preferences, apiEndpoint));
_setupInterceptors(endpoint);
Bus.instance.on<EndpointUpdatedEvent>().listen((event) {
final endpoint = Configuration.instance.getHttpEndpoint();
_enteDio.options.baseUrl = endpoint;
_setupInterceptors(endpoint);
});
}
Network._privateConstructor();
@ -55,34 +57,41 @@ class Network {
Dio getDio() => _dio;
Dio get enteDio => _enteDio;
void _setupInterceptors(String endpoint) {
_dio.interceptors.clear();
_dio.interceptors.add(RequestIdInterceptor());
_enteDio.interceptors.clear();
_enteDio.interceptors.add(EnteRequestInterceptor(endpoint));
}
}
class RequestIdInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
// ignore: prefer_const_constructors
options.headers.putIfAbsent("x-request-id", () => Uuid().v4().toString());
options.headers
.putIfAbsent("x-request-id", () => const Uuid().v4().toString());
return super.onRequest(options, handler);
}
}
class EnteRequestInterceptor extends Interceptor {
final SharedPreferences _preferences;
final String enteEndpoint;
final String endpoint;
EnteRequestInterceptor(this._preferences, this.enteEndpoint);
EnteRequestInterceptor(this.endpoint);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (kDebugMode) {
assert(
options.baseUrl == enteEndpoint,
options.baseUrl == endpoint,
"interceptor should only be used for API endpoint",
);
}
// ignore: prefer_const_constructors
options.headers.putIfAbsent("x-request-id", () => Uuid().v4().toString());
final String? tokenValue = _preferences.getString(Configuration.tokenKey);
options.headers
.putIfAbsent("x-request-id", () => const Uuid().v4().toString());
final String? tokenValue = Configuration.instance.getToken();
if (tokenValue != null) {
options.headers.putIfAbsent("X-Auth-Token", () => tokenValue);
}

View file

@ -0,0 +1,3 @@
import 'package:ente_auth/events/event.dart';
class EndpointUpdatedEvent extends Event {}

View file

@ -1,43 +1,29 @@
import 'package:dio/dio.dart';
import 'package:ente_auth/core/configuration.dart';
import 'package:ente_auth/core/errors.dart';
import 'package:ente_auth/core/network.dart';
import 'package:ente_auth/models/authenticator/auth_entity.dart';
import 'package:ente_auth/models/authenticator/auth_key.dart';
class AuthenticatorGateway {
final Dio _dio;
final Configuration _config;
late String _basedEndpoint;
late Dio _enteDio;
AuthenticatorGateway(this._dio, this._config) {
_basedEndpoint = _config.getHttpEndpoint() + "/authenticator";
AuthenticatorGateway() {
_enteDio = Network.instance.enteDio;
}
Future<void> createKey(String encKey, String header) async {
await _dio.post(
_basedEndpoint + "/key",
await _enteDio.post(
"/authenticator/key",
data: {
"encryptedKey": encKey,
"header": header,
},
options: Options(
headers: {
"X-Auth-Token": _config.getToken(),
},
),
);
}
Future<AuthKey> getKey() async {
try {
final response = await _dio.get(
_basedEndpoint + "/key",
options: Options(
headers: {
"X-Auth-Token": _config.getToken(),
},
),
);
final response = await _enteDio.get("/authenticator/key");
return AuthKey.fromMap(response.data);
} on DioError catch (e) {
if (e.response != null && (e.response!.statusCode ?? 0) == 404) {
@ -51,17 +37,12 @@ class AuthenticatorGateway {
}
Future<AuthEntity> createEntity(String encryptedData, String header) async {
final response = await _dio.post(
_basedEndpoint + "/entity",
final response = await _enteDio.post(
"/authenticator/entity",
data: {
"encryptedData": encryptedData,
"header": header,
},
options: Options(
headers: {
"X-Auth-Token": _config.getToken(),
},
),
);
return AuthEntity.fromMap(response.data);
}
@ -71,50 +52,35 @@ class AuthenticatorGateway {
String encryptedData,
String header,
) async {
await _dio.put(
_basedEndpoint + "/entity",
await _enteDio.put(
"/authenticator/entity",
data: {
"id": id,
"encryptedData": encryptedData,
"header": header,
},
options: Options(
headers: {
"X-Auth-Token": _config.getToken(),
},
),
);
}
Future<void> deleteEntity(
String id,
) async {
await _dio.delete(
_basedEndpoint + "/entity",
await _enteDio.delete(
"/authenticator/entity",
queryParameters: {
"id": id,
},
options: Options(
headers: {
"X-Auth-Token": _config.getToken(),
},
),
);
}
Future<List<AuthEntity>> getDiff(int sinceTime, {int limit = 500}) async {
try {
final response = await _dio.get(
_basedEndpoint + "/entity/diff",
final response = await _enteDio.get(
"/authenticator/entity/diff",
queryParameters: {
"sinceTime": sinceTime,
"limit": limit,
},
options: Options(
headers: {
"X-Auth-Token": _config.getToken(),
},
),
);
final List<AuthEntity> authEntities = <AuthEntity>[];
final diff = response.data["diff"] as List;

View file

@ -408,5 +408,12 @@
"hearUsExplanation": "We don't track app installs. It'd help if you told us where you found us!",
"waitingForBrowserRequest": "Waiting for browser request...",
"launchPasskeyUrlAgain": "Launch passkey URL again",
"passkey": "Passkey"
"passkey": "Passkey",
"developerSettingsWarning":"Are you sure that you want to modify Developer settings?",
"developerSettings": "Developer settings",
"serverEndpoint": "Server endpoint",
"invalidEndpoint": "Invalid endpoint",
"invalidEndpointMessage": "Sorry, the endpoint you entered is invalid. Please enter a valid endpoint and try again.",
"endpointUpdatedMessage": "Endpoint updated successfully",
"customEndpoint": "Connected to {endpoint}"
}

View file

@ -17,6 +17,8 @@ import 'package:ente_auth/ui/common/gradient_button.dart';
import 'package:ente_auth/ui/components/buttons/button_widget.dart';
import 'package:ente_auth/ui/components/models/button_result.dart';
import 'package:ente_auth/ui/home_page.dart';
import 'package:ente_auth/ui/settings/developer_settings_page.dart';
import 'package:ente_auth/ui/settings/developer_settings_widget.dart';
import 'package:ente_auth/ui/settings/language_picker.dart';
import 'package:ente_auth/utils/dialog_util.dart';
import 'package:ente_auth/utils/navigation_util.dart';
@ -33,8 +35,12 @@ class OnboardingPage extends StatefulWidget {
}
class _OnboardingPageState extends State<OnboardingPage> {
static const kDeveloperModeTapCountThreshold = 7;
late StreamSubscription<TriggerLogoutEvent> _triggerLogoutEvent;
int _developerModeTapCount = 0;
@override
void initState() {
_triggerLogoutEvent =
@ -56,114 +62,142 @@ class _OnboardingPageState extends State<OnboardingPage> {
final l10n = context.l10n;
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 40.0, horizontal: 40),
child: Column(
children: [
Column(
children: [
kDebugMode
? GestureDetector(
child: const Align(
alignment: Alignment.topRight,
child: Text("Lang"),
),
onTap: () async {
final locale = await getLocale();
routeToPage(
context,
LanguageSelectorPage(
appSupportedLocales,
(locale) async {
await setLocale(locale);
App.setLocale(context, locale);
},
locale,
child: GestureDetector(
onTap: () async {
_developerModeTapCount++;
if (_developerModeTapCount >= kDeveloperModeTapCountThreshold) {
_developerModeTapCount = 0;
final result = await showChoiceDialog(
context,
title: l10n.developerSettings,
firstButtonLabel: l10n.yes,
body: l10n.developerSettingsWarning,
isDismissible: false,
);
if (result?.action == ButtonAction.first) {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (BuildContext context) {
return const DeveloperSettingsPage();
},
),
);
setState(() {});
}
}
},
child: Center(
child: SingleChildScrollView(
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 40.0, horizontal: 40),
child: Column(
children: [
Column(
children: [
kDebugMode
? GestureDetector(
child: const Align(
alignment: Alignment.topRight,
child: Text("Lang"),
),
onTap: () async {
final locale = await getLocale();
routeToPage(
context,
LanguageSelectorPage(
appSupportedLocales,
(locale) async {
await setLocale(locale);
App.setLocale(context, locale);
},
locale,
),
).then((value) {
setState(() {});
});
},
)
: const SizedBox(),
Image.asset(
"assets/sheild-front-gradient.png",
width: 200,
height: 200,
),
const SizedBox(height: 12),
const Text(
"ente",
style: TextStyle(
fontWeight: FontWeight.bold,
fontFamily: 'Montserrat',
fontSize: 42,
),
),
const SizedBox(height: 4),
Text(
"Authenticator",
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 32),
Text(
l10n.onBoardingBody,
textAlign: TextAlign.center,
style:
Theme.of(context).textTheme.titleLarge!.copyWith(
color: Colors.white38,
),
).then((value) {
setState(() {});
});
},
)
: const SizedBox(),
Image.asset(
"assets/sheild-front-gradient.png",
width: 200,
height: 200,
),
const SizedBox(height: 12),
const Text(
"ente",
style: TextStyle(
fontWeight: FontWeight.bold,
fontFamily: 'Montserrat',
fontSize: 42,
),
],
),
const SizedBox(height: 100),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: GradientButton(
onTap: _navigateToSignUpPage,
text: l10n.newUser,
),
const SizedBox(height: 4),
Text(
"Authenticator",
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 32),
Text(
l10n.onBoardingBody,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge!.copyWith(
color: Colors.white38,
),
const SizedBox(height: 4),
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: Hero(
tag: "log_in",
child: ElevatedButton(
style: Theme.of(context)
.colorScheme
.optionalActionButtonStyle,
onPressed: _navigateToSignInPage,
child: Text(
l10n.existingUser,
style: const TextStyle(
color: Colors.black, // same for both themes
),
),
],
),
const SizedBox(height: 100),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: GradientButton(
onTap: _navigateToSignUpPage,
text: l10n.newUser,
),
),
const SizedBox(height: 4),
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: Hero(
tag: "log_in",
child: ElevatedButton(
style: Theme.of(context)
.colorScheme
.optionalActionButtonStyle,
onPressed: _navigateToSignInPage,
child: Text(
l10n.existingUser,
style: const TextStyle(
color: Colors.black, // same for both themes
),
),
),
),
),
const SizedBox(height: 4),
Container(
width: double.infinity,
padding: const EdgeInsets.only(top: 20, bottom: 20),
child: GestureDetector(
onTap: _optForOfflineMode,
child: Center(
child: Text(
l10n.useOffline,
style: body.copyWith(
color: Theme.of(context).colorScheme.mutedTextColor,
const SizedBox(height: 4),
Container(
width: double.infinity,
padding: const EdgeInsets.only(top: 20, bottom: 20),
child: GestureDetector(
onTap: _optForOfflineMode,
child: Center(
child: Text(
l10n.useOffline,
style: body.copyWith(
color:
Theme.of(context).colorScheme.mutedTextColor,
),
),
),
),
),
),
],
const DeveloperSettingsWidget(),
],
),
),
),
),

View file

@ -5,7 +5,6 @@ import 'dart:math';
import 'package:ente_auth/core/configuration.dart';
import 'package:ente_auth/core/errors.dart';
import 'package:ente_auth/core/event_bus.dart';
import 'package:ente_auth/core/network.dart';
import 'package:ente_auth/events/codes_updated_event.dart';
import 'package:ente_auth/events/signed_in_event.dart';
import 'package:ente_auth/events/trigger_logout_event.dart';
@ -26,6 +25,7 @@ enum AccountMode {
online,
offline,
}
extension on AccountMode {
bool get isOnline => this == AccountMode.online;
bool get isOffline => this == AccountMode.offline;
@ -56,7 +56,7 @@ class AuthenticatorService {
_prefs = await SharedPreferences.getInstance();
_db = AuthenticatorDB.instance;
_offlineDb = OfflineAuthenticatorDB.instance;
_gateway = AuthenticatorGateway(Network.instance.getDio(), _config);
_gateway = AuthenticatorGateway();
if (Configuration.instance.hasConfiguredAccount()) {
unawaited(onlineSync());
}
@ -154,7 +154,7 @@ class AuthenticatorService {
} else {
debugPrint("Skipping delete since account mode is offline");
}
if(accountMode.isOnline) {
if (accountMode.isOnline) {
await _db.deleteByIDs(generatedIDs: [genID]);
} else {
await _offlineDb.deleteByIDs(generatedIDs: [genID]);
@ -163,7 +163,7 @@ class AuthenticatorService {
Future<bool> onlineSync() async {
try {
if(getAccountMode().isOffline) {
if (getAccountMode().isOffline) {
debugPrint("Skipping sync since account mode is offline");
return false;
}
@ -253,7 +253,7 @@ class AuthenticatorService {
}
Future<Uint8List> getOrCreateAuthDataKey(AccountMode mode) async {
if(mode.isOffline) {
if (mode.isOffline) {
return _config.getOfflineSecretKey()!;
}
if (_config.getAuthSecretKey() != null) {

View file

@ -7,10 +7,6 @@ class UserStore {
late SharedPreferences _preferences;
static final UserStore instance = UserStore._privateConstructor();
static const endpoint = String.fromEnvironment(
"endpoint",
defaultValue: "https://api.ente.io",
);
Future<void> init() async {
_preferences = await SharedPreferences.getInstance();

View file

@ -0,0 +1,89 @@
import 'package:dio/dio.dart';
import 'package:ente_auth/core/configuration.dart';
import 'package:ente_auth/l10n/l10n.dart';
import 'package:ente_auth/ui/common/gradient_button.dart';
import 'package:ente_auth/utils/dialog_util.dart';
import 'package:ente_auth/utils/toast_util.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
class DeveloperSettingsPage extends StatefulWidget {
const DeveloperSettingsPage({super.key});
@override
_DeveloperSettingsPageState createState() => _DeveloperSettingsPageState();
}
class _DeveloperSettingsPageState extends State<DeveloperSettingsPage> {
final _logger = Logger('DeveloperSettingsPage');
final _urlController = TextEditingController();
@override
void dispose() {
_urlController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
_logger.info(
"Current endpoint is: " + Configuration.instance.getHttpEndpoint(),
);
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.developerSettings),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: _urlController,
decoration: InputDecoration(
labelText: context.l10n.serverEndpoint,
hintText: Configuration.instance.getHttpEndpoint(),
),
autofocus: true,
),
const SizedBox(height: 40),
GradientButton(
onTap: () async {
String url = _urlController.text;
_logger.info("Entered endpoint: " + url);
try {
final uri = Uri.parse(url);
if ((uri.scheme == "http" || uri.scheme == "https")) {
await _ping(url);
await Configuration.instance.setHttpEndpoint(url);
showToast(context, context.l10n.endpointUpdatedMessage);
Navigator.of(context).pop();
} else {
throw const FormatException();
}
} catch (e) {
showErrorDialog(
context,
context.l10n.invalidEndpoint,
context.l10n.invalidEndpointMessage,
);
}
},
text: context.l10n.saveAction,
),
],
),
),
);
}
Future<void> _ping(String endpoint) async {
try {
final response = await Dio().get(endpoint + '/ping');
if (response.data['message'] != 'pong') {
throw Exception('Invalid response');
}
} catch (e) {
throw Exception('Error occurred: $e');
}
}
}

View file

@ -0,0 +1,27 @@
import 'package:ente_auth/core/configuration.dart';
import 'package:ente_auth/core/constants.dart';
import 'package:ente_auth/l10n/l10n.dart';
import 'package:flutter/material.dart';
class DeveloperSettingsWidget extends StatelessWidget {
const DeveloperSettingsWidget({super.key});
@override
Widget build(BuildContext context) {
final endpoint = Configuration.instance.getHttpEndpoint();
if (endpoint != kDefaultProductionEndpoint) {
final endpointURI = Uri.parse(endpoint);
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Text(
context.l10n.customEndpoint(
endpointURI.host + ":" + endpointURI.port.toString(),
),
style: Theme.of(context).textTheme.bodySmall,
),
);
} else {
return const SizedBox.shrink();
}
}
}

View file

@ -16,6 +16,7 @@ import 'package:ente_auth/ui/settings/account_section_widget.dart';
import 'package:ente_auth/ui/settings/app_version_widget.dart';
import 'package:ente_auth/ui/settings/data/data_section_widget.dart';
import 'package:ente_auth/ui/settings/data/export_widget.dart';
import 'package:ente_auth/ui/settings/developer_settings_widget.dart';
import 'package:ente_auth/ui/settings/general_section_widget.dart';
import 'package:ente_auth/ui/settings/security_section_widget.dart';
import 'package:ente_auth/ui/settings/social_section_widget.dart';
@ -149,6 +150,7 @@ class SettingsPage extends StatelessWidget {
sectionSpacing,
const AboutSectionWidget(),
const AppVersionWidget(),
const DeveloperSettingsWidget(),
const SupportDevWidget(),
const Padding(
padding: EdgeInsets.only(bottom: 60),

View file

@ -203,7 +203,7 @@
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 1300;
LastUpgradeCheck = 1430;
ORGANIZATIONNAME = "";
TargetAttributes = {
33CC10EC2044A3C60003C045 = {

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1300"
LastUpgradeVersion = "1430"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"