公司最近要重构一些老项目,先从鉴权架构下手。遇到的一些经验写下来了。这篇主要讲讲之前封闭开发写的一套最简单的设计。
如何自己设计一套简单通用的架构体系?
鉴权系统,最重要的2个点,无非是,1 登陆,2 鉴权。其他都是以这两个为核心扩散的,还有些边界条件的处理。比如超管怎么处理?用户中途改密码、权限了怎么处理?
先手撸一个最简单的,就不搞 RBAC 那么麻烦的数据库。
用户
设计个用户表,包含用户的账号、密码、角色等信息。
角色
既然是最简单的,那就用枚举写死得了。
权限
每个接口手动设置访问权限,以角色为分割。
具体代码是怎样的?
角色枚举
public enum RoleType implements IntegerValueEnum {
ADMIN(1, "管理员"),
OPERATOR(2, "运营"),
CUSTOMER_SERVICE(3, "客服"),
MERCHANT(4, "商户"),
;
private final Integer value;
private final String name;
RoleType(Integer value, String name) {
this.value = value;
this.name = name;
}
public static RoleType fromValue(final Integer value) {
return Stream.of(RoleType.values())
.filter(v -> v.getValue().equals(value))
.findFirst()
.orElseThrow(() -> new BizException("RoleType枚举不存在"));
}
public Integer getValue() {
return value;
}
public String getName() {
return name;
}
}
用户登陆
密码用 md5 加密,登陆 token 使用 jwt 生成,在生成的时候额外把密码前6位加入,用于在改密码后及时发现弹出重新登陆,如果想做改角色后需重登,可模仿设计。
public PddToolsLoginRespDto login(String userName, String password) {
ThirdPlatformUser user = userMapper.selectByUsername(userName);
if (user == null) throw new BizException("用户不存在");
if (!DigestUtils.md5Hex(password).equals(user.getPassword())) {
throw new BizException("密码错误");
}
String token = getToken(user, DateUtil.localDateTime2Date(LocalDateTime.now().plusDays(EXPIRED_DURATION)));
PddToolsLoginRespDto loginRespDto = new PddToolsLoginRespDto();
BeanUtils.copyProperties(user, loginRespDto);
loginRespDto.setToken(token);
loginRespDto.setRouter(ROLE_ROUTER_MAP.get(user.getRoleType()));
loginRespDto.setUserName(userName);
loginRespDto.setUhzUsername(user.getUhzUsername());
// 非管理员展示username
if (userName.contains("admin")) {
loginRespDto.setZhhUsername("");
} else {
loginRespDto.setZhhUsername(merchantService.getZhhUserName(user.getMerchantId()));
}
return loginRespDto;
}
public String getToken(ThirdPlatformUser user, Date expiredTime) {
Map<String, Object> claims = new HashMap<>(1);
claims.put(TOKEN_USER_ID_KEY, user.getId());
claims.put(TOKEN_USER_PASSWORD_KEY, user.getPassword().substring(0, 6));
return "Bearer " + Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date())
.setExpiration(expiredTime)
.signWith(SignatureAlgorithm.HS512, SECRET)
.compact();
}
接口鉴权
这里稍微复杂点,要考虑到哪些接口需要什么样的权限?怎么判断用户有没有权限?
先设计两个注解一个接口。
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
@Inherited
@Documented
public @interface Auth {
Class<? extends Authorize> value();
}
public interface Authorize {
void handle(Method method);
}
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Inherited
@Documented
public @interface Ignore {}
@Auth 可用在类或方法上,表名接口需要怎样的鉴权。
Authorize,实现此接口就表示了具体的鉴权代码。
@Ignore,标识这个方法不需要鉴权。
比如一个普通的登陆类。
@RestController
@Api(tags = "登陆管理")
@Auth(UserAuthorize.class)
public class LoginController {
@Resource
LoginService loginService;
@Resource
MerchantService merchantService;
@ApiOperation(value = "登陆")
@PostMapping("/login")
@Ignore
public Rs<PddToolsLoginRespDto> login(@RequestBody @Valid PddToolsLoginReqtDto dto) {
return Rs.success(loginService.login(dto));
}
@ApiOperation(value = "登陆token")
@PostMapping("/token")
public Rs<String> token() {
return Rs.success(merchantService.getAuthorization(LocalThread.getCurrentUser().getMerchantId()));
}
}
这个类使用了 @Auth(UserAuthorize.class) 注解,所以类下的每个方法都会走 UserAuthorize 的 handle 的方法,但是在 login 方法上使用了 @Ignore 注解,那么这个方法则不会走鉴权,鉴权的具体逻辑写在 UserAuthorize 中,以上的拦截逻辑写在 controller 拦截器中。
@Component
@Aspect
public class RequestAspect {
@Pointcut("execution(* com.xx.controller..*.*(..))")
public void pointcut() {
}
@Before("pointcut()")
public void before(JoinPoint joinPoint) {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Class controller = methodSignature.getDeclaringType();
Method method = methodSignature.getMethod();
//方法上出现ignore注解则不做任何操作
if (method.isAnnotationPresent(Ignore.class)) {
return;
}
Auth auth = null;
if (method.isAnnotationPresent(Auth.class)) {
auth = method.getDeclaredAnnotation(Auth.class);
} else if (controller.isAnnotationPresent(Auth.class)) {
auth = (Auth) controller.getDeclaredAnnotation(Auth.class);
}
if (auth == null) {
return;
}
Authorize authorize = ApplicationContextRegister.getApplicationContext().getBean(auth.value());
authorize.handle(method);
}
}
@Component
public class UserAuthorize implements Authorize {
@Resource
private HttpServletRequest request;
@Resource
private UserService userService;
@Override
public void handle(Method method) {
String uri = request.getRequestURI();
String token = request.getHeader("Authorization");
if (StringUtils.isBlank(token)) {
throw new BizException(method.getName() + " token为空");
}
ThirdPlatformUser user = userService.getUserByToken(token);
if (user.getIfAvailable() == UserAvailableStatus.NOT_AVAILABLE) {
throw new BizException("用户被禁用");
}
LocalThread.set("user", user);
}
}
到此为止,我们并没有区分出权限,如果要设计某个接口能否被某个角色访问,有两种解决方案,1 再写一个注解,显性标注接口访问需要的角色类型;2 规范设定接口,在 handle 方法中统一处理。系统初期推荐第2种,方便省事,只要按照规范来就好了。
假如对规范的设定是,角色可访问的接口以角色名英文结尾,那么在 handle 方法中加入以下代码即可。
if (!uri.endsWith(user.getRoleType().getName())) {
throw new BizException("权限不足");
}
这样就可以在最短时间内,设计一套最简单可满足需求的鉴权设计。
搞开发容易陷入到一个误区,就是觉得系统约牛逼,qps越高,性能越强的才是好系统,殊不知这只是开发眼中的好系统,leader 眼中,只要你能满足需求同时在最短时间内给他,这才是他期望的好系统,我们要灵活一点,这样路就走宽了宝贝们。
从技术而言,这个系统的确有非常多优化的地方,下次跟着架构的迭代,慢慢把这个做成所有系统都可用的统一鉴权+单点登录的系统。