Просмотр исходного кода

refactor: rename ALL_SCOPE to ALL_SIGN and update related logic; change scope type from string[] to Scope[] in access token model and routes

reiji-h 1 год назад
Родитель
Сommit
01dc28d00e

+ 66 - 8
apps/app/src/interfaces/scope.ts

@@ -40,7 +40,7 @@ export const ACTION = {
 } as const;
 
 type ACTION_TYPE = typeof ACTION[keyof typeof ACTION];
-export const ALL_SCOPE = '*';
+export const ALL_SIGN = '*';
 
 export const ORIGINAL_SCOPE_WITH_ACTION = Object.values(ACTION).reduce(
   (acc, action) => {
@@ -60,7 +60,7 @@ type FlattenObject<T> = {
 
 type AddAllToScope<S extends string> =
   S extends `${infer X}:${infer Y}`
-    ? `${X}:${typeof ALL_SCOPE}` | `${X}:${AddAllToScope<Y>}` | S
+    ? `${X}:${typeof ALL_SIGN}` | `${X}:${AddAllToScope<Y>}` | S
     : S;
 
 type ScopeOnly = FlattenObject<typeof ORIGINAL_SCOPE_WITH_ACTION>;
@@ -83,11 +83,10 @@ type ScopeConstantType = {
     ScopeConstantNode<typeof ORIGINAL_SCOPE> & { ALL: Scope }
 };
 
-
-function buildScopeConstants(): ScopeConstantType {
+const buildScopeConstants = (): ScopeConstantType => {
   const result = {} as Partial<ScopeConstantType>;
 
-  function processObject(obj: Record<string, any>, path: string[] = [], resultObj: Record<string, any>) {
+  const processObject = (obj: Record<string, any>, path: string[] = [], resultObj: Record<string, any>) => {
     Object.entries(obj).forEach(([key, value]) => {
       const upperKey = key.toUpperCase();
       const currentPath = [...path, key];
@@ -102,16 +101,75 @@ function buildScopeConstants(): ScopeConstantType {
       }
       else if (typeof value === 'object') {
         resultObj[upperKey] = {
-          ALL: `${scopePath}:${ALL_SCOPE}` as Scope,
+          ALL: `${scopePath}:${ALL_SIGN}` as Scope,
         };
 
         processObject(value, currentPath, resultObj[upperKey]);
       }
     });
-  }
+  };
   processObject(ORIGINAL_SCOPE_WITH_ACTION, [], result);
 
   return result as ScopeConstantType;
-}
+};
 
 export const SCOPE = buildScopeConstants();
+
+
+export const isValidScope = (scope: string): boolean => {
+  const scopeParts = scope.split(':').map(x => (x === '*' ? 'ALL' : x.toUpperCase()));
+  let obj: any = SCOPE;
+  scopeParts.forEach((part) => {
+    if (obj[part] == null) {
+      return false;
+    }
+    obj = obj[part];
+  });
+  return obj === scope;
+};
+
+export const isAllScope = (scope: string): scope is Scope => {
+  return scope.endsWith(`:${ALL_SIGN}`);
+};
+
+const getAllScopeValues = (scopeObj: any): Scope[] => {
+  const result: Scope[] = [];
+
+  const traverse = (current: any): void => {
+    if (typeof current !== 'object' || current === null) {
+      if (typeof current === 'string') {
+        result.push(current as Scope);
+      }
+      return;
+    }
+    Object.values(current).forEach((value) => {
+      traverse(value);
+    });
+  };
+  traverse(scopeObj);
+  return result;
+};
+
+export const extractScopes = (scopes?: Scope[]): Scope[] => {
+  if (scopes == null) {
+    return [];
+  }
+  const result = new Set<Scope>(scopes);
+  scopes.forEach((scope) => {
+    if (!isAllScope(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);
+    });
+  });
+  return Array.from(result.values());
+};

+ 7 - 6
apps/app/src/server/models/access-token.ts

@@ -8,6 +8,7 @@ import { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 
+import type { Scope } from '~/interfaces/scope';
 import loggerFactory from '~/utils/logger';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
@@ -20,7 +21,7 @@ type GenerateTokenResult = {
   token: string,
   _id: Types.ObjectId,
   expiredAt: Date,
-  scope?: string[],
+  scope?: Scope[],
   description?: string,
 }
 
@@ -28,7 +29,7 @@ export type IAccessToken = {
   user: Ref<IUserHasId>,
   tokenHash: string,
   expiredAt: Date,
-  scope?: string[],
+  scope?: Scope[],
   description?: string,
 }
 
@@ -37,14 +38,14 @@ export interface IAccessTokenDocument extends IAccessToken, Document {
 }
 
 export interface IAccessTokenModel extends Model<IAccessTokenDocument> {
-  generateToken: (userId: Types.ObjectId | string, expiredAt: Date, scope?: string[], description?: string,) => Promise<GenerateTokenResult>
+  generateToken: (userId: Types.ObjectId | string, expiredAt: Date, scope?: Scope[], description?: string,) => Promise<GenerateTokenResult>
   deleteToken: (token: string) => Promise<void>
   deleteTokenById: (tokenId: Types.ObjectId | string) => Promise<void>
   deleteAllTokensByUserId: (userId: Types.ObjectId | string) => Promise<void>
   deleteExpiredToken: () => Promise<void>
   findUserIdByToken: (token: string) => Promise<HydratedDocument<IAccessTokenDocument> | null>
   findTokenByUserId: (userId: Types.ObjectId | string) => Promise<HydratedDocument<IAccessTokenDocument>[] | null>
-  validateTokenScopes: (token: string, requiredScope: string[]) => Promise<boolean>
+  validateTokenScopes: (token: string, requiredScope: Scope[]) => Promise<boolean>
 }
 
 const accessTokenSchema = new Schema<IAccessTokenDocument, IAccessTokenModel>({
@@ -60,7 +61,7 @@ const accessTokenSchema = new Schema<IAccessTokenDocument, IAccessTokenModel>({
 accessTokenSchema.plugin(mongoosePaginate);
 accessTokenSchema.plugin(uniqueValidator);
 
-accessTokenSchema.statics.generateToken = async function(userId: Types.ObjectId | string, expiredAt: Date, scope?: string[], description?: string) {
+accessTokenSchema.statics.generateToken = async function(userId: Types.ObjectId | string, expiredAt: Date, scope?: Scope[], description?: string) {
 
   const token = crypto.randomBytes(32).toString('hex');
   const tokenHash = generateTokenHash(token);
@@ -110,7 +111,7 @@ accessTokenSchema.statics.findTokenByUserId = async function(userId: Types.Objec
   return this.find({ user: userId, expiredAt: { $gt: now } }).select('_id expiredAt scope description');
 };
 
-accessTokenSchema.statics.validateTokenScopes = async function(token: string, requiredScopes: string[]) {
+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 } });

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

@@ -6,6 +6,8 @@ import type { Request, RequestHandler } from 'express';
 import { body } from 'express-validator';
 
 import { SupportedAction } from '~/interfaces/activity';
+import type { Scope } from '~/interfaces/scope';
+import { extractScopes, isValidScope } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { AccessToken } from '~/server/models/access-token';
@@ -19,7 +21,7 @@ const logger = loggerFactory('growi:routes:apiv3:personal-setting:generate-acces
 type ReqBody = {
   expiredAt: Date,
   description?: string,
-  scope?: string[],
+  scope?: Scope[],
 }
 
 interface GenerateAccessTokenRequest extends Request<undefined, ApiV3Response, ReqBody> {
@@ -60,9 +62,12 @@ const validator = [
     .optional()
     .isArray()
     .withMessage('scope must be an array')
-    .custom(() => {
-      // TODO: Check if all values are valid
-      return true;
+    .custom((value: Scope[]) => {
+      value.forEach((scope) => {
+        if (!isValidScope(scope)) {
+          throw new Error('Invalid scope');
+        }
+      });
     }),
 ];
 
@@ -84,7 +89,7 @@ export const generateAccessTokenHandlerFactory: GenerateAccessTokenHandlerFactor
       const { expiredAt, description, scope } = body;
 
       try {
-        const tokenData = await AccessToken.generateToken(user._id, expiredAt, scope, description);
+        const tokenData = await AccessToken.generateToken(user._id, expiredAt, extractScopes(scope), description);
 
         const parameters = { action: SupportedAction.ACTION_USER_ACCESS_TOKEN_CREATE };
         activityEvent.emit('update', res.locals.activity._id, parameters);