赞
踩
通过认证服务进行统一认证,然后通过网关来统一校验认证和鉴权。
将采用 Nacos 作为注册中心,Gateway 作为网关,使用
nimbus-jose-jwt
JWT 库操作 JWT 令牌
Spring Security 是强大的且容易定制的,基于 Spring 开发的实现认证登录与资源授权的应用安全框架
SpringSecurity 的核心功能:
configure(HttpSecurity httpSecurity)
用于配置需要拦截的 url 路径、jwt 过滤器及出异常后的处理器
configure(AuthenticationManagerBuilder auth)
用于配置 UserDetailsService 及 PasswordEncoder
RestfulAccessDeniedHandler
当用户没有访问权限时的处理器,用于返回 JSON 格式的处理结果
RestAuthenticationEntryPoint
当未登录或 token 失效时,返回 JSON 格式的结果
UserDetailsService
SpringSecurity 定义的核心接口,用于根据用户名获取用户信息,需要自行实现
UserDetails
SpringSecurity 定义用于封装用户信息的类(主要是用户信息和权限),需要自行实现
PasswordEncoder
SpringSecurity 定义的用于对密码进行编码及比对的接口,目前使用的是 BCryptPasswordEncoder
JwtAuthenticationTokenFilter
在用户名和密码校验前添加的过滤器,如果有 jwt 的 token,会自行根据 token 信息进行登录
用户在应用 A 上登录认证,应用 A 会颁发给他一个 JWT 令牌(一个包含若干用户状态信息的字符串)。当用户访问应用 B 接口的时候,将这个字符串交给应用 B,应用 B 根据 Token 中的内容进行鉴权。不同的应用之间按照统一标准发放 JWT令牌,统一标准验证 JWT 令牌。从而你在应用 A 上获得的令牌,在应用 B 上也被认可,当然这样这些应用之间底层数据库必须是同一套用户、角色、权限数据。
JWT 存在的问题
说了这么多,JWT 也不是天衣无缝,由客户端维护登录状态带来的一些问题在这里依然存在,举例如下:
续签问题,这是被很多人诟病的问题之一,传统的cookie+session的方案天然的支持续签,但是jwt由于服务端不保存用户状态,因此很难完美解决续签问题,如果引入redis,虽然可以解决问题,但是jwt也变得不伦不类了
注销问题,由于服务端不再保存用户信息,所以一般可以通过修改secret来实现注销,服务端secret修改后,已经颁发的未过期的token就会认证失败,进而实现注销,不过毕竟没有传统的注销方便
密码重置,密码重置后,原本的token依然可以访问系统,这时候也需要强制修改secret
基于第2点和第3点,一般建议不同用户取不同secret
以上的所有的单点集群登陆方案,都是有一个前提就是:应用A、应用B、应用1、应用2、应用3都是你们公司的,你们公司内部应用之间进行单点登陆验证。
但是大家都见过这样一个场景:登录某一个网站,然后使用的是在QQ、微信上保存的用户数据。也就是说第三方应用想使用某个权威平台的用户数据做登录认证,那么这个权威平台该如何对第三方应用提供认证服务?目前比较通用的做法就是OAuth2(现代化的社交媒体网站登录基本都使用OAuth2)
在说明OAuth2需求及使用场景之前,需要先介绍一下OAuth2授权流程中的各种角色:
密码模式与授权码模式最大的区别在于:
因为 Spring Security OAuth “认证服务器”支持多种认证模式,所以不想抛弃它。但是想把最后的"资源访问令牌",由 AccessToken 换成 JWT 令牌。因为 AccessToken 不带有任何的附加信息,就是一个字符串,JWT 是可以携带附加信息的。
理想的解决方案应该是这样的,认证服务负责认证,网关负责校验认证和鉴权,其他 API 服务负责处理自己的业务逻辑。安全相关的逻辑只存在于认证服务和网关服务中,其他服务只是单纯地提供服务而没有任何安全相关逻辑
相关服务划分:
下面介绍下这套解决方案的具体实现,依次搭建认证服务、网关服务和 API 服务
首先来搭建认证服务,它将作为 Oauth2 的认证服务使用,并且网关服务的鉴权功能也需要依赖它
pom.xml
中添加相关依赖,主要是 Spring Security、Oauth2、JWT、Redis 相关依赖<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-rsa</artifactId>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
</dependency>
</dependencies>
application.yml
中添加相关配置,主要是 Nacos 和 Redis 相关配置server:
port: 9401
spring:
profiles:
active: dev
application:
name: security-oauth2-auth
cloud:
nacos:
discovery:
server-addr: 192.168.123.22:8848
username: nacos
password: nacos
redis:
port: 6379
host: localhost
password: xxx
management:
endpoints:
web:
exposure:
include: "*"
keytool
生成 RSA 证书 jwt.jks
,复制到 resource
目录下,在 JDK 的 bin
目录下使用如下命令即可keytool -genkey -alias jwt -keyalg RSA -keystore jwt.jks
UserServiceImpl
类实现 Spring Security 的 UserDetailsService
接口,用于加载用户信息/**
* 用户管理业务类
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService
{
@Autowired
private UmsAdminService adminService;
@Autowired
private HttpServletRequest request;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
{
String clientId = request.getParameter("client_id");
UserDto userDto = null;
if (AuthConstant.ADMIN_CLIENT_ID.equals(clientId))
{
userDto = adminService.loadUserByUsername(username);
}
if (null == userDto)
{
throw new UsernameNotFoundException(EC.ERROR_USER_PASSWORD_INCORRECT.getMsg());
}
SecurityUserDetails securityUser = new SecurityUserDetails(userDto);
if (!securityUser.isEnabled())
{
throw new DisabledException(EC.ERROR_USER_ENABLED.getMsg());
}
else if (!securityUser.isAccountNonLocked())
{
throw new LockedException(EC.ERROR_USER_LOCKED.getMsg());
}
else if (!securityUser.isAccountNonExpired())
{
throw new AccountExpiredException(EC.ERROR_USER_EXPIRE.getMsg());
}
else if (!securityUser.isCredentialsNonExpired())
{
throw new CredentialsExpiredException(EC.ERROR_USER_UNAUTHORIZED.getMsg());
}
return securityUser;
}
}
@Component
public class UmsAdminService
{
@Autowired
private PasswordEncoder passwordEncoder;
public UserDto loadUserByUsername(String username)
{
String password = passwordEncoder.encode("123456a");
if("admin".equals(username))
{
return new UserDto("admin", password, 1, "", CollUtil.toList("ADMIN"));
}
else if("langya".equals(username))
{
return new UserDto("langya", password, 1, "", CollUtil.toList("ADMIN", "TEST"));
}
return null;
}
}
Oauth2ServerConfig
,需要配置加载用户信息的服务 UserServiceImpl
及 RSA 的钥匙对KeyPair
/**
* 认证服务器配置
*/
@AllArgsConstructor
@Configuration
@EnableAuthorizationServer
public class Oauth2ServerConfig extends AuthorizationServerConfigurerAdapter
{
private final PasswordEncoder passwordEncoder;
private final UserDetailsServiceImpl userDetailsService;
private final AuthenticationManager authenticationManager;
private final JwtTokenEnhancer jwtTokenEnhancer;
/**
* 客户端信息配置
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception
{
clients.inMemory()
.withClient(AuthConstant.ADMIN_CLIENT_ID)
.secret(passwordEncoder.encode("123456"))
.scopes("all")
.authorizedGrantTypes("password", "refresh_token")
.accessTokenValiditySeconds(3600 * 24)
.refreshTokenValiditySeconds(3600 * 24 * 7)
.and()
.withClient(AuthConstant.PORTAL_CLIENT_ID)
.secret(passwordEncoder.encode("123456"))
.scopes("all")
.authorizedGrantTypes("password", "refresh_token")
.accessTokenValiditySeconds(3600 * 24)
.refreshTokenValiditySeconds(3600 * 24 * 7);
}
/**
* 配置授权(authorization)以及令牌(token)
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception
{
TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
List<TokenEnhancer> delegates = new ArrayList<>();
delegates.add(jwtTokenEnhancer);
delegates.add(accessTokenConverter());
//配置JWT的内容增强器
enhancerChain.setTokenEnhancers(delegates);
endpoints.authenticationManager(authenticationManager)
//配置加载用户信息的服务
.userDetailsService(userDetailsService)
.accessTokenConverter(accessTokenConverter())
.tokenEnhancer(enhancerChain);
}
/**
* 允许表单认证
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception
{
security.allowFormAuthenticationForClients();
}
/**
* 使用非对称加密算法对token签名
*/
@Bean
public JwtAccessTokenConverter accessTokenConverter()
{
JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
//or 设置对称签名
//jwtAccessTokenConverter.setSigningKey("2430B31859314947BC84697E70B3D31F");
jwtAccessTokenConverter.setKeyPair(keyPair());
return jwtAccessTokenConverter;
}
/**
* 从classpath下的密钥库中获取密钥对(公钥+私钥)
*/
@Bean
public KeyPair keyPair()
{
//从classpath下的证书中获取秘钥对
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"),
"123456".toCharArray());
return keyStoreKeyFactory.getKeyPair("jwt", "123456".toCharArray());
}
}
登录用户的ID
,可以自己实现 TokenEnhancer
接口/**
* JWT 内容增强器
*/
@Component
public class JwtTokenEnhancer implements TokenEnhancer
{
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication)
{
SecurityUserDetails securityUser = (SecurityUserDetails) authentication.getPrincipal();
//把用户名设置到JWT中
Map<String, Object> info = new HashMap<>();
info.put("user_name", securityUser.getUsername());
info.put("client_id", securityUser.getClientId());
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(info);
return accessToken;
}
}
/**
* 获取RSA公钥接口
*/
@RestController
public class KeyPairController
{
@Autowired
private KeyPair keyPair;
@GetMapping("/rsa/publicKey")
public Map<String, Object> getKey()
{
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAKey key = new RSAKey.Builder(publicKey).build();
return new JWKSet(key).toJSONObject();
}
}
/**
* SpringSecurity 安全配置
*/
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter
{
@Override
protected void configure(HttpSecurity http) throws Exception
{
http.authorizeRequests()
.requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll()
.antMatchers("/rsa/publicKey").permitAll()
.anyRequest().authenticated();
}
/**
* 如果不配置 SpringBoot 会自动配置一个 AuthenticationManager 覆盖掉内存中的用户
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception
{
return super.authenticationManagerBean();
}
@Bean
public PasswordEncoder passwordEncoder()
{
return new BCryptPasswordEncoder();
}
}
ResourceServiceImpl
,初始化的时候把资源与角色匹配关系缓存到 Redis 中,方便网关服务进行鉴权的时候获取/**
* 资源与角色匹配关系管理业务类
* <p>
* 初始化的时候把资源与角色匹配关系缓存到Redis中,方便网关服务进行鉴权的时候获取
*/
@Service
public class ResourceServiceImpl
{
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private Map<String, List<String>> resourceRolesMap;
@PostConstruct
public void initData()
{
resourceRolesMap = new TreeMap<>();
resourceRolesMap.put("/admin/hello", CollUtil.toList("ADMIN"));
resourceRolesMap.put("/admin/user/currentUser", CollUtil.toList("ADMIN", "TEST"));
redisTemplate.opsForHash().putAll(AuthConstant.RESOURCE_ROLES_MAP_KEY, resourceRolesMap);
}
}
如果资源权限存储到数据库,也可以直接使用 SQL 语句形成结果集,如:
接下来就可以搭建网关服务了,它将作为 Oauth2 的资源服务、客户端服务使用,对访问微服务的请求进行统一的校验认证和鉴权操作
pom.xml
中添加相关依赖,主要是 Gateway、Oauth2 和 JWT 相关依赖<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!--lb:// need-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
</dependency>
</dependencies>
application.yml
中添加相关配置,主要是路由规则的配置、Oauth2中RSA公钥的配置及路由白名单的配置server:
port: 9201
spring:
main:
#springcloudgateway 的内部是通过 netty+webflux 实现的
#webflux 实现和 spring-boot-starter-web 依赖冲突
web-application-type: reactive
profiles:
active: dev
application:
name: security-oauth2-gateway
cloud:
nacos:
discovery:
server-addr: localhost:8848
username: nacos
password: nacos
gateway:
routes: #配置路由路径
- id: oauth2-api-route
uri: lb://security-oauth2-api
predicates:
- Path=/admin/**
filters:
- StripPrefix=1
- id: oauth2-auth-route
uri: lb://security-oauth2-auth
predicates:
- Path=/auth/**
filters:
- StripPrefix=1
discovery:
locator:
#开启从注册中心动态创建路由的功能
enabled: true
#使用小写服务名,默认是大写
lower-case-service-id: true
security:
oauth2:
resourceserver:
jwt:
#配置RSA的公钥访问地址
jwk-set-uri: 'http://localhost:9401/rsa/publicKey'
redis:
host: 192.168.123.22
port: 6379
password: Hacfin_Redis8
timeout: 6000ms
secure:
ignore:
#配置白名单路径
urls:
- "/actuator/**"
- "/auth/oauth/token"
WebFlux
,所以需要使用 @EnableWebFluxSecurity
注解开启/**
* 资源服务器配置
*/
@AllArgsConstructor
@Configuration
@EnableWebFluxSecurity
public class ResourceServerConfig
{
private final AuthorizationManager authorizationManager;
private final IgnoreUrlsConfig ignoreUrlsConfig;
private final RestfulAccessDeniedHandler restfulAccessDeniedHandler;
private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
private final IgnoreUrlsRemoveJwtFilter ignoreUrlsRemoveJwtFilter;
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http)
{
http.oauth2ResourceServer().jwt()
.jwtAuthenticationConverter(jwtAuthenticationConverter());
//自定义处理JWT请求头过期或签名错误的结果
http.oauth2ResourceServer().authenticationEntryPoint(restAuthenticationEntryPoint);
//对白名单路径,直接移除JWT请求头
http.addFilterBefore(ignoreUrlsRemoveJwtFilter, SecurityWebFiltersOrder.AUTHENTICATION);
http.authorizeExchange()
//白名单配置
.pathMatchers(ArrayUtil.toArray(ignoreUrlsConfig.getUrls(), String.class)).permitAll()
//鉴权管理器配置
.anyExchange().access(authorizationManager)
.and()
.exceptionHandling()
//处理未授权
.accessDeniedHandler(restfulAccessDeniedHandler)
//处理未认证
.authenticationEntryPoint(restAuthenticationEntryPoint)
.and()
.csrf().disable();
return http.build();
}
/**
* @linkhttps://blog.csdn.net/qq_24230139/article/details/105091273
* 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);
}
}
WebFluxSecurity
中自定义鉴权操作需要实现 ReactiveAuthorizationManager
接口@Component
public class AuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {
@Autowired
private RedisTemplate<String,Object> redisTemplate;
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
//从Redis中获取当前路径可访问角色列表
URI uri = authorizationContext.getExchange().getRequest().getURI();
Object obj = redisTemplate.opsForHash().get(RedisConstant.RESOURCE_ROLES_MAP, uri.getPath());
List<String> authorities = Convert.toList(String.class,obj);
authorities = authorities.stream().map(i -> i = AuthConstant.AUTHORITY_PREFIX + i).collect(Collectors.toList());
//认证通过且角色匹配的用户可访问当前路径
return mono
.filter(Authentication::isAuthenticated)
.flatMapIterable(Authentication::getAuthorities)
.map(GrantedAuthority::getAuthority)
.any(authorities::contains)
.map(AuthorizationDecision::new)
.defaultIfEmpty(new AuthorizationDecision(false));
}
}
AuthGlobalFilter
,当鉴权通过后将 JWT 令牌中的用户信息解析出来,然后存入请求的 Header 中,这样后续服务就不需要解析 JWT 令牌了,可以直接从请求的 Header 中获取到用户信息/**
* 将登录用户的JWT转化成用户信息的全局过滤器
*/
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered
{
@Autowired
private RedisTemplate redisTemplate;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain)
{
//认证信息从Header 或 请求参数 中获取
ServerHttpRequest serverHttpRequest = exchange.getRequest();
String token = serverHttpRequest.getHeaders().getFirst(AuthConstant.JWT_TOKEN_HEADER);
if (Objects.isNull(token))
{
token = serverHttpRequest.getQueryParams().getFirst(AuthConstant.JWT_TOKEN_HEADER);
}
if (StrUtil.isEmpty(token))
{
return chain.filter(exchange);
}
try
{
//从token中解析用户信息并设置到Header中去
String realToken = token.replace(AuthConstant.JWT_TOKEN_PREFIX, "");
JWSObject jwsObject = JWSObject.parse(realToken);
String userStr = jwsObject.getPayload().toString();
// 黑名单token(登出、修改密码)校验
JSONObject jsonObject = JSONUtil.parseObj(userStr);
String jti = jsonObject.getStr("jti");
Boolean isBlack = redisTemplate.hasKey(AuthConstant.TOKEN_BLACKLIST_PREFIX + jti);
if (isBlack)
{
}
// 存在token且不是黑名单,request写入JWT的载体信息
ServerHttpRequest request = serverHttpRequest.mutate().header(AuthConstant.USER_TOKEN_HEADER, userStr).build();
exchange = exchange.mutate().request(request).build();
}
catch (ParseException e)
{
e.printStackTrace();
}
return chain.filter(exchange);
}
@Override
public int getOrder()
{
return 0;
}
}
最后搭建一个API服务,它不会集成和实现任何安全相关逻辑,全靠网关来保护它
pom.xml
中添加相关依赖,就添加了一个web依赖<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
LoginUserHolder
组件,用于从请求的 Header 中直接获取登录用户信息/**
* 获取登录用户信息
*/
@Component
public class LoginUserHolder
{
public UserDto getCurrentUser(HttpServletRequest request)
{
String userStr = request.getHeader(AuthConstant.USER_TOKEN_HEADER);
JSONObject userJsonObject = new JSONObject(userStr);
UserDto userDTO = new UserDto();
userDTO.setUserName(userJsonObject.getStr("user_name"));
userDTO.setClientId(userJsonObject.getStr("client_id"));
userDTO.setRoles(Convert.toList(String.class, userJsonObject.get(AuthConstant.AUTHORITY_CLAIM_NAME)));
return userDTO;
}
}
@RestController
@RequestMapping("/user")
public class UserController{
@Autowired
private LoginUserHolder loginUserHolder;
@GetMapping("/currentUser")
public UserDTO currentUser() {
return loginUserHolder.getCurrentUser();
}
}
接下来来演示下微服务系统中的统一认证鉴权功能,所有请求均通过网关访问
启动 Nacos 和 Redis 服务
启动 security-oauth2-auth
、security-oauth2-gateway
及 security-oauth2-api
服务
Spring Cloud Gateway + Oauth2 实现统一认证和鉴权
Spring Cloud Gateway + Spring Security OAuth2 + JWT 实现微服务统一认证授权鉴权
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。