一,OAuth2 和 Jwt 的关系
1.1 什么是 OAuth2
OAUth2就是一套广泛流行的认证授权协议,大白话说呢OAuth2这套协议中有两个核心的角色,认证服务器和资源服务器。
1.2 什么是 Jwt
Jwt(JSON Web Token)就是一个特殊的 token,最大的特性就是无状态,因为它本身可以携带用户的信息(用户ID、用户名、用户的角色集合等)。Jwt 字符串由 Header(头部)、Payload(负载)、Signature(签名)三部分组成。
Header:
JSON对象,用来描述 Jwt 的元数据,alg 属性表示签名的算法,type 标识 token 的类型;
Payload:
JSON 对象,重要部分,除了默认的字段,还可以扩展自定义字段,比如用户ID、姓名、角色等等;
Signature:
对 Header、Payload 这两部分进行签名,认证服务器使用私钥签名,然后在资源服务器使用公钥验签,防止数据被人动了手脚。
Jwt 和传统的 Cookie/Session 会话管理相比较有着多方面的优势,因为 Cookie/Session 需要在服务器 Session 存用户信息,然后拿客户端Cookie 存储的 SessionId 获取用户信息,这个过程需要消耗服务器的内存和对客户端的要求比较严格(需支持 Cookie),而 Jwt 最大的特性在于就是无状态、去中心化,所以 Jwt 更适用分布式的场景,不需要在多台服务器做会话同步这种消耗服务器性能的操作。
1.3 OAuth2 和 Jwt 的关系
- OAuth2 是一种认证授权的协议规范。
- Jwt 是基于 token 的安全认证协议的实现。
OAuth2 的认证服务器签发的 token 可以使用 Jwt 实现,Jwt 轻量且安全。
二,应用架构
我们理想的解决方案应该是这样的,认证服务负责认证,网关负责校验认证和鉴权,其他API服务负责处理自己的业务逻辑。安全相关的逻辑只存在于认证服务和网关服务中,其他服务只是单纯地提供服务而没有任何安全相关逻辑。
相关服务划分:
gateway:
网关服务,负责请求转发和鉴权功能,整合 Spring Security + Oauth2;
auth:
Oauth2 认证服务,负责对登录用户进行认证,整合 Spring Security + Oauth2 + Jwt;
三,认证服务 Auth
主要是对用户进行认证,认证通过后返回 token。
3.1 添加依赖
<!--oauth2-->
<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>
<!--nacos服务发现-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
<version>0.2.2.RELEASE</version>
</dependency>
3.2 配置文件
主要添加 Nacos 相关配置;
3.3 生成 RSA 证书
使用 keytool 生成 RSA 证书 jwt.jks,复制到 resource 目录下,在 JDK 的 bin 目录下使用如下命令即可:
keytool -genkey -alias 证书名 -keyalg RSA -keypass 123456 -keystore jwt.jks -storepass 123456
3.4 实现 Spring Security 的 UserDetailsService 接口
创建 UserServiceImpl 类实现 Spring Security 的 UserDetailsService 接口,用于加载用户信息。
/**
* 自定义用户认证和授权
*/
@Service
@AllArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserFeignService userFeignService;
private final UmsUserFeignService umsUserFeignService;
private final BmsStaffFeignService bmsStaffFeignService;
private final PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
String clientId = RequestUtils.getAuthClientId();
HttpServletRequest request = RequestUtils.getRequest();
String clientType = request.getParameter("clientType");
String cropUserId = request.getParameter("cropUserId");
User user = null;
switch (clientId) {
case AuthConstants.ADMIN_CLIENT_ID:
// 后台用户
Result<UserDTO> userRes = userFeignService.getUserByUserName(username , AuthConstants.ENTRANCE_TYPE_ADMIN, null);
if (ResultCode.USER_NOT_EXIST.getCode().equals(userRes.getCode())) {
throw new UsernameNotFoundException(ResultCode.USER_NOT_EXIST.getMsg());
}
UserDTO userDTO = userRes.getData();
userDTO.setClientId(clientId);
user = new User(userDTO);
break;
case AuthConstants.BUSINESS_CLIENT_ID:
// 商家端
user = businessUser(username, clientType, clientId, cropUserId);
break;
case AuthConstants.WEAPP_CLIENT_ID:
// 小程序会员
Result<UmsUserDTO> memberRes = umsUserFeignService.getUserByOpenid(username);
if (ResultCode.USER_NOT_EXIST.getCode().equals(memberRes.getCode())) {
throw new UsernameNotFoundException(ResultCode.USER_NOT_EXIST.getMsg());
}
UmsUserDTO authUserDTO = memberRes.getData();
authUserDTO.setClientId(clientId);
user = new User(authUserDTO);
break;
default:
throw new DisabledException("登录失败,客户端授权失败!");
}
if (!user.isEnabled()) {
throw new DisabledException("该账户已被禁用!");
} else if (!user.isAccountNonLocked()) {
throw new LockedException("该账号已被锁定!");
} else if (!user.isAccountNonExpired()) {
throw new AccountExpiredException("该账号已过期!");
}
return user;
}
}
3.5 认证服务配置(AuthorizationServerConfig)
AuthorizationServerConfig这个配置类是整个认证服务实现的核心,通过继承 AuthorizationServerConfigurerAdapter 实现认证服务相关配置。总结下来就是两个关键点,客户端信息配置和 access_token 生成配置。
/**
* 授权服务配置
*/
@Configuration
@EnableAuthorizationServer
@AllArgsConstructor
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
private final DataSource dataSource;
private final AuthenticationManager authenticationManager;
private final UserDetailsServiceImpl userDetailsService;
/**
* 配置客户端详情(数据库)
*/
@Override
@SneakyThrows
public void configure(ClientDetailsServiceConfigurer clients) {
JdbcClientDetailsServiceImpl jdbcClientDetailsService = new JdbcClientDetailsServiceImpl(dataSource);
jdbcClientDetailsService.setFindClientDetailsSql(AuthConstants.FIND_CLIENT_DETAILS_SQL);
jdbcClientDetailsService.setSelectClientDetailsSql(AuthConstants.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);
endpoints.authenticationManager(authenticationManager)
.accessTokenConverter(jwtAccessTokenConverter())
.tokenEnhancer(tokenEnhancerChain)
.userDetailsService(userDetailsService)
.reuseRefreshTokens(true);
// refresh token有两种使用方式:重复使用(true)、非重复使用(false),默认为true
// 1 重复使用:access token过期刷新时, refresh token过期时间未改变,仍以初次生成的时间为准
// 2 非重复使用:access token过期刷新时, refresh token过期时间延续,在refresh token有效期内刷新便永不失效达到无需再次登录的目的
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
/*security.allowFormAuthenticationForClients();*/
CustomClientCredentialsTokenEndpointFilter endpointFilter = new CustomClientCredentialsTokenEndpointFilter(security);
endpointFilter.afterPropertiesSet();
endpointFilter.setAuthenticationEntryPoint(authenticationEntryPoint());
security.addTokenEndpointAuthenticationFilter(endpointFilter);
security.authenticationEntryPoint(authenticationEntryPoint())
.tokenKeyAccess("isAuthenticated()")
.checkTokenAccess("permitAll()");
}
/**
* 自定义认证异常响应数据
* @return
*/
@Bean
public AuthenticationEntryPoint authenticationEntryPoint() {
return (request, response, e) -> {
response.setStatus(HttpStatus.HTTP_OK);
response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Cache-Control", "no-cache");
Result result = Result.failed(ResultCode.CLIENT_AUTHENTICATION_FAILED);
response.getWriter().print(JSONUtil.toJsonStr(result));
response.getWriter().flush();
};
}
/**
* 使用非对称加密算法对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("jwt.jks"), "123456".toCharArray());
KeyPair keyPair = factory.getKeyPair("xxx");
return keyPair;
}
/**
* JWT内容增强
*/
@Bean
public TokenEnhancer tokenEnhancer() {
return (accessToken, authentication) -> {
Map<String, Object> map = new HashMap<>(8);
User user = (User) authentication.getUserAuthentication().getPrincipal();
map.put("userId", user.getId());
map.put("clientId", user.getClientId());
map.put("username",user.getUsername());
if (user.getHeadurl() != null){
map.put("headurl",user.getHeadurl());
}
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(map);
return accessToken;
};
}
}
3.6 暴露获取 Jwt 公钥接口
由于我们的网关服务需要 RSA 的公钥来验证签名是否合法,所以认证服务需要有个接口把公钥暴露出来。
@RestController
@RequestMapping("/auth")
public class PublicKeyController {
private KeyPair keyPair;
@GetMapping("/getPublicKey")
public Map<String, Object> loadPublicKey() {
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAKey key = new RSAKey.Builder(publicKey).build();
return new JWKSet(key).toJSONObject();
}
}
3.7 安全配置 WebSecurityConfig
安全配置主要是配置请求访问权限、定义认证管理器、密码加密配置。
@Configuration
@EnableWebSecurity
@Slf4j
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll()
.and()
.authorizeRequests()
.antMatchers("/auth/**").permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable();
}
/**
* 如果不配置SpringBoot会自动配置一个AuthenticationManager,覆盖掉内存中的用户
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
四,资源服务器
网关这里是担任资源服务器的角色,因为网关是微服务资源访问的统一入口,所以在这里做资源访问的统一鉴权是再合适不过。
4.1 添加依赖
在 pom.xml 中添加相关依赖,主要是 Gateway、Oauth2 和 JWT 相关依赖:
<!--OAuth2资源服务器-->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
</dependency>
<!-- gateway -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!--nacos服务发现-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
<version>0.2.2.RELEASE</version>
</dependency>
4.2 修改配置
在 application.yml 中添加相关配置,主要是路由规则
的配置、Oauth2 中 RSA 公钥
的配置及路由白名单
的配置:
server:
port:
app:
id: cloud-gateway
spring:
application:
name: cloud-gateway
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: http://ip/auth/getPublicKey
main:
allow-bean-definition-overriding: true
cloud:
nacos:
discovery:
server-addr:
gateway:
discovery:
locator:
enabled: true
routes:
- id: sec_routh
uri: lb://cloud-seckill
predicates:
- Path=/sec/**
# 配置白名单路径
whitelist:
urls:
- /auth/**
- /favicon.ico
- /webjars/**
- /doc.html
- /swagger-resources/**
- /*/v2/api-docs
- /*/api.pub/**
4.3 鉴权管理器
@Slf4j
@Component
public class AuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
ServerHttpRequest request = authorizationContext.getExchange().getRequest();
String path = request.getMethodValue() + "_" + request.getURI().getPath();
log.info("请求,path={}", path);
// 对应跨域的预检请求直接放行
if (request.getMethod() == HttpMethod.OPTIONS) {
return Mono.just(new AuthorizationDecision(true));
}
// token为空拒绝访问
String token = request.getHeaders().getFirst(AuthConstants.AUTHORIZATION_KEY);
if (StrUtil.isEmpty(token) || (!token.startsWith(AuthConstants.AUTHORIZATION_PREFIX) && !token.startsWith(AuthConstants.BASIC_PREFIX))) {
log.info("请求token为空拒绝访问,path={}", path);
return Mono.just(new AuthorizationDecision(false));
}
// 缓存取资源权限角色关系列表
// 请求路径匹配到的资源需要的角色权限集合authorities
return Mono.just(new AuthorizationDecision(true));
}
}
4.4 资源服务器配置
这里做的工作是将鉴权管理器 AuthorizationManager 配置到资源服务器、请求白名单放行、无权访问和无效 token 的自定义异常响应。配置白名单的资源不用再走鉴权管理器。
@AllArgsConstructor
@Configuration
@EnableWebFluxSecurity
public class ResourceServerConfig {
private final AuthorizationManager authorizationManager;
private final WhiteListConfig whiteListConfig;
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
http.oauth2ResourceServer().jwt()
.jwtAuthenticationConverter(jwtAuthenticationConverter());
// 自定义处理JWT请求头过期或签名错误的结果
http.oauth2ResourceServer().authenticationEntryPoint(authenticationEntryPoint());
http.authorizeExchange()
//放行白名单,不用走鉴权管理器
.pathMatchers(ArrayUtil.toArray(whiteListConfig.getUrls(), String.class)).permitAll()
.anyExchange().access(authorizationManager)
.and()
.exceptionHandling()
// 处理未授权
.accessDeniedHandler(accessDeniedHandler())
//处理未认证
.authenticationEntryPoint(authenticationEntryPoint())
.and().csrf().disable();
return http.build();
}
/**
* 未授权
*/
@Bean
ServerAccessDeniedHandler accessDeniedHandler() {
return (exchange, denied) -> {
Mono<Void> mono = Mono.defer(() -> Mono.just(exchange.getResponse()))
.flatMap(response -> WebUtils.writeFailedToResponse(response, ResultCode.ACCESS_UNAUTHORIZED));
return mono;
};
}
/**
* token无效或者已过期自定义响应
*/
@Bean
ServerAuthenticationEntryPoint authenticationEntryPoint() {
return (exchange, e) -> {
Mono<Void> mono = Mono.defer(() -> Mono.just(exchange.getResponse()))
.flatMap(response -> WebUtils.writeFailedToResponse(response,ResultCode.TOKEN_INVALID_OR_EXPIRED));
return mono;
};
}
/**
* ServerHttpSecurity没有将jwt中authorities的负载部分当做Authentication
* 需要把jwt的Claim中的authorities加入
* 方案:重新定义ReactiveAuthenticationManager权限管理器,默认转换器JwtGrantedAuthoritiesConverter
*/
@Bean
public Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
jwtGrantedAuthoritiesConverter.setAuthorityPrefix(AuthConstants.AUTHORITY_PREFIX);
jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(AuthConstants.JWT_AUTHORITIES_KEY);
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
}
}
至此,资源服务器已整合完成,可通过接口验证。
文章参考
Spring Cloud Gateway + Spring Security OAuth2 + JWT实现微服务统一认证授权鉴权
微服务权限终极解决方案,Spring Cloud Gateway + Oauth2 实现统一认证和鉴权!
评论区