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:
383
lib/api.dart
383
lib/api.dart
@ -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 {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user