Spring Security 认证

1. 介绍

Spring Security 是一个安全管理框架,核心功能有两个:认证、授权

  • 认证:判断 访问者 是不是系统里的用户,可以简单的认为能否登陆
    • 比如:手机刷脸解锁
  • 授权:判断 访问者 是否有权限做某个操作
    • 比如:去京东买东西,上面的价格只能看,不能买,只有商家才能改

官网地址:https://spring.io/projects/spring-security

2. 快速入门

创建一个普通的maven项目

在这里插入图片描述在这里插入图片描述

1. pom.xml

<!-- 引用父pom -->
<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>2.5.13</version>
</parent>

<dependencies>
  <!-- spring web 依赖 -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
</dependencies>

2. Application

@SpringBootApplication
public class Application {
    
    public static void main(String[] args){
        SpringApplication.run(Application.class,args);
    }
}

3. Controller

@RestController
public class SecController {

    @GetMapping("/sec")
    public String sec(){
        return "spring-secutiry";
    }
}

启动项目,访问结果:
在这里插入图片描述

4. 引入 Security

修改 pom.xml 增加 Spring Security 依赖

<!-- spring security 依赖 -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>

然后重启项目,再次访问:localhsot:8080/sec,就需要登录了
在这里插入图片描述

默认的用户名:user,
密码:
在这里插入图片描述

注意,每次重启,密码都变
退出登陆,访问:http://localhost:8080/logout
在这里插入图片描述

3. 从数据库查询用户

上面的用户名、密码是框架自带的,正常情况下,我们的用户都是存储在数据库中
下面,从数据库中获取用户,完成登陆

1. pom.xml

增加 Mybatis Plus 和 mysql 依赖

<!-- mybatis plus -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.5.1</version>
</dependency>
<!-- 连接MySQL数据库 -->
<dependency>
  <groupId>mysql</groupId>
  <artifactId>mysql-connector-java</artifactId>
  <version>5.1.46</version>
</dependency>

2. properties

#数据库
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/test?useSSL=false
spring.datasource.username=
spring.datasource.password=

3. 数据库

CREATE TABLE `user` (
  `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `username` VARCHAR(45) NULL,
  `password` VARCHAR(255) NULL,
  PRIMARY KEY (`id`));
  
  INSERT INTO `test`.`user` (`id`, `username`, `password`) VALUES ('1', 'fengxiansheng', '{noop}123456');

4.代码

User 实体类

@TableName("user")
public class User {

    @TableId
    private Integer id;
    
    //用户名
    private String username;
    
    //密码
    private String password;
    
    get、set 省略
}

UserMapper 接口

public interface UserMapper extends BaseMapper<User> {

}

Application

@SpringBootApplication
@MapperScan("com.feng.security.mapper")
public class Application {

    public static void main(String[] args){
        SpringApplication.run(Application.class,args);
    }
}

UserService

@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    public User getByUserName(String username){
        Map<String,Object> map = new HashMap<String, Object>();
        map.put("username", username);
        List<User> users = userMapper.selectByMap(map);
        if(CollectionUtils.isEmpty(users)){
            return null;
        }
        return users.get(0);
    }
}

新建 LoginUserService,实现 UserDetailsService 接口,这样就可以从数据库查询用户

/**
 * UserDetailsService 是 spring security 提供的一个类,目的是根据 username 获取用户
 * 框架底层会自动调用 loadUserByUsername 方法,查询用户
 */
@Component
public class LoginUserService implements UserDetailsService {

    @Autowired
    private UserService userService;

    /**
     * 根据用户名查询用户对象
     * @param username 前端传的用户名
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 从数据库查询用户
        User user = userService.getByUserName(username);
        if(user == null){
            throw new UsernameNotFoundException("没有这个用户");
        }

        // 把用户信息封装到一个 UserDetails 对象中,UserDetails是一个接口,LoginUser实现了这个接口
        LoginUser loginUser = new LoginUser();
        loginUser.setUser(user);
        return loginUser;
    }
}

新建 LoginUser 类,实现 UserDetails 接口,用来封装 User 信息

/**
 * UserDetails 是spring security提供的一个接口,目的是封装登录的用户
 * 需要实现这个接口,复写其中的方法,框架会自动调用这些方法做一些处理
 */
public class LoginUser implements UserDetails {

    private User user;

    /**
     * 返回用户密码
     * @return
     */
    @Override
    public String getPassword() {
        return user.getPassword();
    }

    /**
     * 返回用户名
     * @return
     */
    @Override
    public String getUsername() {
        return user.getUsername();
    }

    /**
     * 用户是否过期,可以根据用户的信息判断是否过期
     * @return
     *  false 表示用户过期,不可登陆
     *  true 可以登陆
     */
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }


    /**
     * 用户是否锁定,可以根据用户的信息判断是否锁定
     * @return
     *  false 用户锁定,不可登陆
     *  true 没有锁定,可以登陆
     */
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    /**
     * 用户密码是否过期
     * @return
     *  false 表示过期,不可登陆
     *  true 没有过期,可以登陆
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /**
     * 用户是否禁用
     * @return
     *  false 用户禁用,不可登陆
     *  true 没有禁用,可以登陆
     */
    @Override
    public boolean isEnabled() {
        return true;
    }

    /**
     * 返回这个用户的权限列表,暂时先不管
     * @return
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    public User getUser() {
        return user;
    }

    public void setUser(User user) {
        this.user = user;
    }
}

重启项目,然后故意输入错误的用户密码
在这里插入图片描述

再输入正确的,结果正常跳转
在这里插入图片描述

4. 密码加密

spring security 从数据库中拿到密码后,会跟前端传递的密码进行比较,密码一样才能登陆成功
正常情况下数据库中存储的密码都应该是密文,Spring Security拿到后会自行解密
但是,我们的密码是这样子的:{noop}123456,"{noop}"表示密码没有加密,这样 Spring Security 就不会对 123456进行解密,但是存储明文不合适。
Spring Security 提供的 PasswordEncoder 可以帮我们解决这个问题。

1. PasswordEncoder

这是一个接口,主要有2个方法

  • encode(CharSequence rawPassword)
    • 对字符串进行加密,返回密文
  • matches(CharSequence rawPassword, String encodedPassword)
    • 校验明文(rawPassword))是否跟 密文(encodedPassword)匹配
    • 返回 true,表示匹配

主要实现类:
在这里插入图片描述

实现类有很多,我们使用 BCryptPasswordEncoder,对密码进行加密、解密

2. 修改密码

修改密码,使用 BCryptPasswordEncoder 加密,然后存到数据库
修改 UserService ,增加方法:updatePassword

public void updatePassword(String username, String passwrod){
    User user = getByUserName(username);
    BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
    // {bcrypt}:密码的加密方式,有了这个前缀,spring security 拿到密码后,就使用对应的方式解密
    user.setPassword("{bcrypt}" + passwordEncoder.encode(passwrod));
    userMapper.updateById(user);
}

修改 Controller

@Autowired
private UserService userService;
@GetMapping("/modify")
public String modify(@RequestParam String username, @RequestParam String password){
    userService.updatePassword(username, password);
    return "success";
}

浏览器访问:
在这里插入图片描述

数据库:
在这里插入图片描述

3. 优化

但是这样子数据库存储的密码还是有 “{bcrypt}” 这样的前缀,如果外人看见,就知道了我们的加密方式,安全性不好
解决:搞一个配置类,声明一个 _PasswordEncoder _类型的 bean

@Configuration
public class SecurityConfig {

    /**
     * 声明一个 PasswordEncoder ,
     *      在 userservice 中注入使用
     *      同时 spring security 自动使用这个解密
     * 这样数据库存储的密码就不需要 "{加密方式}",这样的前缀
     * @return
     */
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

修改 UserService

@Autowired
private PasswordEncoder passwordEncoder;

public void updatePassword(String username, String passwrod){
    User user = getByUserName(username);
    user.setPassword(passwordEncoder.encode(passwrod));
    userMapper.updateById(user);
}

手动修改数据库,去掉:{bcrypt} 这个前缀
重启项目后,更新密码为 222222:
在这里插入图片描述
数据库:
在这里插入图片描述

5. 自定义登录页面

1. 准备登陆页面

里面的 login.html 就是我们的登陆页面
把 static 文件夹复制到 项目中的 resource 文件夹下
在这里插入图片描述

2. 修改 SecurityConfig

让 SecurityConfig 继承 WebSecurityConfigurerAdapter

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    /**
     * 声明一个 PasswordEncoder ,
     *      在 userservice 中注入使用
     *      同时 spring security 自动使用这个解密
     * 这样数据库存储的密码就不需要 "{加密方式}",这样的前缀
     * @return
     */
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
    @Override
    public void configure(WebSecurity web) throws Exception {
        // 静态资源不用验证
        web.ignoring().antMatchers("/js/**", "/css/**","/images/**");
    }
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()// 开始认证
                .anyRequest().authenticated()// 任何请求都需要认证
                .and()
                // form 表单认证,登录页面,不需要认证
                .formLogin().loginPage("/login.html").permitAll()
                .and()
                .csrf().disable();//关闭 csrf,后面讨论
    }
}

上面代码 antMatchers 方法中 表达式采用了 Ant 风格的路径匹配符:
在这里插入图片描述

3. 重启项目

浏览器访问:http://localhost:8080/sec,就会自动跳转到我们到 login.html
在这里插入图片描述

输入用户名、密码就可登陆

6. 自定义登录接口

通过配置 loginProcessingUrl 自定义登录接口

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
        .anyRequest().authenticated()
        .and()
        .formLogin().loginPage("/login.html")
        .loginProcessingUrl("/user/login")// 指定登录的接口,也就是点击登录时,提交数据的接口
        .permitAll()
        .and()
        .csrf().disable();
}

上面的配置了 /user/login,也就是我们的 form 表单需要把数据提交到这个接口,所以还要修改 login.html
在这里插入图片描述

然后重启项目,依然可以正常访问

7. 登录参数

默认情况下登录表单中的参数是 username 和 password,这个不能变。这是因为,spring security 在框架中定死的
如果要自己设置,需要:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
        .anyRequest().authenticated()
        .and()
        .formLogin().loginPage("/login.html")
        .loginProcessingUrl("/user/login")// 指定登录的接口,也就是点击登录时,提交数据的接口
        .usernameParameter("name")//用户名
        .passwordParameter("pass")//密码
        .permitAll()
        .and()
        .csrf().disable();
}

同时也需要修改 login.html
在这里插入图片描述

8. 登录回调

1. 成功回调

在 Spring Security 中,登录成功后,重定向 URL 的方法有两个:defaultSuccessUrl、successForwardUrl
准备,先搞一个 index.html 作为首页,我们搞的简单一些

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>Spring Security</title>
</head>
<body>
<h1>这里就是首页</h1>
</body>
</html>

把 index.html 放到项目中的 resource 文件夹下
在这里插入图片描述

1. defaultSuccessUrl

  • defaultSuccessUrl(“/index.html”,false);
    • 如果在浏览器中输入的登录地址,登录成功后,就直接跳转到对应地址
    • 如果在浏览器中输入了其他地址,例如 http://localhost:8080/sec,因为没有登录,所以会重定向到登录页面,登录成功后,来到 /sec 页面
    • 如果第二个参数设置为true,只要登录成功,就直接重定向到对应地址

修改代码:

.and()
    .formLogin().loginPage("/login.html")
    .loginProcessingUrl("/user/login")
    .usernameParameter("name")
    .passwordParameter("pass")
    .defaultSuccessUrl("/index.html",false)// 配置登陆成功后跳转
    .permitAll()

重启项目后,浏览器访问:http://localhost:8080/login.html,登陆成功后
在这里插入图片描述
退出登录,浏览器访问:http://localhost:8080/sec,会跳转到登陆页面,再次登录:
在这里插入图片描述
修改代码,设置第二个参数为true:

.and()
    .formLogin().loginPage("/login.html")
    .loginProcessingUrl("/user/login")
    .usernameParameter("name")
    .passwordParameter("pass")
    .defaultSuccessUrl("/index.html",true)// 只要登陆成功,一定会跳转到/index.html
    .permitAll()

重启项目,浏览器访问:http://localhost:8080/sec,会跳转到登陆页面,再次登录:
在这里插入图片描述
它还有一个重载的方法,没有第二个参数,其实内部调用的就是:defaultSuccessUrl(“/index”,false);
在这里插入图片描述

2. successForwardUrl

successForwardUrl 是转发到指定的地址,比如:

.and()
    .formLogin().loginPage("/login.html")
    .loginProcessingUrl("/user/login")
    .usernameParameter("name")
    .passwordParameter("pass")
    .successForwardUrl("/index")// 只要登陆成功,就转发到:/index
    .permitAll()

新增一个 IndexController

@Controller
public class IndexController {
    
    @PostMapping("/index")
    public String sec(){
        System.out.println("index");
        return "redirect:/index.html";
    }
    
}

演示:省略

2. 失败回调

与登录成功相似,登录失败也是有两个方法:

  • failureUrl
    • 登录失败之后,会发生重定向
  • failureForwardUrl
    • 登录失败之后会发生服务端转发

准备,先搞一个 fail.html 作为首页,我们搞的简单一些

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>Spring Security</title>
</head>
<body>
<h1>登录失败</h1>
</body>
</html>

把 fail.html 放到项目中的 resource 文件夹下

1. failureUrl

修改代码:

http.authorizeRequests()
    .antMatchers("/fail.html").permitAll()  // fail.html 这个请求不需要认证
    .anyRequest().authenticated()
    .and()
    .formLogin().loginPage("/login.html")
    .loginProcessingUrl("/user/login")
    .usernameParameter("name")
    .passwordParameter("pass")
    .defaultSuccessUrl("/index.html",false)
    .failureUrl("/fail.html")// 登陆跳转到 fail.html
    .permitAll()
    .and()
    .csrf().disable();

故意输入错误的密码,结果:
在这里插入图片描述

事实上,重定向到百度都没问题:.failureUrl(“http://www.baidu.com”)
在这里插入图片描述

2. failureForwardUrl

修改代码:

http.authorizeRequests()
    .antMatchers("/fail.html").permitAll()  // fail.html 这个请求不需要认证
    .anyRequest().authenticated()
    .and()
    .formLogin().loginPage("/login.html")
    .loginProcessingUrl("/user/login")
    .usernameParameter("name")
    .passwordParameter("pass")
    .defaultSuccessUrl("/index.html",false)
    .failureForwardUrl("/fail")// 转发到 /fail
    .permitAll()
    .and()
    .csrf().disable();

修改 SecController ,增加:

@PostMapping("/fail")
public String fail(){
    return "登陆失败";
}

结果:
在这里插入图片描述

放开断点
在这里插入图片描述

如果想转发到百度:

@PostMapping("/fail")
public String fail(HttpServletResponse response) {
    return "redirect:http://www.baidu.com";
}

登录失败后的结果:
在这里插入图片描述

3. 注销登录

默认注销的 URL 是 /logout,也可以修改

http.logout().logoutUrl("/user/logout");

在这里插入图片描述

还有一个 logoutRequestMatcher 方法不仅可以修改注销 URL,还可以修改请求方式,

http.logout()
    .logoutRequestMatcher(new AntPathRequestMatcher("/user/logout","POST"));

重启后,浏览器访问:http://localhost:8080/user/logout
在这里插入图片描述

这时候只能使用 postman 工具
在这里插入图片描述

上图中 Cookie 的来源:访问 http://localhost:8080/sec,登陆成功后,在下图中可以找到Cookie
在这里插入图片描述

注意:实际中,logoutRequestMatcher方法和 logoutUrl 任意设置一个即可

logoutSuccessUrl 设置注销成功后要跳转的页面

http.logout().logoutUrl("/user/logout")
    // .logoutRequestMatcher(new AntPathRequestMatcher("/user/logout","POST")) 关闭这种方式,太麻烦
    .logoutSuccessUrl("/fail.html");// 退出后跳转到 fail.html

结果:
在这里插入图片描述

4. Remember Me

1. 介绍

Remember Me 即记住我,目的是让用户选择是否记住用户的登录状态。
当用户选择了 Remember Me 选项,则在有效期内若用户重新访问同一个 Web 应用,那么用户可以直接登录到系统中
在这里插入图片描述

目前我们的项目登录成功后,关闭浏览器,再次访问还需要登录

2. 实现

创建表:persistent_logins

create table persistent_logins (
  username varchar(64) not null, 
  series varchar(64) primary key, 
  token varchar(64) not null, 
  last_used timestamp not null
)

修改 login.html,增加 “记住我” 复选框

<div class="">
  <input type="checkbox" name="remember-me"> 记住我
</div>

在这里插入图片描述

修改 SecurityConfig,增加:

@Autowired
private LoginUserService loginUserService;
@Autowired
private DataSource dataSource;//javax.sql.DataSource包下的类
/**
     * remember me 功能是基于token验证的,
     * 这里是通过JdbcTokenRepositoryImpl把token存到persistent_logins表中
     * @return
     */
@Bean
public JdbcTokenRepositoryImpl jdbcTokenRepositoryImpl() {
    JdbcTokenRepositoryImpl persistentTokenRepository = new JdbcTokenRepositoryImpl();
    persistentTokenRepository.setDataSource(dataSource);
    return persistentTokenRepository;
}
//configure 方法 增加
http.rememberMe()
    .tokenRepository(jdbcTokenRepositoryImpl()) // 配置token持久化仓库
    // .tokenValiditySeconds(3600) // 过期时间,单位为秒
    .userDetailsService(loginUserService); // 处理自动登录逻辑

浏览器登录时,选择“记住我”
在这里插入图片描述

登录成功后,数据库中就已经存储token
在这里插入图片描述

这样关闭浏览器后,再次访问就不用登录了

9. 前后端分离

之前功能都是在前后端不分离的情况下,也就是前端的代码在我们后端的项目中在这里插入图片描述

但实际工作中,大多数都是前后端分离的,这样的开发架构下,前后端的交互都是通过 JSON 来进行交互

准备,pom.xml中添加

<dependency>
  <groupId>com.alibaba</groupId>
  <artifactId>fastjson</artifactId>
  <version>1.2.60</version>
</dependency>

1. 登陆成功返回json

修改 SecurityConfig

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
        .antMatchers("/fail.html").permitAll()
        .anyRequest().authenticated()
        .and()
        .formLogin()
        .loginProcessingUrl("/user/login")
        .usernameParameter("name")
        .passwordParameter("pass")
        // 登录成功后设置json格式的返回结果
        .successHandler(new AuthenticationSuccessHandler() {
            /**
                     *
                     * @param request
                     * @param response
                     * @param authentication 保存了登录成功的用户信息
                     * @throws IOException
                     */
            @Override
            public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                                Authentication authentication) throws IOException {
                response.setContentType("application/json;charset=utf-8");
                PrintWriter out = response.getWriter();
                out.write(JSONObject.toJSONString(authentication));
                out.flush();
                out.close();
            }
        })
        // 登录失败后设置json格式的返回结果
        .failureHandler(new AuthenticationFailureHandler() {
            @Override
            public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                                AuthenticationException exception) throws IOException, ServletException {
                response.setContentType("application/json;charset=utf-8");
                PrintWriter out = response.getWriter();
                Map<String,String> map = new HashMap<>();
                map.put("errMsg", exception.getMessage());
                out.write(JSONObject.toJSONString(map));
                out.flush();
                out.close();
            }
        })
        .permitAll()
        .and()
        .csrf().disable();//关闭 csrf,后面讨论
    }

用 postman 访问
在这里插入图片描述

登陆失败:
在这里插入图片描述
=done&style=none&taskId=u3ca4cd9b-a43a-471b-ad8f-d6b52921c32&title=&width=894)

2. 未登陆返回json

目前用户没有登陆,访问:localhost:8080/sec,会跳转到登陆页面,也让它返回json

// configure 方法中新增
http.exceptionHandling().authenticationEntryPoint(new AuthenticationEntryPoint() {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {
        response.setContentType("application/json;charset=utf-8");
        PrintWriter out = response.getWriter();
        Map<String,String> map = new HashMap<>();
        map.put("errMsg", "尚未登录,请先登录");
        out.write(JSONObject.toJSONString(map));
        out.flush();
        out.close();
    }
});

重启后,访问:localhost:8080/sec
在这里插入图片描述

3. 退出登录

http.logout().logoutUrl("/user/logout")
    .logoutSuccessHandler(new LogoutSuccessHandler() {
        @Override
        public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
            response.setContentType("application/json;charset=utf-8");
            PrintWriter out = response.getWriter();
            out.write("退出成功");
            out.flush();
            out.close();
        }
    });

重启后,访问:localhost:8080/user/logout
在这里插入图片描述

4. json格式登陆

目前为止我们都是用form表单的方式提交用户名、密码,这种方式:

  • 默认是从 **UsernamePasswordAuthenticationFilter **的 attemptAuthentication 方法中获取表单中的参数在这里插入图片描述

我们新建一个类,继承 UsernamePasswordAuthenticationFilter,复写 attemptAuthentication方法,从json中获取参数

/**
 * 自定义 UsernamePasswordAuthenticationFilter,从请求中获取用户名密码
 */
public class MyUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {


    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        //如果请求方式不是 post,直接异常
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("请求方式有误:" + request.getMethod());
        }
        //如果请求的参数格式不是json,直接异常
        if (!request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE)){
            throw new AuthenticationServiceException("参数不是json:" + request.getMethod());
        }
        String username=null;
        String password = null;
        try {
            //从 json 数据中 获取用户名、密码
            Map<String,String> map = JSONObject.parseObject(request.getInputStream(),Map.class);
            username = map.get("username");//但是参数名可能不是这个,最好是用 getUsernameParameter() 方法获取参数名
            password = map.get("password");
        } catch (IOException e) {
            throw new AuthenticationServiceException("参数不对:" + request.getMethod());
        }
        if (username == null) {
            username = "";
        }
        if (password == null) {
            password = "";
        }
        username = username.trim();
        // 封装用户名、密码,下面的 authenticate 方法会从中拿到 用户名, 调用我们的 LoginUserService 获取用户,然后比较密码
        UsernamePasswordAuthenticationToken authRequest
                = new UsernamePasswordAuthenticationToken(username,password);
        //设置ip、sessionId信息
        setDetails(request,authRequest);
        // authenticate 方法中封装了具体的密码认证逻辑
        return this.getAuthenticationManager().authenticate(authRequest);
    }
}

修改 SecurityConfig,声明我们的 **MyUsernamePasswordAuthenticationFilter **为 bean 对象
同时使用下面新的 configure 方法,老的可以暂时注释掉

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    /**
     * 声明一个 PasswordEncoder ,这样数据库存储的密码就不需要 "{加密方式}",这样的前缀
     * @return
     */
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    public MyUsernamePasswordAuthenticationFilter myUsernamePasswordAuthenticationFilter() throws Exception {
        MyUsernamePasswordAuthenticationFilter filter = new MyUsernamePasswordAuthenticationFilter();
        filter.setAuthenticationManager(authenticationManagerBean());//认证使用
        //设置登陆成功返回值是json
        filter.setAuthenticationSuccessHandler(new AuthenticationSuccessHandler() {
            @Override
            public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
                response.setContentType("application/json;charset=utf-8");
                PrintWriter out = response.getWriter();
                out.write(JSONObject.toJSONString(authentication));
                out.flush();
                out.close();
            }
        });
        //设置登陆失败返回值是json
        filter.setAuthenticationFailureHandler(new AuthenticationFailureHandler() {
            @Override
            public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
                response.setContentType("application/json;charset=utf-8");
                PrintWriter out = response.getWriter();
                Map<String,String> map = new HashMap<>();
                map.put("errMsg", exception.getMessage());
                out.write(JSONObject.toJSONString(map));
                out.flush();
                out.close();
            }
        });
        //设置登录接口
        filter.setFilterProcessesUrl("/user/login");
        return filter;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and().csrf().disable();
        //把自定义认证过滤器加到拦截器链中
        http.addFilterAt(myUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
    }
}

使用 postman 测试:
在这里插入图片描述

5. 获取当前登录用户

只有一行代码: SecurityContextHolder.getContext().getAuthentication()
修改 SecController

@GetMapping("/sec")
public String sec(){
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    LoginUser currentUser = (LoginUser) authentication.getPrincipal();
    return JSONObject.toJSONString(currentUser);
}

登录成功后,访问:localhost:8080/sec
在这里插入图片描述

更新当前用户信息:SecurityContextHolder.getContext().setAuthentication(authResult)

// 可以找个地方写个这样的静态方法,如果要更新登录的用户信息,就调用这个方法
public static void setLoginUser(UserDetails userDetails) {
	SecurityContextHolder.getContext().setAuthentication(
		new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities()));
}

6. 验证码

添加验证码生成的工具包

<dependency>
  <groupId>com.github.penggle</groupId>
  <artifactId>kaptcha</artifactId>
  <version>2.3.2</version>
</dependency>

修改 SecController 增加:

@GetMapping("/code")
public void getVerifyCode(HttpServletResponse resp, HttpSession session) throws IOException {
    //验证码配置
    Properties properties = new Properties();
    properties.setProperty("kaptcha.image.width", "150");
    properties.setProperty("kaptcha.image.height", "50");
    properties.setProperty("kaptcha.textproducer.char.string", "0123456789");
    properties.setProperty("kaptcha.textproducer.char.length", "4");
    Config config = new Config(properties);
    DefaultKaptcha kaptcha = new DefaultKaptcha();
    kaptcha.setConfig(config);
    
    //生成验证码
    String text = kaptcha.createText();
    //放到session中
    session.setAttribute("verify_code", text);
    //返回给前端
    resp.setContentType("image/jpeg");
    BufferedImage image = kaptcha.createImage(text);
    try(ServletOutputStream out = resp.getOutputStream()) {
        ImageIO.write(image, "jpg", out);
    }
}

修改 SecurityConfig

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
        .antMatchers("/code").permitAll()// 验证码请求不用登录
        .anyRequest().authenticated()
        .and().csrf().disable();

    http.addFilterAt(myUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}

修改 MyUsernamePasswordAuthenticationFilter

.....

String username=null;
String password = null;
String code = null;
try {
    Map<String,String> map = JSONObject.parseObject(request.getInputStream(),Map.class);
    code = map.get("code");
    username = map.get("username");
    password = map.get("password");
} catch (IOException e) {
    throw new AuthenticationServiceException("参数不对:" + request.getMethod());
}
// 校验验证码
String verify_code = (String) request.getSession().getAttribute("verify_code");
if (code == null || verify_code == null || !code.equals(verify_code)) {
    throw new AuthenticationServiceException("验证码错误");
}

.....

获取验证码:
在这里插入图片描述

登录时候,提交验证码:
在这里插入图片描述
在这里插入图片描述

7. 认证流程

在这里插入图片描述

1. UsernamePasswordAuthenticationFilter

第一步:在 UsernamePasswordAuthenticationFilter 中,通过attemptAuthentication 方法
从 request 中获取前端提交的用户名和密码,

  • 封装为 :UsernamePasswordAuthenticationToken(它实现了 Authentication 接口)

然后将Authentication 提交给 认证管理器(AuthenticationManager)进行认证

  • 就是这句代码:this.getAuthenticationManager().authenticate(authRequest);

2. AuthenticationManager

进入 authenticate 方法
在这里插入图片描述

这个方法中,只关心哪里使用了我们传进去的参数:authentication
这个参数,封装了前端提交的 用户名和密码
在这里插入图片描述

继续debug
在这里插入图片描述

进入上图中的 authenticate 方法
在这里插入图片描述

用户登录的方式有很多种,比如:用户名/密码、手机验证码、扫码登录等
每一种都有特定的 Provider 负责处理,DaoAuthenticationProvider 就是负责验证用户名、密码这种方式的登录
同时不同的 Proivder 支持不同的参数(通过supports方法判断)
进入 DaoAuthenticationProvider 的 supports 方法

  • 注意:DaoAuthenticationProvider 类中,没有supports 方法,而是在它的父类中

  • 也就是:AbstractUserDetailsAuthenticationProvider :

  • 在这里插入图片描述

  • 上图中可以看出来,DaoAuthenticationProvider 只支持 UsernamePasswordAuthenticationToken 类型

  • 所以:DaoAuthenticationProvider 才真正封装了 用户名/密码的 认证逻辑

    • 具体是在它的** **authenticate 方法中,(注意:这个方法还是定义在它的父类中)

3. **DaoAuthenticationProvider **

直接再它的 authenticate 方法中打断点
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

8. 手机验证码登录

账号密码是最常见的登录方式,但是现在的登录多种多样:手机验证码、二维码、第三方授权等等
下面模仿账号密码登录,新增一下手机验证码登录

1. 获取手机验证码

修改 SecController 增加:

@GetMapping("/phone/code")
public String phoneCode(HttpSession session) throws IOException {
    //验证码配置
    Properties properties = new Properties();
    properties.setProperty("kaptcha.image.width", "150");
    properties.setProperty("kaptcha.image.height", "50");
    properties.setProperty("kaptcha.textproducer.char.string", "0123456789");
    properties.setProperty("kaptcha.textproducer.char.length", "4");
    Config config = new Config(properties);
    DefaultKaptcha kaptcha = new DefaultKaptcha();
    kaptcha.setConfig(config);

    //生成验证码
    String code = kaptcha.createText();
    session.setAttribute("phoneNum", code);
    return code;
}

2. PhoneNumAuthenticationToken

用户名密码登录用的是 UsernamePasswordAuthenticationToken,继承 AbstractAuthenticationToken
我们新建 PhoneNumAuthenticationToken 继承 AbstractAuthenticationToken

/**
 * 模仿 UsernamePasswordAuthenticationToken
 * 用来封装前端传过来的手机号、验证码
 */
public class PhoneNumAuthenticationToken extends AbstractAuthenticationToken {

    private final Object phone;//手机号

    private Object num;//验证码


    public PhoneNumAuthenticationToken(Object phone, Object num) {
        super(null);
        this.phone = phone;
        this.num = num;
        setAuthenticated(false);
    }

    public PhoneNumAuthenticationToken(Object phone, Object num, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.phone = phone;
        this.num = num;
        super.setAuthenticated(true); // must use super, as we override
    }

    @Override
    public Object getCredentials() {
        return num;
    }

    @Override
    public Object getPrincipal() {
        return phone;
    }
}

3. PhoneNumAuthenticationFilter

之前的 UsernamePasswordAuthenticationFilter 拦截的是 /user/login 请求,从json中获取用户名、密码
参考 UsernamePasswordAuthenticationFilter 写一个过滤器,拦截短信登录接口/phone/login
新建 PhoneNumAuthenticationFilter 继承 AbstractAuthenticationProcessingFilter

/**
 * 模仿 UsernamePasswordAuthenticationFilter 获取前端传递的 手机号、验证码
 */
public class PhoneNumAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    // 表示这个 Filter 拦截 /phone/login 接口
    private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER =
            new AntPathRequestMatcher("/phone/login", "POST");

    // 参数名
    private String phoneParameter = "phone";
    private String numParameter = "num";


    public PhoneNumAuthenticationFilter() {
        super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
    }

    /**
     * 用来获取前端传递的手机号和验证码,然后调用 authenticate 方法进行认证
     * @param request
     * @param response
     * @return
     * @throws AuthenticationException
     * @throws IOException
     * @throws ServletException
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        if (!"POST".equals(request.getMethod())) {
            throw new AuthenticationServiceException("请求方式有误: " + request.getMethod());
        }
        //如果请求的参数格式不是json,直接异常
        if (!request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE)){
            throw new AuthenticationServiceException("参数不是json:" + request.getMethod());
        }
        // 用户以json的形式传参的情况下
        String phone = null;
        String num = null;
        try {
            Map<String,String> map = JSONObject.parseObject(request.getInputStream(),Map.class);
            phone = map.get(phoneParameter);
            num = map.get(numParameter);
        } catch (IOException e) {
            throw new AuthenticationServiceException("参数不对:" + request.getMethod());
        }

        if (phone == null) {
            phone = "";
        }
        if (num == null) {
            num = "";
        }

        phone = phone.trim();
        // 封装手机号、验证码,后面框架会从中拿到 手机号, 调用我们的 LoginPhoneService 获取用户
        PhoneNumAuthenticationToken authRequest
                = new PhoneNumAuthenticationToken(phone, num);
        //设置ip、sessionId信息
        setDetails(request,authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    protected void setDetails(HttpServletRequest request, PhoneNumAuthenticationToken authRequest) {
        authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
    }
}

4. LoginPhoneService

新建 LoginPhoneService

@Component
public class LoginPhoneService implements UserDetailsService {

    @Autowired
    private UserService userService;

    /**
     * 根据手机号查询用户对象
     * @param phone 前端传的手机号
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String phone) throws UsernameNotFoundException {
        // 从数据库查询用户
        User user = userService.getByPhone(phone);
        if(user == null){
            return null;
        }

        // 把用户信息封装到一个 userdetails 对象中,UserDetails是一个接口,LoginUser实现了这个接口
        LoginUser loginUser = new LoginUser();
        loginUser.setUser(user);
        return loginUser;
    }
}

注意,这里需要修改数据库的user表,增加 phone 字段
在这里插入图片描述

5. PhoneAuthenticationProvider

之前说过:this.getAuthenticationManager().authenticate(authRequest); 这句代码,其中的 authenticate 方法封装了具体的用户名、密码热证逻辑,其实里面是调用了 DaoAuthenticationProvider 的 authenticate 方法
在这里插入图片描述

用户登录的方式有很多种,每一种都有特定的 Provider 负责处理,
DaoAuthenticationProvider 就是负责验证用户名、密码这种方式的登录

我们的手机号、验证码登录,需要自己创建一个 Provider
新建 PhoneAuthenticationProvider 实现 AuthenticationProvider 接口,主要实现 authenticate 方法,写我们自己的认证逻辑

/**
 * 主要实现 authenticate 方法,写我们自己的认证逻辑
 */
@Component
public class PhoneAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    private LoginPhoneService loginPhoneService;

    /**
     * 手机号、验证码的认证逻辑
     * @param authentication 其实就是我们封装的 PhoneNumAuthenticationToken
     * @return
     * @throws AuthenticationException
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        PhoneNumAuthenticationToken token = (PhoneNumAuthenticationToken) authentication;
        String phone = (String) token.getPrincipal();// 获取手机号
        String num = (String) token.getCredentials(); // 获取输入的验证码
        // 1. 从 session 中获取验证码
        HttpServletRequest req = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        String phoneNum = (String) req.getSession().getAttribute("phoneNum");
        if (!StringUtils.hasText(phoneNum)) {
            throw new BadCredentialsException("验证码已经过期,请重新发送验证码");
        }
        if (!phoneNum.equals(num)) {
            throw new BadCredentialsException("验证码不正确");
        }
        // 2. 根据手机号查询用户信息
        LoginUser loginUser = (LoginUser) loginPhoneService.loadUserByUsername(phone);
        if (loginUser == null) {
            throw new BadCredentialsException("用户不存在,请注册");
        }
        // 3. 把用户封装到 PhoneNumAuthenticationToken 中,
        // 后面就可以使用 SecurityContextHolder.getContext().getAuthentication(); 获取当前登陆用户信息
        PhoneNumAuthenticationToken authenticationResult = new PhoneNumAuthenticationToken(loginUser, num, loginUser.getAuthorities());
        authenticationResult.setDetails(token.getDetails());
        return authenticationResult;
    }

    /**
     * 判断是上面 authenticate 方法的 authentication 参数,是哪种类型
     * Authentication 是个接口,实现类有很多,目前我们最熟悉的就是 PhoneNumAuthenticationToken、UsernamePasswordAuthenticationToken
     * 很明显,我们只支持 PhoneNumAuthenticationToken,因为它封装的是手机号、验证码
     * @param authentication
     * @return
     */
    @Override
    public boolean supports(Class<?> authentication) {
        // 如果参数是 PhoneNumAuthenticationToken 类型,返回true
        return (PhoneNumAuthenticationToken.class.isAssignableFrom(authentication));
    }
}

6. 配置 SecurityConfig

下面最重要的,把上面的东西配置到 SecurityConfig 中,让其生效

@Bean
public PhoneNumAuthenticationFilter phoneNumAuthenticationFilter() throws Exception {
    PhoneNumAuthenticationFilter filter = new PhoneNumAuthenticationFilter();
    filter.setAuthenticationManager(authenticationManagerBean());//认证使用
    //设置登陆成功返回值是json
    filter.setAuthenticationSuccessHandler(new AuthenticationSuccessHandler() {
        @Override
        public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
            response.setContentType("application/json;charset=utf-8");
            PrintWriter out = response.getWriter();
            out.write(JSONObject.toJSONString(authentication));
            out.flush();
            out.close();
        }
    });
    //设置登陆失败返回值是json
    filter.setAuthenticationFailureHandler(new AuthenticationFailureHandler() {
        @Override
        public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
            response.setContentType("application/json;charset=utf-8");
            PrintWriter out = response.getWriter();
            Map<String,String> map = new HashMap<>();
            map.put("errMsg", "手机登陆失败:"+ exception.getMessage());
            out.write(JSONObject.toJSONString(map));
            out.flush();
            out.close();
        }
    });
    filter.setFilterProcessesUrl("/phone/login");//其实这里不用设置,在 PhoneNumAuthenticationFilter 我们已经定义了一个静态变量
    return filter;
}

@Autowired
private LoginUserService loginUserService;
/**
     * DaoAuthenticationProvider 是默认做账户密码认证的,现在有两种登录方式,手机号和账户密码
     * 如果不在这里声明,账户密码登录不能用
     * @return
     */
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider() {
    DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
    //对默认的UserDetailsService进行覆盖
    authenticationProvider.setUserDetailsService(loginUserService);
    authenticationProvider.setPasswordEncoder(passwordEncoder());
    return authenticationProvider;
}
@Autowired
private PhoneAuthenticationProvider phoneAuthenticationProvider;
@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
        // /phone/code 请求不用登陆
        .antMatchers("/code","/phone/code").permitAll()
        .anyRequest().authenticated()
        .and().csrf().disable();

    http.addFilterAt(myUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
        .authenticationProvider(daoAuthenticationProvider());//把账户密码验证加进去

    //把 手机号认证过滤器 加到拦截器链中
    http.addFilterAfter(phoneNumAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
        .authenticationProvider(phoneAuthenticationProvider);//把验证逻辑加进去
}

7. 测试

不登录访问 localhost:8080/sec在这里插入图片描述

获取手机验证码
在这里插入图片描述

输入错误的验证码
在这里插入图片描述

输入正确的,登录成功
在这里插入图片描述

8. 优化

其实上面的代码可以优化,根据上面的代码逻辑,我们是先根据手机号拿到用户后,再比较验证码是否正确
根据我们之前账户密码登录的经验,比较验证码的代码完全可以放到 PhoneNumAuthenticationFilter 中
但是为了模仿账号密码登录的这个过程,我并没有那样做

10. 认证流程

再次,梳理认证流程
在这里插入图片描述

  • 用户提交用户名、密码被 UsernamePasswordAuthenticationFilter 过滤器拿到,
    • 封装为 :UsernamePasswordAuthenticationToken(它实现了 Authentication 接口)
  • 然后将Authentication 提交给 认证管理器(AuthenticationManager)进行认证
    • 就是这句代码:this.getAuthenticationManager().authenticate(authRequest);
  • authenticate 方法中调用 AuthenticationProvider 对象的 authenticate 方法进行认证
    • 手机登录调用的是我们自己的:PhoneAuthenticationProvider
    • 账号密码登录调用的是:DaoAuthenticationProvider
  • 认证过程:
    • 通过 UserDetailsService 查询用户对象
      • 手机登录调用的是我们自己的:LoginPhoneService
      • 账号密码登录调用的是:LoginUserService
    • 比较密码是否正确(手机登录比较的是验证码)
    • 成功后重新创建Authentication对象,把当前用户放进去
  • 认证结束后,把 Authentication 对象,放到 SecurityContext 中
    • 执行的是:SecurityContextHolder.getContext().setAuthentication();
  • 之后就可以通过SecurityContextHolder.getContext().getAuthentication(); 获取当前用户
  • 13
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值