上一期我们介绍了如何最简单的为一个SpringBoot应用添加Spring Security框架,并使其为应用完成用户鉴权和访问控制的授权服务。 这一期我们将聚焦在用户鉴权部分,用户鉴权又可以从框架核心和与Spring Web集成两个角度切入,我们选择自底向上,先从框架核心领域开始讨论,最后再对Spring Security是如何对Spring Web提供支持和服务进行展开。 为了聚焦在核心业务,一部分源代码都经过一定处理,可能与框架自身代码不同,目的是希望可以更简单的理解核心设计和相关类的职责和功能。
Authentication即鉴权、通俗的说就是用户登录操作,在我们一般的设计中,登录提交和登录成功获取的都是用户记录信息;整个鉴权服务只提供一个验证登录的接口。 在这边我们先预设几个问题,请大家带着这几个预设问题一边了解核心组件一边尝试回答下这几个问题:
首先,在我们一般设计中能对于登录信息的输入会抽象为一个表单或者更简单的只是诸如User authentication(String username,String password)这样的方法签名进行处理。 而Spring Security中设计了一个 类,我们输入的用户名和密码被当做用户的主体标识和用户的鉴权凭证进行封装。
其中Principal便是用户名用于可唯一标识用户信息的属性,Credentials字段则存储密码作为用户端鉴权凭证。如果我们不使用密码而是使用其他诸如令牌之类的也可以将其视为是的鉴权凭证。 不同的验证协议可能存在不同的请求参数格式,Spring Security中也提供了常见的几种常见的封装,比如我们之前说到的使用用户名密码和使用RememberMe的令牌形式。
除此以外,Authentication不仅封装用于登录的属性,在完成鉴权操作后也会将它关联的权限列表全部存储在Authorities中。整个鉴权的最初和最终状态就好比我们去食堂打饭拉卡,把饭盒和饭卡都递给打饭阿姨,只要饭卡里有钱,阿姨就会把对应的饭盒给你装满。你递出去是饭盒和饭卡,拿回来的还是饭盒和饭卡。无论将来饭盒是两层还是三层,饭卡是实体卡还是支付宝都和打饭的过程没有了关系。
而这个
打饭的服务
鉴权服务的形式便是
Authentication authenticate(Authentication authentication) 复制代码
而给我们提供最终鉴权服务的打饭阿姨,她的名字叫 。
我们要提供一个完整的鉴权服务,我们至少需要完成以下两个任务:
在实际开发中,我们的系统能支持多种鉴权实现,可能是对比预置用户密码是否一致,可能是对比一个令牌的值是否一致,也可以简单的认为AuthenticationProvider实现的是如何进行身份验证的服务,通常我们也会称为“认证机制”。Spring Security自身已经提供了多种认证机制,看看下面罗列的类名大概也就知道对应什么认证机制了:
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException; 复制代码
而预置的用户信息被封装在了UserDetails的类中,UserDetails中主要包含的基本与输入的Authentication差不多,包含了用户名、密码、权限列表等基础的用户信息属性。
对比AuthenticationProvider是解决了“如何验证用户输入的用户信息与预置身份信息是否一致”,UserDetailsService是解决了“从哪里获取要对比的预置身份信息记录”。所以在实现UserDetailsService的时候我们不用考虑验证机制的具体实现,只要考虑从哪里获取用户信息就可以了。 如果是从缓存中可使用CachingUserDetailsService,用户信息如是基于LDAP的那么就可以使用LdapUserDetailsService,如果是基于Mybatis这种JDBC框架的就可以使用JdbcUserDetailsManager。 同样的如果你使用的是Spring Security中还没有提供的方式,比如使用JPA,那么只要实现UserDetailService根据JPA的操作客制化一个新的实现类就可以了。
我们已经大致了解了AuthenticationProvider和UserDetailService两个类的主要责任和工作,让我们来考虑最后一个问题,UserDetailsService的服务返回的是UserDetails,AuthenticationProvider的服务返回的是Authentication。那么这个将UserDetails封装的成Authentication的逻辑是是谁负责的?在Sping Security中因为UserDetailsService只提供一个根据用户名返回用户信息的动作,其他的责任跟他都没有关系,怎么将UserDetails组装成Authentication进一步向调用者返回的工作也是由AuthenticationProvider完成的。 回到我们最开始的那个食堂,我们部分的窗口有些特殊的服务,比如有些窗口的阿姨会说英文支持英文点单,有些窗口的阿姨会使用支付宝结账支持支付宝点单。虽然这些阿姨支持各种各样的打饭的姿势,但是本质上还是一手给钱一手给饭的打饭服务。 过了几年,我们的食堂打饭阿姨每位都多才多艺但是也顶不住大学扩招之后带来了翻倍的学生。最近已经有学生反映如果上午最后一节课不逃课去打饭,那么买到午饭就是下午1点的事情了。学校领导根据实际情况反映决定以后每个窗口配一组员工,一个阿姨负责拿饭拿菜,一个叔叔负责点单、刷卡和将饭菜装入饭盒。 没错,那个负责拿饭拿菜的叔叔就是UserDetailService,而那个点菜、刷卡和装饭菜的阿姨就是AuthenticationProvider。打饭的阿姨依旧支持各种多才多艺的学生的打饭姿势,而拿饭菜的叔叔两眼只要看着食堂硕大的大锅菜盘子就行了。 如此分工之后后的一个月,不仅食堂的效率提高了、学生可以在12点钟准备买到午饭之外,广大老师也反应上午最后一节课的出勤率也大大提高了。 最后我们引入最后角色 ,ProviderManager中配置了各种鉴权服务了除了验证用户信息服务之外的配置。同时也ProviderManager也是AuthenticationManager接口的实现类,而AuthenticationManager是唯一向外的提供的用户验证服务接口。AuthenticationManager本身并不实现验证用户身份的逻辑而是委托交由其配置的AuthenticationProvider去完成。
最后一次回到我们的大学食堂,学校为了方便各位宅男宅女们更加方便、快捷地能吃到午饭,将来点菜可以直接通过食堂门口点菜机进行点单。学生们在点菜机上选择自己喜欢饭菜,并刷卡并可以在窗口拿到自己想要的食物。 原本像我这般胖子一顿午饭又想吃煲仔饭又想吃留学生窗口的披萨饼,我就要排两次队。而现在有了这台点菜机之后,我就可以一次性点上两个窗口的饭菜,然后就在门口等着拿就行了。同样食堂窗口排队留学窗口一直冷冷清清的,留学生基本都是秒排拿饭,而粤菜窗口都要排到食堂门口去了,现在有了点菜机的出现可能是自鸦片战争以来第一次实现了中外人权平等,大家都要老老实实在点菜机前排队。食堂打饭阿姨不用再直接面对各种花式打饭姿势的学生了,只要看着点菜机系统上显示打订单然后和拿饭拿菜的叔叔配置装饭盒,然后判断饭卡是不是有效开展工作就可以了,打饭阿姨们纷纷反映工作简单了很多,晚上都有心情去跳广场舞。 到这里基本的Authentication部分的核心组件我们都介绍完了,负责全局配置、直接面对调用者的 点菜机、负责完整验证用户信息服务的 打饭阿姨、负责获取预置用户信息、从大锅饭菜里拿饭菜拿饭拿菜的 叔叔。 下一部分我们将通过简单的Java实例来模拟我们的
食堂
Spring Security的Authentication核心流程。
首先,我们先要组装一个可以提供验证用户的服务,那么我们只要需要一个AuthenticationProvider,而AuthenticationProvider本身不会获取预置的用户信息,所以我们还需要给其配置一个UserDetailsService的实现组件。
//雇佣一组食堂打饭叔叔阿姨组合 UserDetailsService userDetailsService = new InMemoryUserDetailsManager(); AuthenticationProvider provider = new DaoAuthenticationProvider(); //组装完毕 ((DaoAuthenticationProvider) provider).setUserDetailsService(userDetailsService); 复制代码
我们这边使用了InMemoryUserDetailsManager基于内存管理的预置用户身份信息管理和基于用户名和密码的DaoAuthenticationProvider来验证我们的用户身份信息。
下面我们创建我们的第一条用户身份信息,用户名为user,密码为password,角色为USER的身份信息到内存中:
((InMemoryUserDetailsManager) userDetailsService) .createUser(User .withUsername("user").password("password").roles("USER") .build()); 复制代码
然后我们模拟第一个验证请求,我们创建了一个Authentication,其实现为基于用户名和密码的UsernamePasswordAuthenticationToken。我们需要验证的用户名为user,密码位password
Authentication authentication = new UsernamePasswordAuthenticationToken("user","password"); 复制代码
接着我们调用验证服务,并在控制台打印
Authentication result = provider.authenticate(authentication); System.out.println(result); 复制代码
在控制台上中的日志最终一段则会显示验证完成返回的Authentication已经正确的获取了我们之前预设的USER权限。
Granted Authorities: ROLE_USER 复制代码
如果我们输入的用户名和密码不正确呢?让我们尝试下面的代码:
Authentication authentication = new UsernamePasswordAuthenticationToken("user","wrong"); Authentication result = provider.authenticate(authentication); System.out.println(result); 复制代码
针对输入的Authentication的身份信息无法与预置的身份信息进行匹配的场景下,Spring Security的AuthenticationProvider便会抛出org.springframework.security.authentication.BadCredentialsException的异常用于告之用户名和密码与UserDetailsService获取的预置信息不匹配。
最后,我们屏蔽我们与AuthenticationProvider的直连联系,我们使用AuthenticationProvider来为我们提供多种Provider服务。在初始化ProviderManager的时候,我们需要设置Manager所管理的所有AuthenticationProvider的列表,然后一样调用authenticate验证方法也可以达到同样的效果。
List<AuthenticationProvider> providers = new ArrayList<>(); providers.add(provider); AuthenticationManager manager = new ProviderManager(providers); Authentication result = manager .authenticate(authentication); System.out.println(result); 复制代码
那么为什么还要额外有一层AuthenticationManager 的封装?请让我们考虑下以下的场景,如果我们的系统又支持基于用户和密码的服务,其数据是从数据库中获取的,同时又支持使用Token从Redis中验证Token的用户验证服务。在这样的场景下,我们便只要额外在AuthenticationManager的AuthenticationProvider列表增加一个基于Token验证的AuthenticationProvider(它的UserDetailsService则是通过Redis的API实现)便可了。这样的配置修改并不会影响到核心对外部暴露的服务接口和相关Authentication参数。
经过了大学食堂的洗礼,我们基本上已经对Spring Security中Authentication鉴权(身份验证)服务端核心组件和主要流程进行了说明,下一期我们将对Spring Security是如何对Spring Web应用提供支持的机制进行说明。