目录
1. 基本原理
2. 具体实现
2.1 通过mysql加载用户信息的实现
2.2 实现基本的Security逻辑的配置类实现
2.3 实现基本的访问路径资源权限
2.4 登录验证中常用的回调函数自定义
2.4.1 自定义登录成功回调ServerAuthenticationSuccessHandler
2.4.2 自定义登录失败回调ServerAuthenticationFailureHandler
2.4.3 自定义因未登录而访问未授权路径的回调ServerAuthenticationEntryPoint
2.4.4 自定义访问未授权资源路径的回调ServerAccessDeniedHandler
2.4.5 自定义成功退出登录的回调ServerLogoutSuccessHandler
2.4.6 最终配置类引用上述自定义回调的修改
1. 基本原理
spring gateway采用的是webflux的反应式实现,因此对应的sring security也需要用webflux的处理方式,个人整理的WebFlux应用的认证过程如下:
- 用户通过form表单输入用户名和密码,通过POST请求登录认证
- 后台通过ServerFormLoginAuthenticationConverter类的convert接口,把获取到的用户名和密码封装成一个UsernamePasswordAuthenticationToken对象(Authentication的子类),然后把它传递给 ReactiveAuthenticationManager进行认证。
- ReactiveAuthenticationManager的认证逻辑接口authenticate
(1) 如果认证失败则返回null,程序会自动跳到第1 步让用户重新输入用户名和密码
(2) 如果认证成功则返回UsernamePasswordAuthenticationToken对象(authenticated字段被设置为true),并进行下面的步骤 - 将返回的UsernamePasswordAuthenticationToken对象通过类ServerSecurityContextRepository的save接口,把UsernamePasswordAuthenticationToken对象保存到SecurityContext对象中
- 然后默认会将用户重定向到之前访问的页面。
- 用户登录认证成功后再次访问之前受保护的资源时,就会调用ReactiveAuthorizationManager类的check接口对用户访问的路径进行权限鉴定,如不存在对应路径的访问权限,则默认会返回 403 错误码(如果要特殊处理,可以实现ServerAccessDeniedHandler类的handle接口,返回特定的信息)。
【注意】另外,如果要通过数据库或redis查找用户信息,可以重载实现ReactiveUserDetailsService的接口findByUsername,从其他数据源里查出user数据转成UserDetails对象,如果除了用户名和密码,还有其他额外的用户信息需要保存,可以重置UserDetails类,添加额外的信息
基本逻辑类都在包: spring-security-core-6.0.3.jar中
- AuthenticationWebFilter登录认证过滤器的filter接口
包路径:org.springframework.security.web.server.authentication - ServerFormLoginAuthenticationConverter 登录认证:
包路径:org.springframework.security.web.server.authentication
调用包:org.springframework.security.web.server的同名类:
ServerFormLoginAuthenticationConverter的apply接口
取Form转成Map的username和password - ServerHttpBasicAuthenticationConverter类
包路径:org.springframework.security.web.server.authentication
http头字段:Authorization
格式:Basic base64(用户名:密码) - 默认的观察者模式ReactiveAuthenticationManager类:
ObservationReactiveAuthenticationManager
包路径:org.springframework.security.authentication - 默认的ReactiveAuthenticationManager:
UserDetailsRepositoryReactiveAuthenticationManager
(主要验证逻辑在父类:AbstractUserDetailsReactiveAuthenticationManager)
验证通过在createUsernamePasswordAuthenticationToken接口中设置登录验证通过标识authenticated为true
包路径:org.springframework.security.authentication - 默认的ServerSecurityContextRepository:
WebSessionServerSecurityContextRepository - 默认的SecurityContext:
SecurityContextImpl
包路径:org.springframework.security.core.context - 默认的ReactiveAuthorizationManager:
AuthenticatedReactiveAuthorizationManager
包路径:org.springframework.security.authorization
2. 具体实现
2.1 通过mysql加载用户信息的实现
基本的重载方法,为了支持从数据库查找用户登录信息,实现接口类:UserDetails
package com.cloudservice.gateway_service.security;import java.util.Collection;import java.util.Set;import org.springframework.security.core.GrantedAuthority;import org.springframework.security.core.userdetails.UserDetails;public class GatewayUserDetails implements UserDetails { private Long id; // 用户id private String password; // 密码 private String username; // 用户名 private boolean enabled; // 帐户是否可用 private Setauthorities; // 权限信息 public Long getId() { return id; } public void setId(Long id) { this.id = id; } @Override public Collection extends GrantedAuthority> getAuthorities() { return authorities; } public void setAuthorities(Set authorities) { this.authorities = authorities; } @Override public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } @Override public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } @Override public boolean isAccountNonExpired() { // TODO 帐号是到到期 return true; } @Override public boolean isAccountNonLocked() { // TODO 帐号是否锁定 return true; } @Override public boolean isCredentialsNonExpired() { // TODO 密码是否到期 return true; } @Override public boolean isEnabled() { return this.enabled; } public void setEnabled(boolean enabled) { this.enabled = enabled; }}
定义用户表的领域类:Users
package com.cloudservice.gateway_service.security;import java.util.HashSet;import java.util.Set;import jakarta.persistence.Entity;import jakarta.persistence.GeneratedValue;import jakarta.persistence.GenerationType;import jakarta.persistence.Id;import jakarta.validation.constraints.NotNull;import jakarta.validation.constraints.Size;import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;@Data@AllArgsConstructor@NoArgsConstructor@Entitypublic class Users { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; @NotNull @Size(min=5, message="Username must be at least 5 characters long") private String username; @NotNull private String password; private boolean enabled; public GatewayUserDetails to_user_details() { GatewayUserDetails userDetails = new GatewayUserDetails(); userDetails.setId(id); userDetails.setUsername(username); userDetails.setPassword(password); userDetails.setEnabled(enabled); // 此处为了测试路径权限,写死order路径权限,实际中应该是从角色与路径的授权表groups_authorities里读取加载 Setauthorities = new HashSet (); authorities.add(new GatewayUserGrantedAuthority("/order/")); userDetails.setAuthorities(authorities); return userDetails; } public static Users from_user_details(GatewayUserDetails userDetails) { return new Users(userDetails.getId() , userDetails.getUsername() , userDetails.getPassword() , userDetails.isEnabled()); }}
简单通过使用JPA 的Repository类来根据用户名查数据库返回基本的用户信息:
package com.cloudservice.gateway_service.security;import org.springframework.data.repository.CrudRepository;public interface UsersRepository extends CrudRepository{ Iterable findByUsername(String username);}
网关的nacos配置里加上mysql的datasource配置:
spring:
datasource:
url: jdbc:mysql://192.168.10.111:32001/gateway?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true
username: mysql用户名
password: mysql密码
driver-class-name: com.mysql.cj.jdbc.Driver
自定义加载用户信息的类ReactiveUserDetailsService:
package com.cloudservice.gateway_service.security;import java.util.ArrayList;import java.util.List;import org.apache.commons.codec.digest.DigestUtils;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.security.core.userdetails.ReactiveUserDetailsService;import org.springframework.security.core.userdetails.UserDetails;import org.springframework.stereotype.Component;import lombok.extern.slf4j.Slf4j;import reactor.core.publisher.Flux;import reactor.core.publisher.Mono;@Slf4j@Componentpublic class GatewayReactiveUserDetailsService implements ReactiveUserDetailsService { @Autowired private UsersRepository usersRepository; @Override public MonofindByUsername(String username) { // TODO 优先查找缓存再查找数据库 // String Val1 = passwordEncoder.encode("123456"); log.info("gateway find user: {} {}", username, DigestUtils.sha256Hex("123456")); List userDetailsList = new ArrayList (); usersRepository.findByUsername(username).forEach(user -> { userDetailsList.add(user.to_user_details()); }); return Flux.fromIterable(userDetailsList).next(); }}
2.2 实现基本的Security逻辑的配置类实现
package com.cloudservice.gateway_service.security;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;import org.springframework.security.config.web.server.ServerHttpSecurity;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.crypto.password.PasswordEncoder;import org.springframework.security.web.server.SecurityWebFilterChain;@Configuration@EnableWebFluxSecuritypublic class GatewaySecurityConfig { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { http .authorizeExchange() .pathMatchers("/favicon.*", "/login", "/logout").permitAll() .anyExchange().authenticated() .and().formLogin() .and().csrf().disable(); return http.build(); }}
2.3 实现基本的访问路径资源权限
重载路径权限验证类 ReactiveAuthorizationManager,代码如下:
package com.cloudservice.gateway_service.security;import org.springframework.security.authentication.AuthenticationTrustResolver;import org.springframework.security.authentication.AuthenticationTrustResolverImpl;import org.springframework.security.authorization.AuthorizationDecision;import org.springframework.security.authorization.ReactiveAuthorizationManager;import org.springframework.security.core.Authentication;import org.springframework.security.web.server.authorization.AuthorizationContext;import org.springframework.stereotype.Component;import reactor.core.publisher.Mono;@Componentpublic class GatewayReactiveAuthorizationManager implements ReactiveAuthorizationManager{ private AuthenticationTrustResolver authTrustResolver = new AuthenticationTrustResolverImpl(); // 验证通过则返回:AuthorizationDecision(true) // 验证失败则返回:AuthorizationDecision(false) @Override public Mono check(Mono authentication, AuthorizationContext context) { return authentication .filter(authentication_filter -> select_context(authentication_filter, context)) .map(authentication_map -> authenticate(authentication_map, context)) .defaultIfEmpty(new AuthorizationDecision(false)); } private boolean select_context(Authentication authentication, AuthorizationContext context) { // String req_path = context.getExchange().getRequest().getURI().getPath(); // log.info("check filter path: {}", req_path); return !this.authTrustResolver.isAnonymous(authentication); } private AuthorizationDecision authenticate(Authentication authentication, AuthorizationContext context) { if (authentication.isAuthenticated()) { // 判断context.getExchange().getRequest().getPath()是否在authentication_notanonymous.getAuthorities()集合中 String req_path = context.getExchange().getRequest().getURI().getPath(); if (authentication.getAuthorities().contains(new GatewayUserGrantedAuthority(req_path)) == false) { return new AuthorizationDecision(false); } } return new AuthorizationDecision(authentication.isAuthenticated()); }}
用户可访问路径的集合存放在authorities集合里,为了支持集合类的contains操作,必须重载GrantedAuthority类并实现equals和hashCode接口,具体实现代码如下:
package com.cloudservice.gateway_service.security;import org.springframework.security.core.GrantedAuthority;public class GatewayUserGrantedAuthority implements GrantedAuthority, java.lang.Comparable
修改配置类GatewaySecurityConfig的springSecurityFilterChain接口,引用路径鉴权类GatewayReactiveAuthorizationManager:
@Configuration@EnableWebFluxSecuritypublic class GatewaySecurityConfig { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Autowired private GatewayReactiveAuthorizationManager gatewayReactiveAuthorizationManager; @Bean public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { http .authorizeExchange() .pathMatchers("/favicon.ico", "/login", "/logout").permitAll() .anyExchange().access(gatewayReactiveAuthorizationManager) .and().formLogin() .and().csrf().disable(); return http.build(); }}
2.4 登录验证中常用的回调函数自定义
2.4.1 自定义登录成功回调ServerAuthenticationSuccessHandler
通过自定义登录成功时的回调函数,可以自定义登录成功后的返回消息,vue3中可以直接返回json格式,方便判断后在vue3的js代码中实现提示后跳转逻辑,实现代码如下:
package com.cloudservice.gateway_service.security;import org.springframework.core.io.buffer.DataBuffer;import org.springframework.http.server.reactive.ServerHttpResponse;import org.springframework.security.core.Authentication;import org.springframework.security.web.server.WebFilterExchange;import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler;import org.springframework.stereotype.Component;import org.springframework.web.server.ServerWebExchange;import com.fasterxml.jackson.databind.ObjectMapper;import lombok.extern.slf4j.Slf4j;import reactor.core.publisher.Mono;@Slf4j@Componentpublic class CustomLoginSuccessHandler implements ServerAuthenticationSuccessHandler{ @Override public MonoonAuthenticationSuccess(WebFilterExchange webFilterExchange, Authentication authentication) { ServerWebExchange exchange = webFilterExchange.getExchange(); ServerHttpResponse response = exchange.getResponse(); response.getHeaders().add("Content-Type", "application/json; charset=UTF-8"); log.info("user login success: {}", authentication.getName()); Object principal = authentication.getPrincipal(); ObjectMapper objectMapper = new ObjectMapper(); DataBuffer bodyDataBuffer = null; try { bodyDataBuffer = response.bufferFactory().wrap(objectMapper.writeValueAsBytes(principal)); } catch (Exception e) { e.printStackTrace(); } return response.writeWith(Mono.just(bodyDataBuffer)); }}
2.4.2 自定义登录失败回调ServerAuthenticationFailureHandler
通过自定义登录失败回调,可以定制返回登录失败的消息,vue3里一般需要返回json格式数据,实现代码如下:
package com.cloudservice.gateway_service.security;import java.util.HashMap;import org.springframework.core.io.buffer.DataBuffer;import org.springframework.http.HttpStatus;import org.springframework.http.server.reactive.ServerHttpResponse;import org.springframework.security.authentication.BadCredentialsException;import org.springframework.security.authentication.LockedException;import org.springframework.security.core.AuthenticationException;import org.springframework.security.web.server.WebFilterExchange;import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler;import org.springframework.stereotype.Component;import com.alibaba.fastjson.JSON;import lombok.extern.slf4j.Slf4j;import reactor.core.publisher.Mono;@Slf4j@Componentpublic class CustomLoginFailureHandler implements ServerAuthenticationFailureHandler { @Override public MonoonAuthenticationFailure(WebFilterExchange webFilterExchange, AuthenticationException exception) { ServerHttpResponse response = webFilterExchange.getExchange().getResponse(); response.setStatusCode(HttpStatus.FORBIDDEN); response.getHeaders().add("Content-Type", "application/json; charset=UTF-8"); log.info("user login fail: {}", webFilterExchange.getExchange().getRequest().getPath()); HashMap map = new HashMap (); map.put("code", "-1001"); if (exception instanceof LockedException) { map.put("message", "账户被锁定,请联系管理员!"); } else if (exception instanceof BadCredentialsException) { map.put("message", "用户名或者密码输入错误,请重新输入!"); } else { map.put("message", exception.getMessage()); } DataBuffer dataBuffer = response.bufferFactory().wrap(JSON.toJSONBytes(map)); return response.writeWith(Mono.just(dataBuffer)); }}
2.4.3 自定义因未登录而访问未授权路径的回调ServerAuthenticationEntryPoint
通过自定义未登录而访问路径的错误回调 ,可以返回vue3需要的json格式,也可以强制跳转到登录页面,此处实现是强制跳转页面:
package com.cloudservice.gateway_service.security;import java.net.URI;import java.util.HashMap;import org.springframework.core.io.buffer.DataBuffer;import org.springframework.http.HttpStatus;import org.springframework.http.server.reactive.ServerHttpResponse;import org.springframework.security.core.AuthenticationException;import org.springframework.security.web.server.ServerAuthenticationEntryPoint;import org.springframework.stereotype.Component;import org.springframework.web.server.ServerWebExchange;import com.alibaba.fastjson.JSON;import lombok.extern.slf4j.Slf4j;import reactor.core.publisher.Mono;@Slf4j@Componentpublic class CustomNoLoginHandler implements ServerAuthenticationEntryPoint { @Override public Monocommence(ServerWebExchange exchange, AuthenticationException ex) { return Mono.fromRunnable(() -> { ServerHttpResponse response = exchange.getResponse(); response.setStatusCode(HttpStatus.FOUND); response.getHeaders().add("Content-Type", "application/json; charset=UTF-8"); // 强制跳转到登录页面,在vue3中一般是返回json数据,并交由vue3来跳转 response.getHeaders().setLocation(URI.create("/login")); log.info("url when no login: {}", exchange.getRequest().getPath()); HashMap map = new HashMap<>(); map.put("code", HttpStatus.FOUND.value()); map.put("message", "暂未登录,请您先进行登录"); DataBuffer dataBuffer = response.bufferFactory().wrap(JSON.toJSONBytes(map)); response.writeWith(Mono.just(dataBuffer)); }); }}
【注意】加了此自定义回调时,不能正常访问security默认定义的login页面,需要自定义登录页面,本人简单的实现示例:
此示例的controller使用到thymeleaf的模板,因此需要在pom.xml中引用依赖:
spring-boot-starter-thymeleaf
创建一个用来接收GET方式的login请求的controller:
package com.cloudservice.gateway_service.security;import org.springframework.stereotype.Controller;import org.springframework.web.bind.annotation.GetMapping;@Controllerpublic class CustomLoginControl { @GetMapping("/login") public String login() { return "login"; }}
在src/main/resources/templates/目录下定义登录页面模板login.html
Please sign in
2.4.4 自定义访问未授权资源路径的回调ServerAccessDeniedHandler
当访问资源路径进行权限验证时,ReactiveAuthorizationManager验证不通过,则会回调此类的接口,可以自定义返回的数据格式,代码如下:
package com.cloudservice.gateway_service.security;import java.util.HashMap;import org.springframework.core.io.buffer.DataBuffer;import org.springframework.http.HttpStatus;import org.springframework.http.server.reactive.ServerHttpResponse;import org.springframework.security.access.AccessDeniedException;import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler;import org.springframework.stereotype.Component;import org.springframework.web.server.ServerWebExchange;import com.alibaba.fastjson.JSON;import lombok.extern.slf4j.Slf4j;import reactor.core.publisher.Mono;@Slf4j@Componentpublic class CustomUrlNoRightHandler implements ServerAccessDeniedHandler{ @Override public Monohandle(ServerWebExchange exchange, AccessDeniedException denied) { ServerHttpResponse response = exchange.getResponse(); response.setStatusCode(HttpStatus.FORBIDDEN); response.getHeaders().add("Content-Type", "application/json; charset=UTF-8"); log.info("url no right: {}", exchange.getRequest().getPath()); HashMap map = new HashMap<>(); map.put("code", "-1002"); map.put("message", "资源路径无访问权限!"); DataBuffer dataBuffer = response.bufferFactory().wrap(JSON.toJSONBytes(map)); return response.writeWith(Mono.just(dataBuffer)); }}
2.4.5 自定义成功退出登录的回调ServerLogoutSuccessHandler
用于自定义退出登录是的返回数据格式,示例代码如下:
package com.cloudservice.gateway_service.security;import java.util.HashMap;import org.springframework.core.io.buffer.DataBuffer;import org.springframework.http.HttpStatus;import org.springframework.http.server.reactive.ServerHttpResponse;import org.springframework.security.core.Authentication;import org.springframework.security.web.server.WebFilterExchange;import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;import org.springframework.stereotype.Component;import com.alibaba.fastjson.JSON;import lombok.extern.slf4j.Slf4j;import reactor.core.publisher.Mono;@Slf4j@Componentpublic class CustomLogoutSuccessHandler implements ServerLogoutSuccessHandler {@Override public MonoonLogoutSuccess(WebFilterExchange exchange, Authentication authentication) { ServerHttpResponse response = exchange.getExchange().getResponse(); response.setStatusCode(HttpStatus.OK); response.getHeaders().add("Content-Type", "application/json; charset=UTF-8"); log.info("user logout success: {}", authentication.getName()); HashMap map = new HashMap<>(); map.put("code", "0"); map.put("message", "退出登录成功!"); DataBuffer dataBuffer = response.bufferFactory().wrap(JSON.toJSONBytes(map)); return response.writeWith(Mono.just(dataBuffer)); }}
2.4.6 最终配置类引用上述自定义回调的修改
package com.cloudservice.gateway_service.security;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;import org.springframework.security.config.web.server.ServerHttpSecurity;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.crypto.password.PasswordEncoder;import org.springframework.security.web.server.SecurityWebFilterChain;@Configuration@EnableWebFluxSecuritypublic class GatewaySecurityConfig { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Autowired private GatewayReactiveAuthorizationManager gatewayReactiveAuthorizationManager; @Autowired private CustomLoginFailureHandler customLoginFailureHandler; @Autowired private CustomLoginSuccessHandler customLoginSuccessHandler; @Autowired private CustomNoLoginHandler customNoLoginHandler; @Autowired private CustomLogoutSuccessHandler customLogoutSuccessHandler; @Autowired private CustomUrlNoRightHandler customUrlNoRightHandler; @Bean public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { http .authorizeExchange() .pathMatchers("/favicon.ico", "/login", "/logout").permitAll() .anyExchange().access(gatewayReactiveAuthorizationManager) .and().formLogin() .authenticationFailureHandler(customLoginFailureHandler) .authenticationSuccessHandler(customLoginSuccessHandler) .and().exceptionHandling() .accessDeniedHandler(customUrlNoRightHandler) .authenticationEntryPoint(customNoLoginHandler) .and().logout().logoutSuccessHandler(customLogoutSuccessHandler) .and().csrf().disable(); return http.build(); }}
【注意】
1. 登录会话的超时配置,可以修改nacos配置,加上:
server:
reactive:
session:
timeout: 1m # session超时时间为1分钟, 默认是60分钟
2. 为了防止集中登录导致的登录数据库过载,修改GatewayReactiveUserDetailsService类的findByUsername接口实现,引入redis,优先查看redis缓存里有没有用户信息,有则从redis中加载,否则才从mysql数据库中加载
[上一篇]从零开始搭建高负载java架构(04)——gateway网关节点(动态路由)
[下一篇]从零开始搭建高负载java架构(06):gateway网关节点(sentinel篇)
参考资料:
[1]Spring Security详细讲解(JWT+SpringSecurity登入案例)
[2]Spring Security核心类简介
[3]在 webflux 环境中使用 Spring Security