阅读 606

从0到1手撕鉴权系统架构(一) -- 最简单实用的架构体系

公司最近要重构一些老项目,先从鉴权架构下手。遇到的一些经验写下来了。这篇主要讲讲之前封闭开发写的一套最简单的设计。

如何自己设计一套简单通用的架构体系?

鉴权系统,最重要的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 眼中,只要你能满足需求同时在最短时间内给他,这才是他期望的好系统,我们要灵活一点,这样路就走宽了宝贝们。
从技术而言,这个系统的确有非常多优化的地方,下次跟着架构的迭代,慢慢把这个做成所有系统都可用的统一鉴权+单点登录的系统。

文章分类
后端
文章标签