同系列文章导读:【流行框架】文章导读

所有文章均在本博客首发,其他平台同步更新

如有问题,欢迎指正(评论区留言即可)

发表评论时请填写正确邮箱,以便于接收通知【推荐QQ邮箱】


SpringSecurity

简介

与SpringSecurity类似的还有Shiro,也提供了很多安全功能,上手简单,但是目前一线互联网大型项目使用SpringSecurity更多

认证 鉴权

  • 认证:验证当前访问的用户是否是本系统中的用户,也就是判断用户能否访问该系统,需要确定是哪一个具体的用户。用户认证一般要求用户提供用户名和密码
  • 鉴权:经过认证,判断当前认证的用户是否拥有执行某个操作的权限。在一个系统中,不同用户所具有的权限是不同的。比如对一个文件来说,有的用户只能进行读取,而有的用户可以进行修改。一般来说,系统会为不同的用户分配不同的角色,而每个角色对应一系列的权限

所以说,一般的安全框架当中,基本都会有认证和鉴权两个核心功能

同款产品对比

Spring Security

Spring 技术栈的组成部分,通过提供完整可扩展的认证和授权支持保护你的应用程序

特点

  • 和 Spring 无缝整合
  • 全面的权限控制
  • 专门为 Web 开发而设计

    • 旧版本不能脱离 Web 环境使用
    • 新版本对整个框架进行了分层抽取,分成了核心模块和 Web 模块。单独引入核心模块就可以脱离 Web 环境
  • 重量级

Shiro

Apache 旗下的轻量级权限控制框架。

特点

  • 轻量级。Shiro 主张的理念是把复杂的事情变简单。针对对性能有更高要求的互联网应用有更好表现。
  • 通用性

    • 好处:不局限于 Web 环境,可以脱离 Web 环境使用。
    • 缺陷:在 Web 环境下一些特定的需求需要手动编写代码定制。

Spring Security 是 Spring 家族中的一个安全管理框架,实际上,在 Spring Boot 出现之前,Spring Security 就已经发展了多年了,但是使用的并不多,安全管理这个领域,一直是 Shiro 的天下

相对于 Shiro,在 SSM 中整合 Spring Security 都是比较麻烦的操作,所以,Spring Security 虽然功能比 Shiro 强大,但是使用反而没有 Shiro 多(Shiro 虽然功能没有Spring Security 多,但是对于大部分项目而言,Shiro 也够用了)

自从有了 Spring Boot 之后,Spring Boot 对于 Spring Security 提供了自动化配置方案,可以使用更少的配置来使用 Spring Security

因此,一般来说,常见的安全管理技术栈的组合是这样的:

  • SSM + Shiro
  • Spring Boot/Spring Cloud + Spring Security
以上只是一个推荐的组合而已,如果单纯从技术上来说,无论怎么组合,都是可以运行的

入门案例

  1. 创建SpringBoot工程
  2. 创建测试controller进行启动测试

    @RestController
    @RequestMapping("/demo")
    public class DemoController {
        @GetMapping("/hello")
        public String hello(){
            return "Hello Spring Security";
        }
    }
  3. 引入SpringSecurity起步依赖

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
  4. 再次测试

    1. 访问后台接口默认跳到登录页面认证login(默认用户名:user,密码会打印在控制台)
      image-20221029141419915
    2. 自带退出接口logout,访问logout即可完成退出操作

基本原理

过滤器链

  • SpringSecurity本质是一个过滤器链,内置了关于springsecurity的16个过滤器

Spring Security 原理总结_Spring Security

  • FilterSecurityInterceptor:进行权限校验的拦截器(授权)
  • UsernamePasswordAuthenticationFilter:处理登录页输入的用户名和密码是否正确的过滤器(登录认证)
  • ExceptionTranslationFilter:处理在他之前的过滤器中出现的问题,禁止用户登录

FilterSecurityInterceptor

  • 是一个方法级的权限过滤器,基本位于过滤链的最底部
  • super.beforeInvocation(fi):表示查看之前的filter是否通过
  • fi.getChain().doFilter(fi.getRequest(),fi.getResponse()):表示真正的调用后台的服务

ExceptionTranslationFilter

  • 是一个异常过滤器,用来处理在认证授权过程中抛出的异常

UsernamePasswordAuthenticationFilter

  • 对/login的POST请求做拦截,校验表单中的用户名、密码

重要接口

UserDetailsService

  • 当什么都不配置,账号密码都是Spring Security定义生成的。在实际开发中账号密码都是通过查询数据库获得的,所以我们要通过自定义逻辑控制认证逻辑
  • 如果需要自定义逻辑,只需要实现UserDetailsService接口即可
  • 查询数据库获取用户名密码的方法写在这个里面

步骤

  1. 创建类继承UsernamePasswordAuthenticationFilter,重写三个方法
  2. 创建类实现UserDetailService,编写查询数据的过程,返回User对象,这个User对象是安全框架提供的对象

PasswordEncoder

  • 把参数按照特定的解析规则进行解析:String encode(CharSequence rawPassword);
  • 验证从存储中获取到的编码密码与编码后提交的原始密码是否匹配。如果密码匹配,返回true;否则返回false。第一个参数表示需要被解析的密码,第二个参数表示存储的密码:boolean mathes(CharSequence rawPassword, String encodedPassword);
  • 如果解析的密码能够再次进行解析且达到更安全的结果则返回true,否则false:default boolean upgradeEncoding(String encodedPassword)
  • BCryptPasswordEncoder是SpringSecurity官方推荐的密码解析器,平时多使用这个解析器
  • BCryptPasswordEncoder是对bcrypt强散列方法的具体实现,是基于hash算法的单向加密。可以通过strength控制加密强度,默认10

jwt简介

概念

  • JSON Web Token(JWT)是一个非常轻巧的规范。这个规范允许我们使用JWT在用户和服务器之间传递安全可靠的信息。无状态登录模型
  • 好处:不需要服务器端存储session之类的东西
  • 特点:可以被看到,但是不能被篡改,因为第三部分使用了secret
  • 一个JWT实际上就是一个字符串,它由三部分组成:头部、载荷与签名

头部(Header)

  • 头部用于描述关于该JWT的最基本的信息,例如其类型以及签名所用的算法等。这也可以被表示成一个JSON对象

    {"typ":"JWT","alg":"HS256"}
  • 在头部指明了签名算法是HS256算法。我们进行BASE64编码http://base64.xpcha.com/,编码后的字符串如下:

    eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9

载荷(playload)

  • 载荷就是存放有效信息的地方
  • 定义一个playload:

    {"sub":"1234567890","name":"lisi","admin":true,"age":18}
  • 然后将其进行base64加密,得到jwt第二部分

    eyJzdWIioiIxMjM0NTY3ODkwIiwibmFtZSI6Iml0bGlscyIsImFkbWluIjp-cnVlLCJHZ2UiOjE4fq==

签证(signatuer)

jwt的第三部分是一个签证信息,这个签证信息由三部分组成:

  • header(base64后的)
  • playload(base64后的)
  • secret

这个部分需要base64加密后的header和base64加密后的playload使用.连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分

hs256("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIioiIxMjM0NTY3ODkwIiwibmFtZSI6Iml0bGlscyIsImFkbWluIjp-cnVlLCJHZ2UiOjE4fq==",secret)

将这三部分用.连接起来,就形成了一个完整的字符串,构成了最终的jwt

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIioiIxMjM0NTY3ODkwIiwibmFtZSI6Iml0bGlscyIsImFkbWluIjp-cnVlLCJHZ2UiOjE4fq==.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

JJWT签发与验证token

  • JJWT是一个提供端到端的JWT创建和验证的Java库。永远免费和开源(Apache License,版本2.0),JJWT很容易使用和理解。他被设计成一个以建筑为中心的流畅界面,隐藏了大部分的复杂性
  • 官方文档:https://github.com/jwtk/jjwt

使用步骤

  1. 创建项目,引入依赖

    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.9.0</version>
    </dependency>
  2. 创建测试类

    @Test
    void test() {
        JwtBuilder jwtBuilder = Jwts.builder()
            .setId("666")   // 设置id
            .setSubject("HelloCode")    // 设置主题
            .setIssuedAt(new Date())    // 设置签发日期
            .signWith(SignatureAlgorithm.HS256,"HelloCode");   // 签发
        String jwt = jwtBuilder.compact();
        System.out.println(jwt);
    }
    eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI2NjYiLCJzdWIiOiJIZWxsb0NvZGUiLCJpYXQiOjE2NjcxMjE3MzZ9.uqmPsuZHuuSgkA4b_HKGiz4VneOqgtnRiI8Lv7R6YA8

解析token

我们目前已经创建了token,在web应用中这个操作是由服务端进行然后发送给客户端,客户端在下次向服务端发送请求时需要携带这个token(这就好像拿着一张门票一样),服务端接到这个token应该解析出token中的信息(例如用户id),根据这些信息查询数据库返回相应的结果

@Test
void test() {
    String jwt = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI2NjYiLCJzdWIiOiJIZWxsb0NvZGUiLCJpYXQiOjE2NjcxMjE3MzZ9.uqmPsuZHuuSgkA4b_HKGiz4VneOqgtnRiI8Lv7R6YA8";
    Claims helloCode = Jwts.parser().setSigningKey("HelloCode").parseClaimsJws(jwt).getBody();
    System.out.println(helloCode);
}
{jti=666, sub=HelloCode, iat=1667122047}
如果token或者签名密钥不正确,运行时就会报错

设置过期时间

  • 很多时候,我们并不希望签发的token是永久生效的,所以可以为token添加一个过期时间
  • 使用setExpiration(date)设置过期时间,参数为Date类型数据

    JwtBuilder jwtBuilder = Jwts.builder()
            .setId("666")   // 设置id
            .setSubject("HelloCode")    // 设置主题
            .setIssuedAt(new Date())    // 设置签发日期
            .setExpiration(date)    // 设置过期时间
            .signWith(SignatureAlgorithm.HS256,"HelloCode");   // 签发
  • 当超过过期时间才进行解析,就会在运行时报错

自定义claims

  • 在上面的例子中只存储了id和subject两个信息,如果需要存储更多信息(例如角色),就可以自定义claims
JwtBuilder jwtBuilder = Jwts.builder()
        .setId("666")   // 设置id
        .setSubject("HelloCode")    // 设置主题
        .setIssuedAt(new Date())    // 设置签发日期
        .claim("userId","888")    // 设置用户id(自定义claims)
        .signWith(SignatureAlgorithm.HS256,"HelloCode");   // 签发
{jti=666, sub=HelloCode, userId=888}

权限控制

认证

image-20220620115942257

认证流程

  1. 用户访问前端登录页面,提交登录信息,发送给后端服务
  2. 后端到数据库进行查询,检查用户名密码是否匹配

    • 成功则登录成功,生成jwt令牌,发送给前端
    • 失败提示用户重新输入
  3. 前端将拿到的jwt令牌保存到localstorage或者cookie中,认证成功
  4. 每次访问后台资源服务时,前端将会在请求头中携带jwt信息,发送给后端服务
  5. 后端通过请求头中的jwt信息解析出当前用户,进行权限校验

    • 有权限则返回数据,交由前端页面进行渲染展示
    • 无权限提示用户,禁止访问

核心组件

我们系统中会有许多用户,确认当前是哪个用户正在使用我们系统就是登录认证的最终目的。这里我们就提取出了一个核心概念:当前登录用户/当前认证用户。整个系统安全都是围绕当前登录用户展开的,这个不难理解,要是当前登录用户都不能确认了,那A下了一个订单,下到了B的账户上这不就乱套了。这一概念在Spring Security中的体现就是 Authentication,它存储了认证信息,代表当前登录用户。

我们在程序中如何获取并使用它呢?我们需要通过 SecurityContext 来获取AuthenticationSecurityContext就是我们的上下文对象!这个上下文对象则是交由 SecurityContextHolder 进行管理,你可以在程序任何地方使用它:

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

SecurityContextHolder原理非常简单,就是使用ThreadLocal来保证一个线程中传递同一个对象!

  • Authentication:存储了认证信息,代表当前登录用户
  • SeucirtyContext:上下文对象,用来获取Authentication
  • SecurityContextHolder:上下文管理对象,用来在程序任何地方获取SecurityContext

Authentication中是什么信息呢:

  • Principal:用户信息,没有认证时一般是用户名,认证后一般是用户对象
  • Credentials:用户凭证,一般是密码
  • Authorities:用户权限

接口分析

Spring Security是怎么进行用户认证的呢?

AuthenticationManager 就是Spring Security用于执行身份验证的组件,只需要调用它的authenticate方法即可完成认证

Spring Security默认的认证方式就是在UsernamePasswordAuthenticationFilter这个过滤器中进行认证的,该过滤器负责认证逻辑

Spring Security用户认证关键代码如下:

// 生成一个包含账号密码的认证信息
Authentication authenticationToken = new UsernamePasswordAuthenticationToken(username, passwrod);
// AuthenticationManager校验这个认证信息,返回一个已认证的Authentication
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// 将返回的Authentication存到上下文中
SecurityContextHolder.getContext().setAuthentication(authentication);

AuthenticationManager的校验逻辑非常简单:

根据用户名先查询出用户对象(没有查到则抛出异常)将用户对象的密码和传递过来的密码进行校验,密码不匹配则抛出异常

这里每一个步骤Spring Security都提供了组件:

  1. 是谁执行 根据用户名查询出用户对象 逻辑的呢?用户对象数据可以存在内存中、文件中、数据库中,你得确定好怎么查才行。这一部分就是交由UserDetialsService 处理,该接口只有一个方法loadUserByUsername(String username),通过用户名查询用户对象,默认实现是在内存中查询。
  2. 那查询出来的 用户对象 又是什么呢?每个系统中的用户对象数据都不尽相同,咱们需要确认我们的用户数据是啥样的才行。Spring Security中的用户数据则是由UserDetails 来体现,该接口中提供了账号、密码等通用属性。
  3. 对密码进行校验大家可能会觉得比较简单,if、else搞定,就没必要用什么组件了吧?但框架毕竟是框架考虑的比较周全,除了if、else外还解决了密码加密的问题,这个组件就是PasswordEncoder,负责密码加密与校验。

我们可以看下AuthenticationManager校验逻辑的大概源码:

public Authentication authenticate(Authentication authentication) throws AuthenticationException {
...省略其他代码

    // 传递过来的用户名
    String username = authentication.getName();
    // 调用UserDetailService的方法,通过用户名查询出用户对象UserDetail(查询不出来UserDetailService则会抛出异常)
    UserDetails userDetails = this.getUserDetailsService().loadUserByUsername(username);
    String presentedPassword = authentication.getCredentials().toString();

    // 传递过来的密码
    String password = authentication.getCredentials().toString();
    // 使用密码解析器PasswordEncoder传递过来的密码是否和真实的用户密码匹配
    if (!passwordEncoder.matches(password, userDetails.getPassword())) {
        // 密码错误则抛出异常
        throw new BadCredentialsException("错误信息...");
    }

    // 注意哦,这里返回的已认证Authentication,是将整个UserDetails放进去充当Principal
    UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(userDetails,
            authentication.getCredentials(), userDetails.getAuthorities());
    return result;

...省略其他代码
}

UserDetialsServiceUserDetailsPasswordEncoder,这三个组件Spring Security都有默认实现,这一般是满足不了我们的实际需求的,所以这里我们自己来实现这些组件

密码加密PasswordEncoder

实际项目中不会把密码明文存储在数据库中

  • 默认使用的PasswordEncoder要求数据库中存储的密码格式为{id}password。它会根据id去判断密码加密的方式,但是一般不会采取这种方式,所以就需要替换默认的PasswordEncoder
  • 一般使用SpringSecurity为我们提供的BCryptPasswordEncoder

    只需要使用把BCryptPasswordEncoder对象注入SpringSecurity容器中,SpringSecurity就会使用该PasswordEncoder来进行密码校验

    我们可以定义一个SpringSecurity的配置类,SpringSecurity要求这个配置类要继承WebSecurityConfigurerAdapter

    @Configuration
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
        @Bean
        public BCryptPasswordEncoder passwordEncoder(){
            return new BCryptPasswordEncoder();
        }
    }

用户对象UserDetails

该接口就是我们所说的用户对象,它提供了用户的一些通用属性,源码如下:

public interface UserDetails extends Serializable {
    /**
     * 用户权限集合(这个权限对象现在不管它,到权限时我会讲解)
     */
    Collection<? extends GrantedAuthority> getAuthorities();
    /**
     * 用户密码
     */
    String getPassword();
    /**
     * 用户名
     */
    String getUsername();
    /**
     * 用户没过期返回true,反之则false
     */
    boolean isAccountNonExpired();
    /**
     * 用户没锁定返回true,反之则false
     */
    boolean isAccountNonLocked();
    /**
     * 用户凭据(通常为密码)没过期返回true,反之则false
     */
    boolean isCredentialsNonExpired();
    /**
     * 用户是启用状态返回true,反之则false
     */
    boolean isEnabled();
}

实际开发中我们的用户属性各种各样,这些默认属性可能是满足不了,所以我们一般会自己实现该接口,然后设置好我们实际的用户实体对象。实现此接口要重写很多方法比较麻烦,我们可以继承Spring Security提供的org.springframework.security.core.userdetails.User类,该类实现了UserDetails接口帮我们省去了重写方法的工作,然后再将我们自己需要操作的用户类作为成员变量定义在我们自定义的用户类中即可

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import top.hellocode.model.system.SysUser;
import java.util.Collection;

/**
 * @author HelloCode
 * @blog https://www.hellocode.top
 * @date 2023年01月04日 11:38
 */
public class CustomUser extends User {
    /**
     * 我们自己的用户实体对象,要调取用户信息时直接获取这个实体对象
     */
    private SysUser sysUser;

    public CustomUser(SysUser sysUser, Collection<? extends GrantedAuthority> authorities) {
        super(sysUser.getUsername(), sysUser.getPassword(), authorities);
        this.sysUser = sysUser;
    }

    public SysUser getSysUser() {
        return sysUser;
    }

    public void setSysUser(SysUser sysUser) {
        this.sysUser = sysUser;
    }
}

业务对象UserDetailsService

该接口很简单只有一个方法:

public interface UserDetailsService {
    /**
     * 根据用户名获取用户对象(获取不到直接抛异常)
     */
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

我们实现该接口,将用户信息封装到我们自定义的用户对象中返回即可

/**
 * @author HelloCode
 * @blog https://www.hellocode.top
 * @date 2023年01月04日 11:43
 */
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    @Autowired
    private SysUserService userService;
    @Autowired
    private SysMenuService sysMenuService;
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser user = userService.getByUsername(username);
        if(user == null){
            throw new UsernameNotFoundException("用户不存在");
        }
        if(user.getStatus() == 0){
            throw new RuntimeException("用户已封禁");
        }
        // 用户权限信息封装
        List<String> userPermsList = sysMenuService.findUserPermsList(user.getId());
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        for (String perm : userPermsList) {
            authorities.add(new SimpleGrantedAuthority(perm.trim()));
        }
        return new CustomUser(user, authorities);
    }
}

AuthenticationManager校验所调用的三个组件就已经做好实现了

除了这种方法设置用户名密码,还有以下两种方法(了解即可)

  • 第一种方式:通过配置文件(application.properties)

    spring.security.user.name=hellocode
    spring.security.user.password=hellocode
  • 第二种方式:通过配置类

    @Configuration
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
        @Bean
        public PasswordEncoder passwordEncoder(){
            return new BCryptPasswordEncoder();
        }
    
        @Override
        protected void configure(AuthenticationManagerBuilder auth) throws Exception {
            BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
            String pwd = bCryptPasswordEncoder.encode("12345");
            auth.inMemoryAuthentication().withUser("HelloCode").password(pwd).roles("admin");
        }
    }

自定义用户认证接口

这里RedisTemplate通过@Autowired无法注入,需要使用构造方法赋值,在Security配置类中创建实例时注入
/**
 * @author HelloCode
 * @blog https://www.hellocode.top
 * @date 2023年01月04日 12:13
 * 登录过滤器,继承UsernamePasswordAuthenticationFilter,对用户名密码进行登录校验
 */
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {
    private RedisTemplate redisTemplate;

    public TokenLoginFilter(RedisTemplate redisTemplate,AuthenticationManager authenticationManager) {
        this.redisTemplate = redisTemplate;
        this.setAuthenticationManager(authenticationManager);
        this.setPostOnly(false);
        //指定登录接口及提交方式,可以指定任意路径
        this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/admin/system/user/login","POST"));
    }


    /**
     * 登录认证
     * @param request
     * @param response
     * @throws AuthenticationException
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        LoginVo loginVo = null;
        try {
            loginVo = new ObjectMapper().readValue(request.getInputStream(), LoginVo.class);
        } catch (IOException e) {
            e.printStackTrace();
        }
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(loginVo.getUsername(), loginVo.getPassword());
        this.setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    /*
    *   登录成功
    * */
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        CustomUser customUser = (CustomUser) authResult.getPrincipal();
        String token = JwtHelper.createToken(customUser.getSysUser().getId(), customUser.getSysUser().getUsername());
        Map<String,Object> map = new HashMap<>();
        map.put("token",token);
        ObjectMapper mapper = new ObjectMapper();

        //保存权限数据
        redisTemplate.opsForValue().set(customUser.getUsername(), JSON.toJSONString(customUser.getAuthorities()));

        mapper.writeValue(response.getWriter(), Result.ok(map));
    }


    /*
     *   登录失败
     * */
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
        ObjectMapper mapper = new ObjectMapper();
        if(e.getCause() instanceof RuntimeException) {
            mapper.writeValue(response.getWriter(), Result.build(null, 204, e.getMessage()));
        } else {
            mapper.writeValue(response.getWriter(), Result.build(null, ResultCodeEnum.LOGIN_MOBLE_ERROR));
        }
    }
}

认证解析token

因为用户登录状态在token中存储在客户端,所以每次请求接口请求头携带token, 后台通过自定义token过滤器拦截解析token完成认证并填充用户信息实体。

这里RedisTemplate通过@Autowired无法注入,需要使用构造方法赋值,在Security配置类中创建实例时注入
/**
 * @author HelloCode
 * @blog https://www.hellocode.top
 * @date 2023年01月04日 12:36
 */
public class TokenAuthenticationFilter extends OncePerRequestFilter {
    private RedisTemplate redisTemplate;

    public TokenAuthenticationFilter(RedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        logger.info("uri:" + request.getRequestURI());
        //如果是登录接口,直接放行
        if ("/admin/system/user/login".equals(request.getRequestURI())) {
            chain.doFilter(request, response);
            return;
        }

        UsernamePasswordAuthenticationToken authentication = getAuthentication(request);
        if (null != authentication) {
            SecurityContextHolder.getContext().setAuthentication(authentication);
            chain.doFilter(request, response);
        } else {
            ObjectMapper mapper = new ObjectMapper();
            mapper.writeValue(response.getWriter(), Result.build(null, ResultCodeEnum.PERMISSION));
        }
    }

    private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
        // token置于header里
        String token = request.getHeader("token");
        logger.info("token:" + token);
        if (!StringUtils.isEmpty(token)) {
            String useruame = JwtHelper.getUsername(token);
            logger.info("useruame:" + useruame);
            if (!StringUtils.isEmpty(useruame)) {
                // 获取权限信息
                String authoritiesString = (String) redisTemplate.opsForValue().get(useruame);
                List<Map> mapList = JSON.parseArray(authoritiesString, Map.class);
                List<SimpleGrantedAuthority> authorities = new ArrayList<>();
                for (Map map : mapList) {
                    authorities.add(new SimpleGrantedAuthority((String)map.get("authority")));
                }
                return new UsernamePasswordAuthenticationToken(useruame, null, authorities);
            }
        }
        return null;
    }
}

配置用户认证

修改WebSecurityConfig配置类

还需要为相应的过滤器注入RedisTemplate
/**
 * @author HelloCode
 * @blog https://www.hellocode.top
 * @date 2023年01月04日 10:57
 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    UserDetailsService userDetailsService;

    @Autowired
    private RedisTemplate redisTemplate;

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    @Override
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 这是配置的关键,决定哪些接口开启防护,哪些接口绕过防护
        http
                //关闭csrf
                .csrf().disable()
                // 开启跨域以便前端调用接口
                .cors().and()
                .authorizeRequests()
                // 指定某些接口不需要通过验证即可访问。登陆接口肯定是不需要认证的
                .antMatchers("/admin/system/user/login").permitAll()
                // 这里意思是其它所有接口需要认证才能访问
                .anyRequest().authenticated()
                .and()
                //TokenAuthenticationFilter放到UsernamePasswordAuthenticationFilter的前面,这样做就是为了除了登录的时候去查询数据库外,其他时候都用token进行认证。
                .addFilterBefore(new TokenAuthenticationFilter(redisTemplate), UsernamePasswordAuthenticationFilter.class)
                .addFilter(new TokenLoginFilter(redisTemplate,authenticationManager()));

        //禁用session
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 指定UserDetailService和加密器
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    /**
     * 配置哪些请求不拦截
     * 排除swagger相关请求
     * @param web
     * @throws Exception
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/favicon.ico","/swagger-resources/**", "/webjars/**", "/v2/**", "/swagger-ui.html/**", "/doc.html");
    }
}

自定义登录页面

如果是前后端分离项目,使用SpringSecurity只是为了保护后端接口的访问权限,可以不设置登录页面
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                // 自定义登录页面
                .loginPage("/login.html")
                // 指定登录提交路径(代码实现由框架提供)
                .loginProcessingUrl("/user/login")
                // 自定义登录成功跳转的页面
                .defaultSuccessUrl("/demo/hello").permitAll()
            .and().authorizeRequests()
                // 指定不需要认证可以直接放行的请求路径
                .antMatchers("/","/user/login").permitAll()
            .anyRequest().authenticated()
            //关闭csrf防护
            .and().csrf().disable();
    }
}

用户注销

  1. 在登录页面添加一个退出链接

    <body>
        <h1>登录成功</h1>
        <a href="/logout">退出</a>
    </body>
  2. 在配置类添加退出映射地址

    // 自定义用户注销
    http.logout().logoutUrl("/logout").logoutSuccessUrl("/login.html").permitAll();
  3. 编写logout对应的controller代码(清除session或者redis中的记录等)

授权

在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验。在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息。判断当前用户是否拥有访问当前资源所需的权限

SpringSecurity中的Authentication类:

public interface Authentication extends Principal, Serializable {
    //权限数据列表
    Collection<? extends GrantedAuthority> getAuthorities();

    Object getCredentials();

    Object getDetails();

    Object getPrincipal();

    boolean isAuthenticated();

    void setAuthenticated(boolean var1) throws IllegalArgumentException;
}

前面登录时执行loadUserByUsername方法时,return new CustomUser(sysUser, Collections.emptyList());后面的空数据对接就是返回给Spring Security的权限数据。

在TokenAuthenticationFilter中怎么获取权限数据呢?登录时我们把权限数据保存到redis中(用户名为key,权限数据为value即可),这样通过token获取用户名即可拿到权限数据,这样就可构成出完整的Authentication对象

hasAuthority方法

  • 如果当前的主体具有指定的权限,则返回true,否则返回false

步骤

  1. 在配置类设置当前访问的路径需要哪些权限才能访问

    // 当前登录用户,只有具有admin权限才可以访问以下路径
    .antMatchers("/demo/admin").hasAuthority("admin")
  2. 在UserDetailsService中为User对象设置权限

    List<GrantedAuthority> auths = AuthorityUtils.commaSeparatedStringToAuthorityList("admin");
    
    return new User(user.getUserName(),new BCryptPasswordEncoder().encode(user.getPassword()),auths);
  • 如果没有权限,默认会出现下面的页面(403,Forbidden),具体界面都可以自己修改

image-20221120132302615

hasAnyAuthority方法

hasAuthority方法只针对一个权限,比如对应的请求,只要具有admin或manager权限之一即可访问,就需要使用hasAnyAuthority方法

多个权限直接使用,分隔

  • 如果当前的主体有任何提供的权限(给定的作为一个逗号分割的字符串列表)的话,返回true
.antMatchers("/demo/admin").hasAnyAuthority("admin","manager")
// 开启认证登录
.anyRequest().authenticated()

hasRole方法

  • 如果用户具备给定角色就允许访问,否则出现403
  • 如果当前主题具有指定的角色,则返回true
.antMatchers("/demo/role").hasRole("sale")
// 开启认证登录
.anyRequest().authenticated()
List<GrantedAuthority> auths = AuthorityUtils.commaSeparatedStringToAuthorityList("admin,ROLE_sale");

return new User(user.getUserName(),new BCryptPasswordEncoder().encode(user.getPassword()),auths);
注意,在配置中直接写需要的角色即可,但是在分配时,角色需要加上ROLE_前缀(可以在源码中查看)

hasAnyRole方法

  • 表示用户具有配置的多个角色中的一个即可访问
  • 和hasAnyAuthority方法类似
.antMatchers("/demo/role").hasAnyRole("admin","sale")
// 开启认证登录
.anyRequest().authenticated()

自定义403界面

默认页面

image-20221120135147731

自定义页面

  • 在配置类中配置

    // 配置自定义403页面
    http.exceptionHandling().accessDeniedPage("/unauth.html");

image-20221120135517662

注解使用

使用注解需要先开启注解功能!

在配置类或者启动类添加注解(开启基于方法的安全认证机制,也就是说在web层的controller启用注解机制的安全确认):

@EnableGlobalMethodSecurity(securedEnabled=true,prePostEnabled = true)

securedEnabled开启@Secured注解,prePostEnabled 开启@PreAuthorize和@PostAuthorize注解

@Secured

  • 控制器方法注解
  • 判断用户是否具有对应的角色
  • 需要注意的是这里匹配的字符串需要添加前缀ROLE_
@GetMapping("/role")
@Secured({"ROLE_sale"})
public String role(){
    return "Spring Security Role~~~";
}

@PreAuthorize

  • 注解适合进入方法前的权限验证,可以将登录用户的roles/permissions参数传递到方法中
@GetMapping("/hello")
@PreAuthorize("hasAnyAuthority('menu:system')")
public String hello(){
    return "Hello Spring Security";
}

@PostAuthorize

  • 使用不多,在方法执行后再进行权限校验,适合验证带有返回值的权限
@GetMapping("/test")
@PostAuthorize("hasAnyAuthority('menu:system')")
public String test(){
    return "Spring Security Test~~~";
}

@PreFilter

  • 进入控制器之前对数据进行过滤
  • 使用相对较少
@GetMapping("/getTestPreFilter")
@PreAuthorize("hasRole('ROLE_管理员')")
@PreFilter(value = "filterObject.id%2==0")
public List<UserInfo> getTestPreFilter(@RequestBody List<UserInfo> list){
    list.forEach(t -> {
        System.out.println(t.getId() + "\t" + t.getUserName());
    });
    return list;
}

@PostFilter

  • 权限验证之后对数据进行过滤,留下用户名是admin1的数据
  • 表达式中的filterObject引用的是方法返回值List中的某一个元素
  • 使用相对较少
@GetMapping("/getTestPreFilter")
@PreAuthorize("hasRole('ROLE_管理员')")
@PostFilter("filterObject.userName=='admin1'")
public List<UserInfo> getUserAll(){
    ArrayList<UserInfo> list = new ArrayList<>();
    list.add(new UserInfo(1L,"admin1","6666"));
    list.add(new UserInfo(1L,"admin2","8888"));
    return list;
}

异常处理

异常处理有2种方式:

  1. 扩展Spring Security异常处理类:AccessDeniedHandlerAuthenticationEntryPoint
  2. 在spring boot全局异常统一处理

第一种方案说明:如果系统实现了全局异常处理,那么全局异常首先会获取AccessDeniedException异常,要想Spring Security扩展异常生效,必须在全局异常再次抛出该异常

第二种方案比较简单,推荐使用第二种方式

/**
 * spring security异常
 * @param e
 * @return
 */
@ExceptionHandler(AccessDeniedException.class)
@ResponseBody
public Result error(AccessDeniedException e) throws AccessDeniedException {
    return Result.build(null, ResultCodeEnum.PERMISSION);
}

自动登录

  1. cookie技术实现(在客户端,不安全)
  2. 基于安全框架机制实现自动登录

实现原理

springsecurity-自动登录原理_自动登录

  1. 用户登录认证成功
  2. 向客户端存储cookie加密串
  3. 向数据库存储加密串和用户信息字符串
  4. 当再次访问时,拿到cookie信息到数据库进行查询,如果查询成功,则认证成功,可以登录,否则认证失败

功能实现

  1. 创建数据库表(也可以不创建,框架会自动生成)

    create table persistent_logins (
        username varchar(64) not null,
        series varchar(64) primary key, 
        token varchar(64) not null, 
        last_used timestamp not null
    );
  2. 在配置类中注入数据源,配置操作数据库对象

    // 注入数据源
    @Autowired
    private DataSource dataSource;
    
    // 配置对象
    @Bean
    public PersistentTokenRepository persistentTokenRepository(){
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        // 设置数据源
        jdbcTokenRepository.setDataSource(dataSource);
        // 设置是否自动创建数据表
        jdbcTokenRepository.setCreateTableOnStartup(true);
        return jdbcTokenRepository;
    }
  3. 在配置类中配置自动登录

    // 配置自动登录
    .and().rememberMe().tokenRepository(persistentTokenRepository())
        // 设置有效时长(s)
        .tokenValiditySeconds(60)
        .userDetailsService(userDetailsService)
  4. 在登陆页面中添加复选框

    <input type="checkbox" name="remember-me">自动登录<br>
    注意:复选框的name属性必须是remember-me

CSRF

跨域请求伪造(Cross-site request forgery),也被称为one-click attack或者session riding,通常缩写为CSRF或者XSRF,是一种挟制用户在当前已登录的Web应用程序上执行非本意的操作的攻击方法。跟跨网站脚本(XSS)相比,XSS利用的是用户对指定网站的信任,CSRF利用的是网站对用户网页浏览器的信任。

跨站请求攻击,简单地说,是攻击者通过一些技术手段欺骗用户的浏览器去访问一个自己曾经认证过的网站并运行一些操作(如发邮件、发消息,甚至财产操作如转账和购买商品)。由于浏览器曾经认证过,所以被访问的网站会认为是真正的用户操作而去运行。这利用了web中用户身份验证的一个漏洞:简单的身份验证只能保证请求发自某个用户的浏览器,却不能保证请求本身是用户自愿发出的

从Spirng Security 4.0开始,默认情况下会启用CSRF保护,以防止CSRF攻击应用程序,SpringSecurity CSRF会针对PATCH,POST,PUT和DELETE方法进行保护(即使登录也会拒绝这些请求)

步骤

  1. 确保开启了csrf保护
  2. 在登录页面加上一个表单隐藏项

    <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
在上述操作之后,开启CSRF也可以正确处理对应的请求
最后修改:2023 年 01 月 04 日
如果觉得我的文章对你有用,请随意赞赏