spring boot 实战:基于 jwt 优化 spring security 无状态登录
在上一篇文章中,我们通过 spring security 实现了基础的接口权限控制,但采用的 http basic 登录存在明显缺陷:安全性,用户名和密码只是简单的通过 base64 编码之后就开始传送了,很容易被破解,进而暴露用户信息。
本文将引入 jwt(json web token) 技术,重构登录流程,让接口即安全又贴合企业级项目需求。
一、先搞懂:为什么需要 jwt?
jwt的核心优势是 无状态:
- 登录成功后,服务器生成一个包含用户信息和权限的加密 token,直接返回给客户端;
- 客户端后续请求时,只需在请求头携带 token,服务器无需存储任何状态,直接通过 token 验证身份和权限;
- 多台服务器共用一套 token 验证逻辑,无需同步状态,完美适配分布式部署。
二、准备工作:添加 jwt 依赖
在 pom.xml 的 <dependencies> 标签中,新增 jwt 相关依赖(基于 jjwt 框架,spring 官方推荐):
<!-- jwt 核心依赖(jjwt) -->
<dependency>
<groupid>io.jsonwebtoken</groupid>
<artifactid>jjwt-api</artifactid>
<version>0.11.5</version>
</dependency>
<!-- jwt 实现依赖 -->
<dependency>
<groupid>io.jsonwebtoken</groupid>
<artifactid>jjwt-impl</artifactid>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- jwt 加密算法依赖 -->
<dependency>
<groupid>io.jsonwebtoken</groupid>
<artifactid>jjwt-jackson</artifactid>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>添加后点击 idea 右上角的 “load maven changes” 刷新依赖,确保无红色报错。
三、第一步:实现 jwt 工具类(核心)
jwt 的核心操作包括 生成 token、验证 token、解析 token 中的用户信息,我们创建一个工具类封装这些逻辑,方便后续调用。
在 com.example.firstspringbootproject.utils 包下创建 jwtutil 类:
package com.example.firstspringbootproject.utils;
import io.jsonwebtoken.*;
import org.springframework.beans.factory.annotation.value;
import org.springframework.security.core.userdetails.userdetails;
import org.springframework.stereotype.component;
import java.util.date;
import java.util.hashmap;
import java.util.list;
import java.util.map;
import java.util.function.function;
@component
public class jwtutil {
// 1. 从配置文件读取 jwt 关键参数(避免硬编码)
@value("${jwt.secret}") // 密钥(必须保密,建议在生产环境用环境变量配置)
private string secret;
@value("${jwt.expiration}") // token 过期时间(单位:毫秒,这里配置 2 小时)
private long expiration;
@value("${jwt.header}") // 请求头中携带 token 的字段名(如 authorization)
private string tokenheader;
@value("${jwt.prefix}") // token 前缀(如 bearer,规范要求加空格)
private string tokenprefix;
// 2. 生成 token:基于用户信息(用户名、角色)
public string generatetoken(userdetails userdetails) {
// 存储 token 中的自定义信息(payload)
map<string, object> claims = new hashmap<>();
// 将用户角色存入 token(后续验证权限用)
claims.put("roles", userdetails.getauthorities().stream()
.map(authority -> authority.getauthority())
.tolist());
// 构建 token 并返回
return jwts.builder()
.setclaims(claims) // 自定义信息
.setsubject(userdetails.getusername()) // 用户名(唯一标识)
.setissuedat(new date()) // 签发时间
.setexpiration(new date(system.currenttimemillis() + expiration)) // 过期时间
.signwith(signaturealgorithm.hs512, secret) // 加密算法(hs512)+ 密钥
.compact();
}
// 3. 从 token 中获取用户名
public string getusernamefromtoken(string token) {
return getclaimfromtoken(token, claims::getsubject);
}
// 4. 验证 token 是否有效(未过期 + 用户名匹配)
public boolean validatetoken(string token, userdetails userdetails) {
string username = getusernamefromtoken(token);
// 验证逻辑:用户名一致 + token 未过期 + token 未被篡改
return username.equals(userdetails.getusername())
&& !istokenexpired(token);
}
// 5. 从 token 中获取自定义角色信息
public string getrolefromtoken(string token) {
claims claims = getallclaimsfromtoken(token);
// 从自定义信息中获取角色列表(这里简化为单个角色,多角色可返回 list)
return ((list<string>) claims.get("roles")).get(0);
}
// ------------------------------
// 以下是内部工具方法(无需外部调用)
// ------------------------------
// 解析 token,获取所有自定义信息(payload)
private claims getallclaimsfromtoken(string token) {
try {
return jwts.parser()
.setsigningkey(secret) // 用密钥解密
.parseclaimsjws(token)
.getbody();
} catch (expiredjwtexception | malformedjwtexception | signatureexception
| illegalargumentexception | unsupportedjwtexception e) {
// 捕获 token 异常(过期、格式错误、签名错误等)
throw new runtimeexception("无效的 token:" + e.getmessage());
}
}
// 从 token 中获取指定信息(通用方法)
private <t> t getclaimfromtoken(string token, function<claims, t> claimsresolver) {
final claims claims = getallclaimsfromtoken(token);
return claimsresolver.apply(claims);
}
// 判断 token 是否过期
private boolean istokenexpired(string token) {
final date expirationdate = getclaimfromtoken(token, claims::getexpiration);
return expirationdate.before(new date());
}
// 6. 辅助方法:从请求头中提取 token(去除前缀)
public string extracttokenfromheader(string header) {
if (header != null && header.startswith(tokenprefix)) {
// 例如:header = "bearer eyjhbgcioijiuzi1nij9...",返回后面的 token 部分
return header.substring(tokenprefix.length()).trim();
}
return null;
}
// getter 方法(供外部获取配置参数)
public string gettokenheader() {
return tokenheader;
}
public string gettokenprefix() {
return tokenprefix;
}
}四、第二步:配置 jwt 参数(避免硬编码)
在 src/main/resources/application.yml(或 application.properties)中,添加 jwt 相关配置(替换硬编码,方便后续修改):
# jwt 配置 jwt: secret: firstspringbootproject2025secretkeyfirstspringbootproject2025secretkeyfirstspringbootproject2025secretkey # 密钥(生产环境建议用 64 位以上随机字符串) # expiration: 7200000 # token 过期时间(7200000 毫秒 = 2 小时) expiration: 1000 # token 过期时间(1000 毫秒 = 1 秒) header: authorization # 请求头字段名 prefix: bearer # token 前缀(注意末尾有空格)
五、第三步:实现 jwt 登录接口(替换 http basic)
http basic 登录是通过浏览器弹窗输入账号密码,体验较差;我们需要自定义一个 登录接口(如 /api/login),客户端通过 json 提交账号密码,服务器验证通过后返回 jwt token。
1. 创建登录请求参数实体类
在 com.example.firstspringbootproject.dto 包下创建 loginrequestdto(接收客户端提交的账号密码):
package com.example.firstspringbootproject.dto;
import io.swagger.v3.oas.annotations.media.schema;
import jakarta.validation.constraints.notblank;
import lombok.data;
@data
@schema(name = "loginrequestdto", description = "登录请求参数")
public class loginrequestdto {
@notblank(message = "用户名不能为空")
@schema(description = "用户名", example = "admin")
private string username;
@notblank(message = "密码不能为空")
@schema(description = "密码", example = "123456")
private string password;
}2. 创建登录控制器
在 com.example.firstspringbootproject.controller 包下创建 authcontroller,实现登录接口:
package com.example.firstspringbootproject.controller;
import com.example.firstspringbootproject.dto.loginrequestdto;
import com.example.firstspringbootproject.common.result;
import com.example.firstspringbootproject.utils.jwtutil;
import io.swagger.v3.oas.annotations.operation;
import io.swagger.v3.oas.annotations.tags.tag;
import jakarta.validation.valid;
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.security.core.userdetails.userdetails;
import org.springframework.web.bind.annotation.postmapping;
import org.springframework.web.bind.annotation.requestbody;
import org.springframework.web.bind.annotation.requestmapping;
import org.springframework.web.bind.annotation.restcontroller;
import java.util.hashmap;
import java.util.map;
@restcontroller
@requestmapping("/api")
@tag(name = "认证接口", description = "登录、token 相关接口")
public class authcontroller {
@autowired
private authenticationmanager authenticationmanager; // spring security 认证管理器
@autowired
private jwtutil jwtutil; // 自定义 jwt 工具类
/**
* 登录接口:接收账号密码,验证通过后返回 jwt token
*/
@postmapping("/login")
@operation(summary = "用户登录", description = "提交用户名和密码,获取 jwt token")
public result<map<string, string>> login(@valid @requestbody loginrequestdto loginrequest) {
// 1. 调用 spring security 认证管理器,验证账号密码
authentication authentication = authenticationmanager.authenticate(
new usernamepasswordauthenticationtoken(
loginrequest.getusername(),
loginrequest.getpassword()
)
);
// 2. 认证通过:从认证结果中获取用户信息
userdetails userdetails = (userdetails) authentication.getprincipal();
// 3. 生成 jwt token
string token = jwtutil.generatetoken(userdetails);
// 4. 构建返回结果(包含 token 和过期提示)
map<string, string> resultmap = new hashmap<>();
resultmap.put("token", token);
resultmap.put("expiration", "token 有效期 2 小时,请及时刷新");
resultmap.put("role", jwtutil.getrolefromtoken(token)); // 返回用户角色,方便前端处理
return result.success(resultmap);
}
}六、第四步:实现 jwt 认证过滤器(核心拦截逻辑)
客户端登录成功后,后续请求会在 authorization 头中携带 token(格式:bearer eyjhbgcioijiuzi1nij9...)。我们需要自定义一个 过滤器,在请求到达接口前拦截 token,完成以下操作:
- 从请求头中提取 token;
- 验证 token 有效性;
- 从 token 中解析用户信息和角色;
- 将用户信息存入 spring security 上下文,让后续权限校验生效。
在 com.example.firstspringbootproject.filter 包下创建 jwtauthenticationfilter 类:
package com.example.firstspringbootproject.filter;
import com.example.firstspringbootproject.utils.jwtutil;
import jakarta.servlet.filterchain;
import jakarta.servlet.servletexception;
import jakarta.servlet.http.httpservletrequest;
import jakarta.servlet.http.httpservletresponse;
import org.springframework.beans.factory.annotation.autowired;
import org.springframework.security.authentication.usernamepasswordauthenticationtoken;
import org.springframework.security.core.context.securitycontextholder;
import org.springframework.security.core.userdetails.userdetails;
import org.springframework.security.core.userdetails.userdetailsservice;
import org.springframework.security.web.authentication.webauthenticationdetailssource;
import org.springframework.stereotype.component;
import org.springframework.web.filter.onceperrequestfilter;
import java.io.ioexception;
// 自定义 jwt 认证过滤器:每次请求都会执行(onceperrequestfilter 确保一次请求只执行一次)
@component
public class jwtauthenticationfilter extends onceperrequestfilter {
@autowired
private jwtutil jwtutil;
@autowired
private userdetailsservice userdetailsservice; // 之前实现的用户查询服务
@override
protected void dofilterinternal(httpservletrequest request,
httpservletresponse response,
filterchain filterchain
) throws servletexception, ioexception {
try {
// 1. 从请求头中提取 token
string token = jwtutil.extracttokenfromheader(
request.getheader(jwtutil.gettokenheader())
);
// 2. 验证 token:非空 + 有效
if (token != null) {
// 2.1 从 token 中获取用户名
string username = jwtutil.getusernamefromtoken(token);
// 2.2 若用户名存在,且 spring security 上下文未存储用户信息(未登录)
if (username != null && securitycontextholder.getcontext().getauthentication() == null) {
// 2.3 从数据库查询用户完整信息(userdetails)
userdetails userdetails = userdetailsservice.loaduserbyusername(username);
// 2.4 验证 token 有效性(未过期 + 用户名匹配)
if (jwtutil.validatetoken(token, userdetails)) {
// 3. 构建认证对象,存入 spring security 上下文
usernamepasswordauthenticationtoken authentication =
new usernamepasswordauthenticationtoken(
userdetails, // 用户信息
null, // 密码(已验证,无需存储)
userdetails.getauthorities() // 用户权限(角色)
);
// 设置请求详情(如 ip、会话 id)
authentication.setdetails(
new webauthenticationdetailssource().builddetails(request)
);
// 将认证对象存入上下文:后续接口权限校验会从这里获取用户信息
securitycontextholder.getcontext().setauthentication(authentication);
}
}
}
} catch (exception e) {
// token 验证失败(如过期、篡改),打印日志但不阻断请求(后续会返回 401)
logger.error("jwt token 验证失败:" + e.getmessage());
}
// 4. 继续执行过滤器链(让请求到达后续接口或过滤器)
filterchain.dofilter(request, response);
}
}七、第五步:重构 securityconfig(适配 jwt)
之前的 securityconfig 基于 http basic 登录,现在需要修改为 jwt 无状态登录,核心调整点:
- 关闭 session(无状态登录不需要 session);
- 放行登录接口(
/api/login); - 将 jwt 过滤器加入过滤器链;
- 移除 http basic 配置,保留统一异常处理。
修改 com.example.firstspringbootproject.config.securityconfig 类:
package com.example.firstspringbootproject.config;
import com.example.firstspringbootproject.common.result;
import com.example.firstspringbootproject.entity.sysuser;
import com.example.firstspringbootproject.filter.jwtauthenticationfilter;
import com.example.firstspringbootproject.mapper.sysusermapper;
import com.fasterxml.jackson.databind.objectmapper;
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.authentication.dao.daoauthenticationprovider;
import org.springframework.security.config.annotation.authentication.configuration.authenticationconfiguration;
import org.springframework.security.config.annotation.method.configuration.enablemethodsecurity;
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.core.userdetails.userdetailsservice;
import org.springframework.security.core.userdetails.usernamenotfoundexception;
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
@enablemethodsecurity // 启用方法级权限控制(如 @preauthorize("hasrole('admin')"))
public class securityconfig {
@autowired
private sysusermapper sysusermapper;
@autowired
private objectmapper objectmapper;
@autowired
private jwtauthenticationfilter jwtauthenticationfilter; // 自定义 jwt 过滤器
// 1. 密码编码器(不变)
@bean
public passwordencoder passwordencoder() {
return new bcryptpasswordencoder();
}
// 2. 用户详情服务(不变)
@bean
public userdetailsservice userdetailsservice() {
return username -> {
sysuser sysuser = sysusermapper.findbyusername(username);
if (sysuser == null) {
throw new usernamenotfoundexception("用户不存在: " + username);
}
return sysuser;
};
}
// 3. 认证提供者(不变)
@bean
public daoauthenticationprovider authenticationprovider() {
daoauthenticationprovider provider = new daoauthenticationprovider();
provider.setuserdetailsservice(userdetailsservice());
provider.setpasswordencoder(passwordencoder());
return provider;
}
// 4. 认证管理器(不变)
@bean
public authenticationmanager authenticationmanager(authenticationconfiguration config) throws exception {
return config.getauthenticationmanager();
}
// 5. 核心规则配置(重点修改:适配 jwt)
@bean
public securityfilterchain securityfilterchain(httpsecurity http) throws exception {
http
// 1. 关闭 csrf(前后端分离必须关)
.csrf(csrf -> csrf.disable())
// 2. 关闭 session(无状态登录核心:不创建和使用 session)
.sessionmanagement(session -> session
.sessioncreationpolicy(sessioncreationpolicy.stateless)
)
// 3. 配置接口访问规则(调整放行接口)
.authorizehttprequests(auth -> auth
// ① 放行:接口文档(knife4j)、登录接口
.requestmatchers("/doc.html", "/webjars/**", "/v3/api-docs/**", "/api/login").permitall()
// ② 管理员接口:仅 admin 可访问
.requestmatchers("/api/user/all").hasrole("admin")
// ③ 普通用户接口:user/admin 可访问
.requestmatchers("/api/user/**", "/api/product/**").hasanyrole("user", "admin")
// ④ 其他接口:必须登录(token 有效)
.anyrequest().authenticated()
)
// 4. 加入 jwt 过滤器:在 usernamepasswordauthenticationfilter 之前执行
// (先验证 token,再执行后续认证逻辑)
.addfilterbefore(jwtauthenticationfilter, usernamepasswordauthenticationfilter.class)
// 5. 统一异常处理(不变:未登录返回 401,权限不足返回 403)
.exceptionhandling(ex -> ex
.authenticationentrypoint((request, response, authexception) -> {
response.setcontenttype("application/json;charset=utf-8");
result<void> result = result.error(401, "未登录或 token 已过期,请重新登录");
response.getwriter().write(objectmapper.writevalueasstring(result));
})
.accessdeniedhandler((request, response, accessdeniedexception) -> {
response.setcontenttype("application/json;charset=utf-8");
result<void> result = result.error(403, "权限不足,无法访问");
response.getwriter().write(objectmapper.writevalueasstring(result));
})
);
// 关联认证提供者
http.authenticationprovider(authenticationprovider());
return http.build();
}
}八、第六步:测试 jwt 无状态登录流程
启动项目,用 apifox 测试完整流程,验证无状态登录和权限控制是否生效:
1. 测试 1:调用登录接口,获取 token
- 请求地址:
http://localhost:8080/api/login - 请求方法:post
- 请求体(json):
{
"username": "admin",
"password": "123456"
}- 响应结果(成功):
{
"code": 200,
"msg": "success",
"data": {
"role": "role_admin",
"expiration": "token 有效期 2 小时,请及时刷新",
"token": "eyjhbgcioijiuzuxmij9.eyjyb2xlcyi6wyjst0xfx0fetuloil0sinn1yii6imfkbwluiiwiawf0ijoxnzyzndgxmdk0lcjlehaioje3njm0odgyotr9.hsxbctlqo5tuai-knfm2sh4nvmehizoslchkvg86jdc-u7-eztskpkf0r4g0dcatmv3efcjuu4pcewddnniq2a"
}
}复制返回的 token,后续请求会用到。
2. 测试 2:携带 token 访问管理员接口
- 请求地址:
http://localhost:8080/api/user/all(需 admin 角色) - 请求方法:get
- 请求头:添加
authorization: bearer eyjhbgcioijiuzuxmij9.eyjyb2xlcyi6wyjst0xfx0fetuloil0sinn1yii6imfkbwluiiwiawf0ijoxnzyzndgxmdk0lcjlehaioje3njm0odgyotr9.hsxbctlqo5tuai-knfm2sh4nvmehizoslchkvg86jdc-u7-eztskpkf0r4g0dcatmv3efcjuu4pcewddnniq2a(注意bearer后有空格) - 响应结果(成功):
{
"code": 200,
"msg": "success",
"data": [
{
"id": 1,
"name": "小明",
"age": 20,
"phone": "13800138000"
},
{
"id": 2,
"name": "小红",
"age": 19,
"phone": "13900139000"
},
{
"id": 3,
"name": "小李",
"age": 22,
"phone": "13700137000"
}
]
}3. 测试 3:普通用户 token 访问管理员接口(权限不足)
- 先用
zhangsan(密码 123456)调用登录接口,获取普通用户 token; - 携带普通用户 token 访问
http://localhost:8080/api/user/all; - 响应结果(失败):
{
"code": 403,
"msg": "权限不足,无法访问",
"data": null
}4. 测试 4:token 过期(模拟)
- 修改
application.yml中jwt.expiration为1000(1 秒过期); - 重新登录获取 token,1 秒后携带 token 访问接口;
- 响应结果(失败):
{
"code": 401,
"msg": "未登录或 token 已过期,请重新登录",
"data": null
}九、常见问题与优化建议
1. 问题:token 被盗用怎么办?
- 原因:jwt token 一旦生成,在过期前无法主动作废;
- 解决方案:
- 缩短 token 过期时间(如 30 分钟),同时实现 token 刷新接口(用旧 token 换取新 token,避免频繁登录);
- 维护一个 黑名单(如 redis),用户登出或 token 被盗时,将 token 加入黑名单,验证时先检查是否在黑名单中。
2. 问题:密钥(secret)泄露怎么办?
- 原因:密钥是 jwt 安全的核心,泄露后攻击者可伪造 token;
- 解决方案:
- 生产环境中,密钥通过 环境变量 或 配置中心 注入,不写入代码或配置文件;
- 使用 非对称加密算法(如 rs256),用私钥生成 token,公钥验证 token,私钥严格保密。
## 十、总结:jwt 无状态登录的核心价值
通过本文的改造,我们实现了 spring boot + spring security + jwt 的无状态登录,核心优势总结如下:
- 分布式友好:服务器无需存储 session,多台服务器可直接共用 token 验证逻辑;
- 前后端分离适配:通过请求头携带 token,无需依赖 cookie,适配移动端、小程序等多端场景;
- 安全性提升:token 包含过期时间和加密签名,避免身份信息被篡改;
- 扩展性强:token 可自定义存储用户角色、权限等信息,减少数据库查询次数。
至此,恭喜你已具备初级程序员开发水平,可以试着投简历找工作啦。
到此这篇关于spring boot基于 jwt 优化 spring security 无状态登录实战指南的文章就介绍到这了,更多相关spring boot jwt spring security 无状态登录内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论