Spring Security 是强大的,且容易定制的,基于Spring开发的实现认证登录与资源授权的应用安全框架
官网:https://projects.spring.io/spring-security/
SpringSecurity 的核心功能:
SpringSecurity与Shiro对比:
由系统登录页面,进行登录验证到首页,管理员admin用户有日志管理,用户管理,业务一,业务二的权限操作,普通user用户只有业务一,业务二的权限操作
pom.xml:
<dependencies>
<dependency>
<groupId>org.mybatis.spring.bootgroupId>
<artifactId>mybatis-spring-boot-starterartifactId>
<version>2.1.3version>
dependency>
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-thymeleafartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-securityartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-devtoolsartifactId>
<scope>runtimescope>
<optional>trueoptional>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<optional>trueoptional>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-testartifactId>
<scope>testscope>
<exclusions>
<exclusion>
<groupId>org.junit.vintagegroupId>
<artifactId>junit-vintage-engineartifactId>
exclusion>
exclusions>
dependency>
<dependency>
<groupId>org.springframework.securitygroupId>
<artifactId>spring-security-testartifactId>
<scope>testscope>
dependency>
dependencies>
application.yml:
spring:
datasource:
username: root
password: 123456
url: jdbc:mysql:///sys_base?characterEncoding=UTF-8&serverTimezone=UTC
driver-class-name: com.mysql.cj.jdbc.Driver
#控制台打印sql语句
logging:
level:
com.baidu.springboot_10_simpledemo.mapper: debug
准备这6个简单的页面:不需要太好看,能区分开来就行,主要体会其中的逻辑
UserController:
@Controller
public class UserController {
@RequestMapping({"/","/index"})
public String index(String username,String password){
return "index";
}
@PostMapping("/login")
public String login(String username,String password){
return "index";
}
@RequestMapping("/user")
public String user(){
return "user";
}
@RequestMapping("/log")
public String logger(){
return "logger";
}
@RequestMapping("/service1")
public String service1(){
return "service1";
}
@RequestMapping("/service2")
public String service2(){
return "service2";
}
}
HttpBasic登录验证模式是Spring Security实现登录验证最简单的一种方式,也可以说是最简陋的一种方式。它的目的并不是保障登录验证的绝对安全,而是提供一种“防君子不防小人”的登录验证。
就好像是我小时候写日记,都买一个带小锁头的日记本,实际上这个小锁头有什么用呢?如果真正想看的人用一根钉子都能撬开。它的作用就是:某天你的父母想偷看你的日记,拿出来一看还带把锁,那就算了吧,怪麻烦的。
举一个我使用HttpBasic模式的进行登录验证的例子:我曾经在一个公司担任部门经理期间,开发了一套用于统计效率、分享知识、生成代码、导出报表的Http接口。纯粹是为了工作中提高效率,同时我又有一点点小私心,毕竟各部之间是有竞争的,所以我给这套接口加上了HttpBasic验证。公司里随便一个技术人员,最多只要给上一两个小时,就可以把这个验证破解了。说白了,这个工具的数据不那么重要,加一道锁的目的就是不让它成为公开数据。如果有心人破解了,真想看看这里面的数据,其实也无妨。这就是HttpBasic模式的典型应用场景。
config/SecurityConfig:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic()
.and()
.authorizeRequests().anyRequest()
.authenticated();
}
}
这段代码的意思是,当任意一个请求发来,必须通过认证才可以访问系统权限
启动项目时,后台会打印登录验证密码
当你浏览器访问任意路径时,就会由登录验证
用户名默认user
密码是后台打印的
当然我们也可以通过application.yml指定配置用户名密码
spring:
security:
user:
name: admin
password: admin
Spring Security的HttpBasic模式,该模式比较简单,只是进行了通过携带Http的Header进行简单的登录验证,而且没有定制的登录页面,所以使用场景比较窄。
对于一个完整的应用系统,与登录验证相关的页面都是高度定制化的,非常美观而且提供多种登录方式。这就需要Spring Security支持我们自己定制登录页面,也就是接下来给大家介绍的formLogin模式登录认证模式。
formLogin模式的三要素:
一般来说,使用权限认证框架的的业务系统登录验证逻辑是固定的,而资源访问控制规则和用户信息是从数据库或其他存储介质灵活加载的。但这里的所有的用户、资源、权限信息都是代码配置写死的,旨在为大家介绍formLogin认证模式,如何从数据库加载权限认证相关信息我会结合RBAC权限模型来给大家讲解。
首先,和httpBasic一样,我们要继承WebSecurityConfigurerAdapter ,重写configure(HttpSecurity http) 方法,该方法用来配置 登录验证逻辑
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable() //禁用跨站csrf攻击防御
.formLogin()
.loginPage("/login.html")//用户未登录时,访问任何资源都转跳到该路径,即登录页面
.loginProcessingUrl("/login")//登录表单form中action的地址,也就是处理认证请求的路径
.usernameParameter("username")///登录表单form中用户名输入框input的name名,不修改的话默认是username
.passwordParameter("password")//form中密码输入框input的name名,不修改的话默认是password
.defaultSuccessUrl("/index")//登录认证成功后默认转跳的路径
.and()
.authorizeRequests()
.antMatchers("/login.html","/login").permitAll()//不需要通过登录验证就可以被访问的资源路径
.antMatchers("/service1","/service2") //需要对外暴露的资源路径
.hasAnyAuthority("ROLE_user","ROLE_admin") //user角色和admin角色都可以访问
.antMatchers("/log","/user")
.hasAnyRole("admin") //admin角色可以访问
.anyRequest().authenticated();
}
}
解释代码(结合代码中的注释理解)
- csrf().disable():CSRF(Cross-site request forgery跨站请求伪造,也被称为“One Click Attack”或者Session Riding,通常缩写为CSRF或者XSRF,是一种对网站的恶意利用。为了防止跨站提交攻击,通常会配置csrf。Spring Security 3默认关闭csrf,Spring Security 4默认启动了csrf。项目中启用了 security,post请求无法通过,如果不采用csrf,可禁用security的csrf
- authorizeRequests():定制请求的授权规则
- antMatchers写资源路径,permitAll就是不需要通过登录验证就可以被访问的资源路径,其他的资源可以配置授权规则
- hasAuthority:拥有某个权限,如
.antMatchers("/log").hasAuthority("log")
,可以看做是对应资源权限配置对应的类似id- hasAnyAuthority:拥有多个角色权限如ROLE_user、ROLE_admin
- hasRole:拥有角色,如admin
- hasAnyRole:拥有多个角色
- anyRequest().authenticated():表示所有请求必须先认证
在上文中,我们配置了登录验证及资源访问的权限规则,我们还没有具体的用户,下面我们就来配置具体的用户。重写WebSecurityConfigurerAdapter 的 configure(AuthenticationManagerBuilder auth) 方法
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("user")
.password(passwordEncoder().encode("123456"))
.roles("user")
.and()
.withUser("admin")
.password(passwordEncoder().encode("123456"))
//.authorities("log")
.roles("admin")
.and()
.passwordEncoder(passwordEncoder());//配置BCrypt加密
}
//配置 BCrypt 加密
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
- inMemoryAuthentication:在内存里面存储用户的身份认证和授权信息,真实开发中用户信息是从数据库加载的,后面会讲解
- withUser(“user”):用户名是user
- password(passwordEncoder().encode(“123456”)):密码是加密之后的123456
- roles(“user”):用于指定用户的角色,一个用户可以有多个角色
- authorities(“log”):指的是admin用户拥有资源ID对应的资源访问的的权限(log),如果前面授权规则中使用
.antMatchers("/log").hasAuthority("log")
,那么就对应用authorities(“log”)来配置- 多个用户用and()连接
在我们的实际开发中,登录页面login.html和控制层Controller登录验证’/login’都必须无条件的开放。除此之外,一些静态资源如css、js文件通常也都不需要验证权限,我们需要将它们的访问权限也开放出来。下面就是实现的方法:重写WebSecurityConfigurerAdapter类的configure(WebSecurity web) 方法
@Override
public void configure(WebSecurity web) {
//将项目中静态资源路径开放出来
web.ignoring().antMatchers( "/css/**", "/fonts/**", "/img/**", "/js/**");
}
测试效果:
可以看到,admin用户可以四个页面都可以访问,而user用户只能访问具体业务一、二,访问日志、用户管理时,会报403的禁止访问的错误,后面会结合RBAC权限模型讲解,如何在数据库中获取用户信息,权限信息以及退出和记住我的功能
自定义登录验证成功结果处理:
需要自定义一个类MySuccessHandler继承SavedRequestAwareAuthenticationSuccessHandler,并重写
onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) 方法
@Component
public class MySuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
@Value("${spring.security.loginType}")
private String loginType;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
if (loginType.equalsIgnoreCase("json")){
//将返回的对象转换成json数据
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(Result.success(null));
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(json);
}else{
//跳转到登录之前请求的页面
super.onAuthenticationSuccess(request, response, authentication);
}
}
}
然后在SecurityConfig中将自定义的MySuccessHandler 配置上就可以
//先注入
@Autowired
private MySuccessHandler mySuccessHandler;
http.csrf().disable()
.formLogin()
.loginPage("/login.html")
.usernameParameter("username")
.passwordParameter("password")
.loginProcessingUrl("/login")
//.defaultSuccessUrl("/index")
.successHandler(mySuccessHandler)
successHandler和defaultSuccessUrl不能同时使用
自定义登录验证失败结果处理:
需要自定义一个类MyFailureHandler继承SimpleUrlAuthenticationFailureHandler并重写onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) 方法
@Component
public class MyFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Value("${spring.security.loginType}")
private String loginType;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
if (loginType.equalsIgnoreCase("json")) {
//将返回的对象转换成json数据
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(Result.fail("用户名或密码错误!"));
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(json);
}else {
//重新跳转到登录页面
super.onAuthenticationFailure(request, response, exception);
}
}
}
和自定义成功一样的逻辑,走的是自己的自定义的方法,像前端返回一个Result失败的信息,前端再处理登录逻辑,如果不是走的自己的方法,就会调用父类的方法,重新跳转到登录页面
然后在SecurityConfig中将自定义的MyFailureHandler 配置上就可以
@Autowired
private MyFailureHandler myFailureHandler;
http.csrf().disable()
.formLogin()
.loginPage("/login.html")
.usernameParameter("username")
.passwordParameter("password")
.loginProcessingUrl("/login")
.successHandler(mySuccessHandler)
.failureHandler(myFailureHandler)
前端处理登录逻辑:
前端发送一个ajax异步请求,根据返回的json数据处理登录逻辑,成功则跳转到首页,失败则提示错误信息并跳转登录页面
$.post("/login",{"username":username,"password":password,"remember":rememberMe},function (data) {
if (data.isok){
//成功
location.href="/index";
}else {
//失败
alert(data.msg);
location.href="/login.html"
}
})
配置方式:
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) //session创建规则,默认,有需要时创建session
配置方式:
application.yml:
server:
servlet:
session:
timeout: 1m #最少配置一分钟
SecurityConfig:
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) //session创建规则,默认,有需要时创建session
.invalidSessionUrl("/login.html")//session超时后的页面,超时跳转到登录页面
配置方式:
.sessionFixation().migrateSession() //session保护,默认,每次登录验证将会创建新的http会话,即新的sessionID
描述: 限制最大登录用户数,就是一个账号在一个设备已登录,然后在另一个设备同时登录该账号,可以配置之前的账号强制下线
配置方式:
.maximumSessions(1) //配置一个账号最大设备登录
.maxSessionsPreventsLogin(false) //false:当账号在一个设备上登录,允许其他设备登录,但是之前登录的被迫下线,true就是不允许其他设备登录
.expiredSessionStrategy(new MyExpiredSessionStrategy());
自定义一个类MyExpiredSessionStrategy实现SessionInformationExpiredStrategy接口
public class MyExpiredSessionStrategy implements SessionInformationExpiredStrategy {
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
event.getResponse().setContentType("application/json;charset=UTF-8");
event.getResponse().getWriter().write("你的账号在其他设备登录,当前设备被迫下线,如非本人操作,请及时更改密码");
}
}
测试效果:
现在谷歌浏览器上登录admin用户,然后在火狐浏览器登录admin用户,此时会发现谷歌的账户被强制下线了
Role-Based Access Control ----- 基于角色的访问控制
就是用户通过角色与权限进行关联。简单地说,一个用户拥有若干角色,每一个角色拥有若干权限。这样,就构造成“用户-角色-权限”的授权模型
- 用户:系统接口及访问的操作者
- 权限:能够访问某接口后者做某种操作的授权资格
- 角色:具有一类相同操作权限的用户的总称,可以理解为一定数量的权限的集合,权限的载体
主要有6张表,用户表存放用户信息,角色表存放角色信息,权限表存放权限信息,用户表和角色表的关联表存放用户对应的角色,角色表和权限表的关联表存放角色对应的权限
储存加密后的密码:
在test测试类中打印一下由passwordEncoder加密后的密码就行,然后复制到数据库中
@Resource
private PasswordEncoder passwordEncoder;
@Test
void contextLoads() {
System.out.println(passwordEncoder.encode("123456"));
}
UserDetails与UserDetailsService接口
UserDetails(本质上是个实体类,Security会自动从里面取值进行对比),UserDetails就是用户信息,即:用户名、密码、该用户所具有的权限。
源码中的UserDetails接口都有哪些方法:
public interface UserDetails extends Serializable {
//获取用户的权限集合
Collection<? extends GrantedAuthority> getAuthorities();
//获取密码
String getPassword();
//获取用户名
String getUsername();
//账号是否没过期
boolean isAccountNonExpired();
//账号是否没被锁定
boolean isAccountNonLocked();
//密码是否没过期
boolean isCredentialsNonExpired();
//账户是否可用
boolean isEnabled();
}
我们把这些信息提供给Spring Security,Spring Security就知道怎么做登录验证了,这也体现了Springboot的整体理念,配置大于编码,根本不需要我们自己写Controller实现登录验证逻辑。
所以我们需要自定义一个类实现UserDetails接口
@Component
public class MyUserDetails implements UserDetails {
Collection<? extends GrantedAuthority> authorities; //用户权限集合
String password; //密码
String username; //用户名
boolean accountNonExpired; //账户是否没过期
boolean accountNonLocked; //是否没被锁定
boolean credentialsNonExpired; //密码是否没过期
boolean enabled; //账号是否可用
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return enabled;
}
public void setAuthorities(Collection<? extends GrantedAuthority> authorities) {
this.authorities = authorities;
}
public void setPassword(String password) {
this.password = password;
}
public void setUsername(String username) {
this.username = username;
}
public void setAccountNonExpired(boolean accountNonExpired) {
this.accountNonExpired = accountNonExpired;
}
public void setAccountNonLocked(boolean accountNonLocked) {
this.accountNonLocked = accountNonLocked;
}
public void setCredentialsNonExpired(boolean credentialsNonExpired) {
this.credentialsNonExpired = credentialsNonExpired;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
}
一个适应于UserDetails的java POJO类,所谓的 UserDetails接口实现就是一些get方法。get方法由Spring Security调用,我们通过set方法或构造函数为 Spring Security提供UserDetails数据(从数据库查询)
UserDetailsService接口有一个方法叫做loadUserByUsername,我们实现动态加载用户、角色、权限信息就是通过实现该方法。函数见名知义:通过用户名加载用户。该方法的返回值就是UserDetails
实现UserDetailsService接口之前,需要先提供三个数据层的查询方法,一是通过用户名查询用户信息,二是根据用户名查询用户角色列表,三是通过角色列表查询权限列表。
@Mapper
public interface MyUserDetailsServiceMapper {
//根据userID查询用户信息
@Select("SELECT username,password,enabled\n" +
"FROM sys_user u\n" +
"WHERE u.username = #{username}")
MyUserDetails findByUserName(@Param("username") String username);
//根据userID查询用户角色列表
@Select("SELECT role_code\n" +
"FROM sys_role r\n" +
"LEFT JOIN sys_user_role ur ON r.id = ur.role_id\n" +
"LEFT JOIN sys_user u ON u.id = ur.user_id\n" +
"WHERE u.username = #{username}")
List<String> findRoleByUserName(@Param("username")String username);
//根据用户角色查询用户权限
@Select({
""
})
List<String> findAuthorityByRoleCodes(@Param("roleCodes")List<String> roleCodes);
}
实现UserDetailsService接口,实现动态加载用户、角色、权限信息
@Component
public class MyUserDetailsService implements UserDetailsService {
@Resource
private MyUserDetailsServiceMapper mapper;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
//加载基础用户信息
MyUserDetails myUserDetails = mapper.findByUserName(s);
//加载用户角色列表
List<String> roleCodes = mapper.findRoleByUserName(s);
//通过用户角色列表加载用户资源权限列表
List<String> authorities = mapper.findAuthorityByRoleCodes(roleCodes);
//角色是一个特殊的权限,ROLE_前缀
roleCodes = roleCodes.stream()
.map(rc -> "ROLE_" +rc) //每个对象前加前缀
.collect(Collectors.toList()); //再转换回List
authorities.addAll(roleCodes); //添加修改好前缀的角色前缀的角色权限
//把权限类型的权限给UserDetails
myUserDetails.setAuthorities(
//逗号分隔的字符串转换成权限类型列表
AuthorityUtils.commaSeparatedStringToAuthorityList(
//List转字符串,逗号分隔
String.join(",",authorities)
)
);
return myUserDetails;
}
}
在SecurityConfig类中注册自定义的UserDetailsService,重写WebSecurityConfigurerAdapter的 configure(AuthenticationManagerBuilder auth)方法
//先注入
@Autowired
private MyUserDetailsService myUserDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(myUserDetailsService)
.passwordEncoder(passwordEncoder());
}
Mapper:
@Mapper
public interface MyRBACServiceMapper {
@Select("SELECT url \n" +
"FROM sys_menu m\n" +
"LEFT JOIN sys_role_menu rm ON m.id = rm.menu_id\n" +
"LEFT JOIN sys_role r ON r.id = rm.role_id\n" +
"LEFT JOIN sys_user_role ur ON r.id = ur.role_id\n" +
"LEFT JOIN sys_user u ON u.id = ur.user_id\n" +
"WHERE u.username = #{username}")
List<String> findUrlsByUserName(@Param("username") String username);
}
Service:
@Component("rbacService")
public class MyRBACService {
@Resource
private MyRBACServiceMapper mapper;
/**
*判断用户是否具有该请求资源的访问权限
*/
public boolean hasPermission(HttpServletRequest request, Authentication authentication){
//从security中拿出用户主体,实际上是我们之前封装的UserDetails,但是又被封了一层
Object principal = authentication.getPrincipal();
if (principal instanceof UserDetails){
String username = ((UserDetails) principal).getUsername();
List<String> urls = mapper.findUrlsByUserName(username);
for (String url : urls){
if (url.equals(request.getRequestURI())){
return true;
}
}
}
return false;
}
}
注册rbacService
.and()
.authorizeRequests()
.antMatchers("/login.html","/login").permitAll()
.antMatchers("/index").authenticated()
.anyRequest().access("@rbacService.hasPermission(request,authentication)")
- 登录页面“login.html”和登录认证处理路径“/login”需完全对外开发,不需任何鉴权就可以访问
- 首页 "/index"必须authenticated,即:登陆之后才能访问。不做其他额外鉴权规则控制
- 最后,其他的资源的访问我们通过权限规则表达式实现,表达式规则中使用了rbacService,这个类我们自定义实现。该类服务hasPermission从数据库动态加载资源匹配规则,进行资源访问鉴权
测试效果:
和之前静态在内存中配置的效果一样,不过所有用户、角色、权限都是动态从数据库获取
大家退出功能等急了吧,马上讲解,哈哈哈
退出功能相对简单,不做详细讲解
配置方式:
SecurityConfig:
//开启退出功能
http.logout().logoutSuccessUrl("/login.html"); //退出成功后来到的页面(登录页面)
前端退出按钮:
<form action="/logout" method="post">
<input type="submit" value="注销"/>
form>
SpringSecurity也提供了记住我的功能,这是一个很常见的功能,通常都是将用户信息保存在cookie里面,存在客户端,达到记住我的功能
配置方式:
//开启记住我功能
http.rememberMe()
.rememberMeParameter("remember") //传入参数名称
.rememberMeCookieName("remember-me-cookie") //cookie名称
.tokenValiditySeconds(2*24*60*60); //cookie保留时间
前端:
登录后浏览器的cookie:
可以检测一下,先用浏览器登录系统,然后直接关闭浏览器,再次打开浏览器请求,可直接访问,无需登录
这篇博文也算是我自己的一个学习笔记吧,有什么说错的地方,望大家指出,相信你如果认真看完,收获一定颇丰!后续我还会出一些权限管理系统的实战项目