关于用户身份认证与授权

Spring Security是用于解决认证与授权的框架

在根项目下创建新的csmall-passport子模块,最基础的依赖项包括spring-boot-starter-webspring-boot-starter-security(为避免默认存在的测试类出错,应该保留测试的依赖项spring-boot-starter-test),完整的csmall-passwortpom.xml为:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!-- 父级项目 -->
    <parent>
        <groupId>cn.tedu</groupId>
        <artifactId>csmall-server</artifactId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>

    <!-- 当前项目的信息 -->
    <groupId>cn.tedu</groupId>
    <artifactId>csmall-passport</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <!-- 当前项目需要使用的依赖项 -->
    <dependencies>
        <!-- Spring Boot Web:支持Spring MVC -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- Spring Boot Security:处理认证与授权 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <!-- Spring Boot Test:测试 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

</project>

调整完成后,即可启动项目,在启动的日志中,可以看到类似以下内容:

Using generated security password: 2abb9119-b5bb-4de9-8584-9f893e4a5a92

Spring Security有默认登录的账号和密码(以上提示的值),密码是随机的,每次启动项目都会不同。

Spring Security默认要求所有的请求都是必须先登录才允许的访问,可以使用默认的用户名user和自动生成的随机密码来登录。在测试登录时,在浏览器访问当前主机的任意网址都可以(包括不存在的资源),会自动跳转到登录页(是由Spring Security提供的,默认的URL是:http://localhost:8080/login),当登录成功后,会自动跳转到此前访问的URL(跳转登录页之前的URL),另外,还可以通过 http://localhost:8080/logout退出登录

Spring Security的依赖项中包括了Bcrypt算法的工具类,Bcrypt是一款非常优秀的密码加密工具,适用于对需要存储下来的密码进行加密处理。

package cn.tedu.csmall.passport;

import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

public class BcryptPasswordEncoderTests {

    private BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

    @Test
    public void testEncode() {
        // 原文相同的情况,每次加密得到的密文都不同
        for (int i = 0; i < 10; i++) {
            String rawPassword = "123456";
            String encodedPassword = passwordEncoder.encode(rawPassword);
            System.out.println("rawPassword = " + rawPassword);
            System.out.println("encodedPassword = " + encodedPassword);
        }
        // rawPassword = 123456
        // encodedPassword = $2a$10$HWuJ9WgPazrwg9.isaae4u7XdP7ohH7LetDwdlTWuPC4ZAvG.Uc7W
        // encodedPassword = $2a$10$rOwgZMpDvZ3Kn7CxHWiEbeC6bQMGtfX.VYc9DCzx9BxkWymX6FbrS
        // encodedPassword = $2a$10$H8ehVGsZx89lSVHwBVI37OkxWm8LXei4T1o5of82Hwc1rD0Yauhky
        // encodedPassword = $2a$10$meBbCiHZBcYn7zMrZ4fPd.hizrsiZhAu8tmDk.P8QJcCzSQGhXSvq
        // encodedPassword = $2a$10$bIRyvV29aoeJLo6hh1M.yOvKoOud5kC7AXDMSUW4tF/DlcG0bLj9C
        // encodedPassword = $2a$10$eq5BuoAiQ6Uo0.TOPZOFPuRNlPl3t2GoTlaFoYfBu3/Bo3tLzx.v2
        // encodedPassword = $2a$10$DhTSwQfNdqrGgHRmILmNLeV0jt3ZXL435xz0fwyZ315ciI5AuI5gi
        // encodedPassword = $2a$10$T.8/ISoLOdreEEkp4py36O0ZYfihDbdHDuIElZVF3uEgMOX.8sPcK
        // encodedPassword = $2a$10$hI4wweFOGJ7FMduSmcjNBexbKFOjYMWl8hkug0n0k1LNR5vEyhhMW
        // encodedPassword = $2a$10$b4ztMI6tWoiJuoDYKwr7DOywsPkkCdvDxbPfmEsLdp11NdABS7wyy
    }

    @Test
    public void testMatches() {
        String rawPassword = "123456";
        String encodedPassword = "$2a$10$hI4wweFOGJ7FMduSmCjNBexbKFOjYMWl8hkug0n0k1LNR5vEyhhMW";
        boolean matchResult = passwordEncoder.matches(rawPassword, encodedPassword);
        System.out.println("match result : " + matchResult);
    }

}

如果要使得Spring Security能使用数据库中的信息(数据库中的用户名与密码)来验证用户身份(认证),首先,必须实现“根据用户名查询此用户的登录信息(应该包括权限信息)”的查询功能,要实现此查询,需要执行的SQL语句大致是:

select
    ams_admin.id,
    ams_admin.username,
    ams_admin.password,
    ams_admin.is_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';

要在当前模块(csmall-passport)中实现此查询功能,需要:

  • [csmall-passport] 添加数据库编程的相关依赖

    • mysql-connector-java
    • mybatis-spring-boot-starter
    • durid / druid-spring-boot-starter
  • [csmall-passport] 添加连接数据库的配置信息

  • [csmall-passport] 创建MybatisConfiguration配置类,用于配置@MapperScan

  • [csmall-passport] 在配置文件中配置mybatis.mapper-locations属性,以指定XML文件的位置

  • [csmall-pojo] 创建AdminLoginVO

    @Data
    public class AdminLoginVO implements Serializable {
        private Long id;
        private String username;
        private String password;
        private Integer isEnable;
        private List<String> permissions;
    }

  • [csmall-passport] 在pom.xml中添加对csmall-pojo的依赖

  • [csmall-passport] 在src/main/java下的cn.tedu.csmall.passport包下创建mapper.AdminMapper.java接口

  • [csmall-passport] 在接口中添加抽象方法

    AdminLoginVO getLoginInfoByUsername(String username);

  • src/main/resources下创建mapper文件夹,并在此文件夹下粘贴得到AdminMapper.xml

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

    <!-- 忽略固定的代码 -->
    
    <mapper namespace="cn.tedu.csmall.passport.mapper.AdminMapper">
        
        <!-- AdminLoginVO 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.is_enable,
                ams_permission.value
            </if>
        </sql>
        
        <resultMap id="LoginInfoResultMap" type="cn.tedu.csmall.pojo.vo.AdminLoginVO">
            <id column="id" property="id" />
            <result column="username" property="username" />
            <result column="password" property="password" />
            <result column="is_enable" property="isEnable" />
            <collection property="permissions" ofType="java.lang.String">
                <!-- 以下配置类似在Java中执行 new String("/pms/product/read") -->
                <constructor>
                	<arg column="value" />
                </constructor>
            </collection>
        </resultMap>
        
    </mapper>
    
  • 完成后,还应该编写并执行测试

  • 根据有效的用户名查询出的结果例如:

    
    AdminLoginVO(
    	id=1, 
    	username=root, 
    	password=1234, 
    	isEnable=1, 
    	permissions=[
    		/pms/product/read, 
    		/pms/product/update, 
    		/pms/product/delete, 
    		/ams/admin/read, 
    		/ams/admin/update, 
    		/ams/admin/delete
    	]
    )

    Spring Security的认证机制中包含:当客户端提交登录后,会自动调用UserDetailsService接口(Spring Security定义的)的实现类对象中的UserDetails loadUserByUsername(String username)方法(根据用户名加载用户数据),将得到UserDetails类型的对象,此对象中应该至少包括此用户名对应的密码、权限等信息,接下来,Spring Security会自动完成密码的对比,并确定此次客户端提交的信息是否允许登录!类似于:

  • // Spring Security的行为
    UserDetails userDetails = userDetailsService.loadUserByUsername("chengheng");
    // Spring Security将从userDetails中获取密码,用于验证客户端提交的密码,判断是否匹配

    所以,要实现Spring Security通过数据库的数据来验证用户名与密码(而不是采用默认的user用户名和随机的密码),则在cn.tedu.csmall.passport包下创建security.UserDetailsServiceImpl类,实现UserDetailsService接口,并重写接口中的抽象方法:

  • package cn.tedu.csmall.passport.security;
    
    import cn.tedu.csmall.passport.mapper.AdminMapper;
    import cn.tedu.csmall.pojo.vo.AdminLoginVO;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.security.authentication.BadCredentialsException;
    import org.springframework.security.core.userdetails.User;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.security.core.userdetails.UsernameNotFoundException;
    import org.springframework.stereotype.Service;
    
    @Service
    public class UserDetailsServiceImpl implements UserDetailsService {
    
        @Autowired
        private AdminMapper adminMapper;
    
        @Override
        public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
            System.out.println("根据用户名查询尝试登录的管理员信息,用户名=" + s);
            AdminLoginVO admin = adminMapper.getLoginInfoByUsername(s);
            System.out.println("通过持久层进行查询,结果=" + admin);
    
            if (admin == null) {
                System.out.println("根据用户名没有查询到有效的管理员数据,将抛出异常");
                throw new BadCredentialsException("登录失败,用户名不存在!");
            }
    
            System.out.println("查询到匹配的管理员数据,需要将此数据转换为UserDetails并返回");
            UserDetails userDetails = User.builder()
                    .username(admin.getUsername())
                    .password(admin.getPassword())
                    .accountExpired(false)
                    .accountLocked(false)
                    .disabled(admin.getIsEnable() != 1)
                    .credentialsExpired(false)
                    .authorities(admin.getPermissions().toArray(new String[] {}))
                    .build();
            System.out.println("转换得到UserDetails=" + userDetails);
            return userDetails;
        }
    
    }

    完成后,再配置密码加密器即可:

    package cn.tedu.csmall.passport.config;
    
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
    import org.springframework.security.crypto.password.PasswordEncoder;
    
    @Configuration
    public class SecurityConfiguration {
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
    }
    

    重启项目,可以发现在启动过程中不再生成随机的密码值,在浏览器上访问此项目的任何URL,进入登录页,即可使用数据库中的管理员数据进行登录。

    在Spring Security,默认使用Session机制存储成功登录的用户信息(因为HTTP协议是无状态协议,并不保存客户端的任何信息,所以,同一个客户端的多次访问,对于服务器而言,等效于多个不同的客户端各访问一次,为了保存用户信息,使得服务器端能够识别客户端的身份,必须采取某种机制),当下,更推荐使用Token或相关技术(例如JWT)来解决识别用户身份的问题。

    JWT = JSON Web Token,它是通过JSON格式组织必要的数据,将数据记录在票据(Token)上,并且,结合一定的算法,使得这些数据会被加密,然后在网络上传输,服务器端收到此数据后,会先对此数据进行解密,从而得到票据上记录的数据(JSON数据),从而识别用户的身份,或者处理相关的数据。

    其实,在客户端第1次访问服务器端时,是“空着手”访问的,不会携带任何票据数据,当服务器进行响应时,会将JWT响应到客户端,客户端从第2次访问开始,每次都应该携带JWT发起请求,则服务器都会收到请求中的JWT并进行处理。

    要使用JWT,需要添加相关的依赖项,可以实现生成JWT、解析JWT的框架较多,目前,主流的JWT框架可以是jjwt

  • <!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.9.1</version>
    </dependency>

    则在根项目中管理以上依赖,并在csmall-passport中添加以上依赖。

  • 测试方法

  • public class JwtTests {
        // 密钥
        String secretKey = "fjdasflkdjslkffdfadsjlkdsaf";
        @Test
        public void testGenerateJwt() {
            // Claims
            Map<String, Object> claims = new HashMap<>();
            claims.put("id", 9527);
            claims.put("name", "星星");
    
            // JWT的组件部分:Header(头),Payload(载荷),Signature(签名)
            String jwt = Jwts.builder()
                    // Header:指定算法与当前数据类型
                    // 格式为: { "alg": 算法, "typ": "jwt" }
                    .setHeaderParam(Header.CONTENT_TYPE, "HS256")
                    .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                    // Payload:通常包含Claims(自定义数据)和过期时间
                    .setClaims(claims)
                    .setExpiration(new Date(System.currentTimeMillis() + 5 * 60 * 1000))
                    // Signature:由算法和密钥(secret key)这2部分组成
                    .signWith(SignatureAlgorithm.HS256, secretKey)
                    // 打包生成
                    .compact();
    
            // eyJjdHkiOiJIUzI1NiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJuYW1lIjoi5pif5pifIiwiaWQiOjk1MjcsImV4cCI6MTY1NTM2NTY3N30.QwBYVgdkdibEpD-pjX4sKfNu3tw8hBLcJy4-UcN1F3c
            System.out.println(jwt);
        }
        @Test
        public void testParseJwt() {
            String jwt = "eyJjdHkiOiJIUzI1NiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJuYW1lIjoi5pif5pifIiwiaWQiOjk1MjcsImV4cCI6MTY1NTM2NzMwMn0.qBBHearv8iHPNjtDGtO2ci_-KAL4CALHnwzaG_ljsQg";
            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);
        }
    
    }
    


版权声明:本文为m0_59194246原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。