SpringSecurity应用
pring Security 功能简介
- 认证:用户登录,两种认证方式:httpBasic、formLogin
- 授权:判断用户权限,可以访问什么资源
- 安全防护,防止跨站请求,session攻击等。
应用场景:
- 登录
- 授权
- 单一登录,一个账户同一时间只能在一个地方登录
- 集成cas,单点登录
- 集成oauth2,可以做第三方登录
基础入门
- 引入依赖:
<!--添加Spring Security 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
- 再写一个简单的controller
@RestController
public class HelloController {
@RequestMapping("/hello")
public String hello(){
return "hello security";
}
}
- 访问http://localhost:8080/hello,发现自动跳到了登录页面。
- 进行登录,默认用户名:user,密码看启动日志
Using generated security password: bbb788d7-13aa-4e6d-9dc6-b7923a27e6a9
SpringSecurity认证基本原理
在使用SpringSecurity框架,该框架会默认自动地替我们将系统中的资源进行保护,每次访问资源的
时候都必须经过一层身份的校验,如果通过了则重定向到我们输入的url中,否则访问是要被拒绝的。那
么SpringSecurity框架是如何实现的呢? Spring Security功能的实现主要是由一系列过滤器相互配合完
成。也称之为过滤器链
- org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter
根据请求封装获取WebAsyncManager,从WebAsyncManager获取/注册的安全上下文可调
用处理拦截器
- org.springframework.security.web.context.SecurityContextPersistenceFilter
SecurityContextPersistenceFilter主要是使用SecurityContextRepository在session中保存
或更新一个SecurityContext,并将SecurityContext给以后的过滤器使用,来为后续fifilter
建立所需的上下文。SecurityContext中存储了当前用户的认证以及权限信息。
- org.springframework.security.web.header.HeaderWriterFilter
向请求的Header中添加相应的信息,可在http标签内部使用security:headers来控制
- org.springframework.security.web.csrf.CsrfFilter
csrf又称跨域请求伪造,SpringSecurity会对所有post请求验证是否包含系统生成的csrf的
token信息,如果不包含,则报错。起到防止csrf攻击的效果。
- org.springframework.security.web.authentication.logout.LogoutFilter
匹配URL为/logout的请求,实现用户退出,清除认证信息。
- org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
表单认证操作全靠这个过滤器,默认匹配URL为/login且必须为POST请求。
- org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter
如果没有在配置文件中指定认证页面,则由该过滤器生成一个默认认证页面。
- org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter
由此过滤器可以生产一个默认的退出登录页面
- org.springframework.security.web.authentication.www.BasicAuthenticationFilter
此过滤器会自动解析HTTP请求中头部名字为Authentication,且以Basic开头的头信息。
- org.springframework.security.web.savedrequest.RequestCacheAwareFilter
通过HttpSessionRequestCache内部维护了一个RequestCache,用于缓存
HttpServletRequest
- org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
针对ServletRequest进行了一次包装,使得request具有更加丰富的API
- org.springframework.security.web.authentication.AnonymousAuthenticationFilter
当SecurityContextHolder中认证信息为空,则会创建一个匿名用户存入到
SecurityContextHolder中。spring security为了兼容未登录的访问,也走了一套认证流程,
只不过是一个匿名的身份。
- org.springframework.security.web.session.SessionManagementFilter
securityContextRepository限制同一用户开启多个会话的数量
- org.springframework.security.web.access.ExceptionTranslationFilter
异常转换过滤器位于整个springSecurityFilterChain的后方,用来转换整个链路中出现的异
常
- org.springframework.security.web.access.intercept.FilterSecurityInterceptor
获取所配置资源访问的授权信息,根据SecurityContextHolder中存储的用户信息来决定其
是否有权限。
表单认证
3.1 自定义表单登录页
在config包下编写SecurityConfiguration配置类
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
public void configure(WebSecurity web) throws Exception {
//对静态资源放行
web.ignoring().antMatchers("/css/**", "/images/**", "/js/**",
"/favicon.ico");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin() //开启表单认证
.loginPage("/toLoginPage")
.and().authorizeRequests()
.antMatchers("/toLoginPage").permitAll() //登录请求不需要认证
.anyRequest().authenticated(); //所有请求都需要认证
}
}
访问http://localhost:8080/,会自动跳转自定义的登录页面。
这时的登录页面的表单的请求参数必须得跟springSecurity默认的一致才行,那么可以自定义吗?答案是肯定的。
默认值:
自定义参数代码:
protected void configure(HttpSecurity http) throws Exception {
http.formLogin() //开启表单认证
.loginPage("/toLoginPage")
.loginProcessingUrl("/login") //登录处理url
.usernameParameter("username") //自定义用户名参数
.passwordParameter("password")//自定义密码参数
.defaultSuccessUrl("/") //登录成功跳转路径
.and().authorizeRequests()
.antMatchers("/toLoginPage").permitAll() //登录请求不需要认证
.anyRequest().authenticated(); //所有请求都需要认证
http.csrf().disable();
}
3.2 基于数据库实现认证功能
之前我们所使用的用户名和密码是来源于框架自动生成的, 那么我们如何实现基于数据库中的用户名和
密码功能呢? 要实现这个得需要实现security的一个UserDetailsService接口, 重写这个接口里面
loadUserByUsername即可
- 编写MyUserDetailsService并实现UserDetailsService接口,重写loadUserByUsername方法
@Service
public class MyUserDetailsService implements UserDetailsService {
@Autowired
private UserService userService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userService.findByName(username);
if(user==null){
throw new UsernameNotFoundException(username);
}
// 先声明一个权限集合, 因为构造方法里面不能传入null
Collection<? extends GrantedAuthority> authorities = new ArrayList<>();
UserDetails userDetails=new org.springframework.security.core.userdetails.User(username,"{noop}"+user.getPassword()
,true,true,true,true,authorities);
return userDetails;
}
}
在上面new UserDetails里面的{noop}代表的是加密方式为:不加密
2. 在SecurityConfiguration配置类中指定自定义用户认证
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
private MyUserDetailsService myUserDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(myUserDetailsService);
}
//省略部分代码...
}
3.3 数据库用户密码加密认证
Spring Security 中 PasswordEncoder 就是我们对密码进行编码的工具接口。该接口只有两个功能:
一个是匹配验证。另一个是密码编码。一般我们常用的算法是BCrypt算法。
BCrypt算法介绍
任何应用考虑到安全,绝不能明文的方式保存密码。密码应该通过哈希算法进行加密。 有很
多标准的算法比如SHA或者MD5,结合salt(盐)是一个不错的选择。 Spring Security 提供了
BCryptPasswordEncoder类,实现Spring的PasswordEncoder接口使用BCrypt强哈希方法来加密
密码。BCrypt强哈希方法每次加密的结果都不一样,所以更加的安全。
bcrypt加密后的字符串形如:
$2a$10$wouq9P/HNgvYj2jKtUN8rOJJNRVCWvn1XoWy55N3sCkEHZPo3lyWq
其中$是分割符,无意义;2a是bcrypt加密版本号;10是const的值;而后的前22位是salt值;再
然后的字符串就是密码的密文了;这里的const值即生成salt的迭代次数,默认值是10,推荐值12。
- 修改loadUserByUsername方法里的加密方式
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userService.findByName(username);
if(user==null){
throw new UsernameNotFoundException(username);
}
// 先声明一个权限集合, 因为构造方法里面不能传入null
Collection<? extends GrantedAuthority> authorities = new ArrayList<>();
UserDetails userDetails=new org.springframework.security.core.userdetails.User(username,"{bcrypt}"+user.getPassword()
,true,true,true,true,authorities);
return userDetails;
}
- 修改数据库里的密码为加密格式
加密代码如下:
BCryptPasswordEncoder encoder=new BCryptPasswordEncoder();
String encode = encoder.encode("123456");
3.4 获取当前登录用户
在传统web系统中, 我们将登录成功的用户放入session中, 在需要的时候可以从session中获取用户,那么Spring Security中我们如何获取当前已经登录的用户呢?
方式1:SecurityContextHolder
UserDetails principal = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
方式2:Authentication
@RequestMapping("/getUser2")
public UserDetails getUser2(Authentication authentication){
UserDetails principal = (UserDetails) authentication.getPrincipal();
return principal;
}
方式3:@AuthenticationPrincipal
@RequestMapping("/getUser3")
public UserDetails getUser3(@AuthenticationPrincipal UserDetails userDetails){
return userDetails;
}
3.5 remember me功能
在大多数网站中,都会实现RememberMe这个功能,方便用户在下一次登录时直接登录,避免再次输入用户名以及密码去登录,Spring Security针对这个功能已经帮助我们实现, 下面我们来看下他的原理图.
简单token方式
Token=MD5(username+分隔符+expiryTime+分隔符+password)
注意:这种方式不推荐使用,是将用户密码信息存在前端浏览器的cookie中,不安全。
实现方式:
- 前端页面需要增加remember-me的复选框
<div class="form-group">
<div >
<!--记住我 name为remember-me value值可选true yes 1 on 都行-->
<input type="checkbox" name="remember-me" value="true"/>记住我
</div>
</div>
- 后端代码开启remember-me功能
.and().rememberMe() //开启remeberMe功能
.tokenValiditySeconds(60*60) //token失效时间
.rememberMeParameter("remember-me") //自定义表单名称
- 验证,登录成功后查看cookie
登录成功后,关掉浏览器,再次访问,也不需要登录了。
持久化的Token生成方式
token: 随机生成策略,每次访问都会重新生成
series: 登录序列号,随机生成策略。用户输入用户名和密码登录时,该值重新生成。使用remember-me功能,该值保持不变
expiryTime: token过期时间。
CookieValue=encode(series+token)
- 后台代码
@Bean
public PersistentTokenRepository persistentTokenRepository(){
JdbcTokenRepositoryImpl tokenRepository=new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
// 启动时创建一张表, 第一次启动的时候创建, 第二次启动的时候需要注释掉, 否则
// 会报错
tokenRepository.setCreateTableOnStartup(true);
return tokenRepository;
}
注意: tokenRepository.setCreateTableOnStartup(true); 第一次设置为true,后面启动需要设置为false
- 配置持久化token
.and().rememberMe() //开启remeberMe功能
.tokenValiditySeconds(60*60) //token失效时间
.rememberMeParameter("remember-me") //自定义表单名称
.tokenRepository(persistentTokenRepository()) //设置tokenRepository
- 登录成功,查看数据
这两种方式都是依赖cookie的,如果cookie被窃取,会有安全问题。不需要登录,将cookie拷贝到postman里面就能够直接调用接口。
对重要的接口我们需要处理,处理方法如下:
public String hello(){
//获取认证信息
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
//判断如果认证信息是来源于remember-me就拦截
if(RememberMeAuthenticationToken.class.isAssignableFrom(authentication.getClass())){
throw new RememberMeAuthenticationException("请重写登录");
}
return "hello security";
}
3.6 自定义登录成功或失败
有些时候登录成功或失败后需要做一些后续操作,比如日志收集,发送请求等。
自定义成功处理
实现AuthenticationSuccessHandler接口,并重写onAnthenticationSuccesss()方法
自定义失败处理
实现AuthenticationFailureHandler接口,并重写onAuthenticationFailure()方法
- 登录处理类
@Service
public class LoginHandler implements AuthenticationSuccessHandler,AuthenticationFailureHandler {
private RedirectStrategy redirectStrategy = new
DefaultRedirectStrategy();
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
System.out.println("登录失败");
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println("登录成功");
redirectStrategy.sendRedirect(request,response,"/");
}
}
- 配置登录处理
.successHandler(loginHandler)
.failureHandler(loginHandler)
完整配置如下:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin() //开启表单认证
.loginPage("/toLoginPage")
.loginProcessingUrl("/login") //登录处理url
.usernameParameter("username") //自定义用户名参数
.passwordParameter("password")//自定义密码参数
.defaultSuccessUrl("/") //登录成功跳转路径
.successForwardUrl("/")
.successHandler(loginHandler)
.failureHandler(loginHandler)
.and().rememberMe() //开启remeberMe功能
.tokenValiditySeconds(60*60) //token失效时间
.rememberMeParameter("remember-me") //自定义表单名称
.tokenRepository(persistentTokenRepository()) //设置tokenRepository
.and().authorizeRequests()
.antMatchers("/toLoginPage").permitAll() //登录请求不需要认证
.anyRequest().authenticated(); //所有请求都需要认证
http.csrf().disable();
// 允许iframe加载页面
http.headers().frameOptions().sameOrigin();
}
异步登录
- 前端页面改造
<form id="formLogin" action="/login" method="post">
<div class="panel loginbox">
.....
<div style="padding:30px;">
<input type="button" onclick="login()"
class="button button-block bg-main text-
big input-big" value="登录">
</div>
</div>
</form>
</div>
</div>
</div>
<script>
function login() {
$.ajax({
type: "POST",//方法类型
dataType: "json",//服务器预期返回类型
url: "/login", // 登录url
data: $("#formLogin").serialize(),
success: function (data) {
console.log(data)
if (data.code == 200) {
window.location.href = "/";
} else {
alert(data.message);
}
}
});
}
</script>
- 后端改造
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println("登录成功");
Map result = new HashMap();
result.put("code", HttpStatus.OK.value());// 设置响应码
result.put("message", "登录成功");// 设置响应信息
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(result));
}
3.7 退出登录
只需要发送请求,请求路径为/logout即可, 当然这个路径也可以自行在配置类中自行指定, 同时退出
操作也有对应的自定义处理LogoutSuccessHandler,退出登录成功后执行,退出的同时如果有remember-me的数据,同时一并删除
- 前端页面
<a class="button button-little bg-red" href="/logout">
<span class="icon-power-off"></span>退出登录</a></div>
- 后端配置
实现LogoutSuccessHandler接口
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println("退出登录后续处理");
}
.and().logout().logoutUrl("/logout") //设置退出登录url
.logoutSuccessHandler(loginHandler) //自定义退出处理
图形验证码
- 验证码图片生成代码
@RequestMapping("/image/code")
public void imageCode(HttpServletResponse response) throws IOException {
ImageCode imageCode = createImageCode();
BufferedImage image = imageCode.getImage();
response.setContentType("image/jpeg");
ImageIO.write(image,"jpeg",response.getOutputStream());
}
private ImageCode createImageCode() {
int width = 100; // 验证码图片宽度
int height = 36; // 验证码图片长度
int length = 4; // 验证码位数
//创建一个带缓冲区图像对象
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
//获得在图像上绘图的Graphics对象
Graphics g = image.getGraphics();
Random random = new Random();
//设置颜色、并随机绘制直线
g.setColor(getRandColor(200, 250));
g.fillRect(0, 0, width, height);
g.setFont(new Font("宋体", Font.ITALIC, 20));
g.setColor(getRandColor(160, 200));
for (int i = 0; i < 155; i++) {
int x = random.nextInt(width);
int y = random.nextInt(height);
int xl = random.nextInt(12);
int yl = random.nextInt(12);
g.drawLine(x, y, x + xl, y + yl);
}
//生成随机数 并绘制
StringBuilder sRand = new StringBuilder();
for (int i = 0; i < length; i++) {
String rand = String.valueOf(random.nextInt(10));
sRand.append(rand);
g.setColor(new Color(20 + random.nextInt(110), 20 + random.nextInt(110), 20 + random.nextInt(110)));
g.drawString(rand, 13 * i + 6, 16);
}
g.dispose();
return new ImageCode(image, sRand.toString());
}
private Color getRandColor(int fc, int bc) {
Random random = new Random();
if (fc > 255) {
fc = 255;
}
if (bc > 255) {
bc = 255;
}
int r = fc + random.nextInt(bc - fc);
int g = fc + random.nextInt(bc - fc);
int b = fc + random.nextInt(bc - fc);
return new Color(r, g, b);
}
- 校验验证码是否正确
Spring Security的认证校验是由UsernamePasswordAuthenticationFilter过滤器完成的,所以我们的验证码校验逻辑应该在这个过滤器之前。验证码通过后才能到后续的操作.
实现OncePerRequestFilter接口,写过滤其代码校验验证码。代码略
- 配置过滤器
将我们的过滤器配置在UsernamePassword校验前
http.addFilterBefore(validateCodeFilter,
UsernamePasswordAuthenticationFilter.class);
Session管理
5.1 会话超时
- 配置session会话超时时间,默认30分钟
server.servlet.session.timeout=60s
注意:设置低于1分钟不起效
- 自定义设置session超时后跳转地址
http.sessionManagement() //设置session管理
.invalidSessionUrl("/toLoginPage");
5.2 并发控制
并发控制即同一个账号同时在线个数,同一个账号同时在线个数如果设置为1表示,该账号在同一时间内只能有一个有效的登录,如果同一个账号又在其它地方登录,那么就将上次登录的会话过期,即后面的登录会踢掉前面的登录
http.sessionManagement() //设置session管理
.invalidSessionUrl("/toLoginPage") //session无效后跳转路径
.maximumSessions(1) //设置session最大会话数量,1代表同一时间只能一个用户登录
.expiredUrl("/toLoginPage"); //session过期后跳转路径
阻止用户二次登录
sessionManagement也可以配置 maxSessionsPreventsLogin的值,当达到maximumSessions设置的最大会话个数时阻止登录。
http.sessionManagement() //设置session管理
.invalidSessionUrl("/toLoginPage") //session无效后跳转路径
.maximumSessions(1) //设置session最大会话数量,1代表同一时间只能一个用户登录
.maxSessionsPreventsLogin(true) //达到最大会话时阻止登录
.expiredUrl("/toLoginPage"); //session过期后跳转路径
5.3 集群session
问题描述:
实际场景中一个服务会至少有两台服务器在提供服务,在服务器前面会有一个nginx做负载均衡,
用户访问nginx,nginx再决定去访问哪一台服务器。当一台服务宕机了之后,另一台服务器也可以继续
提供服务,保证服务不中断。如果我们将session保存在Web容器(比如tomcat)中,如果一个用户第一
次访问被分配到服务器1上面需要登录,当某些访问突然被分配到服务器二上,因为服务器二上没有用
户在服务器一上登录的会话session信息,服务器二还会再次让用户登录,用户已经登录了还让登录就
感觉不正常了。
解决这个问题有一个方案是将session共享,可以存在单独的地方(redis、数据库、mongodb等)。
- 引入依赖
<!-- 基于redis实现session共享 -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
- 设置session存储类型
#使用redis共享session
spring.session.store-type=redis
csrf防护机制
什么是csrf?
CSRF(Cross-site request forgery),中文名称:跨站请求伪造
从上图可以看出,要完成一次CSRF攻击,受害者必须依次完成三个步骤:
- 登录一个受信任网站A,并生成本地Cookie
- 在不登出A的情况下,访问危险网站B
- 网站B是个黑客做的网站,他里面有些链接和按钮会让用户调用A网站的接口去做一些对你不利的操作。
csrf的防御策略
- 验证http referer
接收到请求的时候判断是不是由自己网站发来的
2. 在请求地址中添加tokon并验证
黑客之所以能完全伪造用户的请求,是因为验证用户的信息放在cookie里的,所以他可以利用你的cookie来通过安全校验。要抵御csrf,只要在请求里加入黑客不能伪造的信息就可以了。
比如在请求参数中加入一个token,并且在服务端建一个拦截器来校验这个token。这个token是服务器给前端的,黑客在别的网站上拿不到,这样就可以避免csrf攻击了。
3. 在http头中自定义属性并验证
这种方法也是使用token来验证,和上一种方法不同的是是将token放在http头中。
security防御csrf
Security依靠org.springframework.security.web.csrf.CsrfFilter拦截器来进行验证token。
- 页面发请求时增加token值
<div>
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
</div>
- 后端(默认是开启防护的,如某接口不需要防护可以配置)
//哪些接口不做csrf防护
http.csrf().ignoringAntMatchers("/user/save");
跨域
跨域,实际上是浏览器的一种保护处理,如果产生了跨域,服务器在返回结果时就会被浏览器拦截。
两个网站端口不同,域名不同,协议不同都会被认为是跨域。
解决跨域
- JSONP
浏览器允许一些带src属性的标签跨域,也就是在某些标签的src属性上写url地址是不会产生跨
域问题
2. CORS解决跨域
CORS是一个W3C标准,全称是"跨域资源共享"(Cross-origin resource sharing)。CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10。浏览器在发起真正的请求之前,会发起一个OPTIONS类型的预检请求,用于请求服务器是否允许跨域,在得到许可的情况下才会发起请求。
Security处理跨域
- 配置cors
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
// 设置允许跨域的站点
corsConfiguration.addAllowedOrigin("*");
// 设置允许跨域的http方法
corsConfiguration.addAllowedMethod("*");
// 设置允许跨域的请求头
corsConfiguration.addAllowedHeader("*");
// 允许带凭证
corsConfiguration.setAllowCredentials(true);
// 对所有的url生效
UrlBasedCorsConfigurationSource source = new
UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfiguration);
return source;
}
http.cors().configurationSource(corsConfigurationSource());
再发请求,成功了