21《Spring Boot 入门教程》Spring Boot 安全管理

1. 前言

安全管理是软件系统必不可少的的功能。根据经典的“墨菲定律”——凡是可能,总会发生。如果系统存在安全隐患,最终必然会出现问题。

本节就来演示下,如何使用 Spring Boot + Spring Security 开发前后端分离的权限管理功能。

2. Spring Security 用法简介

作为一个知名的安全管理框架, Spring Security 对安全管理功能的封装已经非常完整了。

我们在使用 Spring Security 时,只需要从配置文件或者数据库中,把用户、权限相关的信息取出来。然后通过配置类方法告诉 Spring Security , Spring Security 就能自动实现认证、授权等安全管理操作了。

  • 系统初始化时,告诉 Spring Security 访问路径所需要的对应权限。
  • 登录时,告诉 Spring Security 真实用户名和密码。
  • 登录成功时,告诉 Spring Security 当前用户具备的权限。
  • 用户访问接口时,Spring Security 已经知道用户具备的权限,也知道访问路径需要的对应权限,所以自动判断能否访问。

3. 数据库模块实现

3.1 定义表结构

需要 4 张表:

  • 用户表 user:保存用户名、密码,及用户拥有的角色 id 。
  • 角色表 role :保存角色 id 与角色名称。
  • 角色权限表 roleapi:保存角色拥有的权限信息。
  • 权限表 api:保存权限信息,在前后端分离的项目中,权限指的是控制器中的开放接口。

具体表结构如下,需要注意的是 api 表中的 path 字段表示接口的访问路径,另外所有的 id 都是自增主键。


5eca178b095bfe7a07810163.jpg

数据库表结构

3.2 构造测试数据

执行如下 SQL 语句插入测试数据,下面的语句指定了 admin 用户可以访问 viewGoods 和 addGoods 接口,而 guest 用户只能访问 viewGoods 接口。

实例:

-- 用户
INSERT INTO `user` VALUES (1, 'admin', '$2a$10$D0OvhHj2Lh92rNey1EFmM.OqltxhH1vZA8mDpxz7jEofDEqLRplQy', 1);
INSERT INTO `user` VALUES (2, 'guest', '$2a$10$D0OvhHj2Lh92rNey1EFmM.OqltxhH1vZA8mDpxz7jEofDEqLRplQy', 2);
-- 角色
INSERT INTO `role` VALUES (1, '管理员');
INSERT INTO `role` VALUES (2, '游客');
-- 角色权限
INSERT INTO `roleapi` VALUES (1, 1, 1);
INSERT INTO `roleapi` VALUES (2, 1, 2);
INSERT INTO `roleapi` VALUES (3, 2, 1);
-- 权限
INSERT INTO `api` VALUES (1, 'viewGoods');
INSERT INTO `api` VALUES (2, 'addGoods');
代码块12345678910111213

Tips:用户密码是 123 加密后的值,大家了解即可,稍后再进行解释。

4. Spring Boot 后端实现

我们新建一个 Spring Boot 项目,并利用 Spring Security 实现安全管理功能。

4.1 使用 Spring Initializr 创建项目

Spring Boot 版本选择 2.2.5 ,Group 为 com.imooc , Artifact 为 spring-boot-security,生成项目后导入 Eclipse 开发环境。

4.2 引入项目依赖

我们引入 Web 项目依赖、安全管理依赖,由于要访问数据库所以引入 JDBC 和 MySQL 依赖。

实例:

        <!-- Web项目依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- 安全管理依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <!-- JDBC -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <!-- MySQL -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
代码块1234567891011121314151617181920

4.3 定义数据对象

安全管理,肯定需要从数据库中读取用户信息,以便判断用户登录名、密码是否正确,所以需要定义用户数据对象。

实例:

public class UserDo {
    private Long id;
    private String username;
    private String password;
    private String roleId;
    // 省略 get set
}
代码块1234567

4.4 开发数据访问类

系统初始化时,告诉 Spring Security 访问路径所需要的对应权限,所以我们开发从数据库获取权限列表的方法。

实例:

@Repository
public class ApiDao {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    /**
     * 获取所有api
     */
    public List<String> getApiPaths() {
        String sql = "select path from api";
        return jdbcTemplate.queryForList(sql, String.class);
    }
}
代码块12345678910111213

登录时,告诉 Spring Security 真实用户名和密码。 登录成功时,告诉 Spring Security 当前用户具备的权限。

所以我们开发根据用户名获取用户信息和根据用户名获取其可访问的 api 列表方法。

实例:

@Repository
public class UserDao {
    @Autowired
    private JdbcTemplate jdbcTemplate;
    /**
     * 根据用户名获取用户信息
     */
    public List<UserDo> getUsersByUsername(String username) {
        String sql = "select id, username, password from user where username = ?";
        return jdbcTemplate.query(sql, new String[] { username }, new BeanPropertyRowMapper<>(UserDo.class));
    }
    /**
     * 根据用户名获取其可访问的api列表
     */
    public List<String> getApisByUsername(String username) {
        String sql = "select path from user left join roleapi on user.roleId=roleapi.roleId left join api on roleapi.apiId=api.id where username = ?";
        return jdbcTemplate.queryForList(sql, new String[] { username }, String.class);
    }
}

代码块1234567891011121314151617181920

4.5 开发服务类

开发 SecurityService 类,保存安全管理相关的业务方法。

实例:

@Service
public class SecurityService {
    @Autowired
    private UserDao userDao;
    @Autowired
    private ApiDao apiDao;

    public List<UserDo> getUserByUsername(String username) {
        return userDao.getUsersByUsername(username);
    }

    public List<String> getApisByUsername(String username) {
        return userDao.getApisByUsername(username);
    }

    public List<String> getApiPaths() {
        return apiDao.getApiPaths();
    }
}

4.6 开发控制器类

开发控制器类,其中 notLogin 方法是用户未登录时调用的方法,其他方法与权限表中的 api 一一对应。

实例:

@RestController
public class TestController {
    /**
     * 未登录时调用该方法
     */
    @RequestMapping("/notLogin")
    public ResultBo notLogin() {
        return new ResultBo(new Exception("未登录"));
    }

    /**
     * 查看商品
     */
    @RequestMapping("/viewGoods")
    public ResultBo viewGoods() {
        return new ResultBo<>("viewGoods is ok");
    }

    /**
     * 添加商品
     */
    @RequestMapping("/addGoods")
    public ResultBo addGoods() {
        return new ResultBo<>("addGoods is ok");
    }
}

由于是前后端分离的项目,为了便于前端统一处理,我们封装了返回数据业务逻辑对象 ResultBo 。

实例:

public class ResultBo<T> {
    /**
     * 错误码 0表示没有错误(异常) 其他数字代表具体错误码
     */
    private int code;
    /**
     * 后端返回消息
     */
    private String msg;
    /**
     * 后端返回的数据
     */
    private T data;
    /**
     * 无参数构造函数
     */
    public ResultBo() {
        this.code = 0;
        this.msg = "操作成功";
    }
    /**
     * 带数据data构造函数
     */
    public ResultBo(T data) {
        this();
        this.data = data;
    }
    /**
     * 存在异常的构造函数
     */
    public ResultBo(Exception ex) {
        this.code = 99999;// 其他未定义异常
        this.msg = ex.getMessage();
    }
}

4.7 开发 Spring Security 配置类

现在,我们就需要将用户、权限等信息通过配置类告知 Spring Security 了。

4.7.1 定义配置类

定义 Spring Security 配置类,通过注解 @EnableWebSecurity 开启安全管理功能。

实例:

@Configuration
@EnableWebSecurity // 开启安全管理
public class SecurityConfig {
    @Autowired
    private SecurityService securityService;
}

4.7.2 注册密码加密组件

Spring Security 提供了很多种密码加密组件,我们使用官方推荐的 BCryptPasswordEncoder ,直接注册为 Bean 即可。

我们之前在数据库中预定义的密码字符串即为 123 加密后的结果。 Spring Security 在验证密码时,会自动调用注册的加密组件,将用户输入的密码加密后再与数据库密码比对。

实例:

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    public static void main(String[] args) {
        //输出 $2a$10$kLQpA8S1z0KdgR3Cr6jJJ.R.QsIT7nrCdAfsF4Of84ZBX2lvgtbE.
        System.out.println(new BCryptPasswordEncoder().encode("123"));
    }

4.7.3 将用户密码及权限告知 Spring Security

通过注册 UserDetailsService 类型的组件,组件中设置用户密码及权限信息即可。

实例:

    @Bean
    public UserDetailsService userDetailsService() {
        return username -> {
            List<UserDo> users = securityService.getUserByUsername(username);
            if (users == null || users.size() == 0) {
                throw new UsernameNotFoundException("用户名错误");
            }
            String password = users.get(0).getPassword();
            List<String> apis = securityService.getApisByUsername(username);
            // 将用户名username、密码password、对应权限列表apis放入组件
            return User.withUsername(username).password(password).authorities(apis.toArray(new String[apis.size()]))
                    .build();
        };
    }

4.7.4 设置访问路径需要的权限信息

同样,我们通过注册组件,将访问路径需要的权限信息告知 Spring Security 。

实例:

    @Bean
    public WebSecurityConfigurerAdapter webSecurityConfigurerAdapter() {
        return new WebSecurityConfigurerAdapter() {
            @Override
            public void configure(HttpSecurity httpSecurity) throws Exception {
                // 开启跨域支持
                httpSecurity.cors();
                // 从数据库中获取权限列表
                List<String> paths = securityService.getApiPaths();
                for (String path : paths) {
                    /* 对/xxx/**路径的访问,需要具备xxx权限
                    例如访问 /addGoods,需要具备addGoods权限 */
                    httpSecurity.authorizeRequests().antMatchers("/" + path + "/**").hasAuthority(path);
                }
                // 未登录时自动跳转/notLogin
                httpSecurity.authorizeRequests().and().formLogin().loginPage("/notLogin")
                        // 登录处理路径、用户名、密码
                        .loginProcessingUrl("/login").usernameParameter("username").passwordParameter("password")
                        .permitAll()
                        // 登录成功处理
                        .successHandler(new AuthenticationSuccessHandler() {
                            @Override
                            public void onAuthenticationSuccess(HttpServletRequest httpServletRequest,
                                    HttpServletResponse httpServletResponse, Authentication authentication)
                                    throws IOException, ServletException {
                                httpServletResponse.setContentType("application/json;charset=utf-8");
                                ResultBo result = new ResultBo<>();
                                ObjectMapper mapper = new ObjectMapper();
                                PrintWriter out = httpServletResponse.getWriter();
                                out.write(mapper.writeValueAsString(result));
                                out.close();
                            }
                        })
                        // 登录失败处理
                        .failureHandler(new AuthenticationFailureHandler() {
                            @Override
                            public void onAuthenticationFailure(HttpServletRequest httpServletRequest,
                                    HttpServletResponse httpServletResponse, AuthenticationException e)
                                    throws IOException, ServletException {
                                httpServletResponse.setContentType("application/json;charset=utf-8");
                                ResultBo result = new ResultBo<>(new Exception("登录失败"));
                                ObjectMapper mapper = new ObjectMapper();
                                PrintWriter out = httpServletResponse.getWriter();
                                out.write(mapper.writeValueAsString(result));
                                out.flush();
                                out.close();
                            }
                        });
                // 禁用csrf(跨站请求伪造)
                httpSecurity.authorizeRequests().and().csrf().disable();
            }
        };
    }

按上面的设计,当用户发起访问时:

  • 未登录的访问会自动跳转到/notLogin 访问路径。
  • 通过 /login 访问路径可以发起登录请求,用户名和密码参数名分别为 username 和 password 。
  • 登录成功或失败会返回 ResultBo 序列化后的 JSON 字符串,包含登录成功或失败信息。
  • 访问 /xxx 形式的路径,需要具备 xxx 权限。用户所具备的权限已经通过上面的 UserDetailsService 组件告知 Spring Security 了。

5. 测试

启动项目后,我们使用 PostMan 进行验证测试。

5.1 未登录测试

在未登录时,直接访问控制器方法,会自动跳转 /notLogin 访问路径,返回未登录提示信息。

5eca3f6f095e3ba407420354.jpg

未登录测试

5.2 错误登录密码测试

调用登录接口,当密码不对时,返回登录失败提示信息。

5eca3f7909f4b45207440414.jpg

错误登录密码测试

5.3 以 guest 用户登录

使用 guest 用户及正确命名登录,返回操作成功提示信息。

5eca3f8209458abe07420411.jpg

以 guest 用户登录

5.4 guest 用户访问授权接口

按照数据库中定义的规则, guest 用户可以访问 viewGoods 接口方法。


5eca3f8b09829ae807440352.jpg

guest 用户访问授权接口

5.5 guest 用户访问未授权接口

按照数据库中定义的规则, guest 没有访问 addGoods 接口方法的权限。

5eca3f910930576c07430382.jpg

guest 用户访问未授权接口

5.6 admin 用户登录及访问授权接口

按照数据库中定义的规则, admin 用户登录后可以访问 viewGoods 和 addGoods 两个接口方法。

5eca3f970900259307410418.jpg

admin 用户登录

5eca3f9d09e265ae07400353.jpg

admin 用户访问授权接口

5eca3fa4092dfbc707440354.jpg

admin 用户访问授权接口

6. 小结

Spring Boot 整合 Spring Security ,实际上大部分工作都在安全管理配置类上。

我们通过安全管理配置类,将用户、密码及其对应的权限信息放入容器,同时将访问路径所需要的权限信息放入容器, Spring Security 就会按照用户访问路径--判断所需权限--用户是否具备该权限--允许或拒绝访问这样的逻辑实施权限管理了。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,816评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,729评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,300评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,780评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,890评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,084评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,151评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,912评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,355评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,666评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,809评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,504评论 4 334
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,150评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,882评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,121评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,628评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,724评论 2 351

推荐阅读更多精彩内容