Initial commit

This commit is contained in:
Anton Stubenbord
2022-10-30 14:15:37 +01:00
commit cb797df7d2
272 changed files with 16278 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
import 'dart:io';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/core/store/local_vault.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/features/login/model/authentication_information.dart';
import 'package:flutter_paperless_mobile/features/login/model/client_certificate.dart';
import 'package:flutter_paperless_mobile/features/login/model/user_credentials.model.dart';
import 'package:flutter_paperless_mobile/features/login/services/authentication.service.dart';
import 'package:flutter_paperless_mobile/features/settings/model/application_settings_state.dart';
import 'package:injectable/injectable.dart';
const authenticationKey = "authentication";
@singleton
class AuthenticationCubit extends Cubit<AuthenticationState> {
final LocalVault localStore;
final AuthenticationService authenticationService;
AuthenticationCubit(this.localStore, this.authenticationService)
: super(AuthenticationState.initial);
Future<void> initialize() {
return restoreSessionState();
}
Future<void> login({
required UserCredentials credentials,
required String serverUrl,
ClientCertificate? clientCertificate,
}) async {
assert(credentials.username != null && credentials.password != null);
try {
registerSecurityContext(clientCertificate);
} on TlsException catch (_) {
throw const ErrorMessage(ErrorCode.invalidClientCertificateConfiguration);
}
emit(
AuthenticationState(
isAuthenticated: false,
wasLoginStored: false,
authentication: AuthenticationInformation(
username: credentials.username!,
password: credentials.password!,
serverUrl: serverUrl,
token: "",
clientCertificate: clientCertificate,
),
),
);
final token = await authenticationService.login(
username: credentials.username!,
password: credentials.password!,
serverUrl: serverUrl,
);
final auth = AuthenticationInformation(
username: credentials.username!,
password: credentials.password!,
token: token,
serverUrl: serverUrl,
clientCertificate: clientCertificate,
);
await localStore.storeAuthenticationInformation(auth);
emit(AuthenticationState(
isAuthenticated: true,
wasLoginStored: false,
authentication: auth,
));
}
Future<void> restoreSessionState() async {
final storedAuth = await localStore.loadAuthenticationInformation();
final appSettings =
await localStore.loadApplicationSettings() ?? ApplicationSettingsState.defaultSettings;
if (storedAuth == null || !storedAuth.isValid) {
emit(AuthenticationState(isAuthenticated: false, wasLoginStored: false));
} else {
if (!appSettings.isLocalAuthenticationEnabled ||
await authenticationService.authenticateLocalUser("Authenticate to log back in")) {
registerSecurityContext(storedAuth.clientCertificate);
emit(
AuthenticationState(
isAuthenticated: true,
wasLoginStored: true,
authentication: storedAuth,
),
);
} else {
emit(AuthenticationState(isAuthenticated: false, wasLoginStored: true));
}
}
}
Future<void> logout() async {
await localStore.clear();
emit(AuthenticationState.initial);
}
}
class AuthenticationState {
final bool wasLoginStored;
final bool isAuthenticated;
final AuthenticationInformation? authentication;
static final AuthenticationState initial = AuthenticationState(
wasLoginStored: false,
isAuthenticated: false,
);
AuthenticationState({
required this.isAuthenticated,
required this.wasLoginStored,
this.authentication,
});
AuthenticationState copyWith({
bool? wasLoginStored,
bool? isAuthenticated,
AuthenticationInformation? authentication,
}) {
return AuthenticationState(
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
wasLoginStored: wasLoginStored ?? this.wasLoginStored,
authentication: authentication ?? this.authentication,
);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:local_auth/local_auth.dart';
class LocalAuthenticationCubit extends Cubit<LocalAuthenticationState> {
LocalAuthenticationCubit() : super(LocalAuthenticationState(false));
Future<void> authorize(String localizedMessage) async {
final isAuthenticationSuccessful = await getIt<LocalAuthentication>()
.authenticate(localizedReason: localizedMessage);
if (isAuthenticationSuccessful) {
emit(LocalAuthenticationState(true));
} else {
throw const ErrorMessage(ErrorCode.biometricAuthenticationFailed);
}
}
}
class LocalAuthenticationState {
final bool isAuthorized;
LocalAuthenticationState(this.isAuthorized);
}

View File

@@ -0,0 +1,66 @@
import 'package:flutter_paperless_mobile/core/type/json.dart';
import 'package:flutter_paperless_mobile/features/login/model/client_certificate.dart';
class AuthenticationInformation {
static const usernameKey = 'username';
static const passwordKey = 'password';
static const tokenKey = 'token';
static const serverUrlKey = 'serverUrl';
static const clientCertificateKey = 'clientCertificate';
final String username;
final String password;
final String token;
final String serverUrl;
final ClientCertificate? clientCertificate;
AuthenticationInformation({
required this.username,
required this.password,
required this.token,
required this.serverUrl,
this.clientCertificate,
});
AuthenticationInformation.fromJson(JSON json)
: username = json[usernameKey],
password = json[passwordKey],
token = json[tokenKey],
serverUrl = json[serverUrlKey],
clientCertificate = json[clientCertificateKey] != null
? ClientCertificate.fromJson(json[clientCertificateKey])
: null;
JSON toJson() {
return {
usernameKey: username,
passwordKey: password,
tokenKey: token,
serverUrlKey: serverUrl,
clientCertificateKey: clientCertificate?.toJson(),
};
}
bool get isValid {
return serverUrl.isNotEmpty && token.isNotEmpty;
}
AuthenticationInformation copyWith({
String? username,
String? password,
String? token,
String? serverUrl,
ClientCertificate? clientCertificate,
bool removeClientCertificate = false,
bool? isLocalAuthenticationEnabled,
}) {
return AuthenticationInformation(
username: username ?? this.username,
password: password ?? this.password,
token: token ?? this.token,
serverUrl: serverUrl ?? this.serverUrl,
clientCertificate: clientCertificate ??
(removeClientCertificate ? null : this.clientCertificate),
);
}
}

View File

@@ -0,0 +1,39 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_paperless_mobile/core/type/json.dart';
class ClientCertificate {
static const bytesKey = 'bytes';
static const passphraseKey = 'passphrase';
final Uint8List bytes;
final String? passphrase;
ClientCertificate({required this.bytes, this.passphrase});
static ClientCertificate? nullable(Uint8List? bytes, {String? passphrase}) {
if (bytes != null) {
return ClientCertificate(bytes: bytes, passphrase: passphrase);
}
return null;
}
JSON toJson() {
return {
bytesKey: base64Encode(bytes),
passphraseKey: passphrase,
};
}
ClientCertificate.fromJson(JSON json)
: bytes = base64Decode(json[bytesKey]),
passphrase = json[passphraseKey];
ClientCertificate copyWith({Uint8List? bytes, String? passphrase}) {
return ClientCertificate(
bytes: bytes ?? this.bytes,
passphrase: passphrase ?? this.passphrase,
);
}
}

View File

@@ -0,0 +1,13 @@
class UserCredentials {
final String? username;
final String? password;
UserCredentials({this.username, this.password});
UserCredentials copyWith({String? username, String? password}) {
return UserCredentials(
username: username ?? this.username,
password: password ?? this.password,
);
}
}

View File

@@ -0,0 +1,57 @@
import 'dart:convert';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/core/store/local_vault.dart';
import 'package:http/http.dart';
import 'package:injectable/injectable.dart';
import 'package:local_auth/local_auth.dart';
@singleton
class AuthenticationService {
final BaseClient httpClient;
final LocalVault localStore;
final LocalAuthentication localAuthentication;
AuthenticationService(
this.localStore,
this.localAuthentication,
@Named("timeoutClient") this.httpClient,
);
///
/// Returns the authentication token.
///
Future<String> login({
required String username,
required String password,
required String serverUrl,
}) async {
final response = await httpClient.post(
Uri.parse("/api/token/"),
body: {"username": username, "password": password},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data['token'];
} else if (response.statusCode == 400 &&
response.body.toLowerCase().contains("no required certificate was sent")) {
throw const ErrorMessage(ErrorCode.invalidClientCertificateConfiguration);
} else {
throw const ErrorMessage(ErrorCode.authenticationFailed);
}
}
Future<bool> authenticateLocalUser(String localizedReason) async {
if (await localAuthentication.isDeviceSupported()) {
return await localAuthentication.authenticate(
localizedReason: localizedReason,
options: const AuthenticationOptions(
stickyAuth: true,
biometricOnly: true,
useErrorDialogs: true,
),
);
}
return false;
}
}

View File

@@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart';
import 'package:flutter_paperless_mobile/features/login/bloc/authentication_cubit.dart';
import 'package:flutter_paperless_mobile/features/login/view/widgets/client_certificate_form_field.dart';
import 'package:flutter_paperless_mobile/features/login/view/widgets/server_address_form_field.dart';
import 'package:flutter_paperless_mobile/features/login/view/widgets/user_credentials_form_field.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
import 'package:flutter_paperless_mobile/util.dart';
class LoginPage extends StatefulWidget {
const LoginPage({Key? key}) : super(key: key);
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _formKey = GlobalKey<FormBuilderState>();
bool _isLoginLoading = false;
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: true,
appBar: AppBar(
title: Text(S.of(context).loginPageTitle),
bottom: _isLoginLoading
? const PreferredSize(
preferredSize: Size(double.infinity, 4),
child: LinearProgressIndicator(),
)
: null,
),
body: Padding(
padding: const EdgeInsets.all(8.0),
child: FormBuilder(
key: _formKey,
child: ListView(
children: [
const ServerAddressFormField().padded(),
const UserCredentialsFormField(),
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(
S.of(context).loginPageAdvancedLabel,
style: Theme.of(context).textTheme.bodyText1,
).padded(),
),
),
const ClientCertificateFormField(),
LayoutBuilder(builder: (context, constraints) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: SizedBox(
width: constraints.maxWidth,
child: _buildLoginButton(),
),
);
}),
],
),
),
),
);
}
Widget _buildLoginButton() {
return ElevatedButton(
style: ButtonStyle(
backgroundColor: MaterialStatePropertyAll(Theme.of(context).colorScheme.primaryContainer),
elevation: const MaterialStatePropertyAll(0),
),
onPressed: _login,
child: Text(
S.of(context).loginPageLoginButtonLabel,
),
);
}
void _login() async {
FocusScope.of(context).unfocus();
if (_formKey.currentState?.saveAndValidate() ?? false) {
setState(() => _isLoginLoading = true);
final form = _formKey.currentState?.value;
getIt<AuthenticationCubit>()
.login(
credentials: form?[UserCredentialsFormField.fkCredentials],
serverUrl: form?[ServerAddressFormField.fkServerAddress],
clientCertificate: form?[ClientCertificateFormField.fkClientCertificate],
) //TODO: Move Intro slider route push here!
.onError<ErrorMessage>(
(error, _) => showError(context, error),
)
.whenComplete(() => setState(() => _isLoginLoading = false));
}
}
}

View File

@@ -0,0 +1,116 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart';
import 'package:flutter_paperless_mobile/features/login/model/client_certificate.dart';
import 'package:flutter_paperless_mobile/features/login/view/widgets/password_text_field.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
class ClientCertificateFormField extends StatefulWidget {
static const fkClientCertificate = 'clientCertificate';
const ClientCertificateFormField({Key? key}) : super(key: key);
@override
State<ClientCertificateFormField> createState() => _ClientCertificateFormFieldState();
}
class _ClientCertificateFormFieldState extends State<ClientCertificateFormField> {
File? _selectedFile;
@override
Widget build(BuildContext context) {
return FormBuilderField<ClientCertificate?>(
initialValue: null,
validator: (value) {
if (value == null) {
return null;
}
assert(_selectedFile != null);
if (_selectedFile?.path.split(".").last != 'pfx') {
return S.of(context).loginPageClientCertificateSettingInvalidFileFormatValidationText;
}
return null;
},
builder: (field) {
return ExpansionTile(
title: Text(S.of(context).loginPageClientCertificateSettingLabel),
subtitle: Text(S.of(context).loginPageClientCertificateSettingDescriptionText),
children: [
InputDecorator(
decoration: InputDecoration(
errorText: field.errorText,
border: InputBorder.none,
),
child: Column(
children: [
ListTile(
leading: ElevatedButton(
onPressed: () => _onSelectFile(field),
child: Text(S.of(context).genericActionSelectText),
),
title: _buildSelectedFileText(field),
trailing: AbsorbPointer(
absorbing: field.value == null,
child: _selectedFile != null
? IconButton(
icon: const Icon(Icons.close),
onPressed: () => setState(() {
_selectedFile = null;
field.didChange(null);
}),
)
: null,
),
),
if (_selectedFile != null) ...[
ObscuredInputTextFormField(
initialValue: field.value?.passphrase,
onChanged: (value) => field.didChange(
field.value?.copyWith(passphrase: value),
),
label: S.of(context).loginPageClientCertificatePassphraseLabel,
).padded(),
] else
...[]
],
),
),
],
);
},
name: ClientCertificateFormField.fkClientCertificate,
);
}
Future<void> _onSelectFile(FormFieldState<ClientCertificate?> field) async {
FilePickerResult? result = await FilePicker.platform.pickFiles();
if (result != null && result.files.single.path != null) {
File file = File(result.files.single.path!);
setState(() {
_selectedFile = file;
});
final changedValue = field.value?.copyWith(bytes: file.readAsBytesSync()) ??
ClientCertificate(bytes: file.readAsBytesSync());
field.didChange(changedValue);
}
}
Widget _buildSelectedFileText(FormFieldState<ClientCertificate?> field) {
if (field.value == null) {
assert(_selectedFile == null);
return Text(
S.of(context).loginPageClientCertificateSettingSelectFileText,
style: TextStyle(color: Theme.of(context).hintColor),
);
} else {
assert(_selectedFile != null);
return Text(
_selectedFile!.path.split("/").last,
style: const TextStyle(
overflow: TextOverflow.ellipsis,
),
);
}
}
}

View File

@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
class ObscuredInputTextFormField extends StatefulWidget {
final String? initialValue;
final String label;
final void Function(String?) onChanged;
final FormFieldValidator<String>? validator;
const ObscuredInputTextFormField({
super.key,
required this.onChanged,
required this.label,
this.validator,
this.initialValue,
});
@override
State<ObscuredInputTextFormField> createState() => _ObscuredInputTextFormFieldState();
}
class _ObscuredInputTextFormFieldState extends State<ObscuredInputTextFormField> {
bool _showPassword = false;
final FocusNode _passwordFocusNode = FocusNode();
@override
void dispose() {
_passwordFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextFormField(
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: widget.validator,
initialValue: widget.initialValue,
focusNode: _passwordFocusNode,
obscureText: !_showPassword,
autocorrect: false,
onChanged: widget.onChanged,
autofillHints: const [AutofillHints.password],
decoration: InputDecoration(
label: Text(widget.label),
suffixIcon: IconButton(
icon: Icon(_showPassword ? Icons.visibility_off : Icons.visibility),
onPressed: () => setState(() {
_showPassword = !_showPassword;
}),
),
),
);
}
}

View File

@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_paperless_mobile/core/service/connectivity_status.service.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
class ServerAddressFormField extends StatefulWidget {
static const String fkServerAddress = "serverAddress";
const ServerAddressFormField({
Key? key,
}) : super(key: key);
@override
State<ServerAddressFormField> createState() => _ServerAddressFormFieldState();
}
class _ServerAddressFormFieldState extends State<ServerAddressFormField> {
ReachabilityStatus _reachabilityStatus = ReachabilityStatus.undefined;
@override
Widget build(BuildContext context) {
return FormBuilderTextField(
name: ServerAddressFormField.fkServerAddress,
validator: FormBuilderValidators.required(
errorText: S.of(context).loginPageServerUrlValidatorMessageText,
),
decoration: InputDecoration(
suffixIcon: _buildIsReachableIcon(),
hintText: "http://192.168.1.50:8000",
labelText: S.of(context).loginPageServerUrlFieldLabel,
),
onSubmitted: _updateIsAddressReachableStatus,
);
}
Widget? _buildIsReachableIcon() {
switch (_reachabilityStatus) {
case ReachabilityStatus.reachable:
return const Icon(
Icons.done,
color: Colors.green,
);
case ReachabilityStatus.notReachable:
return Icon(
Icons.close,
color: Theme.of(context).colorScheme.error,
);
case ReachabilityStatus.testing:
return const RefreshProgressIndicator();
case ReachabilityStatus.undefined:
return null;
}
}
void _updateIsAddressReachableStatus(String? address) async {
if (address == null || address.isEmpty) {
setState(() {
_reachabilityStatus = ReachabilityStatus.undefined;
});
return;
}
//https://stackoverflow.com/questions/49648022/check-whether-there-is-an-internet-connection-available-on-flutter-app
setState(() => _reachabilityStatus = ReachabilityStatus.testing);
final isReachable = await getIt<ConnectivityStatusService>().isServerReachable(address);
if (isReachable) {
setState(() => _reachabilityStatus = ReachabilityStatus.reachable);
} else {
setState(() => _reachabilityStatus = ReachabilityStatus.notReachable);
}
}
}
enum ReachabilityStatus { reachable, notReachable, testing, undefined }

View File

@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart';
import 'package:flutter_paperless_mobile/features/login/model/user_credentials.model.dart';
import 'package:flutter_paperless_mobile/features/login/view/widgets/password_text_field.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
class UserCredentialsFormField extends StatefulWidget {
static const fkCredentials = 'credentials';
const UserCredentialsFormField({Key? key}) : super(key: key);
@override
State<UserCredentialsFormField> createState() =>
_UserCredentialsFormFieldState();
}
class _UserCredentialsFormFieldState extends State<UserCredentialsFormField> {
@override
Widget build(BuildContext context) {
return FormBuilderField<UserCredentials?>(
name: UserCredentialsFormField.fkCredentials,
builder: (field) => AutofillGroup(
child: Column(
children: [
TextFormField(
textCapitalization: TextCapitalization.words,
autovalidateMode: AutovalidateMode.onUserInteraction,
// USERNAME
autocorrect: false,
onChanged: (username) => field.didChange(
field.value?.copyWith(username: username) ??
UserCredentials(username: username),
),
validator: FormBuilderValidators.required(
errorText: S.of(context).loginPageUsernameValidatorMessageText,
),
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
label: Text(S.of(context).loginPageUsernameLabel),
),
),
ObscuredInputTextFormField(
label: S.of(context).loginPagePasswordFieldLabel,
onChanged: (password) => field.didChange(
field.value?.copyWith(password: password) ??
UserCredentials(password: password),
),
validator: FormBuilderValidators.required(
errorText: S.of(context).loginPagePasswordValidatorMessageText,
),
),
].map((child) => child.padded()).toList(),
),
),
);
}
}
/**
* AutofillGroup(
child: Column(
children: [
FormBuilderTextField(
name: fkUsername,
focusNode: _focusNodes[fkUsername],
onSubmitted: (_) {
FocusScope.of(context).requestFocus(_focusNodes[fkPassword]);
},
validator: FormBuilderValidators.required(
errorText: S.of(context).loginPageUsernameValidatorMessageText,
),
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
labelText: S.of(context).loginPageUsernameLabel,
),
).padded(),
FormBuilderTextField(
name: fkPassword,
focusNode: _focusNodes[fkPassword],
onSubmitted: (_) {
FocusScope.of(context).unfocus();
},
autofillHints: const [AutofillHints.password],
validator: FormBuilderValidators.required(
errorText: S.of(context).loginPagePasswordValidatorMessageText,
),
obscureText: true,
decoration: InputDecoration(
labelText: S.of(context).loginPagePasswordFieldLabel,
),
).padded(),
],
),
);
*/