# 统一认证

# 后端

# 概述

登录授权就是用户通过输入身份信息,例如账号密码、动态口令等提交身份授权的请求,系统收到请求后对身份信息进行校验 ,如果校验通过,则为其签发包含身份信息的令牌,代表授权成功。后续用户就可以通过这个令牌去访问系统内的其他资源。
登录授权模块支持多种授权类型,目前支持账号密码、图形验证码、动态口令以及第三方平台, 后续还会扩展手机扫码、CA等。
为了实现与业务模块的低耦合以及高扩展性,登录模块作为一个独立的模块存在,不依赖任何业务模块。 业务系统如果想使用该模块,只需依赖该模块的jar,并按照模块要求以及业务需求重写或者新增相应的功能即可。

# 引入依赖

        <!--引入登录授权的jar-->
        <dependency>
            <groupId>com.mediway.hos</groupId>
            <artifactId>hos-app-auth</artifactId>
        </dependency>

# 步骤

1、登录前置数据获取

提供登录前数据获取的接口,默认获取支持的登录方式,用于构建登录页面。开发人员可以在此基础上做拓展,增加自己的 业务数据的获取,例如如果页面需要租户选择,获取租户的数据,则可以在此接口处进行拓展。

2、登录授权

提供用户登录授权的接口,所有的登录方式的入口。开发人员可以在此接口上进行业务功能的扩展。主要是对目前登录方式 的重写以及新增自定义的授权模式。 具体的扩展方式在后续会一一说明。

# 登录前置数据

登录前的数据一般用于构建登录页面的展示(根据支持的登录方式)以及一些登录元素的数据,例如开启多租户,则就需要将租户的数据在登录之前获取。 登录授权模块支持配置登录方式,也可自定义登录前需要的数据。通过访问指定接口即可获取到登录前需要的数据,如果没有自定义业务数据 默认返回所支持的授权类型。

# 登录前置数据接口描述

  • 接口地址

    http://localhost:8367/api/auth/pre-auth-data

  • 接口参数

    该接口默认是没有参数的,如果需要传递参数,则可以根据业务需要携带参数,然后通过如下方式去获取即可。

       ////获取请求
   	HttpServletRequest request = HttpContextUtil.getHttpServletRequest();
   	///获取参数
   	String param1=request.getParameter("param1");

# 自定义前置数据

登录前的数据获取开发人员可以根据业务需要去自定义,自定义的方式如下:
1、继承AbstractPreAuthHandler抽象类,并重写getPreAuthData

为了实现高扩展性,只要继承并重写了getPreAuthData方法,程序都会被执行到。因此在重写方法getPreAuthData时,会传入一个map对象,此对象包含了上一个接口返回的数据,本次重写只需要 将你的数据放入map中,并返回即可。向map中存值时,定义key的编码时 要包含你的业务特性,防止重复。

public class BasePreAuthHandlerImpl extends AbstractPreAuthHandler {
    @Autowired
    TenantService tenantService;

    @Autowired
    ConfigCache configCache;

    /**
     * <p>Todo</p>
     * @param authData 数据集合,返回的数据放入此对象中即可
     * @return Map
     * @author zhaoli
     * @date 2022/6/12 11:09
     */
    @Override
    public Map<String, Object> getPreAuthData(Map<String, Object> authData) {
        ///上一次获取数据的的集合,如果没有则初始化一个数据集合即可
        if(CommonUtils.isNotEmpty(authData)){
            authData=new HashMap<String,Object>();
        }
        ///是否角色选择,默认false
        boolean isSelectRole = Boolean.valueOf(configCache.getValueByCode(SysConfigConstant.BASE_LOGIN_ROLE));

        ///获取租户数据
        JSONObject tenantData=tenantService.selectTenant();
        ///将新的获取到,并放入map中
        authData.put(AuthConstant.SELECT_ROLE,isSelectRole);
        authData.put(AuthConstant.TENANT_DATA,tenantData);
        ///返回数据集合
        return authData;
    }
}

2、注册bean

上述新建了自己的业务类后,还需要注册到Spring 容器中,否则将获取不到,可通过@Component或者在配置类中通过@Bean注册,

   ///注册用户组织中心,获取登录前数据的处理类
    @Bean
    public BasePreAuthHandlerImpl basePreAuthHandlerImpl() {
        return new BasePreAuthHandlerImpl();
    }

# 登录授权流程

# 登录授权接口描述

  • 接口地址

    http://10.1.20.80:8367/api/auth/token

  • 接口参数

    该接口支持多种授权类型,每一种授权类型的参数会有些不同,后续会一一说明。其中 grantType是必须的参数,代表授权类型。 当前支持的授权类型如下:

    授权类型 说明
    password 账号密码登录
    captcha 图形验证码登录
    otp 动态口令登录
    social 第三方平台登录

# 支持的登录方式

目前支持的授权类型,通过配置文件去配置,后续会改成从数据库中获取配置数据。配置文件的配置如下

auth:
  login:
    ####支持的登录方式
    authTypeState:
      ####账号登录
      PASSWORD:
        enable: true
      #####图形验证码
      CAPTCHA:
        enable: true
      #####口令登录
      OTP:
        enable: true
      #####第三方平台
      SOCIAL:
        enable: true
        ####支持的第三方平台的编码
        source:
          - GITEE
          - HOS

提示

授权类型默认只支持账号登录,如果需要支持多种类型,就必须按照上述要求配置。
口令登录和第三方平台的登录也是两个独立的模块,如果业务系统需要支持,除了在配置文件中配置,还需要引入相应的jar才可以,否则登录授权的时候会提示不支持此种授权模式。

# 登录授权的流程如下图:

登录流程图

1、登录前的操作 登录前的操作和登录前的数据获取是不一样的,这块主要是进入登录授权接口后,还没有执行授权之前需要执行的业务逻辑。 增加这块主要用于解决登录失败日志的记录,如果有其他的需求,也可以去重写相应的接口。
此处只支持一次重写, 因此不要多次重写并注册到Spring容器中,否则只能获取到最后一次注册的实现类。实现方式如下:

(1)继承IBeforeAuthHandler接口类,并重写recordAuthInfo接口
recordAuthInfo方法传入的是登录时的参数,也可以通过request.getParameter("param1")去直接获取。如果该接口执行的业务逻辑返回的数据 ,需要用于后续授权,则可以将其放入上下文中,也可以初始化一个KV(键值)对象并将数据放入其中返回。
目前登录前的操作主要是将登录的日志信息放入到日志对象中,并放入到上下文中,这样后续不管是登录失败(捕获登录类异常后执行)或者成功, 都可以直接从上下文中获取,并保存到数据库中。

public class BeforeAuthHandlerImpl implements IBeforeAuthHandler {
    @Autowired
    TenantProperties tenantProperties;

    @Autowired
    TenantService tenantService;

    @Override
    public Kv recordAuthInfo(LoginParamVO loginParam) {
        ////获取请求
        HttpServletRequest request= HttpContextUtil.getHttpServletRequest();
        Kv info=Kv.create();
        ///将登录日志的一些信息放到上下文中,用于异常抛出时,获取并写入导致
        ////记录登录日志,并将日志id传递给后续的认证过程,用于日志的更新
        LoginLog ll=new LoginLog();
        //浏览器
        ll.setBrowser(BrowserUtils.checkBrowse(request));
        ///登录ip
        ll.setIp(BaseIpUtils.getIp());
        ////操作系统
        // 准备ip地址、操作系统等信息
        UserAgent userAgent = UserAgentUtil.parse(BaseHttpContextUtils.getRequest().getHeader("User-Agent"));
        ll.setOs(userAgent.getOs().toString());
        ll.setMac(IPUtils.getClientMAC(request));
        ll.setSuccess(WhetherEnum.FALSE.getCode());
        ll.setLoginLocation(BaseIpUtils.getRealAddressByIp(BaseIpUtils.getIp()));
        ll.setTitle(LoginLogTypeEnum.LOGIN.getName());
        ll.setLoginName(loginParam.getLoginName());
        ll.setServerIP(BaseIpUtils.getLocalIp());
        ll.setLoginWay(loginParam.getGrantType());
        ll.setTitle(LoginLogTypeEnum.LOGIN.getName());
        ll.setGrantSource(SourceTypeEnum.getCodeByName(loginParam.getSource()));
        ll.setMsg(LoginLogStateEnum.LOGIN_FAIL.getName());
        //ll.setTenantId(loginParam.getTenantId());
       //是否是登录,如果只是密码校验获取角色数据 设置为true
        String isRecordLogin=request.getParameter("isRecordLogin");
        ///如果只是想通过密码校验,获取角色数据,则不用记录日志,后续也不需要记录在线用户和日志状态
        ////如果上下问中,没有这个日志的信息,则代表不需要记录日志
        if(CommonUtils.isEmpty(isRecordLogin)||Boolean.valueOf(isRecordLogin)){
            ThreadLocalManager.remove("logInfo");
            ThreadLocalManager.put("logInfo",ll);
        }else{
            ThreadLocalManager.remove("logInfo");
        }
       ////校验租户
        String tenantId=loginParam.getTenantId();
        if(CommonUtils.isNotEmpty(tenantId)&& tenantProperties.isEnable()){
            TenantUtil.setTenantId(tenantId);
            ///判断租户的有效性
            if(!tenantService.checkTenantStats(tenantId)){
                ///抛出租户无效的提示
                throw new AuthFailedException(AuthFailedExceptionEnum.TENENTID_OUT_DATE);
            }
        }
        return info;
    }
}

(2)注册bean
上述新建了自己的业务类后,还需要注册到Spring 容器中,否则将获取不到,可通过@Component或者在配置类中通过@Bean注册,如下

   ///注册用户组织中心,获取登录前操作
   @Bean
   public BeforeAuthHandlerImpl beforeAuthHandlerImpl() {
     return new BeforeAuthHandlerImpl();
   }

2、登录认证 根据传递的授权类型参数判断最终使用哪一种授权模式,执行登录认证。无论是哪一种授权类型,在授权成功后,都需要返回一个HosUserDetail类型的用户对象 ,其中包含此次认证的用户的相关信息。

HosUserDetail是一个封装用户信息的接口类,登录模块使用的默认用户对象为HosUser,继承了HosUserDetailHosUser对象包含的信息如下:

public class HosUser  implements HosUserDetail {

    private static final long serialVersionUID = 1L;

    @ApiModelProperty(value = "工号")
    private String userCode;

    @ApiModelProperty(value = "用户ID")
    private String userId;

    @ApiModelProperty(value = "登录名")
    private String loginName;

    @ApiModelProperty(value = "姓名")
    private String name;

    @ApiModelProperty(value = "租户ID")
    private String tenantId;

    @ApiModelProperty(value = "角色ID集合")
    private String roleId;

    @ApiModelProperty(value = "角色编码集合")
    private String roleCode;

    @ApiModelProperty(value = "角色名称集合")
    private String roleName;

    @ApiModelProperty(value = "第三方平台的ID")
    private String oAuthId;

    @ApiModelProperty(value = "授权类型")
    private String grantType;

    @ApiModelProperty(value = "科室Id")
    private String orgId;

    @ApiModelProperty(value = "科室名称")
    private String orgName;

    @ApiModelProperty(value = "机构Id")
    private String instId;

    @ApiModelProperty(value = "机构名称")
    private String instName;

    @ApiModelProperty(value = "登录时间")
    private Date initLoginTime;
    @ApiModelProperty(value = "用户扩展信息")
    private Kv detail;
}

HosUser对象会封装到token中,因此如果需要往token中增加信息,可以使用detail字段(键值对)去扩展。当前也可以 不使用HosUser,自己去重新定义用户对象,只要继承HosUserDetail即可。

3、Token令牌签发 Token是身份认证的一种方式,区别于传统的基于session的身份认证,token认证是一种无状态的认证,主要用于前后端分离框架的安全认证。 JWT TOKEN 则是token认证的一种方式,本质就是一个字符串,它是将用户信息保存到一个Json字符串中, 然后进行编码后得到一个JWT token,并且这个JWT token带有签名信息,接收后可以校验是否被篡改。JWT TOKEN的详细说明参见插件集成。

此处是将认证后返回的用户信息 根据jwt 配置信息 生成最终的访问令牌和刷新令牌。用于后续访问资源的身份认证。JWT配置文件如下:

###非必须设置 
jwt:
  # 令牌内容前缀
  prefix: mediway_
  # 刷新token有效期(单位:秒),默认7天
  refreshExpire: 172800
  # 认证token有效期(单位:秒),默认2小时
  accessExpire: 7200
  ####签名密钥 
  signKey:mediwayisapowerfulmicroservicearchitectureupgradedandoptimizedfromacommercialproject

提示

刷新令牌存在时间会比访问令牌更长,主要用于访问令牌过期的时候,根据刷新令牌重新调用认证接口获取到新的访问令牌和刷新令牌,以达到token续期的目的。 如果用户一直在刷新令牌过期前有操作,理论上可以一直不用输入密码验证,体验会很好。

4、登录成功后的操作 登录成功后的操作作用于签发令牌之后,主要用于构建返回到前端的授权成功的信息以及执行一些其他操作。默认会将令牌以及用户信息返回, 如果还有其他信息需要扩展,则就可以在此处去实现。授权成功后返回给前端的信息包含如下:

    ///访问token
    String accessToken;
    //刷新token
    String refreshToken;
    ////用户信息
    HosUserDetail hosUser;
    ///其他扩展信息
    Kv detail;

如果有其他的信息需要返回,则可将信息封装到detail对象中,实现方式为: (1) 继承IAuthSuccessHandler 实现getAuthInfo接口

public class BaseAuthSuccessHandlerImpl implements IAuthSuccessHandler {
  

    @Override
    public AuthInfoDetail getAuthInfo(String accessToken, String refreshToken, HosUserDetail hosUser, Kv customInfo, LoginParamVO loginParam, HttpServletResponse response) {
                  
        AuthInfo ai = new AuthInfo();
        ai.setAccessToken(accessToken);
        ai.setRefreshToken(refreshToken);
        ai.setHosUser(loginUser);
        ai.setLoginName(hosUser.getLoginName());
        ////扩展拓展信息
         Kv detail=Kv.create();
         detail.put("other","扩展信息");
        return ai;
     }
}

(2)注册bean

上述新建了自己的业务类后,还需要注册到Spring 容器中,否则将获取不到,可通过@Component或者在配置类中通过@Bean注册,如下

   ///注册登录成功后的处理类
   @Bean
   public IAuthSuccessHandler baseAuthSuccessHandlerImpl() {
        return new BaseAuthSuccessHandlerImpl();
   }

除了构建返回的数据,一般登录成功后还需要进行在线用户的数据保存。因为本身JWT token是无状态的,签发的令牌只要不过期就会一直有效。 但是这样会存在一定的安全隐患,容易发生token泄露,并且系统也无法管理已经在线的用户。因此对于已经登录成功的用户, 一般会将其登录的信息(用户信息以及令牌信息等)保存到缓存中。登录模块提供了工具方法如下:

     AuthUtil.dealLoginTokenCache(hosUser,accessToken,refreshToken,loginParam);

5、登录返回的数据示例

{
    "code": "200",
    "msg": "success",
    "data": {
        "accessToken": "mediway_eyJhbGciOiJIUzI1NiJ9.eyJ0b2tlbi10eXBlIjoiYWNjZXNzX3Rva2VuIiwiZXhwIjoxNjU1MDA3NTI1LCJ1c2VyIjoie1wib3JnTmFtZVwiOlwiXCIsXCJyb2xlSWRcIjpcIjQ1ODE4OWNjMDIwM2M1ZmZjMDUwYzM3NzJkNmFkN2UzXCIsXCJ1c2VySWRcIjpcIjFcIixcInVzZXJDb2RlXCI6XCJcIixcImxvZ2luTmFtZVwiOlwiYWRtaW5cIixcInJvbGVDb2RlXCI6XCJldmVyeW9uZVwiLFwidGVuYW50SWRcIjpcIjAwMDAwMFwiLFwicm9sZU5hbWVcIjpcIueUqOaIt1wiLFwibmFtZVwiOlwi566h55CG5ZGYXCIsXCJpbml0TG9naW5UaW1lXCI6MTY1NTAwMDMyNTEyMixcImdyYW50VHlwZVwiOlwiY2FwdGNoYVwiLFwiaW5zdE5hbWVcIjpcIlwifSIsImp0aSI6Ik56SmtNRGswT1dVdE4ySmhNeTAwWmpGaExXRXpOVFl0WWprek5EYzRaV1ZrT1RWayJ9.qbpC5HK08q3vn8XtewN3zKr8_b0Nm3JVdlII4I8_WrQ",
        "refreshToken": "mediway_eyJhbGciOiJIUzI1NiJ9.eyJ0b2tlbi10eXBlIjoicmVmcmVzaF90b2tlbiIsImV4cCI6MTY1NTE3MzEyNSwidXNlciI6IntcIm9yZ05hbWVcIjpcIlwiLFwicm9sZUlkXCI6XCI0NTgxODljYzAyMDNjNWZmYzA1MGMzNzcyZDZhZDdlM1wiLFwidXNlcklkXCI6XCIxXCIsXCJ1c2VyQ29kZVwiOlwiXCIsXCJsb2dpbk5hbWVcIjpcImFkbWluXCIsXCJyb2xlQ29kZVwiOlwiZXZlcnlvbmVcIixcInRlbmFudElkXCI6XCIwMDAwMDBcIixcInJvbGVOYW1lXCI6XCLnlKjmiLdcIixcIm5hbWVcIjpcIueuoeeQhuWRmFwiLFwiaW5pdExvZ2luVGltZVwiOjE2NTUwMDAzMjUxMjIsXCJncmFudFR5cGVcIjpcImNhcHRjaGFcIixcImluc3ROYW1lXCI6XCJcIn0iLCJqdGkiOiJOV0UzTm1Wa05EUXRaR1E0TmkwMFpqVTVMV0kxWlRjdFkyUmpNbU00T0RZME0yWTIifQ.poeI9hQqUszAr-z9SeJjjt-9_bjV0bRxPWzYcg2QVlc",
        "hosUser": {
            "userId": "1",
            "tenantId": "000000",
            "loginName": "admin",
            "userCode": "",
            "equipment": null,
            "initLoginTime": "2022-06-12T02:18:45.122+0000",
            "lastLoginTime": null,
            "roleCode": "everyone",
            "roleId": "458189cc0203c5ffc050c3772d6ad7e3",
            "roleName": "用户",
            "oAuthId": null,
            "grantType": "captcha",
            "orgId": null,
            "orgName": "",
            "instId": null,
            "instName": "",
            "name": "管理员"
        },
        "detail": {
            "roleData": [
                {
                    "userId": "1",
                    "loginName": "admin",
                    "userName": "管理员",
                    "roleId": "458189cc0203c5ffc050c3772d6ad7e3",
                    "roleCode": "everyone",
                    "roleName": "用户",
                    "avatar": null
                }
            ],
            "avatar": null,
            "defaultPageData": {
                "code": null,
                "frame": null,
                "router": null
            },
            "baseUserOrgRole": "1",
            "baseScopeResource": "false",
            "isSelectRole": true,
            "policyErrorCode": ""
        }
    },
    "success": true
}

# 账号(验证码)登录

# 概述

账号登录是通过用户输入账号、密码或者验证码(验证码开启需要)进行身份授权的一种的方式,也是最常用的。 在登录授权模块中 账号登录是默认支持的登录方式。账号登录支持两种数据来源认证,本地数据来源认证和远程数据源(通过resful或者webservice)认证。 账号登录支持授权认证接口的重写,也可以支持不同认证数据来源的重写。

# 账号登录配置文件

###账号登录相关的选项
  login:
    ###验证码的先关参数
    captchaType: line
    captchaLength: 4
    captchaWidth: 200
    captchaHeight: 100
    captchaContentType: number
    ###同一个账户的最大会话数 默认0
    maxSession: 0
    ###是否锁定用户,默认true
    isLockUser: true
    #连续登录失败N次,锁定账户  默认十次
    lockUserNumber: 10
    ##锁定时长
    lockedExpire: 10 * 60
    ##连续登录失败次数N次,开启验证码
    captchaOpenNumber: 5
  • captchaType 验证码类型 默认line line:线段 circle:线圈 shear:线圈
  • captchaLength 验证码位数,默认4位
  • captchaWidth 验证码图片长度,默认200
  • captchaHeight 验证码图片高度 ,默认100
  • captchaContentType 验证码编码的类型,number代表数字,其他值代表数字和字母,默认数字和字母
  • maxSession一个账户的最大会话数 默认0,1代表一个账户只允许一个会话,N代表一个账户可以N个会话
  • isLockUser 是否锁定用户,默认true
  • lockUserNumber 连续登录失败N次,锁定账户 默认10次。目前只有在账号登录时 用户名正确密码错误的时候记录错误次数
  • lockedExpire 锁定时长 默认10分钟
  • captchaOpenNumber 连续登录失败次数N次,开启验证码,默认5次

上述的账号登录的配置项,如果在业务系统中是通过页面配置的,则优先使用本系统的数据。业务系统可以通过事件的形式 对认证配置类的实例重新赋值。例如用户组织中心存在密码策略模块,此模块定义了很多登录相关的参数,因此可通过以下方式 进行了重新赋值。这种方式只适用于配置数据跟登录用户无关,如果每个用户或者租户的配置数据都不同,则不适用。

# 获取图形验证码

如果开启了图形验证码或者登录失败次数超过上述的配置次数,都会开启验证码认证模式,就需要在登录认证之前获取下 验证码,验证码的生成规则,按照上述配置文件设置,也可以不设置走默认配置。

  • 接口地址

    http://localhost:8367/api/auth/captcha

  • 返回数据

    • uuid:唯一标志。登录认证时必须携带这个参数
{
    "code": "200",
    "msg": "success",
    "data": {
        "uuid": "51430a0f41ac4a12b93c69459e0b0db8"
    },
    "success": true
}

# 账号登录认证

账号认证需要的参数如下:

  • captcha-code: 图形验证码 ,图形验证码开启时,必须传递,必须放到header中传递;
  • captcha-key: 上述获取图形验证码时返回的uuid,必须放到header中传递;
  • grantType:授权类型,如果没有开启图形验证码则是password,否则是captcha;
  • loginName: 账号;
  • password: 密码,加密后的,需要配合基础平台中设置的接口加密方式去加密,否则解密会失败;
  • tenantId: 租户id,开启租户则需要传递,否则不用传。

账号登录,会根据上述信息进行信息校验,大概的逻辑为:

  • 校验参数不能为空
  • 如果开启多租户,校验租户有效性
  • 如果是验证码模式,校验验证码的有效性
  • 根据账号,校验账号的有效性
  • 校验密码,密码是不能明文传递的,需要先解密
  • 获取用户的其他信息,构建用户对象并返回。

流程描述:
1、 系统启动后,获取密码策略表中数据对认证属性类的实例中相应的参数进行重新赋值

@Component
public class AuthPropsRunner implements ApplicationRunner {
    @Override
    public void run(ApplicationArguments args) {
        ///校验验证码
        AuthPropsUtil.resetAuthProperties(PolicyConstant.M0009);
        /// 密码错误后 锁定账户 开启
        AuthPropsUtil.resetAuthProperties(PolicyConstant.M0015);
        /// 密码错误的次数
        AuthPropsUtil.resetAuthProperties(PolicyConstant.M0013);
    }
}
public class AuthPropsUtil {
    public static void resetAuthProperties(String code){
        PasswordPolicyCache passwordPolicyCache=PasswordPolicyCache.me();
        ////登录配置文件对应的属性类,包含了配置数据
        AuthProperties ap= AuthUtil.getAuthProperties();
        switch(code){
            ///登录是否需要验证码
            case PolicyConstant.M0009:{

                Map<GrantTypeEnum, AuthConfig> typeState=ap.getAuthTypeState();
                //处理验证码, 的包含验证码的模式
                if(typeState.containsKey(GrantTypeEnum.CAPTCHA)){
                    AuthConfig authConfig=typeState.get(GrantTypeEnum.CAPTCHA);
                    authConfig.setEnable(passwordPolicyCache.getIsLoginCheckVerification());
                    typeState.put(GrantTypeEnum.CAPTCHA,authConfig);
                    ap.setAuthTypeState(typeState);
                }
                break;
            }
            ///设置登录多少次,失败后 锁定用户
            case PolicyConstant.M0015:{
                ap.setIsLockUser(passwordPolicyCache.getIsPasswordErrorLockUser());
                ap.setLockUserNumber(passwordPolicyCache.getPasswordErrorLockUserNumber());
                double expire=passwordPolicyCache.getPasswordErrorLockUserHour()*60*60;
                ap.setLockedExpire(new Double(expire).intValue());
                break;
            }///失败多少次 打开验证码
            case PolicyConstant.M0013:{
                Boolean isOpenCaptcha=passwordPolicyCache.getIsPasswordErrorVerification();
                if(!isOpenCaptcha){
                    ap.setCaptchaOpenNumber(-1);
                }else{
                    ap.setCaptchaOpenNumber(passwordPolicyCache.getPasswordErrorVerificationNumber());
                }
                break;
            }
        }
    }
}

2、密码策略数据更新后,通过注册事件 更新认证属性类中的相关参数

(1) 注册密码策略更新事件

/**
 * <p>密码策略数据的更新事件<p>
 *
 * @author zhaoli
 * @version 1.0
 * @date 2022/5/5 16:00
 **/
public class PasswordPolicyChangeEvent extends ApplicationEvent {
    private String code;

    public PasswordPolicyChangeEvent(Object source) {
        super(source);
    }
    public PasswordPolicyChangeEvent(Object source,String code) {
        super(source);
        this.code=code;
    }
    public String getCode() {
        return code;
    }
}

(2) 密码策略更新事件实现类

/**
 * <p>密码策略数据的更新<p>
 *
 * @author zhaoli
 * @version 1.0
 * @date 2022/5/5 16:00
 **/
@Service
@Slf4j
public class AuthPropertiesReSetService implements ApplicationListener<PasswordPolicyChangeEvent> {

    @Autowired
    private PasswordPolicyCache passwordPolicyCache;
    @Override
    @Async
    public void onApplicationEvent(PasswordPolicyChangeEvent passwordPolicyChangeEvent) {
        ///
        resetAuthProperties(passwordPolicyChangeEvent.getCode());
    }
    public void resetAuthProperties(String code){
        AuthPropsUtil.resetAuthProperties(code);
    }
}

(3) 密码策略更新时发布事件

@Service
@Slf4j
public class PasswordPolicyServiceImpl extends BaseServiceImpl<PasswordPolicyMapper, PasswordPolicy> implements PasswordPolicyService, ApplicationEventPublisherAware {

    // 注入事件发布者
    private ApplicationEventPublisher applicationEventPublisher;
    /**
     * 根据编码更新密码策略
     *  @author chengpeng
     */
    @Transactional
    @Override
    public int update(PasswordPolicyDTO PasswordPolicyDTO){
        
        ////发布事件,更新一些配置数据
        applicationEventPublisher.publishEvent(new PasswordPolicyChangeEvent(this,PasswordPolicyDTO.getCode()));
        return "";
    }
}

上述任何一个流程只要校验失败,只要抛出登录异常即可。

# 自定义账号登录实现

如果不想使用账号登录默认授权的代码,则可以对其进行重写,实现方式如下:
1、 继承 AbstractPasswordTokenGranter 重写grant方法

grant方法的参数为:

  • loginParamVO 包含了登录时的参数,账号登录时只需要loginNamepassword
  • customInfo包含了登录前操作设置的数据,如果登录前操作并没有设置数据,则为空;
public class PasswordTokenGranter extends AbstractPasswordTokenGranter {

	@Override
	public HosUserDetail grant(LoginParamVO loginParamVO, Kv customInfo) {
		///
		String loginName=loginParamVO.getLoginName();
		String password=loginParamVO.getPassword();
		String encryptedPassword=password;
		///用于保存查询后的用户对象列表
		if(StringUtils.isNotBlank(loginName)&&StringUtils.isNotBlank(password)){
			return PasswordGrantUtil.passwordLogin(loginParamVO,null);
		}else{
			throw new AuthFailedException(AuthFailedExceptionEnum.LOGIN_NAME_PASSWORD_EMPTY);
		}
	}
}

grant方法需返回HosUserDetail类型,也就是用户信息的对象,一般返回HosUser用户对象即可,如果默认的HosUser 不满足需要,也可以继承HosUserDetail 去定义属于自己业务的用户对象类,一般来说HosUser已经可以满足大部分场景。

2、注册bean

上述新建了自己的业务类后,还需要注册到Spring 容器中,否则将获取不到,可通过@Component或者在配置类中通过@Bean注册,如下

      @Bean
      @ConditionalOnMissingBean(IPasswordTokenGranter.class)
      public PasswordTokenGranter passwordtokenGranter() {
          return new PasswordTokenGranter();
      }

# 自定义验证用户的数据源

# 本地文件数据源

因为登录授权模块本身没有具体的用户数据来源,如果只引入这个模块,并且使用本地数据源进行账号登录,则就需要在配置文件中配置用户信息,用于用户信息的校验,配置文件如下:

auth:
  ####账号登录的设置,非必须设置
  password:
     ### 账号登录支持的数据来源认证,base代表本地,webservice代表远程,默认是base
    loginType: base
    #### 支持的用户信息
    user:
      #### 支持的用户信息 
      userInfos:
        - loginName: admin
          password: 111111
          name: 管理员
        - loginName: test
          password: 123456
          name: 测试用户

一般来说不会使用使用配置文件中的用户信息,业务系统如果要使用本系统支持的用户信息,则就需要重写这个接口, 调用本系统用到的用户信息进行登录的认证,重写的方式如下:

1、 继承 AbstracPasswordLoginGranter 重写 passwordLogin

public class BasePasswordLoginImpl extends AbstracPasswordLoginGranter {
    @Autowired
    UserService userService;

    /**
     * 密码登录
     *
     * @param loginParamVO 登录时的参数
     * @return UserInfo
     */
    @Override
    public HosUserDetail passwordLogin(LoginParamVO loginParamVO, Kv customInfo){
        ////查询本系统用户数据,进行账号和密码的校验 TODO 
        HosUser hu=new HosUser();
        ////校验成功后,将用户信息封装到HosUser,并返回 TODO 
        return hu;
               
    }
}

2、注册bean

上述新建了自己的业务类后,还需要注册到Spring容器中,否则将获取不到,可通过@Component或者在配置类中通过@Bean注册,如下

    @Bean
    @BasePasswordLogin
    public IPasswordLoginGranter basePasswordLoginImpl() {
        return new BasePasswordLoginImpl();
    }

@BasePasswordLogin是自定义的一个注解,用于根据配置文件中的用户数据来源类型(loginType) 判断是否需要注册bean。只有loginType为base,本地数据来源的认证方式才会生效,代码如下:

@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE,ElementType.METHOD})
@ConditionalOnProperty(name = "loginType", prefix = "auth.password", havingValue = "base" ,matchIfMissing = true)
public @interface BasePasswordLogin {
    String[] value() default "";
}
# 远程接口数据源

远程数据源的认证,主要是通过webservice或者resful 调用远程接口进行登录的认证。如果通过远程数据进行验证,配置文件如下:

auth:
  ####账号登录的设置,非必须设置
  password:
     ### 账号登录支持的数据来源认证,base代表本地,webservice代表远程,默认是base
    loginType: webservice
    webservice:
       ###远程接口地址
      url: http://localhost:8367/contract/selectLikeList
      ####接口类型
      type: restful
      ####代表接口成功返回的标志字段
      resultSuccessFormat:
        -code: 200
      ###需要封装在返回数据的字段设置,一般指用户的扩展信息,
      resultDataFormat:
        -other
      code: 200

一般情况下对于远程接口需要按照要求去接收参数或者返回数据,否则很难通过一个接口满足所有类型的远程接口。 如果远程接口无法改动,并且不满足咱们的接口要求,则业务系统也可以通过重写的方式去实现。

1、继承AbstracPasswordLoginGranter 重写 passwordLogin方法

 public class WSPasswordLoginImpl extends AbstracPasswordLoginGranter {
    /**
     * 密码登录
     *
     * @param loginParamVO 授权参数
     * @param customInfo 认证之前获取到的一些参数,用于后期验证使用,根据实际情况设置,也可以不设置
     * @return HosUserDetail
     */
    @Override
    public HosUserDetail passwordLogin(LoginParamVO loginParamVO, Kv customInfo){
        ////调用远程接口,传递账号和密码的信息,进行校验,密码如何加密传输,后续还的再考虑
         HosUser hu=new HosUser();
        ////校验成功后,将用户信息封装到HosUser,并返回 TODO 
    }
}

2、 注册bean

上述新建了自己的业务类后,还需要注册到Spring 容器中,否则将获取不到,可通过@Component或者在配置类中通过@Bean注册,如下

    @Bean
    @WSPasswordLogin
    public IPasswordLoginGranter wsPasswordLogin() {
        return new WSPasswordLoginImpl();
    }

@WSPasswordLogin是自定义的一个注解,用于根据配置文件中的用户数据来源类型(loginType) 判断是否需要注册bean。只有loginTypewebservice,远程数据来源的认证方式才会生效,代码如下:

@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE,ElementType.METHOD})
@ConditionalOnProperty(name = "loginType", prefix = "auth.password", havingValue = "webservice" ,matchIfMissing = true)
public @interface WSPasswordLogin {
    String[] value() default "";
}

# 动态口令登录

# 概述

动态口令登录是指用户通过手机号获取动态口令进行登录的一种方式。用户如果想通过动态口令登录系统,必须将正确的手机号维护到系统中,否则将无法登录。目前的短信推送方式支持容联云和阿里云平台推送口令,本系统支持推送方式的扩展,业务系统可根据自己的需要进行自定义短信平台的集成与实现。

# 引入依赖

       <dependency>
           <groupId>com.mediway.hos</groupId>
           <artifactId>hos-app-auth-onetimepassword</artifactId>
       </dependency>

# 配置短信服务

配置用于发送口令的短信服务信息,配置信息来自短信服务平台,客户需要优先到短信平台进行服务购买以及数据维护。目前支持容联云和阿里云,也可以扩展其他类型的短信服务,配置的参数如下:

sms:
  #目前仅支持容联云(ronglianyun)和阿里云(ali)短信推送
  name: ronglianyun
  #类型编码:容联云(1)和阿里云(2)
  type: 1
  # 是否启用
  enabled: true
  # 短信模板ID
  templateId: 1
  # regionId
  regionId: cn-hangzhou"
  #accessKey (容联云accountSId)
  accessKey: 8aaf07086715299301672ec644cb11ab
  #secretKey (容联云accountToken)
  secretKey: f23e5886e7674e9b88c90add8b483bef
  #短信签名
  signName:
  #应用id(容联云)
  appId: 8aaf07086715299301672ec6451a11b1

容联云短信服务平台

# 自定义短信服务

1、在枚举类SmsEnum中定义支持的平台信息,yaml配置短信推送方式需要用到。

@Getter
@AllArgsConstructor
public enum SmsEnum {
	/**
	 * ronglianyun
	 */
	RONGLIANYUN("ronglianyun", "1"),
	/**
	 * ali
	 */
	ALI("ali", "2");

	/**
	 * 名称
	 */
	final String name;
	/**
	 * 类型
	 */
	final String category;

	/**
	 * 匹配枚举值
	 *
	 * @param name 名称
	 * @return OssEnum
	 */
	public static SmsEnum of(String name) {
		if (name == null) {
			return null;
		}
		SmsEnum[] values = SmsEnum.values();
		for (SmsEnum smsEnum : values) {
			if (smsEnum.name.equals(name)) {
				return smsEnum;
			}
		}
		return null;
	}

}

2、实现接口类SmsTemplate

@AllArgsConstructor
public class AliSmsTemplate implements SmsTemplate {

	private static final int SUCCESS = 200;
	private static final String FAIL = "fail";
	private static final String OK = "ok";
	private static final String DOMAIN = "dysmsapi.aliyuncs.com";
	private static final String VERSION = "2017-05-25";
	private static final String ACTION = "SendSms";

	private final SmsProperties smsProperties;
	private final IAcsClient acsClient;

	@Override
	public SmsResponse sendMessage(SmsData smsData, Collection<String> phones) {
		CommonRequest request = new CommonRequest();
		request.setSysMethod(MethodType.POST);
		request.setSysDomain(DOMAIN);
		request.setSysVersion(VERSION);
		request.setSysAction(ACTION);
		request.putQueryParameter("PhoneNumbers", StringUtil.join(phones));
		request.putQueryParameter("TemplateCode", smsProperties.getTemplateId());
		request.putQueryParameter("TemplateParam", JSONUtils.obj2String(smsData.getParams()));
		request.putQueryParameter("SignName", smsProperties.getSignName());
		try {
			CommonResponse response = acsClient.getCommonResponse(request);
			Map<String, Object> data = JsonUtils.toMap(response.getData());
			String code = FAIL;
			if (data != null) {
				code = String.valueOf(data.get("Code"));
			}
			return new SmsResponse(response.getHttpStatus() == SUCCESS && code.equalsIgnoreCase(OK), response.getHttpStatus()+"", response.getData());
		} catch (ClientException e) {
			e.printStackTrace();
			return new SmsResponse(Boolean.FALSE, HttpStatus.INTERNAL_SERVER_ERROR.value()+"", e.getMessage());
		}
	}

	@Override
	public SmsCode sendValidate(SmsData smsData, String phone) {
		SmsCode smsCode = new SmsCode();
		boolean temp = sendSingle(smsData, phone);
		if (temp && StringUtil.isNotBlank(smsData.getKey())) {
			String id = IdUtil.simpleUUID();
			String value = smsData.getParams().get(smsData.getKey());
			SMSCache.me().saveOTPCode(id,value,phone);
			smsCode.setId(id).setValue(value);
		} else {
			smsCode.setSuccess(Boolean.FALSE);
		}
		return smsCode;
	}

	@Override
	public boolean validateMessage(SmsCode smsCode) {
		String id = smsCode.getId();
		String value = smsCode.getValue();
		String phone = smsCode.getPhone();
		String cache=SMSCache.me().getLoginOTPCode(id,phone);
		if (StringUtil.isNotBlank(value) && StrUtil.equalsIgnoreCase(cache, value)) {
			SMSCache.me().removeLoginOTPCode(id,phone);
			return true;
		}
		return false;
	}

}

3、新增短信通道配置类,业务系统集成对应短信平台时,会实现自动装配

/**
 * 阿里云短信配置类
 *
 * @author yangtong
 */
@Configuration(proxyBeanMethods = false)
@AllArgsConstructor
@ConditionalOnClass(IAcsClient.class)
@EnableConfigurationProperties(SmsProperties.class)
@ComponentScan(basePackages={"com.mediway.hos.app.auth.sms"},useDefaultFilters = true)
public class AliSmsConfiguration {


	@Bean
	public AliSmsTemplate aliSmsTemplate(SmsProperties smsProperties) {
		IClientProfile profile = DefaultProfile.getProfile(smsProperties.getRegionId(), smsProperties.getAccessKey(), smsProperties.getSecretKey());
		IAcsClient acsClient = new DefaultAcsClient(profile);
		AliSmsTemplate aliSmsTemplate=new AliSmsTemplate(smsProperties, acsClient);
		//type传参和枚举类中的定义保持一致
		SmsBuilder.putPool("2",aliSmsTemplate);
		return aliSmsTemplate;
	}
}

4、新增短信平台构建类,实现短信平台的客户端定义以及参数设置

/**
 * 阿里云短信构建类
 *
 * @author yangtong
 */
public class AliSmsBuilder {

	@SneakyThrows
	public static SmsTemplate template(SmsVO sms) {
		SmsProperties smsProperties = new SmsProperties();
		smsProperties.setTemplateId(sms.getTemplateId());
		smsProperties.setAccessKey(sms.getAccessKey());
		smsProperties.setSecretKey(sms.getSecretKey());
		smsProperties.setRegionId(sms.getRegionId());
		smsProperties.setSignName(sms.getSignName());
		smsProperties.setAppId(sms.getAppId());
		IClientProfile profile = DefaultProfile.getProfile(smsProperties.getRegionId(), smsProperties.getAccessKey(), smsProperties.getSecretKey());
		IAcsClient acsClient = new DefaultAcsClient(profile);
		return new AliSmsTemplate(smsProperties, acsClient);
	}

}

# 配置口令生成规则

配置用于动态口令登录方式的动态口令生成策略,配置的参数如下:

onetimepassword:
  #口令组成   数字:INT 小写字母:LOWER 大写字母:UPPER 特殊字符:SPECIAL
  form:
    - INT
    - LOWER
  #长度
  length: 5
  #有效期(单位是s)
  effectiveTime: 10
  #前缀
  prefix:
  #默认推送方式 短信:SMS  邮箱:mail
  defaultMode: SMS

# 动态口令登录认证

动态口令登录整体的流程如下

动态口令登录流程

# 获取口令

输入手机号,调用获取口令的接口发送口令,并生成唯一标志uuid返回。uuid用于调用登录授权接口的参数之一。

  • 接口地址

http://localhost:8367/api/otp-auth/otp-code?phone=18660886275

  • 参数
    • phone 手机号码
  • 返回数据
    • uuid:唯一标志
{
    "code": "200",
    "msg": "success",
    "data": {
        "uuid": "51430a0f41ac4a12b93c69459e0b0db8"
    },
    "success": true
}

# 口令认证

口令认证需要的参数:

  • captcha-code: 手机上收到的验证码 ,必须放到header中传递;
  • captcha-key: 上述获取口令时返回的uuid,必须放到header中传递;
  • grantType:授权类型,值必须是otp;
  • loginName: 手机号,作为登录名传递;
  • tenantId: 租户id,开启租户则需要传递,否则不用传。

口令认证逻辑如下: 1、校验参数是否为空 2、 校验口令 3、 构建用户信息,只包含手机号(存在loginName字段中),其他用户信息需要在后续校验中去补充。

# 口令认证后的操作

口令认证通过后,业务系统还有根据自己的用户来源去校验,一般是:

  • 校验是否存在这个手机号的用户
  • 校验用户是否有效的

这部分逻辑要根据自己的业务去重写,如果使用的用户组织中心的用户,就调用他们的用户接口去校验。 这里提供了一个重写用户校验逻辑的方式,如下: 1、 继承 IOTPSuccessHandler接口类,重写 getUserInfo方法逻辑

getUserInfo接口参数有两个:

  • HosUserDetail hosUser: 口令认证返回的用户对象,只包含loginName字段数据,实际值为手机号
  • Kv customInfo:登录前操作返回的数据
public class OTPSuccessHandlerImpl implements IOTPSuccessHandler {
   @Autowired
   UserService userService;
   @Override
   public HosUserDetail getUserInfo(HosUserDetail hosUser, Kv customInfo) {
       ///检查用户的有效性,不需要进行密码的校验,后续应该的根据第三方用户id去对照表里去校验用户的有效性 TODO
       LoginLog successLog=(LoginLog) ThreadLocalManager.get("logInfo");
       if(CommonUtils.isNotEmpty(successLog)){
           successLog.setLoginName(hosUser.getLoginName());
           ThreadLocalManager.put("logInfo",successLog);
       }
       ///根据手机号,查询用户信息,后期根据手机号查找,现在还没有人员信息表,HR TODO
       User chekuser=userService.getById("000001");
       if(CommonUtils.isEmpty(chekuser)){
           throw new AuthFailedException(OTPAuthFailedExceptionEnum.OTP_AUTH_USER_NOT_FOND.getCode(),hosUser.getoAuthId());
       }
       User user=userService.checkLoginUser(chekuser.getLoginName());
       HosUser hosOriginUser=(HosUser)hosUser;

       String userCode="";
       ///后边从hr的人员表中,获取用户的工号 TODO
       ///重新组装用户信息
       HosUser hu=new HosUser(user.getId(), user.getLoginName(), userCode, user.getName(), user.getTenantId(), null, null, null,hosOriginUser.getoAuthId(),hosOriginUser.getGrantType(), user.getInstitutionId(),null,null,null,null);
       return BaseAuthUtil.setUserRoleData(hu);
       //return hu;
   }
}

2、注册bean

上述新建了自己的业务类后,还需要注册到Spring容器中,否则将获取不到,可通过在配置类中通过@Bean注册,如下

     @Configuration
     @ConditionalOnClass(OTPTokenGranter.class)
     public  class BaseOTPAuthConfig {
         @Bean
         public IOTPSuccessHandler otpSuccessHandlerImpl(){
             return new OTPSuccessHandlerImpl();
         }
     }

# 第三方平台登录

# 概述

第三方登录,是基于用户在第三方平台上已有的账号和密码来快速完成己方应用的登录功能。 而这里的第三方平台,一般是已经拥有大量用户的平台,国外的比如Facebook,Twitter等,国内的比 如微博、微信、QQ等。

# 引入依赖

       <dependency>
           <groupId>com.mediway.hos</groupId>
           <artifactId>hos-app-auth-social</artifactId>
       </dependency>

# 配置第三方平台数据

配置支持第三方登录平台认证的相关数据,维护的数据来源于第三方平台,客户需要优先到对应平台进行账号注册以及对接信息维护。具体如下如下:

#第三方登录
social:
  hos:
    codeUrl: ${social.hos.serverUrl}:8081/oauth/authorize
    tokenUrl: ${social.hos.serverUrl}:8006/iam/oauth/token
    userUrl: ${social.hos.serverUrl}:8006/iam/oauth/resource/getUserInfo
    serverUrl: http://localhost
  enabled: true
  domain: http://localhost:8000
  oauth:
    GITHUB:
      client-id: 233************
      client-secret: 233************************************
      redirect-uri: ${social.domain}/oauth/redirect/github
    GITEE:
      client-id: b7f7f6e32814f9e21f57f0b0c5c6d120f6ebdc6eee118bb61a72fc63769880d0
      client-secret: def735d4f6fd1fb6c1ec8435d1e072fccca0da04b2d44dd79d7b09c89ecff26c
      redirect-uri: ${social.domain}/oauth/redirect/gitee
    WECHAT_OPEN:
      client-id: 233************
      client-secret: 233************************************
      redirect-uri: ${social.domain}/oauth/redirect/wechat
    QQ:
      client-id: 233************
      client-secret: 233************************************
      redirect-uri: ${social.domain}/oauth/redirect/qq
    DINGTALK:
      client-id: 233************
      client-secret: 233************************************
      redirect-uri: ${social.domain}/oauth/redirect/dingtalk
    HOS:
      client-id: n34k056l7Nf5-test
      client-secret: F86Uk6dSPP3R-test
      redirect-uri: ${social.domain}/oauth/redirect/hos

# 第三方平台认证的认证流程

第三方平台按照oauth2协议中的授权码模式对接认证服务。使用JustAuth插件实现,详细参见插件集成。

1、获取认证平台的授权地址

根据上述配置的数据,返回所需认证平台的授权地址。

  • 接口地址: http://localhost:8367/api/social-auth/oauth/render/HOS
  • 参数:认证平台的编码,就是上述地址中的后缀【HOS】
  • 返回数据:
{
    "code": "200",
    "msg": "success",
    "data": "http://10.1.20.150:8082/oauth/authorize?response_type=code&client_id=66EA4l7s7EID&redirect_uri=http://10.1.20.150:8080/oauth//redirect/hos&state=574340551f0df9c5f80ee7ba09f137a9&scope=",
    "success": true
}

返回的认证地址包含之前配置的数据,具体含义如下:

  • response_type: 值为code 代表授权码模式;
  • client_id 应用id;
  • redirect_uri 回调地址,一般是前端用于接收登录成功后接收授权码的地址;
  • state:代表本次登录认证的状态码,登录时除了校验授权码还有校验state ,一般存在缓存中。第三方平台登录后,会把这个参数回调给本系统。如果此值无效,则也无法登录。
# 登录认证

上述获取到认证平台的登录地址后,前端需要跳转到这个地址上,用户进行登录,登录成功后,会根据配置的回调地址,跳转到这个回调地址上, 并带上code授权码和state状态码(本系统生成的)。此处的登录认证就需要根据授权码和状态码进行校验,步骤为:

1、校验state状态码 2、根据授权码获取访问令牌 3、根据访问令牌获取用户信息 4、查看当前用户是否存在认证平台和本系统的对照,不存在 则抛出异常了 5、将对照的用户id 和其他用户信息(一般是账号和第三方平台的用户id)存入用户对象中传递给下一个流程

# 认证成功后的操作

第三方平台的用户信息获取到后,还需要根据对照用户id或者用户账号进行本系统的用户校验。需要根据本系统的用户来源去重写逻辑,实现方式如下:

1、继承 ISocialSuccessHandler接口类,重写 getUserInfo方法逻辑

getUserInfo接口参数有两个:

  • HosUserDetail hosUser: 第三方认证返回的用户对象,包含用户对照返回的本系统的用户id或者用户账号
  • String source: 认证来源,也就是认证平台的编码
  • Kv customInfo:登录前操作返回的数据
public class SocialSuccessHandlerImpl implements ISocialSuccessHandler {
   @Autowired
   UserService userService;
   @Override
   public HosUserDetail getUserInfo(HosUserDetail hosUser, String source, Kv customInfo) {
       ///检查用户的有效性,不需要进行密码的校验,后续应该的根据第三方用户id去对照表里去校验用户的有效性 TODO
       LoginLog successLog=(LoginLog) ThreadLocalManager.get("logInfo");
       if(CommonUtils.isNotEmpty(successLog)){
           successLog.setLoginName(hosUser.getLoginName());
           ThreadLocalManager.put("logInfo",successLog);
       }
       ///用户对照
       User user=null;
       if(SocialProperties.me().isUserControl()){
           User chekuser=userService.getById(hosUser.getUserId());
           if(CommonUtils.isEmpty(chekuser)){
               throw new AuthFailedException(SocialAuthFailedExceptionEnum.SOCIAL_USER_BIND_INVALID.getCode(),hosUser.getoAuthId());
           }
            user=userService.checkLoginUser(chekuser.getLoginName());
           if(CommonUtils.isEmpty(user)){
               throw new AuthFailedException(SocialAuthFailedExceptionEnum.SOCIAL_USER_BIND_INVALID.getCode(),hosUser.getoAuthId());
           }
       }else{///不需要用户对照,直接根据用户登录名去查询用户,查询不到就抛出异常
            user=userService.checkLoginUser(hosUser.getLoginName());
           if(CommonUtils.isEmpty(user)){
               throw new AuthFailedException(SocialAuthFailedExceptionEnum.SOCIAL_USER_CODE_NOT_FOND.getCode(),hosUser.getoAuthId());
           }
       }

       ///
       HosUser hosOriginUser=(HosUser)hosUser;

       String userCode="";
       ///后边从hr的人员表中,获取用户的工号 TODO
       ///重新组装用户信息
       HosUser hu=new HosUser(user.getId(), user.getLoginName(), userCode, user.getName(), user.getTenantId(), null, null, null,hosOriginUser.getoAuthId(),hosOriginUser.getGrantType(), user.getInstitutionId(),null,null,null,null);
       return BaseAuthUtil.setUserRoleData(hu);
       //return hu;
   }
}

2、注册bean

上述新建了自己的业务类后,还需要注册到Spring 容器中,否则将获取不到,可通过在配置类中通过@Bean注册,如下

         @Configuration
         @ConditionalOnClass(SocialTokenGranter.class)
         public class BaseSocialAuthConfig {
             @Bean
             public SocialSuccessHandlerImpl socialSuccessHandlerImpl(){
                 return new SocialSuccessHandlerImpl();
             }
     
         }

# 刷新令牌登录

用户之前登录成功过并签发了访问令牌和刷新令牌,如果访问令牌已经过期,则可以通过刷新令牌进行再次登录。刷新令牌登录的逻辑很简单, 就是将令牌解析获取到其中的用户信息,并再次生成新的令牌返回给前端。

刷新令牌登录需要的参数如下:

  • grant_type :值必须为refresh_token
  • refresh_token:刷新令牌的值,由上次登录成功后签发。

一般来说刷新令牌在没过期的情况下,都会正确解析并获取到用户信息,但是用户是否还有效,就需要业务系统再去做校验,实现方式如下:

1、继承IRefreshSuccessHandler 重写 getUserInfo方法

getUserInfo接口参数有两个:

  • HosUserDetail hosUser: 上次登录返回的用户信息
  • Kv customInfo:登录前操作返回的数据
 public class RefreshSuccessHandlerImpl implements IRefreshSuccessHandler {
     @Autowired
     UserService userService;
     @Override
     public HosUserDetail getUserInfo(HosUserDetail hosUser, Kv customInfo) {
         ///检查用户的有效性,不需要进行密码的校验
         ///获取用户,验证密码
         User user=userService.checkLoginUser(hosUser.getLoginName());
         if(CommonUtils.isEmpty(user)){
             throw new AuthFailedException(AuthFailedExceptionEnum.LOGIN_NAME_PASSWORD_NOT_CORRECT);
         }
         return hosUser;
     }
 }

2、注册bean

上述新建了自己的业务类后,还需要注册到Spring 容器中,否则将获取不到,可通过@Component或者在配置类中通过@Bean注册,如下

       @Bean
       public RefreshSuccessHandlerImpl refreshSuccessHandlerImpl(){
           return new RefreshSuccessHandlerImpl();
       }

# 扩展认证方式

如果目前支持的登录方式不满足业务系统的需要,也可以去扩展,例如增加一个使用His票据登录的方式,实现方式如下:

1、继承 AbstractTokenGranter抽象类,重写grant方法

继承抽象类后,必须设置一个成员变量GRANT_TYPE,并为其指定一个值,代表一个新的授权方式,不要与默认的方式重名。

  • LoginParamVO loginParamVO: 登录参数
  • Kv customInfo:登录前操作返回的数据
public class HisTokenGranter extends AbstractTokenGranter {
	public static final String GRANT_TYPE = "his";

	@Value("${auth.login.hisUserUrl:http://127.0.0.1/imedical/web/dhcservice.SSUser.cls?wsdl=1}")
    String hisUserUrl="";

	@Value("${auth.login.hisUserMethod:GetCASInfo}")
	String hisUserMethod="GetCASInfo";

	public HisTokenGranter() {
		super(GRANT_TYPE);
	}
	@Override
	public HosUserDetail grant(LoginParamVO loginParamVO, Kv customInfo) {
		String casToken=BaseHttpContextUtils.getRequest().getParameter("CASTicket");
		if(CommonUtils.isOneEmpty(casToken)){
			throw new HisAuthFailedException(HisAuthFailedExceptionEnum.HIS_AUTH_FAILED);
		}
		try {
			boolean isCorrect=true;//JwtBaseAppUtils.isSupportSys(hosToken);
			if(isCorrect){
				////调用webservice获取用户信息,并封装到用户对象里
				//http://127.0.0.1/imedical/web/dhcservice.SSUser.cls?wsdl=1
				Client client= WebServiceUtil.getClient(hisUserUrl, 5000, 5000,null,null);
				Object[] param=new Object[1];
				param[0]=casToken;
				//String resultData= WebServiceUtil.sendByClient(client,hisUserMethod,param);
				String resultData= CXFServiceUtil.getWebserviceDateCommon(hisUserUrl,hisUserMethod,param,5000, 5000,null);
				JSONObject jsonObject = XML.toJSONObject(resultData);
				///成功
				if(jsonObject.getJSONObject("CASLogin").getInt("RtnCode")==1){
					JSONObject obj=jsonObject.getJSONObject("CASLogin");
					HosUser hosUser=new HosUser();
					String loginName=obj.getStr("UserCode");
					String locCode=obj.getStr("LocCode");
					String logonDate=obj.getStr("LogonDate");
					String logonTime=obj.getStr("LogonTime");

					hosUser.setLoginName(loginName);
					hosUser.setOrgCode(locCode);
					hosUser.setInitLoginTime(new Date());
					return hosUser;
				}else{
					throw new HisAuthFailedException(HisAuthFailedExceptionEnum.HIS_AUTH_FAILED);
				}
			}else{
				throw new HisAuthFailedException(HisAuthFailedExceptionEnum.HIS_AUTH_FAILED);
			}
		} catch (Exception e) {
			// 此处只要有异常都需要用户重新登录,不管是不是refreshToken失效
			throw new HisAuthFailedException(HisAuthFailedExceptionEnum.HIS_AUTH_FAILED);
		}
	}
}

2、注册bean 上述新建了自己的业务类后,还需要注册到Spring容器中,否则将获取不到,可通过@Component或者在配置类中通过@Bean注册,如下

  @Bean
  public HisTokenGranter hisTokenGranter() {
      return new HisTokenGranter();
  }

# OAuth2对接

# 概述

该模块主要是满足企业B/S应用、移动端应用的Oauth2标准认证支持。对接模式包括授权码模式、隐式授权模式、密码模式、客户端凭证模式,B/S应用系统建议采用授权码模式进行对接。

# 相关配置

客户端信息配置

客户端信息支持三种数据源,数据库、yaml配置以及webservice接口。

  • 数据库
auth:
  oauth:
    client:
      #database表示使用数据库数据源
      datasourcetype: database

配置页面说明,【功能手册】->【认证策略配置】

  • yaml配置
auth:
  oauth:
    client:
      #conf 表示使用当前配置的数据
      datasourcetype: conf
      clientInfoList:
        - clientId: test1
          clientSecret: test1
          #回调地址,多个用逗号隔开或者采用list方式配置,例如scopes字段的配置
          redirectUris: [https://www.baidu.com,https://www.baidu2.com]
          #访问域,多个采用逗号隔开或者使用list形式
          scopes:
            - read
            - write
          deviceId: 
          ignoreCheckRedirectUri: true
          isAutoApprove: true
          resourceIds:
            - USER-RESOURCE
          #授权模式(authorization_code,password,refresh_token,client_credentials,implicit
          #授权码模式,密码模式,刷新模式,客户端模式,隐式授权模式)
          authorizedGrantTypes:
            - authorization_code
            - password
            - refresh_token
          #访问token时间--单位秒
          accessTokenValiditySeconds: 1000
          #刷新token时间--如果authorizedGrantTypes没有配置refresh_token类型,就不存在有刷新token
          refreshTokenValiditySeconds: 10000
  • webservice接口
auth:
  oauth:
    client:
      #webservice表示使用外部接口类型数据源
      datasourcetype: webservice
      webservice:
        url: http://localhost:8367/contract/selectLikeList
        type: restful

OAuth2生成的token类型配置

oauth2:
  #oauth2存储类型 token--redis存储  jwt--使用jwt类型token   inMemory--内存存储    
  storeType: token

# 授权码模式

1、请求授权码
前端请求地址:

  http://127.0.0.1:8011/oauth/authorize?response_type=code&client_id=test&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Foauth%2Fredirect%2Fhos&state=35e6b2693a937afc1c1402efbd156102&scope=

参数: Body参数:

  • client_id:客户端id
  • redirect_uri:回调地址
  • response_type:code
  • scope:请求域
  • state:状态码
    成功结果:
    未登录会进入登录页面,输入用户名密码成功后,跳转到回调地址
http://192.168.101.138:8099/DTH_SSO/login?code=4gRIrN3i6y&state=fdsfsd

2、根据授权码获取token
后端接口地址:

  http://127.0.0.1:8006/api/oauth/token

Body参数:

  • client_id:客户端id
  • client_secret:客户端秘钥(不能泄露)
  • redirect_uri:回调地址
  • grant_type:authorization_code(固定)
  • code:授权码值
  • scope:请求域(跟获取code时保持一样)
    成功结果:
{
    "code": "200",
    "msg": "success",
    "data": {
        "access_token": "0edb4a06-761c-4584-9085-8245d9bffcb5",
        "token_type": "bearer",
        "refresh_token": "32e3df54-5de0-42e4-af96-4b82e7e95fa2",
        "expires_in": 6665,
        "scope": "all"
    },
    "success": true
}

失败结果:

{
  "code": "500",
  "msg": "授权码无效::OauthInfoException",
  "data": null,
  "success": false
}

3、根据token获取用户信息
后端接口地址:

  http://127.0.0.1:8006/api/oauth/resource/getUserInfo

参数: Body参数:

  • access_token:上一步获取到的accessToken
    成功结果:
{
    "code": "200",
    "msg": "success",
    "data": {
        "userInfo": "admin",
        "clientInfo": {
            "responseTypes": [
                "code"
            ],
            "clientId": "5dLgvm42d75O",
            "authorities": [],
            "approved": true,
            "extensions": {},
            "scope": [
                "all"
            ],
            "resourceIds": []
        }
    },
    "success": true
}

失败结果:

{
    "msg": "Invalid access token: 0edb4a06-761c-4584-9085-8245d9bffcb51",
    "code": "401"
}

# 隐式授权模式

1、请求token
前端请求地址:

  http://127.0.0.1:8011/oauth/authorize?response_type=token&client_id=test&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Foauth%2Fredirect%2Fhos&state=35e6b2693a937afc1c1402efbd156102&scope=

参数: Body参数:

  • client_id:客户端id
  • redirect_uri:回调地址
  • response_type:token(固定)
  • scope:请求域
  • state:状态码
    成功结果:
    未登录会进入登录页面,输入用户名密码成功后,跳转到回调地址
http://192.168.101.138:8099/DTH_SSO/login#access_token=d8c3d163-4554-4b66-b56b-ce2387fd5ddc&token_type=bearer&state=fdsfsd&expires_in=6665&scope=all

2、根据token获取用户信息
后端接口地址:

  http://127.0.0.1:8006/api/oauth/resource/getUserInfo

参数: Body参数:

  • access_token:上一步获取到的accessToken
    成功结果:
{
    "code": "200",
    "msg": "success",
    "data": {
        "userInfo": "admin",
        "clientInfo": {
            "responseTypes": [
                "code"
            ],
            "clientId": "5dLgvm42d75O",
            "authorities": [],
            "approved": true,
            "extensions": {},
            "scope": [
                "all"
            ],
            "resourceIds": []
        }
    },
    "success": true
}

失败结果:

{
    "msg": "Invalid access token: 0edb4a06-761c-4584-9085-8245d9bffcb51",
    "code": "401"
}

# 密码模式

1、根据用户名、密码获取token
后端接口地址:

  http://127.0.0.1:8006/api/oauth/token

Body参数:

  • client_id:客户端id
  • client_secret:客户端秘钥(不能泄露)
  • grant_type:password(固定)
  • scope:请求域(可以为空)
    成功结果:
{
    "code": "200",
    "msg": "success",
    "data": {
        "access_token": "0edb4a06-761c-4584-9085-8245d9bffcb5",
        "token_type": "bearer",
        "refresh_token": "32e3df54-5de0-42e4-af96-4b82e7e95fa2",
        "expires_in": 6665,
        "scope": "all"
    },
    "success": true
}

失败结果:

{
  "code": "500",
  "msg": "用户校验失败::OauthInfoException",
  "data": null,
  "success": false
}

2、根据token获取用户信息
后端接口地址:

  http://127.0.0.1:8006/api/oauth/resource/getUserInfo

参数: Body参数:

  • access_token:上一步获取到的accessToken
    成功结果:
{
    "code": "200",
    "msg": "success",
    "data": {
        "userInfo": "admin",
        "clientInfo": {
            "responseTypes": [
                "code"
            ],
            "clientId": "5dLgvm42d75O",
            "authorities": [],
            "approved": true,
            "extensions": {},
            "scope": [
                "all"
            ],
            "resourceIds": []
        }
    },
    "success": true
}

失败结果:

{
    "msg": "Invalid access token: 0edb4a06-761c-4584-9085-8245d9bffcb51",
    "code": "401"
}

# 客户端模式

1、获取客户端token
后端接口地址:

  http://127.0.0.1:8006/api/oauth/token

Body参数:

  • client_id:客户端id
  • client_secret:客户端秘钥(不能泄露)
  • grant_type:client_credentials(固定)
  • scope:请求域(可以为空)
    成功结果:
{
  "code": "200",
  "msg": "success",
  "data": {
    "access_token": "48630108-eb31-4a0b-a371-867c7f2028e8",
    "token_type": "bearer",
    "expires_in": 6665,
    "scope": "all"
  },
  "success": true
}

失败结果:

{
  "code": "500",
  "msg": "用户校验失败::OauthInfoException",
  "data": null,
  "success": false
}

2、根据token获取用户信息
后端接口地址:

  http://127.0.0.1:8006/api/oauth/resource/getUserInfo

参数: Body参数:

  • access_token:上一步获取到的accessToken
    成功结果:
{
    "code": "200",
    "msg": "success",
    "data": {
        "clientInfo": {
            "clientId": "5dLgvm42d75O",
            "authorities": [],
            "approved": true,
            "extensions": {},
            "scope": [
                "all"
            ],
            "resourceIds": []
        }
    },
    "success": true
}

失败结果:

{
    "msg": "Invalid access token: 0edb4a06-761c-4584-9085-8245d9bffcb51",
    "code": "401"
}

# 注销用户

取消登录授予的令牌,也就是用户退出。本身JWT token是无状态的,签发的令牌只要不过期就会一直有效。但是这样会存在 一定的安全隐患,容易发生token泄露,并且系统也无法管理已经在线的用户。因此对于已经登录成功的用户,会将其 登录的信息(用户信息以及令牌信息等)保存到缓存中。这样用户就可以执行退出的操作,根据携带的令牌将缓存的保存的 用户信息清除,另外还会清除在用户登录期间认证过的第三方系统的令牌,同时调用第三方的退出地址,通知第三方进行退出处理,从而达到单点退出的效果。

  • 接口地址:http://localhost:8367/api/auth-logout
  • 参数:无,只需携带登录时签发的访问令牌放到headeraccess-token)即可

清除掉保存在缓存中的用户信息后,业务系统可能还会存在一些操作,例如记录登出的日志以及其他。因此扩展了一种方式, 业务系统可以自定义登录后执行的逻辑的方式,如下:

1、 继承ILogoutSuccessHandler接口,实现recordLogout方法

public class LogoutSuccessHandlerImpl implements ILogoutSuccessHandler {
    @Autowired
    LoginLogService loginLogService;
    @Autowired
    LogProcessorContext logProcessorContext;
    @Autowired
    private CheckTokenEndpoint checkTokenEndpoint;
    @Autowired
    private CustomTokenServicesImpl customTokenServices;
    @Autowired
    private ClientDetailsCommonService clientDetailsCommonService;

    @Override
    public void recordLogout(OnLineUser OnLineUser) {
        HttpServletRequest request= HttpContextUtil.getHttpServletRequest();
        String loginAccessToken = AuthServerCommonUtils.getTokenByRequestHeader();
        String ssoToken = AuthServerCommonUtils.generateKey(loginAccessToken);
        //获取本次登录所有认证过的访问令牌
        List<String> oAuth2AccessTokenList = AuthServerTokenCache.me().getOAuth2AccessTokenList(ssoToken);
        for (String accessToken:oAuth2AccessTokenList) {
            //解析令牌并取出对应的客户端信息
           Map<String,Object> data= (Map<String, Object>) checkTokenEndpoint.checkToken(accessToken);
           String clientId=data.get("client_id").toString();
           ClientDetailCommon clientDetailCommon = clientDetailsCommonService.loadClientInfoByClientId(clientId);
           String logoutUrl=clientDetailCommon.getLogoutUrl();
            //撤销token
            customTokenServices.revokeToken(accessToken);
           //判断是否需要单点退出,需要调用退出接口并进行日志记录
           if(CommonUtils.isNotEmpty(logoutUrl)){
               //调用应用的退出地址,通知应用进行退出处理
               Map<String, String> map = new HashMap<String, String>();
               map.put("logoutRequest", accessToken);
               map.put("userName",data.get("user_name").toString());
               String resultStr= HttpUtils.post(logoutUrl,map);
               JSONObject result;
               if(CommonUtils.isNotEmpty(resultStr)){
                   result= JSONUtil.parseObj(resultStr);
               }else{
                   result=new JSONObject();
                   result.set("code","-1");
               }
               //注销日志记录
               IamLoginLog iamLoginLog=new IamLoginLog();
               //浏览器
               iamLoginLog.setBrowser(BrowserUtils.checkBrowse(request));
               ///登录ip
               iamLoginLog.setIp(IPUtils.getIpAddr(request));
               ////操作系统
               // 准备ip地址、操作系统等信息
               UserAgent userAgent = UserAgentUtil.parse(BaseHttpContextUtils.getRequest().getHeader("User-Agent"));
               iamLoginLog.setOs(userAgent.getOs().toString());
               iamLoginLog.setMac(IPUtils.getClientMAC(request));
               iamLoginLog.setLoginLocation(BaseIpUtils.getRealAddressByIp(BaseIpUtils.getIp()));
               iamLoginLog.setLoginName(OnLineUser.getLoginName());
               iamLoginLog.setTitle(LoginLogTypeEnum.LOGOUT.getName());
               iamLoginLog.setMsg(LoginLogStateEnum.LOGOUT_SUCCESS.getName());
               iamLoginLog.setClientId(clientId);
               if(result.getStr("code").equals("200")){
                   ///注销成功
                   iamLoginLog.setIsSuccess(WhetherEnum.TRUE.getCode());
                   iamLoginLog.setMsg(LoginLogStateEnum.LOGOUT_SUCCESS.getName());
               }else{
                   ///注销失败
                   iamLoginLog.setIsSuccess(WhetherEnum.FALSE.getCode());
                   iamLoginLog.setMsg(LoginLogStateEnum.LOGOUT_FAIL.getName());
               }
               SpringContextUtils.getBean(LogProcessorContext.class).handleLog(iamLoginLog);
           }
        }
    }

}

2、注册bean

上述新建了自己的业务类后,还需要注册到Spring容器中,否则将获取不到,可通过@Component或者在配置类中通过@Bean注册,如下

      @Bean
      public ILogoutSuccessHandler logoutSuccessHandler() {
          return new LogoutSuccessHandlerImpl();
      }

# 身份鉴权

用户登录成功后,如果想要访问系统其他资源 需要携带上签发的访问令牌。系统会根据签发的令牌 进行鉴权。 携带的令牌需将其放到header中,键为access-tokne,值为访问令牌的值。

系统解析令牌后,如果令牌有效,则会获取到用户的信息,另外还需要判断在在线用户缓存中是否存在用户信息。存在则代表鉴权成功,否则失败。

鉴权成功后,会将获取的用户信息放到上下文中,也提供了工具类获取当前用户的信息,如下:

获取当前用户信息的工具类