jakarta.annotation.security.*
有个注解@RolesAllowed
,专门用来做 RBAC (Role-base Access Control) 的。我们可以把它注解RESTful层的方法来限制访问,如POST
、DELETE
、PUT
方法的一些危险操作:
@Path("/user")
@Produces(MediaType.APPLICATION_JSON)
public class UserResource {
@Inject
private UserService srv;
@POST
@Consumes(MediaType.APPLICATION_JSON)
@RolesAllowed({"User", "Admin"})
public void add(User user) {
srv.persist(user);
}
}
给UserResource#add
方法注解后,每次请求这个方法都会检查用户传过来的JWT(JSON Web Token)加密令牌的groups
字段是否包含User
或者Admin
role
什么是JWT?
JWT是一种加密身份验证技术,是一串Base64URL编码,它大概长这样:
JWT 的结构
JWT 以.
分隔,分为三个字段,分别是:
- header(头部)
- payload(负载)
- signature(签名)
<header>.<payload>.<signature>
头部结构
(解密后的)
{
"typ": "JWT",
"alg": "RS256"
}
typ
(type):类型
alg
(algorithm):指示加密算法
负载结构
(解密后的)是最重要的字段,以键值对形式承载着用户数据
{
"iss": "https://example.com/issuer",
"iat": 1725505766,
"exp": 1725506066,
"jti": "325bcbfc-dd0f-4217-8cb8-ebf5090b0c5d",
... 还有一些非标准的字段
"upn": "jdoe@quarkus.io", 这个是非标准字段
}
payload有以下字段
iss
(issuer):签发者,一般为网站URL
sub
(subject):主题,一般是UUID
aud
(audience):接收者
nbf
(notBefore):生效时间,令牌在此之前不可用
iat
(issuedAt):签发时间
exp
(expiresAt):过期时间
jti
(jwtId):令牌的唯一标识符,一般用于防止重放攻击。
upn
(userPrincipalName):非标准字段,Jakarta EE定义,用来传输用户信息(如用户名),可以用安全上下文
fn(@Context SecurityContext ctx) { ctx.getUserPrincipal().getName() }
获取
签名结构
签名用于验证消息的发送方和消息的完整性。没啥需了解的。
回过来,@RolesAllowed
会检查传过来的JWT的groups
,如果不包含,返回Unauthorized
譬如:
@RequestScoped
@DeclareRoles({"Admin", "User", "Guest"})
@Path("/secured")
public class SecuredResource {
@GET
@RolesAllowed({"Admin", "User"}) // <-- 看这里
@Produces(MediaType.TEXT_PLAIN)
public String secured() {
return "Authenticated!";
}
}
如果我的请求的JWT payload为:
{
...
groups: ["Guest"]
}
请求这个接口就会报错 401 Unauthorized
如果为:
{
...
groups: ["User"]
}
就可以访问。
我用的是Quarkus框架的quarkus-smallrye-jwt
和quarkus-smallrye-jwt-build
实现。这个实现指定的是JWT的groups
字段,我不知道这是不是Jakarta EE规范中的一部分还是实现自定义。
怎么通过HTTP传递令牌?
包含在HTTP请求中:JWT令牌可以通过HTTP请求传递:
Authorization: Bearer <JWT>
自动添加请求头:我们可以在前端设置全局拦截器,这样子所有的请求都会自动发送
// JQuery
$.ajaxSetup({
beforeSend: xhr => {
const token = localStorage.getItem('authToken')
if (token) {
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
}
}
})
// Axios.js
axios.interceptors.request.use(config => {
const token = localStorage.getItem('authToken');
if (token) {
config.headers.Authorization = 'Bearer ' + token;
}
return config;
}, err => Promise.reject(err));
如何向客户端发送并使其保存令牌?
向用户发送了JWT,怎么让客户端储存?
- 保存在
localStorage
:不容易收到CSRF,但容易受到XSS攻击 - 保存在
sessionStorage
:关闭浏览器就会清除,容易受到XSS攻击 - 保存在
cookies
:可以设置SameSite为strict以防止CSRF,httpOnly为true防止XSS——最推荐
import jakarta.ws.rs.core.NewCookie;
import java.util.concurrent.TimeUnit;
public class Cookies {
public static NewCookie withToken(String name, String token) {
return new NewCookie.Builder(name).value(token)
.expiry(TimeUnit.DAYS.toSeconds(7))
.httpOnly(true)
.setPath("/")
.sameSite(NewCookie.SameSiteStrict)
.build();
}
}
@Path("/xxx")
@Produces(MediaType.APPLICATION_JSON)
public class XXXResource {
public Response validate() {
return Response.ok()
.cookie(Cookies.withToken("jwt", Tokens.withRoles("User")))
;
}
}
生成一段带有Admin, User权限的JWT(Quarkus环境):
/src/main/resources/application.properties
配置
mp.jwt.verify.publickey.location=publicKey.pem
mp.jwt.verify.issuer=https://example.com/issuer
smallrye.jwt.sign.key.location=privateKey.pem
import io.smallrye.jwt.build.Jwt;
@Test
public void echoJwt() {
System.out.println(generateToken("User", "Admin"));
}
public static String generateTokem(String... roles) {
return Jwt.issuer("https://example.com/issuer")
.upn("jdoe@quarkus.io")
.groups(List.of(roles))
.sign();
}
然后就可以测试了
curl -X POST localhost:8080/user -H "Content-Type: application/json" -H "Authorization: Bearer <你的jwt>"
JWT 令牌用法
设计一个POST /login
:一旦验证通过,如果没有JWT,就向客服端发送JWT保存在Cookies中;如果有,直接从Cookie中抽取。
import io.smallrye.jwt.build.Jwt;
public class JWT {
public static String withRole(String... roles) {
return Jwt.issuer("https://example.com/issuer")
.upn("jdoe@quarkus.io")
.groups(roles)
.sign();
}
}
@Path("/login")
public class LoginResource {
private static final String COOKIE_JWT_KEY = "jwt";
@Inject ...
@POST
@Consumes(MediaType.APPLICATION_JSON)
public Response login(@Context SecurityContext ctx,
@CookieParam(COOKIE_JWT_KEY) String jwt,
User user) {
if (jwt == null) {
return Response.noContent()
.location("登入界面")
.cookie(new NewCookie.Builder(COOKIE_JWT_KEY).value(JWT.withRole("User", "Admin")))
.build();
} else if (validate(jwt)) {
return Response.noContent()
location("登入界面");
.build();
} else {
throw new ForbiddenException();
}
// 这样获取 JWT upn 声明
ctx.getUserPrincipal() // -> java.security.Principal
.getName(); // -> String
}
}
如何获取
完整版看 https://quarkus.io/guides/security-jwt
Quarkus 集成
Quarkus CLI
quarkus extension add quarkus-smallrye-jwt
quarkus extension add quarkus-smallrye-build
/src/main/resources/application.properties
mp.jwt.verify.publickey.location=publicKey.pem
mp.jwt.verify.issuer=https://example.com/issuer
smallrye.jwt.sign.key.location=privateKey.pem
/src/main/resources/publicKey.pem
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlivFI8qB4D0y2jy0CfEq
Fyy46R0o7S8TKpsx5xbHKoU1VWg6QkQm+ntyIv1p4kE1sPEQO73+HY8+Bzs75XwR
TYL1BmR1w8J5hmjVWjc6R2BTBGAYRPFRhor3kpM6ni2SPmNNhurEAHw7TaqszP5e
UF/F9+KEBWkwVta+PZ37bwqSE4sCb1soZFrVz/UT/LF4tYpuVYt3YbqToZ3pZOZ9
AX2o1GCG3xwOjkc4x0W7ezbQZdC9iftPxVHR8irOijJRRjcPDtA6vPKpzLl6CyYn
sIYPd99ltwxTHjr3npfv/3Lw50bAkbT4HeLFxTx4flEoZLKO/g0bAoV2uqBhkA9x
nQIDAQAB
-----END PUBLIC KEY-----
/src/main/resources/privateKey.pem
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCWK8UjyoHgPTLa
PLQJ8SoXLLjpHSjtLxMqmzHnFscqhTVVaDpCRCb6e3Ii/WniQTWw8RA7vf4djz4H
OzvlfBFNgvUGZHXDwnmGaNVaNzpHYFMEYBhE8VGGiveSkzqeLZI+Y02G6sQAfDtN
qqzM/l5QX8X34oQFaTBW1r49nftvCpITiwJvWyhkWtXP9RP8sXi1im5Vi3dhupOh
nelk5n0BfajUYIbfHA6ORzjHRbt7NtBl0L2J+0/FUdHyKs6KMlFGNw8O0Dq88qnM
uXoLJiewhg9332W3DFMeOveel+//cvDnRsCRtPgd4sXFPHh+UShkso7+DRsChXa6
oGGQD3GdAgMBAAECggEAAjfTSZwMHwvIXIDZB+yP+pemg4ryt84iMlbofclQV8hv
6TsI4UGwcbKxFOM5VSYxbNOisb80qasb929gixsyBjsQ8284bhPJR7r0q8h1C+jY
URA6S4pk8d/LmFakXwG9Tz6YPo3pJziuh48lzkFTk0xW2Dp4SLwtAptZY/+ZXyJ6
96QXDrZKSSM99Jh9s7a0ST66WoxSS0UC51ak+Keb0KJ1jz4bIJ2C3r4rYlSu4hHB
Y73GfkWORtQuyUDa9yDOem0/z0nr6pp+pBSXPLHADsqvZiIhxD/O0Xk5I6/zVHB3
zuoQqLERk0WvA8FXz2o8AYwcQRY2g30eX9kU4uDQAQKBgQDmf7KGImUGitsEPepF
KH5yLWYWqghHx6wfV+fdbBxoqn9WlwcQ7JbynIiVx8MX8/1lLCCe8v41ypu/eLtP
iY1ev2IKdrUStvYRSsFigRkuPHUo1ajsGHQd+ucTDf58mn7kRLW1JGMeGxo/t32B
m96Af6AiPWPEJuVfgGV0iwg+HQKBgQCmyPzL9M2rhYZn1AozRUguvlpmJHU2DpqS
34Q+7x2Ghf7MgBUhqE0t3FAOxEC7IYBwHmeYOvFR8ZkVRKNF4gbnF9RtLdz0DMEG
5qsMnvJUSQbNB1yVjUCnDAtElqiFRlQ/k0LgYkjKDY7LfciZl9uJRl0OSYeX/qG2
tRW09tOpgQKBgBSGkpM3RN/MRayfBtmZvYjVWh3yjkI2GbHA1jj1g6IebLB9SnfL
WbXJErCj1U+wvoPf5hfBc7m+jRgD3Eo86YXibQyZfY5pFIh9q7Ll5CQl5hj4zc4Y
b16sFR+xQ1Q9Pcd+BuBWmSz5JOE/qcF869dthgkGhnfVLt/OQzqZluZRAoGAXQ09
nT0TkmKIvlza5Af/YbTqEpq8mlBDhTYXPlWCD4+qvMWpBII1rSSBtftgcgca9XLB
MXmRMbqtQeRtg4u7dishZVh1MeP7vbHsNLppUQT9Ol6lFPsd2xUpJDc6BkFat62d
Xjr3iWNPC9E9nhPPdCNBv7reX7q81obpeXFMXgECgYEAmk2Qlus3OV0tfoNRqNpe
Mb0teduf2+h3xaI1XDIzPVtZF35ELY/RkAHlmWRT4PCdR0zXDidE67L6XdJyecSt
FdOUH8z5qUraVVebRFvJqf/oGsXc4+ex1ZKUTbY0wqY1y9E39yvB3MaTmZFuuqk8
f3cg+fr8aou7pr9SHhJlZCU=
-----END PRIVATE KEY-----
生成私钥和公钥
# 生成一个长度为 2048 的私钥,2048 是业内推荐长度
openssl genrsa -out privateKey.pem 2048
# 从私钥中提取公钥
openssl rsa -pubout -in privateKey.pem -out publicKey.pem