您现在的位置是:首页 >其他 >带你一步步实现低代码开发平台——身份认证技术方案Session模式、令牌模式介绍与对比,前端改造及后端实现网站首页其他

带你一步步实现低代码开发平台——身份认证技术方案Session模式、令牌模式介绍与对比,前端改造及后端实现

学海无涯,行者无疆 2024-06-11 00:00:02
简介带你一步步实现低代码开发平台——身份认证技术方案Session模式、令牌模式介绍与对比,前端改造及后端实现

技术方案

首先来说一下身份认证,有两种技术方案,一种是基于传统的Session模式,另外一种则是基于令牌的模式,通常使用JWT。

Session模式

传统的session模式使用了很多年,技术非常成熟,不过这种模式的存在一些弊端:

  1. 资源消耗:使用session模式需要在服务器上存储会话数据,这可能会导致服务器资源的消耗增加。如果同时有大量用户使用系统,这可能会导致服务器崩溃或变慢。

  2. 可扩展性:使用session模式可能会影响系统的可扩展性。如果需要将系统扩展到多个服务器上,那么需要确保所有服务器都可以访问会话数据。这可能需要使用共享存储或其他技术,这可能会增加系统的复杂性和成本。

  3. 安全性:使用session模式可能会影响系统的安全性。如果会话数据被泄露或被攻击者篡改,那么攻击者可能会访问用户的敏感信息。因此,需要确保会话数据的安全性,并采取适当的安全措施,如加密和身份验证。

  4. 可维护性:使用session模式可能会影响系统的可维护性。如果会话数据的结构或格式发生变化,那么可能需要修改系统的代码。这可能会导致系统的维护成本增加。

使用session影响最大的其实是第二点扩展性上,生产环境为了防止单点故障,保证高可用,集群部署是常规操作,如何确保用户登录后能正常操作系统,通常有几种解决方式:
1.会话粘滞:负载均衡通过反向代理的方式来实现会话粘滞,即按照某些规则,如ip地址,把某个用户始终路由到某个固定的集群节点。
2.集中存储:使用Redis、数据库等来集中存储会话信息,从而在集群多个节点下共享会话数据。
3.会话复制:将集群某个节点的会话数据,同步到其他集群节点,很少用,因为复杂,易出错,还会带来额外的网络流量。
此外,在移动端接入的情况下,没有传统web的session机制,这时候,还需要借助第三方功能组件,如Spring Session来模拟产生类似于web的session信息,进一步增加的复杂性。

令牌模式

随着前后端分离架构模式的出现和发展,以及微服务的流行,目前基于令牌的方式逐渐成为主流技术方案,其过程如下:

  1. 用户在前端应用程序中输入用户名和密码,并将其发送到后端API。
  2. 后端API验证用户的凭据,并生成一个令牌。令牌通常包含一些元数据,如过期时间和用户ID。
  3. 后端API将令牌发送回前端应用程序。
  4. 前端应用程序将令牌存储在本地存储或cookie中。
  5. 在后续的请求中,前端应用程序将令牌发送到后端API,以证明用户已经通过身份验证。
  6. 后端API验证令牌的有效性,并根据需要执行相应的操作。
    在这个过程中,令牌是身份验证的关键。令牌通常使用JSON Web Token(JWT)格式进行编码,这是一种开放标准,用于在各方之间安全地传输信息。JWT包含一个签名,以确保令牌未被篡改。

令牌模式,主要有以下优点:

  1. 安全性:令牌是一种安全的身份验证机制,因为它们包含签名和加密信息,以确保令牌未被篡改。这使得令牌比传统的基于cookie的身份验证更加安全。
  2. 可扩展性:令牌是一种可扩展的身份验证机制,因为它们可以在多个应用程序之间共享。这使得令牌比传统的基于cookie的身份验证更加灵活。
  3. 无状态性:令牌是一种无状态的身份验证机制,因为它们不需要在服务器端存储任何信息。这使得令牌比传统的基于session的身份验证更加简单和可维护
    以上几点中,无状态性使其特别适合后端灵活扩展,比如增加集群节点。

功能实现

前端改造

发起登录请求

前端框架vue-element-plus-admin的登录操作位于src/views/Login/components/LoginForm.vue中,默认调用的是mock数据。平台后端真实的登录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)
……

然后在srcstoremodules目录下新增user.ts,包括标识、账号、姓名、是否强制修改密码、令牌、菜单权限数组和按钮权限数组这几个关键字段,代码如下:

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,会发现用户信息和令牌两项信息都保存了下来。
image.png

携带令牌访问后端

前端请求后端使用的axios,这就需要修改axios的配置了,在请求拦截器中增加读取令牌,并设置header的方法,对应代码位置是 srcconfigaxiosservice.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行我增加的代码。
这时候,在登录情况下,发起任意一个请求,使用浏览器的调试功能,可以看到header中多了令牌信息。
image.png

令牌失效处理

后端收到前端请求后,会对令牌进行验证,如果验证失败,要么是令牌本身无效,要么是令牌超时,这时统一返回给前端一个401的Http状态码,前端需要根据该状态码给予用户友好提示并导向系统登录页。

这时候要调整的是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秒后自动跳转到登录页面。
image.png

另外,常见的http状态还有400和403,这两个状态,用户通过正常途径操作系统实际不会触发,往往是拿接口调试工具,或者把url地址直接拷贝往浏览器里粘贴引发的。后端对应这两种模式,会返回200状态码,将错误信息放到响应里,由前端给予友好提示。

系统注销后清理令牌

在系统注销后,需要将令牌清理掉,这点框架自身已经做了处理,直接将所有缓存清空,代码位于srccomponentsUserInfosrcUserInfo.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组件来实现身份认证和权限控制,今天重点说下跟身份认证相关的功能点,关于Spring Security如何集成,内容较多,后面再开专篇介绍。

系统登录API

SpringSecurity组件已经内置登录功能,只需要写一个配置类,进行必要的配置即可。
如果只放代码片段,很难看清楚前后依赖和代码逻辑,先把整体配置文件放在下方,再重点介绍要说的功能。

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类的方法。登录成功后设置用户信息,包括标识、账号、姓名、是否强制修改密码、菜单数据、按钮权限,以及令牌的生成,都是在这里实现的。

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();
    }

}

身份认证

接下来就是最核心部分的实现,即进行身份认证,登录成功后,后续每次请求前端都会携带令牌,后端如何来获取和验证这个令牌呢?
SpringSecurity组件自身实际并未直接提供基于令牌的登录模式,不过提供了框架,需要自己扩展下,实现一个过滤器。

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);

同时,将会话管理也配置一下,既然已经使用了令牌模式,那么就不再需要会话,设置SpringSecurity的会话管理策略为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);

开发平台资料

平台名称:一二三开发平台
简介: 企业级通用开发平台
设计资料:csdn专栏
开源地址:Gitee
开源协议:MIT
欢迎收藏、点赞、评论,你的支持是我前行的动力。

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。