书接上文
微服务OAuth 2.1认证授权可行性方案(Spring Security 6)
三个微服务
auth
微服务作为认证服务器,用于颁发JWT
。gateway
微服务作为网关,用于拦截过滤。content
微服务作为资源服务器,用于校验授权。以下是授权相关数据库。
user
表示用户表role
表示角色表user_role
关联了用户和角色,表示某个用户是是什么角色。一个用户可以有多个角色menu
表示资源权限表。@PreAuthorize("hasAuthority('xxx')")
时用的就是这里的code
。permission
关联了角色和资源权限,表示某个角色用于哪些资源访问权限,一个角色有多个资源访问权限。当我们知道userId
,我们就可以知道这个用户可以访问哪些资源,并把这些权限(也就是menu
里的code
字段)写成数组,写到JWT
的负载部分的authorities
字段中。当用户携带此JWT访问具有@PreAuthorize("hasAuthority('xxx')")
修饰的资源时,我们解析出JWT
中的authorities
字段,判断是否包含hasAuthority
指定的xxx
权限,以此来完成所谓的的“授权”。
package com.xuecheng.auth.config;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 身份验证服务器安全配置
*
* @author mumu
* @date 2024/02/13
*/
//@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
@Configuration
@EnableWebSecurity
public class AuthServerSecurityConfig {
private static KeyPair generateRsaKey() {
KeyPair keyPair;
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
keyPair = keyPairGenerator.generateKeyPair();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
return keyPair;
}
/**
* 密码编码器
* 用于加密认证服务器client密码和用户密码
*
* @return {@link PasswordEncoder}
*/
@Bean
public PasswordEncoder passwordEncoder() {
// 密码为明文方式
// return NoOpPasswordEncoder.getInstance();
// 或使用 BCryptPasswordEncoder
return new BCryptPasswordEncoder();
}
/**
* 授权服务器安全筛选器链
*
* 来自Spring Authorization Server示例,用于暴露Oauth2.1端点,一般不影响常规的请求
*
* @param http http
* @return {@link SecurityFilterChain}
* @throws Exception 例外
*/
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0
http
// Redirect to the login page when not authenticated from the
// authorization endpoint
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
)
// Accept access tokens for User Info and/or Client Registration
.oauth2ResourceServer((resourceServer) -> resourceServer
.jwt(Customizer.withDefaults()));
return http.build();
}
/**
* 默认筛选器链
*
* 这个才是我们需要关心的过滤链,可以指定哪些请求被放行,哪些请求需要JWT验证
*
* @param http http
* @return {@link SecurityFilterChain}
* @throws Exception 例外
*/
@Bean
@Order(2)
public SecurityFilterChain defaultFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) ->
authorize
.requestMatchers(new AntPathRequestMatcher("/actuator/**")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/login")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/logout")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/wxLogin")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/register")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/oauth2/**")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/**/*.html")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/**/*.json")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/auth/**")).permitAll()
.anyRequest().authenticated()
)
.csrf(AbstractHttpConfigurer::disable)
//指定logout端点,用于退出登陆,不然二次获取授权码时会自动登陆导致短时间内无法切换用户
.logout(logout -> logout
.logoutUrl("/logout")
.addLogoutHandler(new SecurityContextLogoutHandler())
.logoutSuccessUrl("http://www.51xuecheng.cn")
)
.formLogin(Customizer.withDefaults())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())
// .jwt(jwt -> jwt
// .jwtAuthenticationConverter(jwtAuthenticationConverter())
// )
);
return http.build();
}
private JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
return jwtConverter;
}
/**
* 客户端管理实例
*
* 来自Spring Authorization Server示例
*
* @return {@link RegisteredClientRepository}
*/
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("XcWebApp")
.clientSecret(passwordEncoder().encode("XcWebApp"))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.redirectUri("http://www.51xuecheng.cn")
.redirectUri("http://localhost:63070/auth/wxLogin")
.redirectUri("http://www.51xuecheng.cn/sign.html")
// .postLogoutRedirectUri("http://localhost:63070/login?logout")
.scope("all")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.scope("message.read")
.scope("message.write")
.scope("read")
.scope("write")
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofHours(2)) // 设置访问令牌的有效期
.refreshTokenTimeToLive(Duration.ofDays(3)) // 设置刷新令牌的有效期
.reuseRefreshTokens(true) // 是否重用刷新令牌
.build())
.build();
return new InMemoryRegisteredClientRepository(registeredClient);
}
/**
* jwk源
*
* 对访问令牌进行签名的示例,里面包含公私钥信息。
*
* @return {@link JWKSource}<{@link SecurityContext}>
*/
@Bean
public JWKSource<SecurityContext> jwkSource() {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}
/**
* jwt解码器
*
* JWT解码器,主要就是基于公钥信息来解码
*
* @param jwkSource jwk源
* @return {@link JwtDecoder}
*/
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder().build();
}
/**
* JWT定制器
*
* 可以往JWT从加入额外信息,这里是加入authorities字段,是一个权限数组。
*
* @return {@link OAuth2TokenCustomizer}<{@link JwtEncodingContext}>
*/
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer() {
return context -> {
Authentication authentication = context.getPrincipal();
if (authentication.getPrincipal() instanceof UserDetails userDetails) {
List<String> authorities = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
context.getClaims().claim("authorities", authorities);
}
};
}
}
这里需要注意几点
BCryptPasswordEncoder
密码加密,在设置clientSecret
时需要手动使用密码编码器。jwtTokenCustomizer
解析UserDetails
然后往JWT
中添加authorities
字段,为了后面的授权。package com.xuecheng.ucenter.service.impl;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.xuecheng.ucenter.mapper.XcMenuMapper;
import com.xuecheng.ucenter.mapper.XcUserMapper;
import com.xuecheng.ucenter.model.dto.AuthParamsDto;
import com.xuecheng.ucenter.model.dto.XcUserExt;
import com.xuecheng.ucenter.model.po.XcMenu;
import com.xuecheng.ucenter.model.po.XcUser;
import com.xuecheng.ucenter.service.AuthService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
@Component
@Slf4j
public class UserServiceImpl implements UserDetailsService {
@Autowired
private MyAuthService myAuthService;
@Autowired
XcMenuMapper xcMenuMapper;
/**
* 用户统一认证
*
* @param s 用户信息Json字符串
* @return {@link UserDetails}
* @throws UsernameNotFoundException 找不到用户名异常
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
XcUserExt xcUserExt = myAuthService.execute(username);
return getUserPrincipal(xcUserExt);
}
public UserDetails getUserPrincipal(XcUserExt user){
//用户权限,如果不加报Cannot pass a null GrantedAuthority collection
List<XcMenu> xcMenus = xcMenuMapper.selectPermissionByUserId(user.getId());
String[] permissions = {"read"};
if (ObjectUtils.isNotEmpty(xcMenus)){
permissions = xcMenus.stream().map(XcMenu::getCode).toList().toArray(String[]::new);
log.info("权限如下:{}", Arrays.toString(permissions));
}
//为了安全在令牌中不放密码
String password = user.getPassword();
user.setPassword(null);
//将user对象转json
String userString = JSON.toJSONString(user);
//创建UserDetails对象
return User.withUsername(userString).password(password).authorities(permissions).build();
}
}
这里需要注意几点
username
就是前端/auth/login
的时候输入的账户名。myAuthService.execute(username)
不抛异常,就默认表示账户存在,此时将password
加入UserDetails
并返回,Spring Authorization Server
对比校验两个密码。myAuthService.execute(username)
根据username
获取用户信息返回,将用户信息存入withUsername
中,Spring Authorization Server
默认会将其加入到JWT
中。Spring Authorization Server
默认不会把authorities(permissions)
写入JWT
,需要配合OAuth2TokenCustomizer
手动写入。这样,auth
微服务颁发的JWT
,现在就会包含authorities
字段。示例如下
{
"active": true,
"sub": "{\"cellphone\":\"17266666637\",\"createTime\":\"2024-02-13 10:33:13\",\"email\":\"[email protected]\",\"id\":\"012f3a90-2bc9-4a2c-82a3-f9777c9ac10a\",\"name\":\"xiamu\",\"nickname\":\"xiamu\",\"permissions\":[],\"status\":\"1\",\"updateTime\":\"2024-02-13 10:33:13\",\"username\":\"xiamu\",\"utype\":\"101001\",\"wxUnionid\":\"test\"}",
"aud": [
"XcWebApp"
],
"nbf": 1707830437,
"scope": "all",
"iss": "http://localhost:63070/auth",
"exp": 1707837637,
"iat": 1707830437,
"jti": "8a657c60-968f-4d98-8a4c-22a7b4ecd333",
"authorities": [
"xc_sysmanager",
"xc_sysmanager_company",
"xc_sysmanager_doc",
"xc_sysmanager_log",
"xc_teachmanager_course_list"
],
"client_id": "XcWebApp",
"token_type": "Bearer"
}
@EnableWebFluxSecurity
@Configuration
public class SecurityConfig {
//安全拦截配置
@Bean
public SecurityWebFilterChain webFluxSecurityFilterChain(ServerHttpSecurity http) throws Exception {
return http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeExchange(exchanges ->
exchanges
.pathMatchers("/**").permitAll()
.anyExchange().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration corsConfig = new CorsConfiguration();
corsConfig.addAllowedOriginPattern("*"); // 允许任何源
corsConfig.addAllowedMethod("*"); // 允许任何HTTP方法
corsConfig.addAllowedHeader("*"); // 允许任何HTTP头
corsConfig.setAllowCredentials(true); // 允许证书(cookies)
corsConfig.setMaxAge(3600L); // 预检请求的缓存时间(秒)
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfig); // 对所有路径应用这个配置
return source;
}
}
这里需要注意几点
oauth2.jwt(Customizer.withDefaults())
,但实际上基于远程auth
微服务开放的jwkSetEndpoint
配置的JwtDecoder
。.cors(cors -> cors.configurationSource(corsConfigurationSource()))
一次性处理CORS
问题。 @PreAuthorize("hasAuthority('xc_teachmanager_course_list')")
@ApiResponse(responseCode = "200", description = "Successfully retrieved user")
@Operation(summary = "查询课程信息列表")
@PostMapping("/course/list")
public PageResult<CourseBase> list(
PageParams pageParams,
@Parameter(description = "请求具体内容") @RequestBody(required = false) QueryCourseParamsDto dto){
SecurityUtil.XcUser xcUser = SecurityUtil.getUser();
if (xcUser != null){
System.out.println(xcUser.getUsername());
System.out.println(xcUser.toString());
}
return courseBaseInfoService.queryCourseBaseList(pageParams, dto);
}
使用了@PreAuthorize("hasAuthority('xc_teachmanager_course_list')")
修饰的controller
资源。
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
//安全拦截配置
@Bean
public SecurityFilterChain defaultFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) ->
authorize
.requestMatchers("/**").permitAll()
.anyRequest().authenticated()
)
.csrf(AbstractHttpConfigurer::disable)
.oauth2ResourceServer(oauth -> oauth.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
return http.build();
}
private JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
jwtConverter.setJwtGrantedAuthoritiesConverter(jwt -> {
// 从JWT的claims中提取权限信息
List<String> authorities = jwt.getClaimAsStringList("authorities");
if (authorities == null) {
return Collections.emptyList();
}
return authorities.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
});
return jwtConverter;
}
}
需要注意几点
@EnableMethodSecurity
让@PreAuthorize
生效gateway
一样,需要基于远程auth
微服务开放的jwkSetEndpoint
配置JwtDecoder
。JwtAuthenticationConverter
,让anyRequest().authenticated()
需要验证的请求,除了完成默认的JWT
验证外,还需要完成JwtAuthenticationConverter
指定逻辑。JwtAuthenticationConverter
中将JWT
的authorities
部分形成数组后写入GrantedAuthorities
,这正是spring security6
用于校验@PreAuthorize
的字段。@Slf4j
public class SecurityUtil {
public static XcUser getUser(){
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null){
return null;
}
if (authentication instanceof JwtAuthenticationToken) {
JwtAuthenticationToken jwtAuth = (JwtAuthenticationToken) authentication;
System.out.println(jwtAuth);
Map<String, Object> tokenAttributes = jwtAuth.getTokenAttributes();
System.out.println(tokenAttributes);
Object sub = tokenAttributes.get("sub");
return JSON.parseObject(sub.toString(), XcUser.class);
}
return null;
}
@Data
public static class XcUser implements Serializable {
private static final long serialVersionUID = 1L;
private String id;
private String username;
private String password;
private String salt;
private String name;
private String nickname;
private String wxUnionid;
private String companyId;
/**
* 头像
*/
private String userpic;
private String utype;
private LocalDateTime birthday;
private String sex;
private String email;
private String cellphone;
private String qq;
/**
* 用户状态
*/
private String status;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
}
把JWT
的信息解析回XcUser
,相当于用户携带JWT
访问后端,后端可以根据JWT
获取此用户的信息。当然,你可以尽情的自定义,扩展。
当用户携带JWT
访问需要权限的资源时,现在可以正常的校验权限了。
RegisteredClient
时注册那么多redirectUri
是因为debug
了很久,才发现获取授权码和获取JWT
时,redirect_uri
参数需要一致。cors
问题,spring secuity6
似乎会一开始直接默认拒绝cors
,导致跨域请求刚到gateway
就寄了,到不了content
微服务,即使content
微服务配置了CORS
的处理方案,也无济于事。