Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions lib/security/auth.dart
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,12 @@ class AuthController extends ChangeNotifier {
Future<String?> register(String username, String password) async {
final name = username.trim();
if (name.isEmpty) return 'Please choose a username.';
if (password.length < 6) {
return 'Password must be at least 6 characters.';
if (password.length < 10) {
return 'Password must be at least 10 characters.';
}
if (!RegExp(r'[A-Za-z]').hasMatch(password) ||
!RegExp(r'[0-9]').hasMatch(password)) {
return 'Password must include a letter and a number.';
}
if (_accounts.any((a) => a.username.toLowerCase() == name.toLowerCase())) {
return 'That username is already taken.';
Expand Down Expand Up @@ -205,3 +209,4 @@ class _Lockout {
final int fails;
final int untilMillis;
}

22 changes: 19 additions & 3 deletions test/security_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,27 @@ void main() {
});

group('auth lockout', () {
test('rejects weak passwords for encrypted local accounts', () async {
SharedPreferences.setMockInitialValues({});
final auth = AuthController(await SharedPreferences.getInstance());

expect(
await auth.register('short', 'abc123'),
'Password must be at least 10 characters.',
);
expect(
await auth.register('letters', 'abcdefghij'),
'Password must include a letter and a number.',
);
});

test('locks after repeated failures and unlocks once time passes', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
var now = DateTime(2026, 7, 28, 12);
final auth = AuthController(prefs, clock: () => now);

expect(await auth.register('bob', 'correct1'), isNull);
expect(await auth.register('bob', 'correct1234'), isNull);
auth.logout();

for (var i = 0; i < 5; i++) {
Expand All @@ -49,12 +63,13 @@ void main() {
// Sixth attempt is refused by the lockout, even with the right password.
expect(await auth.login('bob', 'wrong'), contains('Too many attempts'));
expect(auth.lockRemainingSeconds('bob'), greaterThan(0));
expect(await auth.login('bob', 'correct1'), contains('Too many attempts'));
expect(
await auth.login('bob', 'correct1234'), contains('Too many attempts'));

// Move past the lockout window.
now = now.add(const Duration(seconds: 40));
expect(auth.lockRemainingSeconds('bob'), 0);
expect(await auth.login('bob', 'correct1'), isNull);
expect(await auth.login('bob', 'correct1234'), isNull);
expect(auth.isLoggedIn, isTrue);
});
});
Expand Down Expand Up @@ -85,3 +100,4 @@ void main() {
});
});
}

Loading