自定义用户名的第三种方式

这种方式也是我们在开发过程中经常使用到的,可以用这种方式配合数据库来实现动态的访问控制

和第二种类似,同样是使用一个接口,但是这个方法需要我们传入一个userDetailService的实现类,这个需要我们手动进行创建,因为他是一个接口

@Configuration
public class SecurityConfig2 extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }
    @Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

实现类

@Service("userDetailsService")
public class MyUser implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        List<GrantedAuthority> auths = AuthorityUtils.commaSeparatedStringToAuthorityList("role");//添加角色
        return new User("lisi",new BCryptPasswordEncoder().encode("123"),auths);
    }
}

自定义登录页面

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //自定义登录页面
        http.formLogin()
                //自定义的那个登录页面
                .loginPage("/login.html")
                //登录有需要执行的controller
                .loginProcessingUrl("/user/login")
                //登录成功之后跳转的页面
                .defaultSuccessUrl("/test/index").permitAll()
                //添加权限
                .and().authorizeRequests()
                //配置不拦截的路径
                .antMatchers("/","/user/login","/test/login").permitAll()
                .anyRequest().authenticated()
                .and().csrf().disable();
    }

在上面那一个接口中同样给我们了一个同名的方法,但是参数不同,我们可以使用这个参数来进行自定义页面

下面是对应controller中的方法

@RestController
@RequestMapping("/test")
public class TestController {
    @GetMapping("login")
    public String helloSecurity(){
     return "hello security";
    }
    @GetMapping("index")
    public String index(){
        return "hello index";
    }
    }

当我们访问longin.html的时候就自动给我们跳转到了登录页面我们只需要输入设置的用户名和密码就可以进行访问了,需要注意的是我们需要将login.html加入到白名单中去,不然待会我们配置访问权限的时候就会在内部出现死循环,导致我们的程序不能正常的运行,登录访问的controller是springSecurity内部帮我进行实现的,所以,在配置的时候要严格的按照上面的进行书写,username和password也是一样,只要配置的不一样就会出错

访问授权

  1. 在进行登录的时候有一种需求,只允许特定的用户访问特定的页面,这个时候就需要我们进行权限的控制,权限控制的方式有四种
  • 表达式控制 URL 路径权限
  • 表达式控制方法权限
  • 使用过滤注解
  • 动态权限

首先我们来看第一种,就是通过表达式控制 URL 路径权限

Spring Security 支持在 URL 和方法权限控制时使用 SpEL 表达式,如果表达式返回值为 true 则表示需要对应的权限,否则表示不需要对应的权限。提供表达式的类是 SecurityExpressionRoot:

image-20220728194347758

可以看到,SecurityExpressionRoot 有两个实现类,表示在应对 URL 权限控制和应对方法权限控制时,分别对 SpEL 所做的拓展,例如在基于 URL 路径做权限控制时,增加了 hasIpAddress 选项。

我们来看下 SecurityExpressionRoot 类中定义的最基本的 SpEL 有哪些:

image-20220728194519811

这些都是用来设置权限的,某些常用的方法对应的说明如下

表达式 备注
hasRole 用户具备某个角色即可访问资源
hasAnyRole 用户具备多个角色中的任意一个即可访问资源
hasAuthority 类似于 hasRole
hasAnyAuthority 类似于 hasAnyRole
permitAll 统统允许访问
denyAll 统统拒绝访问
isAnonymous 判断是否匿名用户
isAuthenticated 判断是否认证成功
isRememberMe 判断是否通过记住我登录的
isFullyAuthenticated 判断是否用户名/密码登录的
principle 当前用户
authentication 从 SecurityContext 中提取出来的用户对象

使用URL对其进行权限控制我们只需要在protected void configure(HttpSecurity http) throws Exception()方法中使用上面对应的方法即可,例如:

image-20220728195148893

需要注意的是,我们应该提前将/user/login添加到白名单,不然会出错。通过设置以上规则之后我们就可以只允许特定的角色进行访问了,但是我们应该提前为controller添加对应的权限(角色)

在上面的userdetailservice实现类中,我们应该提前设置权限,或者角色

image-20220728195529841

注意因为底层对角色进行校验的时候会自动的加上ROLE前缀,所以,我们在设置角色的时候应该手动的加上前缀,而在设置权限的时候则不需要加上前缀

  1. 设置访问失败的页面,同样我们需要在配置类中配置失败之后跳转的页面,这样的话我们就可以在拒绝访问之后跳转自己想要跳转的页面了

     http.exceptionHandling().accessDeniedPage("/unAuth.html");
    

注解的使用

使用注解的方式进行权限控制,使用注解的步骤如下

  1. 在配置类,或者主类上面添加一个注解

    @EnableMethodSecurity(securedEnabled = true)
    

    告诉开启注解模式,这样的话我们就可以在controller上面使用

    @Secured({"ROLE_manager","admin"})
    

    注解的方式来进行权限的设置

  2. @PreAuthorize

    这个注解和上面那个功能类似,按照字面意思就是在该方法执行之前做验证,注解里面需要加入几个参数

    "hasAnyAuthority('admin')"
    

    可以第一种方法中的任何一个方法

  3. postAuthorize

    这个注解也要加和上面一样的参数,只不过这些验证都是在controller执行完毕之后才执行,如果设置了页面跳转等功能,这些功能是不会生效的,但是如果我们在控制台上面输出数据,这些数据还是会被执行

注意:使用后两种我们依然需要在主类或配置类中加上参数,告诉启用注解,例如:@EnableMethodSecurity(securedEnabled = true,prePostEnabled = true)

  1. 过滤

    Spring Security 中还有两个过滤函数 @PreFilter 和 @PostFilter,可以根据给出的条件,自动移除集合中的元素。

    @PostFilter("filterObject.lastIndexOf('2')!=-1")
    public List<String> getAllUser() {
        List<String> users = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            users.add("javaboy:" + i);
        }
        return users;
    }
    @PreFilter(filterTarget = "ages",value = "filterObject%2==0")
    public void getAllAge(List<Integer> ages,List<String> users) {
        System.out.println("ages = " + ages);
        System.out.println("users = " + users);
    }
  • 在 getAllUser 方法中,对集合进行过滤,只返回后缀为 2 的元素,filterObject 表示要过滤的元素对象。
  • 在 getAllAge 方法中,由于有两个集合,因此使用 filterTarget 指定过滤对象。

注销登录

当我们登录之后自然需要实现对应的登出功能,这个功能的实现依靠如下代码(在配置类中实现)

http.logout().logoutUrl("/logout").logoutSuccessUrl("/test/login").permitAll();

实现记住我

springSecurity给我们提供了一种便捷安全的记住我的方法,使用cookie和数据库进行实现

实现“记住我”功能,在用户登录一次以后,系统会记住用户一段时间,在这段时间,用户不用反复登录就可以使用系统。

  1. 前端改动
<input name="remember-me" type="checkbox"> 自动登录
  1. 创建persistent_logins表
CREATE TABLE `persistent_logins` (
  `username` varchar(64) NOT NULL,
  `series` varchar(64) NOT NULL,
  `token` varchar(64) NOT NULL,
  `last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
  1. 配置类

    配置类中注入 dataSource ,创建一个 PersistentTokenRepository 的Bean:

@Autowired
private DataSource dataSource;

 @Bean
 public PersistentTokenRepository persistentTokenRepository(){
     JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
     tokenRepository.setDataSource(dataSource);
     // 如果token表不存在,使用下面语句可以初始化该表;若存在,请注释掉这条语句,否则会报错。
//        tokenRepository.setCreateTableOnStartup(true);
     return tokenRepository;
 }

config(HttpSecurity http) 中按如下所示配置自动登陆:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            // 如果有允许匿名的url,填在下面
//                .antMatchers().permitAll()
            .anyRequest().authenticated()
            .and()
            // 设置登陆页
            .formLogin().loginPage("/login")
            // 设置登陆成功页
            .defaultSuccessUrl("/").permitAll()
            // 自定义登陆用户名和密码参数,默认为username和password
//                .usernameParameter("username")
//                .passwordParameter("password")
            .and()
            .logout().permitAll()
            // 自动登录
        .and().rememberMe()
             .tokenRepository(persistentTokenRepository())
             // 有效时间:单位s
             .tokenValiditySeconds(60)
             .userDetailsService(userDetailsService);
    // 关闭CSRF跨域
    http.csrf().disable();
}
  1. 运行程序

勾选自动登录后,浏览器Cookie数据库中均存储了token信息

重启服务,直接访问成功登录之后的页面,即可直接登录。

注意:浏览器中的cookies信息不能清空,清空便获取不了cookies这个token,就不能通过token查询数据库来获取用户信息,最后不能自动登录。

CSRF跨站请求伪造攻击

我们时常会在QQ上收到别人发送的钓鱼网站链接,只要你在上面登陆了你的QQ账号,那么不出意外,你的号已经在别人手中了。

实际上这一类网站都属于恶意网站,专门用于盗取他人信息,执行非法操作,甚至获取他人账户中的财产,非法转账等。而这里,我们需要了解一种比较容易发生的恶意操作,从不法分子的角度去了解整个流程。

我们在JavaWeb 阶段已经了解了Session和Cookie的机制,在一开始的时候,服务端会给浏览器一个名为JSESSION的Cookie信息作为会话的唯一凭据,只要用户携带此Cookie访问我们的网站,那么我们就可以认定此会话属于哪个浏览器。

因此,只要此会话的用户执行了登录操作,那么就可以随意访问个人信息等内容。

比如现在,我们的服务器新增了一个转账的接口,用户登录之后,只需要使用POST请求携带需要转账的金额和转账人访问此接口就可以进行转账操作:

@RequestMapping("/index")
public String index(HttpSession session){
    session.setAttribute("login", true);   //这里就正常访问一下index表示登陆
    return "index";
}
@RequestMapping(value = "/pay", method = RequestMethod.POST, produces = "text/html;charset=utf-8") //这里要设置一下produces不然会乱码
@ResponseBody
public String pay(String account,
                  int amount,
                  @SessionAttribute("login") Boolean isLogin){
    if (isLogin) return "成功转账 ¥"+amount+" 给:"+account;
    else return "转账失败,您没有登陆!";
}

那么,大家有没有想过这样一个问题,我们为了搜索学习资料时可能一不小心访问了一个恶意网站,而此网站携带了这样一段内容:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>我是(恶)学(意)习网站</title>
</head>
<body>
    <div>
        <div>对不起,您还没有充值本站的学习会员,请先充值后再观看学习视频</div>
        <form action="http://localhost:8080/mvc/pay" method="post">
            <input type="text" name="account" value="hacker" hidden>
            <input type="number" name="amount" value="666666" hidden>
            <input type="submit" value="点我充值会员,观看完整视频">
        </form>
    </div>
</body>
</html>

注意这个页面并不是我们官方提供的页面,而是不法分子搭建的恶意网站。

我们发现此页面中有一个表单,但是表单中的两个输入框被隐藏了,而我们看到的只有一个按钮,我们不知道这是一个表单,也不知道表单会提交给那个地址,这时整个页面就非常有迷惑性了。

如果我们点击此按钮,那么整个表单的数据会以POST的形式发送给我们的服务端(会携带之前登陆我们网站的Cookie信息),但是这里很明显是另一个网站跳转,通过这样的方式,恶意网站就成功地在我们毫不知情的情况下引导我们执行了转账操作,当你发现上当受骗时,钱已经被转走了。

而这种构建恶意页面,引导用户访问对应网站执行操作的方式称为: 跨站请求伪造 (CSRF,Cross Site Request Forgery)

为了防止上述情况的放生,出现了各种各样的手段去应对,springSecurity也给我们提供了一种机制去防止这种恶意行为

二、防止CSRF攻击
通过正常网站和非法网站发送的HTTP请求确切的来说是一样的,这就是为什么会出现CSRF攻击的原因。为了阻止CSRF攻击,我们需要保证在发送HTTP请求时附带上一些非法网站不能提供的信息以便区分是正常请求和非法请求。Spring提供了两种机制来预防CSRF攻击:

同步Token模式(Synchronizer Token Pattern)
在Session Cookie中指定SameSite Attribute
1、安全方法必须是幂等的
要想使以上两种防止CSRF攻击的方式生效,应用程序需要保证GET, HEAD, OPTIONS和TRACE类型的HTTP请求必须是幂等的(不能改变应用程序的状态)。

注意:幂等性是数学中的一个概念,表达的是N次变换与1次变换的结果相同

2、同步Token模式(Synchronizer Token Pattern)
同步Token模式是防止CSRF最有力的办法。在我们发送HTTP请求时,除了使用Session Cookie之外,还需要生成一个安全的随机CSRF Token放入HTTP请求中。当HTTP请求提交之后,服务器会对比预期CSRF Token和请求中的CSRF Token,如果值不匹配,HTTP请求就会被拒绝。我们可以将CSRF Token作为请求参数或是设置到HTTP header中 ,但是将CSRF Token设置到cookie中是不能有效的预防CSRF攻击,因为cookies会被浏览器自动发送。

不是所有的HTTP请求都需要附加传输CSRF Token,只有那些用于更新应用程序的HTTP请求需要使用CSRF Token。这样可以改善我们的网站环境,可以在我们的网站上使用外部链接而不被CSRF防御机制阻拦。不建议在GET请求中使用随机的token,因为GET请求很容易导致tokens泄露。

同步Token模式生效原因。假设在一个HTTP请求中需要使用一个名称为 _csrf的CSRF Token参数,提交表单如下:

<form method="post"
    action="/transfer">
<input type="hidden"
    name="_csrf"
    value="4bfd1575-3ad1-4d21-96c7-4ef2d9f86721"/>
<input type="text"
    name="amount"/>
<input type="text"
    name="routingNumber"/>
<input type="hidden"
    name="account"/>
<input type="submit"
    value="Transfer"/>
</form>

目前这个表单包含一个隐藏的CSRF Token文本域,外部的网站是无法读取到这个CSRF Token值的,同时同源策略(same origin policy)保证了非法网站不能读取到响应结果。

相应的转账报文如下:

POST /transfer HTTP/1.1
Host: bank.example.com
Cookie: JSESSIONID=randomid
Content-Type: application/x-www-form-urlencoded
amount=100.00&routingNumber=1234&account=9876&_csrf=4bfd1575-3ad1-4d21-96c7-4ef2d9f86721

现在HTTP请求包含了一个安全的随机参数csrf,非法网站没法正确提供这个_csrf参数值,所以在服务器比对的时候就能鉴别出正常访问和非法访问了。

3、SameSite Attribute
一种新型的防止CSRF攻击的方法是在Session Cookie中指定SameSite参数。服务器可以设定SameSite属性,保证Session Cookie不能被发送到外部网站。附带SameSite参数的HTTP响应头如下所示:

Set-Cookie: JSESSIONID=randomid; Domain=bank.example.com; Secure; HttpOnly; SameSite=Lax
注意:Spring Security不直接控制Session Cookie的创建,因此它不提供对SameSite属性的支持。Spring Session在基于servlet的应用程序中提供对SameSite属性的支持。

SameSite的有效值包括:

Strict:任何来自同一个网站的请求都会包含cookie,否则cookie将不会被包含在HTTP请求中。

Lax:来自同一个网站或请求来自顶级导航(top-level navigations )或请求方法是幂等的,这三种情况下请求都会包含cookie,否则cookie将不会被包含在HTTP请求中。

注意:top-level navigations

如果在Session Cookie中设置了SameSite属性,浏览器就不会向来自非法网站的转发请求(transfer request)发送JSESSIONID了。如果非法网站的转发请求中不会被设置session,那么应用就可以阻止CSRF攻击了。当使用SameSite时,有以下一些需要注意的重要事项:

如果将SameSite属性设置成Strict,这确实提供了一种强力的防御措施,但是同时也会给用户带来困扰。细想,一个用户登录一个社交网站,主机地址为https://social.example.com。这个用户从https://email.example.org收到一份电子邮件,在这个电子邮件中包含一个指向社交网站的连接,如果用户点击这个连接,他所期望的是能够正当地跳转到社交网站页面。然而,一旦将SameSite属性设置成Strict,Session Cookie就不会被发送,那么用户就不会被授权成功。

另外一个需要考虑的事情就是,浏览器必须要支持SameSite属性。很多现在的新版浏览器是支持这个属性的,但是有一些旧版的浏览器是不支持的。

所以,使用SameSite作为CSRF攻击的深度防御机制,而不是作为CSRF防御的唯一机制。

三、什么时候使用CSRF防护机制
我们建议在任何需要客户使用浏览器操作的请求都需要使用CSRF防护机制。如果你仅仅是创建一个服务不使用浏览器客户端,那么可能就不需要使用CSRF防护了。

1、CSRF防护和JSON
“我们需不需要保护使用javascript发送的JSON请求呢?”,这需要根据具体情况来确定。你需要注意的是,还是有一些CSRF漏洞会被用于JSON请求的。比如恶意用户可以使用以下的JSON发起CSRF:

<form action="https://bank.example.com/transfer" method="post" enctype="text/plain">
    <input name='{"amount":100,"routingNumber":"evilsRoutingNumber","account":"evilsAccountNumber", "ignore_me":"' value='test"}' type='hidden'>
    <input type="submit"
        value="Win Money!"/>
</form>

这个会生成以下JSON格式数据:

{ "amount": 100,
"routingNumber": "evilsRoutingNumber",
"account": "evilsAccountNumber",
"ignore_me": "=test"
}

如果一个应用程序没有验证Content-Type,那么它就会暴露出这个漏洞。即使Spring MVC应用程序会校验Content-Type,同样可以通过修改URL后缀来利用漏洞,比如将URL改为以.json结尾:

<form action="https://bank.example.com/transfer.json" method="post" enctype="text/plain">
    <input name='{"amount":100,"routingNumber":"evilsRoutingNumber","account":"evilsAccountNumber", "ignore_me":"' value='test"}' type='hidden'>
    <input type="submit"
        value="Win Money!"/>
</form>

2、CSRF和无状态浏览器应用程序
如果我的应用程序是无状态的呢?这并不一定意味着你受到了保护。事实上,如果用户不需要在web浏览器中对给定的请求执行任何操作,他们仍然可能容易受到CSRF攻击。

例如,考虑使用自定义cookie(其中包含用于身份验证的所有状态,而不是JSESSIONID)的应用程序。当进行CSRF攻击时,自定义cookie将与请求一起发送,发送方式与我们前面的示例中发送JSESSIONID cookie的方式相同。此应用程序容易受到CSRF攻击。

使用基本身份验证(basic authentication)的应用程序也容易受到CSRF攻击。应用程序很容易受到攻击,因为浏览器会在任何请求中自动包含用户名和密码,其方式与上一个示例中发送JSESSIONID cookie的方式相同。

四、CSRF注意事项
当你需要实现CSRF防御时,有以下几个注意事项:

1、登录和登出
为了防止伪造登录(退出)请求,登录(退出)HTTP请求应该被保护免受CSRF攻击。防止伪造登录(退出)请求是必要的,这样恶意用户就无法读取受害者的敏感信息。攻击方式如下:

非法用户使用非法凭证执行CSRF登录,导致正常用户被认证为恶意用户
然后,非法用户欺骗正常用户访问被非法网站并输入敏感信息
这些敏感信息与非法用户的帐户相关联,因此非法用户可以使用自己的凭据登录并查看受害者的敏感信息
要确保登录(退出)HTTP请求免受CSRF攻击,一个可能的复杂情况是,用户可能会经历会话超时,导致请求被拒绝。

2、CSRF和Session超时
通常情况下,预期的CSRF Token存储在会话中。这意味着一旦会话过期,服务器就不会找到预期的CSRF令牌并拒绝HTTP请求。有许多解决超时的选项,每个选项都有利弊

减轻超时的最好方法是在表单提交时使用JavaScript请求CSRF Token,然后使用CSRF Token更新表单并提交
使用JavaScript,让用户知道他们的会话即将到期,用户可以单击按钮继续并刷新会话
预期的CSRF Token可以存储在一个cookie中,这可以使得CSRF Token比会话更持久
3、Multipart (文件上传)
为了防止CSRF攻击的发生,必须读取HTTP Request Body 以获得实际的CSRF Token。然而,读取Request Body意味着文件将被上传,这意味着外部站点可以上传文件。下面有两种选择用于阻止multipart/form-data请求时出现CSRF攻击:

将CSRF Token放在Request Body

将CSRF Token放在Request Body中,在执行鉴权之前读取CSRF Token,只有经过授权的用户才能提交由服务器处理的文件,其他用户只能上传临时文件,临时文件上传对大多数服务器的影响可以忽略不计。

将CSRF Token放在URL中

如果不允许未经授权的用户上传临时文件,那么另一种方法是在表单的action属性中包含CSRF Token作为查询参数。这种方法的缺点是查询参数可能被泄漏。最好的办法是将敏感数据放置在Request Body或Headers中,以确保其不泄漏。

4、HiddenHttpMethodFilter
在某些应用程序中,可以使用表单参数来覆盖HTTP方法。例如,可以使用下面的表单将HTTP方法视为delete而不是post。
浏览器form表单只支持GET与POST请求,而DELETE、PUT等method并不支持,Spring3.0添加了一个过滤器,可以将这些请求转换为标准的http方法,使得支持GET、POST、PUT与DELETE请求,对应的读取_method值:

package org.springframework.web.filter;

import java.io.IOException;
import java.util.Locale;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import javax.servlet.http.HttpServletResponse;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.web.util.WebUtils;

public class HiddenHttpMethodFilter extends OncePerRequestFilter {
public static final String DEFAULT_METHOD_PARAM = "_method";

private String methodParam = DEFAULT_METHOD_PARAM;

public void setMethodParam(String methodParam) {
	Assert.hasText(methodParam, "'methodParam' must not be empty");
	this.methodParam = methodParam;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
		throws ServletException, IOException {

	HttpServletRequest requestToUse = request;

	if ("POST".equals(request.getMethod()) && request.getAttribute(WebUtils.ERROR_EXCEPTION_ATTRIBUTE) == null) {
		String paramValue = request.getParameter(this.methodParam);
		if (StringUtils.hasLength(paramValue)) {
			requestToUse = new HttpMethodRequestWrapper(request, paramValue);
		}
	}

	filterChain.doFilter(requestToUse, response);
}

private static class HttpMethodRequestWrapper extends HttpServletRequestWrapper {

	private final String method;

	public HttpMethodRequestWrapper(HttpServletRequest request, String method) {
		super(request);
		this.method = method.toUpperCase(Locale.ENGLISH);
	}

	@Override
	public String getMethod() {
		return this.method;
	}
}
}