Spring Security

2024 年 7 月 14 日 星期日(已编辑)
/ ,
20
摘要
Spring Security 安全框架
这篇文章上次修改于 2024 年 7 月 16 日 星期二,可能部分内容已经不适用,如有疑问可询问作者。

Spring Security

Authentication

基于内存

@Configuration
public class securityInMemoryUser {
    @Bean
    public UserDetailsService userDetailsService(){
        UserDetails user1 = User.builder()
                //创建用户名为eric的用户
                .username("eric")
                //创建密码123456并对密码进行加密
                .password(passwordEncoder().encode("123456"))
                //***重要:为eric用户设置角色为student,然后eric就拥有了ROLE_student的权限
                .roles("student")
                //为eric设置权限,此配置会覆盖上面一行的代码,也就是说eric的权限为student:delete和student:add,而没有了ROLE_student的权限
                .authorities("student:delete","student:add")
                .build();
        UserDetails user2 = User.builder()
                .username("tomas")
                .password(passwordEncoder().encode("123456"))
                .authorities("teacher:delete","teacher:add")
                //这里会对上面进行覆盖
                .roles("teacher")
                .build();
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(user1);
        manager.createUser(user2);
        return manager;
    }
    //设置SpringSecutiry密码加密的类,并将其注册为bean交给spring管理
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

基于数据库

思路

  1. 配置数据库的相关信息,通过xml文件的方式写sql语句
  2. 加载用户信息,需实现UserDetailsService接口,并实现loadUserByUsername方法
  3. 由于loadUserByUsername方法需返回一个UserDetails类型的返回值,需要定义一个安全用户类SecurityUser来实现UserDetails接口,该安全用户类可以获取到用户名和密码以及权限等信息
  4. 编写安全用户SecurityUser类,实现所有方法,并将entity中的用户信息存入到安全用户类中
  5. 编写SecurityUserDetailsServiceImpl类,实现UserDetailsService,回到第二步,加载用户信息
    1. 接收到用户登录的用户名,通过用户名到数据库中查询是否存在用户,不存在抛异常
    2. 获取用户权限信息,通过上一步查询到的用户用户信息(包括id),到权限表中根据id,通过连接查询用户权限,返回List的权限集合
    3. 由于安全用户中的权限类型必须是继承了GrantedAuthority接口的子类,所以我们需要通过将String转换为SimpleGrantedAuthority类型(实现了上述接口的子类之一)的数据
    4. 创建安全用户new,将第一步的用户信息封装到安全用户中,并设置权限信息为第3步转换后的权限
    5. 返回安全用户既可
创建表

msg:在数据库中创建用户表,角色表,权限表,以及对应的连接表,即用户与角色的连接表,角色与权限的连接表。共5张表。

示例数据:

用户1:obama,角色:管理员,权限:学生查询,学生增加,学生修改,学生删除,教师查询,教师修改

用户2:tomas,角色:教师,权限:学生查询,学生增加,学生修改,学生删除,教师添加

用户3:eric,角色:学生,权限:学生查询,导出学生信息

用户表

msg:创建

DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user` (
    -- 用户id
  `user_id` int NOT NULL,
    -- 用户名
  `username` varchar(255) DEFAULT NULL,
    -- 用户密码
  `password` varchar(255) DEFAULT NULL,
    -- 用户性别
  `sex` varchar(255) DEFAULT NULL,
    -- 用户是否可用
  `enabled` int DEFAULT NULL,
    -- 用户是否过期,1为正常,0为过期
  `account_no_expired` int DEFAULT NULL,
    -- 凭据/密码是否过期,1正常,0过期
  `credentials_no_expired` int DEFAULT NULL,
    -- 用户是否被锁定
  `account_no_locked` int DEFAULT NULL,
  PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

msg: 示例数据(密码:123456)

INSERT INTO `sys_user` VALUES ('1', 'obama', '$2a$10$BsjlTIIXhrfig8q7t6evwuSvdl1yUe0dXU0g4HD0Jaj9Lu8s7yGmu', '男', '1', '1', '1', '1');
INSERT INTO `sys_user` VALUES ('2', 'tomas', '$2a$10$BsjlTIIXhrfig8q7t6evwuSvdl1yUe0dXU0g4HD0Jaj9Lu8s7yGmu', '男', '1', '1', '1', '1');
INSERT INTO `sys_user` VALUES ('3', 'eric', '$2a$10$BsjlTIIXhrfig8q7t6evwuSvdl1yUe0dXU0g4HD0Jaj9Lu8s7yGmu', '男', '1', '1', '1', '1');
角色表

msg:创建

DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role` (
    -- 角色id
  `id` int NOT NULL,
    -- 角色名称
  `rolename` varchar(255) DEFAULT NULL,
    -- 角色含义
  `remark` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

msg:示例数据

INSERT INTO `sys_role` VALUES ('1', 'ROLE_ADMIN', '管理员');
INSERT INTO `sys_role` VALUES ('2', 'ROLE_TEACHER', '老师');
INSERT INTO `sys_role` VALUES ('3', 'ROLE_STUDENT', '学生');
权限表

msg:创建

DROP TABLE IF EXISTS `sys_menu`;
CREATE TABLE `sys_menu` (
    -- 权限id
  `id` int NOT NULL AUTO_INCREMENT,
    -- 该权限的父id
  `pid` int DEFAULT NULL,
    -- 权限名称
  `name` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '名称',
    -- 权限编码,java中匹配的权限代码
  `code` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '权限编码',
    -- 类型,0代表菜单,1代表权限,2
  `type` int DEFAULT NULL COMMENT '0代表菜单1权限',
    -- 删除标志
  `delete_flag` tinyint DEFAULT '0' COMMENT '0代表未删除,1代表已删除',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

msg:示例数据

INSERT INTO `sys_menu` VALUES ('1', '0', '学生管理', '/student/**', '0', '0');
INSERT INTO `sys_menu` VALUES ('2', '1', '学生查询', 'student:query', '1', '0');
INSERT INTO `sys_menu` VALUES ('3', '1', '学生添加', 'student:add', '1', '0');
INSERT INTO `sys_menu` VALUES ('4', '1', '学生修改', 'student:update', '1', '0');
INSERT INTO `sys_menu` VALUES ('5', '1', '学生删除', 'student:delete', '1', '0');
INSERT INTO `sys_menu` VALUES ('6', '1', '导出学生信息', 'student:export', '1', '0');
INSERT INTO `sys_menu` VALUES ('7', '0', '教师管理', '/teacher/**', '0', '0');
INSERT INTO `sys_menu` VALUES ('8', '7', '教师查询', 'teacher:query', '1', '0');
INSERT INTO `sys_menu` VALUES ('9', '7', '教师添加', 'teacher:add', '1', '0');
INSERT INTO `sys_menu` VALUES ('10', '7', '教师修改', 'teacher:update', '1', '0');
INSERT INTO `sys_menu` VALUES ('11', '7', '教师删除', 'teacher:delete', '1', '0');
INSERT INTO `sys_menu` VALUES ('12', '7', '导出教师信息', 'teacher:export', '1', '0');
用户角色连接表

msg:创建

DROP TABLE IF EXISTS `sys_role_user`;
CREATE TABLE `sys_role_user` (
    -- 用户id
  `uid` int DEFAULT NULL,
    -- 角色id
  `rid` int DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

msg:示例数据

INSERT INTO `sys_role_user` VALUES ('1', '1');
INSERT INTO `sys_role_user` VALUES ('2', '2');
INSERT INTO `sys_role_user` VALUES ('3', '3');
角色权限连接表

msg:创建

DROP TABLE IF EXISTS `sys_role_menu`;
CREATE TABLE `sys_role_menu` (
    -- 角色id
  `rid` int DEFAULT NULL,
    -- 权限id
  `mid` int DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

msg:示例数据

INSERT INTO `sys_role_menu` VALUES ('1', '2');
INSERT INTO `sys_role_menu` VALUES ('2', '2');
INSERT INTO `sys_role_menu` VALUES ('3', '2');
INSERT INTO `sys_role_menu` VALUES ('1', '3');
INSERT INTO `sys_role_menu` VALUES ('2', '3');
INSERT INTO `sys_role_menu` VALUES ('1', '4');
INSERT INTO `sys_role_menu` VALUES ('2', '4');
INSERT INTO `sys_role_menu` VALUES ('1', '5');
INSERT INTO `sys_role_menu` VALUES ('2', '5');
INSERT INTO `sys_role_menu` VALUES ('3', '6');
INSERT INTO `sys_role_menu` VALUES ('1', '8');
INSERT INTO `sys_role_menu` VALUES ('2', '9');
INSERT INTO `sys_role_menu` VALUES ('1', '10');
配置yml
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/yan
    username: root
    password: 123456

mybatis:
  mapper-locations: classpath:mapper/*.xml
  type-aliases-package: com.yannqing.entity
  configuration:
    #下划线命名转驼峰命名
    map-underscore-to-camel-case: true
    #输出记在日志文件中
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
实体类
User

用户实体类

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.sql.Timestamp;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class User implements Serializable {
    private Integer userId;
    private String username;
    private String password;
    private String sex;
    private String address;
    private String phone;
    private String email;
    private int age;
    private String avatar;
    private String nickName;
    private int enabled;
    private int accountNoExpired;
    private int credentialsNoExpired;
    private int accountNoLock;
    private Timestamp createTime;
    private String description;
}
Permissions

权限实体类

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Permissions {
    private Integer id;
    private Integer pid;
    private Integer type;
    private String name;
    private String code;
}
dao与xml
UserDao
import com.yannqing.entity.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

@Mapper
public interface UserDao {
    /**
     * 根据用户名获取用户信息
     * @param userName
     * @return
     */
    User getByUserName(@Param("userName") String userName);
}
UserMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yannqing.dao.UserDao">
    <select id="getByUserName" resultType="User">
#         select user_id,username,password,sex,address,enabled,account_no_expired,credentials_no,expired,account
        select * from sys_user where username=#{userName}
    </select>

</mapper>
PermissionsDao
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

import java.util.List;

@Mapper
public interface PermissionsDao {
    /**
     * 根据用户id查询相关权限
     * @param userId
     * @return
     */
    List<String> queryPermissionsByUserId(@Param("userId") Integer userId);
}
MenuMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yannqing.dao.PermissionsDao">
    <select id="queryPermissionsByUserId" resultType="String">
        select distinct sm.code from sys_role_user sru inner join sys_role_menu srm on sru.rid = srm.rid
                                                inner join sys_menu sm on srm.mid = sm.id where sru.uid = #{userId}
    </select>

</mapper>

内连接查询!!!涉及到多表查询!

Service

MenuService与UserService实现dao中对应的方法即可

SecurityUserDetailsServiceImpl
import com.yannqing.dao.PermissionsDao;
import com.yannqing.entity.User;
import com.yannqing.service.UserService;
import com.yannqing.vo.SecurityUser;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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;

import java.util.ArrayList;
import java.util.List;

@Service
@Slf4j
public class SecurityUserDetailsServiceImpl implements UserDetailsService {
    @Autowired
    private UserService userService;
    @Autowired
    private PermissionsDao permissionsDao;
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        User user = userService.getByUserName(username);
        if (user==null){
            throw new UsernameNotFoundException("该用户不存在");
        }
        System.out.println(user);
        //获取用户权限
        List<String> permissions = permissionsDao.queryPermissionsByUserId(user.getUserId());
        System.out.println(permissions);

        //将String转为SimpleGrantedAuthority
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        for (String permission : permissions) {
            SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(permission);
            authorities.add(simpleGrantedAuthority);
        }
        //获取SimpleGrantedAuthority集合List的第二种方法,通过流的形式获取
//        List<SimpleGrantedAuthority> au = permissions.stream().map(SimpleGrantedAuthority::new).toList();

        SecurityUser securityUser = new SecurityUser(user);
        securityUser.setSimpleGrantedAuthorities(authorities);

        return securityUser;
    }
}
vo层
SecurityUser
import com.yannqing.entity.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;

public class SecurityUser implements UserDetails {

    private final User user;

    private List<SimpleGrantedAuthority> simpleGrantedAuthorities;

    public void setSimpleGrantedAuthorities(List<SimpleGrantedAuthority> simpleGrantedAuthorities) {
        this.simpleGrantedAuthorities = simpleGrantedAuthorities;
    }

    public SecurityUser(User user) {
        this.user = user;
    }

    public User getUser() {
        return user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        //权限配置
        return simpleGrantedAuthorities;
    }

    @Override
    public String getPassword() {
        String password = user.getPassword();
        //擦除用户的密码,防止传送到前端。
        user.setPassword(null);
        return password;
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    //账户是否未过期,数据库中,0过期,1为正常
    @Override
    public boolean isAccountNonExpired() {
        return user.getAccountNoExpired()==1;
    }

    //账户是否未锁定
    @Override
    public boolean isAccountNonLocked() {
        return user.getAccountNoLock()==0;
    }

    //凭据/密码是否未过期
    @Override
    public boolean isCredentialsNonExpired() {
        return user.getCredentialsNoExpired()==1;
    }

    //用户是否可用
    @Override
    public boolean isEnabled() {
        return user.getEnabled()==1;
    }
}
config层
SecurityConfig
import com.yannqing.security.filter.JwtAuthenticationTokenFilter;
import com.yannqing.security.LoginFailureHandler;
import com.yannqing.security.LoginSuccessHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    protected SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests((authorize)->authorize

                .requestMatchers(HttpMethod.POST,"/login").permitAll()
                .requestMatchers(HttpMethod.POST,"/logout").permitAll()
                .anyRequest()
                .authenticated()
        );
        http.csrf(AbstractHttpConfigurer::disable);
        http.sessionManagement((session)->session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        http.addFilterBefore(new JwtAuthenticationTokenFilter(),UsernamePasswordAuthenticationFilter.class);

        http.formLogin((e)->{
            e.loginProcessingUrl("/login")
                    .successHandler(new LoginSuccessHandler())
                    .failureHandler(new LoginFailureHandler());

        });
        http.cors(Customizer.withDefaults());//跨域拦截关闭

        return http.build();
    }

    /**
     * 对密码进行BCrypt加密
     * @return
     */
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

Authorization

针对方法进行授权

steps:

  1. 对@Configuration的类添加注解@EnableMethodSecurity

  2. 在方法前添加上@PreAuthorize("hasAuthority('ROLE_teacher')")

    1. 这是预授权(在访问前进行权限的判断)
    2. 且仅有ROLE_teacher权限的用户才能访问该方法
    3. 预授权使用最多

针对Url进行授权

在SecurityConfig类中

@Bean
protected SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests((authorize)->authorize
            .requestMatchers("/student/**")
            //只有具有student:add权限的才可以匹配url/student/**
            .hasAnyAuthority("student:add")
            .requestMatchers("/teacher/**")
            //只有具有teacher:add或ROLE_teacher权限的才可以匹配url/teacher/**
            .hasAnyAuthority("ROLE_teacher","teacher:add")
            //其余任何请求都必须经过权限验证后才可以访问
            .anyRequest()
            .authenticated()
    );
    http.formLogin().permitAll();
    return http.build();
}

跨域

SpringBoot中的跨域

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowCredentials(true)
                .allowedOriginPatterns("*")
                .allowedMethods("GET","POST","PUT","DELETE")
                .allowedHeaders("*")
                .exposedHeaders("*");
    }
}

SpringSecurity中的跨域

http.cors(Customizer.withDefaults());//跨域拦截关闭

Vue中的跨域

msg:通过代理解决

server: {
  proxy: {
    '/api': {
      target: 'http://localhost:8080', // 后端服务器的地址
      changeOrigin: true,
      rewrite: (path) => path.replace(/^\/api/, '') // 将请求路径中的 '/api' 前缀移除
    }
  }
},

此后发送axios请求时,直接向“api/xxx”访问即可

异常处理

import com.yannqing.utils.Result;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.io.IOException;

/**
 * 全局异常处理器
 *
 * @author redpig
 */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
    /**
     * 权限校验异常
     */
    @ExceptionHandler(AccessDeniedException.class)
    public Result handleAccessDeniedException(AccessDeniedException e, HttpServletRequest request) {
        String requestURI = request.getRequestURI();
        log.error("请求地址 {},权限校验失败 {}", requestURI, e.getMessage());
        return Result.error("没有权限,请联系管理员授权");
    }

    /**
     * 请求方式不支持
     */
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public Result handleHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException e,
                                                      HttpServletRequest request) {
        String requestURI = request.getRequestURI();
        log.error("请求地址 {},不支持 {} 请求", requestURI, e.getMethod());
        return Result.error(e.getMessage());
    }

    /**
     * 拦截未知的运行时异常
     */
    @ExceptionHandler(RuntimeException.class)
    public void handleRuntimeException(RuntimeException e, HttpServletRequest request, HttpServletResponse response) throws IOException {
        String requestURI = request.getRequestURI();
        log.error("请求地址 {},异常: {}", requestURI, e);

        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(500);
        response.getWriter().write(Result.errorJSON(e.getMessage()));
    }

    /**
     * 系统异常
     */
    @ExceptionHandler(Exception.class)
    public Result handleException(Exception e, HttpServletRequest request) {
        String requestURI = request.getRequestURI();
        log.error("请求地址 {},发生系统异常.", requestURI, e);
        return Result.error(e.getMessage());
    }

}

集成thymleaf

添加依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

配置yml

spring:
    thymeleaf:
        cache: false    #开发时可以不使用缓存
        check-template: true #是否检查模板

添加模板

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

</body>
</html>

新建LoginController和IndexController

LoginController


@Controller
@RequestMapping("/login")
public class LoginController{
    
    @RequestMapping("/toLogin")
    public String toLogin(){
        //返回thymeleaf逻辑视图名。物理视图=前缀+逻辑视图名+后缀
        //其中,默认的前缀为:classpath:/templates/            ---即位于resources下面的templates
        //默认的后缀是.html
        return "login";
    }
}

IndexController

@Controller
@Slf4j
public class IndexController{
    
    @RequestMapping("/toIndex")
    public String toIndex(){
        return "main";//相当于跳转main.html
    }
}

自定义登录页面

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <h2>
        登录页面
    </h2>
    <form action="/login/doLogin" method="post">
        <table>
            <tr>
                <td>用户名</td>
                <td><input type="text" name="uname" value="tomas"></td>
            </tr>
            <tr>
                <td>密码</td>
                <td><input type="password" name="pwd"></td>
                <span th:if="${param.error}">用户名或密码错误</span>
            </tr>
            <tr> <td colspan="2">
                <button type="submit">登录</button>
                </td>
            </tr>
        </table>
    </form>

</body>
</html>

创建main页面

tips:基于thymeleaf

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>系统首页</title>
</head>
<body>
<h1 align="center">系统首页</h1>
 <a href="/student/query">查询学生</a>
<br>
 <a href="/student/add">添加学生</a>
<br>
 <a href="/student/update">更新学生</a>
<br>
 <a href="/student/delete">删除学生</a>
<br>
 <a href="/student/export">导出学生</a>
<br>
<br><br><br>
<h2><a href="/logout">退出</a></h2>
<br>
</body>
</html>

配置SecurityConfig




@Bean
protected SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests((authorize)->authorize
            //任何请求都必须经过权限验证后才可以访问
            .anyRequest()
            .authenticated()
    );
    //关闭session
    http.sessionManagement((session)->session
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    );
    //关闭跨域请求保护
    http.csrf(AbstractHttpConfigurer::disable);
    //配置表单登录
    http.formLogin((formLogin)->formLogin
           .loginPage("/toLogin")
           .usernameParameter("uname")
              .passwordParameter("pwd")
            .loginProcessingUrl("/login/doLogin") //单击登录后进入url
            .failureForwardUrl("/toLogin")//登录失败,回到登录页面
            .successForwardUrl("/toIndex")//登录成功
            .permitAll();
    );

    http.logout((logout)->logout
            .logoutSuccessUrl("/toLogin")  //退出登录后,返回到登录页面
    );

    return http.build();
}

创建student和teacher的页面

tips:在template文件夹下面创建student和teacher文件夹,然后在文件夹中分别创建各自的增删查改测试页面

格式基本一致

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>系统首页-学生管理</title>
</head>
<body>
<h1 align="center">
    系统首页-学生管理-新增
    </h1>
    <a href="/index/toIndex">返回</a>
</body>
</html>

创建403页面

tips:在resources/static/error文件夹下面创建403.html

编写html代码即可

调整Controller层

tips:将StudentController中返回json改成返回页面,分别对应student中的每一个页面

用户无权限时,不展示对应的按钮

  1. 添加依赖
<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecutiry5</artifactId>
</dependency>
  1. 修改main.html

在html标签中,添加xmlns:sec=“http://www.thymeleaf.org/extras/spring-security”

  1. 在对应按钮那边,添加上sec:authorize="hasAuthority('student:query')"即可

<a href="/student/query" sec:authorize="hasAuthority('student:query')">查询学生</a>

拥有student:query权限的角色才可以看到这个按钮,否则无法看到

集成图片验证码

  1. 添加依赖
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.3.9</version>
</dependency>
  1. 建立Controller
@Controller
@Slf4j
public class CaptchaController {
    @GetMapping("/code/getCaptchaCode")
    public void getCaptchaCode(HttpServletRequest request, HttpServletResponse response) throws IOException{
        CircleCaptcha circleCaptchaCaptchaUtil.createCircleCaptcha(200,100,2,20);//宽度,高度,字符数,圆的个数
        String code = circleCaptcha.getCode();
        log.info("生成的图片验证码为:"+code);
        //将验证码存储到session中
        request.getSession().setAttribute("CAPTCHA_CODE",code);
        //将图片写入到响应流里,参数:图片,图片格式,响应流
 a       ImageIo.write(circleCaptcha.getImage,"jpeg",response.getOutputStream());
    }
}
  1. 在授权请求中,将获取验证码放开。
http.authorizeHttpRequests((authorize)->authorize
            .requestMatchers("/code/getCaptchaCode")
            .permitAll()
        .anyRequest()
        .authenticated()
);
  1. 后面验证jwt好像也需要放开,需要自行测试
  2. 前端页面中使用验证码
<img src="/code/getCaptchaCode"  onclick="this.src=this.src">//后面的意思是点击刷新
  1. 自定义过滤器,判断验证码
@Component
@Slf4j
public class ValidateCodeFilter extends OncePerRequestFilter{
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse reponse, FilterChain filterChain){
    //判断路径,是否是/login/doLogin
    String requestURI = request.getRequestURI();
    if(!requestURI.equals("/login/doLogin")){
    doFilter(request,response,filterChain);//不是登录请求,直接放行
    return;
    }
    //校验验证码
    validateCode(request,response,filterChain);
    
    }
    
    public void validateCode(HttpServletRequest request, HttpServletResponse reponse, FilterChain filterChain){
    //前端获取用户输入的code
    String enterCode = request.getParameter("code")//输入框的名称,code
    String captchaCodeInSession = request.getSession().getAttribute("CAPTCHA_CODE");
    request.getSession().removeAttribute("captcha_code_error");//清除提示信息
    if(enterCode==null) {
    request.getSession.setAttribute("captcha_code_error","请您输入验证码");
    response.sendRedirect("/toLogin")
    return ;
    }
    if(captchaCodeInSession==null) {
    request.getSession.setAttribute("captcha_code_error","验证码错误");
    return ;
    }
    if(!enterCode.equalsIgnoreCase(captchaCodeInSession)) {
    request.getSession.setAttribute("captcha_code_error","验证码输入错误");
    response.sendRedirect("/toLogin");
    return;
    }
    request.getSession().removeAttribute("CAPTCHA_CODE");//移除验证码
    //程序执行到这里,说明验证码正确
    this.doFilter(request,response,filterChain);
    }
}

tips: 在前端页面中错误信息如下写

<span th:text="${session.captcha_code_error}">username</span>

  1. 放到过滤器链中。
@Resource
private ValidateCodeFilter validateCodeFilter;


    @Bean
    protected SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        //在用户名密码认证过滤器前添加图片验证码过滤器
        http.addFilterBefore(validateCodeFilter,UsernamePasswordAuthenticationFIlter.class)
    }

jwt认证

jwt的基本信息

  • 全名:json web token
  • 包含header,payload,signature三部分
  • 通过“.”连接
header
{
  "alg": "HS256",    //签名算法
  "typ": "JWT"    //类型,通常默认为JWT
}

然后,对该 JSON 进行Base64Url编码以形成 JWT 的第一部分。

payload

msg:通常有三部分声明(注册声明,公共声明,私人声明)

注册声明(预定义的)

{
    "iss(issuer)":"签发人",
    "exp(expiration time)":"过期时间",
    "sub(subject)":"主题",
    "aud(audience)":"受众",
    "nbf(Not Before)":"生效时间",
    "iat(Issued At)":"签发时间",
    "jti(JWT ID)":"编号"
}

私人声明(自定义)

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

然后对payload进行Base64Url编码以形成 JSON Web 令牌的第二部分。

关闭session

//关闭session
http.sessionManagement((session)->session
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);

添加依赖

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>4.3.0</version>
</dependency>

编写jwt工具类

action:将常用的jwt方法例如创建jwt,校验jwt等封装为工具类

工具类

message:

两种方法:

  1. 将用户信息以及权限封装为jwt存储,并写出对应的获取用户信息以及权限的方法
    1. 此方法需要将用户信息和权限信息封装为UsernamePasswordAuthenticationToken并传给安全上下文才能通过用户名密码过滤器
  2. 将用户认证信息Authentication直接封装为token
    1. 此方法需要将Authentication给解析后赋值给自定义的JwtAuthentication(实现Authentication接口),并将其传给安全上下文才可以
import com.alibaba.fastjson.JSON;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

import java.util.Date;
import java.util.List;

@Slf4j
public class JwtUtils {
    private static final String secret = "111111";
    /**
     *  根据认证信息Authentication生成JWT token
     */
    public static String token(Authentication authentication){
        return JWT.create()
                .withExpiresAt(new Date(System.currentTimeMillis()+ 1000L * 60 * 60 * 24 * 30))  //设置过期时间:单位毫秒
                .withAudience(JSON.toJSONString(authentication)) //设置接受方信息,一般时登录用户
                .sign(Algorithm.HMAC256(secret));
    }

    /**
     * 根据用户详细信息,权限信息  生成token
     * @param userInfo 用户详细信息,密码为空
     * @param authList 用户权限信息
     * @return
     */

    public static String token(String userInfo, List<String> authList){
        return JWT.create()
                .withExpiresAt(new Date(System.currentTimeMillis()+ 1000L * 60 * 60 * 24 * 30))  //设置过期时间:单位毫秒
                .withClaim("userInfo",userInfo)
                .withClaim("authList",authList)
                .sign(Algorithm.HMAC256(secret));
    }

    /**
     * 根据指定日期返回token
     * @param authentication 认证信息
     * @param time 过期时间 单位毫秒
     * @return 返回token
     */
    public static String token(Authentication authentication,Long time){
        return JWT.create()
                .withExpiresAt(new Date(System.currentTimeMillis()+ 1000L * 60 * 60 * 24 * 30))  //设置过期时间:单位毫秒
                .withAudience(JSON.toJSONString(authentication)) //设置接受方信息,一般时登录用户
                .sign(Algorithm.HMAC256(secret));
    }

    /**
     * 验证token合法性
     */
    public static void tokenVerify(String token){
        JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(secret)).build();
        jwtVerifier.verify(token);//没报错说明验证成功

        log.info("token校验成功!");

//        JWT.decode(token).getExpiresAt();
//        String json = JWT.decode(token).getAudience().get(0);
//        JwtAuthentication jwtAuthentication = JSON.parseObject(json, JwtAuthentication.class);

//        SecurityContextHolder.getContext().setAuthentication(jwtAuthentication);
    }

    /**
     * 刷新token
     * @param token
     * @return
     */
    public static String refreshToken(String token){
        JwtUtils.tokenVerify(token);
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        return JwtUtils.token(authentication);
    }

    public static String getUserInfoFromToken(String token){
        JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(secret)).build();
        DecodedJWT decodedJWT = jwtVerifier.verify(token);
        return decodedJWT.getClaim("userInfo").asString();
    }

    public static List<String> getUserAuthorizationFromToken(String token){
        try {
            JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(secret)).build();
            DecodedJWT decodedJWT = jwtVerifier.verify(token);
            return decodedJWT.getClaim("authList").asList(String.class);
        } catch (Exception e) {
            return null;
        }
    }
}
JwtAuthentication
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;

import java.util.Collection;

/**
 *  {@link Authentication}及其实现类的大部分属性没有提供setter方法,
 *  所以在通过json转换回Authentication时,没有setter方法的属性就赋值为空,此类是json转换回Authentication的中间类。
 */
public class JwtAuthentication implements Authentication {

    private Collection<SimpleGrantedAuthority> authorities;
    private Object details;
    private boolean authenticated;
    private Object principal;
    private Object credentials;

    @Override
    public Collection<SimpleGrantedAuthority> getAuthorities() {
        return authorities;
    }

    public void setAuthorities(Collection<SimpleGrantedAuthority> authorities) {
        this.authorities = authorities;
    }

    @Override
    public Object getDetails() {
        return details;
    }

    public void setDetails(Object details) {
        this.details = details;
    }

    @Override
    public boolean isAuthenticated() {
        return authenticated;
    }

    @Override
    public void setAuthenticated(boolean authenticated) {
        this.authenticated = authenticated;
    }

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

    public void setPrincipal(Object principal) {
        this.principal = principal;
    }

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

    public void setCredentials(Object credentials) {
        this.credentials = credentials;
    }

    @Override
    public String getName() {
        return null;
    }
}

认证成功处理器

action:当用户登录成功后,此认证成功处理器将自动生成jwt,后面就可以带着token访问其他页面了

编写处理器
import com.yannqing.utils.JwtUtils;
import com.yannqing.utils.Result;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

public class LoginSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        //生成token,这是其中之一的方法
        String token = JwtUtils.token(authentication);
        //存入redis
        Jedis jedis = new Jedis("http://127.0.0.1:6379/");
        jedis.auth("123456");
        jedis.set("token:"+token, String.valueOf(authentication),new SetParams().ex(60*60*2));

        //返回给前端
        Map<String, Object> data = new HashMap<>();
        data.put("token",token);
        response.getWriter().write(Result.okJSON(data));


    }
}
将认证成功处理器交给spring security
//配置表单登录
http.formLogin((formLogin)->formLogin
        //配置认证成功处理器
        .successHandler(new LoginSuccessHandler())
        .permitAll()
);

认证失败处理器

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;

import java.io.IOException;

public class LoginFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        response.setContentType("text/html;charset=utf-8");
        response.getWriter().write("loginErr");
        System.out.println("登录异常信息:");
        exception.printStackTrace();
    }
}
将认证失败处理器交给spring security
//配置表单登录
http.formLogin((formLogin)->formLogin
        //配置认证成功处理器
        .successHandler(new LoginFailureHandler())
        .permitAll()
);

编写jwt校验过滤器

action:每次访问非登录页面的时候,需要校验一下用户携带的jwt是否合法,是否有权限访问等等

编写过滤器
import cn.hutool.json.JSONUtil;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.yannqing.entity.User;
import com.yannqing.utils.JwtUtils;
import com.yannqing.utils.Result;
import com.yannqing.utils.YannqingTools;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import redis.clients.jedis.Jedis;

import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;

/**
 * 登录之前获取token校验:如果有token、再去校验token的合法性:如果没有报错 则认为登录成功
 * 【token是根据SpringSecurity的Authentication生成的,相当于token就是SpringSecurity认证后的凭证】
 */
@Slf4j
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String requestURI = request.getRequestURI();

        //匿名地址直接访问
        if(YannqingTools.contains(requestURI)){
            filterChain.doFilter(request, response);
            return;
        }

        //获取JWT
        String token = request.getHeader("token");
        log.info("接收到的token: {}",token);
        Jedis jedis = new Jedis("http://127.0.0.1:6379/");
        jedis.auth("123456");
        //判断redis中是否存在jwt
        String redisToken = jedis.get("token:" + token);
        if (redisToken==null){
            response.setStatus(500);
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write(JSONUtil.toJsonStr(Result.error("您已退出,请重新登录!")));
            return;
        }


        if (token != null) {
            try {
                JwtUtils.tokenVerify(token);
                //下面是第二种方法生成的token,需要用户详细信息与用户权限信息
//                String userInfo = JwtUtils.getUserInfoFromToken(token);
//                ObjectMapper objectMapper = new ObjectMapper();
//                User user = objectMapper.readValue(userInfo, User.class);
//                List<String> authList = JwtUtils.getUserAuthorizationFromToken(token);
//                List<SimpleGrantedAuthority> authorities = authList.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
//                UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(user,null,authorities);
//                SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
            }catch (Exception e){
                response.setStatus(200);
                response.setContentType("application/json;charset=UTF-8");
                response.getWriter().write(JSONUtil.toJsonStr(Result.error("非法token")));
                return;
            }
        }
        filterChain.doFilter(request, response);
    }
}
将过滤器器交给spring security
//添加jwt校验过滤器到用户名密码过滤器之前
http.addFilterBefore(JwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

与redis结合使用

action:由于当用户每次登陆时都会创建一个新的jwt,所以退出登录后,原来的jwt不能自动销毁,可能会被黑客利用,所以通过将jwt放到redis中,每次退出登录时,删除redis中的jwt,每次访问url的时候,判断redis中的jwt是否存在。

关闭csrf跨域请求保护
//关闭跨域请求保护
http.csrf(AbstractHttpConfigurer::disable);
添加依赖
<!--        redis-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
    </dependencies>
生成jwt时存入redis

action:首先需要依赖注入StringRedisTemplate,然后在认证成功处理器中,生成jwt后添加下面的代码

//将jwt放到redis中,过期时间和jwt过期时间一致
stringRedisTemplate.opsForValue().set("loginToken:"+jwt,objectMapper.writeValueAsString(authentication),2, TimeUnit.HOURS);
退出成功处理器

action:当用户退出时,将jwt从redis中删除

编写处理器

import com.fasterxml.jackson.databind.ObjectMapper;
import com.yannqing.util.JwtUtils;
import com.yannqing.vo.Result;
import jakarta.annotation.Resource;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.io.PrintWriter;

@Component
@Slf4j
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Resource
    private JwtUtils jwtUtils;

    @Resource
    private ObjectMapper objectMapper;

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        //取出请求头中的Authorization
        String authorization = request.getHeader("Authorization");
        if (authentication==null){
            Result result = new Result(0,"jwt不存在",null);
            printToken(request,response,result);
            return;
        }
        //从Authorization中取出jwt并校验
        String jwtToken = authorization.replace("bearer ", "");
        boolean verifyResult = jwtUtils.verifyToken(jwtToken);
        if (!verifyResult){
            Result result = new Result(0,"jwt非法",null);
            printToken(request,response,result);
            return;
        }
        //从redis中删除登录时候的jwtToken
        stringRedisTemplate.delete("loginToken:"+jwtToken);
        Result result = new Result(1,"jwt删除成功",null);
        printToken(request,response,result);
    }

    /**
     * 将token响应给前端
     * @param request
     * @param response
     * @param result
     * @throws IOException
     */
    private void printToken(HttpServletRequest request, HttpServletResponse response, Result result) throws IOException {
        String strResponse = objectMapper.writeValueAsString(result);
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json;charset=utf-8");
        PrintWriter writer = response.getWriter();
        writer.println(strResponse);
        writer.flush();
    }
}
配置到spring security

action:先依赖注入处理器

//配置退出成功处理器
http.logout((logout)->logout
        .logoutSuccessHandler(myLogoutSuccessHandler)
);
校验jwt时,查询redis

action:每次通过jwt校验过滤器时,需要查询redis中是否存在。

位置放在jwt校验过滤器中的,校验jwt后

//判断redis中是否存在jwt
String redisToken = stringRedisTemplate.opsForValue().get("loginToken:" + jwtToken);
if (redisToken==null){
    Result result = new Result(0,"您已退出,请重新登录",null);
    printToken(request,response,result);
    return;
}
  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...