Sfoglia il codice sorgente

Merge pull request #9704 from weseek/feat/85969-85966-access-token-parser-use-access-token-model

feat: Access token parser support access token model
mergify[bot] 1 anno fa
parent
commit
50d8e01c1f

+ 12 - 0
apps/app/public/static/locales/en_US/translation.json

@@ -228,6 +228,15 @@
     },
     "form_help": {}
   },
+  "page_me_access_token": {
+    "access_token": "Access token",
+    "notice": {
+      "access_token_issued": "Access token is not issued.",
+      "update_token1": "You can update to generate a new Access token.",
+      "update_token2": "You will need to update the Access token in any existing processes."
+    },
+    "form_help": {}
+  },
   "Password": "Password",
   "Password Settings": "Password settings",
   "personal_settings": {
@@ -261,6 +270,9 @@
   "API Token Settings": "API token settings",
   "Current API Token": "Current API token",
   "Update API Token": "Update API token",
+  "Access Token Settings": "Access token settings",
+  "Current Access Token": "Current Access token",
+  "Update Access Token": "Update Access token",
   "in_app_notification_settings": {
     "in_app_notification_settings": "In-App Notification Settings",
     "subscribe_settings": "Settings to automatically subscribe (Receive notifications) to pages",

+ 12 - 0
apps/app/public/static/locales/fr_FR/translation.json

@@ -229,6 +229,15 @@
     },
     "form_help": {}
   },
+  "page_me_access_token": {
+    "access_token": "Jeton Access",
+    "notice": {
+      "access_token_issued": "Aucun jeton d'Access existant.",
+      "update_token1": "Un nouveau jeton peut être généré.",
+      "update_token2": "Modifiez le jeton aux endroits où celui-ci est utilisé."
+    },
+    "form_help": {}
+  },
   "Password": "Mot de passe",
   "Password Settings": "Sécurité",
   "personal_settings": {
@@ -262,6 +271,9 @@
   "API Token Settings": "Jetons d'API",
   "Current API Token": "Mon jeton d'API",
   "Update API Token": "Regénérer",
+  "Access Token Settings": "Jetons d'Access",
+  "Current Access Token": "Mon jeton d'Access",
+  "Update Access Token": "Regénérer",
   "in_app_notification_settings": {
     "in_app_notification_settings": "Notifications",
     "subscribe_settings": "Paramètres d'abonnement automatique aux notifications de pages",

+ 12 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -229,6 +229,15 @@
     },
     "form_help": {}
   },
+  "page_me_access_token": {
+    "access_token": "Access token",
+    "notice": {
+      "access_token_issued": "Access token が設定されていません。",
+      "update_token1": "Access token を更新すると、自動的に新しい Token が生成されます。",
+      "update_token2": "現在の Token を利用している処理は動かなくなります。"
+    },
+    "form_help": {}
+  },
   "Password": "パスワード",
   "Password Settings": "パスワード設定",
   "personal_settings": {
@@ -262,6 +271,9 @@
   "API Token Settings": "API Token設定",
   "Current API Token": "現在のAPI Token",
   "Update API Token": "API Tokenを更新",
+  "Access Token Settings": "Access token 設定",
+  "Current Access Token": "現在のAccess token",
+  "Update Access Token": "Access tokenを更新",
   "in_app_notification_settings": {
     "in_app_notification_settings": "アプリ内通知設定",
     "subscribe_settings": "自動でページをサブスクライブする(通知を受け取る)設定",

+ 14 - 1
apps/app/public/static/locales/zh_CN/translation.json

@@ -233,7 +233,17 @@
       "apitoken_issued": "API token 未发布。",
       "update_token1": "您可以更新以生成新的API令牌。",
       "update_token2": "您需要更新任何现有进程中的API令牌。"
-    }
+    },
+    "form_help": {}
+  },
+  "page_me_access_token": {
+    "access_token": "Access token",
+    "notice": {
+      "access_token_issued": "Access token 未发布。",
+      "update_token1": "您可以更新以生成新的API令牌。",
+      "update_token2": "您需要更新任何现有进程中的API令牌。"
+    },
+    "form_help": {}
   },
   "Password": "密码",
   "Password Settings": "密码设置",
@@ -252,6 +262,9 @@
   "API Token Settings": "API token 设置",
   "Current API Token": "当前 API token",
   "Update API Token": "更新 API token",
+  "Access Token Settings": "Access token 设置",
+  "Current Access Token": "当前 Access token",
+  "Update Access Token": "更新 Access token",
   "in_app_notification_settings": {
     "in_app_notification_settings": "在应用程序通知设置",
     "subscribe_settings": "自动订阅(接收通知)页面的设置",

+ 113 - 6
apps/app/src/client/components/Me/ApiSettings.tsx

@@ -2,12 +2,14 @@ import React, { useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
-import { apiv3Put } from '~/client/util/apiv3-client';
+import {
+  apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
+} from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useSWRxPersonalSettings, usePersonalSettings } from '~/stores/personal-settings';
+import { usePersonalSettings, useSWRxPersonalSettings } from '~/stores/personal-settings';
 
 
-const ApiSettings = React.memo((): JSX.Element => {
+const ApiTokenSettings = React.memo((): JSX.Element => {
 
   const { t } = useTranslation();
   const { mutate: mutateDatabaseData } = useSWRxPersonalSettings();
@@ -29,9 +31,6 @@ const ApiSettings = React.memo((): JSX.Element => {
 
   return (
     <>
-
-      <h2 className="border-bottom pb-2 my-4 fs-4">{ t('API Token Settings') }</h2>
-
       <div className="row mb-3">
         <label htmlFor="apiToken" className="col-md-3 text-md-end col-form-label">{t('Current API Token')}</label>
         <div className="col-md-6">
@@ -84,6 +83,114 @@ const ApiSettings = React.memo((): JSX.Element => {
 
   );
 
+
+});
+
+
+/**
+ * TODO: support managing multiple access tokens.
+ */
+const AccessTokenSettings = React.memo((): JSX.Element => {
+
+  const { t } = useTranslation();
+  const [accessToken, setAccessToken] = React.useState<string | null>(null);
+
+  const submitHandler = useCallback(async() => {
+
+    try {
+      await apiv3Delete('/personal-setting/access-token/all');
+      const expiredAt = new Date('2099-12-31T23:59:59.999Z');
+      const result = await apiv3Post('/personal-setting/access-token', { expiredAt });
+      setAccessToken(result.data.token);
+
+      toastSuccess(t('toaster.update_successed', { target: t('page_me_access_token.access_token'), ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }, [t]);
+
+  React.useEffect(() => {
+    const fetchData = async() => {
+      try {
+        const result = await apiv3Get('/personal-setting/access-token');
+        setAccessToken(result.data.accessTokens.length > 0 ? '*******************' : null);
+      }
+      catch (err) {
+        toastError(err);
+      }
+    };
+
+    fetchData();
+  }, []);
+
+  return (
+    <>
+      <div className="row mb-3">
+        <label htmlFor="apiToken" className="col-md-3 text-md-end col-form-label">{t('Current Access Token')}</label>
+        <div className="col-md-6">
+          {accessToken != null
+            ? (
+              <input
+                // data-testid="grw-api-settings-input"
+                // data-vrt-blackout
+                className="form-control"
+                type="text"
+                name="apiToken"
+                value={accessToken}
+                readOnly
+              />
+            )
+            : (
+              <p>
+                { t('page_me_access_token.notice.access_token_issued') }
+              </p>
+            )}
+        </div>
+      </div>
+
+
+      <div className="row">
+        <div className="offset-lg-2 col-lg-7">
+
+          <p className="alert alert-warning">
+            { t('page_me_access_token.notice.update_token1') }<br />
+            { t('page_me_access_token.notice.update_token2') }
+          </p>
+
+        </div>
+      </div>
+
+      <div className="row my-3">
+        <div className="offset-4 col-5">
+          <button
+            // data-testid="grw-api-settings-update-button"
+            type="button"
+            className="btn btn-primary text-nowrap"
+            onClick={submitHandler}
+          >
+            {t('Update Access Token')}
+          </button>
+        </div>
+      </div>
+    </>
+  );
+});
+
+const ApiSettings = React.memo((): JSX.Element => {
+
+  const { t } = useTranslation();
+
+  return (
+    <>
+      <h3 className="border-bottom pb-2 my-4 fs-5">{ t('API Token Settings') }</h3>
+      <ApiTokenSettings />
+
+      <h3 className="border-bottom pb-2 my-4 fs-5">{ t('Access Token Settings') }</h3>
+      <AccessTokenSettings />
+    </>
+  );
 });
 
 ApiSettings.displayName = 'ApiSettings';

+ 6 - 3
apps/app/src/interfaces/activity.ts

@@ -24,7 +24,8 @@ const ACTION_USER_IMAGE_TYPE_UPDATE = 'USER_IMAGE_TYPE_UPDATE';
 const ACTION_USER_LDAP_ACCOUNT_ASSOCIATE = 'USER_LDAP_ACCOUNT_ASSOCIATE';
 const ACTION_USER_LDAP_ACCOUNT_DISCONNECT = 'USER_LDAP_ACCOUNT_DISCONNECT';
 const ACTION_USER_PASSWORD_UPDATE = 'USER_PASSWORD_UPDATE';
-const ACTION_USER_API_TOKEN_UPDATE = 'USER_API_TOKEN_UPDATE';
+const ACTION_USER_ACCESS_TOKEN_CREATE = 'USER_ACCESS_TOKEN_CREATE';
+const ACTION_USER_ACCESS_TOKEN_DELETE = 'USER_ACCESS_TOKEN_DELETE';
 const ACTION_USER_EDITOR_SETTINGS_UPDATE = 'USER_EDITOR_SETTINGS_UPDATE';
 const ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE = 'USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE';
 const ACTION_PAGE_VIEW = 'PAGE_VIEW';
@@ -203,7 +204,8 @@ export const SupportedAction = {
   ACTION_USER_LDAP_ACCOUNT_ASSOCIATE,
   ACTION_USER_LDAP_ACCOUNT_DISCONNECT,
   ACTION_USER_PASSWORD_UPDATE,
-  ACTION_USER_API_TOKEN_UPDATE,
+  ACTION_USER_ACCESS_TOKEN_CREATE,
+  ACTION_USER_ACCESS_TOKEN_DELETE,
   ACTION_USER_EDITOR_SETTINGS_UPDATE,
   ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE,
   ACTION_PAGE_VIEW,
@@ -394,7 +396,8 @@ export const MediumActionGroup = {
   ACTION_USER_LDAP_ACCOUNT_ASSOCIATE,
   ACTION_USER_LDAP_ACCOUNT_DISCONNECT,
   ACTION_USER_PASSWORD_UPDATE,
-  ACTION_USER_API_TOKEN_UPDATE,
+  ACTION_USER_ACCESS_TOKEN_CREATE,
+  ACTION_USER_ACCESS_TOKEN_DELETE,
   ACTION_USER_EDITOR_SETTINGS_UPDATE,
   ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE,
   ACTION_PAGE_LIKE,

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

@@ -5,7 +5,7 @@ import { mock } from 'vitest-mock-extended';
 
 import type Crowi from '~/server/crowi';
 import type UserEvent from '~/server/events/user';
-
+import { AccessToken } from '~/server/models/access-token';
 
 import type { AccessTokenParserReq } from './interfaces';
 
@@ -72,7 +72,7 @@ describe('access-token-parser middleware', () => {
     expect(nextMock).toHaveBeenCalled();
   });
 
-  it('should set req.user with a valid access token in query', async() => {
+  it('should set req.user with a valid api token in query', async() => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
@@ -102,7 +102,7 @@ describe('access-token-parser middleware', () => {
     expect(nextMock).toHaveBeenCalled();
   });
 
-  it('should set req.user with a valid access token in body', async() => {
+  it('should set req.user with a valid api token in body', async() => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
@@ -133,3 +133,84 @@ describe('access-token-parser middleware', () => {
   });
 
 });
+
+
+describe('access-token-parser middleware for access token', () => {
+
+  let User;
+
+  beforeAll(async() => {
+    const crowiMock = mock<Crowi>({
+      event: vi.fn().mockImplementation((eventName) => {
+        if (eventName === 'user') {
+          return mock<UserEvent>({
+            on: vi.fn(),
+          });
+        }
+      }),
+    });
+    const userModelFactory = (await import('../../models/user')).default;
+    User = userModelFactory(crowiMock);
+  });
+
+  it('should set req.user with a valid access token in query', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: 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',
+    });
+
+    // act
+    const { token } = await AccessToken.generateToken(targetUser._id, new Date(Date.now() + 1000 * 60 * 60 * 24), []);
+    reqMock.query.access_token = token;
+    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 set req.user with a valid access token in body', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: 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',
+    });
+
+    // act
+    const { token } = await AccessToken.generateToken(targetUser._id, new Date(Date.now() + 1000 * 60 * 60 * 24), []);
+    reqMock.query.access_token = token;
+    await accessTokenParser(reqMock, resMock, nextMock);
+
+    // assert
+    expect(reqMock.user).toBeDefined();
+    expect(reqMock.user?._id).toStrictEqual(targetUser._id);
+    expect(serializeUserSecurely).toHaveBeenCalledOnce();
+    expect(nextMock).toHaveBeenCalled();
+  });
+
+});

+ 19 - 4
apps/app/src/server/middlewares/access-token-parser/access-token-parser.ts

@@ -4,6 +4,7 @@ import type { NextFunction, Response } from 'express';
 import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 
+import { AccessToken } from '~/server/models/access-token';
 import loggerFactory from '~/utils/logger';
 
 import type { AccessTokenParserReq } from './interfaces';
@@ -18,14 +19,28 @@ export const accessTokenParser = async(req: AccessTokenParserReq, res: Response,
     return next();
   }
 
-  const User = mongoose.model<HydratedDocument<IUser>, { findUserByApiToken }>('User');
-
   logger.debug('accessToken is', accessToken);
 
-  const user: IUserHasId = await User.findUserByApiToken(accessToken);
+  // check the api token is valid
+  const User = mongoose.model<HydratedDocument<IUser>, { findUserByApiToken }>('User');
+  const userByApiToken: IUserHasId = await User.findUserByApiToken(accessToken);
+  if (userByApiToken != null) {
+    req.user = serializeUserSecurely(userByApiToken);
+    logger.debug('API token parsed.');
+    return next();
+  }
+
+  // check the access token is valid
+  const userId = await AccessToken.findUserIdByToken(accessToken);
+  if (userId == null) {
+    logger.debug('The access token is invalid');
+    return next();
+  }
 
+  // check the user is valid
+  const { user }: {user: IUserHasId} = await userId.populate('user');
   if (user == null) {
-    logger.debug('The access token is invalid');
+    logger.debug('The access token\'s associated user is invalid');
     return next();
   }
 

+ 25 - 15
apps/app/src/server/models/access-token.ts

@@ -1,7 +1,9 @@
 import crypto from 'crypto';
 
 import type { Ref, IUserHasId } from '@growi/core/dist/interfaces';
-import type { Document, Model, Types } from 'mongoose';
+import type {
+  Document, Model, Types, HydratedDocument,
+} from 'mongoose';
 import { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
@@ -14,6 +16,14 @@ const logger = loggerFactory('growi:models:access-token');
 
 const generateTokenHash = (token: string) => crypto.createHash('sha256').update(token).digest('hex');
 
+type GenerateTokenResult = {
+  token: string,
+  _id: Types.ObjectId,
+  expiredAt: Date,
+  scope?: string[],
+  description?: string,
+}
+
 export type IAccessToken = {
   user: Ref<IUserHasId>,
   tokenHash: string,
@@ -27,13 +37,13 @@ export interface IAccessTokenDocument extends IAccessToken, Document {
 }
 
 export interface IAccessTokenModel extends Model<IAccessTokenDocument> {
-  generateToken: (userId: Types.ObjectId, expiredAt: Date, scope: string[], description?: string,) => Promise<string>
+  generateToken: (userId: Types.ObjectId | string, expiredAt: Date, scope?: string[], description?: string,) => Promise<GenerateTokenResult>
   deleteToken: (token: string) => Promise<void>
-  deleteTokenById: (tokenId: Types.ObjectId) => Promise<void>
-  deleteAllTokensByUserId: (userId: Types.ObjectId) => Promise<void>
+  deleteTokenById: (tokenId: Types.ObjectId | string) => Promise<void>
+  deleteAllTokensByUserId: (userId: Types.ObjectId | string) => Promise<void>
   deleteExpiredToken: () => Promise<void>
-  findUserIdByToken: (token: string) => Promise<Types.ObjectId>
-  findTokenByUserId: (userId: Types.ObjectId) => Promise<IAccessTokenDocument[]>
+  findUserIdByToken: (token: string) => Promise<HydratedDocument<IAccessTokenDocument> | null>
+  findTokenByUserId: (userId: Types.ObjectId | string) => Promise<HydratedDocument<IAccessTokenDocument>[] | null>
   validateTokenScopes: (token: string, requiredScope: string[]) => Promise<boolean>
 }
 
@@ -50,7 +60,7 @@ const accessTokenSchema = new Schema<IAccessTokenDocument, IAccessTokenModel>({
 accessTokenSchema.plugin(mongoosePaginate);
 accessTokenSchema.plugin(uniqueValidator);
 
-accessTokenSchema.statics.generateToken = async function(userId: Types.ObjectId, expiredAt: Date, scope?: string[], description?: string) {
+accessTokenSchema.statics.generateToken = async function(userId: Types.ObjectId | string, expiredAt: Date, scope?: string[], description?: string) {
 
   const token = crypto.randomBytes(32).toString('hex');
   const tokenHash = generateTokenHash(token);
@@ -73,20 +83,20 @@ accessTokenSchema.statics.generateToken = async function(userId: Types.ObjectId,
 
 accessTokenSchema.statics.deleteToken = async function(token: string) {
   const tokenHash = generateTokenHash(token);
-  return this.deleteOne({ tokenHash });
+  await this.deleteOne({ tokenHash });
 };
 
-accessTokenSchema.statics.deleteTokenById = async function(tokenId: Types.ObjectId) {
-  return this.deleteOne({ _id: tokenId });
+accessTokenSchema.statics.deleteTokenById = async function(tokenId: Types.ObjectId | string) {
+  await this.deleteOne({ _id: tokenId });
 };
 
-accessTokenSchema.statics.deleteAllTokensByUserId = async function(userId: Types.ObjectId) {
-  return this.deleteMany({ user: userId });
+accessTokenSchema.statics.deleteAllTokensByUserId = async function(userId: Types.ObjectId | string) {
+  await this.deleteMany({ user: userId });
 };
 
 accessTokenSchema.statics.deleteExpiredToken = async function() {
   const now = new Date();
-  return this.deleteMany({ expiredAt: { $lte: now } });
+  await this.deleteMany({ expiredAt: { $lte: now } });
 };
 
 accessTokenSchema.statics.findUserIdByToken = async function(token: string) {
@@ -95,7 +105,7 @@ accessTokenSchema.statics.findUserIdByToken = async function(token: string) {
   return this.findOne({ tokenHash, expiredAt: { $gt: now } }).select('user');
 };
 
-accessTokenSchema.statics.findTokenByUserId = async function(userId: Types.ObjectId) {
+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');
 };
@@ -111,4 +121,4 @@ accessTokenSchema.methods.isExpired = function() {
   return this.expiredAt < new Date();
 };
 
-export const AccessToken = getOrCreateModel<IAccessTokenDocument, IAccessToken>('AccessToken', accessTokenSchema);
+export const AccessToken = getOrCreateModel<IAccessTokenDocument, IAccessTokenModel>('AccessToken', accessTokenSchema);

+ 62 - 0
apps/app/src/server/routes/apiv3/personal-setting/delete-access-token.ts

@@ -0,0 +1,62 @@
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import { query } from 'express-validator';
+
+import { SupportedAction } from '~/interfaces/activity';
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import { AccessToken } from '~/server/models/access-token';
+import loggerFactory from '~/utils/logger';
+
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:personal-setting:generate-access-tokens');
+
+type ReqQuery = {
+  tokenId: string,
+}
+
+type DeleteAccessTokenRequest = Request<undefined, ApiV3Response, undefined, ReqQuery>;
+
+type DeleteAccessTokenHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+const validator = [
+  query('tokenId')
+    .exists()
+    .withMessage('tokenId is required')
+    .isString()
+    .withMessage('tokenId must be a string'),
+];
+
+export const deleteAccessTokenHandlersFactory: DeleteAccessTokenHandlersFactory = (crowi) => {
+
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const addActivity = generateAddActivityMiddleware();
+  const activityEvent = crowi.event('activity');
+
+  return [
+    accessTokenParser,
+    loginRequiredStrictly,
+    addActivity,
+    validator,
+    apiV3FormValidator,
+    async(req: DeleteAccessTokenRequest, res: ApiV3Response) => {
+      const { query } = req;
+      const { tokenId } = query;
+
+      try {
+        await AccessToken.deleteTokenById(tokenId);
+
+        const parameters = { action: SupportedAction.ACTION_USER_ACCESS_TOKEN_DELETE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({});
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3(err.toString(), 'delete-access-token-failed'));
+      }
+    }];
+};

+ 47 - 0
apps/app/src/server/routes/apiv3/personal-setting/delete-all-access-tokens.ts

@@ -0,0 +1,47 @@
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+
+import { SupportedAction } from '~/interfaces/activity';
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { AccessToken } from '~/server/models/access-token';
+import loggerFactory from '~/utils/logger';
+
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:personal-setting:generate-access-tokens');
+
+interface DeleteAllAccessTokensRequest extends Request<undefined, ApiV3Response, undefined> {
+  user: IUserHasId,
+}
+
+type DeleteAllAccessTokensHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+export const deleteAllAccessTokensHandlersFactory: DeleteAllAccessTokensHandlersFactory = (crowi) => {
+
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const addActivity = generateAddActivityMiddleware();
+  const activityEvent = crowi.event('activity');
+
+  return [
+    accessTokenParser, loginRequiredStrictly,
+    addActivity,
+    async(req: DeleteAllAccessTokensRequest, res: ApiV3Response) => {
+      const { user } = req;
+
+      try {
+        await AccessToken.deleteAllTokensByUserId(user._id);
+
+        const parameters = { action: SupportedAction.ACTION_USER_ACCESS_TOKEN_DELETE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({});
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3(err.toString(), 'delete-all-access-token-failed'));
+      }
+    }];
+};

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

@@ -0,0 +1,100 @@
+import type {
+  IUserHasId,
+} from '@growi/core/dist/interfaces';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import { body } from 'express-validator';
+
+import { SupportedAction } from '~/interfaces/activity';
+import type Crowi from '~/server/crowi';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { AccessToken } from '~/server/models/access-token';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:personal-setting:generate-access-tokens');
+
+type ReqBody = {
+  expiredAt: Date,
+  description?: string,
+  scope?: string[],
+}
+
+interface GenerateAccessTokenRequest extends Request<undefined, ApiV3Response, ReqBody> {
+  user: IUserHasId,
+}
+
+type GenerateAccessTokenHandlerFactory = (crowi: Crowi) => RequestHandler[];
+
+const validator = [
+  body('expiredAt')
+    .exists()
+    .withMessage('expiredAt is required')
+    .custom((value) => {
+      const expiredAt = new Date(value);
+      const now = new Date();
+
+      // Check if date is valid
+      if (Number.isNaN(expiredAt.getTime())) {
+        throw new Error('Invalid date format');
+      }
+
+      // Check if date is in the future
+      if (expiredAt <= now) {
+        throw new Error('Expiration date must be in the future');
+      }
+
+      return true;
+    }),
+
+  body('description')
+    .optional()
+    .isString()
+    .withMessage('description must be a string')
+    .isLength({ max: 200 })
+    .withMessage('description must be less than or equal to 200 characters'),
+
+  body('scope')
+    .optional()
+    .isArray()
+    .withMessage('scope must be an array')
+    .custom(() => {
+      // TODO: Check if all values are valid
+      return true;
+    }),
+];
+
+export const generateAccessTokenHandlerFactory: GenerateAccessTokenHandlerFactory = (crowi) => {
+
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const activityEvent = crowi.event('activity');
+  const addActivity = generateAddActivityMiddleware();
+
+
+  return [
+    loginRequiredStrictly,
+    addActivity,
+    validator,
+    apiV3FormValidator,
+    async(req: GenerateAccessTokenRequest, res: ApiV3Response) => {
+
+      const { user, body } = req;
+      const { expiredAt, description, scope } = body;
+
+      try {
+        const tokenData = await AccessToken.generateToken(user._id, expiredAt, scope, description);
+
+        const parameters = { action: SupportedAction.ACTION_USER_ACCESS_TOKEN_CREATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3(tokenData);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3(err.toString(), 'generate-access-token-failed'));
+      }
+    },
+  ];
+};

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

@@ -0,0 +1,42 @@
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { AccessToken } from '~/server/models/access-token';
+import loggerFactory from '~/utils/logger';
+
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:personal-setting:get-access-tokens');
+
+interface GetAccessTokenRequest extends Request<undefined, ApiV3Response, undefined> {
+  user: IUserHasId,
+}
+
+type GetAccessTokenHandlerFactory = (crowi: Crowi) => RequestHandler[];
+
+export const getAccessTokenHandlerFactory: GetAccessTokenHandlerFactory = (crowi) => {
+
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const addActivity = generateAddActivityMiddleware();
+
+  return [
+    accessTokenParser, loginRequiredStrictly,
+    addActivity,
+    async(req: GetAccessTokenRequest, res: ApiV3Response) => {
+      const { user } = req;
+
+      try {
+        const accessTokens = await AccessToken.findTokenByUserId(user._id);
+        return res.apiv3({ accessTokens });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3(err.toString(), 'colud_not_get_access_token'));
+      }
+    },
+  ];
+};

+ 94 - 6
apps/app/src/server/routes/apiv3/personal-setting.js → apps/app/src/server/routes/apiv3/personal-setting/index.js

@@ -8,11 +8,16 @@ import { SupportedAction } from '~/interfaces/activity';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import loggerFactory from '~/utils/logger';
 
-import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
-import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-import EditorSettings from '../../models/editor-settings';
-import ExternalAccount from '../../models/external-account';
-import InAppNotificationSettings from '../../models/in-app-notification-settings';
+import { generateAddActivityMiddleware } from '../../../middlewares/add-activity';
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import EditorSettings from '../../../models/editor-settings';
+import ExternalAccount from '../../../models/external-account';
+import InAppNotificationSettings from '../../../models/in-app-notification-settings';
+
+import { deleteAccessTokenHandlersFactory } from './delete-access-token';
+import { deleteAllAccessTokensHandlersFactory } from './delete-all-access-tokens';
+import { generateAccessTokenHandlerFactory } from './generate-access-token';
+import { getAccessTokenHandlerFactory } from './get-access-tokens';
 
 
 const logger = loggerFactory('growi:routes:apiv3:personal-setting');
@@ -68,7 +73,7 @@ const router = express.Router();
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const { User } = crowi.models;
@@ -414,6 +419,89 @@ module.exports = (crowi) => {
 
   });
 
+  /**
+   * @swagger
+   *   /personal-setting/access-token:
+   *     get:
+   *       tags: [GeneralSetting]
+   *       operationId: getAccessToken
+   *       summary: /personal-setting/access-token
+   *       description: Get access token
+   *       responses:
+   *         200:
+   *           description: succded to get access token
+   *           content:
+   *           application/json:
+   *             schema:
+   *               properties:
+   *                 accessTokens:
+   *                   type: objet
+   *                   description: array of access tokens
+   */
+  router.get('/access-token', getAccessTokenHandlerFactory(crowi));
+
+  /**
+   * @swagger
+   *   /personal-setting/access-token:
+   *     post:
+   *       tags: [GeneralSetting]
+   *       operationId: generateccessToken
+   *       summary: /personal-setting/access-token
+   *       description: Generate access token
+   *       responses:
+   *         200:
+   *           description: succeded to create access token
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 properties:
+   *                   _id:
+   *                     type: string
+   *                     description: id of access token
+   *                   token:
+   *                     type: string
+   *                     description: access token
+   *                   expiredAt:
+   *                     type: Date
+   *                     description: expired date
+   *                   description:
+   *                     type: string
+   *                     description: description of access token
+   *                   scope:
+   *                     type: string[]
+   *                     description: scope of access token
+   */
+  router.post('/access-token', generateAccessTokenHandlerFactory(crowi));
+
+  /**
+   * @swagger
+   *   /personal-setting/access-token/:
+   *     delete:
+   *     tags: [GeneralSetting]
+   *     operationId: deleteAccessToken
+   *     summary: /personal-setting/access-token
+   *     description: Delete access token
+   *     responses:
+   *       200:
+   *         description: succeded to delete access token
+   *
+   */
+  router.delete('/access-token', deleteAccessTokenHandlersFactory(crowi));
+
+  /**
+   * @swagger
+   *   /personal-setting/access-token/all:
+   *     delete:
+   *       tags: [GeneralSetting]
+   *       operationId: deleteAllAccessToken
+   *       summary: /personal-setting/access-token/all
+   *       description: Delete all access tokens
+   *       responses:
+   *         200:
+   *           description: succeded to delete all access tokens
+   */
+  router.delete('/access-token/all', deleteAllAccessTokensHandlersFactory(crowi));
+
   /**
    * @swagger
    *