当前位置: 首页 > 图灵资讯 > 技术篇> SpringCloud微服务实战——搭建企业级开发框架(二十三):Gateway+OAuth2+JWT实现微服务统一认证授权

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

来源:图灵教育
时间:2023-10-20 17:55:08

  OAuth2是一个关于授权的开放标准,核心思路是通过各类认证手段(具体什么手段OAuth2不关心)认证用户身份,并颁发token(令牌),使得第三方应用可以使用该token(令牌)在限定时间、限定范围访问指定资源。  OAuth2中使用token验证用户登录合法性,但token最大的问题是不携带用户信息,资源服务器无法在本地进行验证,每次对于资源的访问,资源服务器都需要向认证服务器发起请求,一是验证token的有效性,二是获取token对应的用户信息。如果有大量的此类请求,无疑处理效率是很低,且认证服务器会变成一个中心节点,这在分布式架构下很影响性能。如果认证服务器颁发的是jwt格式的token,那么资源服务器就可以直接自己验证token的有效性并绑定用户,这无疑大大提升了处理效率且减少了单点隐患。  SpringCloud认证授权解决思路:认证服务负责认证,网关负责校验认证和鉴权,其他API服务负责处理自己的业务逻辑。安全相关的逻辑只存在于认证服务和网关服务中,其他服务只是单纯地提供服务而没有任何安全相关逻辑。微服务鉴权功能划分:

  • gitegg-oauth:Oauth2用户认证和单点登录
  • gitegg-gateway:请求转发和统一鉴权
  • gitegg-system: 读取系统配置的RBAC权限配置并存放到缓存
一、鉴权配置
  1. GitEgg-Platform工程下新建gitegg-platform-oauth2工程,用于统一管理OAuth2版本,及统一配置
<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0"         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">    <parent>        <artifactId>GitEgg-Platform</artifactId>        <groupId>com.gitegg.platform</groupId>        <version>1.0-SNAPSHOT</version>    </parent>    <modelVersion>4.0.0</modelVersion>    <artifactId>gitegg-platform-oauth2</artifactId>    <name>${project.artifactId}</name>    <packaging>jar</packaging>    <dependencies>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-configuration-processor</artifactId>        </dependency>        <dependency>            <groupId>org.springframework.cloud</groupId>            <artifactId>spring-cloud-starter-oauth2</artifactId>        </dependency>        <dependency>            <groupId>org.springframework.security</groupId>            <artifactId>spring-security-oauth2-jose</artifactId>        </dependency>        <dependency>            <groupId>org.springframework.security</groupId>            <artifactId>spring-security-oauth2-resource-server</artifactId>        </dependency>        <dependency>            <groupId>com.gitegg.platform</groupId>            <artifactId>gitegg-platform-swagger</artifactId>            <optional>true</optional>        </dependency>    </dependencies></project>
  1. 在gitegg-oauth工程中引入需要的库
<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0"         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">    <parent>        <artifactId>GitEgg-Cloud</artifactId>        <groupId>com.gitegg.cloud</groupId>        <version>1.0-SNAPSHOT</version>    </parent>    <modelVersion>4.0.0</modelVersion>    <artifactId>gitegg-oauth</artifactId>    <name>${project.artifactId}</name>    <packaging>jar</packaging>    <dependencies>        <!-- gitegg-platform-boot -->        <dependency>            <groupId>com.gitegg.platform</groupId>            <artifactId>gitegg-platform-boot</artifactId>            <version>${gitegg.project.version}</version>        </dependency>        <!-- gitegg-platform-cloud -->        <dependency>            <groupId>com.gitegg.platform</groupId>            <artifactId>gitegg-platform-cloud</artifactId>            <version>${gitegg.project.version}</version>        </dependency>        <!-- gitegg-platform-oauth2 -->        <dependency>            <groupId>com.gitegg.platform</groupId>            <artifactId>gitegg-platform-oauth2</artifactId>            <version>${gitegg.project.version}</version>        </dependency>        <!-- gitegg数据库驱动及连接池 -->        <dependency>            <groupId>com.gitegg.platform</groupId>            <artifactId>gitegg-platform-db</artifactId>        </dependency>        <!-- gitegg mybatis-plus -->        <dependency>            <groupId>com.gitegg.platform</groupId>            <artifactId>gitegg-platform-mybatis</artifactId>        </dependency>        <!-- 验证码 -->        <dependency>            <groupId>com.gitegg.platform</groupId>            <artifactId>gitegg-platform-captcha</artifactId>        </dependency>        <!-- gitegg-service-system 的fegin公共调用方法 -->        <dependency>            <groupId>com.gitegg.cloud</groupId>            <artifactId>gitegg-service-system-api</artifactId>            <version>${gitegg.project.version}</version>        </dependency>        <dependency>            <groupId>org.apache.tomcat.embed</groupId>            <artifactId>tomcat-embed-core</artifactId>        </dependency>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-data-redis</artifactId>        </dependency>    </dependencies></project>
  1. JWT可以使用HMAC算法或使用RSA的公钥/私钥对来签名,防止被篡改。首先我们使用keytool生成RSA证书gitegg.jks,复制到gitegg-oauth工程的resource目录下,CMD命令行进入到JDK安装目录的bin目录下, 使用keytool命令生成gitegg.jks证书
keytool -genkey -alias gitegg -keyalg RSA -keystore gitegg.jks
  1. 新建GitEggUserDetailsServiceImpl.java实现SpringSecurity获取用户信息接口,用于SpringSecurity鉴权时获取用户信息
package com.gitegg.oauth.service;import javax.servlet.http.HttpServletRequest;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.security.core.authority.AuthorityUtils;import org.springframework.security.core.userdetails.UserDetailsService;import org.springframework.security.core.userdetails.UsernameNotFoundException;import org.springframework.security.oauth2.common.exceptions.UserDeniedAuthorizationException;import org.springframework.stereotype.Service;import org.springframework.util.CollectionUtils;import org.springframework.util.StringUtils;import com.gitegg.oauth.enums.AuthEnum;import com.gitegg.platform.base.constant.AuthConstant;import com.gitegg.platform.base.domain.GitEggUser;import com.gitegg.platform.base.enums.ResultCodeEnum;import com.gitegg.platform.base.result.Result;import com.gitegg.service.system.api.feign.IUserFeign;import cn.hutool.core.bean.BeanUtil;import lombok.RequiredArgsConstructor;/** *  实现SpringSecurity获取用户信息接口 * * @author gitegg */@Service@RequiredArgsConstructor(onConstructor = @__(@Autowired))public class GitEggUserDetailsServiceImpl implements UserDetailsService {    private final IUserFeign userFeign;    private final HttpServletRequest request;    @Override    public GitEggUserDetails loadUserByUsername(String username) {        // 获取登录类型,密码,二维码,验证码        String authLoginType = request.getParameter(AuthConstant.AUTH_TYPE);        // 获取客户端id        String clientId = request.getParameter(AuthConstant.AUTH_CLIENT_ID);        // 远程调用返回数据        Result<Object> result;        // 通过手机号码登录        if (!StringUtils.isEmpty(authLoginType) && AuthEnum.PHONE.code.equals(authLoginType))        {            String phone = request.getParameter(AuthConstant.PHONE_NUMBER);            result = userFeign.queryUserByPhone(phone);        }        // 通过账号密码登录        else if(!StringUtils.isEmpty(authLoginType) && AuthEnum.QR.code.equals(authLoginType))        {            result = userFeign.queryUserByAccount(username);        }        else        {            result = userFeign.queryUserByAccount(username);        }        // 判断返回信息        if (null != result && result.isSuccess()) {            GitEggUser gitEggUser = new GitEggUser();            BeanUtil.copyProperties(result.getData(), gitEggUser, false);            if (gitEggUser == null || gitEggUser.getId() == null) {                throw new UsernameNotFoundException(ResultCodeEnum.INVALID_USERNAME.msg);            }            if (CollectionUtils.isEmpty(gitEggUser.getRoleIdList())) {                throw new UserDeniedAuthorizationException(ResultCodeEnum.INVALID_ROLE.msg);            }            return new GitEggUserDetails(gitEggUser.getId(), gitEggUser.getTenantId(), gitEggUser.getOauthId(),                gitEggUser.getNickname(), gitEggUser.getRealName(), gitEggUser.getOrganizationId(),                gitEggUser.getOrganizationName(),                    gitEggUser.getOrganizationIds(), gitEggUser.getOrganizationNames(), gitEggUser.getRoleId(), gitEggUser.getRoleIds(), gitEggUser.getRoleName(), gitEggUser.getRoleNames(),                gitEggUser.getRoleIdList(), gitEggUser.getRoleKeyList(), gitEggUser.getResourceKeyList(),                gitEggUser.getDataPermission(),                gitEggUser.getAvatar(), gitEggUser.getAccount(), gitEggUser.getPassword(), true, true, true, true,                AuthorityUtils.createAuthorityList(gitEggUser.getRoleIdList().toArray(new String[gitEggUser.getRoleIdList().size()])));        } else {            throw new UsernameNotFoundException(result.getMsg());        }    }}
  1. 新建AuthorizationServerConfig.java用于认证服务相关配置,正式环境请一定记得修改gitegg.jks配置的密码,这里默认为123456。TokenEnhancer 为登录用户的扩展信息,可以自己定义。
package com.gitegg.oauth.config;import java.security.KeyPair;import java.util.ArrayList;import java.util.HashMap;import java.util.List;import java.util.Map;import javax.sql.DataSource;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.beans.factory.annotation.Value;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.core.io.ClassPathResource;import org.springframework.data.redis.core.RedisTemplate;import org.springframework.security.authentication.AuthenticationManager;import org.springframework.security.core.userdetails.UserDetailsService;import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;import org.springframework.security.oauth2.provider.TokenGranter;import org.springframework.security.oauth2.provider.token.TokenEnhancer;import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;import org.springframework.security.oauth2.provider.token.store.KeyStoreKeyFactory;import com.anji.captcha.service.CaptchaService;import com.gitegg.oauth.granter.GitEggTokenGranter;import com.gitegg.oauth.service.GitEggClientDetailsServiceImpl;import com.gitegg.oauth.service.GitEggUserDetails;import com.gitegg.platform.base.constant.AuthConstant;import com.gitegg.platform.base.constant.TokenConstant;import com.gitegg.service.system.api.feign.IUserFeign;import lombok.RequiredArgsConstructor;import lombok.SneakyThrows;/** * 认证服务配置 */@Configuration@EnableAuthorizationServer@RequiredArgsConstructor(onConstructor_ = @Autowired)public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {    private final DataSource dataSource;    private final AuthenticationManager authenticationManager;    private final UserDetailsService userDetailsService;    private final IUserFeign userFeign;    private final RedisTemplate redisTemplate;    private final CaptchaService captchaService;    @Value("${captcha.type}")    private String captchaType;    /**     * 客户端信息配置     */    @Override    @SneakyThrows    public void configure(ClientDetailsServiceConfigurer clients) {        GitEggClientDetailsServiceImpl jdbcClientDetailsService = new GitEggClientDetailsServiceImpl(dataSource);        jdbcClientDetailsService.setFindClientDetailsSql(AuthConstant.FIND_CLIENT_DETAILS_SQL);        jdbcClientDetailsService.setSelectClientDetailsSql(AuthConstant.SELECT_CLIENT_DETAILS_SQL);        clients.withClientDetails(jdbcClientDetailsService);    }    /**     * 配置授权(authorization)以及令牌(token)的访问端点和令牌服务(token services)     */    @Override    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {        TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();        List<TokenEnhancer> tokenEnhancers = new ArrayList<>();        tokenEnhancers.add(tokenEnhancer());        tokenEnhancers.add(jwtAccessTokenConverter());        tokenEnhancerChain.setTokenEnhancers(tokenEnhancers);        // 获取自定义tokenGranter        TokenGranter tokenGranter = GitEggTokenGranter.getTokenGranter(authenticationManager, endpoints, redisTemplate,            userFeign, captchaService, captchaType);        endpoints.authenticationManager(authenticationManager)                .accessTokenConverter(jwtAccessTokenConverter())                .tokenEnhancer(tokenEnhancerChain)                .userDetailsService(userDetailsService)            .tokenGranter(tokenGranter)                /**                 *                 * refresh_token有两种使用方式:重复使用(true)、非重复使用(false),默认为true                 * 1.重复使用:access_token过期刷新时, refresh token过期时间未改变,仍以初次生成的时间为准                 * 2.非重复使用:access_token过期刷新时, refresh_token过期时间延续,在refresh_token有效期内刷新而无需失效再次登录                 */                .reuseRefreshTokens(false);    }    /**     * 允许表单认证     */    @Override    public void configure(AuthorizationServerSecurityConfigurer security) {        security.allowFormAuthenticationForClients()                .tokenKeyAccess("permitAll()")                .checkTokenAccess("isAuthenticated()");    }    /**     * 使用非对称加密算法对token签名     */    @Bean    public JwtAccessTokenConverter jwtAccessTokenConverter() {        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();        converter.setKeyPair(keyPair());        return converter;    }    /**     * 从classpath下的密钥库中获取密钥对(公钥+私钥)     */    @Bean    public KeyPair keyPair() {        KeyStoreKeyFactory factory = new KeyStoreKeyFactory(                new ClassPathResource("gitegg.jks"), "123456".toCharArray());        KeyPair keyPair = factory.getKeyPair(                "gitegg", "123456".toCharArray());        return keyPair;    }    /**     * JWT内容增强     */    @Bean    public TokenEnhancer tokenEnhancer() {        return (accessToken, authentication) -> {            Map<String, Object> map = new HashMap<>(2);            GitEggUserDetails user = (GitEggUserDetails) authentication.getUserAuthentication().getPrincipal();            map.put(TokenConstant.TENANT_ID, user.getTenantId());            map.put(TokenConstant.OAUTH_ID, user.getOauthId());            map.put(TokenConstant.USER_ID, user.getId());            map.put(TokenConstant.ORGANIZATION_ID, user.getOrganizationId());            map.put(TokenConstant.ORGANIZATION_NAME, user.getOrganizationName());            map.put(TokenConstant.ORGANIZATION_IDS, user.getOrganizationIds());            map.put(TokenConstant.ORGANIZATION_NAMES, user.getOrganizationNames());            map.put(TokenConstant.ROLE_ID, user.getRoleId());            map.put(TokenConstant.ROLE_NAME, user.getRoleName());            map.put(TokenConstant.ROLE_IDS, user.getRoleIds());            map.put(TokenConstant.ROLE_NAMES, user.getRoleNames());            map.put(TokenConstant.ACCOUNT, user.getAccount());            map.put(TokenConstant.REAL_NAME, user.getRealName());            map.put(TokenConstant.NICK_NAME, user.getNickname());            map.put(TokenConstant.ROLE_ID_LIST, user.getRoleIdList());            map.put(TokenConstant.ROLE_KEY_LIST, user.getRoleKeyList());            //不把权限菜单放到jwt里面,当菜单太多时,会导致jwt长度不可控//            map.put(TokenConstant.RESOURCE_KEY_LIST, user.getResourceKeyList());            map.put(TokenConstant.DATA_PERMISSION, user.getDataPermission());            map.put(TokenConstant.AVATAR, user.getAvatar());            ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(map);            return accessToken;        };    }}
  1. Gateway在认证授权时需要RSA的公钥来验证签名是否合法,所以这里新建GitEggOAuthController的getKey接口用于Gateway获取RSA公钥
    @GetMapping("/public_key")    public Map<String, Object> getKey() {        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();        RSAKey key = new RSAKey.Builder(publicKey).build();        return new JWKSet(key).toJSONObject();    }
  1. 新建ResourceServerConfig.java资源服务器配置,放开public_key的读取权限
@Override@SneakyThrowspublic void configure(HttpSecurity http) {http.headers().frameOptions().disable();http.formLogin().and().authorizeRequests().requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll().and().authorizeRequests().antMatchers("/oauth/public_key").permitAll().anyRequest().authenticated().and().csrf().disable();}
  1. 在gitegg-service-system新建InitResourceRolesCacheRunner.java实现CommandLineRunner接口,用于系统启动时加载RBAC权限配置信息到缓存
package com.gitegg.service.system.component;import java.util.*;import java.util.stream.Collectors;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.beans.factory.annotation.Value;import org.springframework.boot.CommandLineRunner;import org.springframework.data.redis.core.RedisTemplate;import org.springframework.stereotype.Component;import com.gitegg.platform.base.constant.AuthConstant;import com.gitegg.service.system.entity.Resource;import com.gitegg.service.system.service.IResourceService;import cn.hutool.core.collection.CollectionUtil;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;/** * 容器启动完成加载资源权限数据到缓存 */@Slf4j@RequiredArgsConstructor(onConstructor_ = @Autowired)@Componentpublic class InitResourceRolesCacheRunner implements CommandLineRunner {    private final RedisTemplate redisTemplate;    private final IResourceService resourceService;    /**     * 是否开启租户模式     */    @Value(("${tenant.enable}"))    private Boolean enable;    @Override    public void run(String... args) {        log.info("InitResourceRolesCacheRunner running");        // 查询系统角色和权限的关系        List<Resource> resourceList = resourceService.queryResourceRoleIds();        // 判断是否开启了租户模式,如果开启了,那么角色权限需要按租户进行分类存储        if (enable) {            Map<Long, List<Resource>> resourceListMap =                resourceList.stream().collect(Collectors.groupingBy(Resource::getTenantId));            resourceListMap.forEach((key, value) -> {                String redisKey = AuthConstant.TENANT_RESOURCE_ROLES_KEY + key;                redisTemplate.delete(redisKey);                addRoleResource(redisKey, value);                System.out.println(redisTemplate.opsForHash().entries(redisKey).size());            });        } else {            redisTemplate.delete(AuthConstant.RESOURCE_ROLES_KEY);            addRoleResource(AuthConstant.RESOURCE_ROLES_KEY, resourceList);        }    }    private void addRoleResource(String key, List<Resource> resourceList) {        Map<String, List<String>> resourceRolesMap = new TreeMap<>();        Optional.ofNullable(resourceList).orElse(new ArrayList<>()).forEach(resource -> {            // roleId -> ROLE_{roleId}            List<String> roles = Optional.ofNullable(resource.getRoleIds()).orElse(new ArrayList<>()).stream()                .map(roleId -> AuthConstant.AUTHORITY_PREFIX + roleId).collect(Collectors.toList());            if (CollectionUtil.isNotEmpty(roles)) {                resourceRolesMap.put(resource.getResourceUrl(), roles);            }        });        redisTemplate.opsForHash().putAll(key, resourceRolesMap);    }}
  1. 新建网关服务gitegg-gateway,作为Oauth2的资源服务、客户端服务使用,对访问微服务的请求进行转发、统一校验认证和鉴权操作,引入相关依赖
<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0"         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">    <parent>        <artifactId>GitEgg-Cloud</artifactId>        <groupId>com.gitegg.cloud</groupId>        <version>1.0-SNAPSHOT</version>    </parent>    <modelVersion>4.0.0</modelVersion>    <artifactId>gitegg-gateway</artifactId>    <dependencies>        <dependency>            <groupId>com.gitegg.platform</groupId>            <artifactId>gitegg-platform-base</artifactId>            <version>${gitegg.project.version}</version>        </dependency>        <!-- Nacos 服务注册发现 -->        <dependency>            <groupId>com.alibaba.cloud</groupId>            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>        </dependency>        <!-- Nacos 分布式配置 -->        <dependency>            <groupId>com.alibaba.cloud</groupId>            <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>        </dependency>        <!-- OpenFeign 微服务调用解决方案 -->        <dependency>            <groupId>org.springframework.cloud</groupId>            <artifactId>spring-cloud-starter-openfeign</artifactId>        </dependency>        <dependency>            <groupId>com.gitegg.platform</groupId>            <artifactId>gitegg-platform-oauth2</artifactId>            <version>${gitegg.project.version}</version>        </dependency>        <!-- gitegg cache自定义扩展 -->        <dependency>            <groupId>com.gitegg.platform</groupId>            <artifactId>gitegg-platform-cache</artifactId>            <version>${gitegg.project.version}</version>        </dependency>        <dependency>            <groupId>org.springframework.cloud</groupId>            <artifactId>spring-cloud-starter-gateway</artifactId>        </dependency>        <dependency>            <groupId>io.springfox</groupId>            <artifactId>springfox-swagger2</artifactId>        </dependency>        <dependency>            <groupId>com.github.xiaoymin</groupId>            <artifactId>knife4j-spring-ui</artifactId>        </dependency>    </dependencies></project>
  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@EnableWebFluxSecuritypublic 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@Componentpublic 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是否被加入黑名单
@Slf4jpublic 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. 使用密码模式获取tokenHeaders里面加TenantId:0参数密码模式获取token
  2. 通过refresh_token刷新tokenrefresh_token刷新token
  3. 再次执行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 interceptorrequest.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 interceptorrequest.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 requestexport {  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@RestControllerAdvicepublic 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      }    }

备注:一、当验证报401时:进行 /auth/token 的post请求时,没有进行http basic认证。什么是http Basic认证?http协议的一种认证方式,将客户端id和客户端密码按照“客户端ID:客户端密码”的格式拼接,并用base64编码,放在header中请求服务端。例子如下:Authorization:Basic ASDLKFALDSFAJSLDFKLASD=ASDLKFALDSFAJSLDFKLASD= 就是 客户端ID:客户端密码 的64编码二、JWT一直不过期:在自定义TokenEnhancer时,将毫秒加入到了过期时间中,在鉴权解析时,OAuth2是按照秒来解析,所以生成的过期时间非常大,导致token一直未过期。