RuoYiフレームワーク:アーキテクチャ、認証メカニズム、およびカスタムモジュールの拡張

RuoYiフレームワークの概要

RuoYi(若依)フレームワークは、バックエンドとフロントエンドが分離された設計を採用しており、それぞれの開発に最適な技術スタックを活用しています。主要な技術要素とディレクトリ構造は以下の通りです。

ファイル構造

  • バックエンド構造: 主にJavaとSpring Bootを中心としたモジュール構成です。
  • フロントエンド構造: Vue.jsを基盤とし、コンポーネント指向で構築されています。

主要技術スタック

本フレームワークは、効率的な開発と堅牢なセキュリティを実現するために、以下の技術を採用しています。

フロントエンド技術

  • ES6
  • Vue.js
  • Vuex (状態管理)
  • Vue Router (ルーティング)
  • Vue CLI (プロジェクト構築)
  • Axios (HTTPクライアント)
  • Element UI (UIコンポーネントライブラリ)

バックエンド技術

  • Spring Boot
  • MyBatis (ORMフレームワーク)
  • Spring Security (セキュリティフレームワーク)
  • JWT (JSON Web Token)

Spring Bootフレームワーク

Spring Bootは、Springベースのアプリケーション開発を簡素化するためのフレームワークです。設定よりも規約を重視し、「すぐに使える」設計思想で、アプリケーションの迅速な立ち上げとデプロイを可能にします。組み込みのWebサーバー(Tomcat、Jetty、Undertowなど)により、WARファイルとしてデプロイすることなく、スタンドアロンなJARファイルとしてアプリケーションを実行できます。

注意点: Spring Boot 2.xバージョンでは、JDK 8以上、Tomcat 8以上の環境が推奨されます。

利点:

  • コーディングの簡素化: アノテーションを活用することで、定型的なコード量を削減します。
  • 設定の簡素化: 自動構成機能により、新しい技術との統合が容易です。冗長なXML設定は不要です。
  • デプロイの簡素化: 組み込みWebサーバーにより、JARファイルとして簡単にデプロイできます。
  • 監視の簡素化: アプリケーションの実行時監視機能を提供します。
  • 統合の簡素化: 主要な開発フレームワークとの統合がスムーズに行えます。
  • 開発速度の向上: プロジェクトの構築とデプロイの効率を大幅に向上させます。

Spring Securityによるセキュリティ制御

Spring Securityは、Springベースのエンタープライズアプリケーションに対して宣言的なセキュリティアクセス制御を提供する強力なフレームワークです。

主な機能:

  • 認証 (Authentication): ユーザーの本人確認(ログイン処理)を行います。
  • 認可 (Authorization): 認証されたユーザーがどのリソースにアクセスできるか、どのような操作を実行できるかを判断します。
  • セキュリティ保護: クロスサイトスクリプティング (XSS) やセッションハイジャックなどの一般的なWeb攻撃からアプリケーションを保護します。
  • Springとの容易な連携: Springエコシステムとの統合が非常にスムーズです。

認証機能の詳細

検証コード(Captcha)機能

RuoYiにおける検証コード機能は、バックエンドで生成され、ログインページがロードされると同時にバックエンドへリクエストが送信されます。

フロー概要:

  1. ログインページがロードされると、検証コード取得用のAPIエンドポイントにリクエストを送信します。
  2. バックエンドサーバーは、例えば「1+3=?@4」のような計算式を生成します。
  3. バックエンドは「@」で分割し、「1+3=?」をフロントエンドに返します。同時に、計算結果である「4」をRedisに2分間の有効期限で保存します。これにより、有効期限内であれば何度でもこの結果を利用できます。

フロントエンドからのリクエスト例: http://localhost/dev-api/captchaImage (リバースプロキシにより、最終的にはhttp://localhost:8080/captchaImageに転送されます)。

バックエンド処理例 (CaptchaController.java):

package com.ruoyi.web.controller.common;

import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
import javax.annotation.Resource;
import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.FastByteArrayOutputStream;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import com.google.code.kaptcha.Producer;
import com.ruoyi.common.config.RuoYiConfig;
import com.ruoyi.common.constant.CacheConstants;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.utils.sign.Base64;
import com.ruoyi.common.utils.uuid.IdUtils;
import com.ruoyi.system.service.ISysConfigService;

/**
 * 検証コードの操作処理を行うコントローラー
 */
@RestController
public class CaptchaController
{
    @Resource(name = "captchaProducer")
    private Producer captchaProducer; // 文字型検証コード生成用

    @Resource(name = "captchaProducerMath")
    private Producer captchaProducerMath; // 数学式検証コード生成用

    @Autowired
    private RedisCache redisCache; // Redisキャッシュサービス

    @Autowired
    private ISysConfigService configService; // システム設定サービス

    /**
     * 検証コードを生成する
     */
    @GetMapping("/captchaImage")
    public AjaxResult getCode(HttpServletResponse response) throws IOException
    {
        AjaxResult ajax = AjaxResult.success();
        boolean captchaEnabled = configService.selectCaptchaEnabled(); // 検証コード機能が有効か確認
        ajax.put("captchaEnabled", captchaEnabled);
        if (!captchaEnabled)
        {
            return ajax; // 無効であれば検証コードなしで成功を返す
        }

        // 検証コード用のランダムなUUIDを生成
        String uuid = IdUtils.simpleUUID();
        // Redisに保存するためのキーを生成
        String verifyKey = CacheConstants.CAPTCHA_CODE_KEY + uuid;

        String capStr = null, code = null;
        BufferedImage image = null;

        // 検証コードのタイプ(数学式または文字式)に基づいて生成
        String captchaType = RuoYiConfig.getCaptchaType();
        if ("math".equals(captchaType))
        {
            // 例: "4+3=?@7" のような数学式を生成
            String capText = captchaProducerMath.createText();
            capStr = capText.substring(0, capText.lastIndexOf("@")); // "4+3=?" 部分を抽出
            code = capText.substring(capText.lastIndexOf("@") + 1); // "7" の結果部分を抽出
            image = captchaProducerMath.createImage(capStr);
        }
        else if ("char".equals(captchaType))
        {
            capStr = code = captchaProducer.createText(); // 文字式を生成
            image = captchaProducer.createImage(capStr);
        }

        // 生成された検証コードの答え(例: "7")をRedisにキー付きで保存し、2分間有効とする
        redisCache.setCacheObject(verifyKey, code, Constants.CAPTCHA_EXPIRATION, TimeUnit.MINUTES);
        
        // 画像をバイト配列に変換して書き出す
        FastByteArrayOutputStream os = new FastByteArrayOutputStream();
        try
        {
            ImageIO.write(image, "jpg", os);
        }
        catch (IOException e)
        {
            return AjaxResult.error(e.getMessage());
        }

        ajax.put("uuid", uuid); // UUIDをレスポンスに追加
        ajax.put("img", Base64.encode(os.toByteArray())); // Base64エンコードされた画像データをレスポンスに追加
        return ajax;
    }
}

フロントエンドのAPIリクエスト処理

フロントエンドからのAPIリクエストは、apiフォルダ内のファイルにカプセル化され、utils/request.js(Axiosのラッパー)を介して非同期で送信されます。baseURLの値は、環境設定ファイル(例: .env.development)から動的に取得されます。

ログインページでの検証コードリクエスト

ログインページ(login.vue)がロードされると、created()ライフサイクルフックでgetCode()メソッドが呼び出され、検証コードの取得がトリガーされます。これにより、画面に検証コード画像が表示されます。

<template>
  <div class="login">
    <!-- ログインフォームコンポーネント -->
    <el-form ref="loginForm" :model="loginForm" :rules="loginFormRules" class="login-form">
      <!-- システムタイトル -->
      <h3 class="title">RuoYi バックエンド管理システム</h3>

      <!-- ユーザー名入力欄 -->
      <el-form-item prop="username">
        <el-input
          v-model="loginForm.username"
          type="text"
          auto-complete="off"
          placeholder="アカウント"
        >
          <!-- ユーザーアイコン -->
          <svg-icon slot="prefix" icon-class="user" class="el-input__icon input-icon" />
        </el-input>
      </el-form-item>

      <!-- パスワード入力欄 -->
      <el-form-item prop="password">
        <el-input
          v-model="loginForm.password"
          type="password"
          auto-complete="off"
          placeholder="パスワード"
          @keyup.enter.native="handleLogin"
        >
          <!-- パスワードアイコン -->
          <svg-icon slot="prefix" icon-class="password" class="el-input__icon input-icon" />
        </el-input>
      </el-form-item>

      <!-- 検証コード入力欄 (captchaEnabledがtrueの場合のみ表示) -->
      <el-form-item prop="code" v-if="captchaEnabled">
        <el-input
          v-model="loginForm.code"
          auto-complete="off"
          placeholder="検証コード"
          style="width: 63%"
          @keyup.enter.native="handleLogin"
        >
          <!-- 検証コードアイコン -->
          <svg-icon slot="prefix" icon-class="validCode" class="el-input__icon input-icon" />
        </el-input>

        <!-- 検証コード画像、クリックで更新 -->
        <div class="login-code">
          <img :src="codeUrl" @click="getCode" class="login-code-img" />
        </div>
      </el-form-item>

      <!-- ログイン状態を保持するチェックボックス -->
      <el-checkbox v-model="loginForm.rememberMe" style="margin:0px 0px 25px 0px;">ログイン状態を保持</el-checkbox>

      <!-- ログインボタン -->
      <el-form-item style="width:100%;">
        <el-button
          :loading="loading"
          size="medium"
          type="primary"
          style="width:100%;"
          @click.native.prevent="handleLogin"
        >
          <!-- ロード状態に応じてテキストを切り替え -->
          <span v-if="!loading">ログイン</span>
          <span v-else>ログイン中...</span>
        </el-button>

        <!-- 登録リンク (registerがtrueの場合のみ表示) -->
        <div style="float: right;" v-if="register">
          <router-link class="link-type" :to="'/register'">今すぐ登録</router-link>
        </div>
      </el-form-item>
    </el-form>

    <!-- 著作権情報 -->
    <div class="el-login-footer">
      <span>Copyright © 2018-2024 ruoyi.vip All Rights Reserved.</span>
    </div>
  </div>
</template>

<script>
import { getCodeImg } from "@/api/login";
import Cookies from "js-cookie";
import { encrypt, decrypt } from '@/utils/jsencrypt'

export default {
  name: "Login",
  data() {
    return {
      // 検証コード画像のURL
      codeUrl: "",

      // ログインフォームのデータモデル
      loginForm: {
        username: "admin",
        password: "admin123",
        rememberMe: false,
        code: "",
        uuid: ""
      },

      // ログインフォームの検証ルール
      loginFormRules: {
        username: [
          { required: true, trigger: "blur", message: "アカウントを入力してください" }
        ],
        password: [
          { required: true, trigger: "blur", message: "パスワードを入力してください" }
        ],
        code: [{ required: true, trigger: "change", message: "検証コードを入力してください" }]
      },

      // ログインボタンのローディング状態
      loading: false,

      // 検証コード機能の有効/無効
      captchaEnabled: true,

      // 登録機能の有効/無効
      register: false,

      // リダイレクトパス
      redirect: undefined
    };
  },

  // ルート変更を監視し、リダイレクトパラメータを取得
  watch: {
    $route: {
      handler(route) {
        this.redirect = route.query && route.query.redirect;
      },
      immediate: true
    }
  },

  // コンポーネント作成時に実行
  created() {
    // 検証コードを取得し、フォームの状態を初期化
    this.getCode();
    this.getCookie();
  },

  methods: {
    // 検証コードを取得する
    getCode() {
      getCodeImg().then(res => {
        this.captchaEnabled = res.captchaEnabled === undefined ? true : res.captchaEnabled;
        if (this.captchaEnabled) {
          this.codeUrl = "data:image/gif;base64," + res.img; // 画像データをURLとして設定
          this.loginForm.uuid = res.uuid; // UUIDを設定
        }
      });
    },

    // Cookieからログイン情報を復元する
    getCookie() {
      const username = Cookies.get("username");
      const password = Cookies.get("password");
      const rememberMe = Cookies.get('rememberMe')
      this.loginForm = {
        username: username === undefined ? this.loginForm.username : username,
        password: password === undefined ? this.loginForm.password : decrypt(password),
        rememberMe: rememberMe === undefined ? false : Boolean(rememberMe)
      };
    },

    // ログイン処理を実行する
    handleLogin() {
      // フォームのバリデーション
      this.$refs.loginForm.validate(valid => {
        if (valid) {
          this.loading = true;

          // 「ログイン状態を保持」機能の処理
          if (this.loginForm.rememberMe) {
            Cookies.set("username", this.loginForm.username, { expires: 30 });
            Cookies.set("password", encrypt(this.loginForm.password), { expires: 30 });
            Cookies.set('rememberMe', this.loginForm.rememberMe, { expires: 30 });
          } else {
            Cookies.remove("username");
            Cookies.remove("password");
            Cookies.remove('rememberMe');
          }

          // ログインリクエストを送信
          this.$store.dispatch("Login", this.loginForm).then(() => {
            // 成功後、リダイレクトパスまたはホームへ遷移
            this.$router.push({ path: this.redirect || "/" }).catch(()=>{});
          }).catch(() => {
            // ログイン失敗時、ローディング状態をリセットし、検証コードを再取得
            this.loading = false;
            if (this.captchaEnabled) {
              this.getCode();
            }
          });
        }
      });
    }
  }
};
</script>

getCodeImg()は、api/login.jsで定義されたAPI呼び出し関数です。

import request from '@/utils/request'

// ログインメソッド
export function login(username, password, code, uuid) {
  const data = {
    username,
    password,
    code,
    uuid
  }
  return request({
    url: '/login',
    headers: {
      isToken: false,
      repeatSubmit: false
    },
    method: 'post',
    data: data
  })
}

// 登録メソッド
export function register(data) {
  return request({
    url: '/register',
    headers: {
      isToken: false
    },
    method: 'post',
    data: data
  })
}

// ユーザー詳細情報を取得
export function getInfo() {
  return request({
    url: '/getInfo',
    method: 'get'
  })
}

// ログアウトメソッド
export function logout() {
  return request({
    url: '/logout',
    method: 'post'
  })
}

// 検証コードを取得
export function getCodeImg() {
  return request({
    url: '/captchaImage',
    headers: {
      isToken: false
    },
    method: 'get',
    timeout: 20000
  })
}

Axiosプロキシ設定

フロントエンドからhttp://localhost/dev-api/captchaImageのようなパスでリクエストが送信されますが、これはバックエンドの8080ポートではなく、フロントエンドのWebサーバーの80ポートに直接送信されます。これは、フロントエンドで設定されたプロキシサービスによってバックエンドAPIに転送されるためです。この仕組みは、クロスオリジン(CORS)問題を解決するのに役立ちます。

vue.config.jsファイルでプロキシ設定が行われます。例えば、以下の変換ルールが適用されます。

  1. /dev-apiを含むパス(例: http://localhost/dev-api/captchaImage)を、まずターゲットURL(例: http://localhost:8080)と結合します。→ http://localhost:8080/dev-api/captchaImage
  2. パスのリライトルールによって、/dev-api部分が削除されます。→ http://localhost:8080/captchaImage
  3. 最終的に、リライトされたURLがバックエンドAPIにアクセスします。

ログインプロセス

ログイン処理は、フロントエンドのlogin.vueコンポーネントからVuexのLoginアクションを介して開始され、APIサービス内のloginメソッドを呼び出してログインリクエストを送信します。バックエンドは、ユーザー名、パスワード、検証コード、UUIDなどの情報を受け取り、以下のステップで処理します。

バックエンドでのログイン処理 (SysLoginService.java):

package com.ruoyi.framework.web.service;

import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.exception.user.CaptchaException;
import com.ruoyi.common.exception.user.CaptchaExpireException;
import com.ruoyi.common.exception.user.UserPasswordNotMatchException;
import com.ruoyi.common.utils.MessageUtils;
import com.ruoyi.framework.manager.AsyncManager;
import com.ruoyi.framework.manager.factory.AsyncFactory;
import javax.annotation.Resource;

/**
 * ログイン検証サービス
 */
@Component
public class SysLoginService
{
    @Resource
    private TokenService tokenService; // トークンサービス

    @Resource
    private AuthenticationManager authenticationManager; // 認証マネージャー

    @Resource
    private RedisCache redisCache; // Redisキャッシュ

    @Resource
    private ISysConfigService configService; // システム設定サービス

    @Resource
    private SysPasswordService passwordService; // パスワードサービス

    /**
     * ログイン検証を実行する
     *
     * @param username ユーザー名
     * @param password パスワード
     * @param code 検証コード
     * @param uuid 一意の識別子
     * @return 生成されたトークン
     */
    public String login(String username, String password, String code, String uuid)
    {
        // 検証コードのチェック
        validateCaptcha(username, code, uuid);
        // ログイン事前チェック
        passwordService.loginPreCheck(username, password);
        
        // ユーザー認証
        Authentication authentication = null;
        try
        {
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
            // このメソッドはUserDetailsServiceImpl.loadUserByUsernameを呼び出す
            authentication = authenticationManager.authenticate(authenticationToken);
        }
        catch (Exception e)
        {
            if (e instanceof BadCredentialsException)
            {
                // ログイン失敗ログを記録(非同期処理)
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
                throw new UserPasswordNotMatchException(); // パスワード不一致例外
            }
            else
            {
                // その他の例外
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
                throw new ServiceException(e.getMessage());
            }
        }
        finally
        {
            // 認証コンテキストをクリア
            AuthenticationContextHolder.clearContext();
        }
        // ログイン成功ログを記録(非同期処理)
        AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
        LoginUser loginUser = (LoginUser) authentication.getPrincipal(); // ログインユーザー情報を取得
        passwordService.recordLoginInfo(loginUser.getUserId()); // ログイン情報を記録
        // トークンを生成
        return tokenService.createToken(loginUser);
    }

    /**
     * 検証コードの有効性を検証する
     * (実際のコードは省略され、SysLoginService内に`validateCaptcha`メソッドとして存在します)
     */
    private void validateCaptcha(String username, String code, String uuid) {
        // ... 検証コードの検証ロジック ...
        // 例: RedisからUUIDに対応する検証コードを取得し、ユーザー入力と比較
        if (configService.selectCaptchaEnabled()) {
            String captcha = redisCache.getCacheObject(CacheConstants.CAPTCHA_CODE_KEY + uuid);
            if (captcha == null) {
                throw new CaptchaExpireException(); // 検証コードの有効期限切れ
            }
            if (!code.equalsIgnoreCase(captcha)) {
                throw new CaptchaException(); // 検証コードが不正
            }
        }
    }
}

上記のauthenticationManager.authenticateメソッドは、UserDetailsServiceImpl.loadUserByUsernameを呼び出してユーザー認証を行います。

ユーザー詳細サービスの処理 (UserDetailsServiceImpl.java):

package com.ruoyi.framework.web.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.enums.UserStatus;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.MessageUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.system.service.ISysUserService;

/**
 * ユーザー検証処理サービス実装クラス
 *
 * <p>このクラスは {@link UserDetailsService} インターフェースを実装し、ユーザー名に基づいてユーザー情報をロードします。
 * ユーザーが安全にシステムにログインできるよう、一連の認証と権限付与操作を担当します。
 */
@Service
public class UserDetailsServiceImpl implements UserDetailsService
{
    private static final Logger log = LoggerFactory.getLogger(UserDetailsServiceImpl.class); // ロガー

    @Autowired
    private ISysUserService userService; // ユーザーサービス

    @Autowired
    private SysPasswordService passwordService; // パスワードサービス

    @Autowired
    private SysPermissionService permissionService; // 権限サービス

    /**
     * {@inheritDoc}
     * <p>
     * ユーザー名に基づいてユーザー詳細情報をロードし、Spring Securityフレームワークに必要な{@link UserDetails}オブジェクトにカプセル化します。
     * このプロセスでは、以下のステップが実行されます。
     * <ol>
     *   <li>ユーザーサービスを通じてデータベースを照会し、対応するユーザー名のユーザー情報を取得します。</li>
     *   <li>ユーザーが存在しない、削除されている、または無効になっているかを確認します。該当する場合、例外をスローしログを記録します。</li>
     *   <li>パスワードサービスを使用してユーザーパスワードの有効性を検証します。</li>
     *   <li>検証済みのユーザー情報とユーザーが持つメニュー権限を組み合わせて、完全な識別情報を含む{@link LoginUser}オブジェクトを作成します。</li>
     * </ol>
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
    {
        // ユーザー情報を照会
        SysUser user = userService.selectUserByUserName(username);

        // ユーザーが存在しない、または削除されている場合
        if (StringUtils.isNull(user) || UserStatus.DELETED.getCode().equals(user.getDelFlag()))
        {
            log.info("ログインユーザー:{} は存在しないか削除されています。", username);
            throw new ServiceException(MessageUtils.message("user.not.exists"));
        }
        // ユーザーが無効になっている場合
        else if (UserStatus.DISABLE.getCode().equals(user.getStatus()))
        {
            log.info("ログインユーザー:{} は無効にされています。", username);
            throw new ServiceException(MessageUtils.message("user.blocked"));
        }

        // ユーザーパスワードを検証
        passwordService.validate(user);

        // 権限情報を含むログインユーザーオブジェクトを作成
        return createLoginUser(user);
    }

    /**
     * システムユーザー情報をログインユーザーオブジェクト(権限情報を含む)に変換する
     *
     * @param user システムユーザー情報
     * @return ログインユーザーオブジェクト (ユーザーID、部門ID、ユーザー基本情報、メニュー権限リストを含む)
     */
    public LoginUser createLoginUser(SysUser user)
    {
        return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
    }
}

createLoginUserメソッドでは、permissionService.getMenuPermission(user)を呼び出して、ログインユーザーが持つすべてのメニュー権限を取得します。

メニューデータ権限の取得 (SysPermissionService.java):

package com.ruoyi.framework.web.service;

import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import com.ruoyi.common.core.domain.entity.SysRole;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.system.service.ISysMenuService;

/**
 * 権限処理サービス
 */
@Service
public class SysPermissionService
{
    @Autowired
    private ISysMenuService menuService; // メニューサービス

    /**
     * メニューデータ権限を取得する
     *
     * @param user ユーザー情報
     * @return メニュー権限情報
     */
    public Set<String> getMenuPermission(SysUser user)
    {
        Set<String> perms = new HashSet<>();
        // 管理者はすべての権限を持つ
        if (user.isAdmin())
        {
            perms.add("*:*:*");
        }
        else
        {
            List<SysRole> roles = user.getRoles();
            if (!CollectionUtils.isEmpty(roles))
            {
                // 複数の役割が設定されている場合、データ権限マッチングのためにpermissions属性を設定
                for (SysRole role : roles)
                {
                    Set<String> rolePerms = menuService.selectMenuPermsByRoleId(role.getRoleId());
                    role.setPermissions(rolePerms);
                    perms.addAll(rolePerms);
                }
            }
            else
            {
                // 役割がない場合、ユーザーIDに基づいてメニュー権限を取得
                perms.addAll(menuService.selectMenuPermsByUserId(user.getUserId()));
            }
        }
        return perms;
    }
}

最後に、SysLoginServiceのログインメソッドは、tokenService.createToken(loginUser)を呼び出してJWTトークンを生成し、Redisに保存します。生成されたトークンはフロントエンドに返され、クライアント側(通常はCookie)に保存されます。

トークンの役割と管理

トークンは、サーバーが生成しクライアントに発行する文字列であり、クライアントがリクエストを送信する際の認証に使用される識別子です。初回ログイン後、サーバーはトークンを生成しクライアントに返します。以降、クライアントはユーザー名とパスワードを再送信することなく、このトークンを付与してリクエストを送信するだけで認証済みと見なされます。

一般的なフロー:

  1. クライアントはユーザー名とパスワードを使用してログインをリクエストします。
  2. サーバーはユーザー名とパスワードを検証します。
  3. 認証成功後、サーバーはトークンを生成し、それをクライアントに返します。
  4. クライアントは受け取ったトークンを(Cookie、LocalStorage、Vuexなど)に保存します。
  5. クライアントは以降、サーバーにリクエストを送信するたびに、このトークンを付与します。
  6. サーバーはリクエストに含まれるトークンを検証し、有効であれば要求されたデータをクライアントに返します。
  7. トークンは通常、リクエストヘッダーに含めてサーバーに渡されます。サーバーはCORS (Cross-Origin Resource Sharing) ポリシーをサポートする必要があります。

トークン管理サービス (TokenService.java):

package com.ruoyi.framework.web.service;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

import javax.servlet.http.HttpServletRequest;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import com.ruoyi.common.constant.CacheConstants;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.utils.ServletUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.ip.AddressUtils;
import com.ruoyi.common.utils.ip.IpUtils;
import com.ruoyi.common.utils.uuid.IdUtils;
import eu.bitwalker.useragentutils.UserAgent;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

/**
 * Token管理サービス。Tokenの生成、解析、更新、検証、およびユーザー情報のキャッシュとクリアを担当します。
 */
@Component
public class TokenService
{
    private static final Logger log = LoggerFactory.getLogger(TokenService.class);

    @Value("${token.header}")
    private String header; // Tokenリクエストヘッダーの識別子

    @Value("${token.secret}")
    private String secret; // Token暗号化キー

    @Value("${token.expireTime}")
    private int expireTime; // Token有効期限(分)

    protected static final long MILLIS_SECOND = 1000; // ミリ秒
    protected static final long MILLIS_MINUTE = 60 * MILLIS_SECOND; // 分
    private static final Long MILLIS_MINUTE_TEN = 20 * 60 * 1000L; // 20分(ミリ秒)

    @Autowired
    private RedisCache redisCache; // Redisキャッシュユーティリティクラス

    /**
     * リクエストから現在のログインユーザー情報を取得する
     *
     * @param request HTTPリクエストオブジェクト
     * @return ログインユーザー情報
     */
    public LoginUser getLoginUser(HttpServletRequest request)
    {
        // リクエストヘッダーからTokenを取得
        String token = getToken(request);
        if (StringUtils.isNotEmpty(token))
        {
            try
            {
                // Tokenを解析し、ユーザーの一意の識別子(UUID)を取得
                Claims claims = parseToken(token);
                String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);

                // UUIDに基づいてRedisからキャッシュされたログインユーザー情報を取得
                String userKey = getTokenKey(uuid);
                LoginUser user = redisCache.getCacheObject(userKey);
                return user;
            }
            catch (Exception e)
            {
                log.error("ユーザー情報の取得で例外が発生しました: {}", e.getMessage());
            }
        }
        return null;
    }

    /**
     * ログインユーザー情報をキャッシュに保存する
     *
     * @param loginUser ログインユーザー情報
     */
    public void setLoginUser(LoginUser loginUser)
    {
        if (StringUtils.isNotNull(loginUser) && StringUtils.isNotEmpty(loginUser.getToken()))
        {
            // Tokenの有効期限を自動的に更新し、ユーザー情報をキャッシュする
            refreshToken(loginUser);
        }
    }

    /**
     * 指定されたTokenに対応するログインユーザー情報をキャッシュから削除する
     *
     * @param token Token値
     */
    public void delLoginUser(String token)
    {
        if (StringUtils.isNotEmpty(token))
        {
            String userKey = getTokenKey(token);
            redisCache.deleteObject(userKey);
        }
    }

    /**
     * ログインユーザーのために新しいTokenを生成する
     *
     * @param loginUser ログインユーザー情報
     * @return 新しく生成されたToken値
     */
    public String createToken(LoginUser loginUser)
    {
        String token = IdUtils.fastUUID(); // 新しいToken値を生成
        loginUser.setToken(token); // ユーザー情報内のToken値を更新
        setUserAgent(loginUser); // ユーザーエージェント情報を設定

        // Tokenのクレーム情報をカプセル化
        Map<String, Object> claims = new HashMap<>();
        claims.put(Constants.LOGIN_USER_KEY, token);

        // Token文字列を生成して返す
        return createToken(claims);
    }

    /**
     * Tokenの有効性を検証し、期限切れが近いTokenを自動的に更新する
     *
     * @param loginUser ログインユーザー情報
     */
    public void verifyToken(LoginUser loginUser)
    {
        long expireTime = loginUser.getExpireTime();
        long currentTime = System.currentTimeMillis();

        // Tokenの残り有効期限が20分未満の場合、自動的に更新する
        if (expireTime - currentTime <= MILLIS_MINUTE_TEN)
        {
            refreshToken(loginUser);
        }
    }

    /**
     * Tokenの有効期限を更新し、キャッシュ内のユーザー情報の有効期限も更新する
     *
     * @param loginUser ログインユーザー情報
     */
    public void refreshToken(LoginUser loginUser)
    {
        // ユーザーのログイン時間とTokenの有効期限を更新
        loginUser.setLoginTime(System.currentTimeMillis());
        loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);

        // Token値に基づいてログインユーザー情報をRedisにキャッシュする
        String userKey = getTokenKey(loginUser.getToken());
        redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
    }

    /**
     * ログインユーザーのブラウザ、オペレーティングシステムなどのクライアント情報を設定する
     *
     * @param loginUser ログインユーザー情報
     */
    public void setUserAgent(LoginUser loginUser)
    {
        // リクエストのUser-Agentヘッダー情報を解析
        UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent"));

        // ユーザーIPアドレスを取得
        String ip = IpUtils.getIpAddr();

        // ユーザー情報を設定
        loginUser.setIpaddr(ip);
        loginUser.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
        loginUser.setBrowser(userAgent.getBrowser().getName());
        loginUser.setOs(userAgent.getOperatingSystem().getName());
    }

    /**
     * データクレームからToken文字列を生成する
     *
     * @param claims データクレーム
     * @return 生成されたToken文字列
     */
    private String createToken(Map<String, Object> claims)
    {
        // HS512アルゴリズムと秘密鍵を使用してTokenを構築し、署名する
        return Jwts.builder()
                .setClaims(claims)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }

    /**
     * Token文字列を解析し、その中のデータクレームを取得する
     *
     * @param token 解析対象のToken文字列
     * @return データクレームオブジェクト
     */
    private Claims parseToken(String token)
    {
        // 秘密鍵を使用してTokenを解析し、検証する
        return Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody();
    }

    /**
     * Tokenからユーザー名を抽出する
     *
     * @param token Token文字列
     * @return ユーザー名
     */
    public String getUsernameFromToken(String token)
    {
        Claims claims = parseToken(token);
        return claims.getSubject();
    }

    /**
     * HTTPリクエストからToken値を取得する
     *
     * @param request HTTPリクエストオブジェクト
     * @return Token値
     */
    private String getToken(HttpServletRequest request)
    {
        // リクエストヘッダーからTokenを取得し、プレフィックスを削除する
        String token = request.getHeader(header);
        if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX))
        {
            token = token.replace(Constants.TOKEN_PREFIX, "");
        }
        return token;
    }

    /**
     * ユーザーの一意の識別子(UUID)に基づいて、Redisにログインユーザー情報を保存するためのキー名を生成する
     *
     * @param uuid ユーザーの一意の識別子(UUID)
     * @return キャッシュキー名
     */
    private String getTokenKey(String uuid)
    {
        return CacheConstants.LOGIN_TOKEN_KEY + uuid;
    }
}

UIと機能拡張

登録機能

RuoYi管理システムには登録機能も実装されていますが、デフォルトでは非表示になっています。以下の設定を変更することで有効化できます。

  1. ログインページ(login.vue)のregister変数をtrueに設定します。
  2. 関連する設定ファイルで登録機能を有効にします。

登録されたユーザーはすぐにログインできますが、初期状態ではほとんど権限がありません。

動的メニューのロードとウェルカムページ

ログイン成功後、フロントエンドはルートパス/にリダイレクトされ、ホームページのレンダリングが開始されます。この際、バックエンドの/getInfo/getRoutesAPIエンドポイントに対して同時にリクエストを送信し、現在のログインユーザー情報とユーザー固有の動的メニューデータを取得し、ホームページを構築します。

左側の動的メニューは、ホームページのロード時にバックエンドからメニュー情報を動的に取得することで実現されます。

ウェルカムページのカスタマイズ: index.jsのルーティング設定を変更することで、デフォルトのウェルカムページを、例えば統計グラフを表示するダッシュボードなどに変更することができます。

公開APIエンドポイント

認証や権限チェックなしでアクセスを許可したい公開APIエンドポイントがある場合、@Anonymousアノテーションを使用します。これをコントローラーのクラスまたはメソッドに付与するだけで、公開アクセスが可能になります。

// @PreAuthorize("@ss.xxxx('....')") 既存の権限アノテーションはコメントアウトまたは削除します
@Anonymous // 匿名アクセスを許可する
@GetMapping("/list")
public List<SysXxxx> list(SysXxxx xxxx)
{
    return xxxxList;
}

:

@RestController
public class PublicController {
    // グローバルにアクセス可能なインターフェース -- ログイン不要で直接アクセス可能
    @Anonymous // 公開インターフェース
    @GetMapping("/public/hello")
    public AjaxResult get(){
        AjaxResult result = new AjaxResult();
        result.put("message","このリクエストはログインなしで直接アクセスできます");
        return result;
    }
}

カスタムモジュール開発

Mavenマルチモジュールプロジェクトで新しいサブモジュールを追加する手順は以下の通りです。

1. 新しい業務モジュールディレクトリの作成

例えば、ruoyi-testという名前の新しい業務モジュールディレクトリを作成します。

2. pom.xmlファイルの作成

ruoyi-test業務モジュール内にpom.xmlファイルを作成し、親プロジェクトと共通モジュールruoyi-commonを継承・インポートします。必要に応じて他のJARパッケージも追加できます。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
                             http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <parent>
        <groupId>com.ruoyi</groupId>
        <artifactId>ruoyi</artifactId>
        <version>x.x.x</version> <!-- 親プロジェクトのバージョンを指定 -->
    </parent>

    <modelVersion>4.0.0</modelVersion>

    <artifactId>ruoyi-test</artifactId>

    <description>
        テストシステムモジュール
    </description>

    <dependencies>
        <!-- 共通ユーティリティモジュール -->
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-common</artifactId>
        </dependency>
    </dependencies>

</project>

3. 親プロジェクトのpom.xmlに子モジュールの依存関係を追加

親プロジェクトruoyiのルートpom.xmlruoyi-test子モジュールへの依存関係を追加します。

<!-- テストモジュール -->
<dependency>
    <groupId>com.ruoyi</groupId>
    <artifactId>ruoyi-test</artifactId>
    <version>${ruoyi.version}</version> <!-- 親プロジェクトのバージョン変数を指定 -->
</dependency>

4. 親プロジェクトのpom.xmlmodulesセクションに子モジュールを追加

ルートpom.xml<modules>ノードにruoyi-testを追加して、Mavenがビルド時にこのモジュールを認識するようにします。

<modules>
    <!-- ... 他のモジュール ... -->
    <module>ruoyi-test</module>
</modules>

5. ruoyi-adminモジュールのpom.xmlに依存関係を追加

メインの実行可能モジュールであるruoyi-adminpom.xmlruoyi-testモジュールへの依存関係を追加します。

<!-- テストモジュール -->
<dependency>
    <groupId>com.ruoyi</groupId>
    <artifactId>ruoyi-test</artifactId>
</dependency>

6. テストモジュールの開発

ruoyi-test業務モジュール内にcom.ruoyi.test.controllerパッケージを作成し、TmController.javaを新規作成します。このコントローラーを通じて、新しく追加されたモジュールが正しく動作するか確認できます。

package com.ruoyi.test.controller; // パッケージ名を ruoyi.test.controller に変更

import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.core.domain.AjaxResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

// テストサブモジュール開発用コントローラー
@RestController
public class TmController {

    @Anonymous // 匿名アクセスを許可
    @GetMapping("/tm")
    public AjaxResult get() {
        AjaxResult result = new AjaxResult();
        result.put("message", "サブモジュールテスト開発成功!"); // メッセージを日本語に変更
        return result;
    }
}

タグ: Spring Boot vue.js Spring Security JWT redis

5月15日 20:42 投稿