背景
软件系统几乎都会遇到安全问题:漏洞、恶意代码、SQL 注入、XSS、跨站请求伪造、第三方组件风险,甚至是业务逻辑本身的缺陷。要把这些风险压下去,认证和权限控制就成了系统设计里绕不开的一环。
在实际项目里,权限管理通常会落到两类模型上:
- 基于角色的权限控制(RBAC):先定义角色,再把权限分配给角色,最后把用户映射到角色。
- 基于属性的权限控制(ABAC):根据用户属性、环境属性、资源属性等动态判断是否允许访问。
两者没有绝对的优劣。RBAC 更容易落地,ABAC 更灵活,很多系统会把它们结合起来用。
Web 场景里常见的安全问题
Web 应用最常见的风险,基本离不开这几类:
- XSS(跨站脚本攻击)
- SQL 注入
- 信息泄露
- 文件上传漏洞
- CSRF(跨站请求伪造)
除此之外,还有拒绝服务攻击、任意文件下载、恶意脚本传播等问题。真正做系统时,认证只是起点,权限控制、输入校验、审计和最小权限原则,往往要一起上。
核心概念
用户角色
角色是权限管理里最直观的一层抽象。比如管理员、普通用户、运营人员、审核员,不同角色拥有不同的操作范围。角色的好处是简单清晰,权限模型也容易维护。
用户属性
属性关注的是'这个用户是什么样的人',例如身份证号、手机号、邮箱、部门、职位、IP 地址、地理位置等。属性信息常常参与登录认证、风控判断和细粒度授权。
URL 访问控制
URL 访问控制通常通过访问控制列表(ACL)实现,用来限制某个 URL 是否允许访问。它适合做最基础的入口拦截,比如静态资源放行、管理后台限制访问、某些接口仅允许特定角色调用。
会话管理
会话管理解决的是'如何在多次请求之间保持同一个登录状态'。用户登录成功后,服务端需要保存或识别会话标识,这样后续请求才能继续确认用户身份,避免每次都重新登录。
漏洞扫描
漏洞扫描的作用是尽早发现系统里已知的安全风险,比如弱口令、过期依赖、配置错误、暴露接口等。发现问题之后,升级版本、收紧权限、修复配置,都是常规动作。
单点登录
单点登录(SSO)适合多应用协同场景。用户只登录一次,就能在多个系统间复用认证状态。对于企业内部系统来说,这种方式既省事,也能减少账号体系分裂带来的安全隐患。
API 调用权限管理
API 权限管理通常以密钥、Token 或签名机制为基础,用来控制谁能调用什么接口、调用频率是多少、能不能访问敏感资源。对开放平台、微服务和内部接口治理来说,这一层非常关键。
RBAC 与 ABAC 的基本思路
RBAC 的思路很直接:先建角色,再给角色赋权,最后把用户放进对应角色里。用户访问资源时,系统检查当前角色是否具备相应权限。这个模型的优点是直观、稳定、易维护,也最符合大多数业务系统的习惯。
它的执行路径通常是这样:
- 创建角色,并定义每个角色拥有的权限。
- 把用户分配到一个或多个角色中。
- 用户访问资源时,系统检查角色是否匹配。
- 根据角色决定页面展示和功能可见性。
RBAC 的问题也很现实:当业务复杂起来后,角色会越来越多,授权和回收权限都可能变得琐碎。
ABAC 则更灵活。它不会只看'你是谁',而是结合用户属性、资源属性和上下文条件一起判断。例如,同一个用户在公司内网可以访问某些接口,到了外网就不行;或者某个部门的员工只能访问自己部门的数据。
它的执行路径大致是:
- 定义属性类型及其取值范围。
- 将用户、资源、环境信息纳入规则。
- 请求到达时,按属性组合判断是否授权。
- 当属性或业务规则变化时,更新策略。
ABAC 的弹性更大,但规则也更复杂,维护成本通常高于 RBAC。很多企业内部系统会用 ABAC 做精细化控制,而面向公网的业务系统则常常以 RBAC 为主。
Spring Security 中的权限控制示例
Spring Security 提供了比较完整的认证与授权能力。对于 Web 项目来说,常见做法是先通过安全过滤器链拦截请求,再根据 URL、角色或方法注解完成访问控制。
配置 URL 级别的访问控制
下面这个配置展示了典型的 URL 拦截思路:静态资源放行,根路径限制为管理员访问,其他路径按业务需要继续配置。
<bean id="filterChainProxy" class="org.springframework.security.web.FilterChainProxy">
<sec:http>
<sec:intercept-url pattern="/resources/**" access="permitAll"/>
<sec:intercept-url pattern="/" access="hasRole('ROLE_ADMIN')"/>
<sec:intercept-url pattern="/*" />
<sec:http-basic entry-point-ref="authenticationEntryPoint"/>
</sec:http>
</bean>
这里需要注意一点:intercept-url 的顺序很重要。匹配规则是按顺序生效的,越具体的规则越应该放在前面,否则很容易被更宽泛的规则覆盖。
使用方法注解控制权限
除了 URL 拦截,Spring Security 也常用方法级注解做细粒度授权。这样做的好处是,权限规则可以直接贴近业务代码,维护起来更直观。
@RestController
public class MyController {
@RequestMapping("/admin")
@PreAuthorize("hasAuthority('permission:accessAdminPage')")
public String admin() {
return "Welcome Admin!";
}
@RequestMapping("/user")
@RolesAllowed({"ROLE_USER", "ROLE_MANAGER"})
public String user() {
return "Hello User";
}
}
@PreAuthorize 更适合表达复杂权限表达式,而 @RolesAllowed 更偏向角色判断。两者并不冲突,关键在于你想表达的是'某个具体权限'还是'某个角色集合'。
如果用户没有满足条件,Spring Security 会直接返回 403 Forbidden。这个行为在后端权限控制里很常见,也很清晰。
Spring Security 与 JWT 集成
在前后端分离或多服务协作的架构里,JWT 是很常见的认证方案。它的优势是无状态、可携带信息、便于跨服务传递身份。
这里以 RSA 签名思路为例说明整体流程:服务端签发 JWT,客户端在后续请求中携带 Token,服务端校验 Token 后再决定是否放行。
服务端依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
生成和解析 JWT
下面这个 JwtConfig 负责生成 Token 和解析 Token。Token 里包含用户名、签发时间、过期时间等信息,签名后交给客户端保存。
import io.jsonwebtoken.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class JwtConfig {
@Value("${jwt.secret}")
private String secret;
public String generateToken(String username){
Map<String, Object> map = new HashMap<>();
map.put("username", username);
Date now = new Date();
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, 10);
Date expirationTime = calendar.getTime();
return Jwts.builder().setHeaderParam("typ", "JWT").setClaims(map).setIssuer("auth.com").setSubject("subject")
.setAudience("audience").setId("id").setIssuedAt(now).setNotBefore(now)
.setExpiration(expirationTime).signWith(SignatureAlgorithm.HS512, this.secret).compact();
}
public String getUsernameFromToken(String token){
Claims claims = Jwts.parser().setSigningKey(this.secret).parseClaimsJws(token).getBody();
return claims.getSubject();
}
@Bean
public JwtTokenProvider getTokenProvider(){
return new JwtTokenProvider();
}
}
这里有个细节值得留意:getUsernameFromToken 实际读取的是 subject,而 generateToken 里把用户名放进了 claims。如果你打算通过 subject 取用户名,生成时也要一致地写入;否则后续解析出来的值可能并不是你预期的用户名。
从请求中提取 JWT
JwtTokenProvider 的作用是从请求头里取出 Token,再交给 Spring Security 去完成认证。
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
@Component
public class JwtTokenProvider{
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private JwtConfig config;
public Authentication getAuthentication(HttpServletRequest request) throws IOException, ExpiredJwtException, UnsupportedJwtException, MalformedJwtException {
final String authorizationHeader = request.getHeader("Authorization");
if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
throw new RuntimeException("JWT Token not found");
}
final String token = authorizationHeader.substring(7);
final String username = config.getUsernameFromToken(token);
return userDetailsService.loadUserByUsername(username);
}
}
实际项目里,一般还会补上过期校验、签名校验失败处理和异常映射,否则前端收到的错误会比较粗糙。这个例子保留了核心链路,思路是清楚的:拿到 Token、解析出用户名、再加载用户详情交给 Spring Security 处理。
前端如何携带 JWT
前端通常会在请求拦截器里自动加上 Authorization 头。这样业务代码不需要每次手工拼接 Token,请求层就能把认证信息统一带上。
import axios from 'axios';
class AxiosAuthPlugin {
constructor(token) {
this._token = `Bearer ${token}`;
}
request(config) {
const headers = config.headers || {};
headers['Authorization'] = this._token;
config.headers = headers;
return config;
}
}
const instance = axios.create({
baseURL: process.env.VUE_APP_BASE_URL || '/',
timeout: parseInt(process.env.VUE_APP_TIMEOUT) || 10000
});
instance.interceptors.request.use((config) => {
let token = localStorage.getItem('token');
if (!token && JSON.stringify(config.data).includes('refresh')) {
return Promise.reject(new Error('No refresh token provided'));
} else {
token = token ? JSON.parse(token).access_token : '';
config.headers = {'Authorization': `${token}`};
return config;
}
}, error => {
return Promise.reject(error);
});
export default function createAxiosInstance() {
return instance;
}
这段代码的逻辑很典型:普通请求直接带上 access_token,刷新请求则单独处理。这样做能把'访问接口'和'续期 Token'这两条链路分开,失败时也更容易定位问题。
在 Vue 中调用时,通常会把登录、刷新和拉取数据的流程串起来:
import plugin from './AxiosAuthPlugin';
const instance = plugin.createAxiosInstance();
async login() {
try {
const res = await instance.post('/login', {username, password});
localStorage.setItem('token', JSON.stringify(res));
router.push('/');
} catch (err) {
console.log(`Login failed, message:${err}`);
}
},
async refreshToken() {
try {
const refreshToken = JSON.parse(localStorage.getItem('token')).refresh_token;
const res = await instance.post('/token/refresh', {refresh_token: refreshToken});
localStorage.setItem('token', JSON.stringify(res));
} catch (err) {
console.log(`Refresh token failed, message:${err}`);
localStorage.removeItem('token');
alert('Please log in again.');
router.push('/login');
}
},
async getData() {
try {
const res = await instance.get('/data');
return res.data;
} catch (err) {
console.log(`Get data failed, message:${err}`);
if (err.response && err.response.status === 401) {
await refreshToken();
const newData = await getData();
return newData;
}
throw err;
}
}
这里的关键点是:当接口返回 401 时,不要急着让用户重新登录,可以先尝试刷新 Token。只有刷新失败,再把用户引导回登录页。这样体验会顺很多。
实践中的一些提醒
JWT 和 Spring Security 结合起来很方便,但落地时别忽略这些问题:
- Token 不要存得太久,过期时间要合理。
- 解析和校验失败要统一处理,避免前端拿到一堆杂乱异常。
- 如果是敏感系统,优先考虑 HTTPS,避免 Token 在传输过程中被窃取。
- 浏览器本地存储并不是最安全的方案,生产环境里要结合业务风险谨慎选择。
- 权限模型不要一开始就设计得过重,先保证清晰可维护,再逐步细化。
小结
认证解决的是'你是谁',权限控制解决的是'你能做什么'。在 Java 体系里,Spring Security 提供了比较完整的支撑:既能做 URL 级别拦截,也能做方法级授权,还能和 JWT 这类无状态认证方案配合使用。实际项目里,RBAC 适合做主骨架,ABAC 适合补细节,二者结合起来,通常更容易在安全性和可维护性之间找到平衡。
