ローコード開発プラットフォームを実現するためのステップバイステップ - ID 認証技術スキームのセッション モード、トークン モードの紹介と比較、フロントエンドの変換とバックエンドの実装

技術的ソリューション

まず、ID 認証について説明します。技術的なソリューションは 2 つあり、1 つは従来のセッション モードに基づいており、もう 1 つはトークン モードに基づいており、通常は JWT を使用します。

セッションモード

従来のセッション モードは長年使用されており、テクノロジーは非常に成熟していますが、このモードにはいくつかの欠点があります。

  1. リソースの消費: セッション モードを使用するには、セッション データをサーバーに保存する必要があるため、サーバー リソースの消費が増加する可能性があります。これにより、多数のユーザーが同時にシステムを使用している場合、サーバーがクラッシュしたり速度が低下したりする可能性があります。

  2. スケーラビリティ: セッション モードを使用すると、システムのスケーラビリティに影響を与える可能性があります。システムを複数のサーバーに拡張する必要がある場合は、すべてのサーバーがセッション データにアクセスできるようにする必要があります。これには、共有ストレージやその他の技術の使用が必要になる場合があり、システムが複雑になり、コストが増加する可能性があります。

  3. セキュリティ: セッション モードを使用すると、システムのセキュリティに影響を与える可能性があります。セッション データが攻撃者によって侵害または改ざんされると、攻撃者は機密性の高いユーザー情報にアクセスできる可能性があります。したがって、セッション データは、暗号化や認証などの適切なセキュリティ手段を使用して保護する必要があります。

  4. 保守性: セッション モードを使用すると、システムの保守性に影響を与える可能性があります。セッション データの構造または形式が変更された場合、システムのコードの変更が必要になる場合があります。これにより、システムの保守コストが増加する可能性があります。

セッションを使用することの最大の影響は、実際にはスケーラビリティの 2 番目のポイントです。単一障害点を防止し、運用環境での高可用性を確保するために、クラスターのデプロイメントは日常的な操作です。ユーザーがログイン後に正常に動作できることを確認する方法は、通常、いくつかの解決策があります。
1. セッション スティッキー性: ロード バランシングは、リバース プロキシを介してセッション スティッキー性を実装します。つまり、IP アドレスなどの特定のルールに従って、ユーザーは常に固定クラスター ノードにルーティングされます。
2. 集中ストレージ: Redis、データベースなどを使用してセッション情報を集中保存し、クラスター内の複数のノードでセッション データを共有します。
3. セッション レプリケーション: クラスター内のノードのセッション データを他のクラスター ノードに同期します。複雑でエラーが発生しやすく、追加のネットワーク トラフィックが発生するため、ほとんど使用されません。
さらに、モバイル端末からのアクセスの場合、従来の Web セッションの仕組みが存在しないため、Web に似たセッション情報をシミュレートして生成するには Spring Session などのサードパーティの機能コンポーネントが必要になり、複雑。

トークンモード

フロントエンドとバックエンドの分離アーキテクチャ モデルの出現と発展、およびマイクロサービスの普及により、現在のトークンベースのアプローチが徐々に主流の技術ソリューションになりました。そのプロセスは次のとおりです。

  1. ユーザーがフロントエンド アプリケーションにユーザー名とパスワードを入力すると、それがバックエンド API に送信されます。
  2. バックエンド API はユーザーの資格情報を検証し、トークンを生成します。通常、トークンには有効期限やユーザー ID などのメタデータが含まれています。
  3. バックエンド API はトークンをフロントエンド アプリケーションに送り返します。
  4. フロントエンド アプリケーションは、トークンをローカル ストレージまたは Cookie に保存します。
  5. 後続のリクエストでは、フロントエンド アプリケーションはユーザーが認証されたことを証明するためにトークンをバックエンド API に送信します。
  6. バックエンド API はトークンの有効性を検証し、必要に応じて適切なアクションを実行します。
    このプロセスでは、トークンが認証の鍵となります。トークンは通常、当事者間で情報を安全に転送するためのオープン標準である JSON Web Token (JWT) 形式を使用してエンコードされます。JWT には、トークンが改ざんされていないことを保証する署名が含まれています。

トークン モードには主に次の利点があります。

  1. セキュリティ: トークンには、トークンが改ざんされていないことを保証する署名および暗号化された情報が含まれているため、安全な認証メカニズムです。これにより、トークンは従来の Cookie ベースの認証よりも安全になります。
  2. スケーラビリティ: トークンは複数のアプリケーション間で共有できるため、スケーラブルな認証メカニズムです。これにより、トークンは従来の Cookie ベースの認証よりも柔軟になります。
  3. ステートレス: トークンは、サーバー側に情報を保存する必要がないため、ステートレスな認証メカニズムです。これにより、従来のセッションベースの認証よりもトークンがシンプルになり、保守しやすくなります
    。 上記の点の中でも、ステートレスであるため、クラスター ノードの追加など、バックエンドの柔軟な拡張に特に適しています。

機能実現

フロントエンドの変革

ログイン要求を開始する

フロントエンド フレームワーク vue-element-plus-admin のログイン操作は src/views/Login/components/LoginForm.vue にあり、デフォルトの呼び出しはモック データです。プラットフォーム バックエンドの実際のログイン API インターフェイス アドレスは /system/user/login であるため、api/login/index.ts の loginApi メソッドの URL アドレスを変更します。

export const loginApi = (data: UserType) => {
    
    
  return request.post({
    
    
    url: '/system/user/login?username=' + data.username + '&password=' + data.password,
    data
  })
}

ログイン成功後にトークンを保存する

まず、ログイン要求 API によって返されたトークンを保存する必要があります。
ログイン成功後のユーザー情報の保存に関して、フレームワークの元の実装は次のとおりです。

// 登录
const signIn = async () => {
  const formRef = unref(elFormRef)
  await formRef?.validate(async (isValid) => {
    if (isValid) {
      loading.value = true
      const { getFormData } = methods
      const formData = await getFormData<UserType>()

      try {
        const res = await loginApi(formData)

        if (res) {
          wsCache.set(appStore.getUserInfo, res.data)
          // 是否使用动态路由
          if (appStore.getDynamicRouter) {
            getRole()
          } else {
            await permissionStore.generateRoutes('none').catch(() => {})
            permissionStore.getAddRouters.forEach((route) => {
              addRoute(route as RouteRecordRaw) // 动态添加可访问路由表
            })
            permissionStore.setIsAddRouters(true)
            push({ path: redirect.value || permissionStore.addRouters[0].path })
          }
        }
      } finally {
        loading.value = false
      }
    }
  })
}

上記のコードは src/views/Login/components/LoginForm.vue からのもので、重要なステートメントは 14 行目です。キャッシュ ツール クラスを使用して、バックエンドから返されたデータ、つまりユーザー情報が SessionStorage に保存されます。

ここは実際にはvueのグローバル状態管理を使っていないので、以下のように修正しました。

import { useUserStore } from '@/store/modules/user'
const userStore = useUserStore()

  
const res = await loginApi(formData)
if (res) {
   // 保存用户信息
   userStore.setUserAction(res.data)
……

次に、ロゴ、アカウント番号、名前、パスワード変更を強制するかどうか、トークン、メニュー権限配列、ボタン権限配列などのキー フィールドを含む user.ts を src\store\modules ディレクトリに追加します。コードは次のとおりです。

import { store } from '../index'
import { defineStore } from 'pinia'
import { useCache } from '@/hooks/web/useCache'
import { USER_KEY } from '@/constant/common'
const { wsCache } = useCache()
import { setToken } from '@/utils/auth'

interface UserState {
  account: string
  name: string
  forceChangePassword: string
  id: string
  token: string
  buttonPermission: string[]
  menuPermission: string[]
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    account: '',
    name: '',
    forceChangePassword: '',
    id: '',
    token: '',
    buttonPermission: [],
    menuPermission: []
  }),
  getters: {
    getAccount(): string {
      return this.account
    }
  },
  actions: {
    async setUserAction(user) {
      this.account = user.account
      this.name = user.name
      this.forceChangePassword = user.forceChangePassword
      this.id = user.id
      this.token = user.token
      this.buttonPermission = user.buttonPermission
      this.menuPermission = user.menuPermission
      // 保存用户信息
      wsCache.set(USER_KEY, user)
      // 保存令牌
      setToken(user.token)
    },
    async clear() {
      wsCache.clear()
      this.resetState()
    },
    resetState() {
      this.account = ''
      this.name = ''
      this.forceChangePassword = ''
      this.id = ''
      this.token = ''
      this.buttonPermission = []
      this.menuPermission = []
    }
  }
})

export const useUserStoreWithOut = () => {
  return useUserStore(store)
}

43 行目では、ユーザー情報全体を保存します。トークンが頻繁に使用されることを考慮すると、ユーザー オブジェクト全体をフェッチして毎回トークンを取得するのは非効率であるため、トークンは個別に保存されます (45 行目)。ここにカプセル化されたトークン読み取りおよび書き込みツール クラス。

import {
    
     useCache } from '@/hooks/web/useCache'
import {
    
     TOKEN_KEY } from '@/constant/common'
const {
    
     wsCache } = useCache()


// 获取token
export const getToken = () => {
    
    
  return wsCache.get(TOKEN_KEY) ? wsCache.get(TOKEN_KEY) : ''
}


// 设置token
export const setToken = (token) => {
    
    
  wsCache.set(TOKEN_KEY, token)
}


// 删除token
export const removeToken = () => {
    
    
  wsCache.delete(TOKEN_KEY)
}

ログインに成功した後、ブラウザのデバッグ機能を使用して SessionStorage を表示すると、ユーザー情報とトークン情報の両方が保存されていることがわかります。
画像.png

バックエンドにアクセスするためにトークンを持ち込む

フロントエンドは、バックエンドによって使用される axios を要求します。これには、axios の構成を変更し、リクエスト インターセプターに読み取りトークンを追加し、ヘッダー メソッドを設定する必要があります。対応するコードの場所は src\config\axios\service.ts です。次のように:

// 创建axios实例
const service: AxiosInstance = axios.create({
    
    
  baseURL: PATH_URL, // api 的 base_url
  timeout: config.request_timeout // 请求超时时间
})
// request拦截器
service.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    
    
    // 读取token
    const token = getToken()
    if (token) {
    
    
      // 若不为空,则将token放入header属性
      config.headers['X-Token'] = token
    }
    const urlencoded = 'application/x-www-form-urlencoded'
    if (
      config.method === 'post' &&
      (config.headers as AxiosRequestHeaders)['Content-Type'] === urlencoded
    ) {
    
    
      config.data = qs.stringify(config.data)
    }  
    ……
    return config
  },
  (error: AxiosError) => {
    
    
    // Do something with request error
    console.log(error) // for debug
    Promise.reject(error)
  }
)

9行目から14行目は私が追加したコードです。
このとき、ログイン状況でリクエストを開始し、ブラウザのデバッグ機能を使用すると、ヘッダーにさらに多くのトークン情報があることがわかります。
画像.png

トークンの無効化処理

フロントエンドのリクエストを受信した後、バックエンドはトークンを検証します。検証に失敗した場合は、トークン自体が無効であるか、トークンがタイムアウトしました。このとき、401 Http ステータス コードがフロントエンドに返されます。 -end. フロントエンドはこのステータスを使用する必要があります コードにより、ユーザーフレンドリーなプロンプトが表示され、システムのログインページが表示されます。

このとき調整が必要となるのはaxiosの設定であり、ステータスコードの判定やフレンドリーなプロンプトはレスポンスインターセプターで行われます。

// response 拦截器
service.interceptors.response.use(
  (response: AxiosResponse<any>) => {
    
    
    if (response.config.responseType === 'blob') {
    
    
      // 如果是文件流,直接过
      return response
    } else if (response.status === REQUEST_SUCCESS) {
    
    
      return new Promise((resolve) => {
    
    
        // 若为成功请求,直接返回业务数据
        if (response.status === REQUEST_SUCCESS) {
    
    
          resolve(response)
        }
      })
      return response.data
    } else {
    
    
      ElMessage.error(response.data.message)
    }
  },
  (error: AxiosError) => {
    
    
    if (error.response) {
    
    
      if (error.response.status === UNAUTHORIZED) {
    
    
        // 收到401响应时,给出友好提示
        ElMessage.warning('未登录或会话超时,请重新登录')
        // 清空浏览器缓存
        wsCache.clear()
        // 执行页面刷新
        setTimeout(function () {
    
    
          location.reload()
        }, 2000)
      } else if (error.response.status === NOT_FOUND) {
    
    
        ElMessage.error('未找到服务,请确认')
      } else if (error.response.status === METHOD_NOT_ALLOWED) {
    
    
        ElMessage.error('请求的方法不支持,请确认')
      } else {
    
    
        ElMessage.error(error.response.data.message)
      }
      return Promise.reject(error)
    } else {
    
    
      ElMessage.error('请求远程服务器失败')
    }
  }
)

ステータスコード 401 の処理については上記 21 ~ 29 行目を参照、ステータスコード 404 と 405 についても同様の処理方法となります。

401 リクエストを受信すると、システムの上部中​​央にわかりやすいリマインダーが表示され、2 秒後に自動的にログイン ページにジャンプします。
画像.png

さらに、一般的な http ステータスには 400 と 403 があります。これら 2 つのステータスは、実際にはオペレーティング システムの通常の方法でユーザーによってトリガーされるわけではありません。多くの場合、インターフェイス デバッグ ツールを使用するか、URL アドレスを直接コピーして URL に貼り付けることによって引き起こされます。ブラウザ。これら 2 つのモードに対応して、バックエンドは 200 ステータス コードを返し、応答にエラー情報を入れ、フロントエンドはわかりやすいプロンプトを表示します。

システムログアウト後にトークンをクリーンアップする

システムがログアウトした後、トークンをクリアする必要があります。フレームワーク自体がすでにこれを実行しており、すべてのキャッシュが直接クリアされます。コードは src\components\UserInfo\src\UserInfo.vue の 10 行目にあります。

const loginOut = () => {
    
    
  ElMessageBox.confirm(t('common.loginOutMessage'), t('common.reminder'), {
    
    
    confirmButtonText: t('common.ok'),
    cancelButtonText: t('common.cancel'),
    type: 'warning'
  })
    .then(async () => {
    
    
      const res = await loginOutApi().catch(() => {
    
    })
      if (res) {
    
    
        wsCache.clear()
        tagsViewStore.delAllViews()
        resetRouter() // 重置静态路由表
        replace('/login')
      }
    })
    .catch(() => {
    
    })
}

バックエンドの実装

Spring Security のコンポーネントを統合して ID 認証と権限制御を実現するプラットフォームです。今日は ID 認証に関連する機能に焦点を当てます。Spring Security の統合方法については多くのコンテンツがあり、後ほど特別記事で紹介します。

システムログインAPI

Spring Security コンポーネントにはログイン機能が組み込まれており、構成クラスを記述して必要な構成を実行するだけで済みます。
コードスニペットだけを置くと依存関係やコードロジックが分かりにくいので、まず全体の設定ファイルを以下に置き、その後に記載する機能に焦点を当てます。

package tech.abc.platform.framework.security;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.expression.SecurityExpressionOperations;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.FilterInvocation;
import org.springframework.security.web.access.expression.DefaultWebSecurityExpressionHandler;
import org.springframework.security.web.access.expression.WebSecurityExpressionRoot;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsUtils;

/**
 * SpringSecurity安全框架配置
 *
 * @author wqliu
 * @date 2023-03-08
 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    
    

    /**
     * 登录处理地址
     */
    public static final String SYSTEM_USER_LOGIN = "/system/user/login";
    /**
     * 注销处理地址
     */
    public static final String SYSTEM_USER_LOGOUT = "/system/user/logout";
    /**
     * 会话超时地址
     */
    public static final String SYSTEM_USER_SESSION_INVALID = "/system/user/sessionInvalid";


    @Autowired
    private UserDetailsServiceImpl myUserService;

    @Autowired
    private AuthenticationSuccessHandler myAuthenticationSuccessHandler;

    @Autowired
    private AuthenticationFailureHandler myAuthenticationFailHandler;

    @Autowired
    private MyLogoutHandler myLogoutHandler;

    @Autowired
    private MyLogoutSuccessHandler myLogoutSuccessHandler;

    @Autowired
    private MyPermissionEvaluator myPermissionEvaluator;

    @Autowired
    private JwtFilter jwtFilter;


    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    
    
        // 设置自定义用户服务及加密方式
        auth.userDetailsService(myUserService).passwordEncoder(new BCryptPasswordEncoder());
    }


    @Override
    protected void configure(HttpSecurity http) throws Exception {
    
    

        // 允许跨域访问
        http.cors();
        // 禁用csrf攻击防护
        http.csrf().disable();
        // 登录处理
        http.formLogin()
                // 此处的方法实际是虚拟的,并不需要在UserControl控制器中存在,与前端请求一致即可,会被SpringSecurity截获
                // 即使该方法存在,也会被SpringSecurity优先截获
                // 另外,前后端分离的情况下,不需要指定登录地址loginPage参数,指定了也不起作用
                .loginProcessingUrl(SYSTEM_USER_LOGIN)
                // 设置自定义的身份认证成功处理器
                .successHandler(myAuthenticationSuccessHandler)
                // 设置自定义的身份认证失败处理器
                .failureHandler(myAuthenticationFailHandler);

        // 注销处理
        http.logout()
                // 这里新加自定义处理处理器主要是生成用户注销审计日志,如放在logoutSuccessHandler则无法取到当前用户
                .addLogoutHandler(myLogoutHandler)
                .logoutUrl(SYSTEM_USER_LOGOUT)
                .logoutSuccessHandler(myLogoutSuccessHandler)
                .invalidateHttpSession(true);


        // 会话管理
        http.sessionManagement()
                // 使用jwt token,不需要session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);


        // 配置允许访问页面
        http.authorizeRequests()
                // 允许跨域请求中的Preflight请求
                .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()

                // 允许swagger文档接口匿名访问
                .antMatchers("/swagger-ui.html").anonymous()
                .antMatchers("/swagger-resources/**").anonymous()
                .antMatchers("/webjars/**").anonymous()
                .antMatchers("/*/api-docs").anonymous()


        ;

        // 配置其他请求,需认证
        http.authorizeRequests()
                .anyRequest()
                .authenticated();

        // 配置JWT过滤器
        http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);


    }


    @Override
    public void configure(WebSecurity web) {
    
    
        web.expressionHandler(new DefaultWebSecurityExpressionHandler() {
    
    
            @Override
            protected SecurityExpressionOperations createSecurityExpressionRoot(Authentication authentication, FilterInvocation fi) {
    
    
                WebSecurityExpressionRoot root = (WebSecurityExpressionRoot) super.createSecurityExpressionRoot(authentication, fi);
                root.setPermissionEvaluator(myPermissionEvaluator);
                return root;
            }
        });
    }
}

ログイン操作の中心的な構成は次のとおりです。

  /**
   * 登录处理地址
   */
  public static final String SYSTEM_USER_LOGIN = "/system/user/login";


// 登录处理
  http.formLogin()
          // 此处的方法实际是虚拟的,并不需要在UserControl控制器中存在,与前端请求一致即可,会被SpringSecurity截获
          // 即使该方法存在,也会被SpringSecurity优先截获
          // 另外,前后端分离的情况下,不需要指定登录地址loginPage参数,指定了也不起作用
          .loginProcessingUrl(SYSTEM_USER_LOGIN)
          // 设置自定义的身份认证成功处理器
          .successHandler(myAuthenticationSuccessHandler)
          // 设置自定义的身份认证失败处理器
          .failureHandler(myAuthenticationFailHandler);

上記の構成後、バックエンドによってフロントエンドに公開されるログイン操作の API インターフェイス アドレスは /system/user/login になります。

ログインに成功するとトークンが生成されます

SpringSecurity コンポーネントは、認証が成功した後、AuthenticationSuccessHandler クラスのメソッドをコールバックします。ログインに成功した後、ID、アカウント番号、名前、パスワード変更を強制するかどうか、メニュー データ、ボタンの権限、トークンの生成などのユーザー情報を設定します。これらはすべてここで実装されます。

package tech.abc.platform.framework.security;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import tech.abc.platform.common.annotation.SystemLog;
import tech.abc.platform.common.constant.CommonConstant;
import tech.abc.platform.common.constant.TreeDefaultConstant;
import tech.abc.platform.common.entity.MyUserDetails;
import tech.abc.platform.common.enums.LogTypeEnum;
import tech.abc.platform.common.enums.YesOrNoEnum;
import tech.abc.platform.common.utils.CacheUtil;
import tech.abc.platform.common.utils.JwtUtil;
import tech.abc.platform.common.utils.ResultUtil;
import tech.abc.platform.common.vo.Result;
import tech.abc.platform.framework.config.PlatformConfig;
import tech.abc.platform.system.entity.PermissionItem;
import tech.abc.platform.system.entity.User;
import tech.abc.platform.system.enums.PermissionTypeEnum;
import tech.abc.platform.system.service.OrganizationService;
import tech.abc.platform.system.service.UserService;
import tech.abc.platform.system.vo.MenuTreeVO;
import tech.abc.platform.system.vo.MetaVO;
import tech.abc.platform.system.vo.UserVO;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections.CollectionUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

/**
 * 自定义登录成功处理器
 * 如继承父类则会内置跳转首页或权限验证失败前一页,会影响前端跳转,自己实现接口,去除了后端自动跳转
 *
 * @author wqliu
 * @date 2023-03-08
 */
@Component
@Slf4j
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    
    

    public static final double VALIDATE_LICENCE_PERCENT = 0.2;
    @Autowired
    private UserService userService;

    @Autowired
    private OrganizationService organizationService;

    @Autowired
    private CacheUtil cacheUtil;


    @Autowired
    private PlatformConfig platformConfig;

    @Autowired
    private JwtUtil jwtUtil;


    @Override
    @SystemLog(value = "登录成功", logType = LogTypeEnum.AUDIT, logRequestParam = false, executeResult = CommonConstant.YES)
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication)
            throws IOException {
    
    


        MyUserDetails userDetails = (MyUserDetails) authentication.getPrincipal();
        // 获取用户名
        String account = userDetails.getUsername().toLowerCase();

        // 查询用户信息
        QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();
        userQueryWrapper.lambda().eq(User::getAccount, account);
        User user = userService.getOne(userQueryWrapper);

        // 重置登录失败次数
        userService.resetLoginFailureCount(user.getId());


        // 构造返回对象
        UserVO userVO = new UserVO();
        userVO.setId(user.getId());
        userVO.setAccount(user.getAccount());
        userVO.setName(user.getName());

        // 强制修改密码标志位
        userVO.setForceChangePasswordFlag(user.getForceChangePasswordFlag());
        // 判断是否超出密码修改时间
        if (userService.checkExceedPasswordChangeDays(user.getId())) {
    
    
            userVO.setForceChangePasswordFlag(YesOrNoEnum.YES.name());
        }
        // 生成令牌
        String token = jwtUtil.generateTokenWithSubject(user.getAccount(), platformConfig.getSystem().getTokenValidSpan() * 60);
        // 设置令牌
        userVO.setToken(token);


        // 获取权限
        List<PermissionItem> permissionList = userService.getPermission(user.getId());
        if (CollectionUtils.isNotEmpty(permissionList)) {
    
    
            // 获取按钮权限
            List<String> buttonPermission = permissionList.stream().filter(x -> x.getType()
                            .equals(PermissionTypeEnum.BUTTON.toString()))
                    .map(PermissionItem::getPermissionCode).distinct().collect(Collectors.toList());
            userVO.setButtonPermission(buttonPermission);
            // 获取菜单权限
            List<MenuTreeVO> moduleList = getMenu(permissionList);
            userVO.setMenuPermission(moduleList);
        }
        // 构建返回
        ResponseEntity<Result> result = ResultUtil.success(userVO, "登录成功");
        ResultUtil.returnJsonToFront(response, result);

    }


    /**
     * 获取菜单
     * 目前支持两级
     *
     * @return
     */
    public List<MenuTreeVO> getMenu(List<PermissionItem> permissionList) {
    
    

        // 生成模块
        List<MenuTreeVO> moduleList
                = generateModule(permissionList, TreeDefaultConstant.DEFAULT_TREE_ROOT_ID);

        for (MenuTreeVO module : moduleList) {
    
    
            // 生成菜单
            List<MenuTreeVO> menus = generateMenu(permissionList, module.getId());
            module.setChildren(menus);
        }
        return moduleList;
    }


    /**
     * 生成模块
     */
    private List<MenuTreeVO> generateModule(List<PermissionItem> list, String parentId) {
    
    
        List<MenuTreeVO> result = new ArrayList<>();
        for (PermissionItem node : list) {
    
    
            // 获取类型为模块权限项
            if (node.getType().equals(PermissionTypeEnum.MODULE.toString())) {
    
    
                if (node.getPermissionItem().equals(parentId)) {
    
    
                    MenuTreeVO vo = new MenuTreeVO();
                    vo.setId(node.getId());
                    vo.setParentId(node.getPermissionItem());
                    vo.setName(node.getCode());
                    vo.setPath("/" + node.getCode());
                    vo.setComponent(node.getComponent());

                    MetaVO metaVO = new MetaVO();
                    metaVO.setTitle(node.getName());
                    metaVO.setIcon(node.getIcon());
                    metaVO.setHidden(false);
                    vo.setMeta(metaVO);
                    result.add(vo);
                }
            }
        }
        return result;
    }

    /**
     * 生成菜单
     */
    private List<MenuTreeVO> generateMenu(List<PermissionItem> list, String parentId) {
    
    
        List<MenuTreeVO> menus = new ArrayList<MenuTreeVO>();

        List<PermissionItem> permissionList
                = list.stream().filter(x -> x.getPermissionItem().equals(parentId)).collect(Collectors.toList());
        for (PermissionItem permission : permissionList) {
    
    

            if (permission.getType().equals(PermissionTypeEnum.MENU.toString())
                    || permission.getType().equals(PermissionTypeEnum.PAGE.toString())) {
    
    
                MenuTreeVO vo = new MenuTreeVO();
                vo.setId(permission.getId());
                vo.setParentId(permission.getPermissionItem());
                vo.setName(permission.getCode());
                vo.setPath(permission.getCode());
                vo.setComponent(permission.getComponent());


                MetaVO metaVO = new MetaVO();
                metaVO.setTitle(permission.getName());
                metaVO.setIcon(permission.getIcon());
                // 如果为页面,非菜单,则设置隐藏
                metaVO.setHidden(permission.getType().equals(PermissionTypeEnum.PAGE.toString()));

                vo.setMeta(metaVO);
                menus.add(vo);
                // 查找下级
                // 菜单规划为两级,因此此处未使用递归,而是再往下找一级即可
                List<MenuTreeVO> children = generateMenu(list, permission.getId());
                if (children != null && children.size() > 0) {
    
    
                    menus.addAll(children);
                }

            }
        }
        return menus;
    }


}

トークンの生成と検証はツール クラスをカプセル化します。

package tech.abc.platform.common.utils;

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import tech.abc.platform.common.exception.SessionExpiredException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.Date;

/**
 * jwt工具类
 *
 * @author wqliu
 * @date 2023-03-08
 */
@Component
public class JwtUtil {
    
    

    /**
     * 密钥
     */
    @Value("${platform-config.system.tokenSecret}")
    private String secret;


    /**
     * 默认超时时间 30分钟
     */
    private final Long JWT_DEFAULT_EXPIRE_SECONDS = 30 * 60L;


    /**
     * 获取超时时间
     *
     * @param validSpan 时长,单位 秒
     * @return
     */
    private Date getExpireTime(long validSpan) {
    
    

        // 生成JWT过期时间
        long nowMilliSecond = System.currentTimeMillis();
        if (validSpan < 0) {
    
    
            validSpan = JWT_DEFAULT_EXPIRE_SECONDS;
        }
        long expMilliSecond = nowMilliSecond + validSpan * 1000;
        Date exp = new Date(expMilliSecond);
        return exp;
    }

    /**
     * 生成带主题的令牌
     *
     * @param subject   主题
     * @param validSpan 有效时长,单位秒
     * @return jwt令牌
     */
    public String generateTokenWithSubject(String subject, long validSpan) {
    
    
        Algorithm algorithm = Algorithm.HMAC256(secret);
        Date expireTime = getExpireTime(validSpan);

        String token = JWT.create()
                .withSubject(subject)
                .withExpiresAt(expireTime)
                .sign(algorithm);
        return token;

    }

    /**
     * 验证令牌
     *
     * @param token
     * @return
     */
    public void verifyToken(String token) {
    
    
        Algorithm algorithm = Algorithm.HMAC256(secret);

        JWTVerifier verifier = JWT.require(algorithm)
                .build();
        try {
    
    
            verifier.verify(token);
        } catch (Exception ex) {
    
    
            throw new SessionExpiredException("令牌无效或过期,请重新登录");
        }
    }


    /**
     * 解码令牌
     *
     * @param token
     * @return
     */
    public DecodedJWT decode(String token) {
    
    
        return JWT.decode(token);
    }

    /**
     * 获取主题
     *
     * @param token 令牌
     * @return 主题
     */
    public String getSubject(String token) {
    
    
        return decode(token).getSubject();
    }

}

認証

次のステップは、コア部分、つまり ID 認証の実装です。ログインが成功すると、フロントエンドは後続のリクエストごとにトークンを運びます。バックエンドはどのようにしてこのトークンを取得して検証できるでしょうか?
Spring Security コンポーネント自体は、トークンベースのログイン モードを直接提供しませんが、フィルターを実装するために拡張する必要があるフレームワークを提供します。

package tech.abc.platform.framework.security;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.GenericFilterBean;
import tech.abc.platform.common.utils.JwtUtil;
import tech.abc.platform.common.utils.ResultUtil;
import tech.abc.platform.common.vo.Result;
import tech.abc.platform.framework.config.PlatformConfig;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 基于jwt令牌的身份认证过滤器
 *
 * @author wqliu
 * @date 2023-03-08
 */
@Slf4j
@Component
public class JwtFilter extends GenericFilterBean {
    
    


    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private PlatformConfig platformConfig;

    @Autowired
    private JwtUtil jwtUtil;

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
    
    


        HttpServletRequest req = (HttpServletRequest) servletRequest;


        // 优先从http头中获取令牌
        String token = req.getHeader("X-Token");
        // 其次从cookie中获取
        if (StringUtils.isBlank(token)) {
    
    
            Cookie[] cookies = ((HttpServletRequest) servletRequest).getCookies();
            if (cookies != null) {
    
    
                for (int i = 0; i < cookies.length; i++) {
    
    
                    if ("token".equals(cookies[i].getName())) {
    
    
                        token = cookies[i].getValue();
                        break;
                    }
                }
            }
        }
        // 再次,从url地址中获取
        if (StringUtils.isBlank(token)) {
    
    
            token = req.getParameter("X-Token");
        }


        if (StringUtils.isNotBlank(token)) {
    
    
            // 验证令牌
            try {
    
    
                jwtUtil.verifyToken(token);
            } catch (Exception ex) {
    
    
                ResponseEntity<Result> result = ResultUtil.error(ex.getMessage(), HttpStatus.UNAUTHORIZED);
                ResultUtil.returnJsonToFront((HttpServletResponse) servletResponse, result);
                return;
            }

            // 获取账号
            String account = jwtUtil.decode(token).getSubject();
            // 查询用户信息
            UserDetails user = userDetailsService.loadUserByUsername(account);
            // 构造SpringSecurity的认证对象后放到SecurityContextHolder中
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authentication);


        }
        // 执行后续过滤器
        filterChain.doFilter(servletRequest, servletResponse);
    }
}

フィルターは SpringSecurity フィルター チェーンの適切な位置に構成する必要があります

// 配置JWT过滤器
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

同時にセッション管理の設定も行う トークンモードを使用したためセッションは不要となる Spring Securityのセッション管理ポリシーをSessionCreationPolicy.STATELESSに設定する。

// 会话管理
  http.sessionManagement()
          // 使用jwt token,不需要session
          .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

トークン検証処理において検証に失敗した場合は、トークン自体が無効であるか、トークンがタイムアウトしているかのどちらかですが、このときフロントエンドには一律に 401 Http ステータスコードが返され、フロントエンドはユーザーにステータス コードに基づいたフレンドリーなプロンプトが表示され、システム ログイン ページが表示されます。

// 验证令牌
try {
    
    
    jwtUtil.verifyToken(token);
} catch (Exception ex) {
    
    
    ResponseEntity<Result> result = ResultUtil.error(ex.getMessage(), HttpStatus.UNAUTHORIZED);
    ResultUtil.returnJsonToFront((HttpServletResponse) servletResponse, result);
    return;
}

システムログアウトAPI

ログイン API と同様に、これも構成によって生成されます。

/**
 * 注销处理地址
 */
public static final String SYSTEM_USER_LOGOUT = "/system/user/logout";

// 注销处理
  http.logout()
          // 这里新加自定义处理处理器主要是生成用户注销审计日志,如放在logoutSuccessHandler则无法取到当前用户
          .addLogoutHandler(myLogoutHandler)
          .logoutUrl(SYSTEM_USER_LOGOUT)
          .logoutSuccessHandler(myLogoutSuccessHandler)
          .invalidateHttpSession(true);

開発プラットフォーム情報

プラットフォーム名: One Two Three Development Platform
概要: エンタープライズレベルの総合開発プラットフォーム
設計情報: csdn コラム
オープンソースアドレス: Gitee
オープンソースプロトコル: MIT は
お気に入り、いいね、コメントを歓迎します あなたのサポートが私が前進するための原動力です。

おすすめ

転載: blog.csdn.net/seawaving/article/details/130534967