编辑历史:
2021/01/06 15:13 :第三点“单体springboot项目怎么进行多认证方式”,增加一张原理说明图。
正文:
一个正常的系统应用,都会要求安全性,不能让人随便乱搞。我们在开发web应用时,怎么保护我们的资源,这是十分重要的。
在以前jsp / servlet 时代,我们可能会直接在每个servlet上都加上用户身份验证,也会有系统是通过Filter来验证用户身份。
现在web应用中,主要有两套安全框架:shiro 和 spring security。
功能上两者都差不多,shiro有的功能spring security都有,而且spring security也还有一些额外的功能,例如对Oahtu的支持
上图是网上的一些对比,如果是做单体项目,shiro足以,如果是分布式项目,推荐spring security 和 oauth2.0
下面我将会从四个方面讲解spring security的使用:
1:单体springboot项目怎么用?
2:spring security 的认证过程!
3:单体springboot项目怎么进行多认证方式?
4:分布式项目怎么用spring security?
1:单体springboot项目怎么用?
1、引依赖(红色是重点)
<dependencies>
<!--security的starter-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--security 的测试包-->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<!--spring mvc-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--热部署-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!--lombok小辣椒-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!--测试包-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
其实我们只要一引入spring security的依赖,整个项目的web请求都会被spring security拦截。
我们写个启动类,再写个web接口,启动项目
@SpringBootApplication @RestController public class SpringSecurityApplication { public static void main(String[] args) { SpringApplication.run(SpringSecurityApplication.class, args); } @RequestMapping("/say/{name}") public String echo(@PathVariable String name){ return "这里是控制器:" + name; } }
访问 http://localhost:9999/say/123 时就会被重定向到一个登陆页面
这个时候你看一下控制台,就会发现多了一串这玩意
这是spring security 自动帮你创建的一个随机密码,用户名是user,登陆进去后就能正常访问我们的接口了。
spring security不止提供了一个默认的登陆页面,也提供了一个登出接口:http://localhost:9999/logout
有些版本会直接登出,有些新版本会有下面这个页面。
当然我们的正常项目肯定是有自己的用户模块,不可能用它自带的东西。下面将讲怎样接入我们自己的用户模块。
首先假设我们有一个用户实体类
@Getter @Setter @AllArgsConstructor @ToString public class UserDO{ private Integer id; private String userName; private String password; private String realName; private List<String> roles; private List<String> permissions; }
然后呢我们有一个用户业务类,专门去数据库查询用户的。这里我们不连数据库,我懒,不想弄,直接用一个Map充当数据库
@Service public class UserServiceImpl { // 正经的业务代码 public UserDO getUserByUserName(String userName){ if(userList == null){ return null; } UserDO userDO = userList.get(userName); return userDO; } // 用来模拟数据库的一个Map private static Map<String,UserDO> userList; public UserServiceImpl(){ initUserList(); } /** * 模拟数据库用户 * */ private void initUserList(){ if (userList == null){ userList = new HashMap<>(3); userList.put("zhangsan",new UserDO(1,"zhangsan",password_123,"张三" , Arrays.asList("admin","role1"),Arrays.asList("p1","p2","p3"))); userList.put("lisi",new UserDO(2,"lisi",password_123,"李四" , Arrays.asList("role1"),Arrays.asList("p1","p2"))); userList.put("wangwu",new UserDO(2,"wangwu",password_123,"王五" , Arrays.asList("role2"),Arrays.asList("p3","p2"))); } } private static final String password_123 = "$2a$10$a0iYBZkmfqJnhd0g5ck9L.kfcf9RpdHFJ.mt5wf2sN2qzA6y9k/BC"; }
接下来就是重点了。
建一个类继承 WebSecurityConfigurerAdapter ,进行spring security的配置
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; 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.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import javax.annotation.Resource; /** * 这个是spring security 的web安全配置类,这个类必须有 * @author hongcheng */ @Configuration @EnableWebSecurity // 启动web的安全控制,这个注解好象不用也行 public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Resource private UserDetailsService userDetailsService; /** * 配置认证管理器 * */ @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 配置 userDetailsService ,这是用来查询用户信息的 auth.userDetailsService(userDetailsService);
// 设置擦除密码,如果设置成false,那么在spring security中流转的用户认证信息都会带有密码,一般我们都会擦除 auth.eraseCredentials(true); super.configure(auth); } /** * 配置url安全认证拦截的 * */ @Override protected void configure(HttpSecurity http) throws Exception { /* * csrf().disable() 关闭跨域请求限制,如果开启,可能会出现很多非get请求出现405 * .authorizeRequests() 启动请求认证 * .antMatchers("/**").authenticated() 匹配指定的url地址进行认证判断 * .anyRequest().permitAll() 对其他地址进行放行 * formLogin() 启动表单登录 * .loginPage("/login.html") 可以自定义登录页面 * .failureUrl("/login_fail.html") 表单登录失败的跳转地址 * .defaultSuccessUrl("/login_s.html",true) 表单登录成功的跳转地址, * 参数2如果为false,登录成功时会跳转到拦截前的页面,true时登录成功固定跳转给定的页面 * .logout() 启动用户退出,security提供了默认退出地址:/logout * .logoutSuccessUrl("/logout.html") 成功推出后的跳转地址 * */ http.csrf().disable() .authorizeRequests() .anyRequest().authenticated() .and() .formLogin() .failureUrl("/login_fail.html") .defaultSuccessUrl("/login_s.html",true) .permitAll() // permitAll表示登录相关的请求都放开,一定要加,不然你连登录页面都看不到 .and() .logout() .logoutSuccessUrl("/logout.html")
permitAll(); // 这里也要加,不然你退出后就看不到退出页面了 } /** * 这个是配置密码编码器的<br/> * NoOpPasswordEncoder表示不进行密码编码 * */ @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } /** * 这个是我自己写来加密密码的 * */ public static void main(String[] args) { BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); System.err.println(passwordEncoder.encode("123")); } }
这是spring security的配置文件,大部分你们都能看懂,我说说这个url拦截配置
这两个是一起用的,表示对哪些具体的url进行拦截,也可以模糊匹配
.authorizeRequests()
.anyRequest().authenticated()
这个and用于分割不同的拦截策略,例如上面的配置就用了两个and方法,因为他有三组拦截策略,一组是针对所有url的,一组是针对登录表单的,一组是针对登出的 .and()
这个是登录的,spring security默认提供了一个登录接口 http://localhost:9999/login 你也可以自己看登录页面的源码,至于这个接口的实现在哪里,后面会详细讲,这里我们只需要直到默认有这个接口就行 .formLogin() .failureUrl("/login_fail.html") .defaultSuccessUrl("/login_s.html",true) .permitAll()


这是登出的,也有提供默认接口 http://localhost:9999/logout
.logout() .logoutSuccessUrl("/logout.html")
.permitAll();
配置完了后还有一步很关键,就是要告诉spring security去哪里获取你的用户信息
我们自己建一个类,继承 UserDetailsService
import com.hongcheng.springsecurity.entity.UserDO; import com.hongcheng.springsecurity.service.user.UserServiceImpl; 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 javax.annotation.Resource; import java.util.HashSet; import java.util.Set; import java.util.stream.Collectors; /** * 这个是配置用户详情查询的,用于查询用户,给security用的 * @author hongcheng */ @Component public class MyUserDetailsServiceImpl implements UserDetailsService {
// 这个是我们自己的业务类,用来查数据库 @Resource private UserServiceImpl userService; /** * 这个方法是一定要重写的,spring security会根据页面传回来的用户名去查这个用户的权限和密码 * */ @Override public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException { UserDO userDO = userService.getUserByUserName(userName); if(userDO == null){ throw new UsernameNotFoundException("账号不存在"); } Set<String> authritiesSet = new HashSet<>(userDO.getPermissions().size() + userDO.getRoles().size()); authritiesSet.addAll(userDO.getPermissions()); // 这里需要注意,security里面角色和权限都是存在一个字段里面的,但是其角色会自动加上ROLE_前缀来将角色和权限进行区分, // 以便在进行判断是否有某角色时可以进行判断,但是我们一般使用是可以不用加前缀的,如@PreAuthorize("hasRole('role2')"),但是你加也没问题 authritiesSet.addAll(userDO.getRoles().stream().map(role -> "ROLE_" + role).collect(Collectors.toList())); UserDetails userDetails = User.withUsername(userDO.getUserName()) .password(userDO.getPassword()) .authorities(authritiesSet.toArray(new String[authritiesSet.size()])) .build(); return userDetails; } }
UserDetailsService 是spring security提供的一个自定义查询用户的接口,和shiro的 Realm 是相同作用的
另外spring security封装了一些认证异常,并不是说你想抛啥就抛啥,当然,你随便抛也行,程序直接异常而已。
用spring security提供的异常,spring security就会帮你捕获,并提示页面。都是 AuthenticationException 的子类
完了之后你就可以启动项目了,然后自己去测试。
这时你启动项目,你会发现没有随机密码了。用自己设置的账号和密码登录,测试下不同情况吧。
spring security不仅可以将针对web请求进行拦截,还可以对具体方法进行拦截
web拦截:
web url的拦截是要在security的配置类中写的,而且一定要注意,拦截规则是从上往下的,如果前面的拦截规则包含的url返回比下面的还要大,例如:
.antMatchers("/r/**").hasAuthority("p1")
.antMatchers("/r/r1").hasAuthority("p2")
这种情况下,/r/r1 拦截规则是必须有p1权限
@Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() // 开启请求认证 .antMatchers("/r/r1").hasAuthority("p1") // /r/r1 地址的请求需要有p1权限 .antMatchers("/r/r2").hasAnyRole("p2") // /r/r2 地址的请求需要有p2角色 .antMatchers("/r/r3").access("hasAuthority('p1') and hasAuthority('p2')") // /r/r3 地址的请求需要同时有p1和p2这两个权限
.antMatchers("/r/**").authenticated() // /r/** /r开头的其他请求都需要认证 .anyRequest().permitAll() // 剩余没有说明的地址全部开放,不用认证 .and() .formLogin() // ... }
保护URL常用的方法有:
authenticated() 保护URL,需要用户登录
permitAll() 指定URL无需保护,一般应用与静态资源文件
hasRole(String role) 限制单个角色访问,角色将被增加 “ROLE_” .所以”ADMIN” 将和 “ROLE_ADMIN”进行比较.
hasAuthority(String authority) 限制单个权限访问
hasAnyRole(String… roles)允许多个角色访问.
hasAnyAuthority(String… authorities) 允许多个权限访问.
access(String attribute) 该方法使用 SpEL表达式, 所以可以创建复杂的限制.
hasIpAddress(String ipaddressExpression) 限制IP地址或子网
方法拦截:
这种方式主要是往类或者方法上面加注解来限制对该方法的访问
Spring Security在方法的控制权限上支持三种类型的注解,JSR-250注解、@Secured注解和支持表达式的注解
JSR-250注解包括:@RolesAllowed、@PermitAll、@DenyAll
@RolesAllowed:表示访问对应方法时所应具有的角色 ,例如:@RolesAllowed({“User”,“ADMIN”}) 该方法只要具有"User","Admin"任意一种权限就可以访问。这里可以省略前缀ROLE_,实际的权限可能是ROLE_ADMIN,也可能是ADMIN
@PermitAll表示允许所有的角色进行访问,也就是说不进行权限的控制
@DenyAll和@PermitAll相反的,表示无论什么角色都不能访问
@Secured注解:这个注解和@RolesAllowed的作用是一样的,不过@Secured不能省略前缀ROLE_,必须@Secured({“ROLE_User”,“ROLE_ADMIN”})
支持表达式的注解:@PreAuthorize、@PostAuthorize
@PreAuthorize:这是进入方法前进行权限判断,常用
@PostAuthorize:这是方法执行后进行权限判断,不常用
值可以是spring el表达式,一般我们都是用org.springframework.security.access.expression.SecurityExpressionRoot这个类里面的方法进行权限判断,例如:
@RequestMapping("/say/{name}") @PreAuthorize("hasAnyAuthority('p1')") public String echo(@PathVariable String name){ return "这里是控制器:" + name; }
需要注意的还有一点:spring security默认不启用方法注解权限判断,我们必须手动加上。找个加了 @Configuration 注解的类,给他加上下面这个注解,你要用什么注解,你就设置他为true就行
@EnableGlobalMethodSecurity(prePostEnabled = true,jsr250Enabled = true,securedEnabled = true) // 启动基于方法注解的控制
到目前为止,一个单体springboot项目使用spring security就算是完了。
接下来我们总计一下spring security的单体项目使用流程:
- 引入依赖 spring-boot-starter-security
- 编写一个类继承 WebSecurityConfigurerAdapter
- 配置spring security,包括认证配置器、url拦截配置、密码匹配器
- 编写一个类继承 UserDetailsService ,以实现用户信息的查找
- 给响应的方法加权限注解(可选)
自己看着写个小例子玩玩,后面我们会讲一下spring security的整体执行流程。
2:spring security 的认证过程!
源码原理:
要了解他的执行流程,我们就需要调试代码。首先我们知道UserDetailsService 这个的子类,就是我们自己实现的那个类,是用来获取我们自己的用户信息的,那么登录时肯定会执行到这里,所以我们打个断点,启动项目,登录。
进入断点后我们一路看调用栈,因为我们的目标是看spring security的调用,所以我们一路往下找,找到最早被调用的security相关的地方
这里我们发现 org.springframework.security.web.FilterChainProxy#doFilter 这里是最早调用的security的方法。我们看下代码
是不是和我们以前学的servlet时的Filter的写法是一样的。那我们看下这个类的情况
这里是不是就可以看出了,spring security 的 FilterChainProxy 这个类,实际上就是Filter的实现类,所以你是不是明白了,spring security的实现其实就是基于Filter来实现的。
好了,我们继续往下追踪,进入 doFilterInternal 这个方法看下
在 doFilterInternal 我们看到有意思的地方有三处。我们一一分析。
我们发现第一次有意思的地方只是在验证请求的url是否正常,所以我们不管他先。看第二个地方
这里主要是遍历这个过滤器链,判断他和request是否匹配,究竟是匹配什么呢?我们进入match方法里面看看
继续往下看,我们会发现有很多实现类,和security相关的也比较多,那我们就先不管他,继续看第三个地方。
我们先看这一句: FilterChainProxy.VirtualFilterChain vfc = new FilterChainProxy.VirtualFilterChain(fwRequest, chain, filters);
发现VirtualFilterChain 是一个内部类,我们看下这个内部类,看他的属性
originalChain :看参数名和实参,不难发现这就是实际的Filter过滤器链
additionalFilters : 这是一个额外的过滤器链,我们找下传入的实参是什么,发现这就是我们第二步match匹配返回的一个过滤器链 List<Filter> filters = this.getFilters((HttpServletRequest)fwRequest);
现在回想一下,是不是大概明白了点:一个spring security写的 servlet.Filter 的过滤器,里面有个 SecurityFilterChain 类型的集合,在执行 doFilter 方法时悄悄 把List<SecurityFilterChain > 和 request 进行某种匹配
然后自己拿真实的 servlet.Filter 的过滤器链,和 security自己的SecurityFilterChain 链组合,形成一个新的虚拟过滤器链 VirtualFilterChain。
好,现在我们继续回来,接下来就不是执行servlet.Filter 的过滤器链了,而是刚创建的虚拟过滤器链 VirtualFilterChain
我们进去这个方法里面,仔细读下代码,你会发现它分成两部分,上面部分执行servlet.Filter原始过滤器链的,下面执行security的额外过滤器链的。
当然我们通过他打印的日志也能发现,这里是先执行额外链,后执行原始链的。
reached end of additional filter chain; proceeding with original chain
至于为什么是先执行额外链 ,后执行原始链呢?相比研究过 servlet 的 的Filter的人都知道,在执行Filter链时,每个Filter都是请求进来通过一次,返回响应通过一次,如果请求进来的时候你不处理这个额外链,以后就没机会了。但是如果你先执行额外链,
由于 nextFilter.doFilter(request, response, this); 所以接下来执行的都是 VirtualFilterChain 里面的额外链,执行完了后还能继续重新回到原始的 servlet.Filter 链
我们看下 VirtualFilterChain 里面都有啥
我们接着根据线程调用栈往下找security相关的,发现他在一个个执行 additionalFilters 中的Filter 。之前不是说过security 提供了一个登录接口 /login 和一个登出接口 /logout 么,这里看 LogoutFilter 觉得是不是巧合,进去里面看一下代码。
看下红框标出的地方,熟不熟悉
联想一下前面提到的 RequestMatcher 和 需要根据servlet 和 Filter进行某系匹配,匹配某个东西。现在是不是就明白了,那同样的道理,能不能找到 /login 在哪里。
继续往下看,找到 UsernamePasswordAuthenticationFilter,你就找到了 /login 了,接下来我们将会重点分析这里
仔细找你会发现 UsernamePasswordAuthenticationFilter 并没有 doFilter 方法,我们去他父类 AbstractAuthenticationProcessingFilter 上找
这个方法里会去捕获一些认证异常,如果我们胡乱抛出,就不能被是为认证失败了,所以我们要遵守规则
好了,我们看一下关键语句:
authResult = attemptAuthentication(request, response);
我们点击去看看具体实现,发现他又跳到了 UsernamePasswordAuthenticationFilter 这里来
关键的地方就这两个点,1是创建了个Token,2是调用 AuthenticationManager 的 authenticate方法 ,去对传入的账号吗和密码进行认证
我们先看下 AuthenticationManager 是个啥玩意
看看他有啥具体实现类先:
行吧,没啥好玩的,那 authenticate方法我们也不知道看哪个实现类,跳过,继续看调用栈。
继续往下看调用栈,就发现他走的是 org.springframework.security.authentication.ProviderManager#authenticate 这个方法,那我们进去看看代码
代码太长了,我就截关键点
这里我们可以看到 他在遍历,遍历啥呢? AuthenticationProvider 这是啥,不知道,但是看英文意思是认证提供者,先猜测会不会是用来认证的。
然后看下面那行, provider.supports(toTest) 在判断是否支持,支持啥,我们前面看到的 UsernamePasswordAuthenticationToken ,如果支持就执行 result = provider.authenticate(authentication);
那综合来讲,这段代码的意思是不是说“遍历所有 AuthenticationProvider 认证提供者,判断其是否支持 UsernamePasswordAuthenticationToken ,如果支持就执行 AuthenticationProvider 认证提供者的 authenticate 认证方法”。
我们继续看看 ProviderManager 里面有多少个提供者。
好,继续走线程调用栈
我们发现此时执行的是 DaoAuthenticationProvider 的父类的 authenticate 方法,我们看下这个方法具体代码
这个方法里面有四个地方需要重点关注:
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
postAuthenticationChecks.check(user);
我们先看第一个,发现其在父类中是个抽象方法,具体实现在子类
你找下他返回的 UserDetailService 就会发现这其实就是我们自己自定义的UserDetailService
结合前面的,其实就是先根据Token去缓存中找一下,如果找不到,就通过我们自己的定义的 UserDetailService 实现类,去数据库里面查,并组装返回一个UserDetail对象。
那接下来我们看下
preAuthenticationChecks.check(user);
发现这两个先后检查都是内部类,主要检查用户是否被锁定,是否过期超时,是否被禁用,密码凭证是否过期超时。
最后看下 additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
现在整个方法的内容基本都看完了,整理下 org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider#authenticate 做的事
判断传入的认证对象是不是UsernamePasswordAuthenticationToken
尝试去缓存中获取UserDetail
缓存中获取不到,就通过自定义UserDetailService去查数据库
前认证检查,检查用户是否被锁,是否过期超时,是否被禁用
额外认证检查,检查数据库密码和输入密码是否匹配
后认证检查,检查密码凭证是否过期超时
重新生成一个认证成功对象
spring security的整个认证流程算是完了,我们来总结一下
spring security的认证流程 1:基于servlet.filter的过滤器 org.springframework.security.web.FilterChainProxy#doFilter 2:内部自己的过滤器链,会根据当前请求的url,将匹配的认证过滤器筛选保留 org.springframework.security.web.FilterChainProxy.VirtualFilterChain#doFilter 3:spring security自己的抽象过滤器 org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter#doFilter 4:spring security默认的一个账号密码认证过滤器,每个认证过滤器都会绑定一个登录处理url和提交方式(post) org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter#attemptAuthentication 5:由第4步组装一个token对象,传入第5步。ProviderManager会根据token的Class类型来判断使用哪个Provider org.springframework.security.authentication.ProviderManager#authenticate 6:由具体的AuthenticationProvider来负责认证用户信息 org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider#authenticate 7:UserDetailsService在AuthenticationProvider中获取用户信息 org.springframework.security.core.userdetails.UserDetailsService#loadUserByUsername
(这里我偷黑马一张图)
总结下出现过的重要的类和接口
1:FilterChainProxy 基于servlet.filter的过滤器,用于生成spring security自己内部的过滤器链 2:FilterChainProxy.VirtualFilterChain spring security的虚拟过滤器链,用于进行额外的过滤器链处理 3:AbstractAuthenticationProcessingFilter spring security额外过滤器链的父类,可以指定该过滤器处理哪些请求(url和请求method),同时在这里根据请求参数组装一个AbstractAuthenticationToken的实现类,并传给后续操作 4:UsernamePasswordAuthenticationFilter spring security提供的默认用户名密码过滤器链,处理POST方式的/login请求,构建UsernamePasswordAuthenticationToken类 5:ProviderManager 用于管理AuthenticationProvider列表,根据传入的AbstractAuthenticationToken的实现类的不同,遍历AuthenticationProvider列表选择合适的AuthenticationProvider进行认证 6:AbstractUserDetailsAuthenticationProvider AuthenticationProvider的父类,提供了基本的认证过程代码 7:DaoAuthenticationProvider spring security提供的默认用户名密码认证器,处理UsernamePasswordAuthenticationToken类型的认证 8:UserDetailsService spring security提供的用于自定义用户查询方式的接口 9:AbstractAuthenticationToken spring security的认证Token的基类,所有Token都必须继承这个基类 10:UsernamePasswordAuthenticationToken spring security提供的默认用户名密码认证Token
3:单体springboot项目怎么进行多认证方式?
根据前面的说明,我们可以知道spring security提供了一个默认的用户名密码认证方式,这个用户名密码认证方式是怎么工作的呢?
1:UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken spring security提供的默认用户名密码认证Token 2:UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter spring security提供的默认用户名密码过滤器链,处理POST方式的/login请求,构建UsernamePasswordAuthenticationToken类 3:DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider spring security提供的默认用户名密码认证器,处理UsernamePasswordAuthenticationToken类型的认证 4:MyUserDetailsService extends UserDetailsService 自定义的用户查询类
据我们所了解的 ,AbstractAuthenticationProcessingFilter 和 AbstractUserDetailsAuthenticationProvider的实现类有多个,那我们能不能也自己实现一个,然后放进去呢?
模拟手机短信验证码登录,我们先实现 AbstractAuthenticationProcessingFilter ,一些具体的代码我们先抄 UsernamePasswordAuthenticationFilter 的先,后期跟进需要进行修改
/** * @author hongcheng */ public class SmsAuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter { public static final String SPRING_SECURITY_FORM_PHONE_KEY = "phone"; public static final String SPRING_SECURITY_FORM_SMSCODE_KEY = "smsCode"; private String phoneParameter = SPRING_SECURITY_FORM_PHONE_KEY; private String smsCodeParameter = SPRING_SECURITY_FORM_SMSCODE_KEY; private boolean postOnly = true; public SmsAuthenticationProcessingFilter() { super(new AntPathRequestMatcher("/sms/login", "POST")); } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException { if (postOnly && !"POST".equals(request.getMethod())) { throw new AuthenticationServiceException( "Authentication method not supported: " + request.getMethod()); } String phone = request.getParameter(this.phoneParameter); String smsCode = request.getParameter(this.smsCodeParameter); if (phone == null) { phone = ""; } if (smsCode == null) { smsCode = ""; } phone = phone.trim(); SmsAuthenticationToken authRequest = new SmsAuthenticationToken(phone, smsCode); // Allow subclasses to set the "details" property setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); } protected void setDetails(HttpServletRequest request, SmsAuthenticationToken authRequest) { authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request)); } }
期间发现需要用到Token的实现类,我们在实现一个 AbstractAuthenticationToken,基本的代码也是抄 UsernamePasswordAuthenticationToken 的进行修改
/** * @author hongcheng */ public class SmsAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = -6437322217156360297L; private final Object phone; private Object smsCode; public SmsAuthenticationToken(Object phone, Object smsCode) { super(null); this.phone = phone; this.smsCode = smsCode; setAuthenticated(false); } public SmsAuthenticationToken(Object phone, Object smsCode, Collection<? extends GrantedAuthority> authorities) { super(authorities); this.phone = phone; this.smsCode = smsCode; super.setAuthenticated(true); } @Override public Object getCredentials() { return this.smsCode; } @Override public Object getPrincipal() { return this.phone; } @Override public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException { if (isAuthenticated) { throw new IllegalArgumentException( "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead"); }else { super.setAuthenticated(false); } } @Override public void eraseCredentials() { super.eraseCredentials(); smsCode = null; } }
接下来我们实现 AbstractUserDetailsAuthenticationProvider,也是抄 DaoAuthenticationProvider
/** * @author hongcheng */ public class SmsAuthenticationProvider implements AuthenticationProvider { private PasswordEncoder passwordEncoder; private UserDetailsService userDetailsService; @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { try{ String phone = (String) authentication.getPrincipal(); String smsCode = (String)authentication.getCredentials(); WebAuthenticationDetails details = (WebAuthenticationDetails)authentication.getDetails(); // 判断手机号是否存在 UserDetails userDetails = userDetailsService.loadUserByUsername(phone); // 判断验证码是否一致 HttpSession httpSession = ServletUtil.getHttpSession(); // 已经提前将手机验证码存在了session中,key为手机号 Object smsCodeObj = httpSession.getAttribute(phone); if(smsCodeObj == null){ throw new BadCredentialsException("手机验证码错误"); } String smsCodeInSession = (String)smsCodeObj.toString(); if(!StringUtils.hasText(smsCodeInSession) || !smsCodeInSession.equalsIgnoreCase(smsCode)){ throw new BadCredentialsException("手机验证码错误"); } httpSession.removeAttribute(phone); // 构建返回的用户登录成功的token return new SmsAuthenticationToken(userDetails.getUsername(), smsCode, userDetails.getAuthorities()); }catch (Exception e){ if(e instanceof AuthenticationException){ throw e; }else{ throw new AuthenticationServiceException("认证服务异常"); } } } @Override public boolean supports(Class<?> authentication) { /** * providerManager会遍历所有 * security config中注册的provider集合 * 根据此方法返回true或false来决定由哪个provider * 去校验请求过来的authentication */ return (SmsAuthenticationToken.class .isAssignableFrom(authentication)); } public PasswordEncoder getPasswordEncoder() { return passwordEncoder; } public void setPasswordEncoder(PasswordEncoder passwordEncoder) { this.passwordEncoder = passwordEncoder; } public UserDetailsService getUserDetailsService() { return userDetailsService; } public void setUserDetailsService(UserDetailsService userDetailsService) { this.userDetailsService = userDetailsService; } }
最后我们再额外实现一个 UserDetailsService,根据手机号进行查询用户信息
/** * @author hongcheng */ @Service("SmsUserDetailsService") public class SmsUserDetailsServiceImpl implements UserDetailsService { @Resource private UserServiceImpl userService; @Override public UserDetails loadUserByUsername(String phone) throws UsernameNotFoundException { UserDO userDO = userService.getUserByPhone(phone); if(userDO == null){ throw new UsernameNotFoundException("手机号不存在"); } Set<String> authritiesSet = new HashSet<>(userDO.getPermissions().size() + userDO.getRoles().size()); authritiesSet.addAll(userDO.getPermissions()); // 这里需要注意,security里面角色和权限都是存在一个字段里面的,但是其角色会自动加上ROLE_前缀来将角色和权限进行区分, // 以便在进行判断是否有某角色时可以进行判断,但是我们一般使用是可以不用加前缀的,如@PreAuthorize("hasRole('role2')"),但是你加也没问题 authritiesSet.addAll(userDO.getRoles().stream().map(role -> "ROLE_" + role).collect(Collectors.toList())); UserDetails userDetails = User.withUsername(userDO.getPhone()) .password(userDO.getPhone()) .authorities(authritiesSet.toArray(new String[authritiesSet.size()])) .build(); return userDetails; } }
/** * @author hongcheng */ @Service public class UserServiceImpl { public UserDO getUserByUserName(String userName){ if(userNameList == null){ return null; } UserDO userDO = userNameList.get(userName); return userDO; } public UserDO getUserByPhone(String phone){ if(userPhoneList == null){ return null; } UserDO userDO = userPhoneList.get(phone); return userDO; } private static Map<String,UserDO> userNameList; private static Map<String,UserDO> userPhoneList; public UserServiceImpl(){ initUserList(); } /** * 模拟数据库用户 * */ private void initUserList(){ if (userNameList == null){ userNameList = new HashMap<>(3); userNameList.put("zhangsan",new UserDO(1,"zhangsan",password_123,"张三" ,"10086", Arrays.asList("admin","role1"),Arrays.asList("p1","p2","p3"))); userNameList.put("lisi",new UserDO(2,"lisi",password_123,"李四" ,"10010", Arrays.asList("role1"),Arrays.asList("p1","p2"))); userNameList.put("wangwu",new UserDO(2,"wangwu",password_123,"王五" ,"10000", Arrays.asList("role2"),Arrays.asList("p3","p2"))); userPhoneList = new HashMap<>(3); userPhoneList.put("10086",new UserDO(1,"zhangsan",password_123,"张三" ,"10086", Arrays.asList("admin","role1"),Arrays.asList("p1","p2","p3"))); userPhoneList.put("10010",new UserDO(2,"lisi",password_123,"李四" ,"10010", Arrays.asList("role1"),Arrays.asList("p1","p2"))); userPhoneList.put("10000",new UserDO(2,"wangwu",password_123,"王五" ,"10000", Arrays.asList("role2"),Arrays.asList("p3","p2"))); } } private static final String password_123 = "$2a$10$a0iYBZkmfqJnhd0g5ck9L.kfcf9RpdHFJ.mt5wf2sN2qzA6y9k/BC"; }
/** * 模拟资源 * @author hongcheng */ @RestController @RequestMapping("/test") public class TestController { /** * @PreAuthorize("hasAnyAuthority('p1')") * 具体可以使用哪些方法,自己看 MethodSecurityExpressionRoot 这个类 * */ @PreAuthorize("hasAnyAuthority('p1')") @RequestMapping("/r1") public String test1(){ return "这里是资源1,只能p1权限访问"; } @PreAuthorize("hasAnyAuthority('p2')") @RequestMapping("/r2") public String test2(){ return "这里是资源2,只能p2权限访问"; } @PreAuthorize("hasAnyAuthority('p3')") @RequestMapping("/r3") public String test3(){ return "这里是资源3,只能p3权限访问"; } @PreAuthorize("hasRole('admin')") @RequestMapping("/r4") public String test4(){ return "这里是资源4,只能admin角色访问"; } @PreAuthorize("hasRole('ROLE_role1')") @RequestMapping("/r5") public String test5(){ return "这里是资源5,只能role1角色访问"; } @PreAuthorize("hasRole('role2')") @RequestMapping("/r6") public String test6(){ return "这里是资源6,只能role2角色访问"; } @PreAuthorize("isAuthenticated()") @RequestMapping("/r7") public String test7(){ return "这里是资源7,只要认证了就能访问,认证可以是登录,也可以是RememberMe。"; } @PreAuthorize("hasAnyAuthority('p3','p1')") @RequestMapping("/r8") public String test8(){ return "这里是资源8,只要有p1或者p3权限就访问"; } @PreAuthorize("hasAnyAuthority('p2') and hasRole('role2')") @RequestMapping("/r9") public String test9(){ return "这里是资源9,只有同时拥有role2角色和p2权限才能访问"; } /* 返回结果 {"authorities":[{"authority":"ROLE_admin"},{"authority":"ROLE_role1"},{"authority":"p1"},{"authority":"p2"},{"authority":"p3"}],"details":{"remoteAddress":"0:0:0:0:0:0:0:1",
"sessionId":"4ADC66B3D98E239C9B10AC04AD43BDE0"},"authenticated":true,"principal":{"password":null,"username":"zhangsan","authorities":[{"authority":"ROLE_admin"},{"authority":"ROLE_role1"},
{"authority":"p1"},{"authority":"p2"},{"authority":"p3"}],"accountNonExpired":true,"accountNonLocked":true,"credentialsNonExpired":true,"enabled":true},"credentials":null,"name":"zhangsan"} * */ @PreAuthorize("isAuthenticated()") @RequestMapping("/user") public Object getLoginUser(){ Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities(); return authentication; } @RequestMapping("/sms/code/{phone}") public Object getSmsCode(@PathVariable("phone") String phone){ Random r = new Random(); int i = r.nextInt(10); ServletUtil.getHttpSession().setAttribute(phone,i); return i; } }
好了,该实现了的都实现了,但是怎么放进去呢?目前我们有一个配置spring security的类,能不能在那里面设置?答案当然是可以的
/** * 这个是spring security 的web安全配置类,这个类必须有 * @author hongcheng */ @Configuration @EnableWebSecurity // 启动web的安全控制,这个注解好象不用也行 @EnableGlobalMethodSecurity(prePostEnabled = true) // 启动基于方法注解的控制 public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Resource(name="MyUserDetailsService") private UserDetailsService MyUserDetailsService; @Resource(name="SmsUserDetailsService") private UserDetailsService SmsUserDetailsService; @Autowired @Qualifier("authenticationManagerBean") private AuthenticationManager authenticationManager; /** * 这个必须重写,才能使用AuthenticationManager,在成员变量注入进来,再注入过滤器中 * */ @Override @Bean public AuthenticationManager authenticationManagerBean() throws Exception { // ******************************************************************** // 这里必须自己创建ProviderManager,并且把两个provider传进去,否则会一直创建新的ProviderManager // 然后不断在自己和parentAuthenticationManager之间调用,最后栈溢出。 // 网上很多博客都是说在configure(AuthenticationManagerBuilder auth)方法中设置,我实际上测试在这个方法中设置是没用的 ProviderManager authenticationManager = new ProviderManager(Arrays.asList( smsAuthenticationProvider(),daoAuthenticationProvider() )); return authenticationManager; } /** * 认证失败的处理器 * */ @Bean public AuthenticationFailureHandler getFailureHandler(){ return new AuthenticationFailureHandler() { @Override public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException { /** * 认证失败的处理器 * 默认情况下,认证成功/失败,都是重定向到一个页面上的。 * 如果想要认证成功/失败后,返回信息体,而不是重定向,就必须用AuthenticationHandler * 另外,绝对不要用httpServletResponse.getWriter(),一旦你用了Writer,那么setContentType("text/plain;charset=UTF-8")设置的 * 编码utf-8就永远不会被设置进去。具体原因自己看org.apache.catalina.connector.Response#setContentType(java.lang.String)376行 * */ httpServletResponse.getOutputStream().write(e.getMessage().getBytes("UTF-8")); httpServletResponse.setContentType("text/plain;charset=UTF-8"); httpServletResponse.setCharacterEncoding("UTF-8"); httpServletResponse.flushBuffer(); } }; } /** * 认证成功的处理器,和上面一样 * */ @Bean public AuthenticationSuccessHandler getSuccessHandler(){ return new AuthenticationSuccessHandler() { @Override public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException { httpServletResponse.getOutputStream().write("登录成功".getBytes("UTF-8")); httpServletResponse.setContentType("text/plain;charset=UTF-8"); httpServletResponse.setCharacterEncoding("UTF-8"); httpServletResponse.flushBuffer(); } }; } /** * 下面就是自定义的过滤器,配置一下拦截地址、认证成功失败处理器、authenticationManager * 如果还有其他认证过滤器,则再这样写一个 * 自定义登录过滤器 * @Author * @return */ @Bean public SmsAuthenticationProcessingFilter SmsAuthenticationProcessingFilter() throws Exception { SmsAuthenticationProcessingFilter filter = new SmsAuthenticationProcessingFilter(); /** * 自己额外添加的过滤器链必须在这里手动加上两个处理器,不然他会胡乱重定向的。 * */ filter.setAuthenticationSuccessHandler(getSuccessHandler()); filter.setAuthenticationFailureHandler(getFailureHandler()); filter.setAuthenticationManager(authenticationManager); return filter; } /** * 自定义的认证器,这是用于提供认证服务的 * */ @Bean public SmsAuthenticationProvider smsAuthenticationProvider() { SmsAuthenticationProvider smsAuthenticationProvider = new SmsAuthenticationProvider(); smsAuthenticationProvider.setPasswordEncoder(noOpPasswordEncoder()); smsAuthenticationProvider.setUserDetailsService(SmsUserDetailsService); return smsAuthenticationProvider; } /** * DaoAuthenticationProvider是给UsernamePasswordAuthenticationFilter认证用的 * */ @Bean public DaoAuthenticationProvider daoAuthenticationProvider() { DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(); daoAuthenticationProvider.setUserDetailsService(MyUserDetailsService); daoAuthenticationProvider.setPasswordEncoder(bCryptPasswordEncoder()); return daoAuthenticationProvider; } /** * 配置认证管理器 * */ @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { // 很多博客都是在这里加上这句来添加Provider的,如果你只在这里加了,百分百执行时的 // ProviderManager里面只有一个匿名的Provider。 // 记住,我们要自己手动创建一个ProviderManager对象,并且创建时就加上这俩个Provider // auth.authenticationProvider(daoAuthenticationProvider()); // auth.authenticationProvider(smsAuthenticationProvider()); super.configure(auth); } /** * 配置url安全认证拦截的 * */ @Override protected void configure(HttpSecurity http) throws Exception { /* * csrf().disable() 关闭跨域请求限制,如果开启,可能会出现很多非get请求出现405 * .authorizeRequests() 启动请求认证 * .antMatchers("/**").authenticated() 匹配指定的url地址进行认证判断 * .anyRequest().permitAll() 对其他地址进行放行 * formLogin() 启动表单登录 * .loginPage("/login.html") 可以自定义登录页面 * .failureUrl("/login_fail.html") 表单登录失败的跳转地址 * .defaultSuccessUrl("/login_s.html",true) 表单登录成功的跳转地址, * 参数2如果为false,登录成功时会跳转到拦截前的页面,true时登录成功固定跳转给定的页面 * .logout() 启动用户退出,security提供了默认退出地址:/logout * .logoutSuccessUrl("/logout.html") 成功推出后的跳转地址 * */ http.cors(); http.csrf().disable() .authorizeRequests() .antMatchers("/sms/login","/test/sms/code/**","/login.html").permitAll() .anyRequest().authenticated() .and() .formLogin() // .failureUrl("/login_fail.html") // .defaultSuccessUrl("/login_s.html",true) // .loginPage("/login.html") // .loginProcessingUrl("/login") .successHandler(getSuccessHandler()) // 认证成功时的处理器,返回自定义信息,而不是跳转登录页 .failureHandler(getFailureHandler()) // 认证成功时的处理器,返回自定义信息,而不是跳转登录页 .permitAll() .and() .logout() .logoutSuccessUrl("/logout.html") .and() .exceptionHandling() .authenticationEntryPoint(AjaxAuthenticationEntryPoint()) // 未登录时的处理器,返回自定义信息,而不是跳转登录页面 .accessDeniedHandler( accessDeniedHandler()); // 访问拒绝时处理器,返回自定义信息 /** * 这里如果这定了页面,Handler就无效,如果是不使用spring security自带的登录页面,认证时需要始终返回json,就不要设置 * .failureUrl("/login_fail.html") * .defaultSuccessUrl("/login_s.html",true) * .loginPage("/login.html") * .loginProcessingUrl("/login") * */ http.addFilterBefore(SmsAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class); } /** * 一般访问资源时,如果没有权限,就会返回内置异常信息,如需要返回自定义信息,需要重写AccessDeniedHandler * 并需要在void configure(HttpSecurity http)方法中加 * .and() * .exceptionHandling() * .accessDeniedHandler( accessDeniedHandler()); // 访问拒绝时处理器,返回自定义信息 * */ @Bean public AccessDeniedHandler accessDeniedHandler(){ return new AccessDeniedHandler(){ @Override public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException { HashMap<String, Object> responseBody = new HashMap<>(4); responseBody.put("status","403"); responseBody.put("msg",e.getMessage()); responseBody.put("data",null); responseBody.put("list",null); httpServletResponse.setStatus(401); httpServletResponse.getWriter().write(responseBody.toString()); } }; } /** * 一般访问资源时,如果没有登录认证,就会跳转到登录页,重写AuthenticationEntryPoint返回自定义信息 * 需要在void configure(HttpSecurity http)方法中加 * .and() * .exceptionHandling() * .authenticationEntryPoint(AjaxAuthenticationEntryPoint()) // 未登录时的处理器,返回自定义信息,而不是跳转登录页面 * */ @Bean public AuthenticationEntryPoint AjaxAuthenticationEntryPoint(){ return new AuthenticationEntryPoint(){ @Override public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException { HashMap<String, Object> responseBody = new HashMap<>(4); responseBody.put("status","401"); responseBody.put("msg","Need Authorities!"); responseBody.put("data",null); responseBody.put("list",null); httpServletResponse.setStatus(401); httpServletResponse.getWriter().write(responseBody.toString()); } }; } /** * 这个是配置密码编码器的<br/> * BCryptPasswordEncoder表示使用BCrypt算法 * */ @Bean("bCryptPasswordEncoder") public PasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } /** * 这个是配置密码编码器的<br/> * NoOpPasswordEncoder表示不进行密码编码 * */ @Bean("noOpPasswordEncoder") public PasswordEncoder noOpPasswordEncoder() { return NoOpPasswordEncoder.getInstance(); } public static void main(String[] args) { BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); System.err.println(passwordEncoder.encode("123")); } }
接下来我们就可以进行测试了。
首先访问我们随便访问一个资源 http://localhost:9999/test/r6
然后尝试手机号验证码登录,访问 http://localhost:9999/test/sms/code/10086 获取验证码
POST访问 http://localhost:9999/sms/login 进行登录,参数phone=10086,smsCode=上一步获取到的验证码
访问资源 http://localhost:9999/test/r1 和 http://localhost:9999/test/r6
访问 http://localhost:9999/logout 进行登出
尝试使用用户名密码进行登录 http://localhost:9999/login ,参数username=zhangsan,password=123
到目前为止,多认证方式实现完毕
总结:
1:实现自定义SmsAuthenticationToken,继承 AbstractAuthenticationToken 2:实现自定义SmsAuthenticationProcessingFilter,继承 AbstractAuthenticationProcessingFilter 指定过滤器处理的URL地址,也就是指定一个认证地址,构建一个Token,传给后续操作 3:实现自定义SmsAuthenticationProvider,继承 AbstractUserDetailsAuthenticationProvider 指定如何处理Token,支持处理何种Token类型,如何认证用户登录是否合法 4:实现自定义SmsUserDetailsServiceImpl,实现 UserDetailsServiceImpl接口 自定如何根据传入的参数去查询用户信息 5:重写public AuthenticationManager authenticationManagerBean() 手动构建一个ProviderManager,将用到的AuthenticationProvider全部丢进去 6:创建一个SmsAuthenticationProcessingFilter的bean 设置其AuthenticationManager为我们自己构建的对象 7:创建一个SmsAuthenticationProvider的bean 设置其UserDetailsService、PasswordEncoder 8:创建一个DaoAuthenticationProvider的bean 设置其UserDetailsService、PasswordEncoder,因为我们自己手动构建了ProviderManager, 为了避免后续security没有自动加入默认的DaoAuthenticationProvider,我们自己手动加入 9:修改protected void configure(HttpSecurity http) 开放新加的认证需要使用的url, 增加过滤器 http.addFilterBefore(SmsAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class);
4:分布式项目怎么用spring security?
待更新