PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / api / storefront-auth.php

storefront-auth.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/api/storefront-auth.php

342 lines 13.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Storefront authentication REST endpoints.
4 *
5 * Mirrors the legacy `customer_login` admin-ajax action over REST so the
6 * storefront login form (and any headless storefront) can sign a shopper in
7 * via cookie auth without round-tripping through admin-ajax.php.
8 *
9 * Routes:
10 * POST /wp-json/storeengine/v1/auth/login — sign in (sets the WP auth cookie)
11 * POST /wp-json/storeengine/v1/auth/register — create account + sign in
12 * POST /wp-json/storeengine/v1/auth/forgot-password — request reset email
13 * POST /wp-json/storeengine/v1/auth/reset-password — finalize reset with key+login
14 *
15 * Uses the standard WP REST nonce (`X-WP-Nonce`) for CSRF protection on
16 * same-origin storefronts. Cross-origin headless storefronts can't currently
17 * authenticate this way (you can't set the WP auth cookie from another origin) —
18 * that's a deliberate limitation; cross-origin clients should use the dedicated
19 * application-password REST flow instead.
20 *
21 * The non-JS form-post handlers (StoreEngine\Post\ForgotPassword,
22 * StoreEngine\Post\Register) call the same WP core primitives this controller
23 * does — both paths converge on retrieve_password() / wp_create_user() /
24 * reset_password() so behavior stays identical regardless of submission style.
25 */
26
27 namespace StoreEngine\API;
28
29 use StoreEngine\Utils\Helper;
30 use WP_Error;
31 use WP_REST_Request;
32 use WP_REST_Server;
33 use WP_User;
34
35 if ( ! defined( 'ABSPATH' ) ) {
36 exit;
37 }
38
39 class StorefrontAuth extends AbstractRestApiController {
40
41 protected $rest_base = 'auth';
42
43 public static function init() {
44 $self = new self();
45 add_action( 'rest_api_init', [ $self, 'register_routes' ] );
46 }
47
48 public function register_routes() {
49 register_rest_route( $this->namespace, '/' . $this->rest_base . '/login', [
50 [
51 'methods' => WP_REST_Server::CREATABLE,
52 'callback' => [ $this, 'login' ],
53 'permission_callback' => '__return_true',
54 'args' => [
55 'username' => [ 'type' => 'string', 'required' => true ],
56 'password' => [ 'type' => 'string', 'required' => true ],
57 'remember' => [ 'type' => 'boolean', 'default' => false ],
58 'redirect_to' => [ 'type' => 'string', 'required' => false, 'format' => 'uri' ],
59 ],
60 ],
61 ] );
62
63 register_rest_route( $this->namespace, '/' . $this->rest_base . '/register', [
64 [
65 'methods' => WP_REST_Server::CREATABLE,
66 'callback' => [ $this, 'register' ],
67 'permission_callback' => [ $this, 'registration_open' ],
68 'args' => [
69 'email' => [ 'type' => 'string', 'required' => true, 'format' => 'email' ],
70 'password' => [ 'type' => 'string', 'required' => true ],
71 'first_name' => [ 'type' => 'string', 'required' => false ],
72 'last_name' => [ 'type' => 'string', 'required' => false ],
73 'auto_login' => [ 'type' => 'boolean', 'default' => true ],
74 ],
75 ],
76 ] );
77
78 register_rest_route( $this->namespace, '/' . $this->rest_base . '/forgot-password', [
79 [
80 'methods' => WP_REST_Server::CREATABLE,
81 'callback' => [ $this, 'forgot_password' ],
82 'permission_callback' => '__return_true',
83 'args' => [
84 'email' => [ 'type' => 'string', 'required' => true, 'format' => 'email' ],
85 ],
86 ],
87 ] );
88
89 register_rest_route( $this->namespace, '/' . $this->rest_base . '/reset-password', [
90 [
91 'methods' => WP_REST_Server::CREATABLE,
92 'callback' => [ $this, 'reset_password' ],
93 'permission_callback' => '__return_true',
94 'args' => [
95 'key' => [ 'type' => 'string', 'required' => true ],
96 'login' => [ 'type' => 'string', 'required' => true ],
97 'password' => [ 'type' => 'string', 'required' => true ],
98 ],
99 ],
100 ] );
101 }
102
103 /**
104 * Block registration entirely when WP's "Anyone can register" toggle is off.
105 * Matches the gate `wp_registration_url()` and our login template already
106 * respect, so the REST surface can't quietly bypass site policy.
107 */
108 public function registration_open() {
109 if ( ! get_option( 'users_can_register' ) ) {
110 return new WP_Error( 'storeengine_registration_closed', __( 'New account registration is currently disabled.', 'storeengine' ), [ 'status' => 403 ] );
111 }
112 return true;
113 }
114
115 public function login( WP_REST_Request $request ) {
116 $username = sanitize_text_field( (string) $request->get_param( 'username' ) );
117 $password = (string) $request->get_param( 'password' );
118
119 if ( '' === $username ) {
120 return new WP_Error( 'storeengine_auth_username_required', __( 'Username is required', 'storeengine' ), [ 'status' => 422 ] );
121 }
122 if ( '' === $password ) {
123 return new WP_Error( 'storeengine_auth_password_required', __( 'Password is required', 'storeengine' ), [ 'status' => 422 ] );
124 }
125
126 // Wipe any stale auth cookie before attempting a fresh sign-on so a
127 // failed login can't leave the previous session in place.
128 wp_clear_auth_cookie();
129
130 do_action( 'storeengine/shortcode/before_customer_signon' );
131
132 $user = wp_signon( [
133 'user_login' => $username,
134 'user_password' => $password,
135 'remember' => (bool) $request->get_param( 'remember' ),
136 ], is_ssl() );
137
138 if ( is_wp_error( $user ) ) {
139 return new WP_Error( 'storeengine_auth_failed', $user->get_error_message(), [ 'status' => 401 ] );
140 }
141
142 wp_set_current_user( $user->ID );
143
144 do_action( 'storeengine/shortcode/after_customer_signon' );
145
146 $redirect_to = (string) $request->get_param( 'redirect_to' );
147 if ( '' === $redirect_to ) {
148 $redirect_to = $user->has_cap( 'manage_options' ) ? admin_url() : Helper::get_dashboard_url();
149 }
150
151 // If the site is HTTPS-only, ensure a wp-admin redirect doesn't downgrade.
152 if ( is_ssl() && str_contains( $redirect_to, 'wp-admin' ) && str_starts_with( $redirect_to, 'http://' ) ) {
153 $redirect_to = preg_replace( '|^http://|', 'https://', $redirect_to );
154 }
155
156 return rest_ensure_response( [
157 'message' => esc_html__( 'You have logged in successfully. Redirecting...', 'storeengine' ),
158 'redirect_url' => esc_url_raw( wp_validate_redirect( $redirect_to, home_url() ) ),
159 'user' => [
160 'id' => $user->ID,
161 'display_name' => $user->display_name,
162 'email' => $user->user_email,
163 ],
164 ] );
165 }
166
167 /**
168 * Create a customer account. Mirrors the auto-create-on-checkout flow at
169 * CheckoutService::create_customer() so usernames are derived the same way
170 * (email-local-part + dedupe counter) and the same `customer_created`
171 * action fires — which the NewUserNotification email already listens to.
172 *
173 * `auto_login` defaults true. Setting it false is the explicit opt-out for
174 * back-office workflows that create accounts on behalf of someone else.
175 */
176 public function register( WP_REST_Request $request ) {
177 $email = sanitize_email( (string) $request->get_param( 'email' ) );
178 $password = (string) $request->get_param( 'password' );
179 $first_name = sanitize_text_field( (string) $request->get_param( 'first_name' ) );
180 $last_name = sanitize_text_field( (string) $request->get_param( 'last_name' ) );
181
182 if ( '' === $email || ! is_email( $email ) ) {
183 return new WP_Error( 'storeengine_register_invalid_email', __( 'Please enter a valid email address.', 'storeengine' ), [ 'status' => 422 ] );
184 }
185 if ( '' === $password ) {
186 return new WP_Error( 'storeengine_register_password_required', __( 'Please choose a password.', 'storeengine' ), [ 'status' => 422 ] );
187 }
188
189 $min_length = (int) apply_filters( 'storeengine/auth/min_password_length', 8 );
190 if ( strlen( $password ) < $min_length ) {
191 return new WP_Error(
192 'storeengine_register_password_too_short',
193 sprintf(
194 /* translators: %d: minimum password length */
195 _n( 'Password must be at least %d character long.', 'Password must be at least %d characters long.', $min_length, 'storeengine' ),
196 $min_length
197 ),
198 [ 'status' => 422 ]
199 );
200 }
201
202 if ( email_exists( $email ) ) {
203 // Deliberately specific — for *registration* (where you know your
204 // own email), surfacing existence is fine and far more useful than
205 // a generic "something went wrong". The enumeration concern only
206 // applies to the forgot-password flow, where it leaks third-party
207 // accounts.
208 return new WP_Error( 'storeengine_register_email_taken', __( 'An account with this email already exists. Try signing in instead.', 'storeengine' ), [ 'status' => 409 ] );
209 }
210
211 $username = $this->derive_unique_username( $email );
212
213 $userdata = apply_filters( 'storeengine/auth/register_userdata', [
214 'user_login' => $username,
215 'user_email' => $email,
216 'user_pass' => $password,
217 'role' => 'storeengine_customer',
218 'display_name' => trim( $first_name . ' ' . $last_name ) ?: $username,
219 'first_name' => $first_name,
220 'last_name' => $last_name,
221 ], $request );
222
223 $user_id = wp_insert_user( $userdata );
224 if ( is_wp_error( $user_id ) ) {
225 return new WP_Error( 'storeengine_register_failed', $user_id->get_error_message(), [ 'status' => 422 ] );
226 }
227
228 // Reuse the existing checkout-side hook so NewUserNotification email
229 // (the welcome-with-credentials mail) fires on REST signup too.
230 do_action( 'storeengine/checkout/customer_created', $user_id, $userdata );
231
232 $redirect_to = Helper::get_dashboard_url();
233
234 if ( $request->get_param( 'auto_login' ) ) {
235 wp_clear_auth_cookie();
236 $user = wp_signon( [
237 'user_login' => $username,
238 'user_password' => $password,
239 'remember' => true,
240 ], is_ssl() );
241
242 if ( ! is_wp_error( $user ) ) {
243 wp_set_current_user( $user->ID );
244 }
245 }
246
247 return rest_ensure_response( [
248 'message' => esc_html__( 'Your account has been created.', 'storeengine' ),
249 'redirect_url' => esc_url_raw( $redirect_to ),
250 'user' => [
251 'id' => (int) $user_id,
252 'email' => $email,
253 'login' => $username,
254 ],
255 ] );
256 }
257
258 /**
259 * Step 1 of password reset: send the branded email.
260 *
261 * Always returns the same generic response whether or not the email
262 * matches a real account — leaking existence here is the classic
263 * enumeration mistake the form-post handler also takes care to avoid.
264 */
265 public function forgot_password( WP_REST_Request $request ) {
266 $email = sanitize_email( (string) $request->get_param( 'email' ) );
267
268 if ( '' === $email || ! is_email( $email ) ) {
269 return new WP_Error( 'storeengine_forgot_invalid_email', __( 'Please enter a valid email address.', 'storeengine' ), [ 'status' => 422 ] );
270 }
271
272 // retrieve_password() fires `retrieve_password_notification_email`,
273 // which the PasswordReset email class hooks to send the branded HTML
274 // version with a link back into our in-dashboard reset form.
275 retrieve_password( $email );
276
277 return rest_ensure_response( [
278 'message' => esc_html__( 'If an account exists with that email, a reset link is on its way.', 'storeengine' ),
279 ] );
280 }
281
282 /**
283 * Step 2 of password reset: validate the {key, login} from the email link
284 * and apply the new password. `reset_password()` fires the standard WP
285 * `password_reset` action so any integrations stay informed.
286 */
287 public function reset_password( WP_REST_Request $request ) {
288 $key = sanitize_text_field( (string) $request->get_param( 'key' ) );
289 $login = sanitize_text_field( (string) $request->get_param( 'login' ) );
290 $password = (string) $request->get_param( 'password' );
291
292 if ( '' === $key || '' === $login ) {
293 return new WP_Error( 'storeengine_reset_invalid_link', __( 'This reset link is missing required information.', 'storeengine' ), [ 'status' => 422 ] );
294 }
295 if ( '' === $password ) {
296 return new WP_Error( 'storeengine_reset_password_required', __( 'Please choose a new password.', 'storeengine' ), [ 'status' => 422 ] );
297 }
298
299 $min_length = (int) apply_filters( 'storeengine/auth/min_password_length', 8 );
300 if ( strlen( $password ) < $min_length ) {
301 return new WP_Error(
302 'storeengine_reset_password_too_short',
303 sprintf(
304 /* translators: %d: minimum password length */
305 _n( 'Password must be at least %d character long.', 'Password must be at least %d characters long.', $min_length, 'storeengine' ),
306 $min_length
307 ),
308 [ 'status' => 422 ]
309 );
310 }
311
312 $user = check_password_reset_key( $key, $login );
313 if ( is_wp_error( $user ) || ! ( $user instanceof WP_User ) ) {
314 return new WP_Error( 'storeengine_reset_expired', __( 'This reset link is invalid or has expired.', 'storeengine' ), [ 'status' => 410 ] );
315 }
316
317 reset_password( $user, $password );
318
319 return rest_ensure_response( [
320 'message' => esc_html__( 'Your password has been updated. You can now sign in with the new password.', 'storeengine' ),
321 'redirect_url' => esc_url_raw( add_query_arg( 'password_updated', '1', Helper::get_dashboard_url() ) ),
322 ] );
323 }
324
325 /**
326 * Build a unique username from an email's local part, dedup'ing with a
327 * numeric suffix if needed. Same algorithm as
328 * CheckoutService::create_customer() so the two registration paths produce
329 * identical usernames for the same email.
330 */
331 protected function derive_unique_username( string $email ): string {
332 $base = strstr( $email, '@', true ) ?: 'user';
333 $username = $base;
334 $counter = 1;
335 while ( username_exists( $username ) ) {
336 $username = $base . $counter;
337 $counter++;
338 }
339 return $username;
340 }
341 }
342