访问控制
QXJ 项目实现了基于 RBAC(基于 Django Group 的角色访问控制)+ ABAC(属性访问控制)的混合访问控制模型,支持时间规则、地理围栏、设备状态准入等多维度控制。
模型定位:从 CoAC 场景到 RBAC 授权
验收需求(FR_2_2)要求采用 CoAC(Cyberspace-oriented Access Control,面向网络空间的访问控制)模型: 将用户访问时的广义时态、接入位置、接入设备与网络环境等要素组合定义为「场景」, 实体不直接持有权限,而是「通过所处场景获得权限」。
QXJ 的工程落地方式是把场景拆成可管理的属性,挂到角色上:
- RBAC 是骨架:场景要素经关联表(role_time_rules、role_geofences)收敛为角色,角色决定可执行的操作范围;
- ABAC 是血肉:时间、位置、设备、IP 等属性在登录链路实时判定,同一角色在不同场景下决策结果不同;
- 场景判定只在登录链路执行,通过后签发令牌;令牌在后续每次请求中还要经受设备状态与令牌绑定校验, 形成「场景准入 → 会话持有 → 持续校验」的完整闭环。
访问控制架构
多维场景准入
IMPORTANT
QXJ 的 RBAC 基于 Django Group + Role 继承实现,没有独立 Permission 表。 角色 Role 继承自 Group,通过 user_roles 关联表绑定用户。 权限判断通过 IsAdmin / IsSuperUser / AllowAny 三级 permission class 完成, 配合 BlacklistJWTAuthentication 在认证层拦截。
1. 时间规则(TimeRule)
时间规则用于限制用户在特定时间段内访问系统。
规则模型
# apps/time_rules/models.py
from django.db import models
class TimeRule(models.Model):
"""时间规则(单表,角色通过 role_time_rules 关联)。"""
MODE_CHOICES = [('ALLOW', '允许(白名单)'), ('DENY', '禁止(黑名单)')]
WEEK_PARITY_CHOICES = [('', '不限'), ('odd', '仅单周'), ('even', '仅双周')]
rule_name = models.CharField(max_length=32, unique=True)
mode = models.CharField(max_length=8, choices=MODE_CHOICES, default='ALLOW')
start_time = models.TimeField()
end_time = models.TimeField()
weekdays = models.JSONField(default=list, blank=True) # 1-7(ISO 周)
month_days = models.JSONField(default=list, blank=True) # 1-31
months = models.JSONField(default=list, blank=True) # 1-12
week_parity = models.CharField(max_length=8, choices=WEEK_PARITY_CHOICES, default='', blank=True)
valid_from = models.DateField(blank=True, null=True)
valid_to = models.DateField(blank=True, null=True)
is_active = models.BooleanField(default=True)
class Meta:
db_table = 'time_rules_time_rule'2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
字段语义:周期字段留空数组 = 不约束该维度;end_time < start_time 表示跨夜; 角色关联表 RoleTimeRule(M2M),一条角色可挂多条规则。
规则匹配算法
# apps/time_rules/services.py(节选)
def check_user_time_rules(user, now=None):
"""apps/time_rules/services.py,DENY 优先策略。"""
if getattr(settings, 'SKIP_TIME_RULE_CHECK', False): # 联调用,生产不得开启
return True, ''
if user.is_superuser:
return True, ''
rules = get_user_time_rules(user) # 经 RoleTimeRule 取用户各角色的启用规则
if not rules:
return True, '' # 未配置任何启用规则 → 放行
deny_hit, allow_hit = False, False
for rule in rules:
if not time_rule_matches(rule, now):
continue
if rule.mode == 'DENY':
deny_hit = True
break # DENY 命中即拒绝
if rule.mode == 'ALLOW':
allow_hit = True
if deny_hit or not allow_hit:
return False, '当前不在允许的登录时间内'
return True, ''2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
time_rule_matches 对单条规则逐维度判断(任一维度留空即不约束,全部满足才命中): valid_from/valid_to → months → month_days → weekdays(date.isoweekday(), 周一=1…周日=7)→ week_parity(ISO 周序号奇偶)→ 时段(end < start 按跨夜处理)。
使用示例
# 创建时间规则:工作日 09:00-17:00
rule = TimeRule.objects.create(
rule_name='工作日工作时间',
mode='ALLOW',
start_time=time(9, 0),
end_time=time(17, 0),
weekdays=[1, 2, 3, 4, 5], # 周一到周五(ISO 1-7)
)
RoleTimeRule.objects.create(role=teacher_role, time_rule=rule)
# 仅单周全天
rule = TimeRule.objects.create(
rule_name='单周访问', mode='ALLOW',
start_time=time(0, 0), end_time=time(23, 59),
week_parity='odd',
)
# 跨夜时段(end < start 即跨夜)
TimeRule.objects.create(
rule_name='夜间访问', mode='ALLOW',
start_time=time(22, 0), end_time=time(6, 0),
)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2. 地理围栏(Geofence)
地理围栏用于限制用户在特定地理位置范围内访问系统。
围栏模型
# apps/geofences/models.py
from django.db import models
class Geofence(models.Model):
"""地理围栏:中心点 + 半径的圆形区域。"""
COORD_SYSTEM_CHOICES = [
('WGS84', 'WGS84(GPS 原始)'),
('GCJ-02', 'GCJ-02(火星坐标)'),
('BD-09', 'BD-09(百度坐标)'),
]
location_name = models.CharField(max_length=32, unique=True)
longitude = models.CharField(max_length=32, blank=True, null=True) # 字符串保精度
latitude = models.CharField(max_length=32, blank=True, null=True)
coord_system = models.CharField(max_length=16, choices=COORD_SYSTEM_CHOICES, default='GCJ-02')
radius = models.FloatField(default=100.0) # 米
location = models.CharField(max_length=128, blank=True, null=True)
class Meta:
db_table = 'geofences_geofence'
class RoleGeofence(models.Model):
"""角色-围栏关联(unique_together: role, geofence)。"""
role = models.ForeignKey('roles.Role', on_delete=models.CASCADE, related_name='role_geofences')
geofence = models.ForeignKey('geofences.Geofence', on_delete=models.CASCADE, related_name='role_geofences')
class Meta:
db_table = 'role_geofences_role_geofence'
unique_together = ('role', 'geofence')2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
距离计算
# apps/geofences/services.py(节选)
def check_user_geofence(user, position: str):
"""返回 (allowed, reason)。设备登录必须带定位。"""
if getattr(settings, 'SKIP_GEOFENCE_CHECK', False): # 联调用,生产不得开启
return True, ''
if getattr(user, 'is_superuser', False):
return True, ''
geofences = get_user_geofences(user) # 用户各角色关联的围栏(去重)
if not geofences:
return True, '' # 未配置围栏限制 → 放行
coords = parse_position(position)
if coords is None:
return False, '定位信息无效' # 解析失败直接拒绝
lng, lat = coords
for gf in geofences:
distance = haversine_distance(lng, lat, float(gf.longitude), float(gf.latitude))
if distance <= float(gf.radius or 0):
return True, ''
return False, '当前位置不在允许登录的区域内'2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
要点:parse_position 按中国经纬度范围(经度 73–135、纬度 18–54)自动判别 lng,lat / lat,lng 顺序,都不匹配时按 a=lng,b=lat 兜底;距离用 Haversine (R = 6 371 000 m)。围栏本身没有 is_active 字段,不参与「启用/停用」过滤。
使用示例
# 创建地理围栏(init 数据 4 个,GCJ-02)
geofence1 = Geofence.objects.create(
location_name='信工所大范围',
longitude='116.300802', latitude='40.019863',
coord_system='GCJ-02', radius=197,
)
geofence2 = Geofence.objects.create(
location_name='宿舍',
longitude='116.301000', latitude='40.020000',
coord_system='GCJ-02', radius=100,
)
# 关联角色和围栏
RoleGeofence.objects.create(role=teacher_role, geofence=geofence1)
RoleGeofence.objects.create(role=teacher_role, geofence=geofence2)2
3
4
5
6
7
8
9
10
11
12
13
14
15
3. 设备准入(Device / DevicePublicKey)
设备不是「绑定到某个用户」的实体:设备独立注册(设备管理页 / import_keys), 用户与设备的关联在握手与登录时按设备状态 + 会话密钥建立。
实际模型
DeviceType:设备类型;Device:设备唯一标识 eqp_unique_identifier,MAC / 蓝牙 / 星闪地址, 状态 0禁用 / 1激活 / 2挂失 / 3年审中,年审字段;DevicePublicKey:该设备的 SM2 公钥(小写 hex),active 唯一,软删除。
设备校验环节
- 握手时:INIT 帧按帧内 IDA 查设备 + active DevicePublicKey;get_effective_device 对状态 0/2/3 直接拒绝;
- 每次鉴权时:BlacklistJWTAuthentication 的内层 HMAC 校验会再次确认设备有效性;
- 注册 / 导入:设备由管理员在设备管理页注册,或
tools/register_device/import_keys.py扫 files/*.dat 自动注册并导入公钥;没有用户自助「绑定 / 解绑」流程。
4. 用户级时段与 IP 白名单
除角色级 TimeRule / Geofence 外,用户模型还内置两类个人属性限制, 粒度比角色策略更细(适合对单个账号追加约束):
# apps/users/models.py(字段)
login_time_start = models.TimeField(blank=True, null=True) # 每日允许登录时段开始
login_time_end = models.TimeField(blank=True, null=True) # 每日允许登录时段结束
allowed_ips = models.TextField(blank=True, default='') # 允许登录 IP 白名单2
3
4
- 每日时段:
is_login_time_allowed()判断当前时刻;start > end 视为跨午夜窗口 (如 22:00–06:00);start/end 任一为空 = 不限制该维度; - IP 白名单:
is_login_ip_allowed()按行解析,支持精确 IP 与 CIDR 网段 (如192.168.105.0/24),允许#注释与空行;非法条目不阻断、自动跳过; 白名单为空 = 不限制; - 两类限制在
LoginSerializer中执行,超管豁免;拒绝文案统一为 「当前不在允许的登录时段内」/「当前网络环境不允许登录」,不回显具体策略, 避免向探测者泄露规则细节。
5. 登录失败临时锁定
LoginSerializer 内置在线爆破防护,状态由「数据库计数 + Redis 锁」共同维护:
| 要素 | 口径 |
|---|---|
| 计数方式 | 密码每错一次,failed_attempts + 1(持久化在用户表) |
| 锁定阈值 | 连续失败 5 次(MAX_FAILED_ATTEMPTS) |
| 锁定时长 | 900 秒(15 分钟,LOGIN_LOCKOUT_SECONDS),到期自动恢复 |
| 超管策略 | 同样适用——豁免会让最高价值账号暴露于在线爆破;临时退避不会永久锁死超管 |
| 成功恢复 | 密码正确即清零计数、删除锁定键 |
刻意不做「永久冻结」:永久锁定会被攻击者反过来当作拒绝服务武器 (故意输错密码即可锁死任意账号),短时退避 + 自动恢复在安全性与可用性之间取平衡。
6. 认证强度增强
- 双通道验证码:登录短信验证码支持手机与邮箱两个通道,任一校验通过即可; 验证码一次性消费,登录成功后主动清除缓存;
- 发送频率控制:同一手机号 60 秒内只能发送 1 次(
SMS_PHONE_INTERVAL), IP 维度默认限流 5 次/分钟(THROTTLE_SMS_RATE); - 人机验证:
CAPTCHA_ENABLED=true时启用阿里云验证码 2.0,支持两种放行方式—— 请求携带验证参数内联实时校验,或凭 JSBridge 预验证票据(默认 10 分钟内有效)放行; 验证服务 IP 维度限流默认 20 次/分钟; - 登录入口开关:
ALLOW_JSON_LOGIN控制 JSON 明文登录(建议仅开发/测试开启),ALLOW_DEVICE_LOGIN控制专用设备加密登录。
访问控制流程
权限系统
RBAC 模型(基于 Django Group)
QXJ 没有自定义 Permission 表:Role 继承 Django Group(增加 role_type: 0管理员/1普通/2访客),UserRole 关联用户与角色,其 signals 按角色自动同步 is_staff / is_superuser。Django 原生权限(Group.permissions)仍可用,授权主体 是角色而非用户。
权限检查(实际口径)
- DRF 默认权限
IsAdmin(is_staff 或 is_superuser),高敏操作(删用户、密钥轮换等) 用IsSuperUser,公开接口显式AllowAny;公共权限类在common/permissions.py; - 认证类
BlacklistJWTAuthentication:验签 + 黑名单/撤销 + 账户有效 + tv 一致 + 内层 HMAC(含设备有效性),任一不过直接 401; - 业务级限制(时间规则、围栏)在登录链路执行,超管豁免,DENY 优先。
令牌撤销体系(jti / sid / tv)
QXJ 自研 SM2 JWT(不使用 SimpleJWT 的令牌后端),设计了三个粒度的撤销机制, 登出、改密、管理员强制下线等场景都能即时生效,不必等待令牌自然过期:
| 粒度 | 载荷字段 | 机制 | 典型场景 |
|---|---|---|---|
| 单令牌 | jti | 加入黑名单(Redis,剩余有效期内有效,最小 TTL 60 秒) | 登出时拉黑当前 access / refresh |
| 登录会话 | sid | 会话级撤销标记,同一次登录的 access/refresh 共享 sid | 管理员按会话强制下线 |
| 用户全量 | tv(令牌版本号) | 用户改密 / 强制下线(revoke_all_tokens())使 token_version 递增,旧令牌 tv 不一致即失效 | 修改密码、账号安全事件后全部下线 |
鉴权链路按「jti 黑名单 → sid 会话撤销 → tv 版本一致性」顺序检查,任一不过直接 401。
令牌与设备绑定(内层 SM3-HMAC)
仅有外层 SM2 签名时,令牌一旦泄露(如被恶意程序读出)仍可在任意设备上使用。 QXJ 在令牌 custom 字段中增加一层用设备会话密钥计算的 SM3-HMAC, 把令牌与「协商出该密钥的那台设备」绑定:
custom = {
"device_id": "<设备 UUID>",
"user_id": <用户 ID>,
"exp_s": <外层 exp 的副本>,
"hmac": SM3_HMAC(session_key,
device_id ‖ user_id(大端) ‖ exp_s(大端))
}2
3
4
5
6
7
校验要点(BlacklistJWTAuthentication._verify_custom_signature):
exp_s必须与外层exp一致,防止只改外层过期时间;- 设备令牌的
hmac必须非空;服务端按device_id取回握手会话密钥重算 HMAC, 使用compare_digest常量时间比对; - 比对同时再次确认设备当前仍有效(禁用/挂失/年审中即拒), 会话密钥过期则要求重新握手登录;
- 非设备登录(网页管理端,device_id 为空)hmac 必须为空,跳过内层校验。
安全增益:即使令牌被窃取,攻击者设备上没有同一把会话密钥,HMAC 无法通过; 设备挂失或会话密钥 TTL(7 天)过期后,令牌随之失效。 当 VERIFY_INNER_HMAC=true 时,系统强制所有令牌携带有效内层签名, 等价于「只允许专用设备接入,拒绝一切网页端直连」。
审计日志(security_logs)
审计在 security_logs app(不是 apps.audit):
SecurityLog模型:约 30 种 LogType(登录/登出/刷新/握手/CRUD/越权等), Result、Level,请求详情 JSON;敏感字段(密码、验证码、密钥等)在记录前过滤脱敏;SecurityLogMiddleware(security_logs/middleware.py)自动记录,工具函数在 security_logs/utils.py;- 管理端安全日志页(PC + 移动)支持按类型 / 结果 / 时间筛选与详情抽屉。
默认决策语义
各维度的默认策略并非统一「默认拒绝」,工程上按被保护对象的风险分级设计:
| 检查维度 | 未配置时的默认决策 | 说明 |
|---|---|---|
| 角色时间规则 | 放行(fail-open) | 用户没有任何启用规则 = 不受时间约束 |
| 地理围栏 | 放行(fail-open) | 用户没有关联围栏 = 不受位置约束 |
| 用户每日时段 / IP 白名单 | 放行(fail-open) | 字段为空 = 不限制 |
| 设备注册与状态 | 拒绝(fail-closed) | 未注册、状态非激活即拒绝,且没有跳过开关 |
| 会话密钥 / 内层 HMAC | 拒绝(fail-closed) | 无密钥、HMAC 为空即拒绝,密钥过期需重新握手 |
这种取舍的考虑是:时间/位置类业务策略属于「追加约束」,策略体系尚未配置时不应让全系统不可用; 而设备身份是密码学安全基线,任何不确定都必须拒绝。若要获得整体「默认拒绝」语义, 可开启 VERIFY_INNER_HMAC 强制设备准入,并为所有角色配齐 ALLOW 规则。
配置开关
环境变量
# .env
# ---- 业务策略开关(仅联调可临时开启)----
SKIP_TIME_RULE_CHECK=false # 跳过角色时间规则
SKIP_GEOFENCE_CHECK=false # 跳过地理围栏
# ---- 登录入口开关 ----
ALLOW_JSON_LOGIN=true # JSON 明文登录(生产建议 false)
ALLOW_DEVICE_LOGIN=true # 专用设备加密登录
# ---- 锁定与验证码 ----
LOGIN_LOCKOUT_SECONDS=900 # 失败锁定时长(秒)
SMS_PHONE_INTERVAL=60 # 同手机号发送间隔(秒)
CAPTCHA_ENABLED=false # 阿里云人机验证
WEB_LOGIN_SMS_BYPASS=false # 网页端跳过短信验证码(生产必须 false)
# ---- 强制设备接入 ----
VERIFY_INNER_HMAC=false # true:仅允许携带有效内层 HMAC 的设备令牌2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
设备状态校验(0禁用/2挂失/3年审中)没有跳过开关,贯穿握手与每次鉴权。 时间规则与围栏校验在对应服务中读取上述开关,跳过会记录 warning 日志。
最佳实践
1. 规则设计原则
- 最小权限原则:只授予完成工作所需的最小权限
- 职责分离:不同角色负责不同的功能
- 定期审查:定期审查和更新权限配置
2. 性能优化
- 时间规则与围栏只在登录链路执行,通过后签发 Token,后续请求不再重复计算;
- 握手会话密钥只存 Redis(
sm2:device_session_key:{device_id},TTL 7 天), 不落业务表,鉴权时直接读取; - 每次鉴权的设备/黑名单查询是索引命中的小查询,无需额外缓存层。
3. 安全建议
- 定期轮换密钥:定期更新加密密钥
- 监控异常访问:监控和告警异常访问行为
- 日志保留:保留足够长的审计日志
- 权限最小化:避免过度授权