1. 引言
鉴权指身份认证授权,在计算机安全领域,是指验证一个实体的身份并决定这个实体是否被授权执行某项任务的过程,简单来说,就是确认一个用户或者系统是否拥有进行某项操作的权权利。
鉴权通常包含两个方面:
1)身份认证(Authentication):确定一个用户或实体是否为其声称的个体。这通常通过用户名和密码、生物特征识别、智能卡等方式实现。我们在上一篇提到的内容,就属于身份认证的相关内容。
2)授权(Authorization):在身份被确认之后,鉴权系统还会检查该用户是否有权限执行特定的操作。例如,一个用户可能有权访问某个系统,但没有权限修改数据。
我们在上一章系统认证学习的基础上,添加一个权限表和一个用户权限表。
以大学为例,一般有学生、辅导员、教务员、讲师、教授等身份,如下图所示:
2. 修改用户注册
对于每个用户,都应该有对应的权限,用户和权限的关系,可以是一对多的,比如在大学内,一个人,既可以是学生,也可以是辅导员,比如兼职辅导员;既可以是讲师,也可以是班主任。因此,我们修改用户注册接口,在新增用户的同时,添加用户的权限。
2.1. infrastructure层
修改UserContextDetails类,加上用户权限信息
@Data
public class UserContextDetails implements Serializable {
private Integer id;
private String token;
private String username;
private Map<String, String> extendMap = new HashMap<>();
// 权限
private List<PermissionDetails> permissionDetails = new ArrayList<>();
}
修改JwtTokenVerifyInterceptor类,在获取UserContextDetails时,填充用户权限信息
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("token");
if (StringUtils.isEmpty(token)) {
throw new BusinessException(ResultCode.TOKEN_FAILED);
}
Object userDetails = getUserDetailsFromRedis(token);
if (userDetails != null) { // 判断该token在Redis是否存在
// 设置线程上下文
System.out.println("设置线程上下文====================");
UserContextDetails userContextDetails = (UserContextDetails) userDetails;
userContextDetails.setToken(token);
UserContextThreadLocal.setUserContextDetails(userContextDetails);
return true;
}
JwtTokenService jwtTokenService = SpringContextUtils.getBeanOfType(JwtTokenService.class);
JwtTokenProperty jwtTokenProperty = SpringContextUtils.getBeanOfType(JwtTokenProperty.class);
JwtTokenVerifyRequest jwtTokenVerifyRequest = new JwtTokenVerifyRequest();
jwtTokenVerifyRequest.setToken(token);
jwtTokenVerifyRequest.setSecret(jwtTokenProperty.getSecret());
JwtTokenVerifyDTO verify = jwtTokenService.verify(jwtTokenVerifyRequest);
if (verify == null) {
throw new BusinessException(ResultCode.TOKEN_FAILED);
}
// 设置线程上下文
System.out.println("设置线程上下文====================");
UserContextDetails userContextDetails = new UserContextDetails();
userContextDetails.setId(Integer.valueOf(verify.getSubject()));
userContextDetails.setToken(token);
userContextDetails.setUsername(verify.getPayLoads().get("username"));
userContextDetails.setExtendMap(verify.getPayLoads());
List<Role> roles = JSONObject.parseArray(verify.getPayLoads().get("roles"), Role.class);
userContextDetails.setPermissionDetails(roles.stream().map(role -> {
PermissionDetails permissionDetails = new PermissionDetails();
permissionDetails.setName(role.getCode());
return permissionDetails;
}).collect(Collectors.toList()));
UserContextThreadLocal.setUserContextDetails(userContextDetails);
return true;
}
2.2. domain层
添加Role和UserRole相关的实体类、存储类、领域服务类,这里不贴代码了,比较简单。
添加RoleEnum枚举类
package com.yang.domain.common;
public enum RoleEnum {
STUDENT(1, "STUDENT"),
COUNSELOR(2, "COUNSELOR"),
ACADEMIC_ADMINISTRATOR(3, "ACADEMIC_ADMINISTRATOR");
private int code;
private String description;
RoleEnum(int code, String description) {
this.code = code;
this.description = description;
}
public int getCode() {
return this.code;
}
public String getDescription() {
return this.description;
}
public RoleEnum findByCode(int code) {
for (RoleEnum role : values()) {
if (role.getCode() == code) {
return role;
}
}
return null;
}
}
修改user实体类,加上权限列表属性
@Data
@TableName(value = "t_user")
public class User implements Serializable {
@TableId(type = IdType.AUTO)
private Integer id;
private String username;
private String password;
private String salt;
// 是否冻结 0未冻结 1已冻结
private Integer freeze;
private Date createTime;
private Date updateTime;
@TableField(exist = false)
private Map<String, String> featuresMap;
private String features;
@TableField(exist = false)
private List<Role> roles;
}
修改userRepository,在获取用户的时候,填充用户的权限信息
@Repository
public class UserRepository implements IUserRepository {
@Autowired
private UserMapper userMapper;
@Autowired
private IUserRoleRepository userRoleRepository;
@Autowired
private IRoleRepository roleRepository;
private static final int UN_FREEZE = 0;
private static final int FREEZE = 1;
@Override
public boolean saveUser(User user) {
user.setCreateTime(new Date());
user.setUpdateTime(new Date());
user.setFreeze(UN_FREEZE);
user.setFeaturesMap(new HashMap<>());
user.setFeatures(JSONObject.toJSONString(user.getFeaturesMap()));
return userMapper.insert(user) > 0;
}
@Override
public User findByUsernameAndPassword(String username, String password) {
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getUsername, username);
queryWrapper.eq(User::getPassword, password);
User user = userMapper.selectOne(queryWrapper);
if (user != null) {
user.setRoles(findRoleByUserId(user.getId()));
}
return user;
}
@Override
public User findByUsername(String username) {
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getUsername, username);
User user = userMapper.selectOne(queryWrapper);
if (user != null) {
user.setRoles(findRoleByUserId(user.getId()));
}
return user;
}
@Override
public User findById(Integer id) {
User user = userMapper.selectById(id);
if (user != null) {
user.setRoles(findRoleByUserId(user.getId()));
}
return user;
}
@Override
public boolean updateUser(User user) {
user.setUpdateTime(new Date());
return userMapper.updateById(user) > 0;
}
private List<Role> findRoleByUserId(Integer userId) {
List<UserRole> userRoles = userRoleRepository.findUserRoleByUserId(userId);
if (CollectionUtils.isEmpty(userRoles)) {
return new ArrayList<>();
}
List<Integer> roleIdList = userRoles.stream().map(UserRole::getRoleId)
.distinct().collect(Collectors.toList());
return roleRepository.findRoleInIds(roleIdList);
}
}
2.3. application层
修改用户注册接口,添加用户之后,插入一条用户权限记录。
public User register(RegisterUserRequest request) {
User user = userService.register(request);
Integer roleId = request.getRoleId();
if (roleId == null || roleService.findById(roleId) == null) {
roleId = RoleEnum.STUDENT.getCode();
}
UserRole userRole = new UserRole();
userRole.setUserId(user.getId());
userRole.setRoleId(roleId);
userRoleService.save(userRole);
return user;
}
在登录的时候,生成token时,将权限信息作为payloads的一部分,修改UserApplicationService的login方法:
public UserLoginDTO login(LoginUserRequest request) {
User user = userService.login(request);
if (user == null) {
throw new BusinessException(ResultCode.LOGIN_FAILED);
}
// 生成token
UserLoginDTO userLoginDTO = new UserLoginDTO();
userLoginDTO.setUser(user);
JwtTokenGenerateRequest jwtGenerateRequest = userLoginConvertor.convert2JwtTokenGenerateRequest(user);
String token = jwtTokenService.generateJwtToken(jwtGenerateRequest);
userLoginDTO.setToken(token);
UserContextDetails userContextDetails = userLoginConvertor.convert2UserContextDetails(user);
// token存储到redis
redisUtils.setKey("token:" + token, userContextDetails, jwtGenerateRequest.getExpireTime());
return userLoginDTO;
}
将和UserDetailsContext相关的转化,收敛到UserLoginConvertor,方便维护
@Component
public class UserLoginConvertor {
@Autowired
private JwtTokenProperty jwtTokenProperty;
public JwtTokenGenerateRequest convert2JwtTokenGenerateRequest(User user) {
JwtTokenGenerateRequest request = new JwtTokenGenerateRequest();
request.setSubject(user.getId().toString());
request.setExpireTime(jwtTokenProperty.getExpire());
request.setSecret(jwtTokenProperty.getSecret());
request.setPayLoads(convert2Payloads(user));
return request;
}
private Map<String, String> convert2Payloads(User user) {
Map<String, String> payloads = new HashMap<>();
payloads.put("username", user.getUsername());
payloads.put("id", user.getId().toString());
payloads.put("salt", user.getSalt());
payloads.put("roles", JSONObject.toJSONString(user.getRoles()));
return payloads;
}
public UserContextDetails convert2UserContextDetails(User user) {
UserContextDetails userContextDetails = new UserContextDetails();
userContextDetails.setId(user.getId());
userContextDetails.setUsername(user.getUsername());
userContextDetails.setExtendMap(convert2Payloads(user));
userContextDetails.setPermissionDetails(user.getRoles().stream().map(role -> {
PermissionDetails permissionDetails = new PermissionDetails();
permissionDetails.setName(role.getCode());
return permissionDetails;
}).collect(Collectors.toList()));
return userContextDetails;
}
}
2.4. 测试
我们调用注册接口,分别添加张三、李四、王五的信息,他们分别是学生、辅导员、教务员,测试结果如下:
调用登录接口,然后查看对应的redis内容,可以看到确实有权限信息
3. 基于注解的鉴权
对于不同的角色,其权限一般是不同的,以请假为例,当学生因为某些原因不能上学时,可以请假,而请假一般需要有辅导员或教务员批准,因此,学生有请假的权限,教务员和辅导员有审批假条的权限。
3.1. infrastructure层
首先,添加一个权限注解
@Documented
@Retention(value = RetentionPolicy.RUNTIME)
@Target(value = ElementType.METHOD)
public @interface Permission {
String[] code();
}
然后添加一个切面类,解析该注解中,要求的权限,然后获取用户上下文,根据用户上下文中的权限,来判断是否有符合的,如果都不符合,那么抛出权限不足的异常。
package com.yang.infrastructure.auth.aspect;
import com.yang.infrastructure.auth.PermissionDetails;
import com.yang.infrastructure.auth.UserContextDetails;
import com.yang.infrastructure.auth.UserContextThreadLocal;
import com.yang.infrastructure.auth.annotations.Permission;
import com.yang.infrastructure.common.ResultCode;
import com.yang.infrastructure.exception.BusinessException;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Aspect
@Component
public class PermissionAspect {
@Pointcut(value = "@annotation(com.yang.infrastructure.auth.annotations.Permission)")
public void pointCut() {
}
@Before(value = "pointCut()")
public void beforeAdvice(JoinPoint joinPoint) {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Permission permission = methodSignature.getMethod().getAnnotation(Permission.class);
// 获取注解中要求的权限
String[] code = permission.code();
if (code == null || code.length == 0) {
// 没有指定权限时,不进行拦截
return;
}
// 指定权限,先获取当前用户的权限列表
UserContextDetails userContextDetails = UserContextThreadLocal.get();
List<PermissionDetails> permissionDetails = userContextDetails.getPermissionDetails();
if (CollectionUtils.isEmpty(permissionDetails)) {
throw new BusinessException(ResultCode.ACCESS_DENIED);
}
Set<String> ownPermissionSet = permissionDetails.stream().map(PermissionDetails::getName)
.collect(Collectors.toSet());
boolean containPermission = false;
for (String c : code) {
if (ownPermissionSet.contains(c)) {
containPermission = true;
break;
}
}
if (!containPermission) {
throw new BusinessException(ResultCode.ACCESS_DENIED);
}
}
}
3.2. controller层
controller层中,添加一个LeaveController类,用于测试,其中,学生可以提出申请离校,而辅导员和教务员可以进行审批。
package com.yang.controller;
import com.yang.controller.request.leave.AskForLeaveRequest;
import com.yang.infrastructure.auth.UserContextDetails;
import com.yang.infrastructure.auth.UserContextThreadLocal;
import com.yang.infrastructure.auth.annotations.Permission;
import com.yang.infrastructure.common.Response;
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;
@RestController
@RequestMapping(value = "/leave")
public class LeaveController {
@PostMapping("/apply")
@Permission(code = "STUDENT")
public Response applyForLeave(@RequestBody AskForLeaveRequest request) {
UserContextDetails userContextDetails = UserContextThreadLocal.get();
System.out.println(userContextDetails.getUsername() + "申请离校,离校时间:" + request.getLeaveDays()
+ ",离校原因:" + request.getReason());
System.out.println(userContextDetails.getPermissionDetails());
return Response.success();
}
@PostMapping("/approval")
@Permission(code = {"COUNSELOR", "ACADEMIC_ADMINISTRATOR"})
public Response approvalLeave() {
UserContextDetails userContextDetails = UserContextThreadLocal.get();
System.out.println(userContextDetails.getUsername() + "审批离校申请");
System.out.println(userContextDetails.getPermissionDetails());
return Response.success();
}
}
3.3. 测试
首先用学生账号登录,然后分别访问/apply接口和/approval接口
然后登录一个辅导员账号,再次访问/apply和/approval接口
我们查看控制台,也能看到该账户的权限确实是辅导员权限
4. 基于拦截器的鉴权
虽然上述基于注解的鉴权,能完成用户的权限校验,但是如果我们有很多个接口,他们的权限要求都是一样的,比如以/student开头地请求,都需要学生权限,如果是基于注解地鉴权,我们就需要对这些接口一个一个地加上注解,进行鉴权,这样很麻烦。因此,我们可以使用拦截器,对符合某些路径地请求,进行权限校验。
4.1. infrastructure层
我们在基础设施层,加上对应地拦截器,拦截用户请求,并解析出请求地路径,根据路径,找出该路径需要匹配地权限,最后再根据用户上下文,判断是否满足该权限。
package com.yang.infrastructure.auth.interceptors;
import com.yang.infrastructure.auth.PermissionDetails;
import com.yang.infrastructure.auth.UserContextDetails;
import com.yang.infrastructure.auth.UserContextThreadLocal;
import com.yang.infrastructure.common.ResultCode;
import com.yang.infrastructure.exception.BusinessException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.util.CollectionUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.stream.Collectors;
public class PermissionVerifyInterceptor implements HandlerInterceptor {
private Map<String, String> uri2PermissionMap = new ConcurrentHashMap<>();
public void addPermission(String permission, String... uris) {
if (uris.length > 0) {
for (String url : uris) {
uri2PermissionMap.put(url, permission);
}
}
}
public void addPermission(String permission, List<String> uriList) {
if (CollectionUtils.isEmpty(uriList)) {
return;
}
for (String uri : uriList) {
uri2PermissionMap.put(uri, permission);
}
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requestURI = request.getRequestURI();
String permission = getPermissionOfUri(requestURI);
if (StringUtils.isEmpty(permission)) {
// 没有权限要求,直接通过
return true;
}
UserContextDetails userContextDetails = UserContextThreadLocal.get();
List<PermissionDetails> permissionDetails = userContextDetails.getPermissionDetails();
if (CollectionUtils.isEmpty(permissionDetails)) {
throw new BusinessException(ResultCode.ACCESS_DENIED);
}
long count = permissionDetails.stream()
.filter(permissionDetail -> permissionDetail.getName().equals(permission))
.count();
if (count <= 0) {
throw new BusinessException(ResultCode.ACCESS_DENIED);
}
return true;
}
private String getPermissionOfUri(String uri) {
Set<String> keySet = this.uri2PermissionMap.keySet();
if (keySet.contains(uri)) {
return uri2PermissionMap.get(uri);
}
Map<String, String> map = keySet.stream().filter(key -> key.endsWith("*"))
.collect(Collectors.toMap(key -> {
int index = key.lastIndexOf("*");
String tempKey = key.substring(0, index);
return tempKey;
}, Function.identity()));
for (String key : map.keySet()) {
if (uri.startsWith(key)) {
String originKey = map.get(key);
return uri2PermissionMap.get(originKey);
}
}
return null;
}
}
然后将这个拦截器,添加到配置中
package com.yang.infrastructure.configuration;
import com.yang.infrastructure.auth.interceptors.JwtTokenVerifyInterceptor;
import com.yang.infrastructure.auth.interceptors.PermissionVerifyInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new JwtTokenVerifyInterceptor())
.addPathPatterns("/**") // 拦截所有请求
.excludePathPatterns("/user/login", "/user/register"); // 排除登录、注册接口
PermissionVerifyInterceptor permissionVerifyInterceptor = new PermissionVerifyInterceptor();
permissionVerifyInterceptor.addPermission("STUDENT", "/student/needPermission");
permissionVerifyInterceptor.addPermission("COUNSELOR", "/counselor/*");
registry.addInterceptor(permissionVerifyInterceptor)
.addPathPatterns("/**") // 拦截所有请求
.excludePathPatterns("/user/login", "/user/register"); // 排除登录、注册接口
}
}
4.2. 测试
首先,使用辅导员的token,依次访问/student/needPermission, /student/notPermission和/counselor的相关接口
然后使用普通学生的token,依次访问/student/needPermission, /student/notPermission和/counselor的相关接口