Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor Access Tokens #2651

Merged
merged 17 commits into from
Mar 4, 2021
Merged

Refactor Access Tokens #2651

merged 17 commits into from
Mar 4, 2021

Conversation

clarkwinkelmann
Copy link
Member

@clarkwinkelmann clarkwinkelmann commented Mar 2, 2021

This pull request contains all the breaking changes that are necessary to properly implement the security roadmap as well as for the new session management screen in #2074

Since the important breaking changes aren't really tied to the management screen, I extracted those to a separate PR so we can merge them for the next release.

The following changed:

  • Make session token-based instead of user-based. This means clearing tokens now automatically ends sessions.
  • Clear current session access tokens on logout. This means that even if the access token was copied/stolen by a third-party (for example, malware reading the remember cookie), it becomes invalid as soon as you click logout. Previously those tokens would leave until their natural expiration, and that's without taking into account the garbage collector security issue.
  • Introduce increment ID so we can show tokens to moderators in the future without exposing secrets. This is necessary as we need a unique identifier that doesn't automatically grant you access to the user account. This will allow moderators to inspect or terminate user sessions with the future session management UI or an extension. Potential breaking change with no fallback: this means AccessToken::find() now takes the ID as parameter, no longer the token! I'm not sure if any extension might access a token like this. Same goes for findOrFail().
  • Switch to type classes to manage the different token types. This new implementation fixes Garbage Collector isn't deleting expired access tokens #2075 and also provides the basis for a future access token extender to allow extensions to register new types with custom expiration logic.
  • Drop ability to customize lifetime per-token. This is necessary for the feature above. For now lifetime parameter was completely removed from POST /api/token. I'm hoping we can introduce the type parameter to that REST endpoint in a future update, once we decide on which types should be accessible to users and which should be only created by the system/extensions.
  • Introduce whereValid query scope to only get valid tokens along with shortcut findValid($token). This is necessary to ensure security even on low activity forums where the garbage collector might not run very frequently or if a bug causes the garbage collector to not trigger.
  • Add developer access keys that don't expire. These must be created from the database for now. This is mostly a placeholder for now that allows integration testing the new system. Those will be available to users in the future session management UI, possibly protected by a permission. Internal name is currently developer. But they could also be named something like infinite ?
  • Add title in preparation for the developer token UI. The value is not used for now, but I think it's best if it's added together with the other migrations.
  • Add IP and user agent logging. It's not visible anywhere in the UI, but it's valuable information to start logging, and it will be displayed in the future session management UI.

Breaking change guide

This is a summary of what we will need to include in the update guide

Most of the deprecated/removed code will trigger E_USER_DEPRECATED PHP errors in Flarum beta 16. Make sure to enable those PHP errors in your PHP config while testing your code with Flarum beta 16.

Extension API changes

The signature to various method related to authentication have been changed to take $token as parameter instead of $userId. Other changes are the result of the move from $lifetime to $type

  • Flarum\Http\AccessToken::generate($userId) no longer accepts $lifetime as a second parameter. Parameter has been kept for backward compatibility but has no effect. It will be removed in beta 17.
  • Flarum\Http\RememberAccessToken::generate($userId) should be used to create remember access tokens.
  • Flarum\Http\DeveloperAccessToken::generate($userId) should be used to create developer access tokens (don't expire).
  • Flarum\Http\SessionAccessToken::generate() can be used as an alias to Flarum\Http\AccessToken::generate(). We might deprecate AccessToken::generate() in the future.
  • Flarum\Http\Rememberer::remember(ResponseInterface $response, AccessToken $token): passing an AccessToken has been deprecated. Pass an instance of RememberAccessToken instead. As a temporary compatibility layer, passing any other type of token will convert it into a remember token. In beta 17 the method signature will change to accept only RememberAccessToken.
  • Flarum\Http\Rememberer::rememberUser() has been deprecated. Instead you should create/retrieve a token manually with RememberAccessToken::generate() and pass it to Rememberer::remember()
  • Flarum\Http\SessionAuthenticator::logIn(Session $session, $userId) second parameter has been deprecated and is replaced with $token. Backward compatibility is kept. In beta 17, the second parameter method signature will change to AccessToken $token.
  • AccessToken::generate() now saves the model to the database before returning it.
  • AccessToken::find($id) or ::findOrFail($id) can no longer be used to find a token, because the primary key was changed from token to id. Instead you can use AccessToken::findValid($tokenString)
  • It's recommended to use AccessToken::findValid($tokenString): AccessToken or AccessToken::whereValid(): Illuminate\Database\Eloquent\Builder to find a token. This will automatically scope the request to only return valid tokens. On forums with low activity this increases the security since the automatic deletion of outdated tokens only happens every 50 requests on average.

Symfony session changes

If you are directly accessing or manipulating the Symfony session object, the following breaking changes have been made without any backward compatibility:

  • user_id attribute is no longer used. It's no longer set by Flarum, and setting it won't have any effect.
  • access_token has been added. It's a string that maps to the token column of the access_tokens database table.

To retrieve the current user from inside a Flarum extension, the ideal solution which was already present in Flarum is to use $request->getAttribute('actor') which returns a User instance (which might be Guest)

To retrieve the token instance from Flarum, you can use Flarum\Http\AccessToken::findValid($tokenString)

To retrieve the user data from a non-Flarum application, you'll need to make an additional database request to retrieve the token. The user ID is present as user_id on the access_tokens table.

Token creation changes

The lifetime property of access tokens has been removed. Tokens are now either session tokens with 1h lifetime after last activity, or session_remember tokens with 5 years lifetime after last activity.

The remember parameter that was previously available on the POST /login endpoint has been made available on POST /api/token. It doesn't return the remember cookie itself, but the token returned can be used as a remember cookie.

The lifetime parameter of POST /api/token has been deprecated and will be removed in Flarum beta 17. Partial backward compatibility has been provided where a lifetime value longer than 3600 seconds is interpreted like remember=1. Values lower than 3600 seconds result in a normal non-remember token.

New developer tokens that don't expire have been introduced, however they cannot be currently created through the REST API. Developers can create developer tokens from an extension using Flarum\Http\DeveloperAccessToken::generate($userId).

If you manually created tokens in the database from outside Flarum, the type column is now required and must contain session, session_remember or developer. Tokens of unrecognized type cannot be used to authenticate, but won't be deleted by the garbage collector either. In a future version extensions will be able to register custom access token types.

Token usage changes

A security issue in Flarum (#2075) previously caused all tokens to never expire. This had limited security impact due to tokens being long unique characters. However custom integrations that saved a token in an external database for later use might find the tokens no longer working if they were not used recently.

If you use short-lived access tokens for any purpose, take note of the expiration time of 1h. The expiration is based on the time of last usage, so it will remain valid as long as it continues to be used.

Due to the large amount of expired tokens accumulated in the database and the fact most tokens weren't ever used more than once during the login process, we have made the choice to delete all access tokens a lifetime of 3600 seconds as part of the migration, All remaining tokens have been converted to session_remember tokens.

Remember cookie

The remember cookie still works like before, but a few changes have been made that could break unusual implementations.

Now only access tokens created with remember option can be used as remember cookie. Any other type of token will be ignored. This means if you create a token with POST /api/token and then place it in the cookie manually, make sure you set remember=1 when creating the token.

Web session expiration

In previous versions of Flarum, a session could be kept alive forever until the Symfony session files were deleted from disk.

Now sessions are linked to access tokens. A token being deleted or expiring will automatically end the linked web session.

A token linked to a web session will now be automatically deleted from the database when the user clicks logout. This prevents any stolen token from being re-used, but it could break custom integration that previously used a single access token in both a web session and something else.

Confirmed

  • Backend changes: tests are green (run composer test).

Required changes:

  • Related documentation PR: (Remove if irrelevant)

->cursor();

// Insert initial value for our new primary key
foreach ($tokens as $i => $token) {
Copy link
Member Author

@clarkwinkelmann clarkwinkelmann Mar 2, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have to say, I'm a bit worried about this one.

Because of the garbage collector bug, some forums will have accumulated tens/hundreds of thousands/millions? if not more access tokens.

Should I include the equivalent of the garbage collector in there to clear all the expired tokens before the migration?

My thought was that the migration would be simpler by not going the garbage deletion, since it will happen when they start browsing the updated forum anyway.

I'm not sure if anyone has a large forum backup we could try this on.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if anyone has a large forum backup we could try this on.

My forum has 48k access tokens and I also have a local installation based on a recent backup, if necessary

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the info!

I have modified the migrations, the new version should actually delete most of the tokens before attempting to update those that are remaining (only remember tokens will be kept).

Do you know how to run the dev-master of Flarum? That would be a great test once we merge.

I don't want to ask for a copy of the table since it's obviously sensitive data.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know how to run the dev-master of Flarum? That would be a great test once we merge.

To be honest, last time I tried I didn't succeed, because of some Composer constraints issues IIRC. But I can try again. You know where to find me :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@matteocontrini actually, if you could send me a database dump of just the access_tokens table, but without the token column, that would be great! I think some tools like phpMyAdmin allow selecting columns during export.

I'm pretty sure I have a discussion with you on Discord, but I can't recall your username 🙈

@tankerkiller125 tankerkiller125 self-requested a review March 2, 2021 13:20
@clarkwinkelmann
Copy link
Member Author

I have pushed an update. Since most tokens in the table actually won't be used anymore, even if they are still technically valid, I might as well delete them.

So now the following happens on update:

  • All normal tokens (3600 seconds lifetime) are deleted. Those are never used or memorized by Flarum itself after login anyway (unlike future sessions), so they would just get deleted by the garbage collector at some point
  • All remaining tokens are converted into remember tokens. This differs from the first version of the PR where custom lifetimes would be converted back to normal tokens and would likely expire very quickly. With this version, if custom lifetimes were used in custom integrations, they should continue working

I have also fixed the remember from cookie code, and added tests for it.

When running the tests locally I get a bunch of the following error from other tests:

24) Flarum\Tests\integration\extenders\ApiSerializerTest::custom_relationship_exists_if_added
Doctrine\DBAL\Driver\PDO\Exception: SQLSTATE[HY000] [1040] Too many connections

/home/clark/Projects/flarum-master/workbench/core/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/PDO/Exception.php:18
/home/clark/Projects/flarum-master/workbench/core/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/PDOConnection.php:42
/home/clark/Projects/flarum-master/workbench/core/vendor/illuminate/database/Connectors/Connector.php:67
/home/clark/Projects/flarum-master/workbench/core/vendor/illuminate/database/Connectors/Connector.php:46
/home/clark/Projects/flarum-master/workbench/core/vendor/illuminate/database/Connectors/MySqlConnector.php:24
/home/clark/Projects/flarum-master/workbench/core/vendor/illuminate/database/Connectors/ConnectionFactory.php:182
/home/clark/Projects/flarum-master/workbench/core/vendor/illuminate/database/Connection.php:924
/home/clark/Projects/flarum-master/workbench/core/vendor/illuminate/database/Connection.php:906
/home/clark/Projects/flarum-master/workbench/core/vendor/illuminate/database/Connection.php:892
/home/clark/Projects/flarum-master/workbench/core/src/Database/Migrator.php:67
/home/clark/Projects/flarum-master/workbench/core/vendor/illuminate/container/Container.php:843
/home/clark/Projects/flarum-master/workbench/core/vendor/illuminate/container/Container.php:681
/home/clark/Projects/flarum-master/workbench/core/vendor/illuminate/container/Container.php:629
/home/clark/Projects/flarum-master/workbench/core/vendor/illuminate/container/Container.php:945
/home/clark/Projects/flarum-master/workbench/core/vendor/illuminate/container/Container.php:873
/home/clark/Projects/flarum-master/workbench/core/vendor/illuminate/container/Container.php:834
/home/clark/Projects/flarum-master/workbench/core/vendor/illuminate/container/Container.php:261
/home/clark/Projects/flarum-master/workbench/core/vendor/illuminate/container/Container.php:799
/home/clark/Projects/flarum-master/workbench/core/vendor/illuminate/container/Container.php:681
/home/clark/Projects/flarum-master/workbench/core/vendor/illuminate/container/Container.php:629
/home/clark/Projects/flarum-master/workbench/core/src/Extension/ExtensionServiceProvider.php:30
/home/clark/Projects/flarum-master/workbench/core/src/Foundation/Application.php:322
/home/clark/Projects/flarum-master/workbench/core/src/Foundation/Application.php:263
/home/clark/Projects/flarum-master/workbench/core/src/Foundation/InstalledSite.php:150
/home/clark/Projects/flarum-master/workbench/core/src/Foundation/InstalledSite.php:82
/home/clark/Projects/flarum-master/workbench/core/tests/integration/TestCase.php:59
/home/clark/Projects/flarum-master/workbench/core/tests/integration/TestCase.php:98
/home/clark/Projects/flarum-master/workbench/core/tests/integration/BuildsHttpRequests.php:43
/home/clark/Projects/flarum-master/workbench/core/tests/integration/TestCase.php:190
/home/clark/Projects/flarum-master/workbench/core/tests/integration/extenders/ApiSerializerTest.php:388

I also see the tests stopped running in our CI so we'll probably need to wait on that to recover so I can see if the error is just for me or not.


// Insert initial value for our new primary key
// This migration runs after the "add type" migration, this ensures we have a minimal number of tokens remaining
foreach ($tokens as $i => $token) {
Copy link
Sponsor Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How long did this take in your environment? I don't know if there's a way to set all these with one query, but even with clearing out the vast majority of tokens, this could be thousands of queries on larger environments

* This value will be used in the validity and expiration checks.
* @var int Lifetime in seconds. Zero means it will never expire.
*/
protected static $lifetime = 0;
Copy link
Sponsor Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the default token type have a non-infinite lifetime?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's up for debate. I had no particular preference.

Copy link
Sponsor Member

@askvortsov1 askvortsov1 Mar 4, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make the base type an abstract class so it can't be instantiated? Then it wouldn't matter. This might cause issues with the retrieval logic, I'm not sure if Laravel would automatically convert it to the proper type. It'd also require that if extensions want to introduce new types, they do so properly.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just tested, we cannot make it abstract. It doesn't work with Eloquent.

Again, we have the same situation with Post where the base post isn't abstract.

I guess the question here is whether we prefer 0 as the default, or 3600 ? I don't think any other value makes sense.

Copy link
Sponsor Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we set it to null (or not have it at all) in the base class?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally this seems even more confusing. Despite the lack of parameter type-hinting (which I see came in PHP 7.4 or 8?), having a number there makes it obvious what you should set.

If it's null and someone doesn't override it, then I don't think there will be any error, but it also won't behave differently from 0 since it's used in multiple math operations.

src/Http/AccessToken.php Outdated Show resolved Hide resolved
$this->setAccessTokenTypes();
}

protected function setAccessTokenTypes()
Copy link
Sponsor Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make this a container binding in register; the difference for this PR wouldn't be that significant, and we'd need this anyway later for the extender.

Copy link
Sponsor Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type array I mean; the static assignment should be where it is now. I'd even argue to put it directly in boot.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a copy-paste of how post types work. I don't mind changing it, but then it would be logical to also change it for post types.

src/Http/AccessToken.php Outdated Show resolved Hide resolved
src/Http/AccessToken.php Outdated Show resolved Hide resolved
src/Http/SessionAccessToken.php Outdated Show resolved Hide resolved
src/Http/SessionAuthenticator.php Show resolved Hide resolved
clarkwinkelmann and others added 5 commits March 3, 2021 09:15
Make session token-based instead of user-based
Clear current session access tokens on logout
Introduce increment ID so we can show tokens to moderators in the future without exposing secrets
Switch to type classes to manage the different token types. New implementation fixes #2075
Drop ability to customize lifetime per-token
Add developer access keys that don't expire. These must be created from the database for now
Add title in preparation for the developer token UI
Add IP and user agent logging
[ci skip] [skip ci]
Since they were not re-used after login anyway
clarkwinkelmann and others added 5 commits March 4, 2021 14:03
Co-authored-by: Alexander Skvortsov <38059171+askvortsov1@users.noreply.github.com>
Move remember parameter to /api/token and add migration layer for lifetime parameter
[ci skip] [skip ci]
Co-authored-by: Alexander Skvortsov <38059171+askvortsov1@users.noreply.github.com>
@clarkwinkelmann
Copy link
Member Author

New changes:

  • Now ::generate() saves the tokens it creates. I think this is more logical and prevents anyone "generating" a token then forgetting to save it. There aren't many use cases where you would add/modify values outside of ::generate() anyway
  • Added remember parameter to /api/token
  • Added semi-backward compatibility of lifetime parameter to /api/token
  • Added more deprecation messages in the code. Almost every parameter change is covered by one, so anyone developing a custom integration just needs to enable deprecation errors in PHP to see them
  • Made some changes to the installer so that a token can be created as part of the installation for the auto-login of the admin

Copy link
Sponsor Member

@askvortsov1 askvortsov1 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this is really, really close. I like the new API a lot!

src/Http/SessionAuthenticator.php Show resolved Hide resolved
src/Http/SessionAuthenticator.php Outdated Show resolved Hide resolved
src/Http/AccessToken.php Outdated Show resolved Hide resolved
* This value will be used in the validity and expiration checks.
* @var int Lifetime in seconds. Zero means it will never expire.
*/
protected static $lifetime = 0;
Copy link
Sponsor Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we set it to null (or not have it at all) in the base class?

@askvortsov1 askvortsov1 requested a review from SychO9 March 4, 2021 16:36
Copy link
Sponsor Member

@askvortsov1 askvortsov1 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving, as my remaining unanswered comments are non crucial stylistic nitpicks. This is excellent!

@clarkwinkelmann
Copy link
Member Author

I pushed some more commits and deprecated ::generate() on the base class as well for now.

There's still the question of migration performance which I haven't really been able to test.

@askvortsov1
Copy link
Sponsor Member

Also, looks like tests are failing: there's one instance where it gets the order wrong. I think there should be an order-independent comparison option?

@clarkwinkelmann
Copy link
Member Author

I'm really not sure how I would do an order-independent check. I opted to sort them before the comparison.

It seems to be a MariaDB thing? In MySQL they are always returned in the order they were inserted if no order is specified.

I'm going to do a performance test on migrations shortly. We should hold up merging until then.

@clarkwinkelmann
Copy link
Member Author

clarkwinkelmann commented Mar 4, 2021

Performance is indeed not ideal...

Testing with @matteocontrini data, we start with 50k tokens, then are left with 19k remember tokens.

38% remember tokens seems like a lot, but that includes all social logins so it might make sense.

On the command line with a local MySQL server it took 40 seconds.

time php flarum migrate
Migrating Flarum...
Migrated: 2021_03_02_040000_change_access_tokens_add_type
Migrated: 2021_03_02_040500_change_access_tokens_add_id
Migrated: 2021_03_02_041000_change_access_tokens_add_title_ip_agent
Migrating extension: flarum-flags
Nothing to migrate.
Migrating extension: flarum-tags
Nothing to migrate.
Migrating extension: flarum-suspend
Nothing to migrate.
Migrating extension: flarum-sticky
Nothing to migrate.
Migrating extension: flarum-mentions
Nothing to migrate.
Migrating extension: flarum-lock
Nothing to migrate.
Migrating extension: flarum-approval
Nothing to migrate.
Publishing assets...
DONE.

real	0m39.973s
user	0m4.078s
sys	0m1.108s

I'm not sure exactly how many database requests were made in the end, but MySQL workbench showed an average of 500 requests per second during that migration.

image


I have also checked the ratio on the Discuss database. There's around 25k tokens and we average 39% remember tokens. So that's very similar.

That's more remember tokens than I expected to remain on average. It's sad because most of those tokens are probably long forgotten by any client but we have no way of knowing. That kind of relates to the new issue I created to discuss remember me lifetime.


I will check if I find any better solution. Either something fully SQL based. Or moving to update batches, which will already save a lot of requests.

@askvortsov1
Copy link
Sponsor Member

Is it necessary to actually fill in all the IDs? If the column is added as a primary key, StackOverflow seems to suggest that it might be autopopulated: https://stackoverflow.com/questions/14753321/add-auto-increment-id-to-existing-table/17346658

@clarkwinkelmann
Copy link
Member Author

Oh yeah I should have tried that earlier. I started with it but must have been confused by errors which were in fact due to Laravel trying to run the instructions in the wrong order. That's why there are multiple table statement in the migration.

Now that I've moved things around a bit it works without any code to set the values. Let's hope it works on MariaDB as well 😅 I don't have an easy access to MadiaDB to test.

It's down to around 4 seconds with the current solution, and I didn't see any spike in MySQL resources.

time php flarum migrate
Migrating Flarum...
Migrated: 2021_03_02_040000_change_access_tokens_add_type
Migrated: 2021_03_02_040500_change_access_tokens_add_id
Migrated: 2021_03_02_041000_change_access_tokens_add_title_ip_agent
Migrating extension: flarum-flags
Nothing to migrate.
Migrating extension: flarum-tags
Nothing to migrate.
Migrating extension: flarum-suspend
Nothing to migrate.
Migrating extension: flarum-sticky
Nothing to migrate.
Migrating extension: flarum-mentions
Nothing to migrate.
Migrating extension: flarum-lock
Nothing to migrate.
Migrating extension: flarum-approval
Nothing to migrate.
Publishing assets...
DONE.

real	0m4.378s
user	0m0.105s
sys	0m0.058s

@clarkwinkelmann
Copy link
Member Author

So... I guess we're ready for merge now?

I can also confirm I tested running the installation wizard, and logging in with the FoF Oauth extension, which all worked perfectly. I also tried each of registration, password change and email change which all correctly log the user afterwards.

I'll just need to move my text from the first discussion into a PR to the documentation. Not sure of the process for that since there's no beta 16 update page yet.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Garbage Collector isn't deleting expired access tokens
4 participants