Explorar o código

Merge pull request #9971 from weseek/imprv/support-bearer-token

imprv(api): Support Bearer token
mergify[bot] hai 10 meses
pai
achega
cc4d498b96

+ 9 - 2
apps/app/bin/swagger-jsdoc/definition-apiv1.js

@@ -13,15 +13,22 @@ module.exports = {
   ],
   security: [
     {
-      api_key: [],
+      bearer: [],
+      accessTokenInQuery: [],
     },
   ],
   components: {
     securitySchemes: {
-      api_key: {
+      bearer: {
+        type: 'http',
+        scheme: 'bearer',
+        description: 'Access token generated by each GROWI users',
+      },
+      accessTokenInQuery: {
         type: 'apiKey',
         name: 'access_token',
         in: 'query',
+        description: 'Access token generated by each GROWI users',
       },
     },
   },

+ 9 - 2
apps/app/bin/swagger-jsdoc/definition-apiv3.js

@@ -13,15 +13,22 @@ module.exports = {
   ],
   security: [
     {
-      api_key: [],
+      bearer: [],
+      accessTokenInQuery: [],
     },
   ],
   components: {
     securitySchemes: {
-      api_key: {
+      bearer: {
+        type: 'http',
+        scheme: 'bearer',
+        description: 'Access token generated by each GROWI users',
+      },
+      accessTokenInQuery: {
         type: 'apiKey',
         name: 'access_token',
         in: 'query',
+        description: 'Access token generated by each GROWI users',
       },
       cookieAuth: {
         type: 'apiKey',

+ 12 - 6
apps/app/src/features/questionnaire/server/routes/apiv3/questionnaire.ts

@@ -68,7 +68,8 @@ module.exports = (crowi: Crowi): Router => {
    *   get:
    *     tags: [Questionnaire]
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /questionnaire/orders
    *     description: Get questionnaire orders
    *     responses:
@@ -106,7 +107,8 @@ module.exports = (crowi: Crowi): Router => {
    *   get:
    *     tags: [Questionnaire]
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /questionnaire/is-enabled
    *     description: Get questionnaire is enabled
    *     responses:
@@ -132,7 +134,8 @@ module.exports = (crowi: Crowi): Router => {
    *   post:
    *     tags: [Questionnaire]
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /questionnaire/proactive/answer
    *     description: Post proactive questionnaire answer
    *     requestBody:
@@ -205,7 +208,8 @@ module.exports = (crowi: Crowi): Router => {
    *   put:
    *     tags: [Questionnaire]
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /questionnaire/answer
    *     description: Post questionnaire answer
    *     requestBody:
@@ -284,7 +288,8 @@ module.exports = (crowi: Crowi): Router => {
    *   put:
    *     tags: [Questionnaire]
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /questionnaire/skip
    *     description: Skip questionnaire
    *     requestBody:
@@ -332,7 +337,8 @@ module.exports = (crowi: Crowi): Router => {
    *   put:
    *     tags: [Questionnaire]
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /questionnaire/deny
    *     description: Deny questionnaire
    *     requestBody:

+ 59 - 0
apps/app/src/server/middlewares/access-token-parser/access-token-parser.integ.ts

@@ -132,4 +132,63 @@ describe('access-token-parser middleware', () => {
     expect(nextMock).toHaveBeenCalled();
   });
 
+  it('should set req.user with a valid Bearer token in Authorization header', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+      headers: {
+        authorization: undefined,
+      },
+    });
+    const resMock = mock<Response>();
+    const nextMock = vi.fn();
+
+    expect(reqMock.user).toBeUndefined();
+
+    // prepare a user with an access token
+    const targetUser = await User.create({
+      name: faker.person.fullName(),
+      username: faker.string.uuid(),
+      password: faker.internet.password(),
+      lang: 'en_US',
+      apiToken: faker.internet.password(),
+    });
+
+    // act
+    reqMock.headers.authorization = `Bearer ${targetUser.apiToken}`;
+    await accessTokenParser(reqMock, resMock, nextMock);
+
+    // assert
+    expect(reqMock.user).toBeDefined();
+    expect(reqMock.user?._id).toStrictEqual(targetUser._id);
+    expect(serializeUserSecurely).toHaveBeenCalledOnce();
+    expect(nextMock).toHaveBeenCalled();
+  });
+
+  it('should ignore non-Bearer Authorization header', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+      headers: {
+        authorization: undefined,
+      },
+    });
+    const resMock = mock<Response>();
+    const nextMock = vi.fn();
+
+    expect(reqMock.user).toBeUndefined();
+
+    // Generate random string that is guaranteed to be invalid for Basic auth (1024 chars)
+    const randomString = faker.string.alpha(1024);
+
+    // act
+    reqMock.headers.authorization = `Basic ${randomString}`; // Basic auth header with random string
+    await accessTokenParser(reqMock, resMock, nextMock);
+
+    // assert
+    expect(reqMock.user).toBeUndefined();
+    expect(serializeUserSecurely).not.toHaveBeenCalled();
+    expect(nextMock).toHaveBeenCalled();
+  });
+
 });

+ 18 - 2
apps/app/src/server/middlewares/access-token-parser/access-token-parser.ts

@@ -11,9 +11,25 @@ import type { AccessTokenParserReq } from './interfaces';
 const logger = loggerFactory('growi:middleware:access-token-parser');
 
 
+const extractBearerToken = (authHeader: string | undefined): string | null => {
+  if (authHeader == null) {
+    return null;
+  }
+
+  if (!authHeader.startsWith('Bearer ')) {
+    return null;
+  }
+
+  return authHeader.substring(7); // Remove 'Bearer ' prefix
+};
+
 export const accessTokenParser = async(req: AccessTokenParserReq, res: Response, next: NextFunction): Promise<void> => {
-  // TODO: comply HTTP header of RFC6750 / Authorization: Bearer
-  const accessToken = req.query.access_token ?? req.body.access_token;
+  // Extract token from Authorization header first
+  const bearerToken = extractBearerToken(req.headers.authorization);
+
+  // Try all possible token sources in order of priority
+  const accessToken = bearerToken ?? req.query.access_token ?? req.body.access_token;
+
   if (accessToken == null || typeof accessToken !== 'string') {
     return next();
   }

+ 2 - 1
apps/app/src/server/routes/apiv3/activity.ts

@@ -185,7 +185,8 @@ module.exports = (crowi: Crowi): Router => {
    *     summary: /activity
    *     tags: [Activity]
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     parameters:
    *       - name: limit
    *         in: query

+ 6 - 3
apps/app/src/server/routes/apiv3/app-settings.js

@@ -420,7 +420,8 @@ module.exports = (crowi) => {
    *        tags: [AppSettings]
    *        operationId: getAppSettings
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: /app-settings
    *        description: get app setting params
    *        responses:
@@ -1065,7 +1066,8 @@ module.exports = (crowi) => {
    *        tags: [AppSettings]
    *        operationId: updateAppSettingV5SchemaMigration
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: AccessToken supported.
    *        description: Update V5SchemaMigration
    *        responses:
@@ -1110,7 +1112,8 @@ module.exports = (crowi) => {
    *        tags: [AppSettings]
    *        operationId: updateAppSettingMaintenanceMode
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: AccessToken supported.
    *        description: Update MaintenanceMode
    *        requestBody:

+ 12 - 6
apps/app/src/server/routes/apiv3/bookmark-folder.ts

@@ -130,7 +130,8 @@ module.exports = (crowi) => {
    *        tags: [BookmarkFolders]
    *        operationId: createBookmarkFolder
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: Create bookmark folder
    *        description: Create a new bookmark folder
    *        requestBody:
@@ -185,7 +186,8 @@ module.exports = (crowi) => {
    *        tags: [BookmarkFolders]
    *        operationId: listBookmarkFolders
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: List bookmark folders of a user
    *        description: List bookmark folders of a user
    *        parameters:
@@ -274,7 +276,8 @@ module.exports = (crowi) => {
    *        tags: [BookmarkFolders]
    *        operationId: deleteBookmarkFolder
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: Delete bookmark folder
    *        description: Delete a bookmark folder and its children
    *        parameters:
@@ -317,7 +320,8 @@ module.exports = (crowi) => {
    *        tags: [BookmarkFolders]
    *        operationId: updateBookmarkFolder
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: Update bookmark folder
    *        description: Update a bookmark folder
    *        requestBody:
@@ -374,7 +378,8 @@ module.exports = (crowi) => {
    *        tags: [BookmarkFolders]
    *        operationId: addBookmarkToFolder
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: Update bookmark folder
    *        description: Update a bookmark folder
    *        requestBody:
@@ -424,7 +429,8 @@ module.exports = (crowi) => {
    *        tags: [BookmarkFolders]
    *        operationId: updateBookmarkInFolder
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: Update bookmark in folder
    *        description: Update a bookmark in a folder
    *        requestBody:

+ 4 - 2
apps/app/src/server/routes/apiv3/g2g-transfer.ts

@@ -441,7 +441,8 @@ module.exports = (crowi: Crowi): Router => {
    *      summary: /g2g-transfer/generate-key
    *      tags: [GROWI to GROWI Transfer]
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      requestBody:
    *        required: true
    *        content:
@@ -498,7 +499,8 @@ module.exports = (crowi: Crowi): Router => {
    *      summary: /g2g-transfer/transfer
    *      tags: [GROWI to GROWI Transfer]
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      requestBody:
    *        required: true
    *        content:

+ 10 - 5
apps/app/src/server/routes/apiv3/import.js

@@ -169,7 +169,8 @@ export default function route(crowi) {
    *    get:
    *      tags: [Import]
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: getImportSettingsParams
    *      summary: /import
    *      description: Get import settings params
@@ -221,7 +222,8 @@ export default function route(crowi) {
    *    get:
    *      tags: [Import]
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: getImportStatus
    *      summary: /import/status
    *      description: Get properties of stored zip files for import
@@ -252,7 +254,8 @@ export default function route(crowi) {
    *    post:
    *      tags: [Import]
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: executeImport
    *      summary: /import
    *      description: import a collection from a zipped json
@@ -384,7 +387,8 @@ export default function route(crowi) {
    *    post:
    *      tags: [Import]
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: uploadImport
    *      summary: /import/upload
    *      description: upload a zip file
@@ -440,7 +444,8 @@ export default function route(crowi) {
    *    delete:
    *      tags: [Import]
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: deleteImportAll
    *      summary: /import/all
    *      description: Delete all zip files

+ 8 - 4
apps/app/src/server/routes/apiv3/in-app-notification.ts

@@ -103,7 +103,8 @@ module.exports = (crowi) => {
    *    get:
    *      tags: [NotificationSetting]
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: getInAppNotificationList
    *      summary: /in-app-notification/list
    *      description: Get the list of in-app notifications
@@ -193,7 +194,8 @@ module.exports = (crowi) => {
    *    get:
    *      tags: [NotificationSetting]
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: getInAppNotificationStatus
    *      summary: /in-app-notification/status
    *      description: Get the status of in-app notifications
@@ -229,7 +231,8 @@ module.exports = (crowi) => {
    *    post:
    *      tags: [NotificationSetting]
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: openInAppNotification
    *      summary: /in-app-notification/open
    *      description: Open the in-app notification
@@ -275,7 +278,8 @@ module.exports = (crowi) => {
    *    put:
    *      tags: [NotificationSetting]
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: openAllInAppNotification
    *      summary: /in-app-notification/all-statuses-open
    *      description: Open all in-app notifications

+ 9 - 5
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -72,7 +72,8 @@ const routerFactory = (crowi: Crowi): Router => {
    *   get:
    *     tags: [PageListing]
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /page-listing/root
    *     description: Get the root page
    *     responses:
@@ -107,7 +108,8 @@ const routerFactory = (crowi: Crowi): Router => {
    *   get:
    *     tags: [PageListing]
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /page-listing/ancestors-children
    *     description: Get the ancestors and children of a page
    *     parameters:
@@ -172,7 +174,8 @@ const routerFactory = (crowi: Crowi): Router => {
    *   get:
    *     tags: [PageListing]
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /page-listing/children
    *     description: Get the children of a page
    *     parameters:
@@ -211,7 +214,7 @@ const routerFactory = (crowi: Crowi): Router => {
 
     try {
       const pages = await pageService.findChildrenByParentPathOrIdAndViewer(
-        (id || path)as string, req.user, undefined, !hideRestrictedByOwner, !hideRestrictedByGroup,
+        (id || path) as string, req.user, undefined, !hideRestrictedByOwner, !hideRestrictedByGroup,
       );
       return res.apiv3({ children: pages });
     }
@@ -228,7 +231,8 @@ const routerFactory = (crowi: Crowi): Router => {
    *   get:
    *     tags: [PageListing]
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /page-listing/info
    *     description: Get the information of a page
    *     parameters: