笔记内容转载自 AcWing 的 SpringBoot 框架课讲义,课程链接:AcWing SpringBoot 框架课。
本节实现用适合前后端分离的 JWT 验证替代传统的 Session 验证方式,并实现登录、获取信息以及注册三个后端 API。
传统模式是使用 Session 进行验证,但是由于前后端分离后可能会存在跨域问题,因此我们用 JWT 验证会更加方便。JWT 验证不仅可以很容易地实现跨域,也无需在服务器端存储数据,这样就算我们有很多个服务器,那么只需要获得一次令牌后就可以登录多个服务器。
我们所有的页面大致可以分为两大类,第一类是无需登录验证就能访问(公开页面),另一类就是登录后才能访问(授权页面)。
JWT 验证的原理是用户登录后服务器会给用户返回一个 jwt token,且会将一些 ID 之类的用户信息加到 jwt token 里,之后客户端每次向服务器端发送请求的时候都会带上这个令牌,服务器端在访问授权页面时会先验证这个令牌是否合法,如果合法就会根据令牌中的用户信息从数据库中查找出这个用户并将其数据提取至上下文,接着再访问授权页面。
生成 jwt token 时服务器端会先构建一个字符串,第一段存储用户 ID,第二段存储一个只有服务器能看到的密钥,然后可以通过一些哈希算法将字符串加密(加密算法是固定的),接着会将第一段用户 ID 加上加密后的信息合在一起(即 jwt token)返回给用户,之后服务器端想要验证的话就根据接收到的用户 ID 配合自己的密钥重复一遍这个固定的加密算法,看看加密后的结果是否和接收到的 jwt token 中的加密信息一致。
现在可能会有几个问题,比如 jwt token 是存在于客户端的,那么如果用户去篡改里面的数据会怎样,比如把用户 ID 换成具有权限的另一个用户的 ID?
这种情况是不会发生的,因为假如用户修改了 ID,但是由于加密算法的特性是不可逆的,即无法通过加密信息反推回原始字符串的信息,因此用户不知道服务器加密的密钥是什么,就没办法得到修改 ID 后再经过密钥加密的信息。
首先我们先去 Maven 仓库查找并添加以下依赖:
JetBrains Java Annotationsjjwt-apijjwt-impljjwt-jackson然后我们要实现 utils.JwtUtil 类(utils 包创建在 com.kob.backend 包下,放在哪个包下其实都是看个人习惯,不一定都要按这样写),为 JWT 工具类,用来创建、解析 jwt token:
package com.kob.backend.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;
@Component
public class JwtUtil {
public static final long JWT_TTL = 60 * 60 * 1000L * 24 * 14; // token的有效期设置为14天
public static final String JWT_KEY = "IVK157AXCZSChcwW23AUvayrXYhgcXAHKBMDziw17dW"; // 密钥,自己随便打,但是长度要够长,否则会报错
public static String getUUID() {
return UUID.randomUUID().toString().replaceAll("-", "");
}
public static SecretKey generalKey() {
byte[] encodeKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY);
return new SecretKeySpec(encodeKey, 0, encodeKey.length, "HmacSHA256");
}
private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) {
SecretKey secretKey = generalKey();
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
if (ttlMillis == null) {
ttlMillis = JwtUtil.JWT_TTL;
}
long expMillis = nowMillis + ttlMillis;
Date expDate = new Date(expMillis);
return Jwts.builder()
.id(uuid)
.subject(subject)
.issuer("sg")
.issuedAt(now)
.signWith(secretKey)
.expiration(expDate);
}
public static String createJWT(String subject) {
JwtBuilder builder = getJwtBuilder(subject, null, getUUID());
return builder.compact();
}
public static Claims parseJWT(String jwt) throws Exception {
SecretKey secretKey = generalKey();
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(jwt)
.getPayload();
}
}
接下来还需要实现 config.filter.JwtAuthenticationTokenFilter 类(filter 包创建在之前的 config 包下),用来验证 jwt token,如果验证成功,则将 User 信息注入上下文中:
package com.kob.backend.config.filter;
import com.kob.backend.mapper.UserMapper;
import com.kob.backend.pojo.User;
import com.kob.backend.service.impl.utils.UserDetailsImpl;
import com.kob.backend.utils.JwtUtil;
import io.jsonwebtoken.Claims;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private UserMapper userMapper;
@Override
protected void doFilterInternal(HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("Authorization");
if (!StringUtils.hasText(token) || !token.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
token = token.substring(7); // 跳过"Bearer "共7个字符
String userid;
try {
Claims claims = JwtUtil.parseJWT(token);
userid = claims.getSubject();
} catch (Exception e) {
throw new RuntimeException(e);
}
User user = userMapper.selectById(Integer.parseInt(userid));
if (user == null) {
throw new RuntimeException("用户未登录");
}
UserDetailsImpl loginUser = new UserDetailsImpl(user);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser, null, null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
}
}
最后我们还需要配置一下之前实现过的 config.SecurityConfig 类,放行登录、注册等接口,因为用户未登录时需要能访问这些页面才能正常登录:
package com.kob.backend.config;
import com.kob.backend.config.filter.JwtAuthenticationTokenFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
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.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/user/account/login/", "/user/account/register/").permitAll() // 需要公开的链接在这边写即可
.antMatchers(HttpMethod.OPTIONS).permitAll()
.anyRequest().authenticated();
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
}
较新版本的 Spring Security 5.7 会看到提示说 WebSecurityConfigurerAdapter 已经废除,不过目前对本项目没什么影响,如果一定要改可以参考以下代码:
package com.kob.backend.config;
import com.kob.backend.config.filter.JwtAuthenticationTokenFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManagerBean(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/user/account/login/", "/user/account/register/").permitAll()
.antMatchers(HttpMethod.OPTIONS).permitAll()
.anyRequest().authenticated();
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
配置完成后接下来我们就可以创建后端的 API 了。在这之前我们给数据库的 user 表添加一列 photo 用来存储用户的头像链接(数据库中存储图像都是存的链接),类型为 varchar(1000),然后还得去 pojo.User 类中添加一个字段:
private String photo;
现在我们编写第一个 API:/user/account/login/,功能是验证用户名和密码,验证成功后返回 jwt token。
SpringBoot 中写一个 API 一共要实现三个部分:controller 用来调用 service,service 里面实现一个接口,还需要在 service.impl 中写一个具体的接口的实现。
在 service 包下创建 user.account 包,表示用户账号相关的 API,然后创建 LoginService 接口(注意不是创建类):
package com.kob.backend.service.user.account;
import java.util.Map;
public interface LoginService {
Map<String, String> login(String username, String password);
}
接着在 service.impl 包下创建 user.account 包,然后创建 LoginServiceImpl 类用来实现我们之前定义的接口:
package com.kob.backend.service.impl.user.account;
import com.kob.backend.pojo.User;
import com.kob.backend.service.impl.utils.UserDetailsImpl;
import com.kob.backend.service.user.account.LoginService;
import com.kob.backend.utils.JwtUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Service // 注入到Spring中,未来可以用@Autowired注解将该类注入到某个其他类中
public class LoginServiceImpl implements LoginService {
@Autowired
private AuthenticationManager authenticationManager;
@Override
public Map<String, String> login(String username, String password) {
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(username, password); // 需要先封装一下,因为数据库中存的不是明文
Authentication authenticate = authenticationManager.authenticate(authenticationToken); // 验证是否能登录,如果失败会自动处理
UserDetailsImpl loginUser = (UserDetailsImpl)authenticate.getPrincipal();
User user = loginUser.getUser(); // 将用户取出来
String jwt_token = JwtUtil.createJWT(user.getId().toString()); // 将用户的ID转换成jwt_token
Map<String, String> res = new HashMap<>();
res.put("result", "success");
res.put("jwt_token", jwt_token);
return res;
}
}
最后就可以实现 controller 模块了,我们可以先把 controller.user 包下的 UserController 类删了,这是之前学习数据库操作与调试用的,然后创建一个 account 包,在包中创建 LoginController 类:
package com.kob.backend.controller.user.account;
import com.kob.backend.service.user.account.LoginService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class LoginController {
@Autowired // 将接口注入进来,这就是Spring的IoC依赖注入特性
private LoginService loginService;
@PostMapping("/user/account/login/") // 登录采用POST请求,不是明文传输,较为安全
public Map<String, String> login(@RequestParam Map<String, String> info) { // 将POST参数放在一个Map中
String username = info.get("username");
String password = info.get("password");
return loginService.login(username, password);
}
}
实现好后我们可以自己调试一下,如果直接从浏览器访问 URL 的话是 GET 请求,尝试访问 http://localhost:3000/user/account/login/ 会看到报错状态码为405,表示方法不被允许,可以用 Postman 软件调试也可以自己打开前端调试,我们采用第二种方法。
直接在前端项目的 App.vue 文件中编写临时调试代码,使用 Ajax 发出 POST 请求:
<template>
<NavBar />
<router-view />
template>
<script>
import NavBar from "@/components/NavBar.vue";
import $ from "jquery";
export default {
components: {
NavBar,
},
setup() {
$.ajax({
url: "http://localhost:3000/user/account/login/",
type: "POST",
data: {
username: "AsanoSaki",
password: "123456",
},
success(resp) {
console.log(resp);
},
error(resp) {
console.log(resp);
},
});
},
};
script>
<style>
body {
background-image: url("@/assets/images/background.png");
background-size: cover;
}
style>
然后我们在前端页面中打开控制台,一刷新页面即可看到输出结果。我们可以双击并复制下来控制台中的 jwt_token 内容,然后去 JWT IO 中解析一下,能够得到以下结果,其中的 sub 存储的即为用户 ID:
// Header部分,表示加密算法
{
"alg": "HS256"
}
// Payload部分,表示数据
{
"jti": "98d002b0b919404ea0571d815cecf5ba",
"sub": "1",
"iss": "sg",
"iat": 1700100065,
"exp": 1701309665
}
现在我们来编写 API:/user/account/info/,功能是根据客户端传来的 jwt_token 获取用户信息。
首先在 service.user.account 包下创建 InfoService 接口:
package com.kob.backend.service.user.account;
import java.util.Map;
public interface InfoService {
Map<String, String> getInfo();
}
然后在 service.impl.user.account 包下创建 InfoServiceImpl 类,来实现 InfoService 接口:
package com.kob.backend.service.impl.user.account;
import com.kob.backend.pojo.User;
import com.kob.backend.service.impl.utils.UserDetailsImpl;
import com.kob.backend.service.user.account.InfoService;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Service
public class InfoServiceImpl implements InfoService {
@Override
public Map<String, String> getInfo() {
// 将用户信息提取到上下文中
UsernamePasswordAuthenticationToken authenticationToken =
(UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
UserDetailsImpl loginUser = (UserDetailsImpl)authenticationToken.getPrincipal();
User user = loginUser.getUser();
Map<String, String> res = new HashMap<>();
res.put("result", "success");
res.put("id", user.getId().toString());
res.put("username", user.getUsername());
res.put("photo", user.getPhoto());
return res;
}
}
最后在 controller.user.account 包下创建 InfoController:
package com.kob.backend.controller.user.account;
import com.kob.backend.service.user.account.InfoService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class InfoController {
@Autowired
private InfoService infoService;
@GetMapping("/user/account/info/") // 此处是获取信息的请求,使用GET
public Map<String, String> getInfo() {
return infoService.getInfo();
}
}
重启一下后端,然后我们直接访问 http://localhost:3000/user/account/info/ 会看到报错代码为403,表示没有权限访问,因为我们还没登录。
还是和之前一样,我们在前端中测试,将之前登录接收到的 jwt_token 用于之后访问授权链接,此次请求不用传数据,但是需要传一个 headers 表示表头,其中有一个 Authorization 属性,由 Bearer (注意有个空格)加上 jwt_token 组成,我们先直接把前面浏览器控制台输出的 jwt_token 复制过来:
...
<script>
import NavBar from "@/components/NavBar.vue";
import $ from "jquery";
export default {
components: {
NavBar,
},
setup() {
...
$.ajax({
url: "http://localhost:3000/user/account/info/",
type: "GET",
headers: {
// 不是固定的,是官方推荐的写法,Authorization是在我们的后端JwtAuthenticationTokenFilter类中设置的
Authorization: "Bearer " + "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI0OGNjYjZiY2E0ZTk0YjliODI3ZmM3M2Y0OTg5YjNjOSIsInN1YiI6IjEiLCJpc3MiOiJzZyIsImlhdCI6MTcwMDEwODIyOCwiZXhwIjoxNzAxMzE3ODI4fQ.B_eKTBIxfoiXy4b0tp1sPqy7ZH5GqRFfvYOCk2sx6IY",
},
success(resp) {
console.log(resp);
},
error(resp) {
console.log(resp);
},
});
},
};
script>
...
在浏览器控制台可以看到以下输出:
{
"result": "success",
"photo": "https://cdn.acwing.com/media/user/profile/photo/82581_lg_e9bdbcb8aa.jpg",
"id": "1",
"username": "AsanoSaki"
}
注册功能就有一些业务逻辑需要判断,代码量会稍微多一些。
首先在 service.user.account 包下创建 RegisterService 接口:
package com.kob.backend.service.user.account;
import java.util.Map;
public interface RegisterService {
Map<String, String> register(String username, String password, String confirmedPassword);
}
然后在 service.impl.user.account 包下创建 RegisterServiceImpl 类,来实现 InfoService 接口:
package com.kob.backend.service.impl.user.account;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.kob.backend.mapper.UserMapper;
import com.kob.backend.pojo.User;
import com.kob.backend.service.user.account.RegisterService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class RegisterServiceImpl implements RegisterService {
@Autowired
private UserMapper userMapper;
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public Map<String, String> register(String username, String password, String confirmedPassword) {
Map<String, String> res = new HashMap<>();
if (username == null) { // 判断是否存在用户名参数
res.put("result", "The username can't be empty!");
return res;
}
if (password == null || confirmedPassword == null) { // 判断是否存在密码参数
res.put("result", "The password can't be empty!");
return res;
}
username = username.trim(); // 删掉用户名首尾的空白字符
if (username.isEmpty()) { // 判断删去空格后用户名是否为空
res.put("result", "The username can't be empty!");
return res;
}
if (password.isEmpty() || confirmedPassword.isEmpty()) { // 判断密码是否为空
res.put("result", "The password can't be empty!");
return res;
}
if (username.length() > 100 || password.length() > 100) { // 判断用户名或密码长度是否超过数据库字段的范围
res.put("result", "The username or password can't be longer than 100!");
return res;
}
if (!password.equals(confirmedPassword)) { // 判断两次输入的密码是否一致
res.put("result", "The inputs of two passwords are different!");
return res;
}
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("username", username); // 在数据库中查看是否存在用户名相同的用户
List<User> users = userMapper.selectList(queryWrapper);
if (!users.isEmpty()) {
res.put("result", "The username already exists!");
return res;
}
// 执行数据库插入操作
String encodedPassword = passwordEncoder.encode(password);
String photo = "https://cdn.acwing.com/media/user/profile/photo/82581_lg_e9bdbcb8aa.jpg"; // 默认头像
User user = new User(null, username, encodedPassword, photo);
userMapper.insert(user);
res.put("result", "success");
return res;
}
}
最后在 controller.user.account 包下创建 RegisterController:
package com.kob.backend.controller.user.account;
import com.kob.backend.service.user.account.RegisterService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class RegisterController {
@Autowired
private RegisterService registerService;
@PostMapping("/user/account/register/")
public Map<String, String> register(@RequestParam Map<String, String> info) {
String username = info.get("username");
String password = info.get("password");
String confirmedPassword = info.get("confirmedPassword");
return registerService.register(username, password, confirmedPassword);
}
}
同样还是在前端编写调试代码,可以自行尝试数据为空,或者两次密码不一致等不合法操作,然后在浏览器控制台查看结果:
...
<script>
import NavBar from "@/components/NavBar.vue";
import $ from "jquery";
export default {
components: {
NavBar,
},
setup() {
...
$.ajax({
url: "http://localhost:3000/user/account/register/",
type: "POST",
data: {
username: "user7",
password: "123456",
confirmedPassword: "123456",
},
success(resp) {
console.log(resp);
},
error(resp) {
console.log(resp);
},
});
},
};
script>
...
至此我们的后端部分实现完成了,后面就可以开始实现前端部分了。