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

Limit concurrent user sessions #27655

Closed

Conversation

joanlopez
Copy link
Contributor

@joanlopez joanlopez commented Sep 18, 2020

What this PR does / why we need it:

It introduces an extensible way to limit the maximum number of concurrent sessions per user.

Special notes for your reviewer:

  • Still WIP since I'm looking for DELETE statement improvements, but completely open to feedback.
  • Manually tested for user & SAML auth mechanisms (cc/ @mjseaman).

@joanlopez joanlopez requested a review from a team as a code owner September 18, 2020 12:45
@joanlopez joanlopez self-assigned this Sep 18, 2020
@joanlopez joanlopez requested review from papagian and removed request for a team September 18, 2020 12:45
marefr
marefr previously requested changes Sep 21, 2020
Copy link
Member

@marefr marefr left a comment

Choose a reason for hiding this comment

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

See comments

)

func (s *UserAuthTokenService) isConcurrentSessionLimitEnabled() bool {
return s.Cfg.FeatureToggles[concurrentSessionLimitKey]
Copy link
Member

Choose a reason for hiding this comment

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

Why feature toggle and not a setting in [auth]?

Copy link
Contributor Author

@joanlopez joanlopez Sep 21, 2020

Choose a reason for hiding this comment

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

I'm not really sure about this. Maybe @mjseaman can give us with more details.
The issue definition specifies it as such, I suppose it's somehow related with customer requirements.

Copy link
Member

Choose a reason for hiding this comment

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

Feature toggles are only used for temporary features that eventually will be enabled/included per default. Usually used when developing new experimental features. Adding it as a separate setting is basically a toggle.

Copy link
Contributor

Choose a reason for hiding this comment

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

Agreed with @marefr, we don't need feature toggle in this case, this is not an experimental feature.

@@ -404,6 +410,35 @@ func (s *UserAuthTokenService) rotatedAfterParam() int64 {
return getTime().Add(-s.Cfg.LoginMaxInactiveLifetime).Unix()
}

const (
concurrentSessionLimit = 3
Copy link
Member

Choose a reason for hiding this comment

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

Would rather have this as a setting in [auth]. WDYT?

Copy link
Contributor Author

@joanlopez joanlopez Sep 21, 2020

Choose a reason for hiding this comment

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

Let's wait for Mitch to address this.

But yup, I agree that moving this constant into a configuration variable could be a good idea 👌

Copy link
Contributor

Choose a reason for hiding this comment

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

I think we shouldn't change default behavior when introducing new features. So I'd prefer to have two config options: one for enabling user session limit and second for setting limit, like:

[auth]
enable_user_session_limit = false
user_session_limit = 10

Or maybe even just one config option user_session_limit which is 0 by default and disables limit.

Copy link
Member

Choose a reason for hiding this comment

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

Based on earlier setting like login_cookie_name, login_maximum_inactive_lifetime_duration I would suggest login_maximum_concurrent_user_sessions that default to 0 (disabled).

}

return s.SQLStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
result, err := dbSession.Exec("DELETE FROM user_auth_token WHERE id NOT IN (SELECT id FROM user_auth_token WHERE seen_at <> 0 ORDER BY seen_at DESC LIMIT ?) AND seen_at <> 0;", concurrentSessionLimit-1)
Copy link
Member

Choose a reason for hiding this comment

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

Should filter by user id here. Couldn't this query be written with only delete clause/without in(...), maybe not possible with order by then?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Completely agree, I already figured out the filter by user id missing but I was waiting for feedback to do it. Anyway, thanks for pointing it, it helped me to do not forget it.

Regarding the query, I cannot see a simpler way to do it. Probably, the subquery might be replaced by a JOIN clause but not really sure if in a simpler / clearer way. Feel free to write down an example if you have something in mind, I'd really appreciate it.

Thanks!

Copy link
Contributor Author

@joanlopez joanlopez Sep 21, 2020

Choose a reason for hiding this comment

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

Related with the query ☝️ I've already been exploring the approach using something like DELETE ... LIMIT ... OFFSET which might be enough for our case and also seem to be supported by SQLite (also by PosgreSQL & MySQL I guess), but it didn't seem to be supported by the Go client.

@@ -404,6 +410,35 @@ func (s *UserAuthTokenService) rotatedAfterParam() int64 {
return getTime().Add(-s.Cfg.LoginMaxInactiveLifetime).Unix()
}

const (
concurrentSessionLimit = 3
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we shouldn't change default behavior when introducing new features. So I'd prefer to have two config options: one for enabling user session limit and second for setting limit, like:

[auth]
enable_user_session_limit = false
user_session_limit = 10

Or maybe even just one config option user_session_limit which is 0 by default and disables limit.

)

func (s *UserAuthTokenService) isConcurrentSessionLimitEnabled() bool {
return s.Cfg.FeatureToggles[concurrentSessionLimitKey]
Copy link
Contributor

Choose a reason for hiding this comment

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

Agreed with @marefr, we don't need feature toggle in this case, this is not an experimental feature.

}

func (s *UserAuthTokenService) revokeOldUserTokens(ctx context.Context, userId int64) error {
if !s.isConcurrentSessionLimitEnabled() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Isn't better to check this in CreateToken() function? In that case it looks more straightforward and clear I think.

@grafana grafana deleted a comment from mjseaman Sep 21, 2020
@joanlopez
Copy link
Contributor Author

joanlopez commented Sep 23, 2020

After discussing it with @xlson, the initial approach has been changed.

So, the concurrent user session limits are finally implemented as an extensible mechanism that has not limits by default but it can be overwritten when needed.

@sakjur sakjur self-requested a review September 25, 2020 07:46
Copy link
Contributor

@sakjur sakjur left a comment

Choose a reason for hiding this comment

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

Logic looks good, but I think we're missing an index for user_id on the user_auth_token table.

pkg/services/auth/auth_token.go Show resolved Hide resolved
(
SELECT id
FROM user_auth_token
WHERE user_id = ?
Copy link
Contributor

Choose a reason for hiding this comment

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

We don't have an index for user_id in

userAuthTokenV1 := Table{
Name: "user_auth_token",
Columns: []*Column{
{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
{Name: "user_id", Type: DB_BigInt, Nullable: false},
{Name: "auth_token", Type: DB_NVarchar, Length: 100, Nullable: false},
{Name: "prev_auth_token", Type: DB_NVarchar, Length: 100, Nullable: false},
{Name: "user_agent", Type: DB_NVarchar, Length: 255, Nullable: false},
{Name: "client_ip", Type: DB_NVarchar, Length: 255, Nullable: false},
{Name: "auth_token_seen", Type: DB_Bool, Nullable: false},
{Name: "seen_at", Type: DB_Int, Nullable: true},
{Name: "rotated_at", Type: DB_Int, Nullable: false},
{Name: "created_at", Type: DB_Int, Nullable: false},
{Name: "updated_at", Type: DB_Int, Nullable: false},
},
Indices: []*Index{
{Cols: []string{"auth_token"}, Type: UniqueIndex},
{Cols: []string{"prev_auth_token"}, Type: UniqueIndex},
},
}

@marefr wdyt, should we add an index here or is there a better way to go about this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Interesting one! Let's wait for @marefr point of view, but it might have sense since filtering by user_id will still be required, I guess 🤔

Thanks!

@sakjur sakjur dismissed stale reviews from marefr and alexanderzobnin September 25, 2020 08:40

PR significantly changed

Comment on lines +427 to +431
ORDER BY seen_at DESC
LIMIT ?
)
AND user_id = ?
AND seen_at != 0;`
Copy link
Member

Choose a reason for hiding this comment

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

Not sure about the logic for seen_at. Maybe moving this above inserting a new token would simplify things? Doing that could do a query to receive all auth tokens for user and then iterate and find the oldest (based on created_at or rotated_at) above maxConcurrentSessions-1 token and finally delete from user_auth_token where id in(<list of id's>).

Doing this you could potentially move all of this logic to enterprise by wrapping and overriding the UserTokenService in similar manner as done with the data source cache service using registry.RegisterOverride (in enterprise). @joanlopez @xlson thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yup! I also thought about it, but I did it before just to avoid removing an existing session with the possibility of having issues while creating the new one. But this might be something insignificant for this case. So, if it helps us to simplify the code, I agree we can do it the other way round.

Copy link
Contributor Author

@joanlopez joanlopez Sep 29, 2020

Choose a reason for hiding this comment

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

Doing this you could potentially move all of this logic to enterprise by wrapping and overriding the UserTokenService ª in similar manner as done with the data source cache service using registry.RegisterOverride (in enterprise). @joanlopez @xlson thoughts?

This approach produces inject errors because the models.UserTokenService interface is injected into some other services and once both implementations are present it does not know which one should be injected.

Any idea? 🤔 cc/ @marefr @xlson @sakjur

Thank you so much!!! 🙏

@joanlopez
Copy link
Contributor Author

I proceed to close it since it's outdated and no longer valid.

The major outcome from this discussion was #27908.

Thanks guys!!! 👍

@joanlopez joanlopez closed this Oct 6, 2020
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.

None yet

4 participants