前几篇文章分别介绍了Security的原理和源码、讲解了实现整合Security的实现思路、讲解了认证成功和失败后的处理方法。但整合并没有完全结束。
1.已实现内容说明
1.自定了认证过滤器实现UsernamePasswordAuthenticationFilter,重写了里面的认证方法。
@Slf4j
public class CustomerAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtProperty jwtProperty;
public CustomerAuthenticationFilter (AuthenticationManager authenticationManager, JwtProperty jwtProperty) {
this.authenticationManager = authenticationManager;
this.jwtProperty = jwtProperty;
}
/**
* 重写用户认证入口
*
* @param request
* @param response
* @return
* @throws AuthenticationException
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
String contentType = request.getContentType();
String username;
String password;
// json请求处理
if ("application/json".equals(contentType)) {
SysUser sysUser = null;
try {
sysUser = new ObjectMapper().readValue(request.getInputStream(), SysUser.class);
} catch (IOException e) {
log.error("请求数据异常,未读取到用户信息");
return null;
}
username = sysUser.getUsername();
password = sysUser.getPassword();
} else {
//表单数据处理
username = this.obtainUsername(request);
password = this.obtainPassword(request);
}
//如果用户没有输入用户名返回null
if (StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) {
log.error("请求数据异常,未提交用户名或密码");
throw new UsernameNotFoundException("请求参数异常");
}
return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
}
/**
* 认证成功的处理
*
* @param request
* @param response
* @param chain
* @param authResult
* @throws IOException
* @throws ServletException
*/
@Override
public void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) throws IOException, ServletException {
SysUser user = new SysUser();
user.setUsername(authResult.getName());
user.setAuthoritiesFromAuthority(authResult.getAuthorities());
String token = JwtUtils.generateExpireTokenWithSecretKey(user, jwtProperty.getBase64EncodedKey(), 24 * 60 * 60);
response.addHeader("Authorization", "Bearer " + token);
//登录成功时,返回json格式进行提示
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter out = response.getWriter();
out.write(new ObjectMapper().writeValueAsString(R.ok(null, "登陆成功")));
out.flush();
out.close();
}
/**
* 认证失败异常处理
*
* @param request
* @param response
* @param failed
* @throws IOException
* @throws ServletException
*/
@Override
public void unsuccessfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException failed) throws IOException, ServletException {
SecurityContextHolder.clearContext();
response.setContentType("application/json;charset=utf-8");
PrintWriter writer = response.getWriter();
R r = R.failed();
if (failed instanceof InsufficientAuthenticationException) {
r.setMsg("请先登录~~");
} else if (failed instanceof BadCredentialsException) {
r.setMsg("用户名或密码错误");
} else if (failed instanceof UsernameNotFoundException) {
r.setMsg(failed.getLocalizedMessage());
} else {
r.setMsg("用户认证失败,请检查后重试");
}
writer.write(new ObjectMapper().writeValueAsString(r));
writer.flush();
writer.close();
}
}
2.自定义实现了UserDetail对象。
public class EdenUser extends User {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
/**
* 用户ID
*/
private String userId;
/**
* 拓展字段:权限
*/
private List<String> perms;
@JsonCreator
public EdenUser(@JsonProperty("userId") String userId,
@JsonProperty("username") String username, @JsonProperty("password") String password,
@JsonProperty("enabled") boolean enabled, @JsonProperty("accountNonExpired") boolean accountNonExpired,
@JsonProperty("credentialsNonExpired") boolean credentialsNonExpired,
@JsonProperty("accountNonLocked") boolean accountNonLocked,
@JsonProperty("authorities") Collection<? extends GrantedAuthority> authorities) {
super(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities);
this.userId = userId;
if (CollectionUtil.isNotEmpty(authorities)) {
this.perms = authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList());
}
}
@JsonIgnore
@Override
public Collection<GrantedAuthority> getAuthorities() {
if (CollectionUtil.isNotEmpty(perms)) {
return AuthorityUtils.commaSeparatedStringToAuthorityList(StringUtils.collectionToCommaDelimitedString(perms));
}
return null;
}
@JsonIgnore
@Override
public boolean isAccountNonExpired() {
return true;
}
@JsonIgnore
@Override
public boolean isAccountNonLocked() {
return true;
}
@JsonIgnore
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@JsonIgnore
@Override
public boolean isEnabled() {
return true;
}
}
3. 自定义实现并重写了UserDetailsService 的loadUserByUsername方法,有了自己的从数据库获取用户信息的逻辑。
因为是分布式系统,所以这里发出了Feign调用请求,调用system服务获取用户信息(主要是用户名和密码)、权限和菜单权限。system的代码这里就不再贴了,每个人的表结构也不同。即便你是单体服务直接查库,反正只要把这些信息都拿到,满足最后封装你的UserDetail对象的条件就行了。
@Slf4j
@Service
public class EdenUserDetailServiceImpl implements UserDetailsService {
@Autowired
private RemoteUserFeign userFeign;
/**
* 自定义授权认证
*
* @param username 用户登录时输入的用户名
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser sysUser = userFeign.loadUserByUsername(username);
if (sysUser == null) {
log.error("未查询到用户:{}的账户信息", username);
throw new UsernameNotFoundException("用户名或密码错误");
}
// 封裝並返回對象
List<String> permissions = sysUser.getRoles();
permissions.addAll(sysUser.getPerms());
return new EdenUser(sysUser.getId(), username, sysUser.getPassword(),
true, true, true, true,
AuthorityUtils.commaSeparatedStringToAuthorityList(StringUtils.collectionToCommaDelimitedString(permissions)));
}
}
4.增加了Security的配置
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
public class EdenWebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailService;
@Autowired
private EdenLoginSuccessHandler loginSuccessHandler;
@Autowired
private EdenLoginFailureHandler loginFailureHandler;
@Autowired
private EdenLogoutSuccessHandler logoutSuccessHandler;
@Autowired
private AuthProperty authProperty;
@Autowired
private JwtProperty jwtProperty;
;
/***
* 采用BCryptPasswordEncoder对密码进行编码
* @return
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 核心过滤器配置:
* 一般用来配置对静态资源的拦截忽略
*
* @param web
* @throws Exception
*/
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring()
.antMatchers("/test/**");
}
/**
* 安全过滤器链配置:
* 用于构建一个安全过滤器链 SecurityFilterChain
*
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
String ignoreUrls = "";
if (CollectionUtil.isNotEmpty(authProperty.getIgnoreUrls())) {
ignoreUrls = String.join(StrUtil.COMMA, authProperty.getIgnoreUrls());
http.authorizeRequests().antMatchers(ignoreUrls).permitAll();
}
http
// 禁用csrf攻击防护
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
//其余接口,认证通过后即可访问
.anyRequest().authenticated()
.and()
//启用表单身份验证
.formLogin()
//设置进行登录请求处理的接口地址
.loginProcessingUrl("/login")
.permitAll() // 和表单登录有关的直接放行
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessHandler(logoutSuccessHandler)
.permitAll() // 和退出登录有关的直接放行
.and()
.addFilter(new CustomerAuthenticationFilter(authenticationManager(), jwtProperty))
;
}
/**
* 身份认证管理器配置:
* 配置身份认证相关
*
* @param auth
* @throws Exception
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailService).passwordEncoder(passwordEncoder());
}
/**
* @Author: Yan
* @Since: 2023/2/4
* @Description: AuthenticationManager对象在OAuth2认证服务中要使用,提取放入IOC容器中
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
5.经过上面的配置,当请求登录通过了认证后,Security如约地返回了Token。Token中是自定义封装的信息,包含有用户名、密码和权限列表都作为payload放在jwt中,而且还被秘钥加了密。
6.因为是分布式系统,网关(gateway)也配置了Token的验证和放行。如果你不知道我在说什么,看看之前的文章《Spring Security的实现思路》。在这一篇文章里提到了我们所使用的网关验证、资源服务解析的方式。而且网关顺利的拦截到了请求,并验证通过了Token后放行。
@Component
@AllArgsConstructor
public class AuthGlobalFilter implements GlobalFilter, Ordered {
private final JwtProperty jwtProperty;
private final List<String> IGNORE_URIS = CollectionUtil.newArrayList("/auth/login", "/auth/logout", "/auth/oauth/**");
@SneakyThrows
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
//直接放行部分请求路径,如登录、退出等 需要排除的路径弄成可yaml配置的
if (IGNORE_URIS.contains(request.getURI().getPath())) {
return chain.filter(exchange);
}
//获取请求头中的令牌
String token = request.getHeaders().getFirst(SecurityConstant.AUTHENTICATION_HEADER);
// 其他路径,没有令牌或令牌校验失败,不允许访问
if (StrUtil.isBlank(token) || !JwtUtils.verify(token.replace(SecurityConstant.AUTHENTICATION_PREFIX, ""),
jwtProperty.getBase64EncodedKey())) {
ServerHttpResponse response = exchange.getResponse();
// 结束请求并响应信息
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.writeWith(
Mono.just(response.bufferFactory()
.wrap(new ObjectMapper().writeValueAsBytes(R.failed(HttpStatusEnum.of(HttpStatus.UNAUTHORIZED.value()))))
)
);
}
return chain.filter(exchange);
}
/**
* 优先执行对权限的校验
*
* @return 最高执行级别
*/
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
}
}
上面就是我们为整合Security已经完成的工作。后面我们将请求一个自定义接口,看能够顺利进入接口拿到响应。
下面是我在认证服务中自定义的一个接口,专门用来做请求的验证。
@RestController
@RequestMapping("/oauth")
public class AuthController {
/**
* 登陆后,访问测试
*
* @return
*/
@RequestMapping("/toLogin")
public String toLogin() {
return "login";
}
}
2.问题
如果按照上面贴出的配置,我们能请求到接口嘛?答案是否定的。
Token也携带了,放出请求后,网关放行了。但我还是看到了熟悉的登陆界面.....
这说明我们还是被Security拦住了?why???
3.原因分析
结论是,因为Security始终没有办法从SecurityContext中获取到用户信息。
Security框架获取信息肯定是从他自己的SecurityContext上下文中取,不可能从request请求里面拿吧,即便拿了也只能拿到Jwt形式的Token而已,框架自己又不会解密。他从上下文中得不到你的用户信息认为你就是没有登录,没登录那就去登录页面呗。
我们之前说过Secuirty是通过过滤器链实现功能的。他的过滤器有15个。
第二个过滤器的名字SecurityContextPersistenceFilter,一看就是上下文持久化有关的。当你发出请求并没有将用户信息解析出来放在SecurityContext中的时候,这个过滤器从上下文里面也就拿不到,拿不到就没有办法做持久化,供后续使用。
zu
4.解决方法
既然原因是没有放用户信息在SecurityContext,那就放一个呗。但,在哪放?
我们可以从上面的代码中看到,这个过滤器执行过程中有放行的操作chain.doFilter(holder.getRequest(), holder.getResponse());,放行就是传递给后面的过滤器链继续执行,但代码里我们看到最后会回到finally代码块里面继续执行持久化的操作。
那我们就在持久化之前保证他能从SecurityContext中获取到用户信息,能有东西做持久化不就好了?我们来说具体做法。
4.1.自定义过滤器
在这个过滤器doFilter的时候,把请求放行到你自定义的过滤器;在自定义的过滤器里往SecurityContext放入用户信息就行了。
示例如下:
public class BeforeAuthenticateCheckFilter extends OncePerRequestFilter {
private JwtProperty jwtProperty;
public BeforeAuthenticateCheckFilter(JwtProperty jwtProperty) {
this.jwtProperty = jwtProperty;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
//获取请求头中的令牌
String token = request.getHeader(SecurityConstant.AUTHENTICATION_HEADER);
// 其没有令牌,直接放行
if (StrUtil.isBlank(token)) {
filterChain.doFilter(request, response);
return;
}
//解析令牌
SysUser sysUser = JsonUtils.toBean(JwtUtils.parserToken2ObjectWithSecretKey(
token.replace(SecurityConstant.AUTHENTICATION_PREFIX, ""),
jwtProperty.getBase64EncodedKey()), SysUser.class);
if (null == sysUser) {
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
PrintWriter out = response.getWriter();
out.write(new ObjectMapper().writeValueAsString(R.failed("用户信息异常")));
out.flush();
out.close();
}
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(sysUser.getUsername(),
sysUser.getPassword(),
AuthorityUtils.commaSeparatedStringToAuthorityList(StringUtils.collectionToCommaDelimitedString(sysUser.getAuthorities()))));
filterChain.doFilter(request, response);
}
}
特别注意:如果没有请求中没有令牌,一定要放行,让后面的过滤器处理,不要直接结束请求。想想这个道理,后面我们需要把它放在登录认证过滤器的前面,万一人家就是要去登录呢,现在肯定是没有Token的,那岂不是永远也登陆不了了....
// 其没有令牌,直接放行 if (StrUtil.isBlank(token)) { filterChain.doFilter(request, response); return; }
4.2.配置过滤器
上面是自定义过滤器,解析出用户信息并存入上下文。还有一步那就是自定义过滤器加入到过滤器链中。
通常的做法是:自定义过滤器加在UsernamePasswordAuthenticationFilter过滤器之前。
示例如下:
@Override
protected void configure(HttpSecurity http) throws Exception {
String ignoreUrls = "";
if (CollectionUtil.isNotEmpty(authProperty.getIgnoreUrls())) {
ignoreUrls = String.join(StrUtil.COMMA, authProperty.getIgnoreUrls());
http.authorizeRequests().antMatchers(ignoreUrls).permitAll();
}
http
// 此处省略无关配置
.addFilterBefore(new BeforeAuthenticateCheckFilter(jwtProperty), CustomerAuthenticationFilter.class)
.addFilter(new CustomerAuthenticationFilter(authenticationManager(), jwtProperty));
}
CustomerAuthenticationFilter是我们自定义的UsernamePasswordAuthenticationFilter的实现类,用来处理用户信息认证逻辑的核心过滤器。我们这次自定义的过滤器就放在他前面。
5.测试
重启后再进行测试。持久化之前能取到用户信息了; 接口有响应了。