Spring Security
简介
Spring 是非常流行和成功的 Java 应用开发框架,Spring Security 正是 Spring 家族中的成员。Spring Security为基于J2EE企业应用软件提供了全面安全服务。 特别是使用领先的J2EE解决方案-spring框架开发的企业软件项目。
安全方面的两个主要区域是“认证”和“授权”(或者访问控制),一般来说,Web 应用的安全性包括**用户认证(Authentication)和用户授权 ** **(Authorization)**两个部分,这两点也是 Spring Security 重要核心功能。
(1)用户认证指的是:验证某个用户是否为系统中的合法主体,也就是说用户能否访问该系统。用户认证一般要求用户提供用户名和密码。系统通过校验用户名和密码来完成认证过程。通俗点说就是系统认为用户是否能登录
(2)用户授权指的是验证某个用户是否有权限执行某个操作。在一个系统中,不同用户所具有的权限是不同的。比如对一个文件来说,有的用户只能进行读取,而有的用户可以进行修改。一般来说,系统会为不同的用户分配不同的角色,而每个角色则对应一系列的权限。通俗点讲就是系统判断用户是否有权限去做某些事情。
在用户认证方面,Spring Security 框架支持主流的认证方式,包括 HTTP 基本认证、HTTP 表单验证、HTTP 摘要认证、OpenID 和 LDAP 等。在用户授权方面,Spring Security 提供了基于角色的访问控制和访问控制列表(Access Control List,ACL),可以对应用中的领域对象进行细粒度的控制。
Spring Security与Shiro的比较
官方网站:
https://spring.io/projects/spring-security
Spring Security
-
和 Spring 无缝整合。
-
全面的权限控制。
-
专门为 Web 开发而设计。
◼旧版本不能脱离 Web 环境使用。
◼新版本对整个框架进行了分层抽取,分成了核心模块和 Web 模块。单独引入核心模块就可以脱离 Web 环境。
-
重量级。
Shiro
Apache 旗下的轻量级权限控制框架。Apache Shiro是一个强大且易用的Java安全框架,执行身份验证、授权、密码和会话管理。使用Shiro的易于理解的API,您可以快速、轻松地获得任何应用程序,从最小的移动应用程序到最大的网络和企业应用程序。
官网:https://shiro.apache.org/documentation.html
特点:
轻量级。Shiro 主张的理念是把复杂的事情变简单。针对对性能有更高要求的互联网应用有更好表现。
通用性。
好处:不局限于 Web 环境,可以脱离 Web 环境使用。
缺陷:在 Web 环境下一些特定的需求需要手动编写代码定制。
入门案例
添加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
编写Controller
@RestController
public class TestController {
@GetMapping("/")
public String hello(){
return "hello";
}
}
启动程序访问/
因安全框架的原因会将请求拦截自动跳转到认证页面,security默认提供的username为user,password在启动页会自动生成,输入默认账号密码认证成功后才会跳转显示hello!!!!
核心过滤器认识
在我们添加了SpringSecurity 依赖后,在项目的启动日志中,可以直观的看到SpringSecurity 的实现是通过filter chain实现的。
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFil
ter
org.springframework.security.web.context.SecurityContextPersistenceFilter
org.springframework.security.web.header.HeaderWriterFilter
org.springframework.security.web.csrf.CsrfFilter
org.springframework.security.web.authentication.logout.LogoutFilter
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter
org.springframework.security.web.savedrequest.RequestCacheAwareFilter
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
org.springframework.security.web.authentication.AnonymousAuthenticationFilter
org.springframework.security.web.session.SessionManagementFilter
org.springframework.security.web.access.ExceptionTranslationFilter
org.springframework.security.web.access.intercept.FilterSecurityInterceptor
FilterSecurityInterceptor :
动态实现根据用户访问的url进行权限管理
在动态实现根据访问的url进行权限认证时可以自定义FilterInvocationSecurityMetadataSource实现认证规则的配置(例根据用户请求url获取对应url的角色信息),修改FilterSecurityInterceptor自带MetadataSource,同时自定义AccessDecisionManager ,实现访问决策管理(根据登录用户查询到用户的角色,接着比对用户角色与当前url的角色,一致即可访问),最后在配置类中通过
http.authorizeRequests().withObjectPostProcessor(
new ObjectPostProcessor<FilterSecurityInterceptor>() {
@Override
public <O extends FilterSecurityInterceptor> O postProcess(O object) {
object.setSecurityMetadataSource(new CustomerFilterInvocationSecurityMetadataSource());
object.setAccessDecisionManager(new CustomerAccessDecisionManger());
return object;
}
}
实现根据url动态认证授权。。。。。。
//一个位于底层的针对http安全控制的拦截器
public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements
Filter{
//.......
//核心方法 doFilter
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
FilterInvocation fi = new FilterInvocation(request, response, chain);
invoke(fi);
}
//过滤器链执行
public void invoke(FilterInvocation fi) throws IOException, ServletException {
if ((fi.getRequest() != null)
&& (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
&& observeOncePerRequest) {
// filter already applied to this request and user wants us to observe
// once-per-request handling, so don't re-do security checking
//进行过滤器链中过滤器的执行
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
}
else {
// first time this request being called, so perform security checking
if (fi.getRequest() != null && observeOncePerRequest) {
fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
}
//查看之前的filter是否通过
InterceptorStatusToken token = super.beforeInvocation(fi);
try {
//调用服务执行filter过滤器
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
}
finally {
super.finallyInvocation(token);
}
super.afterInvocation(token, null);
}
}
}
UsernamePasswordAuthenticationFilter:
通过默认认证页输入用户名密码后,对/login的post请求做拦截,校验表单数据
//核心方法:
//默认用户名密码认证的url是/login,提交方式是post方式
public UsernamePasswordAuthenticationFilter() {
super(new AntPathRequestMatcher("/login", "POST"));
}
// ~ Methods
// 默认提供的认证机制,默认只是获取到输入的用户名和密码进行比对,没有去查询数据库
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
String username = obtainUsername(request);
String password = obtainPassword(request);
if (username == null) {
username = "";
}
if (password == null) {
password = "";
}
username = username.trim();
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
核心接口
UserDetailsService
Security中加载用户数据的核心接口,当配置中没有账号密码时,通过实现此接口可以自定义逻辑(连接数据库)控制账号、密码认证。
在我们自定义服务时,实现此接口即可。
public interface UserDetailsService {
// ~ Methods
// ========================================================================================================
/**
* Locates the user based on the username. In the actual implementation, the search
* may possibly be case sensitive, or case insensitive depending on how the
* implementation instance is configured. In this case, the <code>UserDetails</code>
* object that comes back may have a username that is of a different case than what
* was actually requested..
*
* @param username the username identifying the user whose data is required.
*
* @return a fully populated user record (never <code>null</code>)
*
* @throws UsernameNotFoundException if the user could not be found or the user has no
* GrantedAuthority
* 根据用户名进行用户找到用户,返回UserDetails对象
*/
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
实现loadUserByUsername方法即可,其返回值为UserDetails
UserDetails
Security默认的用户主体
public interface UserDetails extends Serializable {
// ~ Methods
// ======================================================================================================
/**
* Returns the authorities granted to the user. Cannot return <code>null</code>.
* 获取登录用户所有权限
* @return the authorities, sorted by natural key (never <code>null</code>)
*/
Collection<? extends GrantedAuthority> getAuthorities();
/**
* Returns the password used to authenticate the user.
* 获取密码
* @return the password
*/
String getPassword();
/**
* Returns the username used to authenticate the user. Cannot return <code>null</code>.
* 获取用户名
* @return the username (never <code>null</code>)
*/
String getUsername();
/**
* Indicates whether the user's account has expired. An expired account cannot be
* authenticated.
* 账户是否过期
* @return <code>true</code> if the user's account is valid (ie non-expired),
* <code>false</code> if no longer valid (ie expired)
*/
boolean isAccountNonExpired();
/**
* Indicates whether the user is locked or unlocked. A locked user cannot be
* authenticated.
* 账户是否被锁定
* @return <code>true</code> if the user is not locked, <code>false</code> otherwise
*/
boolean isAccountNonLocked();
/**
* Indicates whether the user's credentials (password) has expired. Expired
* credentials prevent authentication.
* 凭证是否过期
* @return <code>true</code> if the user's credentials are valid (ie non-expired),
* <code>false</code> if no longer valid (ie expired)
*/
boolean isCredentialsNonExpired();
/**
* Indicates whether the user is enabled or disabled. A disabled user cannot be
* authenticated.
* 当前用户是否可用
* @return <code>true</code> if the user is enabled, <code>false</code> otherwise
*/
boolean isEnabled();
}
其默认提供的实现类User作为我们使用的返回值即可!!!!
PasswordEncoder
在web中,密码一般是加密保存的,Security中,默认使用此接口对密码进行编码与适配。
public interface PasswordEncoder {
/**
* Encode the raw password. Generally, a good encoding algorithm applies a SHA-1 or
* greater hash combined with an 8-byte or greater randomly generated salt.
* 初始密码的编码
*/
String encode(CharSequence rawPassword);
/**
* Verify the encoded password obtained from storage matches the submitted raw
* password after it too is encoded. Returns true if the passwords match, false if
* they do not. The stored password itself is never decoded.
*
* @param rawPassword the raw password to encode and match
* @param encodedPassword the encoded password from storage to compare with
* @return true if the raw password, after encoding, matches the encoded password from
* storage
* 将初始密码与编码后的密码进行适配
*/
boolean matches(CharSequence rawPassword, String encodedPassword);
/**
* Returns true if the encoded password should be encoded again for better security,
* else false. The default implementation always returns false.
* @param encodedPassword the encoded password to check
* @return true if the encoded password should be encoded again for better security,
* else false.
* 如果编码后的密码再次进行编码以达到更安全的结果就返回true,默认返回false
*/
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
常见的接口实现类:
BCryptPasswordEncoder:Security官方推荐的密码解析器,使用中可以直接使用或实现PasswordEncoder接口,自定义解析。
@Test
void contextLoads() {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
//对原始密码编码
String s = encoder.encode("123456");
//对原始密码与编码后的密码适配
boolean b = encoder.matches("123456", s);
System.out.println(b);
}
SpringSecurity 在web中的权限使用
1.通过application.yml文件
spring:
security:
user:
name: user #手动配置认证的用户名以及密码
password: 123456
2.通过编写配置类
编写配置类WebSecurityConfig
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
/**
* WebSecurityConfigurerAdapter :实现WebSecurityConfigurer的基础类,用于自定义安全配置
* configure方法:重写添加自定义安全配置
* AuthenticationManagerBuilder : 用于创建自定义认证管理器,用于构建身份验证
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
MyPasswordEncoder encoder = new MyPasswordEncoder();
String s = encoder.encode("123456");
//添加内存身份验证
auth
.inMemoryAuthentication()
.withUser("user")
.password(s)
.roles("ADMIN")
.and()
.withUser("xy")
.password(s)
.roles("USER");
}
}
此时注意,Security默认进行认证时对密码是要求进行编码的,如果不进行编码,会报错
IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"
自定义MyPasswordEncoder,实现PasswordEncoder,实现对认证密码的编码以及匹配(或默认使用BCryptPasswordEncoder也可以)
//PasswordEncoder :SpringSecurity中,默认会对密码进行编码处理,此接口为Security提供的默认实现编码以及匹配的方法
@Component
public class MyPasswordEncoder implements PasswordEncoder {
//对原始密码进行编码 此处可以自定义编码
@Override
public String encode(CharSequence rawPassword) {
//md5加密
return DigestUtils.md5DigestAsHex(rawPassword.toString().getBytes());
}
//对原始密码以及编码后的密码进行比对 根据编码对应进行解码比较密码
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return encodedPassword.equals(DigestUtils.md5DigestAsHex(rawPassword.toString().getBytes()));
}
}
修改TestController,在测试接口上添加@Secured注解,表示当前请求只能是指定角色的用户访问
@RestController
public class TestController {
@GetMapping("/")
@Secured("ROLE_ADMIN")
public String test(){
return "success";
}
@GetMapping("/user")
@Secured({"ROLE_ADMIN","ROLE_USER"})
public String testUser(){
return "user_success";
}
}
在启动类添加注解@EnableGlobalMethodSecurity(securedEnabled = true)开启权限注解。
启动项目分别访问对应接口测试即可!!!
3.通过自定义实现
上面两种都是我们手动定义的用户名密码,实际操作中,用户名密码是保存在数据库中的,此时需要通过自定义实现。
手动添加数据模拟
1、配置UserDetailsService,实现自定义用户服务,实现用户账号、密码的动态获取以及封装
@Service
public class MyUserDetailsService implements UserDetailsService {
//通过用户名进行查找
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//定义权限集合,用于添加当前用户的角色
List<GrantedAuthority> authorities = new ArrayList<>();
//模拟手动封装,实际中的用户角色是需要去数据库查找
authorities.add(new SimpleGrantedAuthority("admin"));
//返回user对象,参数为用户名、密码以及对应的权限集合
return new User(username,new MyPasswordEncoder().encode("123456"),authorities);
}
}
2、修改WebSecurityConfig
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
/**
* WebSecurityConfigurerAdapter :实现WebSecurityConfigurer的基础类,用于自定义安全配置
* configure方法:重写添加自定义安全配置
* AuthenticationManagerBuilder : 用于创建自定义认证管理器,用于构建身份验证
*/
@Autowired
private MyUserDetailsService userDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//添加自定义userDetailsService以及passwordEncoder
auth.
userDetailsService(userDetailsService)
.passwordEncoder(new MyPasswordEncoder());
}
}
3、
整合数据库操作
整合MP+Mysql,实现数据库用户信息的登录查询
ay_user代表用户表,ay_role代表角色表,ay_user_role_rel代表用户与角色的关联表
application.yml:
server:
port: 8081
spring:
datasource:
username: root
password: qwer1234
url: jdbc:mysql://127.0.0.1:3306/testspring-boot?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=PRC
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
关于用户、角色、用户角色关联实体类自定义…
UserMapper:
@Mapper
@Repository
public interface UserMapper extends BaseMapper<AyUser> {
}
AyRoleMapper:
@Mapper
@Repository
public interface AyRoleMapper extends BaseMapper<AyRole> {
}
AyUserRoleRelMapper:
@Mapper
@Repository
public interface AyUserRoleRelMapper extends BaseMapper<AyUserRoleRel> {
}
MPConfig:
@Configuration
@MapperScan("com.example.security_demo.mapper")
public class MPConfig {
}
AyUserServiceImpl:
@Service
public class AyUserServiceImpl implements AyUserService {
@Autowired
private UserMapper userMapper;
@Override
public AyUser findUserByName(String name) {
QueryWrapper<AyUser> wrapper = new QueryWrapper<>();
wrapper.eq("name",name);
AyUser ayUser = userMapper.selectOne(wrapper);
return ayUser;
}
}
AyRoleServiceImpl:
@Service
public class AyRoleServiceImpl extends ServiceImpl<AyRoleMapper, AyRole> implements AyRoleService {
}
AyUserRoleRelServiceImpl:
@Service
public class AyUserRoleRelServiceImpl extends ServiceImpl<AyUserRoleRelMapper, AyUserRoleRel> implements AyUserRoleRelService {
}
MyUserDetailsService:进行修改,连接数据库查找用户信息以及角色信息
@Service
public class MyUserDetailsService implements UserDetailsService {
@Autowired
private AyUserService userService;
@Autowired
private AyRoleService roleService;
@Autowired
private AyUserRoleRelService userRoleRelService;
//通过用户名进行查找
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//数据库查找
AyUser ayUser = userService.findUserByName(username);
//如果用户不存在,抛出异常
if (ayUser == null){
throw new UsernameNotFoundException("用户不存在!!!");
}
//用户存在查询其角色信息进行保存
QueryWrapper<AyUserRoleRel> wrapper = new QueryWrapper();
wrapper.eq("user_id",ayUser.getId());
List<AyUserRoleRel> list = userRoleRelService.list(wrapper);
//定义权限集合,用于添加当前用户的角色
List<GrantedAuthority> authorities = new ArrayList<>();
//遍历根据其角色id查询角色信息并将其封装到权限集合中
if (list != null && list.size() > 0){
list.forEach(ayUserRoleRel -> {
AyRole ayRole = roleService.getById(ayUserRoleRel.getRoleId());
//权限认证和角色认证写法一样
authorities.add(new SimpleGrantedAuthority("ROLE_" + ayRole.getName())); //角色认证默认角色名字前有ROLE_
});
}
//模拟手动封装,实际中的用户角色是需要去数据库查找
// authorities.add(new SimpleGrantedAuthority("ADMIN"));
//返回user对象,参数为用户名、密码以及对应的权限集合
return new User(ayUser.getName(),ayUser.getPassword(),authorities);
}
}
WebSecurityConfig:添加认证、自定义登录页面、自定义访问拒绝后的自定义异常…
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
/**
* WebSecurityConfigurerAdapter :实现WebSecurityConfigurer的基础类,用于自定义安全配置
* configure方法:重写添加自定义安全配置
* AuthenticationManagerBuilder : 用于创建自定义认证管理器,用于构建身份验证
*/
@Autowired
private MyUserDetailsService userDetailsService;
@Autowired
private MyAccessDeniedHandler accessDeniedHandler;
//自定义认证策略
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//添加自定义userDetailsService以及passwordEncoder
auth.
userDetailsService(userDetailsService)
.passwordEncoder(new MyPasswordEncoder());
}
//Security中对http的配置
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/tologin") //自定义登录页面配置
.loginProcessingUrl("/login")//登录路劲
.successForwardUrl("/success")//成功后跳转url
.failureForwardUrl("/fail") //失败后跳转url
;
//资源配置 hasAuthority 权限认证 hasRole 角色认证
http.authorizeRequests()
.antMatchers("/tologin") //配置请求路劲
.permitAll() //放行,无须保护
.antMatchers("/admin")
// .hasAuthority("ADMIN") //访问admin时,需要的权限,权限名一般大写,默认不写ROLE_
.hasRole("ADMIN") //角色认证
.antMatchers("/user")
// .hasAnyAuthority("ADMIN","USER") //满足其一就满足权限
.hasAnyRole("ADMIN","USER")
.anyRequest() //其他请求
.authenticated()//需要认证
.and().exceptionHandling().accessDeniedHandler(accessDeniedHandler) //使用自定义的访问拒绝控制器
// .and().exceptionHandling().accessDeniedPage("") 定义拒绝访问页
.and().csrf().disable() //关闭csrf防护
;
}
}
自定义拒绝访问异常
MyAccessDeniedHandler :
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
//自定义拒绝访问异常处理
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setHeader("Content-Type","application/json;charset=utf-8") ;
PrintWriter out = response.getWriter();
out.write("{\"status\":\"error\",\"msg\":\"权限不足,请联系管理员!!!!\"}");
out.flush();
out.close();
}
}
自定义登录页面
见上面WebSecurityConfig配置即可
注意:登录请求post方式
<div th:if="${param.error}">
无效的用户名或者密码
</div>
<div th:if="${param.logout}">
你已经登出
</div>
<form th:action="@{/login}" method="post">
<div>
<div>
<span>用户名:</span>
<span><input type="text" name="username"></span>
</div>
<div>
<span>密码:</span>
<span><input type="text" name="password"></span>
</div>
<div>
<span>记住我:</span>
<span><input type="checkbox" name="remember-me"></span>
</div>
<div>
<span><input type="submit" value="login"></span>
</div>
</div>
</form>
登出
Spring Security进行默认登录处理时,如果登录成功会自动生成一个cookie保存在本地,在一个会话周期内,用户可以免登录,如果要进行注销登录,可以使用自带的登出配置
登出配置如下:在WebSecurityConfig修改器configure配置即可
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/tologin") //登录页面配置
.loginProcessingUrl("/login")//登录路劲
.successForwardUrl("/success")//成功后跳转url
//.failureForwardUrl("/fail") //失败后跳转url
.and()
.logout() //登出
.logoutUrl("/logout")
//.logoutSuccessUrl("/tologin")
.permitAll()
;
添加登出按钮在登录成功后默认跳转的html中添加登出按钮,post请求
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<p th:inline="text">Hello <span sec:authentication="name"></span></p>
<form th:action="@{/logout}" method="post">
<input type="submit" value="登出"/>
</form>
</body>
</html>
测试即可
@Secured注解实现权限控制
在控制器中对应的请求中添加Secured注解实现,此时需要在WebSecurityConfig中添加@EnableGlobalMethodSecurity(securedEnabled = true)注解
@Configuration
//@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
Controller:
//@Secured({"ROLE_ADMIN,ROLE_USER"})注解配置访问当前方法的角色信息
@GetMapping("/role/admin")
@Secured({"ROLE_ADMIN"})
@ResponseBody
public String auth_admin(){
return "role_admin";
}
@GetMapping("/role/user")
@Secured({"ROLE_ADMIN","ROLE_USER"})
@ResponseBody
public String auth_user(){
return "role_user";
}
prePostEnabled 实现注解添加权限认证控制
修改@EnableGlobalMethodSecurity(securedEnabled = true),添加prePostEnabled = true属性,即请求前进行认证
权限表达式:
在对应controller方法上添加注解
@GetMapping("/role/admin")
// @Secured({"ROLE_ADMIN"})
@ResponseBody
@PreAuthorize("hasAuthority('ROLE_ADMIN')") //方法进入前的权限验证
public String auth_admin(){
return "role_admin";
}
@GetMapping("/role/user")
// @Secured({"ROLE_ADMIN","ROLE_USER"})
@ResponseBody
@PostAuthorize("hasAnyAuthority('ROLE_ADMIN','ROLE_USER')") //方法执行后进行权限验证,使用较少,一般用于对返回值的验证
public String auth_user(){
return "role_user";
}
// @PostFilter 权限验证之后对数据进行过滤
// @PostFilter("filterObject.username == 'jack'")
// @PreFilter: 进入控制器之前对数据进行过滤
JSR-250实现限控制
JSR是Java Specification Requests的缩写,意思是Java 规范提案。是指向JCP(Java Community Process)提出新增一个标准化技术规范的正式请求。任何人都可以提交JSR,以向Java平台增添新的API和服务。JSR已成为Java界的一个重要标准。
针对安全配置上,JSR提供的注解
@RolesAllowed(value = {"ADMIN"})设定访问权限
@DenyAll :拒绝所有访问
@PermitAll: 允许所有访问
实现自动登录
使用remember-me实现
WebSecurityConfig中,添加remember-me,实现Spring Security自带的记住我功能
//security中配置记住我功能
http.authorizeRequests()
.and()
.rememberMe()
.key("xueyin") //添加生成token时的key
.tokenValiditySeconds(60) //设置过期时间 默认过期时间2周
;
在登录页面中添加记住我选框:
<form th:action="@{/login}" method="post">
<div>
<div>
<span>用户名:</span>
<span><input type="text" name="username"></span>
</div>
<div>
<span>密码:</span>
<span><input type="text" name="password"></span>
</div>
<div>
<span><input type="checkbox" name="remember-me"></span>
<span>记住我:</span>
</div>
<div>
<span><input type="submit" value="login"></span>
</div>
</div>
</form>
启动项目测试:
点击登录后关闭浏览器访问需授权才能请求的url,可看到此时不用登录即可实现自动跳转
登录成功后查看cookie信息,可以发现remember-me的cookie 信息
remember-me=JUU4JUFGJUI4JUU4JTkxJTlCOjE2MTg0NzM3NTA1NzA6NWRlYjUyZmY3NTJkZGVhZjE0NzA1ZGFmZjkxZDJjODc
Security默认的实现自动登录即根据此cookie实现的,解析此cookie,默认次用Base64算法实现的
Base64(username:expireTime:MD5(username:expireTime:password:secretKey))
@Test
void test(){
String s = new String(Base64.getDecoder().decode("JUU4JUFGJUI4JUU4JTkxJTlCOjE2MTg0NzM3NTA1NzA6NWRlYjUyZmY3NTJkZGVhZjE0NzA1ZGFmZjkxZDJjODc"));
System.out.println(s);
//%E8%AF%B8%E8%91%9B:1618473750570:5deb52ff752ddeaf14705daff91d2c87
}
在浏览器关闭后,并重新打开之后,用户再去访问,此时会携带着 cookie 中的 remember-me 到服务端,服务到拿到值之后,可以方便的计算出用户名和过期时间,再根据用户名查询到用户密码,然后通过 MD5 散列函数计算出散列值,再将计算出的散列值和浏览器传递来的散列值进行对比,就能确认这个令牌是否有效。
remember-me中token生成过程:
AbstractAuthenticationProcessingFilter#doFilter -> AbstractAuthenticationProcessingFilter#successfulAuthentication ->
AbstractRememberMeServices#loginSuccess ->
TokenBasedRememberMeServices#onLoginSuccess。
核心在于TokenBasedRememberMeServices中的onLoginSuccess方法
@Override
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
String username = retrieveUserName(successfulAuthentication);
String password = retrievePassword(successfulAuthentication);
if (!StringUtils.hasLength(password)) {
UserDetails user = getUserDetailsService().loadUserByUsername(username);
password = user.getPassword();
}
int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
long expiryTime = System.currentTimeMillis();
expiryTime += 1000L * (tokenLifetime < 0 ? TWO_WEEKS_S : tokenLifetime);
String signatureValue = makeTokenSignature(expiryTime, username, password);
setCookie(new String[] { username, Long.toString(expiryTime), signatureValue },
tokenLifetime, request, response);
}
protected String makeTokenSignature(long tokenExpiryTime, String username,
String password) {
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
MessageDigest digest;
digest = MessageDigest.getInstance("MD5");
return new String(Hex.encode(digest.digest(data.getBytes())));
}
- 首先从登录成功的 Authentication 中提取出用户名/密码。
- 由于登录成功之后,密码可能被擦除了,所以,如果一开始没有拿到密码,就再从 UserDetailsService 中重新加载用户并重新获取密码。
- 再接下来去获取令牌的有效期,令牌有效期默认就是两周。
- 再接下来调用 makeTokenSignature 方法去计算散列值,实际上就是根据 username、令牌有效期以及 password、key 一起计算一个散列值。如果我们没有自己去设置这个 key,默认是在 RememberMeConfigurer#getKey 方法中进行设置的,它的值是一个 UUID 字符串。
- 最后,将用户名、令牌有效期以及计算得到的散列值放入 Cookie 中。
RememberMe 功能实现的核心:
Spring Security 中提供了 RememberMeAuthenticationFilter 类专门用来做相关的事情,我们来看下 RememberMeAuthenticationFilter 的 doFilter 方法:
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
if (SecurityContextHolder.getContext().getAuthentication() == null) {
Authentication rememberMeAuth = rememberMeServices.autoLogin(request,
response);
if (rememberMeAuth != null) {
rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);
SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
onSuccessfulAuthentication(request, response, rememberMeAuth);
if (this.eventPublisher != null) {
eventPublisher
.publishEvent(new InteractiveAuthenticationSuccessEvent(
SecurityContextHolder.getContext()
.getAuthentication(), this.getClass()));
}
if (successHandler != null) {
successHandler.onAuthenticationSuccess(request, response,
rememberMeAuth);
return;
}
}
chain.doFilter(request, response);
}
else {
chain.doFilter(request, response);
}
}
其执行核心在于rememberMeServices.autoLogin,即如果无法从SecurityContextHolder获取登录用户实例(User),调用autoLogin进行登录处理,其核心在于获取cookie信息,对其信息进行解码,然后调用processAutoLoginCookie获取用户实例,获取用户名、过期时间,根据用户名获取到密码,再对其编码后与浏览器传递的cookie信息比对,进而判断出令牌是否有效,完成自动登录。。。。。。。
public final Authentication autoLogin(HttpServletRequest request,
HttpServletResponse response) {
String rememberMeCookie = extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
}
logger.debug("Remember-me cookie detected");
if (rememberMeCookie.length() == 0) {
logger.debug("Cookie was empty");
cancelCookie(request, response);
return null;
}
UserDetails user = null;
try {
String[] cookieTokens = decodeCookie(rememberMeCookie);
user = processAutoLoginCookie(cookieTokens, request, response);
userDetailsChecker.check(user);
logger.debug("Remember-me cookie accepted");
return createSuccessfulAuthentication(request, user);
}
catch (CookieTheftException cte) {
throw cte;
}
cancelCookie(request, response);
return null;
}
如果我们开启了 RememberMe 功能,最最核心的东西就是放在 cookie 中的令牌了,这个令牌突破了 session 的限制,即使服务器重启、即使浏览器关闭又重新打开,只要这个令牌没有过期,就能访问到数据。
一旦令牌丢失,别人就可以拿着这个令牌随意登录我们的系统了,这是一个非常危险的操作。
持久化令牌实现
Token是服务端生成的一串字符串,以作客户端进行请求的一个令牌,当第一次登录后,服务器生成一个Token便将此Token返回给客户端,以后客户端只需带上这个Token前来请求数据即可,无需再次带上用户名和密码。
使用Token的目的:Token的目的是为了减轻服务器的压力,减少频繁的查询数据库,使服务器更加健壮。
自动登录实现即:登录认证成功后将生成Token写入浏览器Cookie,同时也保存到数据库中,假如用户浏览器关闭,再次打开请求访问依然会携带cookie
持久化令牌:在自动登录基础上,新增校验,提高系统的安全性。在持久化令牌中,新增了两个经过 MD5 散列函数计算的校验参数,一个是 series,另一个是 token。其中,series 只有当用户在使用用户名/密码登录时,才会生成或者更新,而 token 只要有新的会话,就会重新生成。
持久化令牌的具体处理类在 PersistentTokenBasedRememberMeServices 中
而用来保存令牌的处理类则是 PersistentRememberMeToken,该类的定义也很简洁命令:
public class PersistentRememberMeToken {
private final String username;
private final String series;
private final String tokenValue;
private final Date date; //上次自動登錄时间
//......
使用:
首先我们需要一张表来记录令牌信息,这张表我们可以完全自定义,也可以使用系统默认提供的 JDBC 来操作,如果使用默认的 JDBC,即 JdbcTokenRepositoryImpl,我们可以来分析一下该类的定义:
public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements
PersistentTokenRepository {
// ~ Static fields/initializers
// =====================================================================================
/** Default SQL for creating the database table to store the tokens */
public static final String CREATE_TABLE_SQL = "create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, "
+ "token varchar(64) not null, last_used timestamp not null)";
/** The default SQL used by the <tt>getTokenBySeries</tt> query */
public static final String DEF_TOKEN_BY_SERIES_SQL = "select username,series,token,last_used from persistent_logins where series = ?";
/** The default SQL used by <tt>createNewToken</tt> */
public static final String DEF_INSERT_TOKEN_SQL = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";
/** The default SQL used by <tt>updateToken</tt> */
public static final String DEF_UPDATE_TOKEN_SQL = "update persistent_logins set token = ?, last_used = ? where series = ?";
/** The default SQL used by <tt>removeUserTokens</tt> */
public static final String DEF_REMOVE_USER_TOKENS_SQL = "delete from persistent_logins where username = ?";
在这里默认已经对表的创建、增删改查做了定义,所以可以利用其自动生成表,也可以手动创建。
表生成脚本:
CREATE TABLE `persistent_logins` (
`username` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
`series` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
`token` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
`last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
连接数据库,修改WebSecurityConfig配置类,
//将JdbcTokenRepositoryImpl注入 到容器中,以记录令牌信息
@Bean
public JdbcTokenRepositoryImpl jdbcTokenRepository(){
JdbcTokenRepositoryImpl repository = new JdbcTokenRepositoryImpl();
repository.setDataSource(dataSource);
return repository;
}
//configure(HttpSecurity http)方法中修改rememberme设置
http.authorizeRequests()
.and()
.rememberMe()
.key("xueyin")
.tokenRepository(jdbcTokenRepository()) //使用jdbcTokenRepository进行令牌操作
.tokenValiditySeconds(60)
;
测试:进行记住我登录测试,查看cookie信息以及数据库中信息即可验证是否持久化
CSRF
概念
跨站请求伪造(英语:Cross-site request forgery),也被称为 one-click attack 或者 session riding,通常缩写为 CSRF 或者 XSRF, 是一种挟制用户在当前已登录的Web应用程序上执行非本意的操作的攻击方法。跟跨网站脚本(XSS)相比,XSS 利用的是用户对指定网站的信任,CSRF 利用的是网站对用户网页浏览器的信任。
这是一种非常常见的 Web 攻击方式,其实是很好防御的,但是由于经常被很多开发者忽略,进而导致很多网站实际上都存在 CSRF 攻击的安全隐患。
例子
假如一家银行用以运行转账操作的URL地址如下:http://www.examplebank.com/withdraw?account=AccoutName&amount=1000&for=PayeeName
那么,一个恶意攻击者可以在另一个网站上放置如下代码:
如果有账户名为Alice的用户访问了恶意站点,而她之前刚访问过银行不久,登录信息尚未过期,那么她就会损失1000资金。
这种恶意的网址可以有很多种形式,藏身于网页中的许多地方。此外,攻击者也不需要控制放置恶意网址的网站。例如他可以将这种地址藏在论坛,博客等任何用户生成内容的网站中。这意味着如果服务端没有合适的防御措施的话,用户即使访问熟悉的可信网站也有受攻击的危险。
透过例子能够看出,攻击者并不能通过CSRF攻击来直接获取用户的账户控制权,也不能直接窃取用户的任何信息。他们能做到的,是欺骗用户浏览器,让其以用户的名义运行操作。
防御
Spring Security 中默认实际上就提供了 csrf 防御,即在请求发起时添加隐藏域,保存csrf的信息
hello.html: 注意请求格式 Security 针对 PATCH,POST,PUT 和 DELETE 方法进行防护
隐藏域的 key 是 ${_csrf.parameterName}
,value 则是 ${_csrf.token}
。
这两个值服务端会自动带过来,我们只需要在前端渲染出来即可。
同时,给前端页面适配一个控制器:
@GetMapping("/hello")
public String hello() {
return "hello";
}
给基于post的hello请求适配一个访问接口
@PostMapping("/hello")
@ResponseBody
public String hello(){
return "hello";
}
启动项目,访问hello时需要先进行登录验证,打开开发者模式,登录验证成功时查看传递的表单数据
在这可以看到自动生成的_csrf,点击hello请求时同样会自动携带_csrf,和保存的 csrfToken 做比较,进而判断当前请求是否合法。主要通过 CsrfFilter 过滤器来完成。
附:前后台分离中,一般将_csrf放到cookie中,只需在WebSecurityConfig配置如下
http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
withHttpOnlyFalse 方法获取了 CookieCsrfTokenRepository 的实例,该方法会设置 Cookie 中的 HttpOnly 属性为 false,也就是允许前端通过 js 操作 Cookie(否则你就没有办法获取到 _csrf
)
前端由异步实现,在异步中获取到cookie中的XSRF-TOKEN信息,进行异步交互时自动携带服务端进行比对校验即可。
Spring Security实现同一用户只能在一台设备登录
在同一个系统中,我们可能只允许一个用户在一个终端上登录,一般来说这可能是出于安全方面的考虑,但是也有一些情况是出于业务上的考虑,遇到的需求就是业务原因要求一个用户只能在一个设备上登录。
要实现一个用户不可以同时在两台设备上登录,我们有两种思路:
-
后来的登录自动踢掉前面的登录,就像大家在扣扣中看到的效果。
-
如果用户已经登录,则不允许后来者登录。
第一种方案中,只需要在WebSecurityConfig配置中开启session管理,并设置session最大数为1即可
WebSecurityConfig配置下:
//maximumSessions设置最多只能一人登录,默认后登录者会自动挤掉前登录者,maxSessionsPreventsLogin设置当前用户登录后,不能重复登录
http.sessionManagement()
.maximumSessions(1)
// .maxSessionsPreventsLogin(true)
;
第二种方案即不让后来者登录,只需配置中添加maxSessionsPreventsLogin,同时设置session监听即可
http.sessionManagement()
.maximumSessions(1)
.maxSessionsPreventsLogin(true)
;
//注册session事件监听者,以便实现限定一人登录,用于监听session
@Bean
HttpSessionEventPublisher eventPublisher(){
return new HttpSessionEventPublisher();
}
HttpSessionEventPublisher ,这个类实现了 HttpSessionListener 接口,在该 Bean 中,可以将 session 创建以及销毁的事件及时感知到,并且调用 Spring 中的事件机制将相关的创建和销毁事件发布出去,进而被 Spring Security 感知到
源码自行查看
RBAC权限模型
RBAC(Role-based access control)是一种以角色为基础的访问控制(Role-based access control,RBAC),它是一种较新且广为使用的权限控制机制,这种机制不是直接给用户赋予权限,而是将权限赋予角色。
RBAC 权限模型将用户按角色进行归类,通过用户的角色来确定用户对某项资源是否具备操作权限。RBAC 简化了用户与权限的管理,它将用户与角色关联、角色与权限关联、权限与资源关联,这种模式使得用户的授权管理变得非常简单和易于维护。
详见RBAC文档!!!
Spring Security下实现会话共享(redis)
集群模式下,session并发问题
在传统的单服务架构中,一般来说,只有一个服务器,那么不存在 Session 共享问题,但是在分布式/集群项目中,Session 共享则是一个必须面对的问题
例如客户端发起一个请求,这个请求到达 Nginx 上之后,被 Nginx 转发到 Tomcat A 上,然后在 Tomcat A 上往 session 中保存了一份数据,下次又来一个请求,这个请求被转发到 Tomcat B 上,此时再去 Session 中获取数据,发现没有之前的数据。
解决方案:
session 共享
将各个服务之间需要共享的数据,保存到一个公共的地方(主流方案就是 Redis):
当所有 Tomcat 需要往 Session 中写数据时,都往 Redis 中写,当所有 Tomcat 需要读数据时,都从 Redis 中读。这样,不同的服务就可以使用相同的 Session 数据了。
此时,开发者可以手动往redis中进行读写操作,或直接使用spring提供的Spring-session,Spring Session 就是使用 Spring 中的代理过滤器,将所有的 Session 操作拦截下来,自动的将数据 同步到 Redis 中,或者自动的从 Redis 中读取数据。
对于开发者来说,所有关于 Session 同步的操作都是透明的,开发者使用 Spring Session,一旦配置完成后,具体的用法就像使用一个普通的 Session 一样。
使用:
1、添加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--spring-session共享-->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
2、yml中添加redis基本配置
redis:
host: 127.0.0.1
port: 6379
database: 0
password: 123456
3、代码示例
@RestController
public class HelloController {
@Value("${server.port}")
Integer port;
@GetMapping("/set")
public String set(HttpSession session) {
session.setAttribute("user", "javaboy");
return String.valueOf(port);
}
@GetMapping("/get")
public String get(HttpSession session) {
return session.getAttribute("user") + ":" + port;
}
}
4、打包模拟测试
项目右键,run maven–>package打包后执行
java -jar demo-SNAPSHOT.jar --server.port=8080
java -jar demo-SNAPSHOT.jar --server.port=8081
在浏览器访问8080下的set,用户登录认证后查看redis中是否有信息。
访问8081下的get,查看是否获取session信息
O-1660888816999)]
当所有 Tomcat 需要往 Session 中写数据时,都往 Redis 中写,当所有 Tomcat 需要读数据时,都从 Redis 中读。这样,不同的服务就可以使用相同的 Session 数据了。
此时,开发者可以手动往redis中进行读写操作,或直接使用spring提供的Spring-session,Spring Session 就是使用 Spring 中的代理过滤器,将所有的 Session 操作拦截下来,自动的将数据 同步到 Redis 中,或者自动的从 Redis 中读取数据。
对于开发者来说,所有关于 Session 同步的操作都是透明的,开发者使用 Spring Session,一旦配置完成后,具体的用法就像使用一个普通的 Session 一样。
使用:
1、添加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--spring-session共享-->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
2、yml中添加redis基本配置
redis:
host: 127.0.0.1
port: 6379
database: 0
password: 123456
3、代码示例
@RestController
public class HelloController {
@Value("${server.port}")
Integer port;
@GetMapping("/set")
public String set(HttpSession session) {
session.setAttribute("user", "javaboy");
return String.valueOf(port);
}
@GetMapping("/get")
public String get(HttpSession session) {
return session.getAttribute("user") + ":" + port;
}
}
4、打包模拟测试
项目右键,run maven–>package打包后执行
java -jar demo-SNAPSHOT.jar --server.port=8080
java -jar demo-SNAPSHOT.jar --server.port=8081
在浏览器访问8080下的set,用户登录认证后查看redis中是否有信息。
访问8081下的get,查看是否获取session信息