Spring Security 框架详解

SECURITY

Spring Security框架

Spring Security框架主要解决了认证授权的相关问题。

添加依赖

在Spring Boot项目中,需要使用Spring Security时,需要添加spring-boot-starter-security依赖。

当项目中添加此依赖后,默认会执行一系列的自动配置,将:

  • 当前项目中所有的访问,都是必须先登录才允许的
    • 未登录时,将自动重定向到 /login,当登录成功后,将自动重定向到此前访问的页面,或主页
    • 访问 /logout 可以退出登录
    • 如果希望某些路径不需要登录就可以访问,可以自定义Spring Security的配置类,将这些路径配置为“白名单”
  • 启动项目的过程中,会生成随机的临时密码,用户名为user

密码加密

在开发实践中,所有的用户密码都必须加密之后,再存储到数据库中。

用户的原始密码(例如1234)通常称之为原文明文,加密后得到的结果(例如lkjfadshfdslafndshdsfaj)通常称之为密文

在处理加密时,通常应该选取消息摘要算法对用户的密码进行处理!

注意:不可以使用加密算法对密码进行加密并存储,通常,加密算法是用于保障传输过程的安全的!

消息摘要算法是不可逆的算法,是适合对密码进行加密的!

消息摘要算法的主要特点有:

  • 同一种算法,无论消息长度多少,摘要的长度是固定的
  • 当消息相同时,摘要必然相同
  • 当消息不同时,摘要理论上不会相同(有概率是相同的)
    • 消息的长度是无限的,摘要的长度是有限且固定的

需要注意:理论上有n种不同的消息对应同一个摘要,但是,出现这样的现象的概率极低!

典型的消息摘要算法有:

  • MD系列(Message Digest):MD2 / MD4 / MD5
    • MD系列的全部是128位算法
  • SHA家族(Secure Hash Algorithm):SHA-1 / SHA-256 / SHA-384 / SHA-512
    • SHA-1是160位算法,其它则是与算法名称对应,例如SHA-256就是256位算法
  • SM3(国家加密算法)
    • SM3是256位算法

在Spring Boot中,spring-boot-starter依赖项就包含DigestUtils工具类,可以简便的实现MD5算法的处理,例如:

package cn.tedu.csmall.passport;

import org.junit.jupiter.api.Test;
import org.springframework.util.DigestUtils;

public class MessageDigestTests {

    @Test
    public void testMd5() {
        String rawPassword = "123456";
        String encodedPassword = DigestUtils.md5DigestAsHex(rawPassword.getBytes());
        System.out.println("rawPassword = " + rawPassword);
        System.out.println("encodedPassword = " + encodedPassword);
        // 123456 >>> e10adc3949ba59abbe56e057f20f883e
    }

}

如果想要使用其它消息摘要算法,可以自行在项目中添加commons-codec依赖项,此依赖中也有一个名为DigestUtils的工具类,提供了多种算法的API。

由于消息算法的特点包括“消息相同,摘要必然相同”,所以,在互联网上有一些平台记录了消息与摘要的对应关系,记录在数据库,可以根据摘要进行反向查询,从而得知摘要对应的消息!但是,由于这些平台能够记录的对应关系非常有限,可以使用更复杂的消息,大概率是没有被这些平台收录的,则不会被这些平台反向查询出原消息!

换言之,只要原始密码足够复杂,则不会被这些平台“破解”。

但是,某些场景中并不支持使用复杂的消息(密码),也有些用户不愿意使用复杂的原始密码,则很容易被穷举出消息与摘要的对应列表,为解决此问题,应该在加密过程中使用“盐”,盐的本质就是一个字符串,其作用是使得被运算数据变得更加复杂,例如:

@Test
public void testMd5() {
    String salt = "kjkhglkjjg";
    String rawPassword = "123456";
    //                                                   123456kjkhglkjjg
    String encodedPassword = DigestUtils.md5DigestAsHex((rawPassword + salt).getBytes());
    System.out.println("rawPassword = " + rawPassword);
    System.out.println("encodedPassword = " + encodedPassword);
}

而盐值的具体值并没有明确的要求,包括其使用方式也没有明确的要求!

另外,还可以尝试多重加密,即循环调用以上算法。

所以,为了提高密码的安全性:

  • 强制要求使用强度更高的密码
  • 加盐
  • 多重加密
  • 使用更安全的算法
  • 综合使用以上做法

关于盐的补充:通常,可以使用随机的盐值,则即使完全相同的原始密码,得到的加密结果也完全不同,例如:

@Test
public void testMd5() {
    for (int i = 0; i < 5; i++) {
        String salt = UUID.randomUUID().toString();
        String rawPassword = "123456";
        String encodedPassword = DigestUtils.md5DigestAsHex((salt + rawPassword).getBytes());
        System.out.println("rawPassword = " + rawPassword);
        System.out.println("encodedPassword = " + encodedPassword);
        System.out.println();
    }
}

以上运行结果例如(每次都不同):

rawPassword = 123456
encodedPassword = 678408c66bef83edf72b11ad5b505161

rawPassword = 123456
encodedPassword = 99c3da1ef1d1e9ea976c91a00af0b4c0

rawPassword = 123456
encodedPassword = 52c809ab1ef18607c0f357d1caa4082f

rawPassword = 123456
encodedPassword = faf506f5d7a8d5109fc24d4c700fb136

rawPassword = 123456
encodedPassword = e89b5401bfbd233e24cb3862425ccdb8

需要注意的是,一旦使用随机的盐值,则必须将此随机的盐值记录下来(可以在添加数据时,在数据表中使用专门的字段进行记录,或者,将盐址和加密结果合并成1个字符串作为记录下来的密码),否则,在后续的验证密码时,将无法运算得到匹配的结果!

使用示例:

rawPassword = 123456
salt = 4da1ba18-e9c5-4adc-bc0e-3768aca841ad
encodedPassword = ef3bcab34967ab87d9a3002366439898

得到最终密码(盐值拼接密文):
4da1ba18-e9c5-4adc-bc0e-3768aca841adef3bcab34967ab87d9a3002366439898

当使用了Spring Security框架后,此框架中还包含了BCryptPasswordEncoder类,此类可以使用BCrypt算法对密码进行处理,调用此类对象的encode()方法即可实现加密,调用matches()方法就可以实现将原文和密文进行对比!(这2个方法都是在PasswordEncoder接口中定义的)

在Spring Security中放行某些请求

默认情况下,Spring Security要求所有请求都是必须登录后才可以访问的,当需要放行某些请求时,可以将这些请求路径配置为“白名单”。

需要自定义配置类,继承自WebSecurityConfigurerAdapter类,重写其中的configurer(HttpSecurity)方法:

package cn.tedu.csmall.passport.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Slf4j
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Bean
    public PasswordEncoder passwordEncoder() {
        log.debug("创建密码编码器组件:BCryptPasswordEncoder");
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 在配置路径时,可以使用星号作为通配符
        // 使用 /* 只能匹配1层级路径,例如 /user 或 /brand,不可以匹配多层级,例如不可以匹配到 /user/list
        // 使用 /** 可以匹配若干层级路径

        http.csrf().disable(); // 禁用防止跨域访问,如果无此配置,白名单路径的异步访问也会出现403错误

        http.authorizeRequests() // 请求需要被授权才可以访问
            .antMatchers("/**") // 匹配某些路径
            .permitAll(); // 允许直接访问(不需要经过认证和授权)
    }
}

使用数据库中的账号实现登录

在Spring Security,使用默认的登录时(默认存在的/login页面),默认情况下使用user作为用户名,使用启动时生成的临时密码。在处理过程中,也可以使其使用数据库中的账号进行登录,Spring Security会自动获取在输入框中输入的用户名、密码,然后,会自动调用UserDetailsService接口类型对象的UserDetails loadUserByUsername(String username)方法,并获取返回的UserDetails对象,此对象中应该包含密码的密文值,接下来,Spring Security会自动将登录界面中获取的密码原文进行加密,并与UserDetails中的密文进行对比,以判断是否可以成功登录。

在测试之前,应该先禁用(注释掉相关代码)Spring Security中的configurer(HttpSecurity)方法,否则将不会显示 /login 页面。

接下来,在项目的根包下自定义类security.UserDetailsServiceImpl类,实现UserDetailsService接口,并重写接口中的loadUserByUsername()方法:

@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        log.debug("Spring Security自动根据用户名【{}】查询用户详情", s);
        return null;
    }
    
}

一旦编写了UserDetailsService接口的实现类,并将此类由Spring创建对象,则Spring Security会自动装配此类的对象,在后续启动项目时,将不再生成默认的随机密码,且默认的用户名user将不再可用。

可以在此方法中测试返回某个账号信息,例如:

@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
    log.debug("Spring Security自动根据用户名【{}】查询用户详情", s);

    // 以下是临时使用的代码
    if ("wangkejing".equals(s)) {
        UserDetails userDetails = User.builder()
                .username("wangkejing") // 用户名
                .password("$2a$10$XzUcx6Oag7n0tNKhBAGQEe5sFv9Jow9Fa0020UiWkfajUue7bmjz6") // 密码,此密文的原文是123456
                .disabled(false) // 账号是否禁用
                .accountLocked(false) // 账号是否锁定
                .accountExpired(false) // 账号是否过期
                .credentialsExpired(false) // 认证是否过期
                .authorities("临时给出任意字符串") // 【必须】此账号的权限信息
                .build();
        return userDetails;
    }

    return null;
}

如果此时重启项目,并通过 /login 测试登录:

  • 当用户名不是wangkejing时,浏览器将提示UserDetailsService returned null,且IntelliJ IDEA控制台会有异常信息
  • 当用户名是wangkejing但密码不是123456时,在浏览器将提示“用户名或密码错”
  • 当用户名是wangkejing且密码是123456时,将登录成功(登录后的跳转可能404)

如果要改为通过数据库中的账号来实现登录,则在以上代码中,替换为“根据用户名查询管理员信息,如果存在此用户名对应的数据,则将查询到的管理员信息封装到UserDetails中并返回”即可。

所以,接下来需要执行的任务:

  • 在根包下创建pojo.vo.AdminLoginVO类,此类中至少包括:用户名、密码

  • AdminMapper接口中添加抽象方法AdminLoginVO getByUsername(String username);

  • AdminMapper.xml中配置以上抽象方法映射的SQL

    • select username, password from ams_admin where username=?
      
  • AdminMapperTests中进行测试

  • UserDetailsServiceImpl中,自动装配AdminMapper对象,然后调用以上查询功能,并将查询到的结果中的信息封装到返回的UserDetails中去,如果未查询到有效记录,可以直接返回null

自定义处理登录的流程

默认情况下,Spring Security有默认的登录页,输入用户名、密码后,是由Spring Security自动接收登录请求,然后进行处理的,如果登录成功,会自动跳转到此前访问的页面,如果登录失败,会将错误信息提示到默认的登录页上。

以上这套流程不适用于开发实践,因为:

  • 这不是前后端分离的做法(服务器端处理了登录后,不响应JSON结果)
  • 不便于处理细节,例如使用Validation框架验证请求参数的格式

要解决此问题,应该:像开发其它数据处理流程一样的做法,只不过,在自定义的Service实现过程中,通过Spring Security的机制来验证用户名和密码即可。

首先,需要使得控制器可以接收客户端提交的登录请求,需要:

  • 在根包下创建pojo.dto.AdminLoginDTO类,在此类中封装登录请求的相关参数,例如:用户名,密码

  • AdminController中添加处理登录请求的方法:

    • @PostMapping("/login")
      public JsonResult login(AdminLoginDTO adminLoginDTO) {
          // 通过日志简单的输出
          return JsonResult.ok();
      }
      
  • 完成后,可以Knife4j中测试访问,且响应结果永远是成功(目前还没有真正的实现登录)

然后,需要在Service中准备处理登录,需要:

  • IAdminService中声明:void login(AdminLoginDTO adminLoginDTO);
  • AdminServiceImpl中重写以上方法,实现细节可暂时留空
  • AdminController处理登录时调用Service组件的此方法

关于在Service中处理登录的细节,应该使用Spring Security中的AuthenticationManager对象来执行Spring Security的认证过程(后续保存用户信息、授权访问等都需要)。

如果需要得到AuthenticationManager,需要在Spring Security的配置类(自定义的SecurityConfiguration类)中重写authenticationManager()方法,此方法可以返回AuthenticationManager对象,则在重写在方法上添加@Bean注解,可以使得Spring会自动调用此方法,并将返回结果保存在Spring容器中:

@Bean // 必须添加此注解
@Override
protected AuthenticationManager authenticationManager() throws Exception {
    return super.authenticationManager();
}

然后,回到业务实现类中,自动装配AuthenticationManager对象,在具体实现时,调用此对象的authenticate()方法,即可实现Spring Security的认证,此方法的参数可使用UsernamePasswordAuthenticationToken来封装用户名和密码:

@Override
public void login(AdminLoginDTO adminLoginDTO) {
    log.debug("开始处理管理员登录的业务,参数:{}", adminLoginDTO);

    // 调用AuthenticationManager执行Spring Security的认证
    Authentication authentication
            = new UsernamePasswordAuthenticationToken(
                    adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
    authenticationManager.authenticate(authentication);
    log.debug("登录成功!");
}

以上代码的执行流程大致是:

请求 ==> Controller ==> Service ==> AuthenticationManager ==> UserDetailsServiceImpl ==> Mapper

关于Session

HTTP协议是无状态的协议,从协议本身来说,通信过程中并不会记录用户的相关信息,如果某用户第1次访问了服务器后,第2次再次访问时,服务器并不会知道这是与第1次访问时是同一个用户!

在开发实践时,需要明确用户的身份,所以,各编程语言都提供了基于Session的处理机制,Session是服务器端程序维护的一个类似Map的数据,每个客户端都有一个唯一的Key对应到此处的某个值!所以,各个客户端的访问时,都可以向自己对应的Session数据中存入数据,后续,也可以取出之前存入的数据,例如,可以在登录成功后将用户的id存入到Session中,后续,就可以根据“Session中有没有此id”来判断用户是否登录了,并根据存入的用户id来识别用户的身份。

在Session的具体使用过程中,当某个客户端第1次向服务器端发出请求时,并没有所谓的Map的Key,则服务器会会自动生成一个Key响应到客户端去,客户端会自动将此Key保存下来,并在后续每次发出请求时都自动携带这个Key!在此过程中,客户端还会使用Cookie技术将Key保存在客户端!

Session中的Key本身上都是UUID值,本身并没有具体的信息含义,只是具有唯一性,使得各客户端访问服务器端的Session时不会发生冲突。

目前,并不推荐使用Session技术来处理识别用户的身份,因为在集群架构中,同一个用户的多次请求可能是由集群中不同的服务器进行处理,而Session是存储在服务器内存中的数据,直接使用的话,就会无法识别用户的身份!

关于Token

Token可以称之为“票据”、“令牌”,其最大的特点是类似于Session的Key这样的数据中是体现信息含义的!相当于“火车票”,票上是可以体现一些数据的,服务器就相当于“车站”,不同的车站都有相同的验票机制,能够识别“火车票”的真伪,并从中获取某些信息。

关于JWT

JWT = Json Web Token,相比普通Token,它使用JSON封装更多的信息含义。

为了保证JWT数据在网络上传输时的安全,JWT本身是一组加密后的数据,通常,有相关的工具包来负责生成JWT并解析JWT中的数据。

可以选择使用jjwt工具包来实现,在项目中,添加依赖项:

<!-- JJWT(Java JWT) -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

可以创建测试类,测试生成JWT解析JWT

public class JwtTests {

    // 密钥
    String secretKey = "jfdsakjdsfk%&JFDsfFDFADSFhj875421dsafhjafdsfdsalkjafdsafds";

    @Test
    public void testGenerateJwt() {
        // 准备Claims
        Map<String, Object> claims = new HashMap<>();
        claims.put("id", 9527);
        claims.put("name", "刘老师");

        // 准备过期时间:1分钟
        Date expirationDate = new Date(System.currentTimeMillis() + 1 * 60 * 1000);

        // JWT的组成部分:Header(头)、Payload(载荷)、Signature(签名)
        String jwt = Jwts.builder()
                // Header:用于配置算法与此结果数据的类型
                // 通常配置2个属性:typ(类型)、alg(算法)
                .setHeaderParam("typ", "jwt")
                .setHeaderParam("alg", "HS256")
                // Payload:用于配置需要封装到JWT中的数据
                .setClaims(claims)
                .setExpiration(expirationDate)
                // Signature:用于指定算法与密钥(盐)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
        System.out.println(jwt);
        // eyJ0eXAiOiJqd3QiLCJhbGciOiJIUzI1NiJ9
        // .
        // eyJuYW1lIjoi5YiY6ICB5biIIiwiaWQiOjk1MjcsImV4cCI6MTY1NzYxOTY2Nn0
        // .
        // kDW_hgQKbBb01WA5kQeMaxY8Fc_H2Yao2DdFABlbuiw
    }

    @Test
    public void testParseJwt() {
        String jwt = "eyJ0eXAiOiJqd3QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoi5YiY6ICB5biIIiwiaWQiOjk1MjcsImV4cCI6MTY1NzY3NTExNX0.yMG3xL4b2SCNjaqwrIHB3tfA9HHmkiiLzpuYzJCSlog";
        Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
        Object id = claims.get("id");
        Object name = claims.get("name");
        System.out.println("id=" + id);
        System.out.println("name=" + name);
    }

}

解析JWT时,可能会出现一些异常,例如:

  • 当JWT数据过期时:

    io.jsonwebtoken.ExpiredJwtException: 
    
    JWT expired at 2022-07-13T09:18:35Z. Current time: 2022-07-13T09:27:35Z, a difference of 540694 milliseconds.  Allowed clock skew: 0 milliseconds.
    
  • 当生成和解析使用的密钥不一致时,或JWT数据的最后一部分被恶意篡改时:

    io.jsonwebtoken.SignatureException: 
    
    JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.
    
  • 当JWT数据的第1部分被恶意篡改时:

    io.jsonwebtoken.MalformedJwtException: 
    
    Unable to read JSON value: {"t{�:"jwt","alg":"HS256"}
    

在项目中使用JWT

在项目中使用JWT时,通常需要关注的问题:

  • 什么时候生成JWT:通常是登录成功之后,将生成JWT,且会将JWT响应到客户端
  • 客户端什么时候携带JWT来访问服务器端:服务器端不关心
  • 什么时候检查JWT:?????

向客户端响应JWT

当需要向客户端响应JWT时,需要:

  • AdminServiceImpllogin()中,获取authenticate()返回的结果,将此结果转换成User类型,即可从此User类型中获取当初在UserDetailsService中存入的数据,然后,将必要的部分取出(暂时为username),将其生成为JWT数据(参考测试类,暂不考虑封装工具类)
  • IAdminService 接口中将login()的返回值改为String
  • AdminServiceImpl类中也将login()的返回值改为String,并返回JWT数据
  • AdminController处理登录的方法中,调用Service组件的方法时获取返回值,并将此返回值封装到响应结果中

关于AdminServiceImpl中的实现代码:

@Override
public String login(AdminLoginDTO adminLoginDTO) {
    log.debug("开始处理管理员登录的业务,参数:{}", adminLoginDTO);

    // 调用AuthenticationManager执行Spring Security的认证
    Authentication authentication
            = new UsernamePasswordAuthenticationToken(
                    adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
    Authentication loginResult = authenticationManager.authenticate(authentication);

    // 以上调用的authenticate()方法是会抛出异常的方法,如果还能执行到此处,则表示用户名与密码是匹配的
    log.debug("登录成功!认证方法返回:{} >>> {}", loginResult.getClass().getName(), loginResult);
    // 从认证结果中获取Principal,本质上是User类型,且是UserDetailsService中loadUserByUsername()返回的结果
    log.debug("尝试获取Principal:{} >>> {}", loginResult.getPrincipal().getClass().getName(), loginResult.getPrincipal());
    User user = (User) loginResult.getPrincipal();
    String username = user.getUsername();
    log.debug("登录成功的用户名:{}", username);

    // 应该在此处生成JWT数据,向JWT中存入:id(暂无), username, 权限(暂无)
    Map<String, Object> claims = new HashMap<>();
    claims.put("username", user.getUsername());
    Date expiration = new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000);
    String jwt = Jwts.builder()
            .setHeaderParam("typ", "jwt")
            .setHeaderParam("alg", "HS256")
            .setClaims(claims)
            .setExpiration(expiration)
            .signWith(SignatureAlgorithm.HS256, "lkjfdslkjafds8iufnmdsfadsa")
            .compact();
    log.debug("生成JWT数据:{}", jwt);
    return jwt;
}

关于客户端携带JWT数据

当客户端尝试访问需要认证才能请求的资源时,客户端应该携带JWT数据,而服务器端应该对JWT数据进行获取、检查、解析等处理。

当客户端携带JWT时,通常会将JWT数据放在请求头(Request Header)中的Authorization属性中,并且,通常,服务器端的程序都会设计为从请求头中的Authorization属性中获取JWT数据。

服务器端检查JWT

由于许多不同的请求都需要检查JWT,所以,不会在控制器中处理JWT!

通常,应该在过滤器组件中检查JWT!

  • 过滤器是Java服务器端程序(无论你使用什么框架)中最早接收到客户端请求的组件,且所有请求都会经过过滤器才会执行到控制器
  • Xxxxx

需在自定义过滤器类:

/**
 * <p>处理JWT的过滤器</p>
 *
 * <p>此过滤器将尝试获取请求中的JWT数据,如果存在有效数据,将尝试解析,</p>
 * <p>然后,将解析得到的结果存入到Spring Security的上下文中,</p>
 * <p>以至于Spring Security框架中的其它组件能够从上下文中获取到用户的信息,</p>
 * <p>从而完成后续的授权访问。</p>
 */
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {

    // 最终,过滤器可以选择“阻止”或“放行”
    // 如果选择“阻止”,则后续的所有组件都不会被执行
    // 如果选择“放行”,会执行“过滤器链”中剩余的部分,甚至继续向后执行到控制器等组件
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
        // 此方法是任何请求都会执行的方法
        log.debug("执行JwtAuthorizationFilter");

        // 清除Security的上下文
        // 如果不清除,只要此前存入过信息,即使后续不携带JWT,上下文中的登录信息依然存在
        SecurityContextHolder.clearContext();

        // 从请求头中获取JWT
        String jwt = request.getHeader("Authorization");
        log.debug("从请求头中获取的JWT数据:{}", jwt);

        // 先判断是否获取到了有效的JWT数据,如果无JWT数据,直接放行
        if (!StringUtils.hasText(jwt)) {
            log.debug("请求头中的JWT数据是无效的,直接放行");
            filterChain.doFilter(request, response);
            return;
        }

        // 如果获取到了有效的JWT值,则尝试进行解析
        Claims claims = Jwts.parser().setSigningKey("lkjfdslkjafds8iufnmdsfadsa")
                .parseClaimsJws(jwt).getBody();
        Object username = claims.get("username");
        log.debug("从JWT中解析得到用户名:{}", username);

        // TODO 临时:准备用户权限
        GrantedAuthority authority = new SimpleGrantedAuthority("1");
        List<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(authority);

        // 当解析成功后,应该将相关数据存入到Spring Security的上下文中
        Authentication authentication
                = new UsernamePasswordAuthenticationToken(username, null, authorities);
        SecurityContext securityContext = SecurityContextHolder.getContext();
        securityContext.setAuthentication(authentication);

        // 以下代码将执行“放行”
        filterChain.doFilter(request, response);
    }

}

并在配置类中添加配置:

package cn.tedu.csmall.passport.config;

import cn.tedu.csmall.passport.filter.JwtAuthorizationFilter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Slf4j
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtAuthorizationFilter jwtAuthorizationFilter;

    @Bean
    public PasswordEncoder passwordEncoder() {
        log.debug("创建密码编码器组件:BCryptPasswordEncoder");
        return new BCryptPasswordEncoder();
    }

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 在配置路径时,可以使用星号作为通配符
        // 使用 /* 只能匹配1层级路径,例如 /user 或 /brand,不可以匹配多层级,例如不可以匹配到 /user/list
        // 使用 /** 可以匹配若干层级路径

        // 白名单,不需要登录就可以访问
        String[] urls = {
                "/admins/login",
                "/doc.html",
                "/**/*.css",
                "/**/*.js",
                "/favicon.ico",
                "/v2/api-docs",
                "/swagger-resources"
        };

        http.csrf().disable(); // 禁用防止跨域访问,如果无此配置,白名单路径的异步访问也会出现403错误

        http.authorizeRequests() // 请求需要被授权才可以访问
                .antMatchers(urls) // 匹配某些路径
                .permitAll() // 允许直接访问(不需要经过认证和授权)
                .anyRequest() // 除了以上配置过的其它任何请求
                .authenticated(); // 已经通过认证,即已经登录过才可以访问

        // 添加处理JWT的过滤器,必须执行在处理用户名、密码的过滤器(Spring Security内置)之前
        http.addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class);
    }

}

大致执行流程

 

完整编码流程:基于Security+JWT的管理员登录

  • 相关依赖:spring-boot-starter-securityjjwt
  • 创建管理员登录的VO类,例如AdminLoginVO
  • AdminMapper接口和AdminMapper.xml文件中实现:根据用户名查询管理员信息,应该至少包括:用户名、密码、权限
  • 自定义类,实现UserDetailsService接口,重写loadUserByUsername()方法,在此类中通过AdminMapper的查询找到对应的管理员信息,并封装到UserDetails类型的对象中返回
  • 创建Security配置类,继承自WebSecurityConfigurerAdapter类,在此类中使用@Bean方法得到AuthenticationManager对象,使用@Bean方法得到BCryptPasswordEncoder对象
  • IAdminService接口中添加登录的抽象方法,并在AdminServiceImpl中重写此方法,在方法体中,调用AuthenticationManagerauthenticate()执行认证,如果认证通过,应该生成JWT数据并返回,此JWT数据中应该包含用户名及必要信息
  • AdminController中处理登录请求,并通过调用IAdminService类型的组件来实现,将调用得到的JWT响应到客户端去

完整编码流程:登录后的访问

  • 在Security的配置类中,指定一些白名单,这些是不需要登录就可以直接访问的,其它请求路径都必须登录后才可以访问,需要注意:登录、注册等请求路径必须在白名单,否则不合理
  • 创建JWT过滤器,在此过滤器中:
    • 清除Security的上下文
    • 从请求头中获取JWT
    • 对JWT数据进行基本判断(是否有值),如果没有有效值,直接放行
    • 如果获取到有效的JWT,则解析,得到用户信息,将用户信息存入到上下文中
  • 在Security的配置类中,添加以上过滤器,将其添加在UsernamePasswordAuthenticationFilter之前

关于CORS

CORS:跨域的异步访问,默认情况下,是不允许的。

在使用Spring MVC框架时,需要允许跨域访问时,可以自定义配置类,实现WebMvcConfigure接口,重写其中的addCorsMappings()方法:

@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOriginPatterns("*")
                .allowedHeaders("*")
                .allowedMethods("*")
                .allowCredentials(true)
                .maxAge(3600);
    }
    
}

当项目中进一步使用了Spring Security框架后,当客户端提交复杂请求(自定义了请求头中非常规属性,例如添加了Authorization属性)时,还需要在Spring Security的配置类允许复杂请求的跨域访问,解决方案可以是:

http.cors();

或者:

http.antMatchers(HttpMethod.OPTIONS, "/**").permitAll();

之所以需要进行这样的处理,是因为复杂请求本身有预检(PreFlight)机制,在提交请求时,客户端会自动先提交OPTIONS类型的请求,此时服务器端可能是不通过的,则会出现403错误,并且,实质尝试提交的请求(例如GETPOST)中复杂请求头部信息不会被提交。

在浏览器端,一旦成功的提交了复杂请求,则后续不会自动提交OPTIONS请求执行预检。

实现授权访问

实现授权访问的步骤:

  • 当用户尝试登录时,应该根据用户名从数据库中查询出此管理员的权限信息

  • UserDetailsServiceImpl中,(当登录认证时,Spring Security框架会自动调用此类中的loadUserByUesrname()方法),根据用户名查询到有效管理员信息后,向UserDetails中存入权限信息

    • List<String>格式的权限集合转换成String...格式即可,例如:

      admin.getPermissions().toArray(new String[] {})
      
  • AdminServiceImpllogin()中,认证成功后,从返回的Authentication中取出权限信息,并其生成到JWT中

    • 为保证后续能从JWT中取出权限且还原成正常的格式,应该将权限列表(Collection<? extend GrandtedAuthority>)转换成JSON格式的字符串再写入
  • JwtAuthorizationFilter中,从JWT中解析出权限,并存入到Security的上下文中

    • 从JWT中解析出的权限是JSON格式的字符串,需还原成Collection<? extend GrandtedAuthority>类型才可以存入到Security的上下文中,可以还原成List<SimpleGrantedAuthority>
  • 在Security的配置类SecurityConfiguration上添加注解@EnableGlobalMethodSecurity(prePostEnabled = true)以开启全局的授权访问检查

    • 此配置是一次性的配置
  • 在控制器中,在处理请求的方法上,使用@PreAuthorize注解,配置其中的hasAuthority属性,即可要求此请求必须具有某种权限

    • 例如:@PreAuthorize("hasAuthority('/ams/admin/read')")

根据用户名查询管理员的权限

首先,在AdminLoginVO中添加必要的属性:

package cn.tedu.csmall.passport.pojo.vo;

import lombok.Data;

import java.io.Serializable;
import java.util.List;

@Data
public class AdminLoginVO implements Serializable {

    /**
     * 管理员的id
     */
    private Long id;

    /**
     * 用户名
     */
    private String username;

    /**
     * 密码(密文)
     */
    private String password;

    /**
     * 账号是否启用,0=禁用,1=启用
     */
    private Integer enable;

    /**
     * 此账号的权限列表
     */
    private List<String> permissions;

}

然后,在AdminMapper.xml中配置查询:

<!-- AdminLoginVO getByUsername(String username); -->
<select id="getByUsername" resultMap="LoginResultMap">
    SELECT
        ams_admin.id,
        ams_admin.username,
        ams_admin.password,
        ams_admin.enable,
        ams_permission.value
    FROM ams_admin
    LEFT JOIN ams_admin_role ON ams_admin.id=ams_admin_role.admin_id
    LEFT JOIN ams_role_permission ON ams_admin_role.role_id=ams_role_permission.role_id
    LEFT JOIN ams_permission ON ams_role_permission.permission_id=ams_permission.id
    WHERE
        ams_admin.username=#{username}
</select>

<resultMap id="LoginResultMap" type="cn.tedu.csmall.passport.pojo.vo.AdminLoginVO">
    <id column="id" property="id" />
    <result column="username" property="username" />
    <result column="password" property="password" />
    <result column="enable" property="enable" />
    <collection property="permissions" ofType="java.lang.String">
        <constructor>
            <arg column="value" />
        </constructor>
    </collection>
</resultMap>

关于JWT过滤器的处理细节

解析JWT是可能失败的,例如JWT数据过期、签名错误、数据非法等,这些错误都应该被处理,否则,就会存在异常未处理的情况,最终将导致500错误!

关于以上可能的错误,应该大致分为3类,一类是JWT数据过期,一类是JWT数据被恶意篡改,再另外还有可能是其它的错误。

首先,先在ServiceCode中添加新的业务状态码,对应一些错误:

/**
 * 错误:JWT数据错误,可能被恶意篡改
 */
public static final int ERR_JWT_INVALID = 40001;
/**
 * 错误:JWT过期
 */
public static final int ERR_JWT_EXPIRED = 40300;

然后,需要在JWT过滤器中,自行使用try...catch来捕获并处理异常!

在登录的用户身份标识中添加自定义信息

Spring Security框架中并没有使用、封装用户的ID等相关信息,如果使用过程中,需要自行封装更多的信息,并添加到用户身份标识中,则需要:

  • 自定义类实现UserDetails接口
  • 或,自定义类继承User

并且,在自定义类中添加所需的属性,例如ID,然后,在UserDetailsService的实现类中,在loadUserByUsername()方法返回自定义类的对象。

所以,创建AdminDetails类:

@Setter
@Getter
@EqualsAndHashCode
@ToString(callSuper = true)
public class AdminDetails extends User {

    private Long id;

    public AdminDetails(String username, String password, boolean enabled,
                        Collection<? extends GrantedAuthority> authorities) {
        super(username, password, enabled,
                true, true, true,
                authorities);
    }

}

UserDetailsServiceImpl中,需要返回时:

List<String> permissions = admin.getPermissions();
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
for (String permission : permissions) {
    authorities.add(new SimpleGrantedAuthority(permission));
}

AdminDetails adminDetails = new AdminDetails(
        admin.getUsername(),
        admin.getPassword(),
        admin.getEnable() == 1,
        authorities
);
adminDetails.setId(admin.getId());
log.debug("即将向Spring Security返回AdminDetails:{}", adminDetails);

接下来,在AdminServiceImpllogin()方法中,通过AuthenticationManagerauthenticate()执行认证且通过认证的返回结果就是以上AdminDetails对象,所以,可以从中获取管理员的id,并用于生成JWT数据,则用户登录成功后得到的JWT数据中将包含Id信息。

后续,客户端提交请求时,携带的JWT也是包含Id信息的,可以在JwtAuthenticationFilter中解析得到此Id,最终,此Id值应该封装到Security的上下文中,则可以利用UsernamePasswordAuthenticationToken类的principal属性(Object类型),所以,自定义类,用于封装后续可能需要使用到的管理员信息:

package cn.tedu.csmall.passport.security;

import lombok.Data;

import java.io.Serializable;

/**
 * 当前登录的当事人
 *
 * @author [email protected]
 * @version 0.0.1
 */
@Data
public class LoginPrincipal implements Serializable {

    /**
     * 当前登录的用户id
     */
    private Long id;

    /**
     * 当前登录的用户名
     */
    private String username;

}

然后,在过滤器,将其存入:

// 准备当前登录用户的当事人信息
LoginPrincipal loginPrincipal = new LoginPrincipal();
loginPrincipal.setId(Long.parseLong(id.toString()));
loginPrincipal.setUsername(username.toString());

// 当解析成功后,应该将Authentication存入到Spring Security的上下文中
Authentication authentication
        = new UsernamePasswordAuthenticationToken(loginPrincipal, null, authorities);
//                                             == 以上封装了当事人信息 ==
SecurityContext securityContext = SecurityContextHolder.getContext();
securityContext.setAuthentication(authentication);
log.debug("已经向Security的上下文中写入:{}", authentication);

至此,当客户端携带JWT访问服务器端时,服务器端的Security的上下文中就包含了管理员的id、用户名、权限,其中,权限不需要自行使用,都是Security框架自动判断(你只需要在控制器处理请求的方法上配置@PreAuthorize注解即可),当需要获取管理员的id、用户名时,可以在控制器处理请求的方法的参数列表中添加Authentication即可,此参数就是Security上下文中的认证信息(过滤器中存入的对象),例如:

// http://localhost:9081/admins
@ApiOperation("查询管理员列表")
@ApiOperationSupport(order = 401)
@PreAuthorize("hasAuthority('/ams/admin/read')")
@GetMapping("")
public JsonResult list(Authentication authentication) {
    log.debug("接收到查询管理员列表的请求");
    log.debug("当前认证信息:{}", authentication);
    LoginPrincipal principal = (LoginPrincipal) authentication.getPrincipal();
    Long id = principal.getId();
    log.debug("从认证信息中获取当前登录的管理员的id:{}", id);
    String username = principal.getUsername();
    log.debug("从认证信息中获取当前登录的管理员的用户名:{}", username);
    List<AdminListItemVO> admins = adminService.list();
    return JsonResult.ok(admins);
}

Authentication中获取LoginPrincipal比较麻烦,还需要自行获取、转换类型,可以改为声明LoginPricipal参数(在过滤器中封装到UsernamePasswordAuthenticationTokenpricipal属性中的对象),然后,在此参数前添加@AuthenticationPrincipal注解,即可直接使用:

// http://localhost:9081/admins
@ApiOperation("查询管理员列表")
@ApiOperationSupport(order = 401)
@PreAuthorize("hasAuthority('/ams/admin/read')")
@GetMapping("")
public JsonResult list(@AuthenticationPrincipal LoginPrincipal loginPrincipal) {
    log.debug("接收到查询管理员列表的请求");
    log.debug("当前认证信息中的当事人信息:{}", loginPrincipal);
    Long id = loginPrincipal.getId();
    log.debug("从认证信息中获取当前登录的管理员的id:{}", id);
    String username = loginPrincipal.getUsername();
    log.debug("从认证信息中获取当前登录的管理员的用户名:{}", username);
    List<AdminListItemVO> admins = adminService.list();
    return JsonResult.ok(admins);
}

Spring Security框架的相关概念

Authentication

认证信息,在项目中,它是一个接口,常用的实现类是UsernamePasswordAuthenticationToken,根据应用场景不同,其表现的意义也不同,例如,在AuthenticationManagerauthenticat()方法的参数中,它主要用于封装用户名和密码,但是,在此方法的返回结果中,它表示成功登录的用户信息。

Authorization

认证,在项目中,它主要表现为携带JWT的请求头的属性名,是建议使用的属性名。

Authority

权限,通常表现为一些字符串,这些字符串应该具有唯一、易于阅读的特性,框架会根据登录后的用户信息和控制器中配置的权限进行检查,以判断某用户是否具有执行此操作的权力。

Principal

当事人,是Authentication中的部分属性,以UsernamePasswordAuthenticationToken为例,它当中就包括了Principal、Credentials、Authorities这3大部分,在项目中,如果Authentication是用于执行认证,则此Principal就是用户名,如果Authentication是用户认证后的信息,则可以包含其它意义,例如ID、用户名等。

Token

票据、令牌,指的是携带了一部分有意义的数据的信息。

UserDetails

用户详情,是用于执行认证过程中,封装用户的信息,例如,在UserDetailsService接口的实现类中,在loadUserByUsername()方法中就应该返回此类型的对象,则Spring Security会自动调用此方法来获取UserDetails类型的结果,此结果中应该包含密码,且Spring Security会自动调用PasswordEncoder来验证用户请求登录时输入的密码,并且,此类型也会是认证成功后Authentication中的Principal。

使用Spring Security框架时涉及的文件

pom.xml

需要添加相关依赖,当需要使用Spring Security时,添加spring-boot-starter-security,当需要使用JWT时,添加jjwt(生成和解析JWT数据的工具包)和fastjson(实现对象与JSON字符串互相转换的工具包)。

UserDetailsServiceImpl

UserDetailsService接口的实现类,需要重写其中的UserDetails loadUserByUsername(String s)方法,Spring Security在执行认证时会自动调用此方法,此方法的返回结果必须至少包括:密码、权限和其它必要的信息(根据API决定)。

关于返回的UserDetails,通常可能使用User类型,但是,此类型并不包含id等属性,所以,也可能自定义类实现UserDetails接口,或自定义类继承自User,然后作为返回的UserDetails对象。

SecurityConfiguration

是Spring Security框架的配置类,需要继承自WebSecurityConfigurerAdapter

此类可以添加@EnableGlabalMethodSecurity(prePostEnabled = true)注解,用于开启全局的方法上的授权检查(允许在处理请求的方法使用@PreAuthorize检查权限)。

通常,在此类中会配置PasswordEncoder对应的@Bean方法(此方法也可以在其它配置类中),在执行认证时,Spring Security会自动使用此PasswordEncoder对象的matches()方法来验证密码。

  • 如果密文是BCrypt算法生成的,则应该在@Bean方法中返回BCryptPasswordEncoder,如果没有密文(密码并未加密),则此方法中应该返回NoOpPasswordEncoder,以此类推

在此类中,还可能配置AuthenticationManager对应的@Bean,此方法一般是重写的方法,用于返回AuthenticationManager对象,用于在其它组件中执行认证,例如在Service中自动装配此类型的属性,并调用authenticate()方法来执行认证。

在此类中,比较重要的是重写void configure(HttpSecurity http)方法,在此方法内部对如何处理请求进行配置,通常,需要配置的有:

  • http.csrf().disable():禁用防止跨域伪造的攻击,是固定的配置
  • http.cors():在Spring Security的过滤器链中添加CorsFilter,以实现放行复杂的异步请求的预检

另外,还应该调用http参数对象及对应的链式方法进行一些配置:

  • authroizeRequests():对请求进行认证
  • antMatchers():匹配某些路径,此方法并不决定这些路径应该如何被处理
  • permitAll():允许此前的antMatchers()配置的路径的所有方法直接访问
  • anyRequest():匹配其它的任何请求(请求路径),即在此前调用的所有antMatchers()以外的请求,此方法也不决定这些请求应该如何被处理
  • authenticated():已经认证的

AdminDetails

UserDetails接口的实现类,或User的子类,这个类的主要作用是对User类进行扩展,因为在开发实践中,需要的认证信息中通常还包括用户的id等信息,而Spring Security的User中并没有定义这些属性,所以,不满足开发需求,则其进行扩展。

当编写UserDetailsServiceImplloadUserByUsername()时,此方法应该返回AdminDetails类型的对象。

当调用AuthenticationManagerauthenticate()方法时,返回结果中的Principal就是AdminDetails对象。

JwtUtils

主要定义生成JWT和解析JWT的方法,便于在其它组件中直接调用,而不必关心生成JWT和解析JWT的细节。

JwtAuthorizationFilter

这是处理JWT的过滤器,其主要作用是对客户端的请求头中的有效JWT进行解析,并将解析得到的结果封装到认证信息中,然后将认证信息到Spring Security的上下文中,以至于:

  • Spring Security会自动从上下文中取出认证信息中的权限部分,用于自动判断权限,所以,在控制器中处理请求的方法上,只需要使用@PreAuthroize注解即可实现授权访问的检查
  • 在控制器中处理请求的方法的参数列表中,可以添加Authentication参数,则在控制器中就可以获取认证信息,甚至,不使用Authentication参数,而是使用自定义的当事人类型,添加@AuthenticationPrincipal注解,就可以直接得到自定义的当事人信息

在此过滤器的实现过程中,需要注意:

  • 对于明显无效的JWT(为null、是空字符串等)应该直接放行,因为有些请求本不应该携带JWT数据,例如登录、注册……
  • 解析JWT是可能失败的,特别是JWT可能过期,则应该直接对相关的异常进行处理,当前组件是过滤器,是执行在所有其它组件之前的,所以,不能抛出异常使用Spring MVC统一处理异常的机制
  • 从JWT中解析出相关数据后,应该封装到UsernamePasswordAuthenticationToken中,其中,权限信息应该封装到此类型的authorities属性中,用户的登录信息(当事人信息)应该封装到此类型的principal属性中,另外,如果某个其它的系统(其它项目)并没有权限相关的概念,此处的authorities也不能为空,否则,会被Spring Security视为“没有有效的认证信息”
  • 一定要将认证信息存入到Spring Security的上下文中
  • 为了避免后续使用中可能出现的某些问题(例如第1次访问携带JWT最终向上下文中存入信息,后续不再携带JWT也会视为已登录),应该在过滤器刚刚执行时,清除Spring Security的上下文中的信息

LoginPrincipal

主要用于封装当事人的多个属性,例如同时将id、 用户名存入到UsernamePasswordAuthenticationToken中去,后续,在控制器中处理请求的方法的参数列表中,就可以使用@AuthenticationPrincipal LoginPrincipal loginPrincipal参数来得到当前登录的当事人信息。

其它相关类或实现:AdminMapper及相关

必须实现“根据用户名查询管理员信息”的功能,且返回的结果中必须包含此管理员的权限列表。

其它相关类或实现:AdminServiceImpl

在处理登录的过程中,应该调用AuthenticationManagerauthenticate()执行认证,并获取返回结果,然后,将返回结果中的必要数据用于生成JWT,作为业务方法的返回值。

其它相关类或实现:AdminController

在处理登录时,必须响应调用Service组件时返回的JWT数据。

在其它需要获取认证信息的方法中,在参数列表中添加@AuthenticationPrincipal LoginPrincipal loginPrincipal来获取当前登录的当事人信息。

当某个请求必须拥有某种权限才可以访问时,在方法上添加@PreAuthorize注解配置权限。

作业1:实现csmall-server中的认证访问

目标:原csmall-server项目中的所有功能都是必须登录以后才可以访问的。

开发步骤:

  • csmall-server项目中添加相关依赖项

  • 在项目的启动类的@SpringBootApplication注解中排除UserDetailsServiceAutoConfiguration

    • @SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
      
    • 不再使用临时的user用户名和随机密码

  • 【手写】JWT工具类

  • 创建LoginPrincipal

  • 【手写】使用JwtAuthenticationFilter获取、解析JWT,并将当事人信息封装到Authentication中,并存入到Spring Security的上下文中

  • 创建SecurityConfigration

    • 需要http.cors()
    • 需要http.csrf().disable()
    • 所有请求都必须登录才可以访问

注意:原有的所有客户端请求都必须携带JWT。

作业2:实现csmall-server中的其它功能

检查并确保完成以下功能:

  • 增加品牌
  • 查询品牌列表
  • 根据id删除品牌
  • 增加类别
  • 查询类别列表
  • 根据id删除类别
  • 增加属性
  • 查询属性列表
  • 根据id删除属性
  • 增加相册
  • 查询相册列表
  • 根据id删除相册
  • 增加属性模版
  • 查询属性模版列表
  • 根据id删除属性模版

以上功能需完成持久层、业务逻辑层、控制器、界面。

猜你喜欢

转载自blog.csdn.net/m0_71202849/article/details/126524955