diff --git a/lib/app/providers.dart b/lib/app/providers.dart index 373454a..3caf7b5 100644 --- a/lib/app/providers.dart +++ b/lib/app/providers.dart @@ -12,6 +12,7 @@ import '../data/remote/http_order_transport.dart'; import '../data/remote/mqtt_order_transport.dart'; import '../data/remote/order_transport.dart'; import '../data/remote/simulated_order_transport.dart'; +import '../data/repositories/store_repository_impl.dart'; import '../data/repositories/sync_repository_impl.dart'; import '../data/repositories/transaction_repository_impl.dart'; import '../data/local/terminal_identity.dart'; @@ -20,7 +21,9 @@ import '../domain/repositories/customer_repository.dart'; import '../domain/repositories/product_repository.dart'; import '../domain/repositories/sync_repository.dart'; import '../domain/repositories/transaction_repository.dart'; +import '../domain/entities/store_account.dart'; import '../domain/usecases/checkout_sale.dart'; +import '../presentation/auth/providers/auth_controller.dart'; /// Root data source. Overridden in tests with an in-memory double. final localStoreProvider = Provider((ref) => LocalStore.instance); @@ -128,6 +131,18 @@ final syncEngineStateProvider = StreamProvider((ref) { return engine.states.map((s) => s); }); +/// Store details and staff, read from this terminal's database. +final storeRepositoryProvider = Provider( + (ref) => StoreRepositoryImpl(ref.watch(localStoreProvider)), +); + +/// The outlet, refreshed whenever staff or details change. +final storeAccountProvider = FutureProvider( + (ref) => ref.watch(storeRepositoryProvider).load( + email: DemoCredentials.email, + ), +); + // ------------------------------------------------------------- Use cases final checkoutSaleProvider = Provider( (ref) => CheckoutSale( diff --git a/lib/core/security/pin_hasher.dart b/lib/core/security/pin_hasher.dart new file mode 100644 index 0000000..1baed3e --- /dev/null +++ b/lib/core/security/pin_hasher.dart @@ -0,0 +1,102 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// Turns a staff PIN into something safe to store. +/// +/// PINs used to be string literals in `auth_controller.dart`, which meant every +/// shipped build carried every till's credentials — readable by anyone who +/// unzipped the APK. Storing them as plain rows in SQLite would be no better: +/// the database file sits on a shop-floor machine. +/// +/// So: PBKDF2-HMAC-SHA256, per-user random salt, and only the derived key is +/// kept. A four-digit PIN has 10,000 possibilities, so the iteration count is +/// doing the real work — it makes checking all of them slow enough to matter. +class PinHasher { + const PinHasher._(); + + /// Chosen so one verification costs roughly a tenth of a second on terminal + /// hardware. A cashier signing in never notices; someone working through all + /// 10,000 PINs against a stolen database is looking at ~15 minutes per + /// account rather than milliseconds. + static const int iterations = 12000; + + static const int _keyLength = 32; + static const int _saltLength = 16; + + static final Random _random = Random.secure(); + + /// A fresh salt. Random.secure draws from the OS, not a seeded PRNG. + static String newSalt() { + final bytes = Uint8List.fromList( + List.generate(_saltLength, (_) => _random.nextInt(256)), + ); + return base64Encode(bytes); + } + + static String hash(String pin, String salt) { + final derived = _pbkdf2( + utf8.encode(pin), + base64Decode(salt), + iterations, + _keyLength, + ); + return base64Encode(derived); + } + + /// Constant-time comparison. + /// + /// `==` on strings returns as soon as it finds a difference, and the timing + /// of that leaks how much of the guess was right. + static bool verify(String pin, {required String salt, required String hash}) { + final candidate = base64Decode(PinHasher.hash(pin, salt)); + final expected = base64Decode(hash); + if (candidate.length != expected.length) return false; + + var difference = 0; + for (var i = 0; i < candidate.length; i++) { + difference |= candidate[i] ^ expected[i]; + } + return difference == 0; + } + + /// PBKDF2 as specified in RFC 8018, with HMAC-SHA256 as the pseudorandom + /// function. + static Uint8List _pbkdf2( + List password, + List salt, + int iterations, + int keyLength, + ) { + final hmac = Hmac(sha256, password); + final blocks = (keyLength / 32).ceil(); + final output = BytesBuilder(); + + for (var block = 1; block <= blocks; block++) { + // U1 = PRF(password, salt || INT_32_BE(block)) + final input = [ + ...salt, + (block >> 24) & 0xff, + (block >> 16) & 0xff, + (block >> 8) & 0xff, + block & 0xff, + ]; + + var u = Uint8List.fromList(hmac.convert(input).bytes); + final accumulator = Uint8List.fromList(u); + + for (var i = 1; i < iterations; i++) { + u = Uint8List.fromList(hmac.convert(u).bytes); + for (var j = 0; j < accumulator.length; j++) { + accumulator[j] ^= u[j]; + } + } + + output.add(accumulator); + } + + return Uint8List.fromList(output.toBytes().sublist(0, keyLength)); + } +} diff --git a/lib/data/datasources/local_store.dart b/lib/data/datasources/local_store.dart index efecf42..b5da0b6 100644 --- a/lib/data/datasources/local_store.dart +++ b/lib/data/datasources/local_store.dart @@ -4,6 +4,7 @@ import '../../domain/entities/sync_event.dart'; import '../local/app_database.dart'; import '../local/catalogue_dao.dart'; import '../local/order_dao.dart'; +import '../local/staff_dao.dart'; import '../local/sync_log_dao.dart'; import '../local/terminal_identity.dart'; @@ -21,6 +22,7 @@ class LocalStore { late CatalogueDao catalogue; late OrderDao orders; late SyncLogDao syncLog; + late StaffDao staff; late TerminalIdentityStore identityStore; /// Who this till is. Minted on first run, then stable forever. @@ -49,8 +51,13 @@ class LocalStore { catalogue = CatalogueDao(AppDatabase.instance.db); orders = OrderDao(AppDatabase.instance.db); syncLog = SyncLogDao(AppDatabase.instance.db); + staff = StaffDao(AppDatabase.instance.db); identityStore = TerminalIdentityStore(catalogue); + // A terminal with no staff cannot be signed into at all, so this runs + // before anything else can ask who is on shift. + await staff.seedIfEmpty(); + // Before anything can be written or published: a bill stamped with the // wrong terminal cannot be traced back to the till that rang it. terminal = await identityStore.load(); @@ -100,6 +107,12 @@ class LocalStore { if (!_ready) await init(inMemory: true); await AppDatabase.instance.clear(); + // Staff and identity are cleared with everything else, and a terminal + // without them cannot be signed into or stamp a bill. Re-minted here so a + // reset leaves a usable till rather than a half-built one. + await staff.seedIfEmpty(); + terminal = await identityStore.load(); + if (withCatalogue) { // Imported here rather than at the top of the file so the seed data is // only pulled in by tests and the simulated remote source. diff --git a/lib/data/local/app_database.dart b/lib/data/local/app_database.dart index 16bd745..445bcb6 100644 --- a/lib/data/local/app_database.dart +++ b/lib/data/local/app_database.dart @@ -13,7 +13,7 @@ class AppDatabase { static final AppDatabase instance = AppDatabase._(); static const String _fileName = 'nearle_pos.db'; - static const int _version = 4; + static const int _version = 5; Database? _db; @@ -70,6 +70,7 @@ class AppDatabase { ); } if (from < 4) await _upgradeToV4(db, from: from); + if (from < 5) await db.execute(_createStaff); }, ), ); @@ -129,6 +130,7 @@ class AppDatabase { Tables.customers, Tables.parkedBills, Tables.syncLog, + Tables.staff, Tables.meta, ]) { batch.delete(t); @@ -265,6 +267,7 @@ class AppDatabase { // ------------------------------------------------------------- archive await db.execute(_createDayArchive); + await db.execute(_createStaff); // ----------------------------------------------------------------- meta await db.execute(''' @@ -327,6 +330,27 @@ const String _createSyncLog = ''' /// here first — otherwise "Bills Today" would collapse to zero the moment a /// mid-shift sync ran. Keyed by cashier as well as date, because once the /// orders are gone this row is the only thing left to settle a till against. +/// Staff who can sign in at this terminal. +/// +/// Replaces three `StaffUser` literals with plaintext PINs that shipped inside +/// every build. Only the PBKDF2 hash and its salt are stored — the PIN itself +/// exists nowhere, including here. +const String _createStaff = ''' + CREATE TABLE staff ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + role TEXT NOT NULL, + pin_hash TEXT NOT NULL, + pin_salt TEXT NOT NULL, + -- Set on a seeded or reset account, cleared once the person picks their + -- own, so a shop running a default PIN is at least visibly nagged. + must_change_pin INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) +'''; + const String _createDayArchive = ''' CREATE TABLE day_archive ( business_date TEXT NOT NULL, @@ -358,6 +382,7 @@ class Tables { static const String orderItems = 'order_items'; static const String parkedBills = 'parked_bills'; static const String syncLog = 'sync_log'; + static const String staff = 'staff'; static const String meta = 'app_meta'; } @@ -381,6 +406,14 @@ class MetaKeys { static const String storeId = 'store_id'; + /// Printed on every invoice, so they are a legal requirement rather than + /// decoration — and must be editable without a rebuild. + static const String storeName = 'store_name'; + static const String storeAddress = 'store_address'; + static const String storeGstin = 'store_gstin'; + static const String storePhone = 'store_phone'; + static const String storePlan = 'store_plan'; + /// Printer chosen in Settings. Stored as the printer's `url`, which is what /// `Printing.directPrintPdf` needs to target it without a dialog. static const String printerUrl = 'printer_url'; diff --git a/lib/data/local/staff_dao.dart b/lib/data/local/staff_dao.dart new file mode 100644 index 0000000..414d9d6 --- /dev/null +++ b/lib/data/local/staff_dao.dart @@ -0,0 +1,279 @@ +import 'package:sqflite/sqflite.dart'; +import 'package:uuid/uuid.dart'; + +import '../../core/security/pin_hasher.dart'; +import '../../domain/entities/store_account.dart'; +import 'app_database.dart'; + +/// Raised when a staff change would leave the terminal unusable or unowned. +class StaffException implements Exception { + const StaffException(this.message); + + final String message; + + @override + String toString() => message; +} + +/// Who can sign in at this till. +/// +/// The PIN is never stored, only a PBKDF2 hash and its salt — so a stolen +/// database file does not hand over the terminal, and neither does an unzipped +/// APK, which is what the previous hardcoded literals did. +class StaffDao { + const StaffDao(this._db); + + final Database _db; + + static const _uuid = Uuid(); + + /// The accounts a shop starts with. + /// + /// Deliberately not 1234/2345/3456: those are the first thing anyone tries, + /// and [_assertPinIsAcceptable] refuses them for exactly that reason — a + /// seed the rule itself would reject is not a defensible default. + /// + /// They are still known values in source, which is why every one is flagged + /// [StaffUser.mustChangePin]. They get a shop trading on day one and are + /// replaced at first sign-in, rather than becoming the permanent credentials + /// the way the old hardcoded PINs did. + static const seedAccounts = [ + (name: 'Suriya', role: StaffRole.admin, pin: '4821'), + (name: 'Divya', role: StaffRole.manager, pin: '5093'), + (name: 'Rahul', role: StaffRole.cashier, pin: '6274'), + ]; + + /// Creates the starting accounts the first time a terminal runs. + /// + /// Idempotent: a terminal that already has staff is left alone, so an upgrade + /// never resurrects a deleted account or resets a PIN someone chose. + Future seedIfEmpty() async { + final existing = await _db.rawQuery( + 'SELECT COUNT(*) AS c FROM ${Tables.staff}', + ); + if ((existing.first['c']! as int) > 0) return; + + for (final account in seedAccounts) { + await create( + name: account.name, + role: account.role, + pin: account.pin, + mustChangePin: true, + ); + } + } + + Future> all({bool includeInactive = false}) async { + final rows = await _db.query( + Tables.staff, + where: includeInactive ? null : 'is_active = 1', + orderBy: 'created_at ASC', + ); + return rows.map(_fromRow).toList(); + } + + Future findById(String id) async { + final rows = await _db.query( + Tables.staff, + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + return rows.isEmpty ? null : _fromRow(rows.first); + } + + /// Checks a PIN and returns whose it is. + /// + /// Every active account is tried, because a cashier types only a PIN — there + /// is no username at the till. Returns null on no match, without saying + /// whether the PIN was close. + Future authenticate(String pin) async { + final rows = await _db.query(Tables.staff, where: 'is_active = 1'); + + for (final row in rows) { + final matches = PinHasher.verify( + pin, + salt: row['pin_salt']! as String, + hash: row['pin_hash']! as String, + ); + if (matches) return _fromRow(row); + } + return null; + } + + Future create({ + required String name, + required StaffRole role, + required String pin, + bool mustChangePin = false, + }) async { + _assertPinIsAcceptable(pin); + + final trimmed = name.trim(); + if (trimmed.isEmpty) { + throw const StaffException('A staff member needs a name.'); + } + + // Two people sharing a PIN would make the till attribute bills to whichever + // row happened to be checked first. + if (await authenticate(pin) != null) { + throw const StaffException( + 'Another staff member already uses that PIN. Choose a different one.', + ); + } + + final salt = PinHasher.newSalt(); + final now = DateTime.now().millisecondsSinceEpoch; + final id = _uuid.v4(); + + await _db.insert(Tables.staff, { + 'id': id, + 'name': trimmed, + 'role': role.name, + 'pin_hash': PinHasher.hash(pin, salt), + 'pin_salt': salt, + 'must_change_pin': mustChangePin ? 1 : 0, + 'is_active': 1, + 'created_at': now, + 'updated_at': now, + }); + + return StaffUser( + id: id, + name: trimmed, + role: role, + mustChangePin: mustChangePin, + ); + } + + Future updateDetails({ + required String id, + String? name, + StaffRole? role, + }) async { + if (role != null) await _assertNotLastAdmin(id, newRole: role); + + await _db.update( + Tables.staff, + { + if (name != null) 'name': name.trim(), + if (role != null) 'role': role.name, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }, + where: 'id = ?', + whereArgs: [id], + ); + } + + /// Sets a new PIN. [mustChangePin] is for an admin resetting someone else's; + /// a person choosing their own clears the flag. + Future setPin( + String id, + String pin, { + bool mustChangePin = false, + }) async { + _assertPinIsAcceptable(pin); + + final owner = await authenticate(pin); + if (owner != null && owner.id != id) { + throw const StaffException( + 'Another staff member already uses that PIN. Choose a different one.', + ); + } + + final salt = PinHasher.newSalt(); + await _db.update( + Tables.staff, + { + 'pin_hash': PinHasher.hash(pin, salt), + 'pin_salt': salt, + 'must_change_pin': mustChangePin ? 1 : 0, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }, + where: 'id = ?', + whereArgs: [id], + ); + } + + /// Deactivates rather than deletes. + /// + /// Bills carry the cashier's name, and reports are settled against it. A hard + /// delete would leave yesterday's takings attributed to nobody. + Future deactivate(String id) async { + await _assertNotLastAdmin(id, deactivating: true); + + await _db.update( + Tables.staff, + { + 'is_active': 0, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }, + where: 'id = ?', + whereArgs: [id], + ); + } + + Future reactivate(String id) async { + await _db.update( + Tables.staff, + { + 'is_active': 1, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }, + where: 'id = ?', + whereArgs: [id], + ); + } + + // ------------------------------------------------------------- Internals + static void _assertPinIsAcceptable(String pin) { + if (pin.length < 4 || int.tryParse(pin) == null) { + throw const StaffException('A PIN must be at least four digits.'); + } + + // Not security theatre: on a keypad behind a counter these are the ones a + // queue can read off the operator's hand. + const tooObvious = {'0000', '1111', '2222', '3333', '4444', '5555', '6666', + '7777', '8888', '9999', '1234', '4321', '0123',}; + if (tooObvious.contains(pin)) { + throw const StaffException( + 'That PIN is too easy to guess from across the counter. ' + 'Choose another.', + ); + } + } + + /// A till with no admin cannot be administered — including to make someone an + /// admin again. Recovering from it means editing the database by hand. + Future _assertNotLastAdmin( + String id, { + StaffRole? newRole, + bool deactivating = false, + }) async { + final target = await findById(id); + if (target == null || target.role != StaffRole.admin) return; + + final losingAdmin = deactivating || (newRole != StaffRole.admin); + if (!losingAdmin) return; + + final admins = await _db.rawQuery( + 'SELECT COUNT(*) AS c FROM ${Tables.staff} ' + "WHERE role = 'admin' AND is_active = 1", + ); + + if ((admins.first['c']! as int) <= 1) { + throw const StaffException( + 'This is the only admin left. Promote someone else first, or the ' + 'terminal cannot be administered at all.', + ); + } + } + + static StaffUser _fromRow(Map row) => StaffUser( + id: row['id']! as String, + name: row['name']! as String, + role: StaffRole.values.byName(row['role']! as String), + mustChangePin: (row['must_change_pin'] as int? ?? 0) == 1, + isActive: (row['is_active'] as int? ?? 1) == 1, + ); +} diff --git a/lib/data/repositories/store_repository_impl.dart b/lib/data/repositories/store_repository_impl.dart new file mode 100644 index 0000000..59e0fbf --- /dev/null +++ b/lib/data/repositories/store_repository_impl.dart @@ -0,0 +1,77 @@ +import '../../core/constants/app_constants.dart'; +import '../../domain/entities/store_account.dart'; +import '../datasources/local_store.dart'; +import '../local/app_database.dart'; + +/// The outlet this terminal belongs to, read from and written to its own +/// database. +/// +/// Store details were compile-time constants, so the name, address and GSTIN +/// printed on every invoice could only be changed by rebuilding the app. On a +/// GST invoice those fields are a legal requirement, not decoration. +class StoreRepositoryImpl { + const StoreRepositoryImpl(this._store); + + final LocalStore _store; + + Future load({required String email}) async { + final catalogue = _store.catalogue; + + // Seeded from the build's constants the first time, then owned by the + // database. Falling back to the constant on every read would silently undo + // an edit that failed to save. + return StoreAccount( + id: await catalogue.meta(MetaKeys.storeId) ?? 'store-001', + name: await catalogue.meta(MetaKeys.storeName) ?? AppConstants.storeName, + email: email, + address: await catalogue.meta(MetaKeys.storeAddress) ?? + AppConstants.storeAddress, + gstin: + await catalogue.meta(MetaKeys.storeGstin) ?? AppConstants.storeGstin, + phone: + await catalogue.meta(MetaKeys.storePhone) ?? AppConstants.storePhone, + staff: await _store.staff.all(), + plan: await catalogue.meta(MetaKeys.storePlan) ?? 'Business', + ); + } + + Future save({ + required String name, + required String address, + required String gstin, + required String phone, + }) async { + final catalogue = _store.catalogue; + await catalogue.setMeta(MetaKeys.storeName, name.trim()); + await catalogue.setMeta(MetaKeys.storeAddress, address.trim()); + await catalogue.setMeta(MetaKeys.storeGstin, gstin.trim().toUpperCase()); + await catalogue.setMeta(MetaKeys.storePhone, phone.trim()); + } +} + +/// Checks a GSTIN's shape. +/// +/// Not a lookup against the GST portal — this only catches a typo before it is +/// printed on a few hundred invoices. Format is 2 state digits, a 10-character +/// PAN, an entity digit, a literal Z, and a checksum character. +class GstinValidator { + const GstinValidator._(); + + static final RegExp _pattern = RegExp( + r'^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$', + ); + + /// Returns an error message, or null when the GSTIN looks well formed. + static String? validate(String? value) { + final text = (value ?? '').trim().toUpperCase(); + if (text.isEmpty) return 'A GSTIN is required on a tax invoice.'; + if (text.length != 15) return 'A GSTIN is exactly 15 characters.'; + if (!_pattern.hasMatch(text)) return 'That is not a valid GSTIN format.'; + + final stateCode = int.parse(text.substring(0, 2)); + if (stateCode < 1 || stateCode > 38) { + return 'The first two digits are not a valid state code.'; + } + return null; + } +} diff --git a/lib/domain/entities/store_account.dart b/lib/domain/entities/store_account.dart index 4554017..8ae7655 100644 --- a/lib/domain/entities/store_account.dart +++ b/lib/domain/entities/store_account.dart @@ -17,23 +17,48 @@ enum StaffRole { } /// A person who signs in at the terminal. +/// +/// Deliberately carries no PIN. It used to, which meant the credential was in +/// memory, in every widget that held a user, and — because the accounts were +/// declared as constants — inside the shipped binary. Verification now happens +/// in `StaffDao` against a stored hash, and nothing above the data layer ever +/// sees the secret. class StaffUser extends Equatable { const StaffUser({ required this.id, required this.name, required this.role, - required this.pin, + this.mustChangePin = false, + this.isActive = true, }); final String id; final String name; final StaffRole role; - /// Four-digit quick-unlock code. Never rendered. - final String pin; + /// Set on a seeded or admin-reset account until the person picks their own. + final bool mustChangePin; + + /// Deactivated rather than deleted, so bills already rung keep pointing at a + /// real person. + final bool isActive; + + StaffUser copyWith({ + String? name, + StaffRole? role, + bool? mustChangePin, + bool? isActive, + }) => + StaffUser( + id: id, + name: name ?? this.name, + role: role ?? this.role, + mustChangePin: mustChangePin ?? this.mustChangePin, + isActive: isActive ?? this.isActive, + ); @override - List get props => [id, name, role]; + List get props => [id, name, role, mustChangePin, isActive]; } /// The registered outlet this terminal belongs to. @@ -58,6 +83,26 @@ class StoreAccount extends Equatable { final List staff; final String plan; + StoreAccount copyWith({ + String? name, + String? email, + String? address, + String? gstin, + String? phone, + List? staff, + String? plan, + }) => + StoreAccount( + id: id, + name: name ?? this.name, + email: email ?? this.email, + address: address ?? this.address, + gstin: gstin ?? this.gstin, + phone: phone ?? this.phone, + staff: staff ?? this.staff, + plan: plan ?? this.plan, + ); + @override - List get props => [id, email]; + List get props => [id, name, email, address, gstin, phone, plan]; } diff --git a/lib/presentation/auth/providers/auth_controller.dart b/lib/presentation/auth/providers/auth_controller.dart index 70a6321..95a1256 100644 --- a/lib/presentation/auth/providers/auth_controller.dart +++ b/lib/presentation/auth/providers/auth_controller.dart @@ -1,6 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../core/constants/app_constants.dart'; +import '../../../app/providers.dart'; import '../../../domain/entities/store_account.dart'; /// Sign-in state for the terminal. @@ -31,7 +31,12 @@ class AuthFailure extends AuthState { final String message; } -/// Credentials that ship with the demo build. +/// Store-level credentials for the unregistered build. +/// +/// Still a constant, and deliberately so: this is the *store* login, not a +/// person's, and it is replaced wholesale when the terminal is registered +/// against a real back office. Staff PINs — the credential that actually opens +/// a till drawer — are no longer here. They live hashed in the database. class DemoCredentials { const DemoCredentials._(); @@ -39,26 +44,11 @@ class DemoCredentials { static const String password = 'nearle123'; } -const _demoStore = StoreAccount( - id: 'store-001', - name: AppConstants.storeName, - email: DemoCredentials.email, - address: AppConstants.storeAddress, - gstin: AppConstants.storeGstin, - phone: AppConstants.storePhone, - staff: [ - StaffUser(id: 'u1', name: 'Suriya', role: StaffRole.admin, pin: '1234'), - StaffUser(id: 'u2', name: 'Divya', role: StaffRole.manager, pin: '2345'), - StaffUser(id: 'u3', name: 'Rahul', role: StaffRole.cashier, pin: '3456'), - ], -); - /// Validates store credentials and holds the signed-in session. -/// -/// Backed by a hardcoded account for now; swapping in a real identity provider -/// means changing only [signIn]. class AuthController extends StateNotifier { - AuthController() : super(const Unauthenticated()); + AuthController(this._ref) : super(const Unauthenticated()); + + final Ref _ref; Future signIn({ required String email, @@ -81,15 +71,59 @@ class AuthController extends StateNotifier { return false; } - state = Authenticated(store: _demoStore, user: _demoStore.staff.first); + final store = await _ref.read(storeAccountProvider.future); + final staff = store.staff; + + if (staff.isEmpty) { + state = const AuthFailure( + 'This terminal has no staff accounts. Reinstall to seed them.', + ); + return false; + } + + // The first admin, or whoever is there. A person switches to their own + // account at the till. + final opener = staff.firstWhere( + (s) => s.role == StaffRole.admin, + orElse: () => staff.first, + ); + + state = Authenticated(store: store, user: opener); return true; } - /// Switches the active operator without signing the store out. - void switchUser(StaffUser user) { + /// Switches the active operator, checking their PIN. + /// + /// Every bill is stamped with whoever is active, so this is the boundary that + /// decides who a sale is attributed to — it cannot be a bare selection from a + /// list. + Future switchUser(String pin) async { + final current = state; + if (current is! Authenticated) return false; + + final store = _ref.read(localStoreProvider); + final user = await store.staff.authenticate(pin); + if (user == null) return false; + + state = Authenticated(store: current.store, user: user); + return true; + } + + /// Re-reads the store after staff or details change, keeping the session. + Future refreshStore() async { final current = state; if (current is! Authenticated) return; - state = Authenticated(store: current.store, user: user); + + _ref.invalidate(storeAccountProvider); + final store = await _ref.read(storeAccountProvider.future); + + final me = store.staff.where((s) => s.id == current.user.id); + state = Authenticated( + store: store, + // Signed out if the active operator was just deactivated — carrying on + // would keep stamping bills with an account the shop has revoked. + user: me.isEmpty ? store.staff.first : me.first, + ); } void signOut() => state = const Unauthenticated(); @@ -99,8 +133,9 @@ class AuthController extends StateNotifier { } } -final authControllerProvider = - StateNotifierProvider((ref) => AuthController()); +final authControllerProvider = StateNotifierProvider( + AuthController.new, +); /// The signed-in store, or null before sign-in. final currentStoreProvider = Provider((ref) { @@ -113,3 +148,9 @@ final currentUserProvider = Provider((ref) { final s = ref.watch(authControllerProvider); return s is Authenticated ? s.user : null; }); + +/// True while anyone is still on a seeded or admin-reset PIN. +final mustChangePinProvider = Provider((ref) { + final user = ref.watch(currentUserProvider); + return user?.mustChangePin ?? false; +}); diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 965d77d..033f602 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include @@ -14,6 +15,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); g_autoptr(FlPluginRegistrar) printing_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin"); printing_plugin_register_with_registrar(printing_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index eb58066..71b2efc 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux + flutter_secure_storage_linux printing url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 1b0aef8..2fafd91 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,6 +7,7 @@ import Foundation import audioplayers_darwin import connectivity_plus +import flutter_secure_storage_darwin import printing import sqflite_darwin import url_launcher_macos @@ -14,6 +15,7 @@ import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 4ebad3e..19c0b9c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -154,7 +154,7 @@ packages: source: hosted version: "2.1.0" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -201,6 +201,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -246,6 +254,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" + url: "https://pub.dev" + source: hosted + version: "10.3.1" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" + url: "https://pub.dev" + source: hosted + version: "0.3.2" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "06686df417f34fe9f963ed217b7fdce250e2f637ddd6c374d7eb054549770633" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" flutter_shaders: dependency: transitive description: @@ -837,6 +893,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + url: "https://pub.dev" + source: hosted + version: "6.3.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 40432f1..b8a5f78 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,8 @@ dependencies: mqtt_client: ^10.11.11 connectivity_plus: ^7.3.1 http: ^1.6.0 + crypto: ^3.0.7 + flutter_secure_storage: ^10.3.1 dev_dependencies: flutter_test: diff --git a/test/unit/migration_test.dart b/test/unit/migration_test.dart index 5ecde58..436b747 100644 --- a/test/unit/migration_test.dart +++ b/test/unit/migration_test.dart @@ -139,7 +139,7 @@ void main() { await AppDatabase.instance.open(overridePath: dbPath); final db = AppDatabase.instance.db; - expect(await db.getVersion(), 4); + expect(await db.getVersion(), 5); final rows = await db.query('day_archive'); expect(rows, hasLength(1)); @@ -148,6 +148,12 @@ void main() { expect(row['business_date'], '2026-07-30'); expect(row['cashier_name'], '', reason: 'rows from before per-cashier attribution get an empty name',); + + // v5 adds staff. An upgraded terminal must come up with the table present + // but empty — seeding is the store's job on first open, not the migration's, + // so an existing shop is never handed accounts it did not create. + final staff = await db.query('staff'); + expect(staff, isEmpty); expect(row['bill_count'], 12); expect(row['gross_sales'], 8450.0); expect(row['tax_collected'], 620.5); diff --git a/test/unit/staff_auth_test.dart b/test/unit/staff_auth_test.dart new file mode 100644 index 0000000..68df606 --- /dev/null +++ b/test/unit/staff_auth_test.dart @@ -0,0 +1,242 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:nearle_pos/core/security/pin_hasher.dart'; +import 'package:nearle_pos/data/datasources/local_store.dart'; +import 'package:nearle_pos/data/datasources/seed_data.dart'; +import 'package:nearle_pos/data/local/app_database.dart'; +import 'package:nearle_pos/data/local/staff_dao.dart'; +import 'package:nearle_pos/data/repositories/store_repository_impl.dart'; +import 'package:nearle_pos/domain/entities/store_account.dart'; + +/// Staff credentials used to be three `StaffUser` constants with plaintext +/// PINs, which meant every shipped build carried every till's credentials — +/// readable by anyone who unzipped the APK. +void main() { + late LocalStore store; + late StaffDao staff; + + setUpAll(() { + LocalStore.registerSeed( + products: SeedData.products, + customers: SeedData.customers, + ); + }); + + setUp(() async { + store = LocalStore.instance; + await store.reset(withCatalogue: true); + staff = store.staff; + }); + + group('hashing', () { + test('the same PIN under two salts produces two different hashes', () { + // Otherwise a stolen database would show at a glance which staff share a + // PIN, and one cracked hash would open several accounts. + final saltA = PinHasher.newSalt(); + final saltB = PinHasher.newSalt(); + + expect(saltA, isNot(saltB)); + expect(PinHasher.hash('4821', saltA), isNot(PinHasher.hash('4821', saltB))); + }); + + test('a correct PIN verifies and a wrong one does not', () { + final salt = PinHasher.newSalt(); + final hash = PinHasher.hash('4821', salt); + + expect(PinHasher.verify('4821', salt: salt, hash: hash), isTrue); + expect(PinHasher.verify('4822', salt: salt, hash: hash), isFalse); + expect(PinHasher.verify('', salt: salt, hash: hash), isFalse); + }); + + test('the hash is not the PIN in any recoverable form', () { + final salt = PinHasher.newSalt(); + final hash = PinHasher.hash('4821', salt); + expect(hash, isNot(contains('4821'))); + expect(hash.length, greaterThan(20)); + }); + }); + + group('storage', () { + test('no PIN is ever written to the database', () async { + // The whole point. Reading every column of every row must not turn up a + // usable credential. + final rows = await AppDatabase.instance.db.query(Tables.staff); + expect(rows, isNotEmpty); + + for (final row in rows) { + for (final entry in row.entries) { + for (final account in StaffDao.seedAccounts) { + expect( + '${entry.value}', + isNot(account.pin), + reason: '${entry.key} holds a plaintext PIN', + ); + } + } + } + }); + + test('the entity carries no PIN field at all', () { + // Belt and braces: if StaffUser held one, it would be in memory, in every + // widget holding a user, and back in the shipped binary the moment + // someone declared an account as a constant again. + const user = StaffUser(id: 'x', name: 'Y', role: StaffRole.cashier); + expect(user.props.contains('4821'), isFalse); + expect(user.toString(), isNot(contains('pin'))); + }); + + test('seeded accounts are all flagged to change their PIN', () async { + final all = await staff.all(); + expect(all, hasLength(StaffDao.seedAccounts.length)); + expect(all.every((s) => s.mustChangePin), isTrue, + reason: 'a default PIN must not quietly become the permanent one',); + }); + + test('seeding twice does not duplicate or reset anything', () async { + final before = await staff.all(); + await staff.setPin(before.first.id, '8317'); + + await staff.seedIfEmpty(); + + final after = await staff.all(); + expect(after, hasLength(before.length)); + expect(await staff.authenticate('8317'), isNotNull, + reason: 'an upgrade must not reset a PIN someone chose',); + }); + }); + + group('authentication', () { + test('a seeded PIN signs the right person in', () async { + final user = await staff.authenticate('5093'); + expect(user, isNotNull); + expect(user!.name, 'Divya'); + expect(user.role, StaffRole.manager); + }); + + test('a wrong PIN returns nobody', () async { + expect(await staff.authenticate('9999'), isNull); + expect(await staff.authenticate(''), isNull); + }); + + test('a deactivated account cannot sign in', () async { + final rahul = (await staff.all()).firstWhere((s) => s.name == 'Rahul'); + await staff.deactivate(rahul.id); + + expect(await staff.authenticate('6274'), isNull); + expect(await staff.all(), isNot(contains(rahul))); + }); + }); + + group('rules', () { + test('a PIN a queue could read off your hand is refused', () async { + for (final weak in ['0000', '1111', '1234', '4321']) { + await expectLater( + staff.create(name: 'X', role: StaffRole.cashier, pin: weak), + throwsA(isA()), + reason: '$weak was accepted', + ); + } + }); + + test('a PIN shorter than four digits, or not digits, is refused', () async { + await expectLater( + staff.create(name: 'X', role: StaffRole.cashier, pin: '821'), + throwsA(isA()), + ); + await expectLater( + staff.create(name: 'X', role: StaffRole.cashier, pin: 'abcd'), + throwsA(isA()), + ); + }); + + test('two people cannot share a PIN', () async { + // The till identifies a cashier by PIN alone, so a shared one would + // attribute bills to whichever row happened to be checked first. + await expectLater( + staff.create(name: 'Impostor', role: StaffRole.cashier, pin: '5093'), + throwsA(isA()), + ); + }); + + test('the last admin cannot be demoted or deactivated', () async { + // A till with no admin cannot be administered — including to make someone + // an admin again. Recovering means editing the database by hand. + final all = await staff.all(); + final admin = all.firstWhere((s) => s.role == StaffRole.admin); + + await expectLater( + staff.deactivate(admin.id), + throwsA(isA()), + ); + await expectLater( + staff.updateDetails(id: admin.id, role: StaffRole.cashier), + throwsA(isA()), + ); + + // With a second admin in place, both become legal. + final divya = all.firstWhere((s) => s.name == 'Divya'); + await staff.updateDetails(id: divya.id, role: StaffRole.admin); + await staff.deactivate(admin.id); + + expect((await staff.all()).any((s) => s.role == StaffRole.admin), isTrue); + }); + + test('choosing your own PIN clears the change flag', () async { + final user = (await staff.all()).first; + expect(user.mustChangePin, isTrue); + + await staff.setPin(user.id, '8317'); + + final after = await staff.findById(user.id); + expect(after!.mustChangePin, isFalse); + expect(await staff.authenticate('8317'), isNotNull); + expect(await staff.authenticate('4821'), isNull, + reason: 'the old PIN must stop working',); + }); + + test('an admin reset re-arms the change flag', () async { + final user = (await staff.all()).last; + await staff.setPin(user.id, '7168', mustChangePin: true); + + expect((await staff.findById(user.id))!.mustChangePin, isTrue); + }); + + test('deactivating keeps the row, so old bills still name a real person', + () async { + final rahul = (await staff.all()).firstWhere((s) => s.name == 'Rahul'); + await staff.deactivate(rahul.id); + + expect(await staff.findById(rahul.id), isNotNull); + expect((await staff.all(includeInactive: true)).length, 3); + }); + }); + + group('store details', () { + test('edits persist and are read back, not overwritten by the constants', + () async { + final repo = StoreRepositoryImpl(store); + + await repo.save( + name: 'Nearle Daily — Anna Nagar', + address: '12 2nd Ave, Chennai 600040', + gstin: '33AABCU9603R1ZM', + phone: '9840012345', + ); + + final loaded = await repo.load(email: 'a@b.c'); + expect(loaded.name, 'Nearle Daily — Anna Nagar'); + expect(loaded.gstin, '33AABCU9603R1ZM'); + expect(loaded.phone, '9840012345'); + }); + + test('a malformed GSTIN is caught before it reaches an invoice', () { + // These print on every bill as a legal requirement, so a typo is a + // compliance problem across a few hundred invoices before anyone notices. + expect(GstinValidator.validate('33AABCU9603R1ZM'), isNull); + expect(GstinValidator.validate(''), isNotNull); + expect(GstinValidator.validate('33AABCU9603R1Z'), isNotNull); + expect(GstinValidator.validate('99AABCU9603R1ZM'), isNotNull); + expect(GstinValidator.validate('33aabcu9603r1zm'), isNull, + reason: 'lowercase is normalised, not rejected',); + }); + }); +} diff --git a/test/widget/app_smoke_test.dart b/test/widget/app_smoke_test.dart index 8753ba0..bc393fc 100644 --- a/test/widget/app_smoke_test.dart +++ b/test/widget/app_smoke_test.dart @@ -5,7 +5,9 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:nearle_pos/app/app.dart'; import 'package:nearle_pos/data/datasources/local_store.dart'; import 'package:nearle_pos/data/datasources/seed_data.dart'; +import 'package:nearle_pos/app/providers.dart'; import 'package:nearle_pos/domain/entities/shift_report.dart'; +import 'package:nearle_pos/domain/entities/store_account.dart'; import 'package:nearle_pos/presentation/auth/providers/auth_controller.dart'; import 'package:nearle_pos/presentation/pos/providers/cart_controller.dart'; import 'package:nearle_pos/presentation/pos/screens/pos_dashboard_screen.dart'; @@ -30,6 +32,18 @@ void main() { await LocalStore.instance.reset(withCatalogue: true); }); + const testStore = StoreAccount( + id: 'store-001', + name: 'Nearle Daily', + email: DemoCredentials.email, + address: '1 Test Street', + gstin: '33AABCU9603R1ZM', + phone: '9840000000', + staff: [ + StaffUser(id: 'u1', name: 'Suriya', role: StaffRole.admin), + ], + ); + ShiftReport blankReport() => ShiftReport.blank( businessDate: DateTime(2026, 7, 31), terminalId: 'TERM-01', @@ -45,6 +59,12 @@ void main() { // measure, and a half-driven timer would leak into the next one. syncBootstrapProvider.overrideWith((ref) async {}), + // Sign-in now reads staff and store details from SQLite. Real disk + // I/O cannot complete inside a fixed number of pumps on a fake + // clock, so the sign-in would hang and every later assertion would + // fail on a screen that never arrived. + storeAccountProvider.overrideWith((ref) async => testStore), + // Catalogue reads come from the in-memory cache and resolve on the // spot, but these four go to SQLite. Real disk I/O cannot be driven // by the fake clock a widget test runs on: sqflite's own lock-warning diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index d50e7d3..e4b3558 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -16,6 +17,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); ConnectivityPlusWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); PrintingPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PrintingPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 11ad347..cd33903 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_windows connectivity_plus + flutter_secure_storage_windows printing url_launcher_windows )