SpringSecurityOAuth2单点登录
昨天我发了一个单点登录版本的验证博客,到今天早上我再研究了一下,发现了一些问题:
所以,下面就是介绍与GateWay结合的方式去认证.
这里我为什么没提OAuth,因为没有OAuth也是可以进行鉴权的,但是这种方式你必须使用一个中间件,去保存需要鉴权的路径,哪些角色可以访问哪些路径你必须保存下来,因为我们在单个微服务上是没有鉴权操作了的,而是在GateWay网关里鉴权,所以无法在每个方法上加上注解的方式去鉴权,就必须得保存每个路径是哪个角色可以访问的.
所以这里就必须由一个基本的关系,做这个之前必须得清楚:
一个用户可以由多个角色,一个角色又可以有多个权限,所以这个用户包含其所拥有角色的所有权限.
怎么给用户赋予角色,给角色赋予权限,是数据库的事情了,假如这里已经有一些数据,反正你就记得是这个结构就行.
那么下面的鉴权流程可以是
前端有一个请求,直接打到了认证中心,这个时候,先来到AbstractAuthenticationProcessingFilter中的doFilter方法进行请求过滤,通常可以实现自定义过滤器,过滤成功,尝试鉴权attemptAuthentication,这个方法是由其子类UsernamePasswordAuthenticationFilter实现的,通常可以实现自定义鉴权,然后来到UserDetailsService的loadUserByUsername方法,这个方法经常用来做自定义登录逻辑,授权成功后执行successfulAuthentication方法,这个方法在doFilter方法里被调用,底层会调用核心方法onAuthenticationSuccess,该方法用来实现自定义授权成功逻辑,相反,就有一个自定义授权失败逻辑AuthenticationFailureHandler接口中的onAuthenticationFailure方法
这里多提一嘴,如果是要自定义鉴权逻辑去代替系统的逻辑,一般都是通过自定义才能实现多彩的鉴权方案,至于有些人为什么自定义鉴权逻辑没有生效,那是因为你没有在配置方法configure(HttpSecurity http)中加入这些个逻辑.
下面开始上代码了,先直接上代码,继续往下我会给出相关代码的解释
@Component
public class SheepUserDetailsService implements UserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
System.out.println("函数进来了");
if( !"admin".equals(s) )
throw new UsernameNotFoundException("用户" + s + "不存在" );
//TODO
//根据用户名查询对应角色,然后根据角色查询对应所能访问路径
//将这些路径包装成集合传过去
//获取你直接查询到了角色,先将角色传过去,我这里是先将角色传过去
int id=0;
return new User( s+"-"+id, passwordEncoder.encode("123456"), AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_NORMAL,ROLE_MEDIUM"));
}
}
@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
User principal = (User) authentication.getPrincipal();
String username = principal.getUsername();
String[] users = username.split("-");
// TODO 得到该用户全部角色
//Collection authorities = principal.getAuthorities();
//TODO 查询数据库得到该角色所能访问的全部路径
//模拟:
String[] s1=new String[]{"/login","/register","/serviceedu/front/listTeacher"};
//将权限路径封装到redis中
redisUtils.setCollectionSet(users[0],s1,24, TimeUnit.HOURS);
String s= redisUtils.get("fromUrl");
if (s==null)
s="/";
String jwtToken = JwtUtils.getJwtToken(users[1], users[0]);
Msg msg = Msg.success().data("username", users[0]).data("fromUrl",s).data("token",jwtToken);
redisUtils.del("fromUrl");
httpServletResponse.setContentType("text/json;charset=utf-8");
//塞到HttpServletResponse中返回给前台
httpServletResponse.getWriter().write(JSON.toJSONString(msg));
}
}
@Component
public class FailHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
Msg fail = Msg.fail().data("message", "登录失败或权限不足");
httpServletResponse.setContentType("text/json;charset=utf-8");
//塞到HttpServletResponse中返回给前台
//这种方式很常用,后面的代码都有很多,唯一能反馈给前端json格式的消息
httpServletResponse.getWriter().write(JSON.toJSONString(fail));
}
}
@Component
public class CustomLogoutSuccessHandler extends AbstractAuthenticationTargetUrlRequestHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
// 将子系统的cookie删掉
//建议将token也删除,直接写个controller接口就可以了,可以在前端调用/logout的同时调用删除token接口
Cookie[] cookies = request.getCookies();
if(cookies != null && cookies.length>0){
for (Cookie cookie : cookies){
cookie.setMaxAge(0);
cookie.setPath("/");
response.addCookie(cookie);
}
}
super.handle(request, response, authentication);
}
}
@Configuration
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Autowired
SuccessHandler successHandler;
@Autowired
FailHandler failHandler;
@Autowired
private CustomLogoutSuccessHandler customLogoutSuccessHandler;
//@Autowired
//CustomizeAuthenticationEntryPoint customizeAuthenticationEntryPoint;
// @Autowired
// MyUsernamePasswordAuthenticationFilter myUsernamePasswordAuthenticationFilter;
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userDetailsService);
authenticationProvider.setPasswordEncoder(passwordEncoder());
authenticationProvider.setHideUserNotFoundExceptions(false);
return authenticationProvider;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
// .usernameParameter("mobile")
// .passwordParameter("password")
//自定义登录页面,这个页面是一个controller路径
//我们只需要在对应的controller中重定向到你前端的页面就可以了
.loginPage("/unLogin")
//登录处理逻辑路径,/login代表用系统的处理逻辑
//但是我们重写了用户逻辑,所以会走到重写的用户逻辑里
.loginProcessingUrl("/login")
//自定义鉴权成功处理
.successHandler(successHandler)
//自定义鉴权失败处理
.failureHandler(failHandler)
.permitAll()
.and()
.logout()
//自定义登录成功处理
.logoutSuccessHandler(customLogoutSuccessHandler)
// 无效会话
.invalidateHttpSession(true)
// 清除身份验证
.clearAuthentication(true)
.and().csrf().disable();
//异常处理(权限拒绝、登录失效等)
// .exceptionHandling()
// .authenticationEntryPoint(customizeAuthenticationEntryPoint);
http.authorizeRequests()
.antMatchers(
"/oauth/**",
"/login/**",
"/unLogin",
"/logout/**",
"/uac/oauth/token",
"http://localhost:3000/login",
"http://localhost:8085/uac/login"
).permitAll().anyRequest().authenticated();
//将@bean注入的鉴权器加入到配置当中
//http.addFilterAt(myAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
//如果自定义鉴权器,也要做相关配置,不然不走你的鉴权器
// @Bean
// MyUsernamePasswordAuthenticationFilter myAuthenticationFilter() throws Exception {
// MyUsernamePasswordAuthenticationFilter filter = new MyUsernamePasswordAuthenticationFilter();
// filter.setAuthenticationManager(authenticationManagerBean());
// filter.setAuthenticationSuccessHandler(successHandler);
// filter.setAuthenticationFailureHandler(failHandler);
// return filter;
// }
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(authenticationProvider());
}
}
认证中心这样的就完成了,有人会问,怎么没有OAtuh的配置?注意我这里是用SpringSecurity+GateWay方式去认证,用不到OAuth.
这也是为什么你会看到我用了一个redis去保存用户跟权限这两者的关系,我这里为什么不保存角色,因为没那个必要,虽然一个用户有多个角色,但是这些角色下的权限都属于用户的,而前端传过来的是路径,所以这里我们只是存储用户跟路径权限之间的关系,到时候就可以直接判断该用户是否可以访问该路径了.
那么还有一个小问题就是,你会发现我并没有利用认证中心底层给我生成的token,这个token很难利用上,要利用的话估计要实现自定义过滤器,反正我拿不到.
但是我想到了一个绝妙且笨的方法,就是在鉴权成功的自定义处理方法中,可以直接用RestTemplate发送一个/oauth/token的请求,这样子不就直接拿到token值了吗?然后将其反馈给前端,也不用我们自己去生成这个token
@Component("a")
@Order(2)
//设置执行优先级,在 全局权限认证过滤器 之前执行
public class AuthenticationFilter implements GlobalFilter, InitializingBean {
@Autowired
private RestTemplate restTemplate;
@Autowired
RedisUtils redisUtils;
private static Set<String> shouldSkipUrl = new LinkedHashSet<>();
@Override
public void afterPropertiesSet() throws Exception {
// 在类被初始化完成时,把不拦截认证的请求放入集合
shouldSkipUrl.add("/uac");
shouldSkipUrl.add("/serviceedu/front/listTeacher");
}
@SneakyThrows
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//获取request请求
String requestPath = exchange.getRequest().getURI().getPath();
//如果请求url不需要认证,直接跳过
if(shouldSkip(requestPath)) {
return chain.filter(exchange);
}
//获取Authorization请求头
String token = exchange.getRequest().getHeaders().getFirst("token");
//Authorization请求头为空,抛异常
if(StringUtils.isEmpty(token)) {
return out(exchange.getResponse());
}
redisUtils.set("fromUrl",requestPath,10, TimeUnit.MINUTES);
if (!JwtUtils.checkToken(token)){
return out(exchange.getResponse());
}
Claims memberClaims = JwtUtils.getMemberClaims(token);
if (memberClaims==null)
return out(exchange.getResponse());
String nickname = (String) memberClaims.get("nickname");
Set<String> set = redisUtils.getSet(nickname);
if (!hasPermisson(set,requestPath)){
exchange.getResponse().setStatusCode(HttpStatus.NON_AUTHORITATIVE_INFORMATION);
return exchange.getResponse().setComplete();
}
return chain.filter(exchange);
}
private Mono<Void> out(ServerHttpResponse response) {
JsonObject message = new JsonObject();
message.addProperty("success", false);
message.addProperty("code", 20001);
message.addProperty("data", "鉴权失败");
byte[] bits = message.toString().getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = response.bufferFactory().wrap(bits);
//response.setStatusCode(HttpStatus.UNAUTHORIZED);
//指定编码,否则在浏览器中会中文乱码
response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
return response.writeWith(Mono.just(buffer));
}
private boolean hasPermisson(Set<String> set,String path){
for (String s :
set) {
if (path.contains(s))
return true;
}
return false;
}
private boolean shouldSkip(String reqPath) {
for(String skipPath:shouldSkipUrl) {
if(reqPath.contains(skipPath)) {
return true;
}
}
return false;
}
}
这样子,验证失败了,我们也能传给前端一个json格式的数据,让前端做出反应,验证成功后,就可以放心大胆的去让用户浏览该路径了,效率很高,因为只有第一次需要请求认证中心鉴权,往后直接拿token解析取出redis中的映射路径去鉴权.
其实这里我有个问题的: 就是如果在GateWay网关鉴权,那有用户绕过了网关去访问某个微服务呢?这又该怎么办,那岂不是所有的权限都暴露出来,但是我又想到了其实绕过去极为困难.
但我们不得不承认的事情是,凡事都有两面性,你在网关鉴权了,效率上是高,但是加重了网关的负担,提高了单点故障的风险,如果网关挂了怎么办?通常只能搞成网关"集群"了.
这个其实可以省去redis,就是利用RestTemplate来进行鉴权,也是在网关处,加快性能:
@Component("a")
@Order(2)
//设置执行优先级,在 全局权限认证过滤器 之前执行
public class AuthenticationFilter implements GlobalFilter, InitializingBean {
@Autowired
private RestTemplate restTemplate;
@Autowired
RedisUtils redisUtils;
private static Set<String> shouldSkipUrl = new LinkedHashSet<>();
@Override
public void afterPropertiesSet() throws Exception {
// 在类被初始化完成时,把不拦截认证的请求放入集合
shouldSkipUrl.add("/uac");
shouldSkipUrl.add("/serviceedu/front/listTeacher");
}
@SneakyThrows
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//获取request请求
String requestPath = exchange.getRequest().getURI().getPath();
//如果请求url不需要认证,直接跳过
if(shouldSkip(requestPath)) {
return chain.filter(exchange);
}
//获取Authorization请求头
String token = exchange.getRequest().getHeaders().getFirst("token");
//Authorization请求头为空,抛异常
if(StringUtils.isEmpty(token)) {
return out(exchange.getResponse());
}
TokenInfo tokenInfo=null;
try {
//往授权服务发http请求 /oauth/check_token 并封装返回结果!
tokenInfo = getTokenInfo(authHeader);
}catch (Exception e) {
throw new RuntimeException("校验令牌异常");
}
if(!hasPremisson(tokenInfor,requestPath){
return out(exchange.getResponse());
}
return chain.filter(exchange);
}
private Mono<Void> out(ServerHttpResponse response) {
JsonObject message = new JsonObject();
message.addProperty("success", false);
message.addProperty("code", 20001);
message.addProperty("data", "鉴权失败");
byte[] bits = message.toString().getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = response.bufferFactory().wrap(bits);
//response.setStatusCode(HttpStatus.UNAUTHORIZED);
//指定编码,否则在浏览器中会中文乱码
response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
return response.writeWith(Mono.just(buffer));
}
private boolean hasPremisson(TokenInfo tokenInfo,String currentUrl) {
boolean hasPremisson = false;
//登录用户所拥有的请求url权限集合
List<String> premessionList = Arrays.asList(tokenInfo.getAuthorities());
//与当前请求url,看是否有对应的访问权限
for (String url: premessionList) {
if(currentUrl.contains(url)) {
hasPremisson = true;
break;
}
}
//如果没有,抛异常
if(!hasPremisson){
throw new RuntimeException("没有权限");
}
return hasPremisson;
}
private boolean shouldSkip(String reqPath) {
for(String skipPath:shouldSkipUrl) {
if(reqPath.contains(skipPath)) {
return true;
}
}
return false;
}
private TokenInfo getTokenInfo(String authHeader) {
// 往授权服务发请求 /oauth/check_token
// 获取token的值
String token = StringUtils.substringAfter(authHeader, "bearer ");
//组装请求头
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
//必须设置 basicAuth为对应的 clienId、 clientSecret
headers.setBasicAuth("admin", "123456");
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("token", token);
HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(params, headers);
//往授权服务发http请求 /oauth/check_token
ResponseEntity<TokenInfo> response = restTemplate.exchange("http://localhost:8085/uac/oauth/check_token", HttpMethod.POST, entity, TokenInfo.class);
//获取响应结果 TokenInfo
return response.getBody();
}
}
网上的鉴权方式差不多都是这个方式,直接发送RestTemplate风格的请求去验证token是否正确,然后返回一个权限列表,但在这里需要注意的一点是:
你这个token必须从前端过来是认证中心生成的那个,因为你要发送check_token请求是去认证中心底层/oauth/check_token路径验证的,那它肯定是拿自己生成那个token验证,而且你必须将这个token反馈给前端,就需要知道如何在认证中心的认证成功方法里将底层的token反馈给前端,这样这边网关层才能发送过来,如果你需要验证自己生成的token,可以在认证中心自定义/oauth/token,所以这个方法很麻烦.
其实这里我提出一个疑惑,我在网上看的时候,有人用这种方法重写了两个全局过滤器,有些蒙蔽,为啥要这样做,不是多余了吗?他是将两个一样的过滤器分不同前后加载到ioc容器中,然后在前一个过滤器采用setAttribute的方式放在里面,之后在后面的过滤器里取出来,???各位网友可以说说,是不是多余了,用了两个一模一样的过滤器,直接在第一个过滤器里查出来后验证不就行了吗?
上面的GateWay网关中,还可以这样优化:
因为你从验证中心发送请求获取数据的,所以你必须得每次都请求一次,这样子效率不太好,所以你可以将第一次请求的数据缓存下来,网上的方式都是放在请求头里,一般不要这样做,因为请求头我完全可以在前端制造,这样子就危险了,而是放在redis里,在请求之前先查缓存,如果没有缓存再请求,有缓存就直接拿出缓存鉴权.这样子是不是发现又回到了第一个方式的鉴权?所以我没有使用第二种方式.