TF-571 Setup and get OIDC configuration
This commit is contained in:
@@ -40,6 +40,9 @@ android {
|
|||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
// The number of method references in a .dex file cannot exceed 64K
|
// The number of method references in a .dex file cannot exceed 64K
|
||||||
multiDexEnabled true
|
multiDexEnabled true
|
||||||
|
manifestPlaceholders = [
|
||||||
|
'appAuthRedirectScheme': 'teammail.mobile'
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="https" />
|
<data android:scheme="https" />
|
||||||
</intent>
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.APP_BROWSER" />
|
||||||
|
<data android:scheme="https" />
|
||||||
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<string>teammail-mobile</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ import 'package:model/model.dart';
|
|||||||
|
|
||||||
abstract class AuthenticationOIDCDataSource {
|
abstract class AuthenticationOIDCDataSource {
|
||||||
Future<bool> checkOIDCIsAvailable(OIDCRequest oidcRequest);
|
Future<bool> checkOIDCIsAvailable(OIDCRequest oidcRequest);
|
||||||
|
|
||||||
|
Future<OIDCConfiguration> getOIDCConfiguration(Uri baseUri);
|
||||||
}
|
}
|
||||||
@@ -17,4 +17,13 @@ class AuthenticationOIDCDataSourceImpl extends AuthenticationOIDCDataSource {
|
|||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<OIDCConfiguration> getOIDCConfiguration(Uri baseUri) {
|
||||||
|
return Future.sync(() async {
|
||||||
|
return await _oidcHttpClient.getOIDCConfiguration(baseUri);
|
||||||
|
}).catchError((error) {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -29,4 +29,8 @@ class OIDCHttpClient {
|
|||||||
return OIDCResponse.fromJson(jsonDecode(result));
|
return OIDCResponse.fromJson(jsonDecode(result));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<OIDCConfiguration> getOIDCConfiguration(Uri baseUri) async {
|
||||||
|
return OIDCConfiguration.initial();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:model/oidc/oidc_configuration.dart';
|
||||||
import 'package:model/oidc/request/oidc_request.dart';
|
import 'package:model/oidc/request/oidc_request.dart';
|
||||||
import 'package:tmail_ui_user/features/login/data/datasource/authentication_oidc_datasource.dart';
|
import 'package:tmail_ui_user/features/login/data/datasource/authentication_oidc_datasource.dart';
|
||||||
import 'package:tmail_ui_user/features/login/domain/repository/authentication_oidc_repository.dart';
|
import 'package:tmail_ui_user/features/login/domain/repository/authentication_oidc_repository.dart';
|
||||||
@@ -11,4 +12,9 @@ class AuthenticationOIDCRepositoryImpl extends AuthenticationOIDCRepository {
|
|||||||
Future<bool> checkOIDCIsAvailable(OIDCRequest oidcRequest) {
|
Future<bool> checkOIDCIsAvailable(OIDCRequest oidcRequest) {
|
||||||
return _oidcDataSource.checkOIDCIsAvailable(oidcRequest);
|
return _oidcDataSource.checkOIDCIsAvailable(oidcRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<OIDCConfiguration> getOIDCConfiguration(Uri baseUri) {
|
||||||
|
return _oidcDataSource.getOIDCConfiguration(baseUri);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
|
|
||||||
|
import 'package:model/oidc/oidc_configuration.dart';
|
||||||
import 'package:model/oidc/request/oidc_request.dart';
|
import 'package:model/oidc/request/oidc_request.dart';
|
||||||
|
|
||||||
abstract class AuthenticationOIDCRepository {
|
abstract class AuthenticationOIDCRepository {
|
||||||
Future<bool> checkOIDCIsAvailable(OIDCRequest oidcRequest);
|
Future<bool> checkOIDCIsAvailable(OIDCRequest oidcRequest);
|
||||||
|
|
||||||
|
Future<OIDCConfiguration> getOIDCConfiguration(Uri baseUri);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:core/core.dart';
|
||||||
|
import 'package:model/model.dart';
|
||||||
|
|
||||||
|
class GetOIDCConfigurationSuccess extends UIState {
|
||||||
|
|
||||||
|
final OIDCConfiguration oidcConfiguration;
|
||||||
|
|
||||||
|
GetOIDCConfigurationSuccess(this.oidcConfiguration);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [oidcConfiguration];
|
||||||
|
}
|
||||||
|
|
||||||
|
class GetOIDCConfigurationFailure extends FeatureFailure {
|
||||||
|
final dynamic exception;
|
||||||
|
|
||||||
|
GetOIDCConfigurationFailure(this.exception);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [exception];
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:core/core.dart';
|
||||||
|
import 'package:dartz/dartz.dart';
|
||||||
|
import 'package:tmail_ui_user/features/login/domain/repository/authentication_oidc_repository.dart';
|
||||||
|
import 'package:tmail_ui_user/features/login/domain/state/get_oidc_configuration_state.dart';
|
||||||
|
|
||||||
|
class GetOIDCConfigurationInteractor {
|
||||||
|
final AuthenticationOIDCRepository _oidcRepository;
|
||||||
|
|
||||||
|
GetOIDCConfigurationInteractor(this._oidcRepository);
|
||||||
|
|
||||||
|
Future<Either<Failure, Success>> execute(Uri baseUri) async {
|
||||||
|
try {
|
||||||
|
final oidcConfiguration = await _oidcRepository.getOIDCConfiguration(baseUri);
|
||||||
|
log('GetOIDCConfigurationInteractor::execute(): oidcConfiguration: $oidcConfiguration');
|
||||||
|
return Right<Failure, Success>(GetOIDCConfigurationSuccess(oidcConfiguration));
|
||||||
|
} catch (e) {
|
||||||
|
log('GetOIDCConfigurationInteractor::execute(): ERROR: $e');
|
||||||
|
return Left<Failure, Success>(GetOIDCConfigurationFailure(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import 'package:core/presentation/views/text/text_field_builder.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
import 'package:tmail_ui_user/features/login/domain/state/get_oidc_configuration_state.dart';
|
||||||
import 'package:tmail_ui_user/features/login/presentation/login_controller.dart';
|
import 'package:tmail_ui_user/features/login/presentation/login_controller.dart';
|
||||||
import 'package:tmail_ui_user/features/login/presentation/login_form_type.dart';
|
import 'package:tmail_ui_user/features/login/presentation/login_form_type.dart';
|
||||||
import 'package:tmail_ui_user/features/login/presentation/state/login_state.dart';
|
import 'package:tmail_ui_user/features/login/presentation/state/login_state.dart';
|
||||||
@@ -35,6 +36,9 @@ abstract class BaseLoginView extends GetWidget<LoginController> {
|
|||||||
return AppLocalizations.of(context).requiredEmail;
|
return AppLocalizations.of(context).requiredEmail;
|
||||||
} else if (failure is LoginMissPasswordAction) {
|
} else if (failure is LoginMissPasswordAction) {
|
||||||
return AppLocalizations.of(context).requiredPassword;
|
return AppLocalizations.of(context).requiredPassword;
|
||||||
|
} else if (failure is GetOIDCConfigurationFailure
|
||||||
|
|| failure is LoginCanNotVerifySSOConfigurationAction) {
|
||||||
|
return AppLocalizations.of(context).canNotVerifySSOConfiguration;
|
||||||
} else {
|
} else {
|
||||||
return AppLocalizations.of(context).unknown_error_login_message;
|
return AppLocalizations.of(context).unknown_error_login_message;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import 'package:tmail_ui_user/features/login/domain/repository/authentication_re
|
|||||||
import 'package:tmail_ui_user/features/login/domain/repository/credential_repository.dart';
|
import 'package:tmail_ui_user/features/login/domain/repository/credential_repository.dart';
|
||||||
import 'package:tmail_ui_user/features/login/domain/usecases/authentication_user_interactor.dart';
|
import 'package:tmail_ui_user/features/login/domain/usecases/authentication_user_interactor.dart';
|
||||||
import 'package:tmail_ui_user/features/login/domain/usecases/check_oidc_is_available_interactor.dart';
|
import 'package:tmail_ui_user/features/login/domain/usecases/check_oidc_is_available_interactor.dart';
|
||||||
|
import 'package:tmail_ui_user/features/login/domain/usecases/get_oidc_configuration_interactor.dart';
|
||||||
import 'package:tmail_ui_user/features/login/presentation/login_controller.dart';
|
import 'package:tmail_ui_user/features/login/presentation/login_controller.dart';
|
||||||
|
|
||||||
class LoginBindings extends BaseBindings {
|
class LoginBindings extends BaseBindings {
|
||||||
@@ -32,6 +33,7 @@ class LoginBindings extends BaseBindings {
|
|||||||
Get.find<DynamicUrlInterceptors>(),
|
Get.find<DynamicUrlInterceptors>(),
|
||||||
Get.find<AuthorizationInterceptors>(),
|
Get.find<AuthorizationInterceptors>(),
|
||||||
Get.find<CheckOIDCIsAvailableInteractor>(),
|
Get.find<CheckOIDCIsAvailableInteractor>(),
|
||||||
|
Get.find<GetOIDCConfigurationInteractor>(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +58,9 @@ class LoginBindings extends BaseBindings {
|
|||||||
Get.lazyPut(() => CheckOIDCIsAvailableInteractor(
|
Get.lazyPut(() => CheckOIDCIsAvailableInteractor(
|
||||||
Get.find<AuthenticationOIDCRepository>(),
|
Get.find<AuthenticationOIDCRepository>(),
|
||||||
));
|
));
|
||||||
|
Get.lazyPut(() => GetOIDCConfigurationInteractor(
|
||||||
|
Get.find<AuthenticationOIDCRepository>(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import 'package:get/get.dart';
|
|||||||
import 'package:model/model.dart';
|
import 'package:model/model.dart';
|
||||||
import 'package:tmail_ui_user/features/login/domain/state/authentication_user_state.dart';
|
import 'package:tmail_ui_user/features/login/domain/state/authentication_user_state.dart';
|
||||||
import 'package:tmail_ui_user/features/login/domain/state/check_oidc_is_available_state.dart';
|
import 'package:tmail_ui_user/features/login/domain/state/check_oidc_is_available_state.dart';
|
||||||
|
import 'package:tmail_ui_user/features/login/domain/state/get_oidc_configuration_state.dart';
|
||||||
import 'package:tmail_ui_user/features/login/domain/usecases/authentication_user_interactor.dart';
|
import 'package:tmail_ui_user/features/login/domain/usecases/authentication_user_interactor.dart';
|
||||||
import 'package:tmail_ui_user/features/login/domain/usecases/check_oidc_is_available_interactor.dart';
|
import 'package:tmail_ui_user/features/login/domain/usecases/check_oidc_is_available_interactor.dart';
|
||||||
|
import 'package:tmail_ui_user/features/login/domain/usecases/get_oidc_configuration_interactor.dart';
|
||||||
import 'package:tmail_ui_user/features/login/presentation/login_form_type.dart';
|
import 'package:tmail_ui_user/features/login/presentation/login_form_type.dart';
|
||||||
import 'package:tmail_ui_user/features/login/presentation/state/login_state.dart';
|
import 'package:tmail_ui_user/features/login/presentation/state/login_state.dart';
|
||||||
import 'package:tmail_ui_user/main/routes/app_routes.dart';
|
import 'package:tmail_ui_user/main/routes/app_routes.dart';
|
||||||
@@ -20,6 +22,7 @@ class LoginController extends GetxController {
|
|||||||
final DynamicUrlInterceptors _dynamicUrlInterceptors;
|
final DynamicUrlInterceptors _dynamicUrlInterceptors;
|
||||||
final AuthorizationInterceptors _authorizationInterceptors;
|
final AuthorizationInterceptors _authorizationInterceptors;
|
||||||
final CheckOIDCIsAvailableInteractor _checkOIDCIsAvailableInteractor;
|
final CheckOIDCIsAvailableInteractor _checkOIDCIsAvailableInteractor;
|
||||||
|
final GetOIDCConfigurationInteractor _getOIDCConfigurationInteractor;
|
||||||
|
|
||||||
final TextEditingController urlInputController = TextEditingController();
|
final TextEditingController urlInputController = TextEditingController();
|
||||||
|
|
||||||
@@ -28,6 +31,7 @@ class LoginController extends GetxController {
|
|||||||
this._dynamicUrlInterceptors,
|
this._dynamicUrlInterceptors,
|
||||||
this._authorizationInterceptors,
|
this._authorizationInterceptors,
|
||||||
this._checkOIDCIsAvailableInteractor,
|
this._checkOIDCIsAvailableInteractor,
|
||||||
|
this._getOIDCConfigurationInteractor,
|
||||||
);
|
);
|
||||||
|
|
||||||
var loginState = LoginState(Right(LoginInitAction())).obs;
|
var loginState = LoginState(Right(LoginInitAction())).obs;
|
||||||
@@ -61,13 +65,13 @@ class LoginController extends GetxController {
|
|||||||
|
|
||||||
void _checkOIDCIsAvailable() async {
|
void _checkOIDCIsAvailable() async {
|
||||||
final baseUri = BuildUtils.isWeb ? _parseUri(AppConfig.baseUrl) : _parseUri(_urlText);
|
final baseUri = BuildUtils.isWeb ? _parseUri(AppConfig.baseUrl) : _parseUri(_urlText);
|
||||||
|
log('LoginController::_checkOIDCIsAvailable(): baseUri: $baseUri');
|
||||||
if (baseUri == null) {
|
if (baseUri == null) {
|
||||||
loginState.value = LoginState(Left(LoginMissUrlAction()));
|
loginState.value = LoginState(Left(LoginMissUrlAction()));
|
||||||
} else {
|
} else {
|
||||||
loginState.value = LoginState(Right(LoginLoadingAction()));
|
loginState.value = LoginState(Right(LoginLoadingAction()));
|
||||||
await _checkOIDCIsAvailableInteractor
|
await _checkOIDCIsAvailableInteractor
|
||||||
.execute(OIDCRequest(baseUrl: baseUri.path))
|
.execute(OIDCRequest(baseUrl: baseUri.origin))
|
||||||
.then((response) => response.fold(
|
.then((response) => response.fold(
|
||||||
(failure) => _showFormLoginWithCredentialAction(),
|
(failure) => _showFormLoginWithCredentialAction(),
|
||||||
(success) => success is CheckOIDCIsAvailableSuccess
|
(success) => success is CheckOIDCIsAvailableSuccess
|
||||||
@@ -93,7 +97,12 @@ class LoginController extends GetxController {
|
|||||||
|
|
||||||
void handleLoginPressed() {
|
void handleLoginPressed() {
|
||||||
if (loginFormType.value == LoginFormType.ssoForm) {
|
if (loginFormType.value == LoginFormType.ssoForm) {
|
||||||
|
final baseUri = kIsWeb ? _parseUri(AppConfig.baseUrl) : _parseUri(_urlText);
|
||||||
|
if (baseUri != null) {
|
||||||
|
_getOIDCConfiguration(baseUri);
|
||||||
|
} else {
|
||||||
|
loginState.value = LoginState(Left(LoginMissUrlAction()));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
final baseUri = kIsWeb ? _parseUri(AppConfig.baseUrl) : _parseUri(_urlText);
|
final baseUri = kIsWeb ? _parseUri(AppConfig.baseUrl) : _parseUri(_urlText);
|
||||||
final userName = _parseUserName(_userNameText);
|
final userName = _parseUserName(_userNameText);
|
||||||
@@ -110,6 +119,30 @@ class LoginController extends GetxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _getOIDCConfiguration(Uri baseUri) async {
|
||||||
|
loginState.value = LoginState(Right(LoginLoadingAction()));
|
||||||
|
await _getOIDCConfigurationInteractor.execute(baseUri)
|
||||||
|
.then((response) => response.fold(
|
||||||
|
(failure) {
|
||||||
|
if (failure is GetOIDCConfigurationFailure) {
|
||||||
|
loginState.value = LoginState(Left(failure));
|
||||||
|
} else {
|
||||||
|
loginState.value = LoginState(Left(LoginCanNotVerifySSOConfigurationAction()));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(success) {
|
||||||
|
if (success is GetOIDCConfigurationSuccess) {
|
||||||
|
_getOIDCConfigurationSuccess(success);
|
||||||
|
} else {
|
||||||
|
loginState.value = LoginState(Left(LoginCanNotVerifySSOConfigurationAction()));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _getOIDCConfigurationSuccess(GetOIDCConfigurationSuccess success) {
|
||||||
|
loginState.value = LoginState(Right(success));
|
||||||
|
}
|
||||||
|
|
||||||
void _loginAction(Uri baseUrl, UserName userName, Password password) async {
|
void _loginAction(Uri baseUrl, UserName userName, Password password) async {
|
||||||
loginState.value = LoginState(Right(LoginLoadingAction()));
|
loginState.value = LoginState(Right(LoginLoadingAction()));
|
||||||
await _authenticationInteractor.execute(baseUrl, userName, password)
|
await _authenticationInteractor.execute(baseUrl, userName, password)
|
||||||
@@ -125,11 +158,12 @@ class LoginController extends GetxController {
|
|||||||
pushAndPop(AppRoutes.SESSION);
|
pushAndPop(AppRoutes.SESSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _loginFailureAction(AuthenticationUserFailure failure) {
|
void _loginFailureAction(FeatureFailure failure) {
|
||||||
loginState.value = LoginState(Left(failure));
|
loginState.value = LoginState(Left(failure));
|
||||||
}
|
}
|
||||||
|
|
||||||
void formatUrl(String url) {
|
void formatUrl(String url) {
|
||||||
|
log('LoginController::formatUrl(): $url');
|
||||||
if (url.isValid()) {
|
if (url.isValid()) {
|
||||||
urlInputController.text = url.removePrefix();
|
urlInputController.text = url.removePrefix();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,3 +42,9 @@ class LoginMissPasswordAction extends Failure {
|
|||||||
@override
|
@override
|
||||||
List<Object?> get props => [];
|
List<Object?> get props => [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class LoginCanNotVerifySSOConfigurationAction extends Failure {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
@@ -1393,4 +1393,10 @@ class AppLocalizations {
|
|||||||
'Sign-in with my SSO account',
|
'Sign-in with my SSO account',
|
||||||
name: 'loginInputSSOMessage');
|
name: 'loginInputSSOMessage');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String get canNotVerifySSOConfiguration {
|
||||||
|
return Intl.message(
|
||||||
|
'Can not verify SSO configuration, please check with your system administrator',
|
||||||
|
name: 'canNotVerifySSOConfiguration');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -77,3 +77,4 @@ export 'identity/identity_request_dto.dart';
|
|||||||
export 'oidc/response/oidc_response.dart';
|
export 'oidc/response/oidc_response.dart';
|
||||||
export 'oidc/response/oidc_link_dto.dart';
|
export 'oidc/response/oidc_link_dto.dart';
|
||||||
export 'oidc/request/oidc_request.dart';
|
export 'oidc/request/oidc_request.dart';
|
||||||
|
export 'oidc/oidc_configuration.dart';
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
class OIDCConfiguration with EquatableMixin {
|
||||||
|
static const redirectOidc = 'teammail.mobile://oauthredirect';
|
||||||
|
static const wellKnownOpenId = '.well-known/openid-configuration';
|
||||||
|
|
||||||
|
final String authority;
|
||||||
|
final String clientId;
|
||||||
|
final String redirectUrl = redirectOidc;
|
||||||
|
final List<String> scopes;
|
||||||
|
|
||||||
|
OIDCConfiguration({
|
||||||
|
required this.authority,
|
||||||
|
required this.clientId,
|
||||||
|
required this.scopes
|
||||||
|
});
|
||||||
|
|
||||||
|
factory OIDCConfiguration.initial() {
|
||||||
|
return OIDCConfiguration(
|
||||||
|
authority: 'https://auth.upn.integration-open-paas.org/auth/realms/mobile',
|
||||||
|
clientId: 'teammail-mobile',
|
||||||
|
scopes: ['openid', 'offline_access']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get discoveryUrl {
|
||||||
|
if (authority.endsWith('/')) {
|
||||||
|
return authority + wellKnownOpenId;
|
||||||
|
} else {
|
||||||
|
return authority + '/' + wellKnownOpenId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
authority,
|
||||||
|
clientId,
|
||||||
|
scopes
|
||||||
|
];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user