SpringCloud微服务实战——搭建企业级开发框架(二十三):Gateway+OAuth2+JWT微服务统一认证授权【二】

举报
全栈程序猿 发表于 2023/07/17 14:49:09 2023/07/17
【摘要】 新建AuthResourceServerConfig.java对gateway网关服务进行配置安全配置,需要使用@EnableWebFluxSecurity而非@EnableWebSecurity,因为SpringCloud Gateway基于WebFluxpackage com.gitegg.gateway.config;import org.springframework.context...
  1. 新建AuthResourceServerConfig.java对gateway网关服务进行配置安全配置,需要使用@EnableWebFluxSecurity而非@EnableWebSecurity,因为SpringCloud Gateway基于WebFlux
package com.gitegg.gateway.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter;
import org.springframework.security.web.server.SecurityWebFilterChain;

import com.gitegg.gateway.auth.AuthorizationManager;
import com.gitegg.gateway.filter.WhiteListRemoveJwtFilter;
import com.gitegg.gateway.handler.AuthServerAccessDeniedHandler;
import com.gitegg.gateway.handler.AuthServerAuthenticationEntryPoint;
import com.gitegg.gateway.props.AuthUrlWhiteListProperties;
import com.gitegg.platform.base.constant.AuthConstant;

import cn.hutool.core.util.ArrayUtil;
import lombok.AllArgsConstructor;
import reactor.core.publisher.Mono;

/**
 * 资源服务器配置
 */
@AllArgsConstructor
@Configuration
// 注解需要使用@EnableWebFluxSecurity而非@EnableWebSecurity,因为SpringCloud Gateway基于WebFlux
@EnableWebFluxSecurity
public class AuthResourceServerConfig {

    private final AuthorizationManager authorizationManager;

    private final AuthServerAccessDeniedHandler authServerAccessDeniedHandler;

    private final AuthServerAuthenticationEntryPoint authServerAuthenticationEntryPoint;

    private final AuthUrlWhiteListProperties authUrlWhiteListProperties;

    private final WhiteListRemoveJwtFilter whiteListRemoveJwtFilter;

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
        http.oauth2ResourceServer().jwt()
                .jwtAuthenticationConverter(jwtAuthenticationConverter());
        // 自定义处理JWT请求头过期或签名错误的结果
        http.oauth2ResourceServer().authenticationEntryPoint(authServerAuthenticationEntryPoint);
        // 对白名单路径,直接移除JWT请求头,不移除的话,后台会校验jwt
        http.addFilterBefore(whiteListRemoveJwtFilter, SecurityWebFiltersOrder.AUTHENTICATION);
        http.authorizeExchange()
            .pathMatchers(ArrayUtil.toArray(authUrlWhiteListProperties.getUrls(), String.class)).permitAll()
                .anyExchange().access(authorizationManager)
                .and()
                .exceptionHandling()
                .accessDeniedHandler(authServerAccessDeniedHandler) // 处理未授权
                .authenticationEntryPoint(authServerAuthenticationEntryPoint) //处理未认证
                .and()
                .cors()
                .and().csrf().disable();

        return http.build();
    }

    /**
     * ServerHttpSecurity没有将jwt中authorities的负载部分当做Authentication,需要把jwt的Claim中的authorities加入
     * 解决方案:重新定义ReactiveAuthenticationManager权限管理器,默认转换器JwtGrantedAuthoritiesConverter
     */
    @Bean
    public Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        jwtGrantedAuthoritiesConverter.setAuthorityPrefix(AuthConstant.AUTHORITY_PREFIX);
        jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(AuthConstant.AUTHORITY_CLAIM_NAME);

        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
        return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
    }
}
  1. 新建AuthorizationManager.java实现ReactiveAuthorizationManager接口,用于自定义权限校验
package com.gitegg.gateway.auth;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpMethod;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.ReactiveAuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.server.authorization.AuthorizationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.PathMatcher;
import org.springframework.util.StringUtils;

import com.gitegg.platform.base.constant.AuthConstant;

import cn.hutool.core.convert.Convert;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Mono;

/**
 * 网关鉴权管理器
 */
@Slf4j
@Component
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class AuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {

    private final RedisTemplate redisTemplate;

    /**
     * 是否开启租户模式
     */
    @Value(("${tenant.enable}"))
    private Boolean enable;

    @Override
    public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
        ServerHttpRequest request = authorizationContext.getExchange().getRequest();
        String path = request.getURI().getPath();
        PathMatcher pathMatcher = new AntPathMatcher();

        // 对应跨域的预检请求直接放行
        if (request.getMethod() == HttpMethod.OPTIONS) {
            return Mono.just(new AuthorizationDecision(true));
        }

        // token为空拒绝访问
        String token = request.getHeaders().getFirst(AuthConstant.JWT_TOKEN_HEADER);
        if (StringUtils.isEmpty(token)) {
            return Mono.just(new AuthorizationDecision(false));
        }

        //  如果开启了租户模式,但是请求头里没有租户信息,那么拒绝访问
        String tenantId = request.getHeaders().getFirst(AuthConstant.TENANT_ID);
        if (enable && StringUtils.isEmpty(tenantId)) {
            return Mono.just(new AuthorizationDecision(false));
        }

        String redisRoleKey = AuthConstant.TENANT_RESOURCE_ROLES_KEY;
        // 判断是否开启了租户模式,如果开启了,那么按租户分类的方式获取角色权限
        if (enable) {
            redisRoleKey += tenantId;
        } else {
            redisRoleKey = AuthConstant.RESOURCE_ROLES_KEY;
        }

        //  缓存取资源权限角色关系列表
        Map<Object, Object> resourceRolesMap = redisTemplate.opsForHash().entries(redisRoleKey);
        Iterator<Object> iterator = resourceRolesMap.keySet().iterator();

        //请求路径匹配到的资源需要的角色权限集合authorities统计
        List<String> authorities = new ArrayList<>();
        while (iterator.hasNext()) {
            String pattern = (String) iterator.next();
            if (pathMatcher.match(pattern, path)) {
                authorities.addAll(Convert.toList(String.class, resourceRolesMap.get(pattern)));
            }
        }
        Mono<AuthorizationDecision> authorizationDecisionMono = mono
                .filter(Authentication::isAuthenticated)
                .flatMapIterable(Authentication::getAuthorities)
                .map(GrantedAuthority::getAuthority)
                .any(roleId -> {
                    // roleId是请求用户的角色(格式:ROLE_{roleId}),authorities是请求资源所需要角色的集合
                    log.info("访问路径:{}", path);
                    log.info("用户角色roleId:{}", roleId);
                    log.info("资源需要权限authorities:{}", authorities);
                    return authorities.contains(roleId);
                })
                .map(AuthorizationDecision::new)
                .defaultIfEmpty(new AuthorizationDecision(false));
        return authorizationDecisionMono;
    }
}

  1. 新建AuthGlobalFilter.java全局过滤器,解析用户请求信息,将用户信息及租户信息放在请求的Header中,这样后续服务就不需要解析JWT令牌了,可以直接从请求的Header中获取到用户和租户信息。
package com.gitegg.gateway.filter;

import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.text.ParseException;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Consumer;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;

import com.gitegg.platform.base.constant.AuthConstant;
import com.nimbusds.jose.JWSObject;

import cn.hutool.core.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Mono;

/**
 * 将登录用户的JWT转化成用户信息的全局过滤器
 */
@Slf4j
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {

    /**
     * 是否开启租户模式
     */
    @Value(("${tenant.enable}"))
    private Boolean enable;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {

        String tenantId = exchange.getRequest().getHeaders().getFirst(AuthConstant.TENANT_ID);

        String token = exchange.getRequest().getHeaders().getFirst(AuthConstant.JWT_TOKEN_HEADER);

        if (StrUtil.isEmpty(tenantId) && StrUtil.isEmpty(token)) {
            return chain.filter(exchange);
        }

        Map<String, String> addHeaders = new HashMap<>();

        // 如果系统配置已开启租户模式,设置tenantId
        if (enable && StrUtil.isEmpty(tenantId)) {
            addHeaders.put(AuthConstant.TENANT_ID, tenantId);
        }

        if (!StrUtil.isEmpty(token)) {
        try {
            //从token中解析用户信息并设置到Header中去
            String realToken = token.replace("Bearer ", "");
            JWSObject jwsObject = JWSObject.parse(realToken);
            String userStr = jwsObject.getPayload().toString();
            log.info("AuthGlobalFilter.filter() User:{}", userStr);
            addHeaders.put(AuthConstant.HEADER_USER, URLEncoder.encode(userStr, "UTF-8"));

        } catch (ParseException | UnsupportedEncodingException e) {
            e.printStackTrace();
        }
    }

    Consumer<HttpHeaders> httpHeaders = httpHeader -> {
        addHeaders.forEach((k, v) -> {
            httpHeader.set(k, v);
        });
    };

    ServerHttpRequest request = exchange.getRequest().mutate().headers(httpHeaders).build();
    exchange = exchange.mutate().request(request).build();
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return 0;
    }
}

  1. 在Nacos中添加权限相关配置信息:
spring:
  jackson:
    time-zone: Asia/Shanghai
    date-format: yyyy-MM-dd HH:mm:ss
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: 'http://127.0.0.1/gitegg-oauth/oauth/public_key'
# 多租户配置
tenant:
  # 是否开启租户模式
  enable: true
  # 需要排除的多租户的表
  exclusionTable:
    - "t_sys_district"
    - "t_sys_tenant"
    - "t_sys_role"
    - "t_sys_resource"
    - "t_sys_role_resource"
  # 租户字段名称
  column: tenant_id
# 网关放行白名单,配置白名单路径
white-list:
  urls:
    - "/gitegg-oauth/oauth/public_key"

二、注销登录使JWT失效

  因为JWT是无状态的且不在服务端储存,所以,当系统在执行退出登录时就无法使JWT失效,我们有两种方式拒绝注销登录后的JWT:

  • JWT白名单:每次登录成功就将JWT存到缓存中,缓存有效期和JWT有效期保持一致,注销登录就将JWT从缓存中移出。Gateway每次认证授权先从缓存JWT白名单中获取是否存在该JWT,存在则继续校验,不存在则拒绝访问。

  • JWT黑名单:每当注销登录时,将JWT存到缓存中,解析JWT的到期时间,将缓存过期时间设置为和JWT一致。Gateway每次认证授权先从缓存中获取JWT是否存在于黑名单中,存在则拒绝访问,不存在则继续校验。

  不管是白名单还是黑名单,实现方式的原理都基本一致,就是将JWT先存放到缓存,再根据不同的状态进行判断JWT是否有效,下面是两种方式的优缺点分析:

  • 黑名单功能分析:优点是存放到缓存的数据量将小于白名单方式存放的数据量,缺点是无法获知当前签发了多少JWT,当前在线多少登录用户。
  • 白名单功能分析:优点是当我们需要统计在线用户的时候,白名单方式可以近似的获取到当前系统登录用户,可以扩展踢出登录用户的功能。缺点是数据存储量大,且大量token存在缓存中需要进行校验,万一被攻击会导致大量信息泄露。

综上考虑,还是采用黑名单的方式来实现注销登录功能,实时统计在线人数和踢出用户等功能作为扩展功能来开发,不在登录注销逻辑中掺杂太多的业务处理逻辑,使系统保持低耦合。

为了使JWT有效信息最大程度保证准确性,注销登录除了在系统点击退出登录按钮,还需要监测是否直接关闭页面,关闭浏览器事件,来执行调用系统注销接口

token和refresh_token的过期时间不一致,都在其解析之后的exp字段。因为我们定制了黑名单模式,当用户点击退出登录之后,我们会把refresh_token也加入黑名单,在refresh_token获取刷新token的时候,需要定制校验refresh_token是否被加入到黑名单。

  1. 退出登录接口将token和refresh_token加入黑名单
        /**
     * 退出登录需要需要登录的一点思考:
     * 1、如果不需要登录,那么在调用接口的时候就需要把token传过来,且系统不校验token有效性,此时如果系统被攻击,不停的大量发送token,最后会把redis充爆
     * 2、如果调用退出接口必须登录,那么系统会调用token校验有效性,refresh_token通过参数传过来加入黑名单
     * 综上:选择调用退出接口需要登录的方式
     * @param request
     * @return
     */
    @PostMapping("/logout")
    public Result logout(HttpServletRequest request) {

        String token = request.getHeader(AuthConstant.JWT_TOKEN_HEADER);
        String refreshToken = request.getParameter(AuthConstant.REFRESH_TOKEN);
        long currentTimeSeconds = System.currentTimeMillis() / GitEggConstant.Number.THOUSAND;

        // 将token和refresh_token同时加入黑名单
        String[] tokenArray = new String[GitEggConstant.Number.TWO];
        tokenArray[GitEggConstant.Number.ZERO] = token.replace("Bearer ", "");
        tokenArray[GitEggConstant.Number.ONE] = refreshToken;
        for (int i = GitEggConstant.Number.ZERO; i < tokenArray.length; i++) {
            String realToken = tokenArray[i];
            JSONObject jsonObject = JwtUtils.decodeJwt(realToken);
            String jti = jsonObject.getAsString("jti");
            Long exp = Long.parseLong(jsonObject.getAsString("exp"));
            if (exp - currentTimeSeconds > GitEggConstant.Number.ZERO) {
                redisTemplate.opsForValue().set(AuthConstant.TOKEN_BLACKLIST + jti, jti, (exp - currentTimeSeconds), TimeUnit.SECONDS);
            }
        }
        return Result.success();
    }
  1. Gateway在AuthorizationManager中添加token是否加入黑名单的判断
        //如果token被加入到黑名单,就是执行了退出登录操作,那么拒绝访问
        String realToken = token.replace("Bearer ", "");
        try {
            JWSObject jwsObject = JWSObject.parse(realToken);
            Payload payload = jwsObject.getPayload();
            JSONObject jsonObject = payload.toJSONObject();
            String jti = jsonObject.getAsString("jti");
            String blackListToken = (String)redisTemplate.opsForValue().get(AuthConstant.TOKEN_BLACKLIST + jti);
            if (!StringUtils.isEmpty(blackListToken)) {
                return Mono.just(new AuthorizationDecision(false));
            }
        } catch (ParseException e) {
            e.printStackTrace();
        }
  1. 自定义DefaultTokenService,校验refresh_token是否被加入黑名单
@Slf4j
public class GitEggTokenServices extends DefaultTokenServices {

    private final RedisTemplate redisTemplate;

    public GitEggTokenServices(RedisTemplate redisTemplate)
    {
        this.redisTemplate = redisTemplate;
    }

    @Transactional(
            noRollbackFor = {InvalidTokenException.class, InvalidGrantException.class}
    )
    @Override
    public OAuth2AccessToken refreshAccessToken(String refreshTokenValue, TokenRequest tokenRequest) throws AuthenticationException {

        JSONObject jsonObject = null;
        String jti = null;
        //如果refreshToken被加入到黑名单,就是执行了退出登录操作,那么拒绝访问
        try {
            JWSObject jwsObject = JWSObject.parse(refreshTokenValue);
            Payload payload = jwsObject.getPayload();
            jsonObject = payload.toJSONObject();
            jti = jsonObject.getAsString(TokenConstant.JTI);
            String blackListToken = (String)redisTemplate.opsForValue().get(AuthConstant.TOKEN_BLACKLIST + jti);
            if (!StringUtils.isEmpty(blackListToken)) {
                throw new InvalidTokenException("Invalid refresh token (blackList): " + refreshTokenValue);
            }
        } catch (ParseException e) {
            log.error("获取refreshToken黑名单时发生错误:{}", e);
        }

       OAuth2AccessToken oAuth2AccessToken = super.refreshAccessToken(refreshTokenValue, tokenRequest);

        // RefreshToken不支持重复使用,如果使用一次,则加入黑名单不再允许使用,当刷新token执行完之后,即校验过RefreshToken之后,才执行存redis操作
        if (null != jsonObject && !StringUtils.isEmpty(jti)) {
            long currentTimeSeconds = System.currentTimeMillis() / GitEggConstant.Number.THOUSAND;
            Long exp = Long.parseLong(jsonObject.getAsString(TokenConstant.EXP));
            if (exp - currentTimeSeconds > GitEggConstant.Number.ZERO) {
                redisTemplate.opsForValue().set(AuthConstant.TOKEN_BLACKLIST + jti, jti, (exp - currentTimeSeconds), TimeUnit.SECONDS);
            }
        }

        return oAuth2AccessToken;
    }
}
测试:
  1. 使用密码模式获取token
    Headers里面加TenantId:0参数

密码模式获取token

  1. 通过refresh_token刷新token

refresh_token刷新token

  1. 再次执行refresh_token刷新token,此时因为refresh_token已经调用过一次,所以这里不能再次使用

refresh_token已过期

三、前端自动使用refresh_token刷新token

  1. 使用axios-auth-refresh公共组件,当后台状态返回401时,进行token刷新操作
import axios from 'axios'
import createAuthRefreshInterceptor from 'axios-auth-refresh'
import store from '@/store'
import storage from 'store'
import { serialize } from '@/utils/util'
import notification from 'ant-design-vue/es/notification'
import modal from 'ant-design-vue/es/modal'
import { VueAxios } from './axios'
import { ACCESS_TOKEN, REFRESH_ACCESS_TOKEN } from '@/store/mutation-types'

// 创建 axios 实例
const request = axios.create({
  // API 请求的默认前缀
  baseURL: process.env.VUE_APP_API_BASE_URL,
  timeout: 30000 // 请求超时时间
})

// 当token失效时,需要调用的刷新token的方法
const refreshAuthLogic = failedRequest =>
  axios.post(process.env.VUE_APP_API_BASE_URL + '/gitegg-oauth/oauth/token',
  serialize({ client_id: process.env.VUE_APP_CLIENT_ID,
      client_secret: process.env.VUE_APP_CLIENT_SECRET,
      grant_type: 'refresh_token',
      refresh_token: storage.get(REFRESH_ACCESS_TOKEN)
    }),
    {
      headers: { 'TenantId': process.env.VUE_APP_TENANT_ID, 'Content-Type': 'application/x-www-form-urlencoded' }
    }
    ).then(tokenRefreshResponse => {
      if (tokenRefreshResponse.status === 200 && tokenRefreshResponse.data && tokenRefreshResponse.data.success) {
        const result = tokenRefreshResponse.data.data
        storage.set(ACCESS_TOKEN, result.tokenHead + result.token, result.expiresIn * 1000)
        storage.set(REFRESH_ACCESS_TOKEN, result.refreshToken, result.refreshExpiresIn * 1000)
        failedRequest.response.config.headers['Authorization'] = result.tokenHead + result.token
      }
      return Promise.resolve()
})

// 初始化刷新token拦截器
createAuthRefreshInterceptor(request, refreshAuthLogic, {
  pauseInstanceWhileRefreshing: true // 当刷新token执行时,暂停其他请求
})

// 异常拦截处理器
const errorHandler = (error) => {
  if (error.response) {
    const data = error.response.data
    if (error.response.status === 403) {
      notification.error({
        message: '禁止访问',
        description: data.message
      })
    } else if (error.response.status === 401 && !(data.result && data.result.isLogin)) {
      // 当刷新token超时,则调到登录页面
      modal.warn({
        title: '登录超时',
        content: '由于您长时间未操作, 为确保安全, 请重新登录系统进行后续操作 !',
        okText: '重新登录',
        onOk () {
            store.dispatch('Timeout').then(() => {
                window.location.reload()
            })
         }
      })
    }
  }
  return Promise.reject(error)
}

// request interceptor
request.interceptors.request.use(config => {
  const token = storage.get(ACCESS_TOKEN)
  // 如果 token 存在
  // 让每个请求携带自定义 token 请根据实际情况自行修改
  if (token) {
    config.headers['Authorization'] = token
  }
  config.headers['TenantId'] = process.env.VUE_APP_TENANT_ID
  return config
}, errorHandler)

// response interceptor
request.interceptors.response.use((response) => {
  const res = response.data
  if (res.code) {
    if (res.code !== 200) {
      notification.error({
        message: '操作失败',
        description: res.msg
      })
      return Promise.reject(new Error(res.msg || 'Error'))
    } else {
      return response.data
    }
  } else {
    return response
  }
}, errorHandler)

const installer = {
  vm: {},
  install (Vue) {
    Vue.use(VueAxios, request)
  }
}

export default request

export {
  installer as VueAxios,
  request as axios
}

四、记住密码功能实现

有时候,在我们在可信任的电脑上可以实现记住密码功能,前后端分离项目的实现只需要把密码记录到localstorage中,然后每次访问登录界面时,自动填入即可。这里先使用明文进行存储,为了系统安全,在实际应用过程需要将密码加密存储,后台校验加密后的密码

  1. 在created中读取是否记住密码
created () {
    this.queryCaptchaType()
      this.$nextTick(() => {
        const rememberMe = storage.get(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-rememberMe')
          if (rememberMe) {
            const username = storage.get(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-username')
            const password = storage.get(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-password')
            if (username !== '' && password !== '') {
            this.form.setFieldsValue({ 'username': username })
            this.form.setFieldsValue({ 'password': password })
            this.form.setFieldsValue({ 'rememberMe': true })
          }
        }
      })
  },
  1. 每次登录成功之后,根据是否勾选记住密码来确定是否填入用户名密码
     // 判断是否记住密码
      const rememberMe = this.form.getFieldValue('rememberMe')
      const username = this.form.getFieldValue('username')
      const password = this.form.getFieldValue('password')
      if (rememberMe && username !== '' && password !== '') {
          storage.set(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-username', username, 60 * 60 * 24 * 7 * 1000)
          storage.set(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-password', password, 60 * 60 * 24 * 7 * 1000)
          storage.set(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-rememberMe', true, 60 * 60 * 24 * 7 * 1000)
      } else {
         storage.remove(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-username')
         storage.remove(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-password')
         storage.remove(process.env.VUE_APP_TENANT_ID + '-' + process.env.VUE_APP_CLIENT_ID + '-rememberMe')
      }

五、密码尝试次数过多则锁定账户

从系统安全方面来讲,我们需要支持防止用户账户被暴力破解的措施,目前技术已经能够轻松破解大多数的验证码,这为暴力破解用户账户提供了方便,那么这里我们的系统需要密码尝试次数过多锁定账户的功能。SpringSecurity的UserDetails接口定义了isAccountNonLocked方法来判断账户是否被锁定

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();

    String getPassword();

    String getUsername();

    boolean isAccountNonExpired();

    boolean isAccountNonLocked();

    boolean isCredentialsNonExpired();

    boolean isEnabled();
}
  1. 自定义LoginFailureListener事件监听器,监听SpringSecurity抛出AuthenticationFailureBadCredentialsEvent异常事件,使用Redis计数器,记录账号错误密码次数
/**
 * 当登录失败时的调用,当密码错误过多时,则锁定账户
 * @author GitEgg
 * @date 2021-03-12 17:57:05
 **/
@Slf4j
@Component
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class LoginFailureListener implements ApplicationListener<AuthenticationFailureBadCredentialsEvent> {

    private final UserDetailsService userDetailsService;

    private final RedisTemplate redisTemplate;

    @Value("${system.maxTryTimes}")
    private int maxTryTimes;

    @Override
    public void onApplicationEvent(AuthenticationFailureBadCredentialsEvent event) {

        if (event.getException().getClass().equals(UsernameNotFoundException.class)) {
            return;
        }

        String userName = event.getAuthentication().getName();

        GitEggUserDetails user = (GitEggUserDetails) userDetailsService.loadUserByUsername(userName);

        if (null != user) {
            Object lockTimes = redisTemplate.boundValueOps(AuthConstant.LOCK_ACCOUNT_PREFIX + user.getId()).get();
            if(null == lockTimes || (int)lockTimes <= maxTryTimes){
                redisTemplate.boundValueOps(AuthConstant.LOCK_ACCOUNT_PREFIX + user.getId()).increment(GitEggConstant.Number.ONE);
            }
        }
    }
}
  1. GitEggUserDetailsServiceImpl方法查询Redis记录的账号锁定次数
            // 判断账号是否被锁定(账户过期,凭证过期等可在此处扩展)
            Object lockTimes = redisTemplate.boundValueOps(AuthConstant.LOCK_ACCOUNT_PREFIX + gitEggUser.getId()).get();
            boolean accountNotLocked = true;
            if(null != lockTimes && (int)lockTimes >= maxTryTimes){
                accountNotLocked = false;
            }

六、登录时是否需要输入验证码

验证码设置前三次(可配置)登录时,不需要输入验证码,当密码尝试次数大于三次时,需要输入验证码,登录方式的一个思路:初始进入登录界面,用户可选择自己的登录方式,我们系统OAuth默认设置了三种登录方式:

  • 用户名+密码登录
  • 用户名+密码+验证码
  • 手机号+验证码登录

系统默认采用用户名+密码登录,当默认的用户名密码登录错误次数(默认一次)超过系统配置的最大次数时,则必须输入验证码登录,当验证码也超过一定次数时(默认五次),都不行则锁定账户二小时之后才可以继续尝试。因为考虑到有些系统可能不会用到短信验证码等,所以这里作为一个扩展功能:如果有需要可以在用户名密码错误过多时,强制只用短信验证码才能登录,且一定要设置超过错误次数就锁定。

  1. 在自定义的GitEggUserDetailsServiceImpl增加账号判断
            // 从Redis获取账号密码错误次数
            Object lockTimes = redisTemplate.boundValueOps(AuthConstant.LOCK_ACCOUNT_PREFIX + gitEggUser.getId()).get();

            // 判断账号密码输入错误几次,如果输入错误多次,则锁定账号
            // 输入错误大于配置的次数,必须选择captcha或sms_captcha
            if (null != lockTimes && (int)lockTimes >= maxNonCaptchaTimes && ( StringUtils.isEmpty(authGrantType) || (!StringUtils.isEmpty(authGrantType)
                    && !AuthEnum.SMS_CAPTCHA.code.equals(authGrantType) && !AuthEnum.CAPTCHA.code.equals(authGrantType)))) {
                throw new GitEggOAuth2Exception(ResultCodeEnum.INVALID_PASSWORD_CAPTCHA.msg);
            }

            // 判断账号是否被锁定(账户过期,凭证过期等可在此处扩展)
            if(null != lockTimes && (int)lockTimes >= maxTryTimes){
                throw new LockedException(ResultCodeEnum.PASSWORD_TRY_MAX_ERROR.msg);
            }

            // 判断账号是否被禁用
            String userStatus = gitEggUser.getStatus();
            if (String.valueOf(GitEggConstant.DISABLE).equals(userStatus)) {
                throw new DisabledException(ResultCodeEnum.DISABLED_ACCOUNT.msg);
            }
  1. 自定义OAuth2拦截异常并统一处理
/**
 * 自定义Oauth异常拦截处理器
 */
@Slf4j
@RestControllerAdvice
public class GitEggOAuth2ExceptionHandler {

    @ExceptionHandler(InvalidTokenException.class)
    public Result handleInvalidTokenException(InvalidTokenException e) {
        return Result.error(ResultCodeEnum.UNAUTHORIZED);
    }

    @ExceptionHandler({UsernameNotFoundException.class})
    public Result handleUsernameNotFoundException(UsernameNotFoundException e) {
        return Result.error(ResultCodeEnum.INVALID_USERNAME_PASSWORD);
    }

    @ExceptionHandler({InvalidGrantException.class})
    public Result handleInvalidGrantException(InvalidGrantException e) {
        return Result.error(ResultCodeEnum.INVALID_USERNAME_PASSWORD);
    }

    @ExceptionHandler(InternalAuthenticationServiceException.class)
    public Result handleInvalidGrantException(InternalAuthenticationServiceException e) {
        Result result = Result.error(ResultCodeEnum.INVALID_USERNAME_PASSWORD);
        if (null != e) {
            String errorMsg = e.getMessage();
            if (ResultCodeEnum.INVALID_PASSWORD_CAPTCHA.getMsg().equals(errorMsg)) {
                //必须使用验证码
                result = Result.error(ResultCodeEnum.INVALID_PASSWORD_CAPTCHA);
            }
            else if (ResultCodeEnum.PASSWORD_TRY_MAX_ERROR.getMsg().equals(errorMsg)) {
                //账号被锁定
                result = Result.error(ResultCodeEnum.PASSWORD_TRY_MAX_ERROR);
            }
            else if (ResultCodeEnum.DISABLED_ACCOUNT.getMsg().equals(errorMsg)) {
                //账号被禁用
                result = Result.error(ResultCodeEnum.DISABLED_ACCOUNT);
            }
        }
        return result;
    }
}
  1. 前端登录页面增加判断,默认采用password方式登录,当错误达到一定次数时,必须使用验证码登录
    requestFailed (err) {
      this.isLoginError = true
      if (err && err.code === 427) {
        // 密码错误次数超过最大限值,请选择验证码模式登录
        if (this.customActiveKey === 'tab_account') {
            this.grantType = 'captcha'
        } else {
            this.grantType = 'sms_captcha'
        }
        this.loginErrorMsg = err.msg
        if (this.loginCaptchaType === 'sliding') {
            this.$refs.verify.show()
        }
      } else if (err) {
            this.loginErrorMsg = err.msg
      }
    }
GitEgg-Cloud是一款基于SpringCloud整合搭建的企业级微服务应用开发框架,开源项目地址:

Gitee: https://gitee.com/wmz1930/GitEgg

GitHub: https://github.com/wmz1930/GitEgg

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。