Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
2.27% covered (danger)
2.27%
2 / 88
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
DB_Data_Store
2.27% covered (danger)
2.27%
2 / 88
20.00% covered (danger)
20.00%
1 / 5
170.74
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 create_db
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 save
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
12
 get_value_for_code
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
30
 delete_expired_codes
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Uses a custom db table (as distinct from transients) to store the autologin codes.
4 *
5 * @link       https://BrianHenry.ie
6 * @since      1.2.1
7 *
8 * @package    brianhenryie/bh-wp-autologin-urls
9 */
10
11namespace BrianHenryIE\WP_Autologin_URLs\API\Data_Stores;
12
13use BrianHenryIE\WP_Autologin_URLs\API\Data_Store_Interface;
14use DateInterval;
15use DateTime;
16use DateTimeImmutable;
17use DateTimeInterface;
18use DateTimeZone;
19use Exception;
20use Psr\Log\LoggerInterface;
21use Psr\Log\LoggerAwareTrait;
22
23/**
24 * Creates a custom database table via standard $wpdb functions to store and retrieve the autologin codes.
25 *
26 * phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
27 */
28class DB_Data_Store implements Data_Store_Interface {
29    use LoggerAwareTrait;
30
31    /**
32     * Constructor.
33     *
34     * @param LoggerInterface $logger A PSR logger.
35     */
36    public function __construct( LoggerInterface $logger ) {
37        $this->setLogger( $logger );
38
39        add_action( 'plugins_loaded', array( $this, 'create_db' ), 1 );
40    }
41
42    /**
43     * The current plugin database version.
44     *
45     * @var int
46     */
47    protected static $db_version = 1;
48
49    /**
50     * Option name in wp_options for checking the database version on install/upgrades.
51     *
52     * @var string
53     */
54    public static $db_version_option_name = 'bh_wp_autologin_urls_db_version';
55
56    /**
57     * Create or upgrade the database.
58     *
59     * Check the saved wp_option for the last time the database was created and update accordingly.
60     *
61     * @hooked plugins_loaded
62     */
63    public function create_db(): void {
64
65        $current_db_version = get_option( self::$db_version_option_name, 0 );
66
67        if ( ! ( self::$db_version > $current_db_version ) ) {
68            return;
69        }
70
71        global $wpdb;
72
73        $table_name      = $wpdb->prefix . 'autologin_urls';
74        $charset_collate = $wpdb->get_charset_collate();
75
76        $sql = "CREATE TABLE {$table_name} (
77          expires_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
78          hash varchar(64) NOT NULL,
79          userhash varchar(64) NOT NULL, 
80          PRIMARY KEY  (hash),
81          KEY expires_at (expires_at)
82        ) {$charset_collate};";
83
84        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
85        $result = dbDelta( $sql );
86
87        update_option( self::$db_version_option_name, self::$db_version );
88
89        $this->logger->info( 'Updated database to version ' . self::$db_version );
90    }
91
92    /**
93     * Save an autologin code for a user so it can be checked in future, before the expires_in time.
94     *
95     * @param int    $user_id The user id the code is being saved for.
96     * @param string $code The autologin code being used in the user's URL.
97     * @param int    $expires_in Number of seconds the code is valid for.
98     *
99     * @throws Exception DateTime exception.
100     * @throws Exception For `$wpdb->last_error`.
101     */
102    public function save( int $user_id, string $code, int $expires_in ): void {
103
104        $key = hash( 'sha256', $code );
105
106        // Concatenate $user_id and $password so the database cannot be searched by username.
107        $value = hash( 'sha256', $user_id . $code );
108
109        global $wpdb;
110
111        $datetime = new DateTime( 'now', new DateTimeZone( 'UTC' ) );
112        $datetime->add( new DateInterval( "PT{$expires_in}S" ) );
113        $expires_at = $datetime->format( 'Y-m-d H:i:s' );
114
115        $result = $wpdb->insert(
116            $wpdb->prefix . 'autologin_urls',
117            array(
118                'expires_at' => $expires_at,
119                'hash'       => $key,
120                'userhash'   => $value,
121            )
122        );
123
124        if ( ! empty( $wpdb->last_error ) ) {
125            $this->logger->error( $wpdb->last_error );
126            throw new Exception( $wpdb->last_error );
127        }
128
129        if ( false === $result ) {
130            $this->logger->error(
131                'Error saving autologin code for wp_user:' . $user_id,
132                array(
133                    'user_id'    => $user_id,
134                    'code'       => $code,
135                    'expires_in' => $expires_in,
136                    'expires_at' => $expires_at,
137                    'hash'       => $key,
138                    'userhash'   => $value,
139                )
140            );
141            return;
142        }
143
144        $this->logger->debug(
145            'Saved autologin code for wp_user:' . $user_id,
146            array(
147                'user_id' => $user_id,
148                'expires' => $expires_at,
149            )
150        );
151    }
152
153    /**
154     * Retrieve the value stored for the given autologin code, if it has not expired.
155     *
156     * @param string $code The autologin code in the user's URL.
157     * @param bool   $delete Delete the code after fetching it. I.e. this is a single use code.
158     *
159     * @return string|null
160     * @throws Exception DateTime exception.
161     * @throws Exception For `$wpdb->last_error`.
162     */
163    public function get_value_for_code( string $code, bool $delete = true ): ?string {
164
165        global $wpdb;
166
167        $key = hash( 'sha256', $code );
168
169        /**
170         * We've no interest in caching, rather, for security, we're deleting the entry as soon as it's found.
171         * phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
172         */
173        $result = $wpdb->get_row(
174            $wpdb->prepare( 'SELECT expires_at, userhash FROM ' . $wpdb->prefix . 'autologin_urls WHERE hash = %s', $key )
175        );
176
177        if ( ! empty( $wpdb->last_error ) ) {
178            $this->logger->error( $wpdb->last_error );
179            throw new Exception( $wpdb->last_error );
180        }
181
182        if ( is_null( $result ) ) {
183            $this->logger->debug( 'Code not found.' );
184            return null;
185        }
186
187        // Delete the code so it can only be used once (whether valid or not).
188        if ( $delete ) {
189            $wpdb->delete(
190                $wpdb->prefix . 'autologin_urls',
191                array( 'hash' => $key )
192            );
193        }
194
195        $expires_at = new DateTimeImmutable( $result->expires_at, new DateTimeZone( 'UTC' ) );
196
197        $now = new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
198
199        if ( $now > $expires_at ) {
200            $this->logger->debug(
201                'Valid code but already expired',
202                array(
203                    'code'       => $code,
204                    'expires_at' => $expires_at,
205                    'hash'       => $key,
206                )
207            );
208            return null;
209        }
210
211        return $result->userhash;
212    }
213
214    /**
215     * Delete codes that are no longer valid.
216     *
217     * @param DateTimeInterface $before The date from which to purge old codes.
218     *
219     * @return array{deleted_count:int|null}
220     * @throws Exception For `$wpdb->last_error`.
221     */
222    public function delete_expired_codes( DateTimeInterface $before ): array {
223
224        // get current datetime in mysql format.
225        $mysql_formatted_date = $before->format( 'Y-m-d H:i:s' );
226
227        global $wpdb;
228        $result = $wpdb->query( $wpdb->prepare( 'DELETE FROM ' . $wpdb->prefix . 'autologin_urls WHERE expires_at < %s', $mysql_formatted_date ) );
229
230        if ( ! empty( $wpdb->last_error ) ) {
231            $this->logger->error( $wpdb->last_error );
232            throw new Exception( $wpdb->last_error );
233        }
234
235        // I think this is the number of entries deleted.
236        $this->logger->info( 'Delete expired codes wpdb result: ' . $result, array( 'result' => $result ) );
237
238        return array( 'deleted_count' => intval( $result ) );
239    }
240}