2
0
mirror of https://github.com/inventree/inventree-app.git synced 2025-06-13 10:45:29 +00:00

Token auth (#434)

* Embed device platform information into token request

* Remove username and password from userProfile

* Display icon to show if profile has associated user token

* Remove username / password from login settings screen

* Refactor login procedure around token auth

* Refactoring

* Add profile login screen

- Username / password values are not stored
- Just to fetch api token

* Login with basic auth

* Pass profile to API when connecting

* Remove _BASE_URL accessor

- Fixes URL caching bug

* Add more context to login screen

* Add helper functions for unit tests

- Change default port to 8000 (makes testing easier with local inventree instance)

* api.dart handles basic auth now

* fix api_test.dart

* Further test improvements

* linting fixes

* Provide feedback when login fails

* More linting

* Record user details on login, and display in "about" widget

* Fix string lookup

* Add extra debug

* Fix auth values

* Fix user profile test
This commit is contained in:
Oliver
2023-10-23 01:29:16 +11:00
committed by GitHub
parent 382c8461f9
commit 76b6191a67
18 changed files with 1023 additions and 705 deletions

View File

@ -192,16 +192,13 @@ class InvenTreeAPI {
bool _strictHttps = false;
// Endpoint for requesting an API token
static const _URL_GET_TOKEN = "user/token/";
static const _URL_GET_ROLES = "user/roles/";
// Base URL for InvenTree API e.g. http://192.168.120.10:8000
String _BASE_URL = "";
static const _URL_TOKEN = "user/token/";
static const _URL_ROLES = "user/roles/";
static const _URL_ME = "user/me/";
// Accessors for various url endpoints
String get baseUrl {
String url = _BASE_URL;
String url = profile?.server ?? "";
if (!url.endsWith("/")) {
url += "/";
@ -242,21 +239,22 @@ class InvenTreeAPI {
// Available user roles (permissions) are loaded when connecting to the server
Map<String, dynamic> roles = {};
// Authentication token (initially empty, must be requested)
String _token = "";
// Profile authentication token
String get token => profile?.token ?? "";
bool get hasToken => token.isNotEmpty;
String? get serverAddress {
return profile?.server;
}
bool get hasToken => _token.isNotEmpty;
/*
* Check server connection and display messages if not connected.
* Useful as a precursor check before performing operations.
*/
bool checkConnection() {
// Firstly, is the server connected?
// Is the server connected?
if (!isConnected()) {
showSnackIcon(
@ -272,16 +270,20 @@ class InvenTreeAPI {
return true;
}
// Server instance information
String instance = "";
// Map of user information
Map<String, dynamic> userInfo = {};
// Server version information
String _version = "";
String get username => (userInfo["username"] ?? "") as String;
// API version of the connected server
int _apiVersion = 1;
// Map of server information
Map<String, dynamic> serverInfo = {};
int get apiVersion => _apiVersion;
String get serverInstance => (serverInfo["instance"] ?? "") as String;
String get serverVersion => (serverInfo["version"] ?? "") as String;
int get apiVersion => (serverInfo["apiVersion"] ?? 1) as int;
// Plugins enabled at API v34 and above
bool get pluginsEnabled => apiVersion >= 34 && (serverInfo["plugins_enabled"] ?? false) as bool;
// API endpoint for receiving purchase order line items was introduced in v12
bool get supportsPoReceive => apiVersion >= 12;
@ -330,13 +332,6 @@ class InvenTreeAPI {
bool get supportsBarcodePOReceiveEndpoint => isConnected() && apiVersion >= 139;
// Are plugins enabled on the server?
bool _pluginsEnabled = false;
// True plugin support requires API v34 or newer
// Returns True only if the server API version is new enough, and plugins are enabled
bool pluginsEnabled() => apiVersion >= 34 && _pluginsEnabled;
// Cached list of plugins (refreshed when we connect to the server)
List<InvenTreePlugin> _plugins = [];
@ -363,9 +358,6 @@ class InvenTreeAPI {
// Test if the provided plugin mixin is supported by any active plugins
bool supportsMixin(String mixin) => getPlugins(mixin: mixin).isNotEmpty;
// Getter for server version information
String get version => _version;
// Connection status flag - set once connection has been validated
bool _connected = false;
@ -379,33 +371,68 @@ class InvenTreeAPI {
return !isConnected() && _connecting;
}
/*
* Connect to the remote InvenTree server:
*
* - Check that the InvenTree server exists
* - Request user token from the server
* - Request user roles from the server
*/
Future<bool> _connect() async {
if (profile == null) return false;
/*
* Perform the required login steps, in sequence.
* Internal function, called by connectToServer()
*
* Performs the following steps:
*
* 1. Check the api/ endpoint to see if the sever exists
* 2. If no token available, perform user authentication
* 2. Check the api/user/me/ endpoint to see if the user is authenticated
* 3. If not authenticated, purge token, and exit
* 4. Request user roles
* 5. Request information on available plugins
*/
Future<bool> _connectToServer() async {
if (!await _checkServer()) {
return false;
}
if (!hasToken) {
return false;
}
if (!await _checkAuth()) {
showServerError(_URL_ME, L10().serverNotConnected, L10().serverAuthenticationError);
// Invalidate the token
if (profile != null) {
profile!.token = "";
await UserProfileDBManager().updateProfile(profile!);
}
return false;
}
if (!await _fetchRoles()) {
return false;
}
if (!await _fetchPlugins()) {
return false;
}
// Finally, connected
return true;
}
/*
* Check that the remote server is available.
* Ping the api/ endpoint, which does not require user authentication
*/
Future<bool> _checkServer() async {
String address = profile?.server ?? "";
String username = profile?.username ?? "";
String password = profile?.password ?? "";
address = address.trim();
username = username.trim();
password = password.trim();
// Cache the "strictHttps" setting, so we can use it later without async requirement
_strictHttps = await InvenTreeSettingsManager().getValue(INV_STRICT_HTTPS, false) as bool;
if (address.isEmpty || username.isEmpty || password.isEmpty) {
if (address.isEmpty) {
showSnackIcon(
L10().incompleteDetails,
icon: FontAwesomeIcons.circleExclamation,
success: false
L10().incompleteDetails,
icon: FontAwesomeIcons.circleExclamation,
success: false
);
return false;
}
@ -414,27 +441,24 @@ class InvenTreeAPI {
address = address + "/";
}
_BASE_URL = address;
// Cache the "strictHttps" setting, so we can use it later without async requirement
_strictHttps = await InvenTreeSettingsManager().getValue(INV_STRICT_HTTPS, false) as bool;
// Clear the list of available plugins
_plugins.clear();
debug("Connecting to ${apiUrl}");
debug("Connecting to ${apiUrl} -> username=${username}");
APIResponse response;
response = await get("", expectedStatusCode: 200);
APIResponse response = await get("", expectedStatusCode: 200);
if (!response.successful()) {
debug("Server returned invalid response: ${response.statusCode}");
showStatusCodeError(apiUrl, response.statusCode, details: response.data.toString());
return false;
}
var data = response.asMap();
Map<String, dynamic> _data = response.asMap();
// We expect certain response from the server
if (!data.containsKey("server") || !data.containsKey("version") || !data.containsKey("instance")) {
serverInfo = {..._data};
if (serverVersion.isEmpty) {
showServerError(
apiUrl,
L10().missingData,
@ -444,17 +468,9 @@ class InvenTreeAPI {
return false;
}
// Record server information
_version = (data["version"] ?? "") as String;
instance = (data["instance"] ?? "") as String;
if (apiVersion < _minApiVersion) {
// Default API version is 1 if not provided
_apiVersion = (data["apiVersion"] ?? 1) as int;
_pluginsEnabled = (data["plugins_enabled"] ?? false) as bool;
if (_apiVersion < _minApiVersion) {
String message = L10().serverApiVersion + ": ${_apiVersion}";
String message = L10().serverApiVersion + ": ${apiVersion}";
message += "\n";
message += L10().serverApiRequired + ": ${_minApiVersion}";
@ -472,18 +488,86 @@ class InvenTreeAPI {
return false;
}
/**
* Request user token information from the server
* This is the stage that we check username:password credentials!
*/
// Clear the existing token value
_token = "";
// At this point, we have a server which is responding
return true;
}
response = await get(_URL_GET_TOKEN);
/*
* Check that the user is authenticated
* Fetch the user information
*/
Future<bool> _checkAuth() async {
debug("Checking user auth @ ${_URL_ME}");
userInfo.clear();
final response = await get(_URL_ME);
if (response.successful() && response.statusCode == 200) {
userInfo = response.asMap();
return true;
} else {
debug("Auth request failed: Server returned status ${response.statusCode}");
if (response.data != null) {
debug("Server response: ${response.data.toString()}");
}
return false;
}
}
/*
* Fetch a token from the server,
* with a temporary authentication header
*/
Future<APIResponse> fetchToken(UserProfile userProfile, String username, String password) async {
debug("Fetching user token from ${userProfile.server}");
profile = userProfile;
// Form a name to request the token with
String platform_name = "inventree-mobile-app";
final deviceInfo = await getDeviceInfo();
if (Platform.isAndroid) {
platform_name += "-android";
} else if (Platform.isIOS) {
platform_name += "-ios";
} else if (Platform.isMacOS) {
platform_name += "-macos";
} else if (Platform.isLinux) {
platform_name += "-linux";
} else if (Platform.isWindows) {
platform_name += "-windows";
}
if (deviceInfo.containsKey("name")) {
platform_name += "-" + (deviceInfo["name"] as String);
}
if (deviceInfo.containsKey("model")) {
platform_name += "-" + (deviceInfo["model"] as String);
}
if (deviceInfo.containsKey("systemVersion")) {
platform_name += "-" + (deviceInfo["systemVersion"] as String);
}
// Construct auth header from username and password
String authHeader = "Basic " + base64Encode(utf8.encode("${username}:${password}"));
// Perform request to get a token
final response = await get(
_URL_TOKEN,
params: { "name": platform_name},
headers: { HttpHeaders.authorizationHeader: authHeader}
);
// Invalid response
if (!response.successful()) {
switch (response.statusCode) {
case 401:
case 403:
@ -500,67 +584,29 @@ class InvenTreeAPI {
debug("Token request failed: STATUS ${response.statusCode}");
return false;
if (response.data != null) {
debug("Response data: ${response.data.toString()}");
}
}
data = response.asMap();
final data = response.asMap();
if (!data.containsKey("token")) {
showServerError(
apiUrl,
L10().tokenMissing,
L10().tokenMissingFromResponse,
);
return false;
}
// Return the received token
_token = (data["token"] ?? "") as String;
debug("Received token from server");
bool result = false;
// Request user role information (async)
result = await getUserRoles();
if (!result) {
showServerError(
apiUrl,
L10().serverError,
L10().errorUserRoles,
L10().tokenMissing,
L10().tokenMissingFromResponse,
);
return false;
}
// Request plugin information (async)
result = await getPluginInformation();
// Save the token to the user profile
userProfile.token = (data["token"] ?? "") as String;
if (!result) {
showServerError(
apiUrl,
L10().serverError,
L10().errorPluginInfo
);
debug("Received token from server: ${userProfile.token}");
return false;
}
// Ok, probably pretty good...
if (_notification_timer == null) {
debug("starting notification timer");
_notification_timer = Timer.periodic(
Duration(seconds: 5),
(timer) {
_refreshNotifications();
});
}
return true;
await UserProfileDBManager().updateProfile(userProfile);
return response;
}
void disconnectFromServer() {
@ -568,24 +614,25 @@ class InvenTreeAPI {
_connected = false;
_connecting = false;
_token = "";
profile = null;
// Clear received settings
_globalSettings.clear();
_userSettings.clear();
serverInfo.clear();
_connectionStatusChanged();
}
// Public facing connection function
Future<bool> connectToServer() async {
/* Public facing connection function.
*/
Future<bool> connectToServer(UserProfile prf) async {
// Ensure server is first disconnected
disconnectFromServer();
// Load selected profile
profile = await UserProfileDBManager().getSelectedProfile();
profile = prf;
if (profile == null) {
showSnackIcon(
@ -596,12 +643,14 @@ class InvenTreeAPI {
return false;
}
_connecting = true;
// Cancel notification timer
_notification_timer?.cancel();
_connecting = true;
_connectionStatusChanged();
_connected = await _connect();
// Perform the actual connection routine
_connected = await _connectToServer();
_connecting = false;
if (_connected) {
@ -610,6 +659,15 @@ class InvenTreeAPI {
icon: FontAwesomeIcons.server,
success: true,
);
if (_notification_timer == null) {
debug("starting notification timer");
_notification_timer = Timer.periodic(
Duration(seconds: 5),
(timer) {
_refreshNotifications();
});
}
}
_connectionStatusChanged();
@ -620,18 +678,13 @@ class InvenTreeAPI {
/*
* Request the user roles (permissions) from the InvenTree server
*/
Future<bool> getUserRoles() async {
Future<bool> _fetchRoles() async {
roles.clear();
debug("API: Requesting user role data");
// Next we request the permissions assigned to the current user
// Note: 2021-02-27 this "roles" feature for the API was just introduced.
// Any "older" version of the server allows any API method for any logged in user!
// We will return immediately, but request the user roles in the background
final response = await get(_URL_GET_ROLES, expectedStatusCode: 200);
final response = await get(_URL_ROLES, expectedStatusCode: 200);
if (!response.successful()) {
return false;
@ -645,12 +698,17 @@ class InvenTreeAPI {
return true;
} else {
showServerError(
apiUrl,
L10().serverError,
L10().errorUserRoles,
);
return false;
}
}
// Request plugin information from the server
Future<bool> getPluginInformation() async {
Future<bool> _fetchPlugins() async {
_plugins.clear();
@ -690,7 +748,7 @@ class InvenTreeAPI {
if (roles[role] == null) {
debug("checkPermission - role '$role' is null!");
return true;
return false;
}
try {
@ -1045,7 +1103,14 @@ class InvenTreeAPI {
* @param method is the HTTP method e.g. "POST" / "PATCH" / "GET" etc;
* @param params is the request parameters
*/
Future<HttpClientRequest?> apiRequest(String url, String method, {Map<String, String> urlParams = const {}}) async {
Future<HttpClientRequest?> apiRequest(
String url,
String method,
{
Map<String, String> urlParams = const {},
Map<String, String> headers = const {},
}
) async {
var _url = makeApiUrl(url);
@ -1085,11 +1150,16 @@ class InvenTreeAPI {
try {
_request = await client.openUrl(method, _uri).timeout(Duration(seconds: 10));
// Set headers
// Default headers
defaultHeaders().forEach((key, value) {
_request?.headers.set(key, value);
});
// Custom headers
headers.forEach((key, value) {
_request?.headers.set(key, value);
});
return _request;
} on SocketException catch (error) {
debug("SocketException at ${url}: ${error.toString()}");
@ -1262,12 +1332,13 @@ class InvenTreeAPI {
* Perform a HTTP GET request
* Returns a json object (or null if did not complete)
*/
Future<APIResponse> get(String url, {Map<String, String> params = const {}, int? expectedStatusCode=200}) async {
Future<APIResponse> get(String url, {Map<String, String> params = const {}, Map<String, String> headers = const {}, int? expectedStatusCode=200}) async {
HttpClientRequest? request = await apiRequest(
url,
"GET",
urlParams: params,
headers: headers,
);
@ -1334,7 +1405,10 @@ class InvenTreeAPI {
Map<String, String> defaultHeaders() {
Map<String, String> headers = {};
headers[HttpHeaders.authorizationHeader] = _authorizationHeader();
if (hasToken) {
headers[HttpHeaders.authorizationHeader] = _authorizationHeader();
}
headers[HttpHeaders.acceptHeader] = "application/json";
headers[HttpHeaders.contentTypeHeader] = "application/json";
headers[HttpHeaders.acceptLanguageHeader] = currentLocale;
@ -1342,11 +1416,10 @@ class InvenTreeAPI {
return headers;
}
// Construct a token authorization header
String _authorizationHeader() {
if (_token.isNotEmpty) {
return "Token $_token";
} else if (profile != null) {
return "Basic " + base64Encode(utf8.encode("${profile?.username}:${profile?.password}"));
if (token.isNotEmpty) {
return "Token ${token}";
} else {
return "";
}
@ -1579,3 +1652,5 @@ class InvenTreeAPI {
});
}
}