admin管理员组文章数量:1634976
前言
本文侧重实战,是统一认证系统的一个demo,适合对oauth2协议、SpringSecurity、Vue等技术有一定理解后阅读。
这个demo以房屋出租系统为背景,主要实现了oauth2的授权码模式,client信息入库,增强token,前后端分离架构,用户RBAC权限模型,前端动态路由等。
demo的gitlab地址会在文末贴出。
先看一下登录及动态路由的效果。
gif展示:超级管理员登录展示
gif展示普通用户登录展示
1.名词解释
1.1.前后端分离
前后端分离的部署架构大家都不陌生,这里就列举一下前后端分离的优缺点。
优点:
- 提高开发效率
前后端各负其责, 前端和后端都做自己擅长的事情,不互相依赖,开发效率更快,而且分工比较均衡,会大大提高开发效率 - 用户访问速度快,提升页面性能,优化用户体验
没有页面之间的跳转,资源都在同一个页面里面,无刷线加载数据,页面片段间的切换快,使用户体验上升了一大截;前后端不分离,稍不留神会触发浏览器的重排和重绘,加载速度慢,降低用户的体验 - 增强代码可维护性,降低维护成本,改善代码的质量
前后端不分离,代码较为繁杂,维护起来难度大,成本高 - 减轻了后端服务器的请求压力
公共资源只需要加载一次,减少了HTTP请求数 - 同一套后端程序代码,不用修改就可以用于Web界面、手机、平板等多种客户端
缺点:
- 首屏渲染的时间长
将多个页面的资源打包糅合到一个页面,这个页面一开始需要加载的东西会非常多,而网速是一定的,所以会导致首屏渲染时间很长,首屏渲染后,就是无刷新更新,用户体验相对较好 - 不利于搜索引擎的优化(SEO)
现有的搜索引擎都是通过爬虫工具来爬取各个网站的信息,这些爬虫工具一般只能爬取页面上(HTML)的内容,而前后端分离,前端的数据基本上都是存放在行为逻辑(JavaScript)文件中,爬虫工具无法爬取,无法分析出你网站到底有什么内容,无法与用户输入的关键词做关联,最终排名就低
原文链接:https://blog.csdn/ccsundhine/article/details/111034332
1.2.oauth2授权码模式
授权码模式:
- 客户端拿着信息到认证服务器
- 认证服务器校验客户端信息后返回授权页面
- 用户授权后,认证服务器生成授权码返回给客户端
- 客户端的后端拿着授权码去请求access token
- 认证服务器生成access token给客户端后端
- 后端拿着access token请求客户所需要的资源
- 资源服务器返回数据
- 客户端渲染数据并展示
原文链接:https://blog.csdn/NeverFG/article/details/124089339
1.3.单点登录
单点登录,英文是 Single Sign On,缩写为 SSO。多个站点(192.168.1.20X)共用一台认证授权服务器(192.168.1.110,用户数据库和认证授权模块共用)。用户经由其中任何一个站点(比如 192.168.1.201)登录后,可以免登录访问其他所有站点。而且,各站点间可以通过该登录状态直接交互。
1.4.RBAC权限模型
RBAC(Role Based Access Control,基于角色的访问控制),就是用户通过角色与权限进行关联,而不是直接将权限赋予用户。
一个用户拥有若干个角色,每个角色拥有若干个权限,这样就构成了“用户-角色-权限”的授权模型。这种授权模型的好处在于,不必每次创建用户时都进行权限分配的操作,只要分配用户相应的角色即可,而且角色的权限变更比用户的权限变更要少得多,减少频繁设置。
RBAC模型中,用户与角色之间、角色与权限之间,一般是多对多的关系。
1.5.动态路由
基于RBAC权限模型,每个用户都有一个或多个角色,每个角色对应一个或多个功能。比如一个房屋出租系统,作为游客,只能查看房源信息;作为用户,除了查看房源信息,还可以维护自己的用户信息,房源信息等;作为一个地区的管理员,可能有拉黑某些账号,删除不合规房源的权限。如何在用户登录时根据其角色不同展示不同的界面信息和功能模块呢?
对于前后端分离的架构来说,实现动态路由通常有两种方案:
一、将路由表存在客户端,根据后端传回的用户角色信息确定加载哪些路由。
二、将路由信息以数据库的形式存储起来,在登陆时,通过查询用户的权限信息动态的生成路由表返回给前端,前端拿到路由表后添加到路由里。
第一种方式的优点是,它实施起来较为简单,路由表实际是写死在前端页面里了,后端只需要查询当前用户的角色返回给前端即可;缺点是,如果路由有变化则需要修改前端代码。
第二种方式的优点是,它真正的实现了动态路由,路由信息修改灵活,修改数据库的代价肯定比修改前端代码中的路由表的代价小;缺点是,实施起来相对复杂。
2.总体目标
- 前后端分离架构
- 实现oauth2协议的授权码模式
- 实现基于oauth2的SSO单点登录
- 实现基于RBAC权限模型的动态路由
3.技术选型
前端:
vue+Element plus组件库
后端:
springboot+mybatisplus+springsecurity+msyql数据库
4.搭建过程
4.1.认证及资源服务器搭建
4.1.1.资源准备
4.1.1.1.pom.xml文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache/POM/4.0.0" xmlns:xsi="http://www.w3/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache/POM/4.0.0 https://maven.apache/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.5.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.xxxx</groupId>
<artifactId>springsecurityoauth2-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>springsecurityoauth2-demo</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
<spring-cloud.version>Greenwich.SR2</spring-cloud.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!--jwt 依赖-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
<!--MySQL数据库驱动-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!--mybatis-plus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.16</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.9</version>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
4.1.1.2.数据库搭建
首先,要实现oauth2的授权码模式,需要提供以下表格,分别用于存储授权码code、存储用户授权动作信息、存储access_token、refresh_token、存储客户端信息等。建表sql参见:
https://blog.csdn/xiangjunyes/article/details/121582654
其中oauth_client_details表插入一条数据,表示注册一条客户端信息。
insert into `oauth_client_details`(`client_id`,`resource_ids`,`client_secret`,`scope`,`authorized_grant_types`,`web_server_redirect_uri`,`authorities`,`access_token_validity`,`refresh_token_validity`,`additional_information`,`autoapprove`) values ('admin',NULL,'$2a$10$L.xhi7kv2Eq8SHWtlt0kTOypBymybsD7t0zZelw1oLLsDRHi9iCni','all','authorization_code','http://localhost:9092/#/login,http://localhost:9092/#/home,http://localhost:8088/user/remoteCall,http://localhost:8088/login,http://localhost:8089/login,http://localhost:8089/user/login,http://localhost:8088/user/login,http://localhost:8088/user/getMenu',NULL,3600,864000,NULL,'true');
client_secret字段加密后存入,对应明文密码为112233。
web_server_redirect_uri字段有很多我用于测试的url,可按需修改。
接下来,RBAC权限模型的数据表(以房屋出租系统为项目背景)建表sql参见:
CREATE TABLE `sys_menu` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '菜单名',
`path` varchar(200) DEFAULT NULL COMMENT '路由地址',
`component` varchar(255) DEFAULT NULL COMMENT '组件路径',
`status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT '0' COMMENT '菜单状态(0正常 1停用)',
`del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT '0' COMMENT '是否删除(0未删除 1已删除)',
`perms` varchar(100) DEFAULT NULL COMMENT '权限标识',
`icon` varchar(100) DEFAULT '#' COMMENT '菜单图标',
`create_by` bigint(20) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` bigint(20) DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`remark` bigint(20) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='菜单表';
/*Data for the table `sys_menu` */
insert into `sys_menu`(`id`,`name`,`path`,`component`,`status`,`del_flag`,`perms`,`icon`,`create_by`,`create_time`,`update_by`,`update_time`,`remark`) values (1,'房源查询','/houseSearch','houseSearch','0','0','system:user','#',3,'2022-07-13 13:47:10',3,'2022-07-13 13:47:10',1),(2,'房屋管理','/houseManage','houseManage','0','0','system:user','#',3,'2022-07-13 13:47:10',3,'2022-07-13 13:47:10',1),(3,'用户中心','/userCenter','userCenter','0','0','system:user','#',3,'2022-07-13 13:47:10',3,'2022-07-13 13:47:10',1),(4,'用户管理','/userManage','userManage','0','0','system:admin','#',3,'2022-07-13 13:47:10',3,'2022-07-13 13:47:10',1),(5,'客诉中心','/customerService','customerService','0','0','system:admin','#',3,'2022-07-13 13:47:10',3,'2022-07-13 13:47:10',1),(6,'角色管理','/roleManage','roleManage','0','0','system:sadmin','#',3,'2022-07-13 13:47:10',3,'2022-07-13 13:47:10',1),(7,'菜单管理','/menuManage','menuManage','0','0','system:sadmin','#',3,'2022-07-13 13:47:10',3,'2022-07-13 13:47:10',1);
/*Table structure for table `sys_role` */
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(128) DEFAULT NULL,
`role_key` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '角色权限字符串',
`status` char(1) DEFAULT '0' COMMENT '角色状态(0正常 1停用)',
`del_flag` char(1) DEFAULT '0' COMMENT 'del_flag',
`create_by` bigint(200) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` bigint(200) DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='角色表';
/*Data for the table `sys_role` */
insert into `sys_role`(`id`,`name`,`role_key`,`status`,`del_flag`,`create_by`,`create_time`,`update_by`,`update_time`,`remark`) values (1,'游客','visitor','0','0',1,'2022-07-07 10:22:29',1,'2022-07-07 10:22:29',NULL),(2,'用户','user','0','0',1,'2022-07-07 10:22:29',1,'2022-07-07 10:22:29',NULL),(3,'管理员','admin','0','0',1,'2022-07-07 10:22:29',1,'2022-07-07 10:22:29',NULL),(4,'超级管理员','sadmin','0','0',1,'2022-07-07 10:22:29',1,'2022-07-07 10:22:29',NULL);
/*Table structure for table `sys_role_menu` */
DROP TABLE IF EXISTS `sys_role_menu`;
CREATE TABLE `sys_role_menu` (
`role_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
`menu_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '菜单id',
PRIMARY KEY (`role_id`,`menu_id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*Data for the table `sys_role_menu` */
insert into `sys_role_menu`(`role_id`,`menu_id`) values (1,1),(2,1),(2,2),(2,3),(3,1),(3,2),(3,3),(3,4),(3,5),(4,1),(4,2),(4,3),(4,4),(4,5),(4,6),(4,7);
/*Table structure for table `sys_user` */
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',
`nick_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '昵称',
`password` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '密码',
`status` char(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
`email` varchar(64) DEFAULT NULL COMMENT '邮箱',
`phone_number` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '手机号',
`sex` char(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',
`avatar` varchar(128) DEFAULT NULL COMMENT '头像',
`user_type` char(1) NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)',
`create_by` bigint(20) DEFAULT NULL COMMENT '创建人的用户id',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` bigint(20) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`del_flag` int(11) DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户表';
/*Data for the table `sys_user` */
insert into `sys_user`(`id`,`user_name`,`nick_name`,`password`,`status`,`email`,`phone_number`,`sex`,`avatar`,`user_type`,`create_by`,`create_time`,`update_by`,`update_time`,`del_flag`) values (1,'rrr','wqw','$2a$10$aCh4JMfuqWYJrXPkPbCV9OQHdN5VkqlKG599XdqgfN6r0/dLf6pQG','0','qqq@163','11111111111','1','#','1',1,'2022-07-20 16:14:46',NULL,'2022-07-12 13:47:23',0),(2,'www','coding','$2a$10$aCh4JMfuqWYJrXPkPbCV9OQHdN5VkqlKG599XdqgfN6r0/dLf6pQG','0','www@163','22222222222','1','#','1',2,'2022-07-20 16:14:46',NULL,'2022-07-12 13:47:23',0),(3,'eee','sdaw','$2a$10$aCh4JMfuqWYJrXPkPbCV9OQHdN5VkqlKG599XdqgfN6r0/dLf6pQG','0','eee@163','33333333333','1','#','1',3,'2022-07-20 16:14:46',NULL,'2022-07-12 13:47:23',0),(4,'qqq','sadasd','$2a$10$aCh4JMfuqWYJrXPkPbCV9OQHdN5VkqlKG599XdqgfN6r0/dLf6pQG','0','rrr@163','44444444444','1','#','1',4,'2022-07-20 16:14:46',NULL,'2022-07-12 13:47:23',0),(5,'ttt','asda','$2a$10$aCh4JMfuqWYJrXPkPbCV9OQHdN5VkqlKG599XdqgfN6r0/dLf6pQG','0','ttt@163','55555555555','1','#','1',5,'2022-07-20 16:14:46',NULL,'2022-07-12 13:47:23',0);
/*Table structure for table `sys_user_role` */
DROP TABLE IF EXISTS `sys_user_role`;
CREATE TABLE `sys_user_role` (
`user_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '用户id',
`role_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '角色id',
PRIMARY KEY (`user_id`,`role_id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*Data for the table `sys_user_role` */
insert into `sys_user_role`(`user_id`,`role_id`) values (1,1),(2,2),(3,3),(4,4);
sys_user中的用户密码经过加密后存储,对应的明文密码为1234。
4.1.1.3.引入datasource
在application.yml中引入数据库。
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: your mysql url?serverTimezone=GMT%2B8&useSSL=false&rewriteBatchedStatements=true&useUnicode=true&characterEncoding=utf8
username: root
password: your password
4.1.1.4.数据层准备
通过mybatisPlus自动生成sys_user和sys_menu的mapper、service、dao。
将自定义的sql语句写入mapper.xml文件中。
用户信息查询sql
<mapper namespace="com.xxxx.springsecurityoauth2demo.mapper.SysUserMapper">
<select id="selectUserInfoByUserName" resultType="com.xxxx.springsecurityoauth2demo.pojo.dto.SysUserDTO">
SELECT
user_name,nick_name,email,phone_number,sex,avatar,create_by,create_time,update_by,update_time
FROM
sys_user
WHERE
user_name = #{userName}
</select>
</mapper>
用户菜单查询sql
<mapper namespace="com.xxxx.springsecurityoauth2demo.mapper.SysMenuMapper">
<select id="selectPermsByUserId" resultType="java.lang.String">
SELECT
DISTINCT m.`perms`
FROM
sys_user_role ur
LEFT JOIN `sys_role` r ON ur.`role_id` = r.`id`
LEFT JOIN `sys_role_menu` rm ON ur.`role_id` = rm.`role_id`
LEFT JOIN `sys_menu` m ON m.`id` = rm.`menu_id`
WHERE
user_id = #{userId}
AND r.`status` = 0
AND m.`status` = 0
</select>
<select id="selectMenusByUserName" resultType="com.xxxx.springsecurityoauth2demo.pojo.dto.SysMenuDTO">
SELECT
DISTINCT sm.`name`,sm.`path`,sm.`component`,sm.`icon`
FROM
sys_user su
LEFT JOIN sys_user_role sur ON su.`id`=sur.`user_id`
LEFT JOIN sys_role_menu srm ON sur.`role_id`=srm.`role_id`
LEFT JOIN sys_menu sm ON srm.`menu_id`=sm.`id`
WHERE
user_name = #{userName}
AND su.`status`=0
AND sm.`status`=0
</select>
</mapper>
准备一个登录用户类User,SpringSecurity提供了一个UserDetails接口,所有接收到和查询到的登录信息都保存在这个接口的实现类中,SpringSecurity提供了一个默认的User类,开发人员也可根据需要自行定义。
package com.xxxx.springsecurityoauth2demo.pojo;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
/**
* 用户类
*/
public class User implements UserDetails {
private String username;
private String password;
private List<GrantedAuthority> authorities;
public User(String username, String password, List<GrantedAuthority> authorities) {
this.username = username;
this.password = password;
this.authorities = authorities;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
准备一个Result类,定义好认证服务器返回数据的格式
package com.xxxx.springsecurityoauth2demo.pojo;
import lombok.Data;
import java.io.Serializable;
/**
* 返回前端实体类
* @author qqq
* @param <T>
*/
@Data
public class Result<T> implements Serializable {
private Integer code;
private String message;
private T data;
public Result(Code code, T data) {
this.code = code.getCode();
this.message = code.getMessage();
this.data = data;
}
public Result(Code code) {//重载,可只传code
this.code = code.getCode();
this.message = code.getMessage();
}
public Result(String error){//重载,可只传message
this.code=500;
this.message = error;
}
}
制定数据传输对象DTO。DTO的作用是,在回传用户信息时,我们不想按数据库里定义好的User对象的格式回传,通常数据库内都有些敏感字段(密码、余额等),我们可以定义一个不包含这些敏感信息的用户类用作数据传输,将数据库查询的数据类型(resultType)设置为DTO即可。
用户DTO
package com.xxxx.springsecurityoauth2demo.pojo.dto;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 用户表
* @TableName sys_user
*/
@TableName(value ="sys_user")
@Data
public class SysUserDTO implements Serializable {
/**
* 用户名
*/
private String userName;
/**
* 昵称
*/
private String nickName;
/**
* 邮箱
*/
private String email;
/**
* 手机号
*/
private String phoneNumber;
/**
* 用户性别(0男,1女,2未知)
*/
private String sex;
/**
* 头像
*/
private String avatar;
/**
* 创建人的用户id
*/
private Long createBy;
/**
* 创建时间
*/
private Date createTime;
/**
* 更新人
*/
private Long updateBy;
/**
* 更新时间
*/
private Date updateTime;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}
菜单DTO
package com.xxxx.springsecurityoauth2demo.pojo.dto;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
/**
* 菜单表
* @author qqq
* @TableName sys_menu
*/
@TableName(value ="sys_menu")
@Data
public class SysMenuDTO implements Serializable {
/**
* 菜单名
*/
private String name;
/**
* 路由地址
*/
private String path;
/**
* 组件路径
*/
private String component;
/**
* 菜单图标
*/
private String icon;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}
最后别忘了在springboot启动类上加上MapperScan注解表明mapper文件的位置。
4.1.2.AuthorizationServerConfig配置
4.1.2.1.AuthorizationServerConfig
package com.xxxx.springsecurityoauth2demo.config;
import com.xxxx.springsecurityoauth2demo.service.UserServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import javax.sql.DataSource;
import java.util.ArrayList;
import java.util.List;
/**
* 授权服务器配置
*
* @author qqq
* @since 1.0.0
*/
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private UserServiceImpl userServiceImpl;
@Autowired
@Qualifier("jwtTokenStore")
private TokenStore tokenStore;
@Autowired
private JwtAccessTokenConverter jwtAccessTokenConverter;
@Autowired
private JwtTokenEnhancer jwtTokenEnhancer;
@Autowired
private DataSource dataSource;
/**
* 用来配置令牌(token)的访问端点和令牌服务(tokenservices)
* @param endpoints
* @throws Exception
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
//配置JWT内容增强器
TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
List<TokenEnhancer> delegates = new ArrayList<>();
delegates.add(jwtTokenEnhancer);
delegates.add(jwtAccessTokenConverter);
enhancerChain.setTokenEnhancers(delegates);
endpoints.authenticationManager(authenticationManager)
.userDetailsService(userServiceImpl)
//配置存储令牌策略
.tokenStore(tokenStore)
.accessTokenConverter(jwtAccessTokenConverter)
.tokenEnhancer(enhancerChain);
}
/**
* 配置客户端详情
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
//将dataSource配置到client中,client信息会从此数据库的oauth_client_details表中读取
clients.jdbc(dataSource);
//在测试阶段也可以以内存的形式配置client信息
// clients.inMemory()
// //配置client-id
// .withClient("admin")
// //配置client-secret
// .secret(passwordEncoder.encode("112233"))
// //配置访问token的有效期
// .accessTokenValiditySeconds(3600)
// //配置刷新Token的有效期
// .refreshTokenValiditySeconds(864000)
// //配置redirect_uri,用于授权成功后跳转
// .redirectUris("http://localhost:8088/login")
// //自动授权配置
// .autoApprove(true)
// //配置申请的权限范围
// .scopes("all")
// //配置grant_type,表示授权类型
// .authorizedGrantTypes("password","refresh_token","authorization_code");
}
/**
* 用来配置令牌端点的安全约束.
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
//获取秘钥需要身份认证,使用单点登录时必须配置
security.tokenKeyAccess("isAuthenticated()");
}
}
4.1.2.2.自定义JWT存储器、增强器
存储器
package com.xxxx.springsecurityoauth2demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
/**
* JwtToken配置类
*
* @author qqq
* @since 1.0.0
*/
@Configuration
public class JwtTokenStoreConfig {
@Bean
public TokenStore jwtTokenStore(){
return new JwtTokenStore(jwtAccessTokenConverter());
}
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter(){
JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter();
//配置JWT使用的秘钥
accessTokenConverter.setSigningKey("test_key");
return accessTokenConverter;
}
@Bean
public JwtTokenEnhancer jwtTokenEnhancer(){
return new JwtTokenEnhancer();
}
}
增强器
package com.xxxx.springsecurityoauth2demo.config;
import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import java.util.HashMap;
import java.util.Map;
/**
* JWT内容增强器
*
* @author qqq
* @since 1.0.0
*/
public class JwtTokenEnhancer implements TokenEnhancer {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
Map<String,Object> info = new HashMap<>();
info.put("enhance","enhance info");
((DefaultOAuth2AccessToken)accessToken).setAdditionalInformation(info);
return accessToken;
}
}
4.1.2.3.自定义登录逻辑
package com.xxxx.springsecurityoauth2demo.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.xxxx.springsecurityoauth2demo.mapper.SysMenuMapper;
import com.xxxx.springsecurityoauth2demo.mapper.SysUserMapper;
import com.xxxx.springsecurityoauth2demo.pojo.SysUser;
import com.xxxx.springsecurityoauth2demo.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
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.List;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* 自定义登录逻辑
* @author qqq
* @since 1.0.0
*/
@Service
public class UserServiceImpl implements UserDetailsService {
@Autowired
private SysUserMapper sysUserMapper;
@Autowired
private SysMenuMapper sysMenuMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//登录校验
QueryWrapper<SysUser> sysUserQueryWrapper = new QueryWrapper<>();
sysUserQueryWrapper.eq("user_name",username);
SysUser loginUser = sysUserMapper.selectOne(sysUserQueryWrapper);
if (Objects.isNull(loginUser)){
throw new UsernameNotFoundException("the user is not found");
}
List<String> perms = sysMenuMapper.selectPermsByUserId(loginUser.getId());
List<GrantedAuthority> authorities = perms.stream()
.map(SimpleGrantedAuthority::new)
// .map(s -> new SimpleGrantedAuthority(s))
.collect(Collectors.toList());
return new User(loginUser.getUserName(), loginUser.getPassword(), authorities);
}
}
4.1.3.Spring Security配置
package com.xxxx.springsecurityoauth2demo.config;
import com.xxxx.springsecurityoauth2demo.handler.CustomLoginSuccessHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter;
import org.springframework.web.filter.CorsFilter;
/**
* Security配置类
* @author qqq
* @since 1.0.0
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Autowired
private CustomLoginSuccessHandler customLoginSuccessHandler;
@Autowired
private CorsFilter corsFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/oauth/**","/login/**","logout/**")
.permitAll()
.anyRequest()
.authenticated()
.and()
.formLogin()
.successHandler(customLoginSuccessHandler)
.permitAll()
.and()
// 添加处理跨域过滤器
.addFilterBefore(corsFilter, WebAsyncManagerIntegrationFilter.class)
// 允许iframe嵌入
.headers()
.frameOptions()
.disable()
.and()
// 配置logout
.logout();
}
}
作为前后端分离架构下的SSO认证服务器,在SpringSecurity中有两个配置非常重要,一个是跨域过滤器corsFilter,二是登录成功处理器successHandler。前者实现请求跨域功能;后者可以在用户登录后跳转到指定的前端页面,这点很重要,一开始对框架的了解不够深入,作者在这里摸索了很久(摸索了一套用iframe+前端监听实现的登录跳转,最后可以把这套方案也列在文章最后),如果对框架登录的处理流程有所了解的话,会少走很多弯路。
4.1.4.跨域配置
package com.xxxx.springsecurityoauth2demo.config;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
/**
* @author qqq
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CustomerCorsFilter extends org.springframework.web.filter.CorsFilter {
public CustomerCorsFilter() {
super(configurationSource());
}
private static UrlBasedCorsConfigurationSource configurationSource() {
CorsConfiguration corsConfig = new CorsConfiguration();
List<String> allowedHeaders = Arrays.asList("x-auth-token", "content-type", "X-Requested-With", "XMLHttpRequest","Access-Control-Allow-Origin","Authorization","authorization");
List<String> exposedHeaders = Arrays.asList("x-auth-token", "content-type", "X-Requested-With", "XMLHttpRequest","Access-Control-Allow-Origin","Authorization","authorization");
List<String> allowedMethods = Arrays.asList("POST", "GET", "DELETE", "PUT", "OPTIONS","HEAD");
List<String> allowedOrigins = Arrays.asList("*");
corsConfig.setAllowedHeaders(allowedHeaders);
corsConfig.setAllowedMethods(allowedMethods);
corsConfig.setAllowedOrigins(allowedOrigins);
corsConfig.setExposedHeaders(exposedHeaders);
corsConfig.setMaxAge(36000L);
corsConfig.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfig);
return source;
}
}
4.1.5.登录成功处理器
package com.xxxx.springsecurityoauth2demo.handler;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* @author qqq
*/
@Component
public class CustomLoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private DefaultRedirectStrategy defaultRedirectStrategy = new DefaultRedirectStrategy();
@Override
protected void handle(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println(authentication);
defaultRedirectStrategy.sendRedirect(request,response,"http://localhost:9092/#/home");
// http://localhost:9092/#/home就是登录成功后要跳转的前端页面
}
}
4.1.6.ResourceServerConfig配置
package com.xxxx.springsecurityoauth2demo.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
/**
* 资源服务器配置
*
* @author qqq
* @since 1.0.0
*/
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest()
.authenticated()
.and()
.requestMatchers()
.antMatchers("/user/**");
}
}
4.1.7.受保护资源UserController
package com.xxxx.springsecurityoauth2demo.controller;
import com.alibaba.fastjson.JSON;
import com.xxxx.springsecurityoauth2demo.pojo.Code;
import com.xxxx.springsecurityoauth2demo.pojo.Result;
import com.xxxx.springsecurityoauth2demo.pojo.dto.SysMenuDTO;
import com.xxxx.springsecurityoauth2demo.pojo.dto.SysUserDTO;
import com.xxxx.springsecurityoauth2demo.service.SysMenuService;
import com.xxxx.springsecurityoauth2demo.service.SysUserService;
import io.jsonwebtoken.Jwts;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Objects;
import static com.xxxx.springsecurityoauth2demo.utils.Utils.toJsonString;
/**
* @author qqq
* @since 1.0.0
*/
@RestController
@RequestMapping("/user")
@CrossOrigin
public class UserController {
@Autowired
private SysMenuService sysMenuService;
@Autowired
private SysUserService sysUserService;
/**
* 获取当前用户的menu
*
*/
@RequestMapping("/getMenu")
@PreAuthorize("hasAuthority('system:user')")
public Object getMenu(Authentication authentication, HttpServletRequest request){
String userName = (String) authentication.getPrincipal();
if (Objects.isNull(userName)){
throw new RuntimeException("用户未登录");
}
List<SysMenuDTO> menusByUserName =
sysMenuService.getMenusByUserName(userName);
HashMap<String, List> stringListHashMap = new HashMap<>();
stringListHashMap.put("routes",menusByUserName);
System.out.println(userName + ":" + menusByUserName);
return toJsonString(new Result(Code.SUCCESS,stringListHashMap));
}
/**
* 获取当前用户的信息
*
*/
@RequestMapping("/getUserInfo")
@PreAuthorize("hasAuthority('system:user')")
@ResponseBody
public String login(Authentication authentication, HttpServletRequest request, HttpServletResponse response){
// 1.从认证信息中取出用户名
String userName = (String) authentication.getPrincipal();
// 2.到数据库查询用户信息及权限信息
List<SysUserDTO> userInfoByUserName =
sysUserService.getUserInfoByUserName(userName);
// 3.构造map并转为json格式后返回
HashMap<String, List> stringListHashMap = new HashMap<>();
stringListHashMap.put("userInfo",userInfoByUserName);
// return toJsonString(new Result(Code.SUCCESS,userInfoByUserName));
return toJsonString(new Result(Code.SUCCESS,stringListHashMap));
}
/**
* 登录接口
*
*/
@RequestMapping("/login")
@PreAuthorize("hasAuthority('system:user')")
@ResponseBody
public String login(){
return JSON.toJSONString("Logging in, please wait...");
}
}
4.1.8.toJSON工具类
package com.xxxx.springsecurityoauth2demo.utils;
import com.alibaba.fastjson.JSON;
import com.xxxx.springsecurityoauth2demo.pojo.Code;
import com.xxxx.springsecurityoauth2demo.pojo.Result;
import java.util.HashMap;
import java.util.Map;
/**
* 自定义工具包
* @author qqq
*/
public class Utils {
/**
* 出错时掉调用,返回Code
* @param code
* @return
*/
public static String FailResult(Code code){
Result<Object> result = new Result<>(code);
return toJsonString(result);
}
/**
* 出错时掉调用,返回错误提示
* @param message
* @return
*/
public static String FailResult(String message){
Result<Object> result = new Result<>(message);
return toJsonString(result);
}
/**
* 成功时返回单个数据
* @param object
* @return
*/
public static String result(Object object){
Result<Object> result = new Result<>(Code.SUCCESS, object);
String string = toJsonString(result);
return string;
}
/**
* 成功时 返回打包给前端的json字符串,返回多个数据,以下面格式来
* @param args 返回数据,严格按照顺序:先数据名1,然后数据1,数据名2,数据2,。。。若无数据返回则不写
* @return
*/
public static String resultMulti(Object...args){
int len = args.length;
HashMap<String, Object> map = new HashMap<String, Object>();
Result result = null;
if((len+2)%2!=0){
result = new Result(Code.RESULT_STRING_METHOD_VALUE_WRONG,null);
}
if(len==0){
result = new Result<Map>(Code.SUCCESS,null);
}
String name = null;
Object o = null;
for (int i =0;i<len;i++){
if((i+2)%2==0 ){
name = args[i].toString();
}
if ((i+2)%2!=0){
o = args[i];
}
if(name!=null&&o!=null) {
map.put(name, o);
name=null;
o=null;
}
}
result = new Result<Map>(Code.SUCCESS,map);
String string = toJsonString(result);
return string;
}
/**
* 对象转json
* @param re
* @return
*/
public static String toJsonString(Result re){
return JSON.toJSONString(re);
}
}
至此认证及资源服务器的搭建完成。
4.1.9.总结
在AuthorizationServerConfig中配置客户端信息,令牌存储、增强,跨域配置,登录成功处理器。
在UserServiceImpl中自定义登录逻辑。
在ResourceServerConfig中配置资源访问权限
4.2.客户端搭建
4.2.1.资源准备
4.2.1.1.pom.xml文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache/POM/4.0.0" xmlns:xsi="http://www.w3/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache/POM/4.0.0 https://maven.apache/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.5.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.xxxx</groupId>
<artifactId>oauth2client01-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>oauth2client01-demo</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
<spring-cloud.version>Greenwich.SR2</spring-cloud.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
<version>2.1.2.RELEASE</version>
</dependency>
<!-- https://mvnrepository/artifact/com.alibaba/fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.9</version>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
4.2.1.2.application.yml文件
server:
port: 8088
servlet:
session:
cookie:
name: OAUTH2-CLIENT-SESSIONID01
security:
# 配置客户端信息以及认证服务器的地址
oauth2:
client:
client-id: admin
client-secret: 112233
user-authorization-uri: http://localhost:8080/oauth/authorize
access-token-uri: http://localhost:8080/oauth/token
resource:
jwt:
key-uri: http://localhost:8080/oauth/token_key
4.2.2.OAuthClientConfig
package com.xxxx.oauth2client01demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client.OAuth2ClientContext;
import org.springframework.security.oauth2.client.OAuth2RestTemplate;
import org.springframework.security.oauth2.client.resource.OAuth2ProtectedResourceDetails;
/**
*
*
* @author qqq
* OAuth2Client客户端配置
*/
@Configuration
public class OAuthClientConfig{
/**
* 定义OAuth2RestTemplate
* 可从配置文件application.yml读取oauth配置注入OAuth2ProtectedResourceDetails
* @param oAuth2ClientContext
* @param details
* @return
*/
@Bean
public OAuth2RestTemplate oauth2RestTemplate(OAuth2ClientContext oAuth2ClientContext,
OAuth2ProtectedResourceDetails details) {
return new OAuth2RestTemplate(details, oAuth2ClientContext);
}
}
oauth2RestTemplate的作用是发起OAuth2认证的Rest请求,OAuth2RestTemplate类中封装了获取token的方法。
简单解读一下此方法所传入的两个参数OAuth2ClientContext oAuth2ClientContext,OAuth2ProtectedResourceDetails details。
前者是client的请求上下文信息,其中包含有access token和stateKey的信息。access token用于资源请求的凭证,stateKey则用于防止跨站请求伪造(CSRF)攻击,这一点可以参考Oauth2.0 里面的 state 参数是干什么的? - 尹三黎 - 博客园 (cnblogs)。
后者为从配置文件中读取到的受保护资源的详细信息,包括认证服务器的url、客户端信息等。
4.2.3.跨域配置
客户端也需要配置跨域,配置同认证服务器。
4.2.4.UserController
package com.xxxx.oauth2client01demo.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.OAuth2RestTemplate;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
*
* @author qqq
* @since 1.0.0
*/
@RestController
@RequestMapping("/user")
@CrossOrigin
public class UserController {
@Autowired
OAuth2RestTemplate restTemplate;
/**
* 获取当前用户信息
* @param authentication
* @return
*
*/
@RequestMapping("getCurrentUser")
public Object getCurrentUser(Authentication authentication){
return authentication;
}
/**
* 获取菜单,受SpringSecurity保护
* http://localhost:8088/user/getMenu
* @return
*/
@GetMapping("/getMenu")
public String getMenu(HttpServletRequest request){
ResponseEntity<String> responseEntity = restTemplate
.getForEntity("http://localhost:8080/user/getMenu", String.class);
return responseEntity.getBody();
}
/**
* 查询用户数据接口,受SpringSecurity保护
* http://localhost:8088/user/getUserInfo
* @return
*/
@GetMapping("/getUserInfo")
@ResponseBody
public String getUserInfo(HttpServletRequest request, HttpServletResponse response){
ResponseEntity<String> responseEntity = restTemplate
.getForEntity("http://localhost:8080/user/getUserInfo", String.class);
return responseEntity.getBody();
}
/**
* 登录接口,受SpringSecurity保护
* http://localhost:8088/user/login
* @return "登录中,请稍后。。。"
*/
@GetMapping("/login")
@ResponseBody
public String login(){
ResponseEntity<String> responseEntity = restTemplate
.getForEntity("http://localhost:8080/user/login", String.class);
return responseEntity.getBody();
}
/**
* 登出接口,不是单点登出,只是把自己的cookie删除掉了
* http://localhost:8088/user/logout
* @return
*/
@GetMapping("/logout")
@ResponseBody
public String logout(HttpServletRequest request,HttpServletResponse response, Authentication authentication) throws IOException {
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0) {
for (Cookie cookie : cookies) {
cookie.setMaxAge(0);
cookie.setPath("/");
response.addCookie(cookie);
}
}
}
}
restTemplate.getForEntity方法用于调用第三方接口,在此用于调用资源服务器的受保护资源,需要传入了两个参数(接口url,期望的返回值类型)。
4.2.5.@EnableOAuth2Sso
package com.xxxx.oauth2client01demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.oauth2.client.EnableOAuth2Sso;
import org.springframework.context.ConfigurableApplicationContext;
/**
* @author qqq
*/
@SpringBootApplication
//开启单点登录功能
@EnableOAuth2Sso
public class Oauth2client01DemoApplication {
public static void main(String[] args) {
ConfigurableApplicationContext run = SpringApplication.run(Oauth2client01DemoApplication.class, args);
}
}
@EnableOAuth2Sso开启客户端的单点登录功能,简单解读下此注解的作用。
/**
* Enable OAuth2 Single Sign On (SSO). If there is an existing
* {@link WebSecurityConfigurerAdapter} provided by the user and annotated with
* {@code @EnableOAuth2Sso}, it is enhanced by adding an authentication filter and an
* authentication entry point. If the user only has {@code @EnableOAuth2Sso} but not on a
* WebSecurityConfigurerAdapter then one is added with all paths secured.
*
* @author Dave Syer
* @since 1.3.0
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@EnableOAuth2Client
@EnableConfigurationProperties(OAuth2SsoProperties.class)
@Import({ OAuth2SsoDefaultConfiguration.class, OAuth2SsoCustomConfiguration.class,
ResourceServerTokenServicesConfiguration.class })
public @interface EnableOAuth2Sso {
}
@Target(ElementType.TYPE):表示此注解的作用目标为接口、类、枚举;
@Retention(RetentionPolicy.RUNTIME):表示此注解不仅被保存到class文件中,jvm加载class文件之后,仍然存在;
@EnableOAuth2Client:启动oauth2客户端配置;
@EnableConfigurationProperties(OAuth2SsoProperties.class):启用一些默认的配置信息,如登录页面的路径,即触发重定向到OAuth2授权服务器的路径;
@Import({OAuth2SsoDefaultConfiguration.class,OAuth2SsoCustomConfiguration.class,ResourceServerTokenServicesConfiguration.class }):引入oauth2SSO客户端最重要的三个配置文件,分别为默认配置、自定义配置、资源服务器配置,配置源码部分在此不再展开。
4.2.6.总结
配置oauth2RestTemplate用于向认证服务器发起请求,配置跨域过滤器,在启动类上使用@EnableOAuth2Sso注解。
4.3.前端搭建
至此,回看我们的总体目标
- 前后端分离架构🐕
- 实现oauth2协议的授权码模式🐕
- 实现基于oauth2的SSO单点登录🐕
- 实现基于RBAC权限模型的动态路由
第4点的前半句也实现了,只有动态路由没有实现了,经过一段时间的摸索,参考了手摸手教你实现vue的动态路由(router.v4.x版本) - 掘金 (juejin)的实现方案。
这里先回顾下根据RBAC权限模型创建的四种角色以及它们的菜单权限:
游客:房源查询
用户:房源查询、房屋管理、用户中心
管理员:房源查询、房屋管理、用户中心、用户管理、客诉中心
超级管理员:房源查询、房屋管理、用户中心、用户管理、客诉中心、角色管理、菜单管理
我们所期望的效果,当用户没有登录,以游客身份访问的时候,这时只显示房屋查询的页面;在用户登录时,根据他的角色动态的显示菜单。
注:前端页面代码较长,只贴关键部分代码及图示。
4.3.1.资源准备
各依赖版本
"dependencies": {
"axios": "^0.27.2",
"core-js": "^3.6.5",
"jquery": "^3.6.0",
"js-cookie": "^3.0.1",
"vue": "^3.0.0",
"vue-router": "^4.0.0-0",
"vuex": "^4.0.0-0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-router": "~4.5.0",
"@vue/cli-plugin-vuex": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.0.0",
"@vue/eslint-config-prettier": "^6.0.0",
"babel-eslint": "^10.1.0",
"element-plus": "^1.2.0-beta.6",
"eslint": "^6.7.2",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-vue": "^7.0.0",
"prettier": "^2.2.1",
"sass": "^1.26.5",
"sass-loader": "^8.0.2",
"vuex-persistedstate": "^4.1.0"
}
4.3.2.项目文件结构
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GezLbPdA-1661595522039)(C:\Users\qqq\AppData\Roaming\Typora\typora-user-images\image-20220801142507926.png)]
先看下各个文件的作用。
router/index.js:配置静态路由;
store/index.js:管理全局参数(用户信息和动态路由),定义修改这些全局参数的方法;
styles/index.css:全局样式文件;
utils/request.js:封装的axios请求文件;
main.js:项目入口文件;
permission.js:全局路由守卫和登录判断;
utils.js:多级路由加载(作者为了简化,数据库中只配置了一级路由);
vue.config.js:host、port、proxy代理配置。
4.3.3.静态路由配置 router/index.js
import { createRouter, createWebHashHistory } from "vue-router"
const Home = () => import("../views/Home")
const nullPage = () => import("../views/404")
const houseSearch = () => import("../views/houseSearch")
//模拟visitor路由
export const visitorRouter = [
{
path: "/houseSearch",
name: "房源查询",
component: houseSearch
}
]
const routes = [
{
path: "/home",
name: "home",
component: Home
},
{
path: "/404",
name: "404",
component: nullPage
}
]
export const router = createRouter({
history: createWebHashHistory(),
routes
})
4.3.4.全局参数配置 store/index.js
import { createStore } from "vuex"
//页面刷新不丢失插件
import createPersistedState from "vuex-persistedstate"
import { router } from "@/router"
import { filterAsyncRouter } from "@/utils"
import request from "@/utils/request"
import { visitorRouter } from "../router"
let routerList = []
export default createStore({
// 全局参数
state: {
userInfo: {
avater: "",
email: "",
nickname: "",
phoneNumber: "",
sex: "",
updateTime: "",
userName: "",
routerList: []
}
},
// set方法
mutations: {
SET_USER_INFO(state, val) {
state.userInfo = val
},
// eslint-disable-next-line no-unused-vars
ADD_ROUTE(state) {
console.log("路由添加前", router.getRoutes())
//路由未添加之前是2个,我们判断是否添加过,没添加过就添加
console.log(39, router.getRoutes().length)
console.log("添加路由前vuex里的state", state.userInfo.routerList)
if (router.getRoutes().length === 2) {
let addRouterList
if (state.userInfo.routerList.length === 1) {
addRouterList = state.userInfo.routerList
} else {
addRouterList = filterAsyncRouter(
JSON.parse(JSON.stringify(state.userInfo.routerList)) //这里深拷贝下,不然会出问题
)
}
console.log("要添加的路由列表1", addRouterList)
console.log("要添加的路由列表2", state.userInfo.routerList)
addRouterList.forEach((i) => {
console.log("添加路由", i)
router.addRoute("home", i)
})
}
console.log("路由添加后", router.getRoutes())
}
},
actions: {
//登陆
login({ commit }, userInfo) {
const {
avater,
email,
nickname,
phoneNumber,
sex,
updateTime,
userName
} = userInfo
return new Promise((resolve) => {
console.log(
"store.index.js获取用户router之前的routerList:",
routerList
)
//模拟登陆,获取用户信息, 权限路由列表
//假设返回的有token, 路由列表(根据不同用户返回不同)
/**********************模拟后端传过来的路由列表----START***********************/
// let routerList = []
// if (username === "admin") {
// routerList = authRouter
// } else if (username === "commonUser") {
// routerList = [authRouter[0]]
// }
/**********************模拟后端传过来的路由列表----END***********************/
// let token = "testToken"
request({
url: "api/user/getMenu",
method: "get"
}).then((resp) => {
console.log("请求到的用户路由:", resp.data.routes)
routerList = resp.data.routes
//把用户信息存入vuex
commit("SET_USER_INFO", {
avater,
email,
nickname,
phoneNumber,
sex,
updateTime,
userName,
routerList
})
console.log("login over")
console.log("用户信息保存完毕后的routerList:", routerList)
//添加路由
commit("ADD_ROUTE")
resolve()
})
})
},
//游客登陆
loginForVisitor({ commit }) {
return new Promise((resolve) => {
console.log(
"store.index.js获取用户router之前的routerList:",
routerList
)
routerList = visitorRouter
//把用户信息存入vuex
commit("SET_USER_INFO", {
routerList
})
console.log("login over")
console.log("用户信息保存完毕后的routerList:", routerList)
//添加路由
commit("ADD_ROUTE")
// console.log(
// "保存vuex后的state:",
// this.state.userInfo.routerList.length
// )
resolve()
})
},
//添加路由
addRoute({ commit }) {
commit("ADD_ROUTE", this.state)
},
//注销
logout({ commit, state }) {
return new Promise((resolve) => {
//拷贝一下
const delRouterList = JSON.parse(
JSON.stringify(state.userInfo.routerList)
)
console.log("待删除的路由表", delRouterList)
//删除添加的路由,如果路由是多层的 递归下。。
delRouterList.forEach((route) => {
console.log("删除route:", route)
router.removeRoute(route.name)
})
//删除路由
commit("SET_USER_INFO", {
avater: "",
email: "",
nickname: "",
phoneNumber: "",
sex: "",
updateTime: "",
userName: "",
routerList: []
})
console.log("删除用户信息后:", state.userInfo)
resolve("注销 success, 清空路由,用户信息")
})
}
},
modules: {},
plugins: [createPersistedState()]
})
4.3.5.请求封装 utils/request.js
import axios from "axios"
const request = axios.create({
baseURL:
process.env.NODE_ENV === "production" ? process.env.VUE_APP_BASE_API : "/", // api 的 base_url
timeout: 12000, // 请求超时时间
withCredentials: true
})
// response 拦截器
// 可以在接口响应后统一处理结果
request.interceptors.response.use(
(response) => {
let res = response.data
// 如果是返回的文件
if (response.config.responseType === "blob") {
return res
}
// 兼容服务端返回的字符串数据
if (typeof res === "string") {
// 为了接收认证服务器的信息所做的处理
if (res === "Logging in, please wait...") {
return res
}
if (res.indexOf("Please sign in") !== -1) {
return res
}
res = res ? JSON.parse(res) : res
}
console.log("request response:", res)
return res
},
(error) => {
console.log("err" + error) // for debug
return Promise.reject(error)
}
)
export default request
4.3.6.路由守卫 permission.js
/**
* 权限配置文件
*/
import { router } from "./router"
import store from "./store"
import { ElMessage } from "element-plus"
router.beforeEach(async (to, from, next) => {
//获取用户信息
let { userInfo } = store.state
const { userName } = userInfo
console.log("用户角色", userName ? userName : "未登陆")
//有用户信息
if (userName) {
//触发添加路由方法,里面会判断是否需要添加
await store.dispatch("addRoute")
let { routerList } = userInfo
//根据to.name来判断是否为动态路由, 是否有人知道还有更好的判断方法?
if (!to.name) {
//当前路由是动态的,确定是有的, 有就跳自己,没有就跳404,, tip: 刷新后动态路由的to.name为空
if (routerList.findIndex((i) => i.path === to.path) !== -1) {
next({ ...to, replace: true })
} else {
next("/404")
}
} else {
console.log(28, router.getRoutes())
console.log(to.name)
if (to.path === "/home"){
next("/houseSearch")
} else {
next()
}
}
}
//无用户信息
else {
// 这个地方还有个bug,在游客状态下,主页不能刷新
// 这个bug的导致原因是
// 不管是游客还是用户,它的路由都是动态生成的
// 获取动态路由的逻辑在home页面
// 当home页面加载完成后(即获取到动态路由后)会立即push到/houseSearch
// houseSearch页面才是业务意义上的主页
// 在houseSearch页面上刷新不会触发获取动态路由的逻辑
// 路由就丢失了,会显示空白页
// 这个bug暂时没有修
// 2022.08.22 by qqq
if (to.path === "/houseSearch" || to.path === "/home") {
next()
} else {
ElMessage.error("请先登陆!")
next("/home")
}
}
})
router.afterEach(() => {})
4.3.7.多级路由加载 utils.js
export const loadView = (view) => {
// 路由懒加载
return () => Promise.resolve(require(`@/views/${view}`).default) //router4版本
}
//为权限路由异步添加组件
export const filterAsyncRouter = (routeList) => {
return routeList.filter((route) => {
// console.log(9, route)
if (route.component) {
// 如果不是布局组件就只能是页面的引用了
// 利用懒加载函数将实际页面赋值给它
route.component = loadView(route.component)
// console.log(15, routeponent)
// 判断是否存在子路由,并递归调用自己
if (route.children && route.children.length) {
route.children = filterAsyncRouter(route.children)
}
return true
}
})
}
4.3.8.vue.config.js
const webpack = require("webpack")
module.exports = {
devServer: {
open: true,
host: "localhost",
port: 9092,
//这里的ip和端口是前端项目的;下面为需要跨域访问后端项目
proxy: {
"/api": {
target: "http://localhost:8088/", //这里填入你要请求的接口的前缀
ws: true, //代理websocked
changeOrigin: true, //虚拟的站点需要更改origin
secure: false, //是否https接口
pathRewrite: {
"^/api": "" //重写路径
}
}
}
}
}
4.3.9.main.js
import { createApp } from "vue"
import App from "./App.vue"
import { router } from "./router"
import store from "./store"
import $ from "jquery"
//element
import ElementPlus from "element-plus"
import "element-plus/dist/index.css"
//路由钩子权限
import "@/permission.js"
import "@/styles/index.css"
createApp(App).use(store).use(router).use(ElementPlus).use($).mount("#app")
4.3.10.搭个页面框架
Home.vue
<template>
<div class="home">
<el-container>
<el-header style="background-color: #67c23a; height: 50px">
<span v-if="store.state.userInfo.userName">
{{ store.state.userInfo.userName }}
</span>
<span v-else>正在以游客身份访问</span>
<el-button v-if="store.state.userInfo.userName" @click="logout">
注销
</el-button>
<el-button v-else v-on:click="login()">登录</el-button>
</el-header>
<el-container>
<el-aside
width="200px"
style="background-color: #79bbff; height: calc(100vh - 50px)"
>
<div class="p-side-bar">
<el-menu :default-active="$route.path" :router="true">
<el-menu-item
:index="menu.path"
v-for="menu in menuList"
:key="menu.name"
>
<span>{{ menu.name }}</span>
</el-menu-item>
</el-menu>
</div>
</el-aside>
<el-container>
<el-main>
<router-view></router-view>
</el-main>
<el-footer style="background-color: #337ecc; height: 50px">
Footer
</el-footer>
</el-container>
</el-container>
</el-container>
</div>
</template>
4.3.11.主页获取动态路由逻辑
Home.vue
const initUserInfo = () => {
console.log("开始用户信息初始化")
request({
url: "api/user/login",
method: "get"
}).then((res) => {
if (res === "Logging in, please wait...") {
// 拿到信息表示登录成功,去请求用户数据
// api/user/login接口是前端用来确定是否有用户登录的接口,所有用户都可以访问
// 功能是返回一段字符串 Logging in, please wait...
// 对SSO系统来说,所有的被保护接口在被访问时,都会去检查是否有登录态
// 没有就跳转到登录页面,有就检查用户是否有访问这个接口的权限
// 有的话就返回内容
// 因此拿到这段信息就可以去请求用户的动态菜单信息
request({
url: "api/user/getUserInfo",
method: "get"
}).then((res) => {
// 将用户数据存入store中
store.dispatch("login", res.data.userInfo[0]).then(() => {
// 存入store后初始化菜单
initMenu()
})
// 修改loginStatus
localStorage.setItem("loginStatus", "authorized")
ElMessage.success("登录成功")
})
} else {
// 请求不到,以游客身份访问
store.dispatch("loginForVisitor").then(() => {
// 存入store后初始化菜单
initMenu()
})
localStorage.setItem("loginStatus", "visitor")
ElMessage.info("以游客身份访问中...")
}
})
console.log("用户信息初始化完成")
}
4.3.12.初始化菜单
Home.vue
const initMenu = () => {
var routerList = router.getRoutes()
console.log("HOME页面里查到的路由:", routerList)
menuList.value = routerList.filter((route) => {
if (route.name !== "home" && route.name !== "404") {
console.log("过滤后的路由:", route)
return route
}
})
console.log(135, routerList.length)
console.log(window.location.href)
// 菜单初始化完成后跳转到/houseSearch页面
if (window.location.href.indexOf("home") !== -1) {
router.push({ path: "/houseSearch" })
}
}
4.3.13.准备好其他页面
页面内容如下:
<template>
<div class="p-admin-page">房源查询</div>
</template>
<script>
export default {
name: '',
components: {},
data() {
return {}
},
created() {},
mounted() {},
methods: {}
}
</script>
<style lang="scss" scoped>
.p-name {
}
</style>
其他的页面结构相同,仅
标签中的内容不同。5.效果展示
按SSO server -> SSO client -> 项目前端的顺序启动。
如果不启动SSO server启动SSO client的话会报错。
5.1.进入主页
http://localhost:9092/
gif展示:进入主页展示
可以看到网址转变过程为:http://localhost:9092/ -> http://localhost:9092/#/ -> http://localhost:9092/#/home -> http://localhost:9092/#/houseSearch
依次解读一下:
http://localhost:9092/ -> http://localhost:9092/#/ :hash路由模式;
http://localhost:9092/#/ -> http://localhost:9092/#/home :全局路由守卫起作用,拦截到未定义的路由,跳转到/home;
if (to.path === "/houseSearch" || to.path === "/home") {
next()
} else {
ElMessage.error("请先登陆!")
next("/home")
}
详见 [4.3.6.路由守卫]。
http://localhost:9092/#/home -> http://localhost:9092/#/houseSearch :home页面获取用户信息,当前用户未登录,以游客身份访问,加载/houseSearch页面路由,跳转到houseSearch页面。
// 请求不到,以游客身份访问
store.dispatch("loginForVisitor").then(() => {
// 存入store后初始化菜单
initMenu()
})
localStorage.setItem("loginStatus", "visitor")
ElMessage.info("以游客身份访问中...")
详见 [Home.vue]。
5.2.登录
根据数据库用户表中的信息,可以看出:
用户qqq 对应超级管理员,拥有所有菜单的访问权限;
用户www 对应普通用户,仅有房源查询、房屋管理、用户中心三个菜单的访问权限。
详见[4.1.1.2.数据库搭建]。
超级管理员登录
gif展示:超级管理员登录展示
普通用户登录
gif展示:普通用户登录展示
可以看到登录过程中网址转变过程为:http://localhost:9092/#/houseSearch -> http://localhost:8080/login -> http://localhost:9092/#/home -> http://localhost:9092/#/houseSearch
依次解读一下:
http://localhost:9092/#/houseSearch -> http://localhost:8080/login :点击登录按钮后,跳转到8080认证服务器进行登录;
http://localhost:8080/login -> http://localhost:9092/#/home :登录成功后,根据LoginSuccessHandler中配置的逻辑跳转到前端页面;
@Override
protected void handle(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println(authentication);
defaultRedirectStrategy.sendRedirect(request,response,"http://localhost:9092/#/home");
}
详见 [4.1.5.登录成功处理器]
http://localhost:9092/#/home -> http://localhost:9092/#/houseSearch :home页面获取用户信息,加载路由,跳转到houseSearch页面。
5.3.菜单功能
gif展示:菜单跳转展示
6.项目地址
https://github/ganningniang/houserent_Oauth2_sso.git
使用注意事项:
-
前后端交互均以ip端口的形式写在代码中,如果IP或端口有修改,需分别注意修改以下位置:
**前端url变动:**前端vue.config.js中host与port;Home.vue页面中注销后跳转的地址;认证服务器CustomLoginSuccessHandler修改认证成功后跳转的url;数据库中oauth_client_details表web_server_redirect_uri字段。
**认证服务器url变动:**客户端application.yml认证服务器地址;客户端UserController中RestTemplate请求的地址。
**客户端url变动:**前端Home.vue点击登录或注销触发的跳转url;vue.config.js中设置的代理地址;客户端application.yml中的port设置。
-
如不修改ip及端口,此demo唯一依赖为mysql数据库,参照4.1.1.2中的sql语句建立即可。
-
启动顺序为认证服务器 -> 客户端 -> 前端。
版权声明:本文标题:基于Oauth2授权码模式的SSO单点登录+基于RBAC权限模型的动态路由的前后端分离的权限认证系统 内容由热心网友自发贡献,该文观点仅代表作者本人, 转载请联系作者并注明出处:https://www.elefans.com/dianzi/1729176187a1188745.html, 本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容,一经查实,本站将立刻删除。
发表评论