基于角色的访问控制
隐式访问控制:根据用户的角色判断是否具有某项操作
显示访问控制:为用户分配某种权限,根据权限判断是否可以进行某种操作。与具体角色无关,权限控制更加灵活
认证( authentication ):建立主体( principal) ,确认用户角色。"主体"不仅指用户,还可以是其他执行操作的系统或设备。
授权(authorization ):或称为"访问控制(access-control), 是指决定是否允许主体在应用程序中执行某种操作
Spring Security是Spring中常用的安全服务框架,它包含如下模块:
通过gradle引入Spring Security依赖如下
dependencies {
implementation('org.springframework.boot:spring-boot-starter-security')
...
}
首先创建表示权限的类Authority
实现GrantedAuthority
接口
所有的Authentication实现类都保存了一个GrantedAuthority列表表示用户所具有的权限,通过AuthenticationManager设置到Authentication对象中的,然后AccessDecisionManager将依据GrantedAuthority来鉴定用户是否具有访问对应资源的权限。
GrantedAuthority是一个接口,其中只定义了一个getAuthority()
方法,其返回值为String类型。该方法允许AccessDecisionManager获取一个能够精确代表该权限的字符串。
@Entity
public class Authority implements GrantedAuthority {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String authority;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
@Override
public String getAuthority() { return authority; }
public void setAuthority(String authority) { this.authority = authority; }
}
接着需要创建用户角色类User
,它实现了Spring Security的UserDetails
接口,该接口需要重写getAuthorities()、getUsername()、getPassword()、isAccountNonExpired()、isEnabled()等方法用于获取、设置用户的基本信息。
其中@ManyToMany
声明多对多映射,即User中用authorities
字段表示用户的多个权限类Authority,并通过@JoinTable
连接到对应的数据表user_authority。
在getAuthorities()
返回权限列表时,需要将列表authorities中的Authority类型权限转换为SimpleGrantedAuthority
类型后再返回。SimpleGrantedAuthority是Spring Security中GrantedAuthority接口的具体实现类,和我们自定义的Authority类似,用字符串role保存用户的角色信息,并实现了getAuthority()方法返回角色。
@Entity // 实体
public class User implements UserDetails, Serializable {
private static final long serialVersionUID = 1L;
private Long id; // 用户的唯一标识
private String name;
private String email;
private String username; // 用户账号,用户登录时的唯一标识
private String password; // 登录时密码
private String avatar; // 头像图片地址
@ManyToMany(cascade = CascadeType.DETACH, fetch = FetchType.EAGER)
@JoinTable(name = "user_authority", joinColumns = @JoinColumn(name = "user_id", referencedColumnName = "id"),
inverseJoinColumns = @JoinColumn(name = "authority_id", referencedColumnName = "id"))
private List<Authority> authorities;
protected User() { // JPA 的规范要求无参构造函数;设为 protected 防止直接使用
}
public User(String name, String email,String username,String password) {
this.name = name;
this.email = email;
this.username = username;
this.password = password;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 将查询结果 List 转换成 List
List<SimpleGrantedAuthority> simpleAuthorities = new ArrayList<>();
for(GrantedAuthority authority : this.authorities){
simpleAuthorities.add(new SimpleGrantedAuthority(authority.getAuthority()));
}
return simpleAuthorities;
}
public void setAuthorities(List<Authority> authorities) { this.authorities = authorities; }
@Override
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
@Override
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getAvatar() { return avatar; }
public void setAvatar(String avatar) { this.avatar = avatar; }
@Override
public boolean isAccountNonExpired() { return true; }
@Override
public boolean isAccountNonLocked() { return true; }
@Override
public boolean isCredentialsNonExpired() { return true; }
@Override
public boolean isEnabled() { return true; }
@Override
public String toString() {
return String.format("User[id=%d, username='%s', name='%s', email='%s', password='%s']", id, username, name, email, password);
}
}
权限Authority的Repository,用于完成和数据库的交互,直接继承JPA接口的默认实现即可
public interface AuthorityRepository extends JpaRepository<Authority, Long>{
}
用户User的Repository
public interface UserRepository extends JpaRepository<User, Long> {
//根据方法名生成查询函数
User findByUsername(String username);
//使用Distinct
List<User> findDistinctByUsername(String username);
/**
* 根据用户名分页查询用户列表
*
* @param name 名字
* @param pageable 分页
* @return 分页用户信息
*/
Page<User> findByNameLike(String name, Pageable pageable);
}
定义Authority对应的Service接口与实现类
@Service
public class AuthorityServiceImpl implements AuthorityService {
@Autowired
private AuthorityRepository authorityRepository;
@Override
public Authority getAuthorityById(Long id) {
return authorityRepository.getOne(id);
}
}
定义User对应的Service,通过调用UserRepository实现User的增删改查等操作。此外还需要实现UserDetailsService
接口的loadUserByUsername()方法,用于之后权限校验时进行用户查询
@Service
public class UserServiceImpl implements UserService, UserDetailsService {
@Autowired
private UserRepository userRepository;
@Transactional
@Override
public User saveUser(User user) {
return userRepository.save(user);
}
@Override
public List<User> listUsers() {
return userRepository.findAll();
}
......
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByUsername(username);
}
Controller层的定义遵循RESTful风格,当通过GET方法访问路径/users时,触发userList()
方法,在其中通过userService的listUser()方法获取所有的User信息列表并放到Model中,最后返回页面user/list
在前端页面完成用户增加或修改操作后会发送Post请求到UserController,此时会触发saveOrUpdateUser()
,在其中对用户信息和权限进行保存
通过@DeleteMapping注解定义对DELETE请求的响应方法delete(),完成对用户的删除操作
@RestController
@RequestMapping("/users")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") // 指定角色权限才能操作方法
public class UserController {
@Autowired
private UserService userService;
@Autowired
private AuthorityService authorityService;
@GetMapping
public ModelAndView userList(Model model) {
Sort sort = Sort.by(Sort.Order.desc("id"));
Pageable pageable =PageRequest.of(pageIndex,pageSize, sort);
Page<User> page = userService.listUsers();
List<User> list = page.getContent(); // 当前所在页面数据列表
model.addAttribute("page", page);
model.addAttribute("userList", list);
return new ModelAndView("user/list", "userModel", model);
}
@PostMapping
public ResponseEntity<Response> saveOrUpdateUser(User user, Long authorityId) {
//保存用户的权限列表
List<Authority> authorities = new ArrayList<>();
authorities.add(authorityService.getAuthorityById(authorityId));
user.setAuthorities(authorities);
//保存用户信息
try {
userService.saveUser(user);
} catch (ConstraintViolationException e) {
return ResponseEntity.ok().body(new Response(false, ConstraintViolationExceptionHandler.getMessage(e)));
}
return ResponseEntity.ok().body(new Response(true, "保存成功", user));
}
@DeleteMapping(value = "{id}")
public ResponseEntity<Response> delete(@PathVariable("id") Long id, Model model) {
System.out.println("delete reach");
try {
userService.removeUser(id);
} catch (Exception e) {
return ResponseEntity.ok().body(new Response(false, e.getMessage()));
}
return ResponseEntity.ok().body(new Response(true, "处理成功"));
}
}
在SecurityConfigure
类中对Spring Security的访问权限进行配置
在configure()
方法中对访问路径的权限进行配置,antMatchers()
对访问路径进行匹配,permitAll()
代表放行所有对该路径的请求,hasRole("ADMIN")
代表只有ADMIN的角色才能访问。formLogin()
代表使用表单进行验证,使用loginPage()
、failureUrl()
定义登录、错误页面
在configureGlobal()
方法中对用户认证信息进行管理。在其中通过userDetailsService()
获取用户信息,通过authenticationProvider()
获取认证信息,除了使用之前定义的userDetailsService获取用户信息外,还用到passwordEncoder来对密码进行加密
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true) // 启用方法级别的安全校验
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private static final String KEY = "RM_KEY";
@Qualifier("userServiceImpl")
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private PasswordEncoder passwordEncoder;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // 使用BCrypt加密
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userDetailsService);
authenticationProvider.setPasswordEncoder(passwordEncoder); // 设置密码加密方式
return authenticationProvider;
}
/**
* 自定义配置
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/css/**", "/js/**", "/fonts/**", "/index").permitAll() // 都可以访问
.antMatchers("/h2-console/**").permitAll() // 都可以访问
.antMatchers("/admins/**").hasRole("ADMIN") // 需要相应的角色才能访问
.and()
.formLogin() //基于 Form 表单登录验证
.loginPage("/login").failureUrl("/login-error") // 自定义登录界面、登录失败页面
.and().rememberMe().key(KEY) // 启用 remember me
.and().exceptionHandling().accessDeniedPage("/403"); // 处理异常,拒绝访问就重定向到 403 页面
http.csrf().ignoringAntMatchers("/h2-console/**"); // 禁用 H2 控制台的 CSRF 防护
http.headers().frameOptions().sameOrigin(); // 允许来自同一来源的H2 控制台的请求
}
/**
* 认证信息管理
*/
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService); //获取用户认证信息
auth.authenticationProvider(authenticationProvider());
}
其中通过@EnableGlobalMethodSecurity(prePostEnabled=true) 开启了方法级别的安全校验,即可以在某个具体方法上添加注解来精确控制是否允许访问。有如下四种方法注解:
@PreAuthorize("hasRole('ADMIN')")
List<Person> findAll();
Thymeleaf中可以引入依赖从而便捷地使用Springsecurity相关特性,通过gradle引入thymeleaf-extras-springsecurity5
依赖,注意spring boot2.3.4匹配的版本号为5,版本不匹配无法使用
dependencies {
//Spring Security
implementation('org.springframework.boot:spring-boot-starter-security')
implementation('org.thymeleaf.extras:thymeleaf-extras-springsecurity5')
...
}
然后在要使用的页面声明spring security标签前缀sec
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
通过isAuthenticated()
可以判断用户是否已经登录,isAnonymous()
判断用户未登录,从而可以实现根据用户是否登录显示不同的内容。通过sec:authentication="name"
可以获取登录的用户名。
<ul class="nav navbar-nav navbar-right">
<li sec:authorize="isAuthenticated()" class="active">
<a href="./">
<span sec:authentication="name">span>
<span class="badge">3span>
a>
li>
<li sec:authorize="isAuthenticated()" style="margin-top: 1.5rem;">
<form th:action="@{/logout}" method="post">
<input class="btn btn-outline-success " type="submit" value="退出">
form>
li>
<li sec:authorize="isAnonymous()">
<a href="/login" class="btn btn-outline-success my-2 my-sm-0" type="submit">登录a>
li>
<li sec:authorize="isAnonymous()">
<a href="/register" class="btn btn-outline-success my-2 my-sm-0" type="submit">注册a>
li>
ul>
如下所示,如果用户已经登陆则显示用户名和退出按钮,若未登录则显示登录和注册
类似地,在Spring boot中可以通过SecurityContextHolder获取当前登录的用户信息
User user= (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
值得注意的是Spring security会自动实现对/login、/logout请求的处理,例如下面的登陆页面发送Post请求到@{/login},Spring Security会对用户名密码进行判断,如果登录成功会跳转到请求的页面,否则跳转到/login-error。此外还可以通过name="remember-me"
的checkbox实现记住我的功能,关闭浏览器再次访问无需重新登录。发送Post请求到@{/logout}会注销用户并跳转到登陆页面。
<form th:action="@{/login}" method="post">
<h2 >请登录h2>
<div class="form-group col-md-5">
<label for="username" class="col-form-label">账号label>
<input type="text" class="form-control" id="username" name="username" maxlength="50" placeholder="请输入账号">
div>
<div class="form-group col-md-5">
<label for="password" class="col-form-label">密码label>
<input type="password" class="form-control" id="password" name="password" maxlength="30" placeholder="请输入密码" >
div>
<div class="form-group col-md-2">
<input type="checkbox" name="remember-me"/> 记住我
div>
<div class="form-group col-md-5">
<button type="submit" class="btn btn-primary">登录button>
div>
<div class=" col-md-5" th:if="${loginError}">
<p class="blog-label-error" th:text="${errorMsg}">p>
div>
form>
在上面的configure()中通过http.csrf()
开启了网站的CSFR防护,CSRF全称为跨站点请求伪造(Cross—Site Request Forgery),即当用户A在通过网页安全验证后访问B网站,并将校验信息保存到本地cookie,之后黑客通过诱导A访问C页面后再去访问B网站,这样C就可以使用A本地的校验信息通过B的安全验证,从而以A的身份进行危险操作。
CSRF 攻击之所以能够成功,是因为黑客可以完全伪造用户的请求,该请求中所有的用户验证信息都是存在于 cookie 中,因此黑客可以在不知道这些验证信息的情况下直接利用用户自己的 cookie 来通过安全验证。要抵御 CSRF,关键在于在请求中放入黑客所不能伪造的信息,并且该信息不存在于 cookie 之中。可以在 HTTP 请求中以参数的形式加入一个随机产生的 token,并在服务器端验证这个 token,如果请求中没有 token 或者 token 内容不正确,则认为可能是 CSRF 攻击而拒绝该请求。
对于 GET 请求,token 将附在请求地址之后,这样 URL 就变成 http://url?csrftoken=tokenvalue。 而对于 POST 请求来说,要在 form 的最后加上 ,Thymeleaf模板引擎会自动在form表单后添加csrf校验字段。但是我们通过Ajax发送请求时就需要手动添加校验信息了。
如下所示,首先在页面模板中添加csfr字段,这样所有页面公用头部模板就都添加了
<head th:fragment="static">
<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
...
head>
接着在js中获取头部的csrf内容,并且在发送Ajax请求时将其添加到请求头即可通过CSRF验证
// 删除用户
$("#mainContainer").on("click", ".blog-delete-user", function () {
// 获取 CSRF Token
const csrfToken = $("meta[name='_csrf']").attr("content");
const csrfHeader = $("meta[name='_csrf_header']").attr("content");
const deleteUrl = projectName + "/users/" + $(this).attr("userId")
$.ajax({
url: deleteUrl,
type: 'DELETE',
beforeSend: function (request) {
request.setRequestHeader(csrfHeader, csrfToken); // 在请求头添加CSRF Token
},
success: function (data) {
if (data.success) {
getUersByName(0, _pageSize); //删除成功刷新主界面
} else {
console.log("del fail");
}
}
});
});
});