vue+spring-security前后端分离登录实现

本文详细介绍了在前后端分离的登录认证中,如何使用Spring-Security替代Shiro进行登录实现。文中提到了Shiro的灵活性和Spring-Security的集成优势,分析了JWT和Session的优缺点,选择了Session作为保存用户状态的方式。接着,文章阐述了Spring-Security的AuthenticationBuilder、UserDetailsService、DaoAuthenticationProvider等组件与Shiro的对应关系,并展示了具体的实现代码。此外,还讨论了Axios在Vue中的封装和使用,以及权限控制的实现策略。最后,文章提及了数据访问层的实现,如Spring-Data和MyBatis-Plus的比较。
摘要由CSDN通过智能技术生成

vue+spring-security前后端分离登录实现

1.shiro和spring-security区别

首先Shiro较之Spring SecurityShiro在保持强大功能的同时,还在简单性和灵活性方面拥有巨大优势。Shiro是一个强大而灵活的开源安全框架,能够非常清晰的处理认证、授权、管理会话以及密码加密。

Spring Security除了不能脱离SpringShiro的功能它都有。Spring SecuritySpring结合较好,如果项目用的springmvc,使用起来很方便。

我们公司的登录认证主要使用了Shiro,实现了登录认证以及oauth2认证。提供了接口供不同个性化登录实现,如:

  1. IdentityBuilder:构建登录身份,控制登录流程
  2. UserService:查询用户真实信息
  3. CredentialsMatcher:校验登录身份和真实信息
  4. NamedAuthenticationListener:登录成功、失败的后处理

参照这套认证体系,我们采用Spring Security来重构以下。

2.jwt和session的选择

基于session和基于jwt的方式的主要区别就是用户的状态保存的位置,session是保存在服务端的,而jwt是保存在客户端的。jwt的优点就不说了,主要说说缺点

  1. 由于jwt的payload是使用base64编码的,并没有加密,因此jwt中不能存储敏感数据。而session的信息是存在服务端的,相对来说更安全。
  2. jwt太长。由于是无状态使用JWT,所有的数据都被放到JWT里,如果还要进行一些数据交换,那载荷会更大,经过编码之后导致jwt非常长,cookie的限制大小一般是4k,cookie很可能放不下,所以jwt一般放在local storage里面。并且用户在系统中的每一次http请求都会把jwt携带在Header里面,http请求的Header可能比Body还要大。而sessionId只是很短的一个字符串,因此使用jwt的http请求比使用session的开销大得多。
  3. 无状态是jwt的特点,但也导致了这个问题,jwt是一次性的。想修改里面的内容,就必须签发一个新的jwt。

为了安全性我们也是选择session来保存用户状态,而我们公司的系统经常要在服务端根据session进行权限控制,而jwt主要用在移动端的无状态场景。

3.spring-security权限控制实现

3.1 AuthenticationBuilder对标IdentityBuilder

Spring Security用一个类专门负责接手前端数据构建登录身份,这个类时UsernamePasswordAuthenticationFilter,但是这个类只能接受url请求中的参数,对于请求参数不在url中的则没法解析。所以我们需要个性化这个类让他能够Request PayLoad中的数据。

/**
 * {@link UsernamePasswordAuthenticationFilter}
 */
public class FrameUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    private final AuthenticationBuilder authenticationBuilder;

    public FrameUsernamePasswordAuthenticationFilter(AuthenticationBuilder authenticationBuilder) {
        super(new AntPathRequestMatcher("/api/login", "POST"));
        this.authenticationBuilder = authenticationBuilder;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response) throws AuthenticationException {
        Authentication authRequest = authenticationBuilder.build(request, response);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

}

另外我们在封装一个AuthenticationBuilder接口用于个性化实现,并提供默认实现FrameAuthenticationBuilder

public class FrameAuthenticationBuilder implements AuthenticationBuilder {
    
    @Override
    public Authentication build(HttpServletRequest request, HttpServletResponse response) {
        LoginVO loginVO = JSON.parseObject(getRequestPayload(request), LoginVO.class);
        return new UsernamePasswordAuthenticationToken(
                loginVO.getUsername(), loginVO.getPassword());
    }

    public String getRequestPayload(HttpServletRequest request) {
        try {
            return IOUtils.toString(request.getReader());
        } catch (IOException ex) {
            ex.printStackTrace();
        }
        return "";
    }
}

3.2 UserDetailsService对标UserService

Spring Security有个类专门负责通过username查找用户信息的接口,这个接口就是UserDetailsService

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //可以从数据库里根据username去除用户信息
    }

3.3 DaoAuthenticationProvider对标CredentialsMatcher

AbstractUserDetailsAuthenticationProvider类提供了一个抽象方法additionalAuthenticationChecks用子类DaoAuthenticationProvider实现,UserDetails参数为AuthenticationBuilder构建的前端参数,UsernamePasswordAuthenticationTokenUserDetailsService通过前端传递的username去数据库中查询到的用户信息,对比两个对象的密码判断是否认证通过。值得一提的时密码提供了PasswordEncoder接口实现自定义加密类型,后续我们可以基于SM3加密算法来实现。

@SuppressWarnings("deprecation")
protected void additionalAuthenticationChecks(UserDetails userDetails,
        UsernamePasswordAuthenticationToken authentication)
        throws AuthenticationException {
    if (authentication.getCredentials() == null) {
        logger.debug("Authentication failed: no credentials provided");

        throw new BadCredentialsException(messages.getMessage(
                "AbstractUserDetailsAuthenticationProvider.badCredentials",
                "Bad credentials"));
    }

    String presentedPassword = authentication.getCredentials().toString();

    if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
        logger.debug("Authentication failed: password does not match stored value");

        throw new BadCredentialsException(messages.getMessage(
                "AbstractUserDetailsAuthenticationProvider.badCredentials",
                "Bad credentials"));
    }
}

shiro在抽象类AuthenticatingRealm也有类似的方法,并提供了CredentialsMatcher接口。

protected void assertCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) throws AuthenticationException {
    CredentialsMatcher cm = getCredentialsMatcher();
    if (cm != null) {
        if (!cm.doCredentialsMatch(token, info)) {
            //not successful - throw an exception to indicate this:
            String msg = "Submitted credentials for token [" + token + "] did not match the expected credentials.";
            throw new IncorrectCredentialsException(msg);
        }
    } else {
        throw new AuthenticationException("A CredentialsMatcher must be configured in order to verify " +
                "credentials during authentication.  If you do not wish for credentials to be examined, you " +
                "can configure an " + AllowAllCredentialsMatcher.class.getName() + " instance.");
    }
}

3.4 AuthenticationSuccessHandler、AuthenticationFailureHandler、AccessDeniedHandler和AuthenticationEntryPoint对标NamedAuthenticationListener

AuthenticationSuccessHandler接口主要用于实现用户登录成功后数据的返回,对应Shiro``AuthenticationListener类的onSuccess方法。
AuthenticationFailureHandler接口主要用于实现用户登录失败后的数据返回,对应Shiro``AuthenticationListener类的onFailure方法。
AccessDeniedHandler接口主要用于实现权限未认证数据的返回处理,也是对应Shiro``AuthenticationListener类的onFailure方法。
AuthenticationEntryPoint接口主要用于实现权限未认证时或其他异常数据的返回处理,也是对应Shiro``AuthenticationListener类的onFailure方法。

3.5 组装起来

Spring Security的配置方式非常优雅,通过HttpSecurity链式调用进行配置的

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.exceptionHandling()
            .accessDeniedHandler(accessDeniedHandler) // 处理认证失败
            .authenticationEntryPoint(authenticationEntryPoint) //数据处理终端
            .and()
            .formLogin()
            .successHandler(authenticationSucessHandler) // 处理登录成功
            .failureHandler(authenticationFailureHandler) // 处理登录失败
            .apply(new FrameUsernamePasswordAuthenticationConfigurer(authenticationBuilder, authenticationSuccessHandler, authenticationFailureHandler));
    }
}

3.6 如果AuthenticationBuilder有多个性化实现

Spring IOC如果有多个实现类我们在注入引用时如果不指定bean的名称是会报错的,本着开闭原则,注入bean的地方不可能每次个性化都去修改名称,所以我们能否实现多实现类是根据优先级进行选举,答案是可以的。在DefaultListableBeanFactory我们找到了determinePrimaryCandidate选举方法,但是这个个方法只能识别@Primary注解,对于多个实现类如果标记为@Primary则选取有标记的实现类,但是如果又有个实现类也标记了@Primary就又不行了,所以可以增加一个@FramePriority注解用来增加@Primary,并增加一个int类型参数值用于标记优先级,如果选举是根据获取这个值最大的实现类。

public class FrameListableBeanFactory extends DefaultListableBeanFactory {

    @Override
    protected String determinePrimaryCandidate(Map<String, Object> candidates, @Nullable Class<?> requiredType) {
        String primaryBeanName = null;
        for (Map.Entry<String, Object> entry : candidates.entrySet()) {
            String candidateBeanName = entry.getKey();
            Object beanInstance = entry.getValue();
            if (isPrimary(candidateBeanName, beanInstance)) {
                if (primaryBeanName != null) {
                    boolean candidateLocal = containsBeanDefinition(candidateBeanName);
                    boolean primaryLocal = containsBeanDefinition(primaryBeanName);
                    if (candidateLocal && primaryLocal) {
                        //获取自定义选举结果
                        candidateBeanName = determineBidPriorityCandidate(candidates);
                        if (candidateBeanName != null) {
                            return candidateBeanName;
                        }
                        throw new NoUniqueBeanDefinitionException(requiredType, candidates.size(),
                                "more than one 'primary' bean found among candidates: " + candidates.keySet());
                    } else if (candidateLocal) {
                        primaryBeanName = candidateBeanName;
                    }
                } else {
                    primaryBeanName = candidateBeanName;
                }
            }
        }
        return primaryBeanName;
    }

    protected String determineBidPriorityCandidate(Map<String, Object> candidates) {
        List<Object> list = new ArrayList<>(candidates.values());
        //先取出有@BidPriority注解的
        list = list.stream().filter(a -> a.getClass().getAnnotation(FramePriority.class) != null).collect(Collectors.toList());
        //如果没有直接返回
        if (list.isEmpty()) {
            return null;
        }
        //根据@BidPriority注解值排序一下
        list.sort(Comparator.comparingInt(a -> a.getClass().getAnnotation(FramePriority.class).value()));
        for (Map.Entry<String, Object> entry : candidates.entrySet()) {
            String candidateBeanName = entry.getKey();
            Object beanInstance = entry.getValue();
            //取排序后第一个即最小的
            if (beanInstance.equals(list.get(0))) {
                return candidateBeanName;
            }
        }
        return null;
    }
}

4.axios封装统一请求

前端我们采用vue,本次我们采用的时开源框架vbenvben是基于vue3开发的,支持TypeScript。前后端交互我们采用Axios封装请求。

function createAxios(opt?: Partial<CreateAxiosOptions>) {
  return new VAxios(
    deepMerge(
      {
        // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#authentication_schemes
        // authentication schemes,e.g: Bearer
        // authenticationScheme: 'Bearer',
        authenticationScheme: '',
        timeout: 10 * 1000,
        // 基础接口地址
        // baseURL: globSetting.apiUrl,
        // 后台增加X-Requested-With校验,有这个标记的表示前台请求 add by lurj
        headers: { 'Content-Type': ContentTypeEnum.JSON, 'X-Requested-With': 'XMLHttpRequest' },
        // 如果是form-data格式
        // headers: { 'Content-Type': ContentTypeEnum.FORM_URLENCODED },
        // 数据处理方式
        transform,
        // 配置项,下面的选项都可以在独立的接口请求中覆盖
        requestOptions: {
          // 默认将prefix 添加到url
          joinPrefix: true,
          // 是否返回原生响应头 比如:需要获取响应头时使用该属性
          isReturnNativeResponse: false,
          // 需要对返回数据进行处理
          isTransformResponse: true,
          // post请求的时候添加参数到url
          joinParamsToUrl: false,
          // 格式化提交参数时间
          formatDate: true,
          // 消息提示类型
          errorMessageMode: 'message',
          // 接口地址
          apiUrl: globSetting.apiUrl,
          // 接口拼接地址
          urlPrefix: urlPrefix,
          //  是否加入时间戳
          joinTime: true,
          // 忽略重复请求
          ignoreCancelToken: true,
          // 是否携带token
          withToken: true,
        },
      },
      opt || {},
    ),
  );
}
export const defHttp = createAxios();

增加一个方法专门处理后端返回的对象

  /**
   * @description: 处理请求数据。如果数据不是预期格式,可直接抛出错误
   */
  transformRequestHook: (res: AxiosResponse<Result>, options: RequestOptions) => {
    const { data } = res;
    if (!data) {
      // return '[HTTP] Request has no return value';
      throw new Error(t('sys.api.apiRequestFailed'));
    }
    //  这里 code,result,message为 后台统一的字段,需要在 types.ts内修改为项目自己的接口返回格式
    const { code, body, msg } = data;
    // 这里逻辑可以根据项目进行修改
    const hasSuccess = data && Reflect.has(data, 'code') && code === ResultEnum.SUCCESS;
    if (hasSuccess) {
      return body;
    }
    // 未登录跳转登录页面
    const hasForbidden = data && Reflect.has(data, 'code') && code === ResultEnum.FORBIDDEN;
    if (hasForbidden) {
      throw new Error('Access Denied');
    }
  }

在’vue-router’中进行权限控制

  router.beforeEach(async (to, from, next) => {
        if (whitePathList.includes(to.path as PageEnum)) {
        if (to.path === LOGIN_PATH) {
        const isSessionTimeout = userStore.getSessionTimeout;
        try {
            // 尝试自动登录,如果是已授权则直接跳转首页,未授权会被捕获异常
            await userStore.afterLoginAction();
            if (!isSessionTimeout) {
            next((to.query?.redirect as string) || '/');
            return;
            }
        } catch (error) {
            if (error instanceof Error && error.message === 'Access Denied') {
            userStore.setToken(undefined);
            userStore.setSessionTimeout(false);
            userStore.setUserInfo(null);
            userStore.$reset;
            console.log(userStore.getUserInfo);
            console.log(userStore.getUserInfo.userId);
            console.log(userStore.getToken);
            console.log(error.message);
            // 如果鉴权失败则继续跳转登录页
            next();
            return;
            }
        }
        }
        console.log(2);
        next();
        return;
        }
        // 非前后端分离框架未授权时直接从后台重定向到登录界面
        // 前后端分离框架需要有一个全局的认证处理逻辑,处理未授权时统一跳回登录界面
        // 前后端jwt模式会在前台缓存token,可以根据是否有token判断登录状态,使用session模式前台不存token,所以每次都需要访问服务端鉴权因此会降低性能
        // 为了防止一直访问服务端鉴权增加LastUpdateTime,整个页面刷新才会重置store
        // get userinfo while last fetch time is empty
        console.log(userStore.getLastUpdateTime);
        if (userStore.getLastUpdateTime === 0) {
            try {
            await userStore.getUserInfoAction();
            } catch (error) {
            if (error instanceof Error && error.message === 'Access Denied') {
                userStore.setToken(undefined);
                userStore.setSessionTimeout(false);
                userStore.setUserInfo(null);
                // 必须要充值不然有缓存
                userStore.$reset;
                console.log(userStore.getUserInfo);
                console.log(userStore.getUserInfo.userId);
                console.log(userStore.getToken);
                console.log(error.message);
                const redirectData: { path: string; replace: boolean; query?: Recordable<string> } = {
                path: LOGIN_PATH,
                replace: true,
                };
                if (to.path) {
                redirectData.query = {
                    ...redirectData.query,
                    redirect: to.path,
                };
                }
                console.log(redirectData);
                next(redirectData);
            } else {
                next();
            }
            return;
            }
        }
    }

5.sping-data对标DAO

DAO的特性就不多说了,此次我们不引入公司框架,为了实现数据库查询我们采用了sping-data,使用sping-data的好处是我们可以直接封装一个领域驱动中的仓储层。而我们也是运用领域驱动的思想进行设计的。

  • 仓储层
public interface FrameUserRepository extends PagingAndSortingRepository<FrameUser, String> {

    Optional<FrameUser> findByUsername(String username);

    Optional<FrameUser> findByUsernameOrMobile(String username, String mobile);
}
  • api层
public interface FrameUserService {

    FrameUser findByUsername(String username);

    FrameUser findByUsernameOrMobile(String username, String mobile);

    List<FrameRole> findRoleByUserguid(String userguid);
}
  • 实现层
@Component
public class FrameUserServiceImpl implements FrameUserService {

    private final FrameUserRepository frameUserRepository;
    private final FrameRoleRepository frameRoleRepository;
    private final FrameUserRoleRelationRepository frameUserRoleRelationRepository;

    public FrameUserServiceImpl(FrameUserRepository frameUserRepository,
                                FrameRoleRepository frameRoleRepository,
                                FrameUserRoleRelationRepository frameUserRoleRelationRepository) {
        this.frameUserRepository = frameUserRepository;
        this.frameRoleRepository = frameRoleRepository;
        this.frameUserRoleRelationRepository = frameUserRoleRelationRepository;
    }

    @Override
    public FrameUser findByUsername(String username) {
        return frameUserRepository.findByUsername(username).orElse(null);
    }

    @Override
    public FrameUser findByUsernameOrMobile(String username, String mobile) {
        return frameUserRepository.findByUsernameOrMobile(username, mobile).orElse(null);
    }

    @Override
    public List<FrameRole> findRoleByUserguid(String userguid) {
        List<FrameRole> list = new ArrayList<>();
        Iterable<FrameUserRoleRelation> frameUserRoleRelations = frameUserRoleRelationRepository.findByUserguid(userguid);
        frameUserRoleRelations.forEach(p -> frameRoleRepository.findById(p.getRoleguid()).ifPresent(list::add));
        return list;
    }
}
  • 控制层
@Slf4j
@RestController
@RequestMapping("/api/user")
public class FrameLoginController {

    private final FrameUserService frameUserService;

    public FrameLoginController(final FrameUserService frameUserService) {
        this.frameUserService = frameUserService;
    }

    @GetMapping(value = "/list")
    public Result<?> searchByPid(@RequestParam(value = "pid") String pid,
                                 Pageable pageable) {
        return Result.OK(frameUserService.findByPid(pid, pageable));
    }
}
  • 其他领域通过api调用
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        FrameUser frameUser = frameUserService.findByUsernameOrMobile(username, username);
        if (frameUser == null) {
            throw new UsernameNotFoundException("该用户不存在");
        }
    }

多数据源实现

使用spring-jdbJdbcTemplate实现,并采用druid进行连接池管理。

@Test
protected void test() {
    DruidDataSource dataSource = new DruidDataSource();
    dataSource.setUrl("jdbc:mysql://192.168.220.236:3306/jeecg-boot-dev?characterEncoding=UTF-8&useUnicode=true&useSSL=false&tinyInt1isBit=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai");
    dataSource.setUsername("root");
    dataSource.setPassword("123456");
    JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
    Integer count = jdbcTemplate.queryForObject("select count(*) from frame_user", Integer.class);
    String sql = "select * from frame_user where username=?";
    String name = "admin";
    List<Map<String, Object>> list = jdbcTemplate.query(sql, (rs, rowNum) -> {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("username", rs.getString("username"));
        return map;
    }, name);
    List<Map<String, Object>> list2 = jdbcTemplate.queryForList(sql, name);
    System.out.println(count);
}

最后谈下mybatis和mybatis-plus

知乎上看到一条评价很有意思:mybatis-plus就仿佛,你开着一辆名叫mybatis的手动挡汽车,然后请了一个叫mybatis-plus的人坐在副驾驶帮你挂档。既然如此为什么不从一开始直接开手动挡车呢?spring data jpa他不香吗?

主要是我不会用,也不想学。。配置太复杂

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值