第四阶段(二)

158 阅读13分钟

关于UUID

百科资料:UUID 是通用唯一识别码(Universally Unique Identifier)的缩写,是指在一台机器上生成的数字,UUID是一个128比特的数值,它保证对在同一时空中的所有机器都是唯一的。通常平台会提供生成的API。按照开放软件基金会(OSF)制定的标准计算,用到了以太网卡地址、纳秒级时间、芯片ID码和随机数。

密码加密

所有需要存储下来的密码,都必须先加密,再存储加密后的结果!

原始密码通常称之密码的原文,也可以称之为明文密码,加密后的结果通常称之为密文!

目前,主流使用的用于解决各种应用场景的算法都不需要单独设计,有许多成熟的算法直接可用,并且,这些算法都有对应的简单的API。

对密码的原文进行编码(encode)时,不可以使用加密算法!所有的加密算法都是可以逆向运算的,这类算法主要用于保障传输过程的安全性,并不适用于需要存储下来的密码的加密过程!

编码:可以作为动词,将原始数据通过某种算法,运算得到另一个数据的过程,可称之为“编码”。

推荐使用消息摘要算法或其它哈希算法,用于对密码的原文进行编码处理,这些算法都是单向的,是不可逆向运算的,即使算法类型、运算参数、密文全部被泄露,也不可能被计算得到密码的原文。

常见的消息摘要算法有:

  • MD(Message Digest)系列:
    • MD2(128位算法)
    • MD4(128位算法)
    • MD5(128位算法)
  • SHA(Secure Hash Algorithm)家族
    • SHA-1(160位算法)
    • SHA-256(256位算法)
    • SHA-384(384位算法)
    • SHA-512(512位算法)

消息摘要算法的典型特征有:

  • 使用同一种算法时,如果消息(原文)相同,则摘要(编码结果)一定相同
  • 使用同一种算法时,无论消息多长,摘要的长度是固定不变的
  • 使用同一种算法时,如果摘要不同,则消息极大概率不会相同
    • 必然存在n种不同的消息,对应的摘要是完全相同的

由于消息与摘要的对应关系是固定的,例如当消息是123456时,摘要一定是e10adc3949ba59abbe56e057f20f883e,所以,可以使用数据库记录各个消息与摘要的对应关系,当尝试“破解”时,可以通过此数据库查询出密码的原文!

当密码的原文只有1位长度时,可能的密码有95种,当密码的原文有2位长度时,可能的密码有95 x 95种,以此类推,6位长度的密码有约7350亿种不同的排列组合,7位长度的密码有约70万亿种不同的排列组合,8位长度的密码有约6650万亿种不同的排列组合……

以8位长度为例,这么多种MD5结果需要占用6650万亿乘以32的字符数量,需要占用约387TB存储空间,这还不包括存储密码的原文需要占用的存储空间!

所以,通过记录原文与密文的对应关系来“破解”密码的做法,可以处理的密码的种类是非常有限的,只要用户使用的原始密码相对比较复杂,就不可以通过此方式实现破解!

为了进一步保证密码安全,可以采用加“盐”的做法!所谓的“盐”,本质上应该是一个难以预测且内容不会过于简单的字符串,它将被应用于编码过程中,例如:

@Test
void md5Test() {
    String salt = "54o9itgwe83flkjgfdslkjfdslkjnagreikh";
    String rawPassword = "123456";
    String encodedPassword = DigestUtils.md5DigestAsHex(
            (rawPassword + salt).getBytes());
    System.out.println("原文:" + rawPassword);
    System.out.println("密文:" + encodedPassword);
}

在以上过程中,“盐”的核心价值是“使得被运算数据变得更加复杂”!所以,使用什么样的盐、如何使用盐值,并没有明确的规定!

所以,通常,有效的保障密码安全的编码手段有:

  • 要求用户使用安全强度更高的原始密码
    • 位数要长,组成的字符要多样化
  • 使用盐,甚至随机盐
    • 使用随机盐的话,需要将盐值保存下来
  • 使用位数更长的算法
  • 多重加密
  • 综合以上做法

关于csmall-passport项目

项目GITEE地址:gitee.com/chengheng20…

此项目主要用于实现“管理员”账号的后台管理功能,主要实现:

  • 管理员登录
  • 添加管理员
  • 删除管理员
  • 显示管理员列表
  • 启用 / 禁用管理员
  • 其它相关

此项目的定位,可以是整套项目(包含csmall-product及其它相关相册)的共用的“登录入口”,即在其它项目中不必实现“登录”的功能,而是在此项目(csmall-passport)中实现即可,在此项目上成功登录的管理员,在其它相关相册中不必重复登录,也能被识别身份与权限。

关于RBAC

RBACRole-Based Access Control,基于角色的访问控制

在涉及权限管理员的软件设计中,应该至少需要设计以下3张数据表:

  • 账号表(管理员表,或用户表):ams_admin,用于记录管理员的账号

  • 角色表:ams_role,与账号表关联,并且,与权限表关联

  • 权限表:ams_permission,用于记录此项目中所有的权限(权限标识)

并且,还至少需要2张关联表

  • 账号与角色的关联表:ams_admin_role
  • 角色与权限的关联表:ams_role_permission

关于Spring Security框架

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

**认证:**判断尝试执行访问时的身份信息,例如:判断是否已经登录,或登录的账号是哪一个。最核心的功能就是登录

**授权:**判断是否允许已经通过认证的访问是否可以执行,例如:判断是否具有权限执行某项操作

添加Spring Security的依赖

在基于Spring Boot的项目中,使用Spring Security应该添加依赖项:

<!-- Spring Boot支持Spring Security的依赖项,主要解决认证与授权相关问题 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

当在项目中添加了以上依赖项后,你的项目会发生以下变化(Spring Boot中的Spring Security自动配置的默认行为):

  • 所有的请求都是必须要登录才允许访问的,包括不存在URL

  • 提供了默认的登录页面,当未登录时,会自动重定向到此登录页面

  • 提供了临时的默认的登录账号,用户名是user,密码是启动项目时在控制台中可以看到的UUID值(每次重启项目后都会重新生成)

1680489291827.png

  • 默认使用Session存储用户的登录信息,所以,登录后的有效期取决于Session的有效期

  • 当登录成功后,会自动重定向到此前尝试访问的URL,如果此前没有尝试访问某个URL,则重定向到根路径

  • 可以通过/logout路径 访问 ”退出登录“的页面,以实现退出所有

  • 即使登录成功了,所有POST请求都是不允许的,而GET请求都是允许的

关于Spring Security的配置类

在项目的根包下,创建config.SecurityConfiguration类,继承自WebSecurityConfigurerAdapter类,并在类上添加@Configuration注解:

@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
}

然后,在类中重写void configure(HttpSecurity http)方法:

@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // super.configure(http); // 不要调用父类的同款方法
    }

}

**注意:**在以上配置方法中,不要使用super调用父类的同款方法!

由于没有调用父类的同款方法,再次重启项目后,与此前将会有些不同:

  • 所有请求都不再要求登录
  • 默认的登录、登出的URL不可用

关于登录表单

在Spring Security的配置类中的configure(HttpSecurity http)方法中,根据是否调用了参数对象的formLogin()方法,决定是否启用登录表单页(/login)和登录页(/logout)及对应的功能,例如:

@Override
protected void configure(HttpSecurity http) throws Exception {
    // 调用formLogin()表示启用登录和登出页面,如果未调用此方法,则没有登录和登出页面
    http.formLogin();
    
    // super.configure(http); // 不要调用父类的同款方法
}

关于URL的授权访问

在Spring Security的配置类中的configure(HttpSecurity http)方法中进行授权访问的配置:

@Override
protected void configure(HttpSecurity http) throws Exception {
    // 白名单
    String[] urls = {
            "",
            "/",
            "/index.html",
            "/helloworld"
    };

    // 配置URL的授权访问
    // 注意:配置时,各请求的授权访问遵循“第一匹配原则”,即根据代码从上至下,以第1次匹配到的规则为准
    // 所以,在配置时,必须将更加精准的配置写在前面,覆盖范围更大的匹配的配置写在后面
    http.authorizeRequests() // 配置URL的授权访问
            .mvcMatchers(urls) // 匹配某些请求
            .permitAll() // 直接许可,即:不需要通过认证就可以访问
            .anyRequest() // 匹配任何请求
            .authenticated() // 以上匹配到的请求必须是“已经通过认证的”
    ;

    // 调用formLogin()表示启用登录和登出页面,如果未调用此方法,则没有登录和登出页面
    http.formLogin();
    // super.configure(http); // 不要调用父类的同款方法
}

使用临时的自定义账号实现登录

如果不想使用Spring Security默认的用户名和密码登录,而是改为自定义的账号信息来登录,例如,假定root是正确的用户名,匹配的密码是1234,需要自定义类,实现UserDetailsService接口,并确保此类是一个组件类,则Spring Security框架会基于此实现类来处理认证。

在项目的根包下去创建security.UserDetailsServiceImpl类,在类上添加@Service注解,并重写接口中的抽象方法:

@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    
    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        return null;
    }
    
}

当项目中存在UserDetailsService接口类型的组件对象时,当尝试处理登录时,Spring Security会自动的使用登录表单提交过来的用户名来调用以上loadUserByUsername()方法,并得到UserDetails类型的对象,此对象中应该包含用户的相关信息,例如密码、账号状态等,接下来,Spring Security会自动的判断账号的状态,并使用登录表单提交过来的密码与UserDetails中的密码进行对比,以决定此账号是否能够登录。

所以,当重写loadUserByUsername()时,只需要实现“根据用户名返回匹配的用户详情”即可,至于此方法的调用、返回的结果如何用于判断是否能够成功登录,都是由Spring Security自动处理的!

则重写方法:

@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
    log.debug("Spring Security自动调用了loadUserByUsername()方法,参数:{}", s);

    // 假设正确的用户名是root,匹配的密码是1234
    if (!"root".equals(s)) {
        log.warn("用户名【{}】错误,将不会返回有效的UserDetails(用户详情)", s);
        return null;
    }

    UserDetails userDetails = User.builder() // 构建者模式
            .username("root") // 存入用户名
            .password("1234") // 存入密码
            .disabled(false) // 存入启用、禁用状态
            .accountLocked(false) // 存入账号是否锁定的状态
            .credentialsExpired(false) // 存入凭证是否过期的状态
            .accountExpired(false) // 存入账号是否过期的状态
            .authorities("这是一个临时的山寨权限,暂时没什么用") // 存入权限列表
            .build(); // 执行构建,得到UserDetails类型的对象
    log.debug("即将向Spring Security返回UserDetails类型的对象,返回结果:{}", userDetails);
    return userDetails;
}

注意:Spring Security在验证登录时,要求密码必须经过加密处理,即:在loadUserByUsername()方法中返回的UserDetails中的密码必须是密文,即使你执意不加密,也必须明确的表示出来!

SecurityConfiguration类中,通过@Bean方法来配置PasswordEncoder,并返回某个密码编码器对象,Spring Security会自动使用它来验证密码,例如,可以使用NoOpPasswordEncoder,表示“不对密码进行加密处理”,例如:

@Bean
public PasswordEncoder passwordEncoder() {
    return NoOpPasswordEncoder.getInstance();
}

提示:当项目中存在UserDetailsService类型的组件对象时,Spring Security框架不再提供默认的账号(用户名为user,密码为启动时的UUID值的账号),所以,启动项目时也不会看到临时的UUID密码了。

关于BCrypt算法

BCrypt算法是主流的、最安全的用于处理密码加密的算法之一!其主要特征有:

  • 密文长度固定,是60位的
  • 使用了随机的盐值,所以,使用相同的原文反复加密会得到不同的密文
  • 运算效率极低,大多家用电脑在强度10时,每秒只能运算13次左右
    • 由于运算效率极低,不容易被暴力破解
public class BCryptTests {

    // BCryptPasswordEncoder的构造方法的int参数表示“强度”
    // 强度表示加密时会执行2的多少次方的哈希运算
    // 如果使用无参数构造方法,则强度值为10
    // 建议不要使用15以上的强度值
    BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

    @Test
    void encode() {
        String rawPassword = "123456";
        System.out.println("原文:" + rawPassword);

        long start = System.currentTimeMillis();
        for (int i = 0; i < 13; i++) {
            String encodedPassword = passwordEncoder.encode(rawPassword);
            System.out.println("密文:" + encodedPassword);
        }
        long end = System.currentTimeMillis();
        System.out.println(end - start);
    }

    @Test
    void matches() {
        String rawPassword = "123456";
        System.out.println("原文:" + rawPassword);

        String encodedPassword = "$2a$10$ZWwzVRwZGI/aKEmgcrwid.Ch6pKySRgtQrSMQBf3YNGeTDSiGIKQq";
        System.out.println("密文:" + encodedPassword);

        boolean result = passwordEncoder.matches(rawPassword, encodedPassword);
        System.out.println("对比结果:" + result);
    }

}

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

使用数据库中的账号实现登录,需要实现“根据用户名查询用户的登录信息”,需要执行的SQL语句大致是:

select id, username, password, enable from ams_admin where username=?

首先,需要在项目的根包下创建pojo.vo.AdminLoginInfoVO类:

@Data
public class AdminLoginInfoVO implements Serializable {
    private Long id;
    private String username;
    private String password;
    private Integer enable;
}

然后,在AdminMapper.java中添加抽象方法:

/**
 * 根据用户名查询管理员的登录信息
 *
 * @param username 用户名
 * @return 匹配的登录信息,如果没有匹配的数据,则返回null
 */
AdminLoginInfoVO getLoginInfoByUsername(String username);

并在AdminMapper.xml中配置:

<!-- AdminLoginInfoVO getLoginInfoByUsername(String username); -->
<select id="getLoginInfoByUsername" resultMap="LoginInfoResultMap">
    SELECT
        <include refid="LoginInfoQueryFields"/>
    FROM
        ams_admin
    WHERE
        username=#{username}
</select>

<sql id="LoginInfoQueryFields">
    <if test="true">
        id, username, password, enable
    </if>
</sql>

<resultMap id="LoginInfoResultMap" type="cn.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO">
    <id column="id" property="id"/>
    <result column="username" property="username"/>
    <result column="password" property="password"/>
    <result column="enable" property="enable"/>
</resultMap>

完成后,在AdminMapperTests中测试:

@Test
void getLoginInfoByUsername() {
    String username = "root";
    Object queryResult = mapper.getLoginInfoByUsername(username);
    log.debug("根据用户名【{}】查询登录信息完成,查询结果:{}", username, queryResult);
}

接下来,调整UserDetailsServiceImpl中的loadUserByUsername()方法,改为“通过数据库查询管理员详情并返回”,例如:

@Autowired
private AdminMapper adminMapper;

@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
    log.debug("Spring Security自动调用了loadUserByUsername()方法,参数:{}", s);

    AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);
    log.debug("根据用户名【{}】查询登录信息,结果:{}", s, loginInfo);
    if (loginInfo == null) {
        String message = "用户名不存在,将无法返回有效的UserDetails对象,则返回null";
        log.warn(message);
        return null;
    }

    log.debug("开始创建返回给Spring Security的UserDetails对象……");
    UserDetails userDetails = User.builder() // 构建者模式
            .username(loginInfo.getUsername()) // 存入用户名
            .password(loginInfo.getPassword()) // 存入密码
            .disabled(loginInfo.getEnable() != 1) // 存入启用、禁用状态
            .accountLocked(false) // 存入账号是否锁定的状态
            .credentialsExpired(false) // 存入凭证是否过期的状态
            .accountExpired(false) // 存入账号是否过期的状态
            .authorities("这是一个临时的山寨权限,暂时没什么用") // 存入权限列表
            .build(); // 执行构建,得到UserDetails类型的对象
    log.debug("即将向Spring Security返回UserDetails类型的对象,返回结果:{}", userDetails);
    return userDetails;
}

由于数据库的密码是基于BCrypt算法进行加密处理的,所以,还需要替换掉SecurityConfiguration中通过@Bean方法配置的密码编码器,需要改为BCryptPasswordEncoder,例如:

@Bean
public PasswordEncoder passwordEncoder() {
    // return NoOpPasswordEncoder.getInstance();
    return new BCryptPasswordEncoder();
}

完成后,可以重启项目,测试登录。

关于防止伪造的跨域攻击

**伪造的跨域攻击:**此类攻击是基于“服务器对客户端的浏览器的信任”,例如,用户在浏览器的第1个选项卡中登录了,那么,在第2个、第3个等等同一个浏览器的其它选项卡中访问同样的服务器,也会被视为“已登录”的状态。所以,假设某个用户在浏览器的第1个选项卡中登录了网上银行,此用户在第2个选项卡中打开了另一个网站,此网站可能是恶意的网站(不是此前第1个选项卡的网上银行的网站),在恶意网站中隐藏了一个向网上银行的网站发起请求的链接,并自动发出了请求(比较典型的做法是将链接设置为<img>标签的src属性值,并隐藏此<img>标签使之不显示),则会导致在第2个选项卡中的恶意网站被打开时,就自动的向网上银行发起了请求,而网上银行收到了请求后,会视为“已登录”的状态!

<img src="http://网上银行1/转账?目标=坏人&金额=500" style="display: none;"/>
<img src="http://网上银行2/转账?目标=坏人&金额=500" style="display: none;"/>
<img src="http://网上银行3/转账?目标=坏人&金额=500" style="display: none;"/>

当然,以上只是模拟的情况,并不可能真正的实现转账,毕竟当下的网上银行的转账等涉及财产的保护措施很多,不会收到请求就直接执行转账,通常可能需要输入密码、短信验证码等等,但是,对于防护措施不完全的网站,仍可能受到此类攻击!

**典型的防御机制:**在不是前后端分离的开发模式下,服务器端在生成表单时,会在表单中隐藏一个具有“唯一性”或很强的“随机性”的值,正常提交表单时,此值会随着表单中的其它数据一并提交到服务器端,服务器端会接收并检查这个“唯一”、“随机”的值,如果这个值与生成表单时的相同,则视为正常请求,否则,将视为“伪造的跨域攻击”!

以Spring Security默认的登录表单为例:

1680514585773.png

当Spring Security启用了“防止伪造的跨域攻击”的防御机制后,所有POST请求的请求参数中都必须携带以上名为_csrf的请求参数,且参数必须是服务器端生成的值,由于我们自行开发的功能并不知道此值是多少,无法携带这个请求参数,所以,默认情况下,所有POST请求都会被视为“伪造的跨域攻击”,服务器端会响应403错误!

在“前后端分离”的项目中,由于服务器端不再负责生成各表单页面,也就无法在表单中添加以上_csrf的请求数据,则提交请求时,不可能提交正确的_csrf参数,所以,这种防御机制在前后端分离的项目中并不适用!

SecurityConfiguration中的configurer(HttpSecurity http)方法中添加配置,关闭“防止伪造的跨域攻击”这种防御机制:

http.csrf().disable();

重启后,POST类型的请求不再是403错误!

前后端分离的登录

首先,应该先不启用Spring Security的默认登录表单,即:在Spring Security的配置类中,不再调用http.formLogin()方法,并且,API文档的相关请求必须配置在“白名单”中:

// 白名单
// 所有路径必须使用 / 作为第1个字符
// 使用1个星号作为通配符时,表示通配此层级的任意资源,例如:/admins/*,可以匹配 /admins/delete、/admins/add-new
// 但是,不可以匹配多层级,例如:/admins/* 不可匹配 /admins/9527/delete
// 使用2个连续的星号,表示通配若干层级的任意资源,例如:/admins/* 可以匹配 /admins/delete、/admins/9527/delete
String[] urls = {
        "/doc.html",
        "/favicon.ico",
        "/**/*.css",
        "/**/*.js",
        "/swagger-resources",
        "/v2/api-docs",
};

然后,还需要:

  • 需要使用控制器(Controller)接收来自客户端提交的用户名和密码
    • 通常,应该自定义类,封装客户端提交的请求参数
  • 在Service层中验证登录
    • 具体的处理,仍应该交给Spring Security来实现,只需要手动调用AuthenticationManager(认证管理器)对象的authenticate()方法,接下来,Spring Security框架会自动调用UserDetailsServiceImpl接口对象的loadUserByUsername()方法获取用户信息详情,并自动验证此用户是否允许登录

所以,在项目的根包下创建pojo.dto.AdminLoginDTO类,用于封装客户端提交的用户名和密码:

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

import lombok.Data;

import java.io.Serializable;

/**
 * 管理员登录信息的DTO类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Data
public class AdminLoginDTO implements Serializable {

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

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

}

然后,将csmall-product项目中的相关类复制到当前项目中:

  • ServiceCode
  • ServiceException
  • JsonResult

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

@PostMapping("/login")
public JsonResult login(AdminLoginDTO adminLoginDTO) {
    log.debug("开始处理【管理员登录】的请求,参数:{}", adminLoginDTO);
    // TODO 具体功能待定
    return JsonResult.ok();
}

并且,将以上配置的请求路径添加在“白名单”中:

String[] urls = {
        "/doc.html",
        "/favicon.ico",
        "/**/*.css",
        "/**/*.js",
        "/swagger-resources",
        "/v2/api-docs",
        "/admins/login" // 新增
};

至于Service处理验证登录的部分,需要:

在项目的根包下创建service.IAdminService接口,并在其中声明处理登录的抽象方法:

@Transactional
public interface IAdminService {

    void login(AdminLoginDTO adminLoginDTO);

}

在项目的根包下创建service.impl.AdminServiceImpl类,实现以上接口:

@Slf4j
@Service
public class AdminServiceImpl implements IAdminService {

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

}

至此,在AdminController中就可以注入Service对象,并调用Service方法处理登录(虽然Service中还没有具体的实现验证登录):

@Autowired
private IAdminService adminService;

@PostMapping("/login")
public JsonResult login(AdminLoginDTO adminLoginDTO) {
    log.debug("开始处理【管理员登录】的请求,参数:{}", adminLoginDTO);
    
    // 调用Service方法处理登录
    adminService.login(adminLoginDTO);
    
    return JsonResult.ok();
}

具体的验证过程,需要使用到AuthenticationManager,它是一个接口,这种类型的对象可以通过Spring Security的配置类中,重写父类的方法并添加@Bean注解得到,所以,在SecurityConfiguration中添加:

@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
}

然后,在Service的方法中处理验证登录:

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

    // 创建认证信息对象
    Authentication authentication = new UsernamePasswordAuthenticationToken(
            adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
    // 调用认证管理器执行认证
    authenticationManager.authenticate(authentication);
    // 如果没有出现异常,则表示验证登录成功
    log.debug("验证登录成功");
}

目前,可以尝试通过API文档的调整功能测试登录,当使用正确的账号时,将响应成功,当用户名不存在、密码错误、账号状态错误时,将响应403

在项目中添加全局异常处理器,可以发现,当登录失败时,全局异常处理器中捕获并处理Throwable异常的方法被执行,并且,通过处理过程中获取的异常信息,可以看到:

  • 当用户名不存在时,Spring Security将抛出异常:
org.springframework.security.authentication.InternalAuthenticationServiceException: UserDetailsService returned null, which is an interface contract violation
  • 当密码错误时,Spring Security将抛出异常:
org.springframework.security.authentication.BadCredentialsException: 用户名或密码错误
  • 当账号状态错误时,Spring Security将抛出异常:
org.springframework.security.authentication.DisabledException: 用户已失效

以上异常的继承结构是:

AuthenticationException
-- BadCredentialsException(密码错误)
-- AuthenticationServiceException
-- -- InternalAuthenticationServiceException(用户名错误)

则先在ServiceCode中补充新的业务状态码:

/**
 * 错误:登录失败,用户名或密码错
 */
ERR_UNAUTHORIZED(40100),
/**
 * 错误:登录失败,账号被禁用
 */
ERR_UNAUTHORIZED_DISABLED(40101),

并在全局异常处理器中添加处理以上异常的方法:

@ExceptionHandler({
        InternalAuthenticationServiceException.class,
        BadCredentialsException.class
})
public JsonResult handleAuthenticationException(AuthenticationException e) {
    String message = "登录失败,用户名或密码错!";
    log.warn(message);
    return JsonResult.fail(ServiceCode.ERR_UNAUTHORIZED, message);
}

@ExceptionHandler
public JsonResult handleDisabledException(DisabledException e) {
    String message = "登录失败,此账号已经被禁用!";
    log.warn(message);
    return JsonResult.fail(ServiceCode.ERR_UNAUTHORIZED_DISABLED, message);
}

以上处理的流程大致是:

1680579166386.png

关于通过认证的标准

在Spring Security中,为每个客户端分配了一个SecurityContext,并且,Spring Security会根据在SecurityContext中是否存在有效的Authentication(认证信息)来判断是否已经通过认证!即:

  • 如果在SecurityContext存在有效的Authentication:已通过认证
  • 如果在SecurityContext不存在有效的Authentication:未通过认证

并且,SecurityContext默认是基于Session的,所以,也符合Session的相关特征,例如默认的有效期(过期时间)。

在项目中,可以通过SecurityContextHolder的静态方法getContext()方法即可得到当前客户端对应的SecurityContext对象。

所以,在AdminServiceImpl中验证登录时,当视为“登录成功”,需要将此用户(管理员)对应的Authentication存入到SecurityContext中:

@Override
public void login(AdminLoginDTO adminLoginDTO) {
    log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
    Authentication authentication = new UsernamePasswordAuthenticationToken(
            adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
    
    // 【调整】调用以下方法时,获取返回的结果
    Authentication authenticationResult
            = authenticationManager.authenticate(authentication);
    // 【新增】将Authentication存入到SecurityContext中
    SecurityContext securityContext = SecurityContextHolder.getContext();
    securityContext.setAuthentication(authenticationResult);
}

关于authenticate()的返回结果

当调用AuthenticationManagerauthenticate()方法验证登录时,如果验证通过,返回的结果例如:

2023-04-04 14:45:03.800 DEBUG 6076 --- [nio-9081-exec-1] c.t.c.p.service.impl.AdminServiceImpl    : 验证登录成功,返回的Authentication为:

UsernamePasswordAuthenticationToken [
   Principal=org.springframework.security.core.userdetails.User [
      Username=root, 
      Password=[PROTECTED], 
      Enabled=true, 
      AccountNonExpired=true, 
      credentialsNonExpired=true, 
      AccountNonLocked=true, 
      Granted Authorities=[这是一个临时的山寨权限,暂时没什么用]
   ], 
   Credentials=[PROTECTED], 
   Authenticated=true, 
   Details=null, 
   Granted Authorities=[这是一个临时的山寨权限,暂时没什么用]
]

以上Authentication中的Principal属性(当事人)其实就是UserDetailsService实现类中返回的UserDetails类型的对象!

识别当事人

在由Spring系列框架调用的方法中,可以在参数列表中添加当事人类型的参数,并在此参数上添加@AuthenticationPrincipal注解,则框架会自动为此参数注入值,例如:

@GetMapping("")
//                 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 注解
//                                      当事人的数据类型 ↓↓↓↓
public String list(@AuthenticationPrincipal @ApiIgnore User user) {
    log.debug("当事人的用户名:{}", user.getUsername());
    return "接收到【查询管理员列表】的请求,但是,服务器端尚未实现此功能!";
}

可以注入的当事人,就是SecurityContext中的Authentication中的Principal,目前,这个值就是UserDetailsSerivce类型中loadUserByUsername()返回的UserDetails类型的对象!

由Spring Security提供的UserDetails类型的典型实现类User不一定能满足编程需求,例如,此类型中没有id属性,则会导致后续在控制器中也无法直接得知当前登录的用户(管理员)的ID值!

可以自定义类,直接实现UserDetails接口,或继承自User类从而间接的实现UserDetails接口,并作为loadUserByUsername()方法的返回结果。

在项目的根包下创建security.AdminDetails类,继承自User类:

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

    @Getter
    private Long id;

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

}

然后,调整UserDetailsServiceImpl类中loadUserByUsername()方法的返回结果,改为返回AdminDetails类型的对象:

@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
    log.debug("Spring Security自动调用了loadUserByUsername()方法,参数:{}", s);

    AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);
    log.debug("根据用户名【{}】查询登录信息,结果:{}", s, loginInfo);
    if (loginInfo == null) {
        String message = "用户名不存在,将无法返回有效的UserDetails对象,则返回null";
        log.warn(message);
        return null;
    }

    log.debug("开始创建返回给Spring Security的UserDetails对象……");
    
   // ========== 以下是新的代码,替换了原有的代码 ==========
    Collection<GrantedAuthority> authorities = new ArrayList<>();
    authorities.add(new SimpleGrantedAuthority("这是一个临时的山寨权限,暂时没什么用"));

    AdminDetails adminDetails = new AdminDetails(
            loginInfo.getId(),
            loginInfo.getUsername(),
            loginInfo.getPassword(),
            loginInfo.getEnable() == 1,
            authorities
    );

    log.debug("即将向Spring Security返回UserDetails类型的对象,返回结果:{}", adminDetails);
    return adminDetails;
}

经过以上调整后,在SecurityContext中的Authentication中的Principal就是以上返回的AdminDetails类型的对象,则可以在方法中注入:

@GetMapping("")
//                            注入的类型改为AdminDetails ↓↓↓↓↓↓↓↓↓↓↓↓
public String list(@AuthenticationPrincipal @ApiIgnore AdminDetails adminDetails) {
    log.debug("当事人的ID:{}", adminDetails.getId()); // 可以获取扩展的属性值
    log.debug("当事人的用户名:{}", adminDetails.getUsername());

    return "接收到【查询管理员列表】的请求,但是,服务器端尚未实现此功能!";
}

处理授权

Spring Security框架的核心价值之一就是“授权”,即验证当前用户是否具有访问某个资源的权限。

首先,需要调整原有的Mapper层的“根据用户名查询管理员的登录信息”功能,将用户名对应的管理员的权限列表查询出来,这是一个关联查询:

  • 先根据用户名查询ams_admin表,得到此用户名对应的管理员ID
  • 根据管理员ID,关联ams_admin_role表,得到关联的角色ID
  • 根据角色ID,关联ams_role_permission表,得到关联的权限ID
  • 根据权限ID,关联ams_permission表,得到权限信息

需要执行的SQL语句大致是:

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 username='root';

则需要在AdminLoginInfoVO中添加新的属性,用于封装管理员的权限列表:

@Data
public class AdminLoginInfoVO implements Serializable {

    // 暂不关心原有的其它代码
    
    // ===== 以下是新增的属性 =====
    /**
     * 权限列表
     */
    private List<String> permissions;

}

然后,调整AdminMapper.xml中的配置:

<!-- AdminLoginInfoVO getLoginInfoByUsername(String username); -->
<select id="getLoginInfoByUsername" resultMap="LoginInfoResultMap">
    SELECT
        <include refid="LoginInfoQueryFields"/>
    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
        username=#{username}
</select>

<sql id="LoginInfoQueryFields">
    <if test="true">
        ams_admin.id,
        ams_admin.username,
        ams_admin.password,
        ams_admin.enable,
        ams_permission.value
    </if>
</sql>

<!-- collection标签:配置List类型的属性 -->
<!-- collection标签的property属性:List类型的属性的名称 -->
<!-- collection标签的ofType属性:List属性的元素的数据类型,取值为元素类型的全限定名,java.lang包下的类可以省略包名 -->
<!-- collection标签的子级:配置如何创建出List集合中的各个元素对象 -->
<!-- collection标签的子级的constructor标签:通过构造方法创建对象 -->
<!-- constructor标签的子级的arg标签:配置构造方法的参数 -->
<resultMap id="LoginInfoResultMap" type="cn.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO">
    <id column="id" property="id"/>
    <result column="username" property="username"/>
    <result column="password" property="password"/>
    <result column="enable" property="enable"/>
    <collection property="permissions" ofType="String">
        <constructor>
            <arg column="value"/>
        </constructor>
    </collection>
</resultMap>

以上查询结果例如:

2023-04-04 17:20:12.529 DEBUG 12956 --- [           main] c.t.c.passport.mapper.AdminMapperTests   : 

根据用户名【root】查询登录信息完成,查询结果:

AdminLoginInfoVO(
   id=1, 
   username=root, 
   password=$2a$10$N.ZOn9G6/YLFixAOPMg/h.z7pCu6v2XyFDtC4q.jeeGm/TEZyj15C, 
   enable=1, 
   permissions=[
      /ams/admin/read, /ams/admin/add-new, 
      /ams/admin/delete, /ams/admin/update, 
      /pms/product/read, /pms/product/add-new, 
      /pms/product/delete, /pms/product/update, 
      /pms/brand/read, /pms/brand/add-new, 
      /pms/brand/delete, /pms/brand/update, 
      /pms/category/read, /pms/category/add-new, 
      /pms/category/delete, /pms/category/update, 
      /pms/picture/read, /pms/picture/add-new, 
      /pms/picture/delete, /pms/picture/update, 
      /pms/album/read, /pms/album/add-new, 
      /pms/album/delete, /pms/album/update
   ]
)

**注意:**在本次查询中,即使列表与属性名相同,对应的配置标签也都是必须的!

UserDetailsServiceImpl中,调整原有的“山寨权限”的相关代码,改为真实的权限:

@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private AdminMapper adminMapper;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        log.debug("Spring Security自动调用了loadUserByUsername()方法,参数:{}", s);

        AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);
        log.debug("根据用户名【{}】查询登录信息,结果:{}", s, loginInfo);
        if (loginInfo == null) {
            String message = "用户名不存在,将无法返回有效的UserDetails对象,则返回null";
            log.warn(message);
            return null;
        }

        // ===== 以下是本次调整的代码 ======
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        for (String permission : loginInfo.getPermissions()) {
            authorities.add(new SimpleGrantedAuthority(permission));
        }
        
        // 暂不关心后续的本次未调整的代码
    }

}

经过以上调整后,在SecurityContext中的Authentication中的Principal中的权限,就是从数据库中查出来的管理员的真实权限!

接下来,就可以利用Spring Security的检查权限的机制,需要先在配置类上开启“基于方法的权限检查”,例如:

@Slf4j
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true) // 新增
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    // 暂不关心方法内部的代码
}

注意:以上注解是用于开启基于方法的权限检查,并不要求是哪个组件中的方法,也就是说,你可以把以上注解添加在项目中的任何方法上,例如Service中的方法、Controller中的方法,甚至其它组件中的方法,由于当前项目中,所有Service方法都是被Controller方法调用的,所以,推荐将检查权限的注解添加在Controller中的方法上。

然后,在AdminController中:

@GetMapping("")
@PreAuthorize("hasAuthority('/ams/admin/read')") // 新增
public String list(@AuthenticationPrincipal @ApiIgnore 
    AdminDetails adminDetails) {
    // 暂不关心方法内部的代码
}

至此,权限的配置已经完成!如果使用无权限的账号发起请求,服务器端会出现错误:

org.springframework.security.access.AccessDeniedException: 不允许访问

所以,还应该在全局异常处理器中添加对以上异常进行处理的方法:

@ExceptionHandler
public JsonResult handleAccessDeniedException(AccessDeniedException e) {
    String message = "禁止访问,当前登录的账号无此操作权限!";
    log.warn(message);
    return JsonResult.fail(ServiceCode.ERR_FORBIDDEN, message);
}

# 附:关于权限设计

当用户的数量很多时:

```
【用户与角色的关联表】
1  1:红钻
1  2:绿钻
1  3:黑钻
2  1:红钻
3  1:红钻
4  1:红钻
5  1:红钻

【角色与权限的关联表】
1  xx
1  xx
1  xx
2  xx
```

当管理员的数量较少时:

```
【管理员与权限的关联表】
管理员ID  权限ID
1        1
1        2
1        3
1        4
1        5
1        6
2        3
2        4
2        5
3        1
3        3
3        5

4        2
4        3
4        4
5        2
5        3
5        4
6        2
6        3
6        4
7        2
7        3
7        4
8        2
8        3
8        4
```