httpStatus(403);
}
$username = (string) $request->username;
// Checked before the credentials are tried, so a caller who is already
// over the limit costs nothing but the transient read.
if (!AuthRateLimiter::isAllowed(AuthRateLimiter::LOGIN, $username)) {
return Response::error(AuthRateLimiter::errorMessage(AuthRateLimiter::LOGIN))
->httpStatus(429);
}
$user = AuthService::login(
$username,
(string) $request->password,
(bool) ($request->remember ?? false)
);
if (is_wp_error($user)) {
// Only a failure is counted, so somebody signing in correctly is
// never throttled for it.
AuthRateLimiter::consume(AuthRateLimiter::LOGIN, $username);
return Response::error(self::loginErrorMessage($user))
->httpStatus(401);
}
AuthRateLimiter::forget(AuthRateLimiter::LOGIN, $username);
Hooks::doAction('bit_connect_after_login', $user);
return Response::success(self::authResponse($user));
}
/**
* REST signup endpoint.
*
* Creates the account WordPress's own registration form would have created
* — wp_insert_user() with the site's default role, after the `register_post`
* and `registration_errors` hooks have had their say — and then signs the
* member in through wp_signon(), like the login endpoint. With email
* verification on, nothing is created until the link is opened.
*/
public function signup(RestSignupRequest $request)
{
if (AuthService::isCustomUrlMode()) {
return Response::error(
__('The portal registration form is disabled. Please use the site registration page.', 'bit-connect')
)->httpStatus(403);
}
if (!AuthService::canRegister()) {
return Response::error(__('Registration is currently disabled.', 'bit-connect'))
->httpStatus(403);
}
$email = sanitize_email((string) $request->email);
// Keyed on the email rather than the username: the username is derived
// from the email when the form omits it, and the address is what a
// verification message is sent to.
if (!AuthRateLimiter::isAllowed(AuthRateLimiter::SIGNUP, $email)) {
return Response::error(AuthRateLimiter::errorMessage(AuthRateLimiter::SIGNUP))
->httpStatus(429);
}
AuthRateLimiter::consume(AuthRateLimiter::SIGNUP, $email);
$rawUsername = $request->username
? (string) $request->username
: preg_replace('/[^a-zA-Z0-9._-]/', '', explode('@', $email)[0]);
$username = sanitize_user((string) $rawUsername);
$password = (string) $request->password;
$displayName = sanitize_text_field((string) ($request->display_name ?? $username));
// The gate WordPress's own registration form runs — see
// AuthService::registrationErrors().
$refused = AuthService::registrationErrors($username, $email);
if ($refused !== null) {
return Response::error(self::errorMessage($refused))
->httpStatus(422);
}
// Hashed here, before the branch, so the copy parked for email
// verification is never the password itself.
$passwordHash = wp_hash_password($password);
// When email verification is required, defer user creation until the email is confirmed
if (AuthService::requiresEmailVerification()) {
// Validate uniqueness before storing
if (username_exists($username)) {
return Response::error(__('Sorry, that username already exists!', 'bit-connect'))
->httpStatus(422);
}
if (email_exists($email)) {
return Response::error(__('Sorry, that email address is already used!', 'bit-connect'))
->httpStatus(422);
}
AuthService::storePendingAndSendVerification(
compact('username', 'passwordHash', 'email', 'displayName'),
$email
);
return Response::success(
[
'status' => 'verification_pending',
'email' => $email,
]
);
}
// wp_insert_user() rejects a duplicate login or email with a WP_Error.
$userId = AuthService::createMember($username, $email, $displayName, $passwordHash);
if (is_wp_error($userId)) {
return Response::error(self::errorMessage($userId))
->httpStatus(422);
}
Hooks::doAction('bit_connect_after_register', $userId);
// Through wp_signon(), like the login endpoint, so everything on the
// `authenticate` chain sees this sign-in as well.
$user = AuthService::login($username, $password, false);
if (is_wp_error($user)) {
// The account exists; something on the authenticate chain declined
// to open a session for it here. The member can use the login form.
return Response::success(self::createdResponse($userId));
}
return Response::success(self::authResponse($user));
}
/**
* Finish a registration parked for email verification.
*
* The token is the key the registration was parked under, single-use and
* expiring with its transient, and it arrived by email at the address being
* claimed. Opening the link is the proof this flow asks for, so the account
* is created and a session opened for it. Only the password's hash was kept,
* so there is nothing to hand wp_signon(); wp_set_auth_cookie() opens the
* session for the account that was confirmed a moment ago.
*/
public function verifyEmail(RestVerifyEmailRequest $request)
{
$pending = AuthService::getPendingRegistration((string) $request->token);
if ($pending === null) {
return Response::error(__('Invalid verification link.', 'bit-connect'))
->httpStatus(422);
}
$userId = AuthService::createMember(
(string) $pending['username'],
(string) $pending['email'],
(string) $pending['displayName'],
(string) $pending['passwordHash']
);
if (is_wp_error($userId)) {
return Response::error(self::errorMessage($userId))
->httpStatus(422);
}
Hooks::doAction('bit_connect_after_register', $userId);
AuthService::primeCookieJar();
wp_set_current_user($userId);
wp_set_auth_cookie($userId);
$user = get_userdata($userId);
return Response::success(
$user instanceof WP_User ? self::authResponse($user) : self::createdResponse($userId)
);
}
/**
* Send a password reset email using WordPress's native lost password flow.
*/
public function forgotPassword(RestForgotPasswordRequest $request)
{
$login = sanitize_text_field((string) $request->login);
if (empty($login)) {
return Response::error(__('Please enter your username or email address.', 'bit-connect'))
->httpStatus(422);
}
// Every attempt counts, success or not: this endpoint sends mail, and
// the harm is a member's inbox being filled rather than a secret being
// guessed. Consumed before the send so a slow mailer cannot be used to
// hold the limiter open.
if (!AuthRateLimiter::isAllowed(AuthRateLimiter::PASSWORD_RESET, $login)) {
return Response::error(AuthRateLimiter::errorMessage(AuthRateLimiter::PASSWORD_RESET))
->httpStatus(429);
}
AuthRateLimiter::consume(AuthRateLimiter::PASSWORD_RESET, $login);
$result = retrieve_password($login);
// The same answer whether or not an account matched. WordPress says
// "There is no account with that username or email address", which let
// anyone test addresses against the site one at a time. A real failure
// — the mail could not be sent, resets are switched off — still says so.
$unknownAccount = is_wp_error($result) && \in_array($result->get_error_code(), ['invalidcombo', 'invalid_email'], true);
if (is_wp_error($result) && !$unknownAccount) {
return Response::error(self::errorMessage($result))
->httpStatus(422);
}
return Response::success(
['message' => __('If an account matches, a link to reset its password is on its way. Please check your inbox.', 'bit-connect')]
);
}
/**
* Build the response payload for an endpoint that changes the auth state.
*
* WordPress REST nonces are bound to the current user, so the `wp_rest`
* nonce embedded at page render (for the logged-out visitor) becomes invalid
* the moment we set the auth cookie. Returning a fresh nonce lets the SPA keep
* making authenticated requests without a full page reload — otherwise the next
* request fails the cookie nonce check (rest_cookie_invalid_nonce / 403).
*/
private static function authResponse(WP_User $user): array
{
return array_merge(
self::formatUser($user),
[
'nonce' => wp_create_nonce('wp_rest'),
// The same map auth/me and the bootstrap payload carry. Without
// it the portal falls back to assuming every member may post,
// vote and comment — and a member whose role grants none of that
// was shown the forms, then refused on submit.
'capabilities' => PermissionService::currentUserCapabilities(),
]
);
}
/**
* WordPress error messages are written for wp-login.php and carry markup —
* "Error: … Lost your password?". The SPA
* renders them as plain text, so the tags have to go before they ship.
*
* @param WP_Error $error
*/
private static function errorMessage($error): string
{
return trim(wp_strip_all_tags((string) $error->get_error_message()));
}
/**
* A failed sign-in, worded so it does not say which half was wrong.
*
* WordPress answers an unknown username "is not registered on this site"
* and a known one with the wrong password "The password you entered for
* the username X is incorrect", so the form told anyone which accounts
* exist. Those codes share one sentence; anything else — a second factor
* asked for, an account awaiting verification, a lockout — is the member's
* to read and passes through as WordPress or its plugin wrote it.
*
* @param \WP_Error $error
*/
private static function loginErrorMessage($error): string
{
$credentialCodes = ['invalid_username', 'invalid_email', 'incorrect_password'];
if (\in_array($error->get_error_code(), $credentialCodes, true)) {
return __('The username, email address or password is incorrect.', 'bit-connect');
}
return self::errorMessage($error);
}
/**
* The payload for an account that was created but not signed in: the user
* as WordPress now holds it, with no nonce because no session was opened.
*/
private static function createdResponse(int $userId): array
{
$user = get_userdata($userId);
return $user instanceof WP_User ? self::formatUser($user) : ['id' => $userId];
}
private static function formatUser(WP_User $user): array
{
$roles = array_values($user->roles);
return [
'id' => $user->ID,
'username' => $user->user_login,
'slug' => ProfileSlugService::slugFor($user->ID),
'email' => $user->user_email,
'display_name' => $user->display_name,
'avatar' => get_avatar_url($user->ID),
'role' => $roles[0] ?? null,
'roles' => $roles,
];
}
}