Compromised password blocking
tool_mupwned
A security-focused plugin that integrates with the Have I Been Pwned (HIBP) API using the k-Anonymity model to detect compromised passwords during Moodle login and password change flows. When a compromised password is detected, the plugin can block login, force a password reset, destroy active sessions, and expire web service/mobile tokens. It hooks into Moodle's `check_password_policy` and `print_password_policy` callbacks and is configured entirely through admin settings.
The plugin is well-structured, focused, and follows Moodle security and coding standards throughout. It uses Moodle's download_file_content() for HTTP requests (not raw curl), parameterized $DB methods for all database operations, get_string() for all user-facing text, and get_config() for settings. There are no XSS, SQL injection, CSRF, or authentication/authorization vulnerabilities.
The only findings are low-severity code quality observations: the plugin directly modifies core user and external_tokens tables using $DB->set_field(). In both cases, no suitable core API exists for the specific operations needed (invalidating a password by setting it to AUTH_PASSWORD_NOT_CACHED, and batch-expiring all tokens for a user). This is a recognized pattern in Moodle plugins when core lacks appropriate methods.
An info-level observation notes that the check_password_policy callback can terminate execution via redirect() or throw, which prevents subsequent plugins' callbacks from running. This is an intentional security design choice.
The plugin includes both PHPUnit and Behat tests, implements the Privacy API (null_provider, appropriate since it owns no user data tables), and has proper GPL license headers on all files. No third-party libraries are bundled.
Plugin Overview
tool_mupwned is a compact security plugin that checks user passwords against the Have I Been Pwned database during login and password change operations. It uses the k-Anonymity model — only the first 5 characters of the SHA1 hash are sent to the API, preserving password privacy.
Architecture
The plugin consists of:
lib.php— Two Moodle callbacks:tool_mupwned_print_password_policy()adds breach-check info to password policy display, andtool_mupwned_check_password_policy()performs the actual HIBP check and blocks compromised passwordsclasses/local/blocker.php— Core logic:is_password_compromised()calls the HIBP range API viadownload_file_content(), andguess_service()determines the login context from$SCRIPTclasses/event/user_login_blocked.php— Standard Moodle event for audit loggingsettings.php— Three admin toggles: enable/disable, force password reset, expire tokensclasses/privacy/provider.php—null_provider(no plugin-owned user data)
Security Assessment
The plugin follows Moodle security best practices:
- Uses
download_file_content()(Moodle's curl wrapper) instead of raw HTTP functions - All database operations use parameterized
$DBmethods - Short HTTP timeouts (5s) prevent login flow blocking
- Graceful fallback when the HIBP API is unreachable (allows login)
- All user-facing strings use
get_string()from language files - Settings restricted to
moodle/site:configcapability
Key Findings
Only low-severity code quality observations were found — direct writes to core tables (user.password and external_tokens.validuntil) where no core API exists for the needed operations. One info-level note about non-standard callback behavior (redirect/throw within the password policy callback chain).
Findings
The plugin directly modifies two core Moodle tables using $DB->set_field():
user.password— set toAUTH_PASSWORD_NOT_CACHEDto invalidate the compromised passwordexternal_tokens.validuntil— set to a magic expiration date to expire all tokens for the user
While Moodle provides update_internal_user_password() for password updates, that function always hashes the input or sets AUTH_PASSWORD_NOT_CACHED only when the auth plugin prevents local passwords. There is no core API to programmatically invalidate a user's password by setting it to AUTH_PASSWORD_NOT_CACHED. Similarly, no core API exists for batch-expiring all external tokens for a user.
This is a recognized pattern in Moodle plugins when core lacks appropriate methods, but it bypasses any event triggers or side effects that a dedicated API would handle (e.g., update_internal_user_password() triggers user_password_updated event).
Low risk. These are writes to core tables that the plugin doesn't own, which is a code quality concern rather than a security issue. The operations are correct, use parameterized queries (no injection risk), and are gated behind admin configuration. The main consequence is bypassing core event triggers (user_password_updated), which could mean other plugins monitoring password changes aren't notified. However, the plugin fires its own user_login_blocked event for audit purposes.
Both writes occur inside tool_mupwned_check_password_policy(), which is a Moodle callback invoked during the login flow when a user's password is found to be compromised. The user.password write invalidates the password so the user must reset it. The external_tokens.validuntil write expires all web service and mobile tokens. Both operations are gated behind admin-controlled config settings (resetpassword and expiretokens).
$DB->set_field('user', 'password', AUTH_PASSWORD_NOT_CACHED, ['id' => $user->id]);
No core API currently supports this operation. If a future Moodle version provides an API for programmatic password invalidation, migrate to it. Consider also triggering \core\event\user_password_updated for consistency with core behavior.
$DB->set_field('external_tokens', 'validuntil', blocker::TOKEN_EXPIRATION, ['userid' => $user->id]);
No core API currently supports batch-expiring tokens for a user. If a future Moodle version provides such an API, migrate to it.
The tool_mupwned_check_password_policy() callback can terminate execution in two ways instead of returning a string:
- Calls
redirect()(which sends HTTP headers and callsdie()) when the login is via the web login page - Throws
moodle_exceptionwhen the login is vialogin/token.phpor a web service
Moodle's get_password_policy_errors() iterates over all plugins with a check_password_policy callback. When this plugin terminates execution, any subsequent plugins' callbacks in the loop will not run.
This is an intentional security design choice — the plugin prioritizes immediately blocking the compromised login over the standard callback return pattern. The approach is effective and well-tested.
Informational. This is a deliberate architectural decision, not a defect. The redirect/throw pattern ensures that a user with a compromised password cannot proceed past the login screen. The trade-off is that other plugins' password policy callbacks are skipped, but this is acceptable given the security context. If other plugins also need to act on compromised passwords, they would need to handle the case where the user never reaches them.
The check_password_policy callback is invoked from get_password_policy_errors() in lib/moodlelib.php. The core code iterates over all registered plugin callbacks in a loop. Normally each callback returns a string (error message) or null. This plugin breaks that contract when it detects a compromised password during login, choosing to immediately block the login rather than just reporting an error message that the user could potentially bypass.
redirect(new moodle_url('/login/forgot_password.php'), $message, null, \core\output\notification::NOTIFY_ERROR);
throw new moodle_exception('invalidlogin');
The plugin uses the k-Anonymity model for HIBP lookups — only the first 5 characters of the SHA1 hash of the password are sent to the API. The full password hash never leaves the server, which is a well-established privacy-preserving approach.
The plugin includes comprehensive test coverage: a PHPUnit test suite covering both the blocker class and the lib.php callbacks, plus Behat acceptance tests covering login blocking, account creation with compromised passwords, and password change rejection.
The HTTP request to the HIBP API uses a 5-second timeout for both connection and transfer, and gracefully allows login if the API is unreachable. This prevents the external service from becoming a single point of failure for the Moodle login flow.
The settings.php page properly requires the moodle/site:config capability and displays contextual warnings when prerequisite Moodle settings (passwordpolicy, passwordpolicycheckonlogin) are disabled.