赞
踩
书接上文
微服务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(); } /** * 授权服务器安全筛选器链 * <br/> * 来自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(); } /** * 默认筛选器链 * <br/> * 这个才是我们需要关心的过滤链,可以指定哪些请求被放行,哪些请求需要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; } /** * 客户端管理实例 * <br/> * 来自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源 * <br/> * 对访问令牌进行签名的示例,里面包含公私钥信息。 * * @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解码器 * <br/> * 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定制器 * <BR/> * 可以往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\":\"1138882663@qq.com\",\"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
的处理方案,也无济于事。赞
踩
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。