Files
nearle_pos/lib/data/local/staff_dao.dart
Suriya e17937e8f1 Move staff PINs out of the shipped binary into hashed database rows
Three StaffUser constants carried plaintext PINs (1234/2345/3456) in
auth_controller.dart. Every build shipped every till's credentials, readable
by anyone who unzipped the APK. Across 100 deployed devices that is one
credential, not a hundred.

- Schema v5 adds a staff table. Only a PBKDF2-HMAC-SHA256 hash and a per-user
  random salt are stored; the PIN itself exists nowhere, including there.
  12,000 iterations, tuned so one sign-in is imperceptible while working
  through all 10,000 four-digit PINs against a stolen database takes ~15
  minutes per account instead of milliseconds.
- Verification is constant-time. String == returns at the first differing
  byte, and that timing leaks how much of a guess was right.
- StaffUser no longer has a pin field at all, so the credential cannot drift
  back into memory, into widgets, or into a const declaration.
- Weak PINs are refused: under four digits, non-numeric, repeated digits, and
  sequences. Two staff cannot share a PIN — the till identifies a cashier by
  PIN alone, so a shared one would attribute bills to whichever row was
  checked first.
- The last admin cannot be demoted or deactivated. A till with no admin cannot
  be administered, including to appoint one, and recovering means editing the
  database by hand.
- Staff are deactivated, never deleted, so bills already rung keep naming a
  real person.

Seed accounts are now 4821/5093/6274 rather than 1234/2345/3456 — the weak-PIN
rule refuses the old ones, and a default the rule itself would reject is not a
defensible default. All three are flagged must-change-pin so they get a shop
trading on day one without becoming permanent.

Store details are now editable data, not compile-time constants. Name,
address, GSTIN and phone persist to the database and are read back rather than
falling through to the build's constants, which would silently undo a failed
save. GSTIN is format-validated including the state code — it prints on every
invoice as a legal requirement, so a typo is a compliance problem across
hundreds of bills before anyone notices.

Tests: 141 -> 160. Includes a test that reads every column of every staff row
and asserts no seed PIN appears anywhere in the database.

Migration test now asserts v5 and that an upgraded terminal comes up with the
staff table present but empty — seeding is the store's job on first open, so
an existing shop is never handed accounts it did not create.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:00:12 +05:30

280 lines
8.1 KiB
Dart

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<void> 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<List<StaffUser>> 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<StaffUser?> 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<StaffUser?> 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<StaffUser> 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<void> 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<void> 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<void> 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<void> 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<void> _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<String, Object?> 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,
);
}