From 8e38f0c9cdba43dd8e91d8b6234b08eac95041a6 Mon Sep 17 00:00:00 2001 From: Matt Gros Date: Sat, 27 Jun 2026 06:44:42 -0400 Subject: [PATCH] feat(api): complete /auth contract (login/register/logout/refresh/me/profile) Extend the API auth controller from the single validate endpoint to the full Flutter contract. WordPress owns its users, so auth is WP-native: login verifies credentials with wp_authenticate() and issues an Escalated API token; register uses wp_create_user() when registration is open; me/profile read/update the token's user; logout/refresh revoke (and re-issue) the token. login/register are public; the rest require a Bearer token. --- includes/Api/class-auth-controller.php | 226 ++++++++++++++++++++++--- tests/Test_Api_Auth.php | 78 +++++++++ 2 files changed, 284 insertions(+), 20 deletions(-) create mode 100644 tests/Test_Api_Auth.php diff --git a/includes/Api/class-auth-controller.php b/includes/Api/class-auth-controller.php index 1be0c9e..592e2dc 100644 --- a/includes/Api/class-auth-controller.php +++ b/includes/Api/class-auth-controller.php @@ -1,13 +1,18 @@ namespace, - '/'.$this->rest_base.'/validate', - [ + $public = '__return_true'; + $authed = [$this, 'token_permissions_check']; + + $routes = [ + ['validate', WP_REST_Server::CREATABLE, 'validate_token', $authed], + ['login', WP_REST_Server::CREATABLE, 'login', $public], + ['register', WP_REST_Server::CREATABLE, 'register', $public], + ['logout', WP_REST_Server::CREATABLE, 'logout', $authed], + ['refresh', WP_REST_Server::CREATABLE, 'refresh', $authed], + ['me', WP_REST_Server::READABLE, 'me', $authed], + ['profile', WP_REST_Server::EDITABLE, 'update_profile', $authed], + ]; + + foreach ($routes as [$path, $methods, $callback, $permission]) { + register_rest_route( + $this->namespace, + '/'.$this->rest_base.'/'.$path, [ - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => [$this, 'validate_token'], - 'permission_callback' => [$this, 'token_permissions_check'], - 'args' => [], - ], - ] - ); + [ + 'methods' => $methods, + 'callback' => [$this, $callback], + 'permission_callback' => $permission, + 'args' => [], + ], + ] + ); + } } /** * Validate the provided Bearer token and return user information. * * @param WP_REST_Request $request The incoming request. - * @return WP_REST_Response|\WP_Error + * @return \WP_REST_Response|\WP_Error */ public function validate_token(WP_REST_Request $request) { @@ -60,12 +80,178 @@ public function validate_token(WP_REST_Request $request) return $this->success([ 'valid' => true, - 'user' => [ - 'id' => $user->ID, - 'display_name' => $user->display_name, - 'email' => $user->user_email, - 'roles' => $user->roles, - ], + 'user' => $this->user_payload($user), ]); } + + /** + * Authenticate credentials and issue an API token. + * + * @param WP_REST_Request $request The incoming request. + * @return \WP_REST_Response|\WP_Error + */ + public function login(WP_REST_Request $request) + { + $username = (string) ($request->get_param('username') ?: $request->get_param('email')); + $password = (string) $request->get_param('password'); + + if ($username === '' || $password === '') { + return $this->error('escalated_missing_credentials', __('Username and password are required.', 'escalated'), 422); + } + + $user = wp_authenticate($username, $password); + + if (is_wp_error($user)) { + return $this->error('escalated_invalid_credentials', __('Invalid credentials.', 'escalated'), 401); + } + + return $this->success($this->issue_token_response($user)); + } + + /** + * Register a new account (only when WordPress registration is open). + * + * @param WP_REST_Request $request The incoming request. + * @return \WP_REST_Response|\WP_Error + */ + public function register(WP_REST_Request $request) + { + if (! get_option('users_can_register')) { + return $this->error('escalated_registration_disabled', __('Registration is disabled.', 'escalated'), 403); + } + + $email = sanitize_email((string) $request->get_param('email')); + $username = (string) ($request->get_param('username') ?: $email); + $password = (string) $request->get_param('password'); + + if (! is_email($email) || $password === '') { + return $this->error('escalated_invalid_registration', __('A valid email and password are required.', 'escalated'), 422); + } + + $user_id = wp_create_user($username, $password, $email); + + if (is_wp_error($user_id)) { + return $this->error('escalated_registration_failed', $user_id->get_error_message(), 422); + } + + return $this->success($this->issue_token_response(get_userdata($user_id)), 201); + } + + /** + * Return the authenticated user. + * + * @param WP_REST_Request $request The incoming request. + * @return \WP_REST_Response + */ + public function me(WP_REST_Request $request) + { + $user = get_userdata($this->check_token_permission($request)); + + return $this->success(['user' => $this->user_payload($user)]); + } + + /** + * Update the authenticated user's profile. + * + * @param WP_REST_Request $request The incoming request. + * @return \WP_REST_Response|\WP_Error + */ + public function update_profile(WP_REST_Request $request) + { + $user_id = $this->check_token_permission($request); + $args = ['ID' => $user_id]; + + if ($request->get_param('display_name') !== null) { + $args['display_name'] = sanitize_text_field((string) $request->get_param('display_name')); + } + if ($request->get_param('email') !== null) { + $email = sanitize_email((string) $request->get_param('email')); + if (! is_email($email)) { + return $this->error('escalated_invalid_email', __('Invalid email address.', 'escalated'), 422); + } + $args['user_email'] = $email; + } + + $result = wp_update_user($args); + + if (is_wp_error($result)) { + return $this->error('escalated_profile_update_failed', $result->get_error_message(), 422); + } + + return $this->success(['user' => $this->user_payload(get_userdata($user_id))]); + } + + /** + * Revoke the current token. + * + * @param WP_REST_Request $request The incoming request. + * @return \WP_REST_Response + */ + public function logout(WP_REST_Request $request) + { + $this->revoke_request_token($request); + + return $this->success(['success' => true]); + } + + /** + * Issue a fresh token and revoke the current one. + * + * @param WP_REST_Request $request The incoming request. + * @return \WP_REST_Response + */ + public function refresh(WP_REST_Request $request) + { + $user = get_userdata($this->check_token_permission($request)); + $this->revoke_request_token($request); + + return $this->success($this->issue_token_response($user)); + } + + /** + * Issue an API token for a user and build the token+user response. + * + * @param \WP_User $user + */ + private function issue_token_response($user): array + { + $created = ApiToken::create_token($user->ID, 'flutter-app'); + + return [ + 'token' => $created ? $created['token'] : null, + 'user' => $this->user_payload($user), + ]; + } + + /** + * Serialize a user for API responses. + * + * @param \WP_User $user + */ + private function user_payload($user): array + { + return [ + 'id' => $user->ID, + 'display_name' => $user->display_name, + 'email' => $user->user_email, + 'roles' => $user->roles, + ]; + } + + /** + * Revoke the token carried by the request's Authorization header. + * + * @param WP_REST_Request $request The incoming request. + */ + private function revoke_request_token(WP_REST_Request $request): void + { + $header = (string) $request->get_header('Authorization'); + + if ($header !== '' && str_starts_with($header, 'Bearer ')) { + $record = ApiToken::find_by_token(substr($header, 7)); + if ($record) { + ApiToken::delete($record->id); + } + } + } } diff --git a/tests/Test_Api_Auth.php b/tests/Test_Api_Auth.php new file mode 100644 index 0000000..bc5c367 --- /dev/null +++ b/tests/Test_Api_Auth.php @@ -0,0 +1,78 @@ +set_param('username', $username); + $request->set_param('password', $password); + + return $request; + } + + public function test_login_issues_token_for_valid_credentials() + { + self::factory()->user->create([ + 'user_login' => 'pat', + 'user_pass' => 'secret123', + 'user_email' => 'pat@example.com', + ]); + + $response = $this->controller()->login($this->login_request('pat', 'secret123')); + + $this->assertNotWPError($response); + $data = $response->get_data(); + $this->assertNotEmpty($data['token']); + $this->assertSame('pat@example.com', $data['user']['email']); + } + + public function test_login_rejects_invalid_credentials() + { + self::factory()->user->create(['user_login' => 'pat3', 'user_pass' => 'secret123']); + + $response = $this->controller()->login($this->login_request('pat3', 'wrong')); + + $this->assertWPError($response); + $this->assertSame(401, $response->get_error_data()['status']); + } + + public function test_login_requires_credentials() + { + $response = $this->controller()->login($this->login_request('', '')); + + $this->assertWPError($response); + $this->assertSame(422, $response->get_error_data()['status']); + } + + public function test_me_returns_user_and_logout_revokes_token() + { + self::factory()->user->create([ + 'user_login' => 'pat4', + 'user_pass' => 'secret123', + 'user_email' => 'pat4@example.com', + ]); + $token = $this->controller()->login($this->login_request('pat4', 'secret123'))->get_data()['token']; + + $me = new WP_REST_Request('GET', '/escalated/v1/auth/me'); + $me->set_header('Authorization', 'Bearer '.$token); + $this->assertSame('pat4@example.com', $this->controller()->me($me)->get_data()['user']['email']); + + $logout = new WP_REST_Request('POST', '/escalated/v1/auth/logout'); + $logout->set_header('Authorization', 'Bearer '.$token); + $this->assertTrue($this->controller()->logout($logout)->get_data()['success']); + $this->assertNull(ApiToken::find_by_token($token)); + } +}