紧接着上篇博客,上面我们已经完成了基本的登录功能,下面开始整合Swagger2、测试登录功能接着完善我们的项目。这一篇主要是实现菜单列表。
目录
- 1.配置Swagger2
- 1.1.测试Swagger2
- 1.2重新测试项目:
- 2.菜单列表
- 2.1.权限管理RBAC基本概念
- 2.2.RBAC表结构设计
- 2.3.定义子菜单和角色列表
- 2.4.实现查询菜单功能
- 2.5.SQL语句编写
由于是前后端分离项目,所以接口文档是必须的。为了规范,这里我们使用Swagger2,前面在代码自动生成时已经导入了依赖,现在在config目录下写Swagger配置类。
SwaggerConfig.java
@Configuration
@EnableSwagger2
public class SwaggerConfig {
@Bean
public Docket createRestApi() {
// 文档类型
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo())
.select()
.apis(RequestHandlerSelectors.basePackage("com.kt.controller"))
.paths(PathSelectors.any())
.build();
}
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("VRS接口文档")
.description("VRS接口文档")
.contact(new Contact("KamTeng", "http:localhost:8081/doc.html", "*********@qq.com"))
.version("1.0")
.build();
}
}
编写一个测试类
TestController.java
@RestController
public class TestController {
@GetMapping("/test")
public String test() {
return "it's a test";
}
}
端口:http://localhost:8081/doc.html
出现这样的界面说明一切都没有问题。
但是点击test-controller
,调试发送,结果:
这是因为test接口需要登录之后才可以访问,前面SecurityConfig中只放行了login、index、logout以及静态资源,所以剩下的所有资源都要经过登录之后才可以访问。
Swagger提供了全局登录功能,登录之后把JWT令牌放到全局的Authorization里面,Swagger文档相当于访问Test接口,会携带JWT令牌,携带了令牌之后经过刚刚写的登录授权拦截器(JwtAuthorizationTokenFilter),有令牌会自动登录,就能够访问接口了。
修改SwaggerConfig.java
@Configuration
@EnableSwagger2
public class SwaggerConfig {
@Bean
public Docket createRestApi() {
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo())
.select()
.apis(RequestHandlerSelectors.basePackage("com.kt.controller"))
.paths(PathSelectors.any())
.build()
.securityContexts(securityContexts())
.securitySchemes(securitySchemes());
}
private ApiInfo apiInfo() {
......
}
private List<ApiKey> securitySchemes() {
// 设置请求头信息
List<ApiKey> keys = new ArrayList<>();
ApiKey apiKey = new ApiKey("Authorization", "Authorization", "Header");
keys.add(apiKey);
return keys;
}
private List<SecurityContext> securityContexts() {
// 设置需要登录认证的路径
List<SecurityContext> contexts = new ArrayList<>();
contexts.add(getContextByPath("/test/.*"));
return contexts;
}
private SecurityContext getContextByPath(String path) {
return SecurityContext.builder()
.securityReferences(defaultAuth())
.forPaths(PathSelectors.regex(path))
.build();
}
private List<SecurityReference> defaultAuth() {
List<SecurityReference> references = new ArrayList<>();
// global全局accessEverything允许所以
AuthorizationScope scope = new AuthorizationScope("global", "accessEverything");
AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];
authorizationScopes[0] = scope;
references.add(new SecurityReference("Authorization", authorizationScopes));
return references;
}
}
重启项目后先获取验证码,若不确定验证码可以去后端控制台查看。
username:admin
password:123456
参数值中输入:
Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImNyZWF0ZWQiOjE2MTQ5NTM5MjU2MjQsImV4cCI6MTYxNTU1ODcyNX0.-uFTquk9QJgvpbNcT_flEUh2l8UqOS7kovIF5EDpUvr4lkJ5M1Haqu_KZJAjZtDNaiWr3cqqbxEq2RVOwQrlIg
注意:Bearer后有一个空格
获取当前登录用户的信息(直接点击发送即可)
如果有结果则一切正常。
根据当前登录用户id和对应的角色查询菜单列表,每个能够成功登录的用户都会带有一个角色或者几个角色(或者没有),我们的t_menu_role中就有对应的mid(菜单id)、rid(权限id),t_admin_role表中也有rid,也就是说根据t_menu表中的url找到对应的菜单id,然后根据中间表(t_role)判断需要哪些角色拥有这些url权限,最后进行比较,就知道这个用户是不是真的具有访问菜单的权限。
RBAC是基于角色的访问控制(Role-Based Access Control
),再RBAC中,权限与角色相关联,用户通过扮演适当的角色从而得到相应权限。这样的管理都是层级相互依赖的,权限赋予角色,角色又赋予用户,这样的权限设计清除,管理起来也很方便。
RBAC授权实际上是Who
、What
、How
三元组之间的关系,也就是Who
对What
进行How
的操作,简单说明就是谁对什么资源做了怎样的操作。
2.2.1实体对应关系
用户-角色-资源实体间对应关系图分析:
这里用户与角色实体对应关系为多对多,角色与资源对应关系同样为多对多关系,所以在实体设计上用户与角色间增加用户角色实体,将多对多的对应关系拆分为一对多,同理,角色与资源多对多对应关系拆分出中间实体对象权限实体。
2.2.2.表结构设计
从上面实体对应关系分析,权限表设计分为以下基本的五张表结构:用户表(t_admin),角色表(t_role),用户角色表(t_admin_role),菜单表(t_menu),菜单权限表(t_menu_role),表结构如下:
t_admin_role表:
修改我们的Menu实体类和Admin实体类,因为在返回菜单的时候可能会存在子菜单,所以只需要在Menu.java中定义Children属性即可,我们还需要为Admin、Menu都添加一个role属性,并且修改Admin中的getAuthorities()方法,后面需要对Menu和Admin中的role进行比较。
Menu.java
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("t_menu")
@ApiModel(value="Menu对象", description="")
public class Menu implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty(value = "id")
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@ApiModelProperty(value = "url")
private String url;
@ApiModelProperty(value = "path")
private String path;
......
@ApiModelProperty(value = "子菜单")
@TableField(exist = false)
private List<Menu> children;
@ApiModelProperty(value = "角色列表")
@TableField(exist = false)
private List<Role> roles;
}
Admin.java
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("t_admin")
@ApiModel(value="Admin对象", description="")
public class Admin implements Serializable, UserDetails {
private static final long serialVersionUID = 1L;
@ApiModelProperty(value = "id")
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
......
@ApiModelProperty(value = "备注")
private String remark;
@ApiModelProperty(value = "角色/权限")
@TableField(exist = false)
private List<Role> roles;
@Override
@JsonDeserialize(using = CustomAuthorityDeserializer.class)
public Collection<? extends GrantedAuthority> getAuthorities() {
List<SimpleGrantedAuthority> authorityList = roles
.stream()
.map(role -> new SimpleGrantedAuthority(role.getName()))
.collect(Collectors.toList());
return authorityList;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return enabled;
}
}
用户正常登录之后,用户的相关信息会在后端通过从spring security全局对象获取我们想要的用户信息,不需要前端传。所以我们新建一个类,这个类就是专门用了从spring security中获取用户信息,方便后面设计到用户信息的接口和方法。
菜单被频繁的读取和渲染时,如果菜单数量过大,每一次的这种操作势必会重复查询数据库,为了提高菜单加载速度,我们可以将第一次通过sql语句从数据库中查询出来的菜单放到Redis里,后面直接从Redis中获取,而不会通过sql语句反复查询数据库。当然这次项目涉及到的菜单不是很多,而且Redis每次需要打开服务,所以我会把功能实现,但后面我不会使用Redis。根据自身情况可用可不用。
2.4.1.Redis相关依赖和配置
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>org.apache.commonsgroupId>
<artifactId>commons-pool2artifactId>
dependency>
application.yml(端口、密码等根据自身情况,yml注重格式,注意缩进)
# redis
redis:
# 超时时间
timeout: 10000ms
# 服务器地址
host:
# 服务器端口
port: 6379
# 数据库
database: 0
# 密码
password: 654321
lettuce:
pool:
# 最大连接数 默认8
max-active: 1024
# 最大连接阻塞等待时间 默认-1
max-wait: 10000ms
# 最大空闲连接
max-idle: 200
# 最小空闲连接
min-idle: 5
RedisConfig.java
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
// String类型 key序列器
redisTemplate.setKeySerializer(new StringRedisSerializer());
// String类型 value序列器
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
// Hash类型 key序列器
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
// Hash value序列器
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setConnectionFactory(factory);
return redisTemplate;
}
}
2.4.2.从spring security中获取用户根据类
AdminUtils.java
public class AdminUtils {
public static Admin getCurrentAdmin() {
// 获取全局上下文,获取用户。
return (Admin) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}
}
2.4.3.通过用户ID查询菜单列表服务类、实现类和Mapper
IMenuService.java
public interface IMenuService extends IService<Menu> {
/**
* 通过用户ID查询菜单列表
* @return
*/
List<Menu> getMenusByAdminId();
/**
* 根据角色获得菜单列表
* @return
*/
List<Menu> getMenusWithRole();
}
MenuServiceImpl.java
@Service
public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements IMenuService {
@Override
public List<Menu> getMenusByAdminId() {
Integer adminId = AdminUtils.getCurrentAdmin().getId();
// 如果Redis未开启服务,会报RedisConnectionFailureException,那么我们就直接从数据库中获取
try {
ValueOperations<String, Object> opsForValue = redisTemplate.opsForValue();
// 从redis获取菜单数据
List<Menu> menus = (List<Menu>) opsForValue.get("menu_" + adminId);
// 如果为空,就去数据库中获取
if (CollectionUtils.isEmpty(menus)) {
menus = menuMapper.getMenusByAdminId(adminId);
// 将数据设置到redis中
opsForValue.set("menu_" + adminId, menus);
}
return menus;
} catch (RedisConnectionFailureException e) {
List<Menu> menus = menuMapper.getMenusByAdminId(adminId);
return menus;
}
}
@Override
public List<Menu> getMenusWithRole() {
return menuMapper.getMenusWithRole();
}
}
MenuMapper.java
public interface MenuMapper extends BaseMapper<Menu> {
/**
*通过用户ID查询菜单列表
* @param adminId
* @return
*/
List<Menu> getMenusByAdminId(Integer adminId);
/**
* 根据角色获得菜单列表
* @return
*/
List<Menu> getMenusWithRole();
}
因为需要用Admin中的Role与Menu中的Role比较,那么还需要查询出我们登录的Admin对应的Role(角色)。
2.4.4.通过用户ID查询Role服务类、实现类和Mapper
在IAdminService.java添加对应接口
public interface IAdminService extends IService<Admin> {
......
/**
* 根据用户id查询角色列表
* @param adminId
* @return
*/
List<Role> getRoles(Integer adminId);
}
AdminServiceImpl.java
@Service
public class AdminServiceImpl extends ServiceImpl<AdminMapper, Admin> implements IAdminService {
@Resource
private UserDetailsService userDetailsService;
@Resource
private PasswordEncoder passwordEncoder;
......
@Override
public List<Role> getRoles(Integer adminId) {
return roleMapper.getRoles(adminId);
}
}
RoleMapper.java
public interface RoleMapper extends BaseMapper<Role> {
/**
* 根据用户id查询角色列表
* @param adminId
* @return
*/
List<Role> getRoles(Integer adminId);
}
2.4.5.过滤器
在component目录下新建一个权限控制类分别是专门根据请求的url分析请求所需的角色的过滤器(CustomFilter)和判断用户角色的过滤器(CustomUrlDecisionManger)。
CustomFilter.java
@Component
public class CustomFilter implements FilterInvocationSecurityMetadataSource {
@Resource
private IMenuService menuService;
AntPathMatcher antPathMatcher = new AntPathMatcher();
@Override
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
// 获取请求的url
String requestUrl = ((FilterInvocation) object).getRequestUrl();
List<Menu> menus = menuService.getMenusWithRole();
for (Menu menu : menus
) {
// 判断请求url与菜单角色是否匹配
if (antPathMatcher.match(menu.getUrl(), requestUrl)) {
String[] str = menu.getRoles().stream().map(Role::getName).toArray(String[]::new);
return SecurityConfig.createList(str);
}
}
// 匹配不成功,默认登录即可访问
return SecurityConfig.createList("ROLE_LOGIN");
}
@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
return null;
}
@Override
public boolean supports(Class<?> aClass) {
return false;
}
}
CustomUrlDecisionManger.java
@Component
public class CustomUrlDecisionManger implements AccessDecisionManager {
@Override
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> collection)
throws AccessDeniedException, InsufficientAuthenticationException {
for (ConfigAttribute configAttribute : collection) {
// 当前url所需角色
String needRole = configAttribute.getAttribute();
// 判断角色是否登录即可访问的角色,此角色在CustomFilter中设置
if ("ROLE_LOGIN".equals(needRole)) {
if (authentication instanceof AnonymousAuthenticationToken) {
throw new AccessDeniedException("尚未登录,请先登录!");
} else {
return;
}
}
// 判断用户角色是否为url所需角色
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
for (GrantedAuthority authority : authorities) {
if (authority.getAuthority().equals(needRole)) {
return;
}
}
}
throw new AccessDeniedException("权限不足,请联系管理员。");
}
@Override
public boolean supports(ConfigAttribute configAttribute) {
return false;
}
@Override
public boolean supports(Class<?> aClass) {
return false;
}
}
写完之后配置Security
修改SecurityConfig.java
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private IAdminService adminService;
@Resource
private RestAuthorizationEntryPoint restAuthorizationEntryPoint;
......
@Override
protected void configure(HttpSecurity http) throws Exception {
// 使用JWT,不需要csrf
http.csrf().disable()
// 使用JWT,不需要session
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 所有请求都要认证
.anyRequest().authenticated()
// 动态权限配置
.withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
@Override
public <O extends FilterSecurityInterceptor> O postProcess(O o) {
o.setAccessDecisionManager(customUrlDecisionManger);
o.setSecurityMetadataSource(customFilter);
return o;
}
})
.and()
// 禁用缓存
.headers()
.cacheControl();
// 添加JWT 登录授权过滤器
http.addFilterBefore(jwtAuthorizationTokenFilter(), UsernamePasswordAuthenticationFilter.class);
// 添加自定义未授权和未登录结果返回
http.exceptionHandling()
.accessDeniedHandler(restfulAccessDeniedHandler)
.authenticationEntryPoint(restAuthorizationEntryPoint);
}
@Override
@Bean
public UserDetailsService userDetailsService() {
return username -> {
Admin admin = adminService.getAdminByUserName(username);
if (admin != null) {
admin.setRoles(adminService.getRoles(admin.getId()));
return admin;
}
throw new UsernameNotFoundException("用户名或密码不存在");
};
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public JwtAuthorizationTokenFilter jwtAuthorizationTokenFilter() {
return new JwtAuthorizationTokenFilter();
}
}
2.4.6.接口
MenuController.java
@RestController
@RequestMapping("/system/cfg")
public class MenuController {
@Resource
private IMenuService menuService;
@ApiOperation(value = "通过用户ID查询菜单列表")
@GetMapping("/menu")
public List<Menu> getMenusByAdminId() {
return menuService.getMenusByAdminId();
}
}
MenuMapper.xml
<!-- 通过用户ID查询菜单列表 -->
<select id="getMenusByAdminId" resultMap="Menus">
SELECT DISTINCT
m1.*,
m2.id AS id2,
m2.url AS url2,
m2.path AS path2,
m2.component AS component2,
m2.`name` AS name2,
m2.iconCls AS iconCls2,
m2.requireAuth AS requireAuth2,
m2.parentId AS parentId2,
m2.enabled AS enabled2
FROM
t_menu m1,
t_menu m2,
t_admin_role ar,
t_menu_role mr
WHERE
m1.id = m2.parentId
AND m2.id = mr.mid
AND mr.rid = ar.rid
AND ar.adminId = #{id}
AND m2.enabled = TRUE
ORDER BY
m2.id
</select>
<!-- 根据角色获得菜单列表 -->
<select id="getMenusWithRole" resultMap="MenusWithRole">
SELECT
m.*,
r.id AS rid,
r.`name` AS rname,
r.nameZh AS rnameZh
FROM
t_menu m,
t_menu_role mr,
t_role r
WHERE
m.id = mr.mid
AND r.id = mr.rid
ORDER BY
m.id
</select>
resultMap:Menus和MenusWithRole
<resultMap id="Menus" type="com.kt.pojo.Menu" extends="BaseResultMap">
<collection property="children" ofType="com.kt.pojo.Menu">
<id column="id2" property="id"/>
<result column="url2" property="url"/>
<result column="path2" property="path"/>
<result column="component2" property="component"/>
<result column="name2" property="name"/>
<result column="iconCls2" property="iconCls"/>
<result column="requireAuth2" property="requireAuth"/>
<result column="parentId2" property="parentId"/>
<result column="enabled2" property="enabled"/>
</collection>
</resultMap>
<resultMap id="MenusWithRole" type="com.kt.pojo.Menu" extends="BaseResultMap">
<collection property="roles" ofType="com.kt.pojo.Role">
<id column="rid" property="id"/>
<result column="rname" property="name"/>
<result column="rnameZh" property="nameZh"/>
</collection>
</resultMap>
RoleMapper.xml
<select id="getRoles" resultType="com.kt.pojo.Role">
SELECT
r.id,
r.`name`,
r.nameZh
FROM
t_role AS r
LEFT JOIN t_admin_role AS ar ON r.id = ar.rid
WHERE
ar.adminId = #{adminId}
select>
最后修改我们的登录方法
LoginController.java
@ApiOperation(value = "获取当前登录用户的信息")
@GetMapping("/admin/info")
public Admin getAdminInfo(Principal principal) {
if (principal != null) {
String username = principal.getName();
Admin admin = adminService.getAdminByUserName(username);
admin.setPassword(null);
admin.setRoles(adminService.getRoles(admin.getId()));
return admin;
}
return null;
}
到这里整合Swagger2以及根据角色权限查询菜单列表模块就完成了,由于篇幅和代码量较大,对应的测试和职位、职称模块留在下一篇博客:
处理全局异常