adonisjs / auth

Official Authentication package for AdonisJS
https://docs.adonisjs.com/guides/auth/introduction
MIT License
191 stars 65 forks source link

Add the ability to revoke all tokens for a specific User ID #174

Closed drewrawitz closed 2 years ago

drewrawitz commented 3 years ago

Why this feature is required (specific use-cases will be appreciated)?

I would love to include a "Sign Out Everywhere" button on my application which would revoke ALL tokens assigned to a specific User ID. Right now, I can hit my/login endpoint multiple times and get a bunch of different tokens. I'm using Redis as my token driver.

I'm not experienced enough in Redis to know how to do this efficiently. It seems like it would be pretty easy if the user ID was exposed in the token name, but since the user ID is in the actual contents, I don't know the best way to grab all tokens for a supplied user ID.

I'm thinking something like this:

/**
  * Revoke All User Sessions
  */
public async revoke({ auth }: HttpContextContract) {
  const userId = auth.use('api').user?.id

  if (userId) {
    await auth.use('api').revokeAll(userId)
  }

  return {
    revoked: true,
  }
}

Of course, revokeAll currently doesn't exist. Is there anything currently built out that I can utilize? Any sort of guidance would be much appreciated.

Thanks!

thetutlage commented 3 years ago

Hello @drewrawitz 👋

I think what you need is first a tracking system to track a list of devices a user is logged in from and maybe attach some metadata like the ip, device and location to it.

You can achieve this by self tracking the tokens generated for a given user.

Also, this is not possible to do with the current auth system because of the way tokens are stored inside Redis, there is no way to look them up by a user id. Personally, I am not too keen to change the storage mechanism, as the logic of tracking user login devices can be managed by the application separately.

drewrawitz commented 3 years ago

Hi @thetutlage!, thanks for your response!

I think that all makes sense! So even though my sessions are all managed in Redis, I can still create a sessions table in Postgres that would only be called on login/logout/revoke all, which would store the relationship between userId and tokenId. The tokenId wouldn't be the API token, but the name of the key in Redis, e.g. api:ckpghy5kh0001mgps0lx6hkz2.

// Login 
const session = await auth.use('api').login(user, {
  expiresIn: Config.get('expirations.auth.apiTokenSessionLength'),
})

const { token } = session;

And after digging through the source code, it looks like I can grab the cuid that's used as the token name in Redis by doing something like:

import { base64 } from '@poppinss/utils/build/helpers'

const parts = token.split('.')
const sessionId = base64.urlDecode(parts[0], undefined, true)
const tokenId = `api:${sessionId}`

So now I can add a new row in the sessions table in Postgres that has the userId, the tokenId, and other metadata like you mentioned above (ip address, device). So when a user clicks "Sign out everywhere", I can grab all of the tokenId values for a specific user and simply delete them all in Redis which would immediately revoke access.

Does that all sound right?

stale[bot] commented 2 years ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

hlozancic commented 2 years ago

I'm also working on feature like this.

I agree with @thetutlage that this should be managed by the application separately, but currently it's very hard to achieve this just because we cannot simply get key that will be used to store session in redis.

It would be great if something like this would be possible:

For api driver:

const session = await auth.use('api').login(user)
const { sessionId } = session

For web driver:

const session = await auth.use('web').login(user)
const { sessionId } = session
hlozancic commented 2 years ago

I sorted out my implementation by using this nasty hack right before I send response that user successfully loggedIn in app.

// login user using web auth
await auth.use('web').login(user)

// our nasty hack to prevent async cuid regeneration that we can't get inside our controller
// https://github.com/adonisjs/session/blob/f2f98243f14ec6e538a5757c0c598b912c7fb8f3/src/Session/index.ts#L411
const oldSessionId = auth.ctx.session.sessionId
auth.ctx.session.regenerateSessionId = false
await auth.ctx.session.driver.destroy(oldSessionId)
const newSessionId = cuid()
auth.ctx.session.sessionId = newSessionId

// now we can use auth.ctx.session.sessionId to store it to database so we can find redisKey named by it to logout users on demand
await Sessions.create({ userId: user.id, redisKey: newSessionId })

// send ok...
response.ok('Successfully logged in!')
ammezie commented 1 year ago

@hlozancic how are you able to access the content from the auth object?