SpringSecurity用户身份认证与授权
in JavaDevelop with 0 comment

SpringSecurity用户身份认证与授权

in JavaDevelop with 0 comment

[

关于用户身份认证与授权

基本原理

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

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 退出登录。

自定义用户认证逻辑

处理用户信息获取逻辑

org.springframework.security.core.userdetails.UserDetailsService

UserDetailsService接口用于加载用户特定的数据,它在整个框架中作为用户DAO使用,是验证提供者使用的策略。
该接口只需要一个只读方法,这简化了对新的数据访问策略的支持。

实现一个自定义的UserDetailsService

package cn.mrcode.imooc.springsecurity.securitybrowser;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.authority.AuthorityUtils;
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.Component;

/**
 * ${desc}
 * @author zhuqiang
 * @version 1.0.1 2018/8/3 9:16
 * @date 2018/8/3 9:16
 * @since 1.0
 */
// 自定义数据源来获取数据
// 这里只要是存在一个自定义的 UserDetailsService ,那么security将会使用该实例进行配置
@Component
public class MyUserDetailsService implements UserDetailsService {
    Logger logger = LoggerFactory.getLogger(getClass());

    // 可以从任何地方获取数据
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 根据用户名查找用户信息
        logger.info("登录用户名", username);
        // 写死一个密码,赋予一个admin权限
        return new User(username, "123456",
                        AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

这样就能让自定义的UserdetailsService生效了。但是在浏览器中登录的时候,后台报错了

java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"

这个异常是spring security5+后密码策略变更了。必须使用 PasswordEncoder 方式也就是你存储密码的时候
需要使用{noop}123456这样的方式。这个在官网文档中有讲到

花括号里面的是encoder id ,这个支持的全部列表在以下的方法中定义

org.springframework.security.crypto.factory.PasswordEncoderFactories#createDelegatingPasswordEncoder

noop 对应的处理类是org.springframework.security.crypto.password.NoOpPasswordEncoder,只用于测试;因为没有做任何加密功能

修改完成之后再次访问。发现可以了

处理用户校验逻辑

自定义的其他逻辑是在 org.springframework.security.core.userdetails.User 中提供的,
只要在登录的时候把user中提供的信息返回即可达到支持的业务逻辑,下面列出支持的业务场景:

// 这里的几个布尔值的含义对应上面列出来的顺序
User admin = new User(username, "{noop}123456",
                      true, true, true, false,
                      AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));

处理密码加密解密

密码加密解密是使用了下面这个类

org.springframework.security.crypto.password.PasswordEncoder

在自己摸索中也经常提示 这个类或则什么id为null;

配置只需要提供一个实例即可,会自动使用该实例

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

在UserDetailsService中需要模拟存入数据库中的密码就是加密后的字符串

@Component
public class MyUserDetailsService implements UserDetailsService {
    Logger logger = LoggerFactory.getLogger(getClass());

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        logger.info("登录用户名:{}", username);
        String password = passwordEncoder.encode("123456");
        logger.info("数据库密码{}", password);
        User admin = new User(username,
//                              "{noop}123456",
                              password,
                              true, true, true, true,
                              AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

其实框架会把提交的密码使用我们定义的passwordEncode加密后调用 org.springframework.security.crypto.password.PasswordEncoder#matches方法,
与 返回的User中的密码进行比对。配对正常就验证通过;

唯一感觉奇怪的是{noop}123456方式的密码居然可以不用写了;
尝试过{bcrypt}123456还是不行

尝试两次登录,发下每次加密的都不一样,但是还能对上结果。感觉很强大;
介绍说:对于同一个串加密多次产生的不一样,就不会存在暴利破解一个串,其他串都失效的情况.

但是本人还是没有想明白。总感觉哪里没有想明白一样,不同的串都能反推出来,有啥安全的?

2018-08-03 13:45:58.614  INFO 18300 --- [nio-8080-exec-3] c.m.i.s.s.MyUserDetailsService           : 登录用户名:admin
2018-08-03 13:45:58.708  INFO 18300 --- [nio-8080-exec-3] c.m.i.s.s.MyUserDetailsService           : 数据库密码$2a$10$TaQerjh.VaTRfSLxUozH/eaxgZAcM1H7b0NHEj3peL8Ar8cGfY/R.
2018-08-03 13:47:08.035  INFO 18300 --- [nio-8080-exec-7] c.m.i.s.s.MyUserDetailsService           : 登录用户名:admin
2018-08-03 13:47:08.128  INFO 18300 --- [nio-8080-exec-7] c.m.i.s.s.MyUserDetailsService           : 数据库密码$2a$10$jR3gKmOp7LifbXPPHie.JuOW1FmqklzsdZm1spK/r19MkXjpPOa4a

总结

自定义登录成功处理

security 默认的登录成功处理是跳转到需要授权之前访问的url;
而在一些场景下:比如 前后分离,登录是通过ajax访问,没有办法处理301跳转;
而是登录成功则返回相关的数据即可;

自定义入口还是在表单登录处配置的

http
        // 定义表单登录 - 身份认证的方式
        .formLogin()
        .loginPage("/authentication/require")
        .loginProcessingUrl("/authentication/form")
        .successHandler(myAuthenticationSuccessHandler)

myAuthenticationSuccessHandler 的编写

/**
 * .formLogin().successHandler() 中需要的处理器类型
 * @author zhuqiang
 * @version 1.0.1 2018/8/3 16:29
 * @date 2018/8/3 16:29
 * @since 1.0
 */
@Component("myAuthenticationSuccessHandler")
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    private org.slf4j.Logger logger = LoggerFactory.getLogger(getClass());

    //  com.fasterxml.jackson.databind.
    // spring 是使用jackson来进行处理返回数据的
    // 所以这里可以得到他的实例
    @Autowired
    private com.fasterxml.jackson.databind.ObjectMapper objectMapper;

    /**
     * @param request
     * @param response
     * @param authentication 封装了所有的认证信息
     * @throws IOException
     * @throws ServletException
     */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        logger.info("登录成功");

        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(authentication));
    }
}

查看输出的authentication

{
    "authorities": [
        {
            "authority": "admin"
        }
    ],
    "details": {
        "remoteAddress": "0:0:0:0:0:0:0:1",
        "sessionId": "FE0F33577E7E5D89AF15FCCD6FE5A4B3"
    },
    "authenticated": true,
    "principal": {
        "password": null,
        "username": "admin",
        "authorities": [
            {
                "authority": "admin"
            }
        ],
        "accountNonExpired": true,
        "accountNonLocked": true,
        "credentialsNonExpired": true,
        "enabled": true
    },
    "credentials": null,
    "name": "admin"
}

自定义失败处理

和处理成功类似,实现类为 org.springframework.security.web.authentication.AuthenticationFailureHandler

封装成可配置属性

编写属性配置支持枚举

package cn.mrcode.imooc.springsecurity.securitycore.properties;

/**
 * 登录成功/失败是跳转还是返回json
 * @author zhuqiang
 * @version 1.0.1 2018/8/3 16:48
 * @date 2018/8/3 16:48
 * @since 1.0
 */
public enum LoginType {
    REDIRECT,
    JSON
}

更改登录成功的后的处理器

@Component("myAuthenticationSuccessHandler")
public class MyAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
    private org.slf4j.Logger logger = LoggerFactory.getLogger(getClass());

    //  com.fasterxml.jackson.databind.
    // spring 是使用jackson来进行处理返回数据的
    // 所以这里可以得到他的实例
    @Autowired
    private com.fasterxml.jackson.databind.ObjectMapper objectMapper;

    @Autowired
    private SecurityProperties securityProperties;

    /**
     * @param request
     * @param response
     * @param authentication 封装了所有的认证信息
     * @throws IOException
     * @throws ServletException
     */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        logger.info("登录成功");
        if (securityProperties.getBrowser().getLoginType() == LoginType.JSON) {
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write(objectMapper.writeValueAsString(authentication));
        } else {
            // 把本类实现父类改成 AuthenticationSuccessHandler 的子类 SavedRequestAwareAuthenticationSuccessHandler
            // 之前说spring默认成功是跳转到登录前的url地址
            // 就是使用的这个类来处理的
            super.onAuthenticationSuccess(request, response, authentication);
        }
    }
}

这样登录成功的就ok了。
对于失败的来说是一样的,继承的父类改成spring默认的处理器

org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler

Bcrypt算法

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)中实现此查询功能,需要:

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

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)来解决识别用户身份的问题。