本文使用mybatis-plus作为ORM框架,然后使用springsecurity6作为安全框架,采用JWT作为权限确认的方式。
其实我现在就刚入门这个springsecurity6而已,有许多都没有摸清它的使用,写下这篇文章只是想记录一下,下次想用的时候来看看。
一. 准备阶段
1.JWT工具类
上面说了使用JWT。那就得有一个能生成JWT和检测JWT的工具类,我在下面给出了一个简陋的工具类,包含了三个方法。这个工具类的jar包依赖如下:
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
本文的JWT工具类的加密算法就普普通通的HS256,token有效期就只有4个小时,这个token里面就只存有userName这一个信息,所以为了确保不会出错,在数据库中的userName这一列,不能出现一模一样的两行数据,换句话说,就是用户名不能重复。代码如下:
@Component
@Slf4j
public class JwtUtil {
//常量
public static final long EXPIRE = 1000 * 60 * 60 * 4; //token过期时间,4个小时
public static final String APP_SECRET = "ukc8BDbRigUDaY6pZFfWus2jZWLPHO"; //秘钥
//生成token字符串的方法
public String getToken(String userName){
return Jwts.builder()
.setHeaderParam("typ", "JWT")
.setHeaderParam("alg", "HS256")
.setSubject("user")
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRE))
.claim("userName", userName)//设置token主体部分 ,存储用户信息
.signWith(SignatureAlgorithm.HS256, APP_SECRET)
.compact();
}
//验证token字符串是否是有效的 包括验证是否过期
public boolean checkToken(String jwtToken) {
if(jwtToken == null || jwtToken.isEmpty()){
log.error("Jwt is empty");
return false;
}
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
Claims body = claims.getBody();
if ( body.getExpiration().after(new Date(System.currentTimeMillis()))){
return true;
} else
return false;
} catch (Exception e) {
log.error(e.getMessage());
return false;
}
}
public Claims getTokenBody(String jwtToken){
if(jwtToken == null || jwtToken.isEmpty()){
log.error("Jwt is empty");
return null;
}
try {
return Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken).getBody();
} catch (Exception e){
log.error(e.getMessage());
return null;
}
}
}
2.数据库连接
本项目使用mybatis-plus作为ORM框架,然后使用druid连接池,所需要的依赖如下:
<!-- https://mvnrepository.com/artifact/com.baomidou/mybatis-plus-boot-starter -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.15</version>
</dependency>
接着在application.yml中进行配置,我的数据库名字起名叫dubbd,你们可以根据自己数据库名字将dubbd更换。
server:
port: 8080
datasource:
url: localhost:3306/dubbd
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${datasource.url}?useSSL=false&useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true&autoReconnect=true&failOverReadOnly=false&maxReconnects=10&allowPublicKeyRetrieval=true
username: root
password: 123456
hikari:
maximum-pool-size: 10
max-lifetime: 1770000
druid:
validation-query: SELECT 1 FROM DUAL
initial-size: 10
min-idle: 10
max-active: 200
min-evictable-idle-time-millis: 300000
test-on-borrow: false
test-while-idle: true
time-between-eviction-runs-millis: 30000
pool-prepared-statements: true
max-open-prepared-statements: 100
再然后,我们要在数据库的一个表中准备自己需要的用户数据,我的用户数据如下:
导出后的sql脚本如下:
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`userName` varchar(100) DEFAULT NULL,
`password` varchar(100) DEFAULT NULL,
`role` varchar(20) DEFAULT NULL,
`userId` varchar(100) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户信息表';
LOCK TABLES `user` WRITE;
INSERT INTO `user` VALUES ('huonzy','$2a$10$kNrjJ3.1wCSmSnjs1JI.RO6RMrQc.oRiJ93T2sefyCOzXb7yy.Mmm','user','1');
UNLOCK TABLES;
3.实现表和实体类的绑定
在上面,我们已经明确了我们的表名为user,所以要使用mybatis-plus,我们就要创建一个User类实体类,并使用注解来绑定这个表,因为我们同时还要使用User这个实体类作为springsecurity的用户类,所以我们在创建这个实体类时,还要实现UserDetails这个接口,下面是我们需要的依赖:
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
接下来是实体类的代码:
@TableName("user")
@Data
public class User implements UserDetails {
@TableField("userName")
private String userName;
@TableField("password")
private String password;
@TableId("userId")
private String userId;
@TableField("role")
private String role;
@Override//用户所拥有的权限,返回的列表中至少得有一个值,否则这个用户啥权限都没有
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority(role));
}
@Override//实现UserDetails的getPassword方法,返回实体类的password
public String getPassword() {
return password;
}
@Override//这个方法是UserDetails中的方法,必须实现
public String getUsername() {
return userName;
}
public String getUserName(){//这个是mybatis-plus需要用到的方法
return this.userName;
}
@Override//返回true,代表用户账号没过期
public boolean isAccountNonExpired() {
return true;
}
@Override//返回true,代表用户账号没被锁定
public boolean isAccountNonLocked() {
return true;
}
@Override//返回true,代表用户密码没有过期
public boolean isCredentialsNonExpired() {
return true;
}
@Override//返回true,代表用户账号还能够使用
public boolean isEnabled() {
return true;
}
}
最后就是建立mapper层的CRUD了:
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
不要忘记在启动类下加上@MapperScan注解。
4.统一返回前后端交互的数据格式
前端登录时传给后端的,无疑就是用户名和密码了,我们创建一个类来接收者两个数据:
@Data
public class LoginReuqest {
private String userName;
private String password;
}
后端传给前端的,就是一个统一的格式:
@Data
@Builder
public class HnResult {
private Integer code;
private String msg;
private Object data;
public static HnResult ok(String message) {
return HnResult.builder().code(200).msg(message).build();
}
public static HnResult ok() {
return HnResult.builder().code(200).msg("成功").build();
}
public static HnResult error(String message){
return HnResult.builder().code(500).msg(message).build();
}
public static HnResult error(){
return HnResult.builder().code(500).msg("失败").build();
}
public <T> HnResult setData(T data){
this.data = data;
return this;
}
}
到这里,准备都已经完成了,开始进入登录的用户信息确认阶段。
二.用户登录认证阶段
当一个用户访问一个网站时,那必须要进行登录的,没有账号的话那就去注册。用户登录只需要一次就够了,登录后,后面发出的请求就不需要再进行登录认证了。所以我们要确保这个过程只用经历一次。对于用户登录的信息确认,我们有两种实现方式,第一种就是实现UsernamePasswordAuthenticationFilter;第二种就是自定义一个控制层的用户登录接口,然后在这个接口里面使用AuthenticationManager来对用户信息进行确认。其实这两种方式差不多,UsernamePasswordAuthenticationFilter也会调用AuthenticationManager来对用户信息进行确认。第二种方式就是直接把UsernamePasswordAuthenticationFilter中的doFilterInternal方法里面的代码搬到了控制层的登录接口里面而已。所以我们在这里使用第二种方式,会了第二种方式,第一种方式也差不多会了。因为实现UsernamePasswordAuthenticationFilter,只需要实现doFilterInternal方法。
1.实现UserDetailsService
在前面的准备阶段,我们定义了一个实体类User并让其实现了UserDetails接口,之所以我们要实现这个接口,是因为springsecurity中的认证过程中采用的用户服务返回的数据格式就是UserDetails。这个用户服务就是UserDetailsService,同样的,这个也是一个接口,也需要创建一个服务类对其进行实现,我们只需要实现其loadUserByUsername方法就行了,代码如下:
@Service
@Slf4j
public class UserService implements UserDetailsService {
@Resource
UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUserName, username);
try {
User user = userMapper.selectOne(wrapper);//这里要确保userName是唯一的
return user;
}catch (Exception e){
log.error("user is not find");
return null;
}
}
}
接下来,就是对SpringSecurity进行修改配置了。
2.将PasswordEncoder注册成Bean
我们的用户登录认证流程其实就是上面的这幅图,里面用到的类,除了UsernamePasswordAuthenticationToken不需要注册成Bean外,其余都要注册成Bean。我们先注册PasswordEncoder,这个其实是一个接口,我们需要将其实现类注册成接口。我们需要创建一个配置类,用来容纳所有的springsecurity配置修改,然后在里面将PasswordEncoder注册成Bean,代码如下:
@Configuration//声明该类是一个配置类
@EnableWebSecurity//开启springsecurity配置修改
public class SecurityConfig {
@Bean//PasswordEncoder的实现类为BCryptPasswordEncoder
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
3.将AuthenticationProvider注册成Bean
我们从上图可以看到,AuthenticationProvider使用了PasswordEncoder和UserDetailsService,所以我们在配置 AuthenticationProvider时要将这两个加上。将下面的代码加入上面创建的SecurityConfig配置类即可。
@Resource
UserService userService;
@Bean
public AuthenticationProvider authenticationProvider(){
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setPasswordEncoder(passwordEncoder());
provider.setUserDetailsService(userService);
return provider;
}
4.将AuthenticationManager注册成Bean
将AuthenticationManager配置成Bean就十分简单了,也是将下面的代码加入SecurityConfig中:
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
5.将SecurityFilterChain注册成Bean
不知道大家有没有发现,我们上面并没有建立其AuthenticationManager和AuthenticationProvider的联系,那AuthenticationManager该如何使用AuthenticationProvider进行用户登录认证。AuthenticationManager在注册成Bean的时候,用的是配置里面的AuthenticationManager,所以我们需要在SecurityFilterChain中修改认证时使用的AuthenticationProvider变成我们自己注册的Bean,这样子AuthenticationManager就可以使用我们自己配置的AuthenticationProvider了。对SecurityFilterChain也需要写入SecurityConfig中,代码如下:
@Resource
JwtFilter jwtFilter;//后面jwt验证需要用到的过滤器,现在先不理它
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity.formLogin(AbstractHttpConfigurer::disable)//取消默认登录页面的使用
.logout(AbstractHttpConfigurer::disable)//取消默认登出页面的使用
.authenticationProvider(authenticationProvider())//将自己配置的PasswordEncoder放入SecurityFilterChain中
.csrf(AbstractHttpConfigurer::disable)//禁用csrf保护,前后端分离不需要
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))//禁用session,因为我们已经使用了JWT
.httpBasic(AbstractHttpConfigurer::disable)//禁用httpBasic,因为我们传输数据用的是post,而且请求体是JSON
.authorizeHttpRequests(request -> request.requestMatchers(HttpMethod.POST, "/user/login", "/user/register").permitAll().anyRequest().authenticated())//开放两个接口,一个注册,一个登录,其余均要身份认证
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);//将用户授权时用到的JWT校验过滤器添加进SecurityFilterChain中,并放在UsernamePasswordAuthenticationFilter的前面
return httpSecurity.build();
}
在上面的代码中,我禁用了默认登录和登出页面,因为我们是前后端分离,不需要后端提供页面。我禁用了session的创建和使用,因为我已经使用了JWT,即java web token,我禁用了CSRF保护,因为前后端分离不需要这种保护。
6.实现控制层登录接口
登录接口的实现其实可以按照上面那幅图的流程来写,不过在用户验证成功后要把信息存入SecurityContext中,想要获取SecurityContext就要通过SecurityContextHolder的getContext方法获取。
@RestController
@RequestMapping("/user/")
@Slf4j
public class UserController {
@Resource
AuthenticationManager authenticationManager;
@PostMapping("login")
public HnResult doLogin(@RequestBody LoginReuqest request){
try{
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(request.getUserName(), request.getPassword());
Authentication authentication = authenticationManager.authenticate(auth);
SecurityContextHolder.getContext().setAuthentication(authentication);
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
String jwtToken = jwtUtil.getToken(userDetails.getUsername());
return HnResult.ok("登录成功").setData(jwtToken);
} catch (Exception e){
log.error(e.getMessage());
log.error("userName or password is not correct");
return HnResult.error("登录失败");
}
}
}
7.接口测试
使用apifox进行测试这个登录接口能不能成功返回数据。
三.用户注册阶段
用户注册也要构建一个控制层的接口,注意要把用户的密码经过PasswordEncoder.encode()编码后,再存入数据库。
1.在UserService中再添加一个插入用户的方法
public User insertUser(User user){
try {
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUserName, user.getUserName());
if (userMapper.selectList(wrapper).size() == 0){
userMapper.insert(user);
return user;
}else {
log.error("userName already exists");
return null;
}
}catch (Exception e){
log.error(e.getMessage());
return null;
}
}
2.构建注册接口
注册接口里面,我们用到了一个ID生成方法,该方法需要下面这个依赖:
<!-- https://mvnrepository.com/artifact/cn.hutool/hutool-extra -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-extra</artifactId>
<version>5.8.21</version>
</dependency>
接口代码如下:
@Resource
UserService userService;
@Resource
PasswordEncoder passwordEncoder;
@PostMapping("register")
public HnResult doRegister(@RequestBody User user){
try {
if (user.getPassword() != null && !user.getPassword().isEmpty()){
String password = passwordEncoder.encode(user.getPassword());
user.setPassword(password);
user.setUserId(IdUtil.getSnowflakeNextIdStr());
if (userService.insertUser(user) == null){
throw new Exception("用户名已存在");
}
String jwtToken = jwtUtil.getToken(user.getUserName());
return HnResult.ok().setData(jwtToken);
}else
throw new Exception("密码为空");
}catch (Exception e){
log.error(e.getMessage());
return HnResult.error("注册失败" + e.getMessage());
}
}
3.接口测试
数据库里的确多了一条数据。
四.用户权限验证阶段
用户在每发一个请求到后端这里,都要进行验证其权限是否足够访问这个接口,在我这里的话,只需要验证jwt是否可用就行了。
1.构建JWT验证过滤器
代码如下:
@Component
@Slf4j
public class JwtFilter extends OncePerRequestFilter {
@Resource
JwtUtil jwtUtil;
@Resource
UserService userService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String jwtToken = request.getHeader("token");//从请求头中获取token
if (jwtToken != null && !jwtToken.isEmpty() && jwtUtil.checkToken(jwtToken)){
try {//token可用
Claims claims = jwtUtil.getTokenBody(jwtToken);
String userName = (String) claims.get("userName");
UserDetails user = userService.loadUserByUsername(userName);
if (user != null){
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
}
} catch (Exception e){
log.error(e.getMessage());
}
}else {
log.warn("token is null or empty or out of time, probably user is not log in !");
}
filterChain.doFilter(request, response);//继续过滤
}
}
2.将JWT过滤器添加进SecurityFilterChain
这一步骤我们在配置 SecurityFilterChain的时候已经做过了,大家可以返回到第二部分那里去看。
3.验证和测试
将之前注册好的用户的token放入请求头中,参数名为token,参数值则为之前返回的jwt。随便写一个接口,然后访问这个接口。
@PostMapping("check")
public HnResult doCheck(){
log.info("权限验证成功");
return HnResult.ok();
}