可莉要你帮她做一个蹦蹦炸弹管理系统!(Spring Security + Vue前后端分离学习示例)
最近旅行者除了完成每日委托之外,还花了许多时间研究了Spring Security的使用。
今天,可莉急匆匆地跑来找到了旅行者,说自己研究了许多种类的炸弹,但是随着炸弹种类、数量的增加,可莉觉得越来越难以管理自己的“宝贝”了,想请旅行者帮帮忙。
看着可莉满是“求求你”的样子,旅行者决定答应她。
旅行者一拍脑袋,决定用自己这几天“废寝忘食”研究的Spring Security,加上Vue,搭建一个“蹦蹦炸弹”的管理系统。
于此同时,可莉担心被琴团长发现自己具体的“宝贝”类型和数量,便让旅行者做的管理系统只能由她自己来操作,其他人不能查看或者修改。
可莉的好伙伴七七、迪奥娜和早柚也听说可莉最近研发出来了很多类型的炸弹,都非常好奇,可莉也决定让旅行者将她们也加入管理系统,但是可莉只让她们查询炸弹信息,不能修改。
废话不多说,明确了任务,旅行者拿出了笔记本电脑,坐在猫尾酒馆里开始整理思路,并打开了vscode、idea开始干了。
1,思路?整理!梳理啦!
在做这个管理系统之前,旅行者决定好好梳理一下思路。
在此之前,我们还是需要学习用户登录的一些基本原理,这样看这个文章才更好理解,如果不熟悉可以看看这个文章:链接
既然旅行者使用了Spring Security作为安全框架,我们就先来大致了解一下其原理。
Spring Security的登录验证流程核心就是过滤器链。当一个请求到达时按照过滤器链的顺序依次进行处理,通过了所有过滤器链的验证,才能访问我们的API接口。
与此同时,Spring Security还提供了多种过滤器,以实现多种不同认证方式:
UsernamePasswordAuthenticationFilter
使用用户名密码的登录认证方式SmsCodeAuthenticationFilter
使用短信验证码登录认证方式SocialAuthenticationFilter
使用社交媒体方式登录认证方式Oauth2AuthenticationProcessingFilter
使用Oauth2的认证方式
今天,旅行者决定使用UsernamePasswordAuthenticationFilter
的方式来实现这个管理系统的登录认证方式。
来简单看看认证流程:
这里看不懂没关系,我们不需要完全掌握里面所有类的逻辑,重要的部分在下面会讲到。
然后,这是一个前后端分离的系统,也就是说,Spring Security不再负责各个页面的跳转,只是负责各个接口的访问管理,前端vue实现页面跳转。
默认情况下,Spring Security中登录成功、失败、退出登录、403等等,都是会跳转到一个专门的页面(默认是请求转发方式)。
但是现在由于是前后端分离的系统,Spring Security只能向前端发送一个JSON格式的响应,前端接收到之后,根据请求内容,控制页面跳转。
除此之外,登录成功后,后端还需要写一个判断用户是否登录的接口并加入拦截。这样前端每次访问这个接口判断是否登录,如果登录了后端就会返回用户数据给前端显示,没有登录就会被拦截返回403,前端跳转至登录页。
思路明确了,我们只让Spring Security控制对各个接口的访问,还有用户登录、权限等等。每一个操作,后端就会返回前端一个JSON数据,前端判断是否操作成功,然后控制跳转和显示。
2,依赖?准备!设置啦!
首先新建Spring Boot工程,在其中勾选上Security
,以及MyBatis的相关依赖,并配置数据源。
也可以后续在pom.xml
中加入Spring Security
依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
复制代码
除此之外,我们还需要用到fastjson的json工具:
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.73</version>
</dependency>
复制代码
然后启动项目,访问你的地址,你会发现进入了个登录页面,因为默认情况下,Spring Security会拦截所有请求。
因此,里面有许多东西我们需要修改。
3,数据?模型!构建啦!
现在,我们就需要构建一下数据模型,以及数据库表。
在这个系统中,无疑要使用到RBAC(基于角色的权限管理)。
也就是说,我们要通过建立用户与角色的对应关系,使得每个用户可以拥有多个角色,每个角色可以拥有多个权限,用户根据拥有的角色进行操作与资源访问。
一个最基本的RBAC的类图如下:
用户可以有多个角色,一个角色可以被多个用户拥有。同时,一个角色可以有多个权限,一个权限也可以被多个角色拥有。
也就是说,用户和角色之间是多对多的关系,角色和权限之间也是多对多的关系。
至于为什么要在用户和权限之间加一个角色?这不是多此一举吗?其实这是有必要的,有利于系统未来扩展,以及灵活地权限控制。
根据此,我们就可以设计数据库了,对于多对多的数据库模型建立和MyBatis的级联如果不太熟悉,可以先看看这个文章:链接
在这个管理系统中,需要有 管理员(admin) 和 访问者(visitor) 两个角色,其中管理员可以 增加(addBomb) 和查询(queryBomb) 炸弹,而访问者只能 查询(queryBomb) 炸弹。
这里的管理员、访问者就是角色,增加、查询炸弹就是权限。
这里创建一个名为dataobject
的包,并在里面创建用户类(MyUser
)、角色类(Role
)和权限类(Permission
),如下:
package com.example.securitytest.dataobject;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.io.Serializable;
import java.util.HashSet;
import java.util.Set;
/**
* 用户
*/
@Setter
@Getter
@NoArgsConstructor
public class MyUser implements Serializable {
/**
* 主键id
*/
private int id;
/**
* 用户名
*/
private String username;
/**
* 密码
*/
private String password;
/**
* 昵称
*/
private String nickname;
/**
* 头像
*/
private String avatar;
/**
* 用户的角色
*/
private Set<Role> roles;
/**
* 获取该用户的全部权限
*
* @return 用户的权限
*/
public Set<Permission> getPermissions() {
Set<Permission> permissions = new HashSet<>();
for (Role role : roles) {
for (Permission permission : role.getPermissions()) {
permissions.add(permission);
}
}
return permissions;
}
}
复制代码
package com.example.securitytest.dataobject;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.io.Serializable;
import java.util.List;
import java.util.Set;
/**
* 角色类
*/
@Getter
@Setter
@NoArgsConstructor
public class Role implements Serializable {
/**
* 主键id
*/
private int id;
/**
* 角色名
*/
private String name;
/**
* 拥有该角色的用户
*/
private List<MyUser> myUsers;
/**
* 该角色拥有的权限
*/
private Set<Permission> permissions;
}
复制代码
package com.example.securitytest.dataobject;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.io.Serializable;
import java.util.Set;
/**
* 权限
*/
@Getter
@Setter
@NoArgsConstructor
public class Permission implements Serializable {
/**
* 主键id
*/
private int id;
/**
* 权限名
*/
private String name;
/**
* 该权限下的角色
*/
private Set<Role> roles;
}
复制代码
然后创建相应的MyBatis的Mapper类和XML,这里只实现增加和查询两个功能,在此不贴代码了,最后会给出示例仓库的地址。
与此同时,我们最好再构建一个返回结果模型,专门用于返回结果给前端。新建model
包,里面建立Result
类:
package com.example.securitytest.model;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.io.Serializable;
@Setter
@Getter
@NoArgsConstructor
public class Result<T> implements Serializable {
/**
* 消息
*/
private String message;
/**
* 是否操作成功
*/
private boolean success;
/**
* 返回的数据主体(返回的内容)
*/
private T data;
/**
* 设定结果为成功
*
* @param msg 消息
*/
public void setResultSuccess(String msg) {
this.message = msg;
this.success = true;
this.data = null;
}
/**
* 设定结果为成功
*
* @param msg 消息
* @param data 数据体
*/
public void setResultSuccess(String msg, T data) {
this.message = msg;
this.success = true;
this.data = data;
}
/**
* 设定结果为失败
*
* @param msg 消息
*/
public void setResultFailed(String msg) {
this.message = msg;
this.success = false;
this.data = null;
}
}
复制代码
可莉要管理的内容是炸弹,因此还需要创建炸弹实体类及其相应的数据库表。
好了,数据模型构建完成,DAO层也完成了,最后定义Control层编写API获取数据,这里就省略API的代码了。
接下来,我们要开始建立用户登录服务了。
这里给出sql文件:
-- 初始化用户表
drop table if exists `my_user`, `role`, `user_role`, `permission`, `role_permission`, `bomb`;
create table `my_user`
(
`id` int not null,
`username` varchar(16) not null unique,
`password` varchar(64) not null,
`nickname` varchar(16) not null,
`avatar` varchar(128) not null,
primary key (`id`)
) engine = InnoDB
default charset = utf8mb4;
create table `role`
(
`id` int not null,
`name` varchar(16) not null unique,
primary key (`id`)
) engine = InnoDB
default charset = utf8mb4;
create table `user_role`
(
`user_id` int not null,
`role_id` int not null,
primary key (`user_id`, `role_id`),
foreign key (`user_id`) references `my_user` (`id`) on delete cascade on update cascade,
foreign key (`role_id`) references `role` (`id`) on delete cascade on update cascade
) engine = InnoDB
default charset = utf8mb4;
create table `permission`
(
`id` int not null,
`name` varchar(16) not null unique,
primary key (`id`)
) engine = InnoDB
default charset = utf8mb4;
create table `role_permission`
(
`role_id` int not null,
`permission_id` int not null,
primary key (`role_id`, `permission_id`),
foreign key (`role_id`) references `role` (`id`) on delete cascade on update cascade,
foreign key (`permission_id`) references `permission` (`id`) on delete cascade on update cascade
) engine = InnoDB
default charset = utf8mb4;
create table `bomb`
(
`id` int unsigned auto_increment,
`name` varchar(32) not null,
`type` varchar(32) not null,
primary key (`id`)
) engine = InnoDB
default charset = utf8mb4;
-- 初始化测试数据
insert into `my_user`
values (0, 'klee', '$2a$10$uifIuXG6FzrI.NqhhZN0H.PziUzqn78Nwq7Dg9C2V4Fa3nXNdZj5e', '可莉', '/avatar/klee.jpg'), -- 密码:123456
(1, 'qiqi', '$2a$10$4QRxlo/YuR8ZdFJSW4uV3uT9HqUxZgad5oeQHuHy2nfS4en/Z8dQ2', '七七', '/avatar/qiqi.jpg'), -- 密码:789101112
(2, 'diona', '$2a$10$Q8QQb4s9Iy12qCDmiw0Qwe8/TvCOolKgaylPAus5kE5E5k/cp.2km', '迪奥娜', '/avatar/diona.jpg'), -- 密码:13141516
(3, 'sayu', '$2a$10$uifIuXG6FzrI.NqhhZN0H.PziUzqn78Nwq7Dg9C2V4Fa3nXNdZj5e', '早柚', '/avatar/sayu.jpg');
-- 密码:123456
-- Spring Security中角色必须以"ROLE_"开头
insert into `role`
values (0, 'ROLE_admin'),
(1, 'ROLE_visitor');
insert into `user_role`
values (0, 0),
(1, 1),
(2, 1),
(3, 1);
insert into `permission`
values (0, 'queryBomb'),
(1, 'addBomb');
insert into `role_permission`
values (0, 0),
(0, 1),
(1, 0);
insert into `bomb` (name, type)
values ('小炸弹', '诡雷'),
('兔兔伯爵', '嘲讽'),
('蹦蹦炸弹', '集束炸弹');
复制代码
4,服务?逻辑!重写啦!
Spring Security中有着自己的用户认证、登录成功/失败、退出登录等等逻辑,这些我们都需要重写,自定义为自己的,才能满足实际业务需要。
(1) 自定义用户名密码登录拦截器
由于是前后端分离系统,我们一般会发送json格式的登录数据给后端,但是默认只支持表单格式的,这个时候我们就需要重写UsernamePasswordAuthenticationFilter
,重新实现其中attemptAuthentication
方法,这个方法主要用于从前端请求获取用户名和密码,并提交给后续认证。
创建一个包filter
,在里面创建MyAuthFilter
类,实现自定义的认证过滤器,先给出我的代码:
package com.example.securitytest.filter;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
/**
* 自定义的认证过滤器,实现前后端分离的json格式登录请求解析
*/
public class MyAuthFilter extends UsernamePasswordAuthenticationFilter {
/**
* 请求体中的用户名字段名
*/
private String usernameParameter = "username";
/**
* 请求体中的密码字段名
*/
private String passwordParameter = "password";
/**
* 创建自定义认证过滤器
*/
public MyAuthFilter() {
}
/**
* 创建自定义认证过滤器,并自定义用户名、密码字段名
*
* @param usernameParameter 自定义用户名字段名
* @param passwordParameter 自定义密码字段名
*/
public MyAuthFilter(String usernameParameter, String passwordParameter) {
this.usernameParameter = usernameParameter;
this.passwordParameter = passwordParameter;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
// 判断是否为JSON类型数据
if (request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE)) {
// 获取请求体
Map<String, String> requestBody = null;
try {
// 解析json为Map对象
requestBody = new ObjectMapper().readValue(request.getInputStream(), Map.class);
} catch (Exception e) {
e.printStackTrace();
}
// 获取请求体中的用户名/密码字段值,并执行认证
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(requestBody.get(usernameParameter), requestBody.get(passwordParameter));
// 返回认证结果
return this.getAuthenticationManager().authenticate(auth);
} else {
// 否则,使用默认表单登录方式
return super.attemptAuthentication(request, response);
}
}
}
复制代码
上述我还加入了usernameParameter
和passwordParameter
字段,以方便自定义前端登录认证请求中的用户名/密码字段名。
当然,现在实现了自定义的拦截器,我们是不是还需要配置进Spring Security中去呢?是的!当前现在不要急啦!这些自己实现的类,后面会来一起配置。
(2) 自定义用户数据查询逻辑
我们建立一个service
的包,并在里面建立impl
包,在其中创建一个类表示自己的用户数据查询逻辑,这个类需要实现UserDetailsService
接口里面的loadUserByUsername
方法,我这里先放上代码:
package com.example.securitytest.service.impl;
import com.example.securitytest.dao.MyUserDAO;
import com.example.securitytest.dataobject.MyUser;
import com.example.securitytest.dataobject.Permission;
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.User;
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.Component;
import java.util.HashSet;
import java.util.Set;
/**
* 自行实现Spring Security的登录逻辑
*/
@Component
public class MyUserDetailsServiceImpl implements UserDetailsService {
@Autowired
private MyUserDAO myUserDAO;
/**
* 取出用户
*
* @param username 用户名
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
MyUser myUser = myUserDAO.getByUsername(username);
if (myUser == null) {
throw new UsernameNotFoundException("找不到用户!");
}
// 组装权限
Set<GrantedAuthority> authorities = new HashSet<>();
Set<Permission> permissions = myUser.getPermissions();
for (Permission permission : permissions) {
authorities.add(new SimpleGrantedAuthority(permission.getName()));
}
return new User(myUser.getUsername(), myUser.getPassword(), authorities);
}
}
复制代码
首先需要说明的是,Spring Security中有一个User
类,表示用户对象,但是实际情况下我们还是要建立自己的用户模型(我这里的MyUser
),因为内置的User
类不能满足业务需要。
这个User
有个三个参数构造器,三个参数分别是:用户名、密码、权限。
不过这个loadUserByUsername
到底是干什么的呢?顾名思义,这个方法肯定是用于根据用户名(凭证)从数据库查询用户信息的。
因此,这里仅仅只用从数据库查询我们自己的用户信息,然后将里面的用户名、密码和权限或者角色字段提取出来放到User
类实例并返回即可。不需要在这里实现密码比对。
注意在Spring Security中,角色和权限都被视为GrantedAuthority
,但是角色名一定要以ROLE_
开头!这是Spring Security对角色和权限两者的判断依据,这个在前面sql文件中也注释说明了。
这样,这个方法就会返回相应的用户信息拿去和前端传来的进行比对。
还有一点要注意的是:这个类虽然也是属于Service
层的,但是其作用仅仅是用于查询出用户对象传给Spring Security框架,不建议将自己的其它用户逻辑也写在里面,建议自己建立自己的用户服务接口和实现类并把自己的用户服务逻辑写在自己的实现类中。
(3) 自定义登录成功后逻辑
默认登录成功会跳转至某个页面,但是现在我们需要登录成功后发送json数据。
建立包handler
,并创建类MyAuthSuccessHandler
,这个类要实现AuthenticationSuccessHandler
接口中的onAuthenticationSuccess
方法。这里先上代码:
package com.example.securitytest.handler;
import com.alibaba.fastjson.JSON;
import com.example.securitytest.model.Result;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
/**
* 自定义登录成功处理器逻辑
*/
public class MyAuthSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException {
// 设定响应状态码为200
httpServletResponse.setStatus(HttpServletResponse.SC_OK);
// 设定响应内容是utf-8编码的json类型
httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
httpServletResponse.setCharacterEncoding("utf-8");
// 组装自己的结果对象
Result result = new Result();
result.setResultSuccess("登录成功!");
// 序列化结果对象为JSON
String resultJSON = JSON.toJSONString(result);
// 写入响应体
PrintWriter writer = httpServletResponse.getWriter();
writer.write(resultJSON);
writer.flush();
writer.close();
}
}
复制代码
在这个方法中,有一个HttpServletRequest
类型参数表示接收的请求对象,HttpServletResponse
参数表示返回相应对象,Authentication
类型参数表示用户认证后的信息,里面有用户名、权限、用户登录ip等等我们可以在此获取。
与此同时,我们使用fastjson将我们的结果对象序列化为JSON字符串,并写入响应流,使得前端得到我们的JSON数据的响应。
(4) 自定义登录失败后逻辑
登录失败同样也需要返回json。
我们还是在handler
中建立MyAuthFailureHandler
,需要实现接口AuthenticationFailureHandler
的onAuthenticationFailure
方法。
package com.example.securitytest.handler;
import com.alibaba.fastjson.JSON;
import com.example.securitytest.model.Result;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
/**
* 自定义认证失败处理器
*/
public class MyAuthFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException {
// 设定响应状态码为200
httpServletResponse.setStatus(HttpServletResponse.SC_OK);
// 设定响应内容是utf-8编码的json类型
httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
httpServletResponse.setCharacterEncoding("utf-8");
// 组装自己的结果对象
Result result = new Result();
result.setResultFailed("用户名或者密码错误!");
// 序列化结果对象为JSON
String resultJSON = JSON.toJSONString(result);
// 写入响应体
PrintWriter writer = httpServletResponse.getWriter();
writer.write(resultJSON);
writer.flush();
writer.close();
}
}
复制代码
和登录成功的方法很类似,这里不再过多赘述。
(5) 自定义403无权限访问逻辑
在handler
中新建MyAccessDeniedHandler
,需要实现接口AccessDeniedHandler
的handle
方法。
package com.example.securitytest.handler;
import com.alibaba.fastjson.JSON;
import com.example.securitytest.model.Result;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
/**
* 自定义权限不足处理器
*/
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException {
// 设定响应状态码为403
httpServletResponse.setStatus(HttpServletResponse.SC_FORBIDDEN);
// 设定响应内容是utf-8编码的json类型
httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
httpServletResponse.setCharacterEncoding("utf-8");
// 组装自己的结果对象
Result result = new Result();
result.setResultFailed("权限不足!请联系管理员!");
// 序列化结果对象为JSON
String resultJSON = JSON.toJSONString(result);
// 写入响应体
PrintWriter writer = httpServletResponse.getWriter();
writer.write(resultJSON);
writer.flush();
writer.close();
}
}
复制代码
默认Spring Security会拦截全部请求,拦截后就会触发这个无权访问的方法。在后面我们需要配置具体需要拦截的路径。
(6) 自定义退出登录成功逻辑
在handler
中建立MyLogoutSuccessHandler
,实现接口LogoutSuccessHandler
的onLogoutSuccess
方法。
package com.example.securitytest.handler;
import com.alibaba.fastjson.JSON;
import com.example.securitytest.model.Result;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
/**
* 自定义登出成功处理器
*/
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
// 设定响应状态码为200
httpServletResponse.setStatus(HttpServletResponse.SC_OK);
// 设定响应内容是utf-8编码的json类型
httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
httpServletResponse.setCharacterEncoding("utf-8");
// 组装结果
Result result = new Result();
result.setResultSuccess("退出成功!", null);
// 序列化结果对象为JSON
String resultJSON = JSON.toJSONString(result);
// 写入响应体
PrintWriter writer = httpServletResponse.getWriter();
writer.write(resultJSON);
writer.flush();
writer.close();
}
}
复制代码
可见这几个xxxHandler
类(登录成功、登录失败、无权访问、退出登录逻辑)都很相似,返回JSON的方法也是一样的,主要是组装我们自己的结果对象、序列化为JSON字符串然后写入返回的响应流中。
5,安全?策略!配置啦!
上述定义了一系列的自定义实现类,现在就需要进行配置了。除此之外,我们还要配置拦截路径、各个接口路径的访问权限。
新建config
包,在里面新建SecurityConfig
类,继承WebSecurityConfigurerAdapter
并重写其中的configure
方法。先上代码:
package com.example.securitytest.config;
import com.example.securitytest.filter.MyAuthFilter;
import com.example.securitytest.handler.MyAccessDeniedHandler;
import com.example.securitytest.handler.MyAuthFailureHandler;
import com.example.securitytest.handler.MyAuthSuccessHandler;
import com.example.securitytest.handler.MyLogoutSuccessHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
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.authentication.UsernamePasswordAuthenticationFilter;
/**
* Spring Security配置
*/
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 配置密码加密器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 配置自定义用户名密码拦截器为Bean
*/
@Bean
public UsernamePasswordAuthenticationFilter myAuthFilter() throws Exception {
UsernamePasswordAuthenticationFilter myAuthFilter = new MyAuthFilter();
// 注意,因为是自定义登录拦截器,所以登录接口地址要在此配置!
myAuthFilter.setFilterProcessesUrl("/api/user/login");
// 设定为自定义的登录成功/失败处理器
myAuthFilter.setAuthenticationSuccessHandler(new MyAuthSuccessHandler());
myAuthFilter.setAuthenticationFailureHandler(new MyAuthFailureHandler());
myAuthFilter.setAuthenticationManager(authenticationManagerBean());
return myAuthFilter;
}
/**
* 配置安全拦截策略
*/
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
// 设定认证拦截器
httpSecurity.authorizeRequests()
// 设定查询炸弹列表的权限
.antMatchers("/api/bomb/get").hasAuthority("queryBomb")
// 设定添加炸弹的权限
.antMatchers("/api/bomb/add").hasAuthority("addBomb")
// 需要登录后才能获取用户信息
.antMatchers("/api/user/islogin").authenticated()
// 放行头像url
.antMatchers("/avatar/*").permitAll();
// 自定义退出登录url和配置自定义的登出成功处理器
httpSecurity.logout().logoutUrl("/api/user/logout").logoutSuccessHandler(new MyLogoutSuccessHandler());
// 关闭csrf
httpSecurity.csrf().disable();
// 设定自己的登录认证拦截器
httpSecurity.addFilterAt(myAuthFilter(), UsernamePasswordAuthenticationFilter.class);
// 设定为自定义的权限不足处理器
httpSecurity.exceptionHandling().accessDeniedHandler(new MyAccessDeniedHandler());
}
}
复制代码
首先,定义了上述的passwordEncoder
方法注入为Bean,配置密码加密器,一般就如上配置BCryptPasswordEncoder
即可。
后续在写添加用户服务时,需要对用户密码进行加密才能存入数据库,那么直接在相应服务类中自动注入(
@Autowired
)一个PasswordEncoder
对象,并调用其encode
方法即可加密。我们上述已经配置了创建一个BCryptPasswordEncoder
实例注册为Bean,那么就可以在别的地方使用自动注入。
然后,上述方法myAuthFilter
就是我们用于配置自己的用户名密码拦截器方法,将其注入为Bean。在其中还给它配置了登录请求的路径(登录信息请求发送给这个路径)、我们自定义的登录成功/失败拦截器。
重点在于下面的configure
方法,利用其中的参数HttpSecurity
完成各个配置。
可以看见它的方法是链式调用的,其中:
authorizeRequests
配置要认证的路径以及其访问权限,然后:antMatchers
匹配路径,接着设定该路径的访问权限:hasAuthority
拥有指定权限才能访问该路径hasAnyAuthority
指定多个权限,只要拥有其中一个,就可以访问该路径hasRole
拥有指定角色才能访问该路径hasAnyRole
指定多个角色,只要拥有其中一个,就可以访问该路径hasIpAddress
指定的ip才能访问该路径authenticated
只要登录了就可以访问该路径permitAll
不拦截该路径
logout
配置退出登录,然后:logoutUrl
配置退出登录API的请求路径(退出登录请求发送给这个路径)logoutSuccessHandler
设定退出登录成功的处理器,这里就填入了我们上述自定义的退出登录成功逻辑实现类的实例
addFilterAt
自定义拦截器,上述定义了我们自定义的用户名密码登录拦截器,在这里设定即可。第一个参数是我们拦截器的实例,上面已经定义了方法myAuthFilter
,第二个参数表示要设定拦截器的类型,我们使用的是UsernamePasswordAuthenticationFilter
exceptionHandling
配置异常处理,然后:accessDeniedHandler
设定自定义403逻辑
需要注意的是,旅行者在网上发现很多教程都是在这个configure
方法、利用其中的参数HttpSecurity
设定的登录路径、登录成功、失败方法逻辑,但是为什么这里不一样呢?因为我们自定义了用户名密码登录拦截器,就需要在自定义的拦截器实例里面设定,然后再像上面把自定义拦截器实例配置到HttpSecurity
中即可。
以及我们上述antMatchers
的路径匹配规则在这里提一下:
?
表示匹配任何单字符,例如/api/??
将会匹配/api/aa
、/api/ab
等等,但是不会匹配/api/a
或者/api/aaa
*
表示匹配0或者任意数量的字符,例如/api/*
会匹配/api/a
、/api/aaa
等等**
表示匹配0或者多级目录,例如/resource/**
会匹配/resource/aa
、/resource/aa/bb
等等
上述的antMatchers
其实也可以换成regexMatchers
,即使用正则表达式进行匹配,其子方法和上面相同,这里不再赘述。
以及前面提了角色名必须以ROLE_
开头,但是这里如果要用到hasRole
或者hasAnyRole
的话,里面的参数就不能以ROLE_
开头了,否则会报错。例如只允许ROLE_admin
访问,那么应该写hasRole("admin")
。
与此同时,在这里我还是强调一下判断用户登录的API,我先放出用户API类(Controller层)代码:
package com.example.securitytest.api;
import com.example.securitytest.dao.MyUserDAO;
import com.example.securitytest.dataobject.MyUser;
import com.example.securitytest.model.Result;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/user")
public class UserAPI {
@Autowired
private MyUserDAO myUserDAO;
/**
* 判断用户登录,这个api会被加入Spring Security拦截器,若用户没有登录会返回无权访问403
*
* @return 用户信息
*/
@GetMapping("/islogin")
public Result<MyUser> isLogin(Authentication authentication) { // 在Controller类方法中加上Spring Security的Authentication类型参数,可以获取当前登录的用户信息例如用户名等等
Result<MyUser> result = new Result<>();
// 判断是否登录,如果未登录通常这个authentication为null
if (authentication == null || !authentication.isAuthenticated()) {
result.setResultFailed("用户未登录!");
return result;
}
// 获取当前登录的用户信息
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
// 获取用户名并查询用户
MyUser myUser = myUserDAO.getByUsername(userDetails.getUsername());
if (myUser == null) {
result.setResultFailed("用户无效!");
return result;
}
result.setResultSuccess("用户已经登录!", myUser);
return result;
}
}
复制代码
这里只有一个方法isLogin
,可见其中我们可以加上Spring Security的Authentication
类型参数,这个参数在我们上面自定义登录成功处理器的时候也见到过,其实就是一个东西,表示当前登录的用户信息。我们可以用这个参数,判断用户是否登录,以及获取当前登录的用户信息,我们这里就用它获取了当前登录用户的用户名并拿去查询数据库得到完整用户信息给前端。
我们还可以用这个Authentication
类型参数判断当前操作和被操作的用户名是否一致。
为什么这里可以获取已经登录的用户呢?其实明白用户登录的原理就知道了,Spring Security也是用session实现用户登录的,只要某个设备在这个网站登录了,那么后面这个设备任何发给这个后端的请求都会带着cookie,里面存着sessionId,这样后端就知道这个设备的用户登录了,并且能够查到登录的用户信息,直到用户退出或者cookie失效,如果不熟悉还是建议看看我的一个讲解开发用户登录的博客,加深理解。
这样,从自定义一些处理逻辑、到配置,就完成了!
6,网页?前端!开干啦!
旅行者终于完成了后端的构建,现在开始构建前端了。
因为这篇文章主要是讲解Spring Security的使用,这里不讲解前端怎么写,只是提提思路。
这里是Vue的前后端分离模式,前端通过axios
发送请求,得到了结果,来控制页面跳转、显示等等。
每一个页面加载时都会向后端的判断用户是否登录接口发送请求判断是否登录,也可见上面配置安全策略中拦截了这个判断是否登录的接口(/api/user/islogin
),这个接口实质就是查询信息,这样前端判断如果返回的是403
就说明没登录,返回200
就说明登录了。
当然这是我的思路,大家也可以自己实现。
这里用到了Vue多页应用构建,对Vue多页应用不熟悉可以看看这个:链接
这里来看看部分效果吧!
登录界面
可莉可以增加和查看炸弹
早柚只能查看炸弹,但是不能添加炸弹
7,完成?放工!总结啦!
至此,旅行者给可莉的第一版“炸弹管理系统”就完成了!这里简单起见就没有做数量管理、用户注册等等功能。
实际开发中,我们会有数据模型(dataobject)、DAO层(操作数据库)、Service层(服务逻辑)和Controller层(API),这里简单起见省略了Service层。
不过,这里在前后端分离的模式下,我们Spring Security的一些逻辑就需要自行设定。
最核心的思想就是:
- 后端不再控制页面跳转,只控制接口和数据的访问、用户登录等资源访问
- 每一个操作后端都给前端返回JSON数据而非执行请求转发、跳转
- 让前端读取结果判断操作成功还是失败,实现跳转显示等等
- 后端开发一个判断用户是否登录的接口,前端每次加载(
mounted
时)就可以发送请求判断是否登录,如果登录了就返回用户信息前端以显示