Преглед изворни кода

Merge remote-tracking branch 'origin/feat/162829-162857-scope' into feat/162830-choose-scope-of-access-token

NaokiHigashi28 пре 1 година
родитељ
комит
1339778e89

+ 2 - 1
apps/app/src/client/components/Me/AccessTokenForm.tsx

@@ -44,11 +44,12 @@ export const AccessTokenForm = React.memo((props: AccessTokenFormProps): JSX.Ele
 
   const onSubmit = (data: FormInputs) => {
     const expiredAtDate = new Date(data.expiredAt);
+    const scopes: Scope[] = data.scopes ? data.scopes : [];
 
     submitHandler({
       expiredAt: expiredAtDate,
       description: data.description,
-      scope: data.scopes,
+      scopes,
     });
   };
 

+ 1 - 1
apps/app/src/client/components/Me/AccessTokenList.tsx

@@ -60,7 +60,7 @@ export const AccessTokenList = React.memo((props: AccessTokenListProps): JSX.Ele
                     <tr key={token._id}>
                       <td className="text-break">{token.description}</td>
                       <td>{token.expiredAt.toString().split('T')[0]}</td>
-                      <td>{token.scope.join(', ')}</td>
+                      <td>{token.scopes.join(', ')}</td>
                       <td>
                         <button
                           className="btn btn-danger"

+ 1 - 1
apps/app/src/interfaces/access-token.ts

@@ -3,7 +3,7 @@ import type { Scope } from './scope';
 export type IAccessTokenInfo = {
   expiredAt: Date,
   description: string,
-  scope: Scope[],
+  scopes: Scope[],
 }
 
 export type IResGenerateAccessToken = IAccessTokenInfo & {

+ 9 - 1
apps/app/src/interfaces/scope.ts

@@ -1,5 +1,5 @@
 // If you want to add a new scope, you only need to add a new key to the ORIGINAL_SCOPE object.
-export const ORIGINAL_SCOPE = {
+export const ORIGINAL_SCOPE_ADMIN = {
   admin: {
     top: {},
     app: {},
@@ -19,6 +19,9 @@ export const ORIGINAL_SCOPE = {
     ai_integration: {},
     full_text_search: {},
   },
+} as const;
+
+export const ORIGINAL_SCOPE_USER = {
   user: {
     info: {},
     external_account: {},
@@ -34,6 +37,11 @@ export const ORIGINAL_SCOPE = {
   },
 } as const;
 
+export const ORIGINAL_SCOPE = {
+  ...ORIGINAL_SCOPE_ADMIN,
+  ...ORIGINAL_SCOPE_USER,
+} as const;
+
 export const ACTION = {
   READ: 'read',
   WRITE: 'write',

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

@@ -189,7 +189,7 @@ describe('access-token-parser middleware for access token with scopes', () => {
     expect(nextMock).toHaveBeenCalled();
   });
 
-  it('should authenticate with no scopes', async() => {
+  it('should authenticate with specific scope', async() => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
@@ -255,7 +255,7 @@ describe('access-token-parser middleware for access token with scopes', () => {
     reqMock.query.access_token = token;
     await accessTokenParser([SCOPE.WRITE.USER.INFO])(reqMock, resMock, nextMock);
 
-    // assert
+    // // assert
     expect(reqMock.user).toBeUndefined();
     expect(serializeUserSecurely).not.toHaveBeenCalled();
     expect(nextMock).toHaveBeenCalled();
@@ -322,7 +322,7 @@ describe('access-token-parser middleware for access token with scopes', () => {
 
     // act - try to access with read:user:info scope
     reqMock.query.access_token = token;
-    await accessTokenParser([SCOPE.READ.USER.INFO])(reqMock, resMock, nextMock);
+    await accessTokenParser([SCOPE.READ.USER.INFO, SCOPE.READ.USER.API.ACCESS_TOKEN])(reqMock, resMock, nextMock);
 
     // assert
     expect(reqMock.user).toBeDefined();

+ 13 - 12
apps/app/src/server/models/access-token.ts

@@ -12,7 +12,7 @@ import type { Scope } from '~/interfaces/scope';
 import loggerFactory from '~/utils/logger';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
-import { extractScopes } from '../util/scope-utils';
+import { extractAllScope, extractScopes } from '../util/scope-utils';
 
 const logger = loggerFactory('growi:models:access-token');
 
@@ -22,7 +22,7 @@ type GenerateTokenResult = {
   token: string,
   _id: Types.ObjectId,
   expiredAt: Date,
-  scope?: Scope[],
+  scopes?: Scope[],
   description?: string,
 }
 
@@ -30,7 +30,7 @@ export type IAccessToken = {
   user: Ref<IUserHasId>,
   tokenHash: string,
   expiredAt: Date,
-  scope?: Scope[],
+  scopes?: Scope[],
   description?: string,
 }
 
@@ -39,7 +39,7 @@ export interface IAccessTokenDocument extends IAccessToken, Document {
 }
 
 export interface IAccessTokenModel extends Model<IAccessTokenDocument> {
-  generateToken: (userId: Types.ObjectId | string, expiredAt: Date, scope?: Scope[], description?: string,) => Promise<GenerateTokenResult>
+  generateToken: (userId: Types.ObjectId | string, expiredAt: Date, scopes?: Scope[], description?: string,) => Promise<GenerateTokenResult>
   deleteToken: (token: string) => Promise<void>
   deleteTokenById: (tokenId: Types.ObjectId | string) => Promise<void>
   deleteAllTokensByUserId: (userId: Types.ObjectId | string) => Promise<void>
@@ -55,27 +55,27 @@ const accessTokenSchema = new Schema<IAccessTokenDocument, IAccessTokenModel>({
   },
   tokenHash: { type: String, required: true, unique: true },
   expiredAt: { type: Date, required: true, index: true },
-  scope: [{ type: String, default: '' }],
+  scopes: [{ type: String, default: '' }],
   description: { type: String, default: '' },
 });
 
 accessTokenSchema.plugin(mongoosePaginate);
 accessTokenSchema.plugin(uniqueValidator);
 
-accessTokenSchema.statics.generateToken = async function(userId: Types.ObjectId | string, expiredAt: Date, scope?: Scope[], description?: string) {
+accessTokenSchema.statics.generateToken = async function(userId: Types.ObjectId | string, expiredAt: Date, scopes?: Scope[], description?: string) {
 
-  const extractedScopes = extractScopes(scope ?? []);
+  const extractedScopes = extractScopes(scopes ?? []);
   const token = crypto.randomBytes(32).toString('hex');
   const tokenHash = generateTokenHash(token);
 
   try {
     const { _id } = await this.create({
-      user: userId, tokenHash, expiredAt, scope: extractedScopes, description,
+      user: userId, tokenHash, expiredAt, scopes: extractedScopes, description,
     });
 
     logger.debug('Token generated');
     return {
-      token, _id, expiredAt, scope: extractedScopes, description,
+      token, _id, expiredAt, scopes: extractedScopes, description,
     };
   }
   catch (err) {
@@ -106,20 +106,21 @@ accessTokenSchema.statics.findUserIdByToken = async function(token: string, requ
   const tokenHash = generateTokenHash(token);
   const now = new Date();
   if (requiredScopes != null && requiredScopes.length > 0) {
-    return this.findOne({ tokenHash, expiredAt: { $gt: now }, scope: { $all: requiredScopes } }).select('user');
+    const extractedScopes = requiredScopes.map(scope => extractAllScope(scope)).flat();
+    return this.findOne({ tokenHash, expiredAt: { $gt: now }, scopes: { $all: extractedScopes } }).select('user');
   }
   return this.findOne({ tokenHash, expiredAt: { $gt: now } }).select('user');
 };
 
 accessTokenSchema.statics.findTokenByUserId = async function(userId: Types.ObjectId | string) {
   const now = new Date();
-  return this.find({ user: userId, expiredAt: { $gt: now } }).select('_id expiredAt scope description');
+  return this.find({ user: userId, expiredAt: { $gt: now } }).select('_id expiredAt scopes description');
 };
 
 accessTokenSchema.statics.validateTokenScopes = async function(token: string, requiredScopes: Scope[]) {
   const tokenHash = generateTokenHash(token);
   const now = new Date();
-  const tokenData = await this.findOne({ tokenHash, expiredAt: { $gt: now }, scope: { $all: requiredScopes } });
+  const tokenData = await this.findOne({ tokenHash, expiredAt: { $gt: now }, scopes: { $all: requiredScopes } });
   return tokenData != null;
 };
 

+ 6 - 6
apps/app/src/server/routes/apiv3/personal-setting/generate-access-token.ts

@@ -21,7 +21,7 @@ const logger = loggerFactory('growi:routes:apiv3:personal-setting:generate-acces
 type ReqBody = {
   expiredAt: Date,
   description?: string,
-  scope?: Scope[],
+  scopes?: Scope[],
 }
 
 interface GenerateAccessTokenRequest extends Request<undefined, ApiV3Response, ReqBody> {
@@ -58,12 +58,12 @@ const validator = [
     .isLength({ max: 200 })
     .withMessage('description must be less than or equal to 200 characters'),
 
-  body('scope')
+  body('scopes')
     .optional()
     .isArray()
     .withMessage('scope must be an array')
-    .custom((value: Scope[]) => {
-      value.forEach((scope) => {
+    .custom((scopes: Scope[]) => {
+      scopes.forEach((scope) => {
         if (!isValidScope(scope)) {
           throw new Error(`Invalid scope: ${scope}}`);
         }
@@ -88,10 +88,10 @@ export const generateAccessTokenHandlerFactory: GenerateAccessTokenHandlerFactor
     async(req: GenerateAccessTokenRequest, res: ApiV3Response) => {
 
       const { user, body } = req;
-      const { expiredAt, description, scope } = body;
+      const { expiredAt, description, scopes } = body;
 
       try {
-        const tokenData = await AccessToken.generateToken(user._id, expiredAt, scope, description);
+        const tokenData = await AccessToken.generateToken(user._id, expiredAt, scopes, description);
 
         const parameters = { action: SupportedAction.ACTION_USER_ACCESS_TOKEN_CREATE };
         activityEvent.emit('update', res.locals.activity._id, parameters);

+ 1 - 1
apps/app/src/server/routes/apiv3/personal-setting/get-access-tokens.ts

@@ -25,7 +25,7 @@ export const getAccessTokenHandlerFactory: GetAccessTokenHandlerFactory = (crowi
   const addActivity = generateAddActivityMiddleware();
 
   return [
-    accessTokenParser(),
+    accessTokenParser([SCOPE.READ.USER.API.ACCESS_TOKEN]),
     loginRequiredStrictly,
     addActivity,
     async(req: GetAccessTokenRequest, res: ApiV3Response) => {

+ 37 - 17
apps/app/src/server/util/scope-utils.ts

@@ -3,7 +3,7 @@ import {
 } from '../../interfaces/scope';
 
 export const isValidScope = (scope: Scope): boolean => {
-  const scopeParts = scope.split(':').map(x => (x === '*' ? 'ALL' : x.toUpperCase()));
+  const scopeParts = scope.split(':').map(x => (x === ALL_SIGN ? 'ALL' : x.toUpperCase()));
   let obj: any = SCOPE;
   scopeParts.forEach((part) => {
     if (obj[part] == null) {
@@ -22,7 +22,7 @@ export const hasAllScope = (scope: Scope): scope is Scope => {
  * Returns all values of the scope object
  * For example, SCOPE.READ.USER.API.ALL returns ['read:user:api:access_token', 'read:user:api:api_token']
  */
-const getAllScopeValues = (scopeObj: any): Scope[] => {
+const getAllScopeValuesFromObj = (scopeObj: any): Scope[] => {
   const result: Scope[] = [];
 
   const traverse = (current: any): void => {
@@ -55,33 +55,53 @@ const getImpliedScopes = (scope: Scope): Scope[] => {
   return [];
 };
 
+export const extractAllScope = (scope: Scope): Scope[] => {
+  if (!hasAllScope(scope)) {
+    return [scope];
+  }
+  const result = [] as Scope[];
+  const scopeParts = scope.split(':').map(x => (x.toUpperCase()));
+  let obj: any = SCOPE;
+  scopeParts.forEach((part) => {
+    if (part === ALL_SIGN) {
+      return;
+    }
+    obj = obj[part];
+  });
+  getAllScopeValuesFromObj(obj).forEach((value) => {
+    result.push(value);
+  });
+  return result;
+};
+
+
+/**
+ * Extracts scopes from a given array of scopes
+ * And delete all scopes
+ * For example, [SCOPE.WRITE.USER.API.ALL] === ['write:user:api:all']
+ * returns ['read:user:api:access_token',
+ *          'read:user:api:api_token'
+ *          'write:user:api:access_token',
+ *          'write:user:api:api_token']
+ */
 export const extractScopes = (scopes?: Scope[]): Scope[] => {
   if (scopes == null) {
     return [];
   }
+
   const result = new Set<Scope>(); // remove duplicates
   const impliedScopes = new Set<Scope>();
+
   scopes.forEach((scope) => {
     getImpliedScopes(scope).forEach((impliedScope) => {
       impliedScopes.add(impliedScope);
     });
   });
   impliedScopes.forEach((scope) => {
-    if (!hasAllScope(scope)) {
-      result.add(scope);
-      return;
-    }
-    const scopeParts = scope.split(':').map(x => (x.toUpperCase()));
-    let obj: any = SCOPE;
-    scopeParts.forEach((part) => {
-      if (part === ALL_SIGN) {
-        return;
-      }
-      obj = obj[part];
-    });
-    getAllScopeValues(obj).forEach((value) => {
-      result.add(value);
+    extractAllScope(scope).forEach((extractedScope) => {
+      result.add(extractedScope);
     });
   });
-  return Array.from(result.values());
+  const resultArray = Array.from(result.values()).filter(scope => !hasAllScope(scope));
+  return resultArray;
 };