工业互联网平台2.0版本后端代码
编辑 | blame | 历史 | 原始文档

工业互联网平台鉴权功能

  1. 登录功能
    登录接口: http://0.0.0.0/admin-api/system/auth/login

① 参数说明

  {
  "tenantName": "tenant1", //租户名称(必传),需事先在平台配置
  "username": "admin",     //用户名(必传),平台事先维护好用户
  "password": "123",       //密码(必传)
  "captchaVerification": "PfcH6mgr==" //验证码(非必传),若开启的话需要前后台同时开启
  }

其中租户名称在登录逻辑中用不到,用到的是租户对应的ID,接口请求的时候需要将Tenant-Id封装到请求头中,登录逻辑会从请求头中获取并使用,如下截图
特别注意:平台其它接口也都需要封装此租户ID

② 主要代码
1、控制层接口

  `@PostMapping("/login")
  @PermitAll
  @Operation(summary = "使用账号密码登录")
  public CommonResult<AuthLoginRespVO> login(@RequestBody @Valid AuthLoginReqVO reqVO) {
  return success(authService.login(reqVO));
  }`

2、参数对象 AuthLoginReqVO

@Schema(description = "管理后台 - 账号密码登录 Request VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AuthLoginReqVO {

@Schema(description = "账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "iailabyuanma")
@NotEmpty(message = "登录账号不能为空")
@Length(min = 4, max = 16, message = "账号长度为 4-16 位")
@Pattern(regexp = "^[A-Za-z0-9]+$", message = "账号格式为数字以及字母")
private String username;

@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "buzhidao")
@NotEmpty(message = "密码不能为空")
@Length(min = 4, max = 16, message = "密码长度为 4-16 位")
private String password;

// ========== 图片验证码相关 ==========

@Schema(description = "验证码,验证码开启时,需要传递", requiredMode = Schema.RequiredMode.REQUIRED,
        example = "PfcH6mgr8tpXuMWFjvW6YVaqrswIuwmWI5dsVZSg7sGpWtDCUbHuDEXl3cFB1+VvCC/rAkSwK8Fad52FSuncVg==")
@NotEmpty(message = "验证码不能为空", groups = CodeEnableGroup.class)
private String captchaVerification;

}
3、登录实现类

@Override
public AuthLoginRespVO login(AuthLoginReqVO reqVO) {
    // 校验验证码
    validateCaptcha(reqVO);

    // 使用账号密码,进行登录
    AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword());


    // 创建 Token 令牌,记录登录日志
    return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME);
}

4、验证用户名密码

@Override
public AdminUserDO authenticate(String username, String password) {
    final LoginLogTypeEnum logTypeEnum = LoginLogTypeEnum.LOGIN_USERNAME;
    // 校验账号是否存在
    AdminUserDO user = userService.getUserByUsername(username);
    if (user == null) {
        createLoginLog(null, username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS);
        throw exception(AUTH_LOGIN_BAD_CREDENTIALS);
    }
    if (!userService.isPasswordMatch(password, user.getPassword())) {
        createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS);
        throw exception(AUTH_LOGIN_BAD_CREDENTIALS);
    }
    // 校验是否禁用
    if (CommonStatusEnum.isDisable(user.getStatus())) {
        createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.USER_DISABLED);
        throw exception(AUTH_LOGIN_USER_DISABLED);
    }
    return user;
}

用户名密码校验不通过常量如下图

5、创建令牌

private AuthLoginRespVO createTokenAfterLoginSuccess(Long userId, String username, LoginLogTypeEnum logType) {
    // 插入登陆日志
    createLoginLog(userId, username, logType, LoginResultEnum.SUCCESS);
    // 创建访问令牌
    OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken(userId, getUserType().getValue(),
            OAuth2ClientConstants.CLIENT_ID_DEFAULT, null);
    // 构建返回结果
    return AuthConvert.INSTANCE.convert(accessTokenDO);
}

其中“OAuth2ClientConstants.CLIENT_ID_DEFAULT”是在平台配置好的默认客户端,主要包括授权类型、刷新token有效期、访问token有效期等

6、创建访问令牌和刷新令牌

@Override
@Transactional
public OAuth2AccessTokenDO createAccessToken(Long userId, Integer userType, String clientId, List<String> scopes) {
    OAuth2ClientDO clientDO = oauth2ClientService.validOAuthClientFromCache(clientId);
    // 创建刷新令牌
    OAuth2RefreshTokenDO refreshTokenDO = createOAuth2RefreshToken(userId, userType, clientDO, scopes);
    // 创建访问令牌
    return createOAuth2AccessToken(refreshTokenDO, clientDO);
}
private OAuth2AccessTokenDO createOAuth2AccessToken(OAuth2RefreshTokenDO refreshTokenDO, OAuth2ClientDO clientDO) {
    OAuth2AccessTokenDO accessTokenDO = new OAuth2AccessTokenDO().setAccessToken(generateAccessToken())
            .setUserId(refreshTokenDO.getUserId()).setUserType(refreshTokenDO.getUserType())
            .setUserInfo(buildUserInfo(refreshTokenDO.getUserId(), refreshTokenDO.getUserType()))
            .setClientId(clientDO.getClientId()).setScopes(refreshTokenDO.getScopes())
            .setRefreshToken(refreshTokenDO.getRefreshToken())
            .setExpiresTime(LocalDateTime.now().plusSeconds(clientDO.getAccessTokenValiditySeconds()));
    accessTokenDO.setTenantId(TenantContextHolder.getTenantId()); // 手动设置租户编号,避免缓存到 Redis 的时候,无对应的租户编号
    oauth2AccessTokenMapper.insert(accessTokenDO);
    // 记录到 Redis 中
    oauth2AccessTokenRedisDAO.set(accessTokenDO);
    return accessTokenDO;
}
private OAuth2RefreshTokenDO createOAuth2RefreshToken(Long userId, Integer userType, OAuth2ClientDO clientDO, List<String> scopes) {
    OAuth2RefreshTokenDO refreshToken = new OAuth2RefreshTokenDO().setRefreshToken(generateRefreshToken())
            .setUserId(userId).setUserType(userType)
            .setClientId(clientDO.getClientId()).setScopes(scopes)
            .setExpiresTime(LocalDateTime.now().plusSeconds(clientDO.getRefreshTokenValiditySeconds()));
    oauth2RefreshTokenMapper.insert(refreshToken);
    return refreshToken;
}

注意:访问令牌是请求后端接口的通行证,所有需要鉴权的后端api都需要携带访问令牌,访问令牌过期后会请求刷新令牌接口,重新获取访问令牌。 当刷新令牌过期后,就需要重新登录获取授权了!
7、刷新令牌

@PostMapping("/refresh-token")
@PermitAll
@Operation(summary = "刷新令牌")
@Parameter(name = "refreshToken", description = "刷新令牌", required = true)
public CommonResult<AuthLoginRespVO> refreshToken(@RequestParam("refreshToken") String refreshToken) {
    return success(authService.refreshToken(refreshToken));
}
@Override
public AuthLoginRespVO refreshToken(String refreshToken) {
    OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.refreshAccessToken(refreshToken, OAuth2ClientConstants.CLIENT_ID_DEFAULT);
    return AuthConvert.INSTANCE.convert(accessTokenDO);
}
@Override
public OAuth2AccessTokenDO refreshAccessToken(String refreshToken, String clientId) {
    // 查询访问令牌
    OAuth2RefreshTokenDO refreshTokenDO = oauth2RefreshTokenMapper.selectByRefreshToken(refreshToken);
    if (refreshTokenDO == null) {
        throw exception0(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), "无效的刷新令牌");
    }

    // 校验 Client 匹配
    OAuth2ClientDO clientDO = oauth2ClientService.validOAuthClientFromCache(clientId);
    if (ObjectUtil.notEqual(clientId, refreshTokenDO.getClientId())) {
        throw exception0(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), "刷新令牌的客户端编号不正确");
    }

    // 移除相关的访问令牌
    List<OAuth2AccessTokenDO> accessTokenDOs = oauth2AccessTokenMapper.selectListByRefreshToken(refreshToken);
    if (CollUtil.isNotEmpty(accessTokenDOs)) {
        oauth2AccessTokenMapper.deleteBatchIds(convertSet(accessTokenDOs, OAuth2AccessTokenDO::getId));
        oauth2AccessTokenRedisDAO.deleteList(convertSet(accessTokenDOs, OAuth2AccessTokenDO::getAccessToken));
    }

    // 已过期的情况下,删除刷新令牌
    if (DateUtils.isExpired(refreshTokenDO.getExpiresTime())) {
        oauth2RefreshTokenMapper.deleteById(refreshTokenDO.getId());
        throw exception0(GlobalErrorCodeConstants.UNAUTHORIZED.getCode(), "刷新令牌已过期");
    }

    // 创建访问令牌
    return createOAuth2AccessToken(refreshTokenDO, clientDO);
}

8、前端封装token和租户ID代码参考

  // request拦截器
  service.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
  // 是否需要设置 token
  let isToken = (config!.headers || {}).isToken === false
  whiteList.some((v) => {
  if (config.url && config.url.indexOf(v) > -1) {
  return (isToken = false)
  }
  })
  if (getAccessToken() && !isToken) {
  config.headers.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token
  }
  // 设置租户
  if (tenantEnable && tenantEnable === 'true') {
  const tenantId = getTenantId()
  if (tenantId) config.headers['tenant-id'] = tenantId
  }
  const method = config.method?.toUpperCase()
  // 防止 GET 请求缓存
  if (method === 'GET') {
  config.headers['Cache-Control'] = 'no-cache'
  config.headers['Pragma'] = 'no-cache'
  }
  // 自定义参数序列化函数
  else if (method === 'POST') {
  const contentType = config.headers['Content-Type'] || config.headers['content-type']
  if (contentType === 'application/x-www-form-urlencoded') {
  if (config.data && typeof config.data !== 'string') {
  config.data = qs.stringify(config.data)
  }
  }
  }
  return config
  },
  (error: AxiosError) => {
  // Do something with request error
  console.log(error) // for debug
  return Promise.reject(error)
  }
  )
  1. 退出登录功能
    登出接口: http://0.0.0.0/admin-api/system/auth/logout

核心代码如下

@PostMapping("/logout")
@PermitAll
@Operation(summary = "登出系统")
public CommonResult<Boolean> logout(HttpServletRequest request) {
    String token = SecurityFrameworkUtils.obtainAuthorization(request,
            securityProperties.getTokenHeader(), securityProperties.getTokenParameter());
    if (StrUtil.isNotBlank(token)) {
        authService.logout(token, LoginLogTypeEnum.LOGOUT_SELF.getType());
    }
    return success(true);
}
@Override
public void logout(String token, Integer logType) {
    // 删除访问令牌
    OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.removeAccessToken(token);
    if (accessTokenDO == null) {
        return;
    }
    // 删除成功,则记录登出日志
    createLogoutLog(accessTokenDO.getUserId(), accessTokenDO.getUserType(), logType);
}
@Override
public OAuth2AccessTokenDO removeAccessToken(String accessToken) {
    // 删除访问令牌
    OAuth2AccessTokenDO accessTokenDO = oauth2AccessTokenMapper.selectByAccessToken(accessToken);
    if (accessTokenDO == null) {
        return null;
    }
    oauth2AccessTokenMapper.deleteById(accessTokenDO.getId());
    oauth2AccessTokenRedisDAO.delete(accessToken);
    // 删除刷新令牌
    oauth2RefreshTokenMapper.deleteByRefreshToken(accessTokenDO.getRefreshToken());
    return accessTokenDO;
}