]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Add ability to customize token lifetime
authorChocobozzz <me@florianbigard.com>
Thu, 29 Dec 2022 13:18:07 +0000 (14:18 +0100)
committerChocobozzz <me@florianbigard.com>
Wed, 4 Jan 2023 10:41:29 +0000 (11:41 +0100)
config/default.yaml
config/production.yaml.example
server/initializers/checker-before-init.ts
server/initializers/config.ts
server/initializers/constants.ts
server/lib/auth/oauth.ts
server/lib/auth/tokens-cache.ts
server/tests/api/users/index.ts
server/tests/api/users/oauth.ts [new file with mode: 0644]
server/tests/api/users/users.ts
shared/server-commands/requests/requests.ts

index 1b7c3314da8a618f74d2e998f034d1496de2c867..d4977d0037e425c92072a94840b8e022c9dab186 100644 (file)
@@ -37,6 +37,11 @@ rates_limit:
     window: 10 minutes
     max: 10
 
+oauth2:
+  token_lifetime:
+    access_token: '1 day'
+    refresh_token: '2 weeks'
+
 # Proxies to trust to get real client IP
 # If you run PeerTube just behind a local proxy (nginx), keep 'loopback'
 # If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet)
index da067b3b55828ba96369883a0ab1e6d731fc89d8..17dc6839bbc28822ad883601b3e731efbc3e23ef 100644 (file)
@@ -35,6 +35,11 @@ rates_limit:
     window: 10 minutes
     max: 10
 
+oauth2:
+  token_lifetime:
+    access_token: '1 day'
+    refresh_token: '2 weeks'
+
 # Proxies to trust to get real client IP
 # If you run PeerTube just behind a local proxy (nginx), keep 'loopback'
 # If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet)
index 39713a26678692035b653239dddae1627dfbfd74..57852241c2fa6927147f838bc964eeef35c28d51 100644 (file)
@@ -13,6 +13,7 @@ function checkMissedConfig () {
     'webserver.https', 'webserver.hostname', 'webserver.port',
     'secrets.peertube',
     'trust_proxy',
+    'oauth2.token_lifetime.access_token', 'oauth2.token_lifetime.refresh_token',
     'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max',
     'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address',
     'email.body.signature', 'email.subject.prefix',
index c2f8b19fd60278cf579e54f5680933f33348e5d2..28aaf36a974dd2432c775e823f6ac2f731a22ded 100644 (file)
@@ -149,6 +149,12 @@ const CONFIG = {
     HOSTNAME: config.get<string>('webserver.hostname'),
     PORT: config.get<number>('webserver.port')
   },
+  OAUTH2: {
+    TOKEN_LIFETIME: {
+      ACCESS_TOKEN: parseDurationToMs(config.get<string>('oauth2.token_lifetime.access_token')),
+      REFRESH_TOKEN: parseDurationToMs(config.get<string>('oauth2.token_lifetime.refresh_token'))
+    }
+  },
   RATES_LIMIT: {
     API: {
       WINDOW_MS: parseDurationToMs(config.get<string>('rates_limit.api.window')),
index ec5045078fcedeff1fbb73bf68fa86cff0cb9bcb..0dab524d9cef3d4a07408f10d73e17927b3c1e37 100644 (file)
@@ -101,11 +101,6 @@ const SORTABLE_COLUMNS = {
   VIDEO_REDUNDANCIES: [ 'name' ]
 }
 
-const OAUTH_LIFETIME = {
-  ACCESS_TOKEN: 3600 * 24, // 1 day, for upload
-  REFRESH_TOKEN: 1209600 // 2 weeks
-}
-
 const ROUTE_CACHE_LIFETIME = {
   FEEDS: '15 minutes',
   ROBOTS: '2 hours',
@@ -1033,7 +1028,6 @@ export {
   JOB_ATTEMPTS,
   AP_CLEANER,
   LAST_MIGRATION_VERSION,
-  OAUTH_LIFETIME,
   CUSTOM_HTML_TAG_COMMENTS,
   STATS_TIMESERIE,
   BROADCAST_CONCURRENCY,
index bc0d4301f082519a02a666d279c0e6bf5ad8c431..2905c79a21ea9df187b6bd99b9fe07adbe185c65 100644 (file)
@@ -10,10 +10,11 @@ import OAuth2Server, {
 } from '@node-oauth/oauth2-server'
 import { randomBytesPromise } from '@server/helpers/core-utils'
 import { isOTPValid } from '@server/helpers/otp'
+import { CONFIG } from '@server/initializers/config'
 import { MOAuthClient } from '@server/types/models'
 import { sha1 } from '@shared/extra-utils'
 import { HttpStatusCode } from '@shared/models'
-import { OAUTH_LIFETIME, OTP } from '../../initializers/constants'
+import { OTP } from '../../initializers/constants'
 import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model'
 
 class MissingTwoFactorError extends Error {
@@ -32,8 +33,9 @@ class InvalidTwoFactorError extends Error {
  *
  */
 const oAuthServer = new OAuth2Server({
-  accessTokenLifetime: OAUTH_LIFETIME.ACCESS_TOKEN,
-  refreshTokenLifetime: OAUTH_LIFETIME.REFRESH_TOKEN,
+  // Wants seconds
+  accessTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN / 1000,
+  refreshTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN / 1000,
 
   // See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications
   model: require('./oauth-model')
@@ -182,10 +184,10 @@ function generateRandomToken () {
 
 function getTokenExpiresAt (type: 'access' | 'refresh') {
   const lifetime = type === 'access'
-    ? OAUTH_LIFETIME.ACCESS_TOKEN
-    : OAUTH_LIFETIME.REFRESH_TOKEN
+    ? CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN
+    : CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN
 
-  return new Date(Date.now() + lifetime * 1000)
+  return new Date(Date.now() + lifetime)
 }
 
 async function buildToken () {
index 410708a352e325bff62db53beca2689548384867..43efc7d02bd8aec815afd4eea7ebbd57a2b8875d 100644 (file)
@@ -36,8 +36,8 @@ export class TokensCache {
     const token = this.userHavingToken.get(userId)
 
     if (token !== undefined) {
-      this.accessTokenCache.del(token)
-      this.userHavingToken.del(userId)
+      this.accessTokenCache.delete(token)
+      this.userHavingToken.delete(userId)
     }
   }
 
@@ -45,8 +45,8 @@ export class TokensCache {
     const tokenModel = this.accessTokenCache.get(token)
 
     if (tokenModel !== undefined) {
-      this.userHavingToken.del(tokenModel.userId)
-      this.accessTokenCache.del(token)
+      this.userHavingToken.delete(tokenModel.userId)
+      this.accessTokenCache.delete(token)
     }
   }
 }
index 643f1a531f8dbd779403ceaf4bc1e6704816ce63..0313845ef0fb2fa94593c9f3dab6b9957726fe47 100644 (file)
@@ -1,3 +1,4 @@
+import './oauth'
 import './two-factor'
 import './user-subscriptions'
 import './user-videos'
diff --git a/server/tests/api/users/oauth.ts b/server/tests/api/users/oauth.ts
new file mode 100644 (file)
index 0000000..6a3da5e
--- /dev/null
@@ -0,0 +1,192 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { expect } from 'chai'
+import { wait } from '@shared/core-utils'
+import { HttpStatusCode, OAuth2ErrorCode, PeerTubeProblemDocument } from '@shared/models'
+import { cleanupTests, createSingleServer, killallServers, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
+
+describe('Test oauth', function () {
+  let server: PeerTubeServer
+
+  before(async function () {
+    this.timeout(30000)
+
+    server = await createSingleServer(1, {
+      rates_limit: {
+        login: {
+          max: 30
+        }
+      }
+    })
+
+    await setAccessTokensToServers([ server ])
+  })
+
+  describe('OAuth client', function () {
+
+    function expectInvalidClient (body: PeerTubeProblemDocument) {
+      expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
+      expect(body.error).to.contain('client is invalid')
+      expect(body.type.startsWith('https://')).to.be.true
+      expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
+    }
+
+    it('Should create a new client')
+
+    it('Should return the first client')
+
+    it('Should remove the last client')
+
+    it('Should not login with an invalid client id', async function () {
+      const client = { id: 'client', secret: server.store.client.secret }
+      const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+
+      expectInvalidClient(body)
+    })
+
+    it('Should not login with an invalid client secret', async function () {
+      const client = { id: server.store.client.id, secret: 'coucou' }
+      const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+
+      expectInvalidClient(body)
+    })
+  })
+
+  describe('Login', function () {
+
+    function expectInvalidCredentials (body: PeerTubeProblemDocument) {
+      expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
+      expect(body.error).to.contain('credentials are invalid')
+      expect(body.type.startsWith('https://')).to.be.true
+      expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
+    }
+
+    it('Should not login with an invalid username', async function () {
+      const user = { username: 'captain crochet', password: server.store.user.password }
+      const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+
+      expectInvalidCredentials(body)
+    })
+
+    it('Should not login with an invalid password', async function () {
+      const user = { username: server.store.user.username, password: 'mew_three' }
+      const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+
+      expectInvalidCredentials(body)
+    })
+
+    it('Should be able to login', async function () {
+      await server.login.login({ expectedStatus: HttpStatusCode.OK_200 })
+    })
+
+    it('Should be able to login with an insensitive username', async function () {
+      const user = { username: 'RoOt', password: server.store.user.password }
+      await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 })
+
+      const user2 = { username: 'rOoT', password: server.store.user.password }
+      await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 })
+
+      const user3 = { username: 'ROOt', password: server.store.user.password }
+      await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 })
+    })
+  })
+
+  describe('Logout', function () {
+
+    it('Should logout (revoke token)', async function () {
+      await server.login.logout({ token: server.accessToken })
+    })
+
+    it('Should not be able to get the user information', async function () {
+      await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+    })
+
+    it('Should not be able to upload a video', async function () {
+      await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+    })
+
+    it('Should be able to login again', async function () {
+      const body = await server.login.login()
+      server.accessToken = body.access_token
+      server.refreshToken = body.refresh_token
+    })
+
+    it('Should be able to get my user information again', async function () {
+      await server.users.getMyInfo()
+    })
+
+    it('Should have an expired access token', async function () {
+      this.timeout(60000)
+
+      await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString())
+      await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString())
+
+      await killallServers([ server ])
+      await server.run()
+
+      await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+    })
+
+    it('Should not be able to refresh an access token with an expired refresh token', async function () {
+      await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+    })
+
+    it('Should refresh the token', async function () {
+      this.timeout(50000)
+
+      const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString()
+      await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate)
+
+      await killallServers([ server ])
+      await server.run()
+
+      const res = await server.login.refreshToken({ refreshToken: server.refreshToken })
+      server.accessToken = res.body.access_token
+      server.refreshToken = res.body.refresh_token
+    })
+
+    it('Should be able to get my user information again', async function () {
+      await server.users.getMyInfo()
+    })
+  })
+
+  describe('Custom token lifetime', function () {
+    before(async function () {
+      this.timeout(120_000)
+
+      await server.kill()
+      await server.run({
+        oauth2: {
+          token_lifetime: {
+            access_token: '2 seconds',
+            refresh_token: '2 seconds'
+          }
+        }
+      })
+    })
+
+    it('Should have a very short access token lifetime', async function () {
+      this.timeout(50000)
+
+      const { access_token: accessToken } = await server.login.login()
+      await server.users.getMyInfo({ token: accessToken })
+
+      await wait(3000)
+      await server.users.getMyInfo({ token: accessToken, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+    })
+
+    it('Should have a very short refresh token lifetime', async function () {
+      this.timeout(50000)
+
+      const { refresh_token: refreshToken } = await server.login.login()
+      await server.login.refreshToken({ refreshToken })
+
+      await wait(3000)
+      await server.login.refreshToken({ refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+    })
+  })
+
+  after(async function () {
+    await cleanupTests([ server ])
+  })
+})
index 421b3ce1682306b43f168afb4c7eb89fb5a36cc5..93e2e489a3d4f06b39e0810f1d8848a282f6590f 100644 (file)
@@ -2,15 +2,8 @@
 
 import { expect } from 'chai'
 import { testImage } from '@server/tests/shared'
-import { AbuseState, HttpStatusCode, OAuth2ErrorCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models'
-import {
-  cleanupTests,
-  createSingleServer,
-  killallServers,
-  makePutBodyRequest,
-  PeerTubeServer,
-  setAccessTokensToServers
-} from '@shared/server-commands'
+import { AbuseState, HttpStatusCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models'
+import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
 
 describe('Test users', function () {
   let server: PeerTubeServer
@@ -39,166 +32,6 @@ describe('Test users', function () {
     await server.plugins.install({ npmName: 'peertube-theme-background-red' })
   })
 
-  describe('OAuth client', function () {
-    it('Should create a new client')
-
-    it('Should return the first client')
-
-    it('Should remove the last client')
-
-    it('Should not login with an invalid client id', async function () {
-      const client = { id: 'client', secret: server.store.client.secret }
-      const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
-
-      expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
-      expect(body.error).to.contain('client is invalid')
-      expect(body.type.startsWith('https://')).to.be.true
-      expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
-    })
-
-    it('Should not login with an invalid client secret', async function () {
-      const client = { id: server.store.client.id, secret: 'coucou' }
-      const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
-
-      expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
-      expect(body.error).to.contain('client is invalid')
-      expect(body.type.startsWith('https://')).to.be.true
-      expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
-    })
-  })
-
-  describe('Login', function () {
-
-    it('Should not login with an invalid username', async function () {
-      const user = { username: 'captain crochet', password: server.store.user.password }
-      const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
-
-      expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
-      expect(body.error).to.contain('credentials are invalid')
-      expect(body.type.startsWith('https://')).to.be.true
-      expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
-    })
-
-    it('Should not login with an invalid password', async function () {
-      const user = { username: server.store.user.username, password: 'mew_three' }
-      const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
-
-      expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
-      expect(body.error).to.contain('credentials are invalid')
-      expect(body.type.startsWith('https://')).to.be.true
-      expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
-    })
-
-    it('Should not be able to upload a video', async function () {
-      token = 'my_super_token'
-
-      await server.videos.upload({ token, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
-    })
-
-    it('Should not be able to follow', async function () {
-      token = 'my_super_token'
-
-      await server.follows.follow({
-        hosts: [ 'http://example.com' ],
-        token,
-        expectedStatus: HttpStatusCode.UNAUTHORIZED_401
-      })
-    })
-
-    it('Should not be able to unfollow')
-
-    it('Should be able to login', async function () {
-      const body = await server.login.login({ expectedStatus: HttpStatusCode.OK_200 })
-
-      token = body.access_token
-    })
-
-    it('Should be able to login with an insensitive username', async function () {
-      const user = { username: 'RoOt', password: server.store.user.password }
-      await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 })
-
-      const user2 = { username: 'rOoT', password: server.store.user.password }
-      await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 })
-
-      const user3 = { username: 'ROOt', password: server.store.user.password }
-      await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 })
-    })
-  })
-
-  describe('Logout', function () {
-    it('Should logout (revoke token)', async function () {
-      await server.login.logout({ token: server.accessToken })
-    })
-
-    it('Should not be able to get the user information', async function () {
-      await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
-    })
-
-    it('Should not be able to upload a video', async function () {
-      await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
-    })
-
-    it('Should not be able to rate a video', async function () {
-      const path = '/api/v1/videos/'
-      const data = {
-        rating: 'likes'
-      }
-
-      const options = {
-        url: server.url,
-        path: path + videoId,
-        token: 'wrong token',
-        fields: data,
-        expectedStatus: HttpStatusCode.UNAUTHORIZED_401
-      }
-      await makePutBodyRequest(options)
-    })
-
-    it('Should be able to login again', async function () {
-      const body = await server.login.login()
-      server.accessToken = body.access_token
-      server.refreshToken = body.refresh_token
-    })
-
-    it('Should be able to get my user information again', async function () {
-      await server.users.getMyInfo()
-    })
-
-    it('Should have an expired access token', async function () {
-      this.timeout(60000)
-
-      await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString())
-      await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString())
-
-      await killallServers([ server ])
-      await server.run()
-
-      await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
-    })
-
-    it('Should not be able to refresh an access token with an expired refresh token', async function () {
-      await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
-    })
-
-    it('Should refresh the token', async function () {
-      this.timeout(50000)
-
-      const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString()
-      await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate)
-
-      await killallServers([ server ])
-      await server.run()
-
-      const res = await server.login.refreshToken({ refreshToken: server.refreshToken })
-      server.accessToken = res.body.access_token
-      server.refreshToken = res.body.refresh_token
-    })
-
-    it('Should be able to get my user information again', async function () {
-      await server.users.getMyInfo()
-    })
-  })
-
   describe('Creating a user', function () {
 
     it('Should be able to create a new user', async function () {
@@ -512,6 +345,7 @@ describe('Test users', function () {
   })
 
   describe('Updating another user', function () {
+
     it('Should be able to update another user', async function () {
       await server.users.update({
         userId,
@@ -562,13 +396,6 @@ describe('Test users', function () {
     })
   })
 
-  describe('Video blacklists', function () {
-
-    it('Should be able to list my video blacklist', async function () {
-      await server.blacklist.list({ token: userToken })
-    })
-  })
-
   describe('Remove a user', function () {
 
     before(async function () {
@@ -653,8 +480,9 @@ describe('Test users', function () {
   })
 
   describe('User blocking', function () {
-    let user16Id
-    let user16AccessToken
+    let user16Id: number
+    let user16AccessToken: string
+
     const user16 = {
       username: 'user_16',
       password: 'my super password'
index dc9cf4e015a2bcea704ceec00896fcd98bfecda6..cb0e1a5fbd920f3646f9f4c9d1a3b3022977e4b4 100644 (file)
@@ -199,7 +199,7 @@ function buildRequest (req: request.Test, options: CommonRequestParams) {
   return req.expect((res) => {
     if (options.expectedStatus && res.status !== options.expectedStatus) {
       throw new Error(`Expected status ${options.expectedStatus}, got ${res.status}. ` +
-        `\nThe server responded this error: "${res.body?.error ?? res.text}".\n` +
+        `\nThe server responded: "${res.body?.error ?? res.text}".\n` +
         'You may take a closer look at the logs. To see how to do so, check out this page: ' +
         'https://github.com/Chocobozzz/PeerTube/blob/develop/support/doc/development/tests.md#debug-server-logs')
     }