Skip to content

Commit

Permalink
fix: Update password hash algoritm. (#1993)
Browse files Browse the repository at this point in the history
  • Loading branch information
SandPod committed Mar 11, 2024
1 parent 03bebfa commit a78b9e9
Show file tree
Hide file tree
Showing 23 changed files with 2,375 additions and 410 deletions.
1 change: 1 addition & 0 deletions .github/workflows/dart-tests.yaml
Expand Up @@ -87,6 +87,7 @@ jobs:
"packages/serverpod_serialization",
"tests/serverpod_test_client",
"tests/serverpod_test_server",
"modules/serverpod_auth/serverpod_auth_server",
]
steps:
- uses: actions/checkout@v3
Expand Down
Expand Up @@ -128,6 +128,11 @@ class AuthConfig {
/// Default is 8 characters.
final int minPasswordLength;

/// True if unsecure random number generation is allowed. If set to false, an
/// error will be thrown if the platform does not support secure random number
/// generation. Default is true but will be changed to false in Serverpod 2.0.
final bool allowUnsecureRandom;

/// Creates a new Auth configuration. Use the [set] method to replace the
/// default settings. Defaults to `config/firebase_service_account_key.json`.
AuthConfig({
Expand Down Expand Up @@ -156,5 +161,6 @@ class AuthConfig {
'config/firebase_service_account_key.json',
this.maxPasswordLength = 128,
this.minPasswordLength = 8,
this.allowUnsecureRandom = true,
});
}
@@ -1,24 +1,136 @@
import 'dart:convert';
import 'dart:math';

import 'package:crypto/crypto.dart';
import 'package:email_validator/email_validator.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_server/module.dart';
import 'package:serverpod_auth_server/src/business/email_secrets.dart';
import 'package:serverpod_auth_server/src/business/password_hash.dart';
import 'package:serverpod_auth_server/src/business/user_images.dart';

/// Collection of utility methods when working with email authentication.
class Emails {
/// Generates a password hash from a users password and email. This value
/// can safely be stored in the database without the risk of exposing
/// passwords.
static String generatePasswordHash(String password, String email) {
var salt = Serverpod.instance.getPassword('email_password_salt') ??
'serverpod password salt';
if (AuthConfig.current.extraSaltyHash) {
salt += ':$email';
static String generatePasswordHash(String password) {
return PasswordHash.argon2id(
password,
pepper: EmailSecrets.pepper,
allowUnsecureRandom: AuthConfig.current.allowUnsecureRandom,
);
}

/// Generates a password hash from the password using the provided hash
/// algorithm and validates that they match.
///
/// If the password hash does not match the provided hash, the
/// [onValidationFailure] function is called with the hash and the password
/// hash as arguments.
///
/// If an error occurs, the [onError] function is called with the error as
/// argument.
static bool validatePasswordHash(
String password,
String email,
String hash, {
void Function(String hash, String passwordHash)? onValidationFailure,
void Function(Object e)? onError,
}) {
try {
return PasswordHash(
hash,
legacySalt: EmailSecrets.legacySalt,
legacyEmail: AuthConfig.current.extraSaltyHash ? email : null,
pepper: EmailSecrets.pepper,
).validate(password, onValidationFailure: onValidationFailure);
} catch (e) {
onError?.call(e);
return false;
}
}

/// Migrates an EmailAuth entry if required.
///
/// Returns the new [EmailAuth] object if a migration was required,
/// null otherwise.
static EmailAuth? tryMigrateAuthEntry({
required String password,
required EmailAuth entry,
}) {
if (!PasswordHash(entry.hash, legacySalt: EmailSecrets.legacySalt)
.shouldUpdateHash()) {
return null;
}

var newHash = PasswordHash.argon2id(
password,
pepper: EmailSecrets.pepper,
allowUnsecureRandom: AuthConfig.current.allowUnsecureRandom,
);

return entry.copyWith(hash: newHash);
}

/// Migrates legacy password hashes to the latest hash algorithm.
///
///[batchSize] is the number of entries to migrate in each batch.
///
/// Returns the number of migrated entries.
static Future<int> migrateLegacyPasswordHashes(
Session session, {
int batchSize = 100,
}) async {
var updatedEntries = 0;
int lastEntryId = 0;

while (true) {
var entries = await EmailAuth.db.find(
session,
where: (t) => t.hash.notLike(r'%$%') & (t.id > lastEntryId),
orderBy: (t) => t.id,
limit: batchSize,
);

if (entries.isEmpty) {
return updatedEntries;
}

lastEntryId = entries.last.id!;

var migratedEntries = entries.where((entry) {
try {
return PasswordHash(
entry.hash,
legacySalt: EmailSecrets.legacySalt,
).isLegacyHash();
} catch (e) {
session.log(
'Error when checking if hash is legacy: $e',
level: LogLevel.error,
);
return false;
}
}).map((entry) {
return entry.copyWith(
hash: PasswordHash.migratedLegacyToArgon2idHash(
entry.hash,
legacySalt: EmailSecrets.legacySalt,
pepper: EmailSecrets.pepper,
allowUnsecureRandom: AuthConfig.current.allowUnsecureRandom,
),
);
}).toList();

try {
await EmailAuth.db.update(session, migratedEntries);
updatedEntries += migratedEntries.length;
} catch (e) {
session.log(
'Failed to update migrated entries: $e',
level: LogLevel.error,
);
}
}
return sha256.convert(utf8.encode(password + salt)).toString();
}

/// Creates a new user. Either password or hash needs to be provided.
Expand Down Expand Up @@ -58,7 +170,7 @@ class Emails {
}

session.log('creating email auth', level: LogLevel.debug);
hash = hash ?? generatePasswordHash(password!, email);
hash = hash ?? generatePasswordHash(password!);
var auth = EmailAuth(
userId: userInfo.id!,
email: email,
Expand Down Expand Up @@ -91,12 +203,22 @@ class Emails {
}

// Check old password
if (auth.hash != generatePasswordHash(oldPassword, auth.email)) {
if (!validatePasswordHash(
oldPassword,
auth.email,
auth.hash,
onError: (e) {
session.log(
' - error when validating password hash: $e',
level: LogLevel.error,
);
},
)) {
return false;
}

// Update password
auth.hash = generatePasswordHash(newPassword, auth.email);
auth.hash = generatePasswordHash(newPassword);
await EmailAuth.db.updateRow(session, auth);

return true;
Expand Down Expand Up @@ -180,7 +302,7 @@ class Emails {

if (emailAuth == null) return false;

emailAuth.hash = generatePasswordHash(password, emailAuth.email);
emailAuth.hash = generatePasswordHash(password);
await EmailAuth.db.updateRow(session, emailAuth);

return true;
Expand Down Expand Up @@ -226,7 +348,7 @@ class Emails {
accountRequest = EmailCreateAccountRequest(
userName: userName,
email: email,
hash: generatePasswordHash(password, email),
hash: generatePasswordHash(password),
verificationCode: _generateVerificationCode(),
);
await EmailCreateAccountRequest.db.insertRow(session, accountRequest);
Expand Down Expand Up @@ -258,10 +380,142 @@ class Emails {
);
}

/// Authenticates a user with email and password. Returns an
/// [AuthenticationResponse] with the users information.
static Future<AuthenticationResponse> authenticate(
Session session,
String email,
String password,
) async {
email = email.toLowerCase();
password = password.trim();

session.log('authenticate $email / XXXXXXXX', level: LogLevel.debug);

// Fetch password entry
var entry = await EmailAuth.db.findFirstRow(session, where: (t) {
return t.email.equals(email);
});

if (entry == null) {
return AuthenticationResponse(
success: false,
failReason: AuthenticationFailReason.invalidCredentials,
);
}

if (await _hasTooManyFailedSignIns(session, email)) {
return AuthenticationResponse(
success: false,
failReason: AuthenticationFailReason.tooManyFailedAttempts,
);
}

session.log(' - found entry ', level: LogLevel.debug);

// Check that password is correct
if (!Emails.validatePasswordHash(
password,
email,
entry.hash,
onValidationFailure: (hash, passwordHash) => session.log(
' - $passwordHash saved: $hash',
level: LogLevel.debug,
),
onError: (e) {
session.log(
' - error when validating password hash: $e',
level: LogLevel.error,
);
},
)) {
await _logFailedSignIn(session, email);
return AuthenticationResponse(
success: false,
failReason: AuthenticationFailReason.invalidCredentials,
);
}

session.log(' - password is correct, userId: ${entry.userId})',
level: LogLevel.debug);

var migratedAuth = Emails.tryMigrateAuthEntry(
password: password,
entry: entry,
);

if (migratedAuth != null) {
session.log(' - migrating authentication entry', level: LogLevel.debug);
try {
await EmailAuth.db.updateRow(session, migratedAuth);
} catch (e) {
session.log(
' - failed to update migrated auth: $e',
level: LogLevel.error,
);
}
}

var userInfo = await Users.findUserByUserId(session, entry.userId);
if (userInfo == null) {
return AuthenticationResponse(
success: false,
failReason: AuthenticationFailReason.invalidCredentials,
);
} else if (userInfo.blocked) {
return AuthenticationResponse(
success: false,
failReason: AuthenticationFailReason.blocked,
);
}

session.log(' - user found', level: LogLevel.debug);

// Sign in user and return user info
var auth = await session.auth.signInUser(
entry.userId,
'email',
scopes: userInfo.scopes,
);

session.log(' - user signed in', level: LogLevel.debug);

return AuthenticationResponse(
success: true,
userInfo: userInfo,
key: auth.key,
keyId: auth.id,
);
}

static String _generateVerificationCode() {
return Random().nextString(
length: 8,
chars: '0123456789',
);
}

static Future<bool> _hasTooManyFailedSignIns(
Session session, String email) async {
var numFailedSignIns = await EmailFailedSignIn.db.count(
session,
where: (t) =>
t.email.equals(email) &
(t.time >
DateTime.now()
.toUtc()
.subtract(AuthConfig.current.emailSignInFailureResetTime)),
);
return numFailedSignIns >= AuthConfig.current.maxAllowedEmailSignInAttempts;
}

static Future<void> _logFailedSignIn(Session session, String email) async {
session as MethodCallSession;
var failedSignIn = EmailFailedSignIn(
email: email,
time: DateTime.now(),
ipAddress: session.httpRequest.remoteIpAddress,
);
await EmailFailedSignIn.db.insertRow(session, failedSignIn);
}
}
@@ -0,0 +1,13 @@
import 'package:serverpod/serverpod.dart';

/// Secrets used for email authentication.
abstract class EmailSecrets {
/// The salt used for hashing legacy passwords.
static String get legacySalt =>
Serverpod.instance.getPassword('email_password_salt') ??
'serverpod password salt';

/// The pepper used for hashing passwords.
static String? get pepper =>
Serverpod.instance.getPassword('emailPasswordPepper');
}

0 comments on commit a78b9e9

Please sign in to comment.