10

xxl-job 登入功能集成 OIDC 统一认证

 3 years ago
source link: https://my.oschina.net/klblog/blog/5088057
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

xxl-job 是一款 java 开发的、开源的分布式任务调度系统,自带了登录认证功能,不支持对接、扩展 LDAP 、OIDC 等标准认证系统,考虑到单独维护 xxl-job 自有的用户系统不方便,以及存在人员离职、调岗、权限变动等需要及时调整用户权限的情况,需要接入公司统一的 OIDC 认证系统

xxl-job 自身认证功能分析

xxl-job 自带的登录认证用户信息维护在 mysql 的 user 表中,用户从登录页提交用户名和密码,后端查询用户信息、校验密码,验证成功后设置登录信息到 cookie 中,采用 cookie 保持登录状态,大致的流程如下:

OIDC 的认证流程

OIDC(OpenID Connect) 是一种融合了 OpenID 、Oauth2 的身份认证协议。认证流程上和 Oauth2 基本一致,但是,OIDC 在 Oauth2 的 access_token 基础上新增了一个使用 jwt 生成的 idToken,idToken 中携带了用户基本信息,使用私钥验签成功后,可直接使用,省略了通过 access_token 获取用户信息的步骤。所以 OIDC 的认证流程既和 Oauth2 类似又有区别,基本流程如下:

  1. 客户端准备包含所需请求参数的身份验证请求。
  2. 客户端将请求发送到授权服务器。
  3. 授权服务器对终端用户进行身份验证。
  4. 授权服务器获得终端用户同意/授权。
  5. 授权服务器将 code 发送回客户端 。
  6. 客户端将 code 发送到令牌端点获取 access_token 和 idToken。
  7. 客户端使用私钥验证 idToken 拿到用户标识 or 将 access_token 发送到授权服务器获取用户标识。

这里注意最后第 6、7 点操作,这里开始 OIDC 和 Oauth2 不一样了

xxl-job 集成 OIDC 后的认证流程

从 OIDC 的认证流程得知,终端用户通过授权服务器授权认证后,授权服务器会携带 code 重定向到客户端服务,客户端通过 code 可以拿到用户唯一标识,通过这个唯一标识,可以继续完成客户端原本的认证流程。集成 OIDC 后,xxl-job 登录的大致流程如下:

集成 OIDC 后,系统认证保持用户登录状态的机制没有变化,依然使用  Cookie ,需要特殊处理以及关注地方有:

  • 用户首次登录系统,由于不存在系统中,需要先创建用户
  • 如果系统首次投产使用,记得设计一个可以从配置指定管理账户的功能,不然你得手动改数据库了
  • 如果系统运行很久了,需要考虑好原系统用户和 OIDC 授权用户的映射关系
  • 退出操作时,除了清除自身的用户登录状态,是否退出 OIDC 服务(实现 sso)的登录状态也需要考虑

xxl-job 登录模块重新设计

考虑开发环境使用 OIDC 服务不方便以及解耦对第三方认证授权服务的依赖,决定在集成 OIDC 时,兼容本地登录功能,登录流程由登录模式来控制区分,登录模式使用配置驱动,设计集成 OIDC 后 ,xxl-job 支持的登录模式如下:

  • onlyLocal :只支持 xxl-job 自身用户系统登录认证
  • onlyOidc : 只支持 Oidc 授权服务器授权登录认证
  • mix :混合模式,同时支持自身用户系统登录认证、Oidc 授权服务器授权登录认证

onlyLocal 模式登录界面:

mix 模式登录界面:

olnyOidc 模式登录界面:

olnyOidc 模式特殊,从设计上来说,如果需要保留用户使用习惯,可以保留一个跳转到 OIDC 授权服务器的链接按钮给用户点击。如果做的干净利落,在 olnyOidc 模式下,访问登录页可以直接 302 到 OIDC 授权服务器。

保留登录按钮的界面(实际这个页面取消了)

配置属性类,省略了get、set

/**
 * @author kl (http://kailing.pub)
 * @since 2021/6/21
 */
@ConfigurationProperties(prefix = "oidc")
@Configuration
public class OidcProperties {

    private static final LoginMod DEFAULT_LOGIN_MOD = LoginMod.onlyLocal;

    private LoginMod loginMod = DEFAULT_LOGIN_MOD;
    private String clientId;
    private String clientSecret;
    private String accessTokenUrl;
    private String profileUrl;
    private String redirectUri;
    private String logoutUrl;
    private String loginUrl;
    private List<String> adminLists = new ArrayList<>();

    public enum LoginMod {
        mix,
        onlyOidc,
        onlyLocal
    }
}

对应了如下的配置, 除了 login-mod  、redirect-uri 、admin-Lists 是 xxl-job 自身登录功能需要,其他的配置均由 OIDC 授权服务器提供

oidc.login-mod=onlyOidc
oidc.client-id = xxl-job-dev
oidc.client-secret = xx
oidc.base-url = https://sso.security.oidc.com
oidc.access-token-url = ${oidc.base-url}/cas/oidc/accessToken
oidc.login-url = ${oidc.base-url}/cas/oidc/authorize?response_type=code&client_id=${oidc.client-id}&redirect_uri=${oidc.redirect-uri}&scope=openid
oidc.redirect-uri = http://172.26.203.103:8071/oidc/tokenLogin
oidc.logout-url =${oidc.base-url}/cas/logout?service=${oidc.redirect-uri}
oidc.admin-Lists = chenkailing

Oidc 服务类,使用这个类里的方法和 OIDC 授权服务器交互

/**
 * @author kl (http://kailing.pub)
 * @since 2021/6/21
 */
@Service
public class OidcService {

    private final OidcProperties oidcProperties;
    private final RestTemplate restTemplate;
    
    public OidcService(OidcProperties oidcProperties, RestTemplate restTemplate) {
        this.oidcProperties = oidcProperties;
        this.restTemplate = restTemplate;
    }

    /**
     * 请求 OIDC 授权服务器,获取 idToken
     * idToken 中包含的信息 (非标准)
     * {
     * "sub": "248289761001",
     * "name": "Jane Doe",
     * "given_name": "Jane",
     * "family_name": "Doe",
     * "preferred_username": "j.doe",
     * "email": "[email protected]",
     * "picture": "http://example.com/janedoe/me.jpg"
     * }
     */
    public String getUsernameByCode(String code) {
        URI uri = UriComponentsBuilder.fromUriString(oidcProperties.getAccessTokenUrl())
                .queryParam("client_id", oidcProperties.getClientId())
                .queryParam("client_secret", oidcProperties.getClientSecret())
                .queryParam("redirect_uri", oidcProperties.getRedirectUri())
                .queryParam("code", code)
                .queryParam("grant_type", "authorization_code")
                .build()
                .toUri();
        AuthorizationEntity auth = restTemplate.getForObject(uri, AuthorizationEntity.class);
        Assert.notNull(auth, "AccessToken is null");
        String idToken = auth.getIdToken();
        int i = idToken.lastIndexOf('.');
        String withoutSignatureToken = idToken.substring(0, i+1);
        return Jwts.parserBuilder()
                .build()
                .parseClaimsJwt(withoutSignatureToken)
                .getBody()
                .get("sub", String.class);
    }

    /**
     * @return 1 : 管理员 、0 : 普通用户
     */
    public int getUserRole(XxlJobUser user) {
        List<String> adminLists = oidcProperties.getAdminLists();
        if (adminLists.contains(user.getUsername())) {
            return 1;
        }
        return 0;
    }

    public String getOidcLoginUrl() {
        return oidcProperties.getLoginUrl();
    }

    public OidcProperties.LoginMod getLoginMod() {
        return oidcProperties.getLoginMod();
    }

    public boolean isRedirectOidcLoginUrl() {
        return oidcProperties.getLoginMod().equals(OidcProperties.LoginMod.onlyOidc);
    }

    public String getLogoutUrl() {
        return oidcProperties.getLogoutUrl();
    }

    static class AuthorizationEntity {

        @JsonProperty("access_token")
        private String accessToken;
        @JsonProperty("id_token")
        private String idToken;
        @JsonProperty("refresh_token")
        private String refreshToken;
        @JsonProperty("expires_in")
        private String expiresIn;
        @JsonProperty("token_type")
        private String tokenType;

        private String scope;
    }

}

OIDC 登录接口,也就是提供给 OIDC 授权服务器回调的接口

/**
 * OIDC登录
 */
@RequestMapping(value = "/oidc/tokenLogin", method = {RequestMethod.POST, RequestMethod.GET})
@PermissionLimit(limit = false)
public ModelAndView loginByOidc(HttpServletRequest request, HttpServletResponse response, ModelAndView modelAndView) {
    if (loginService.ifLogin(request, response) != null) {
        modelAndView.setView(new RedirectView("/", true, false));
        return modelAndView;
    }
    String code = request.getParameter("code");
    if (Objects.isNull(code)) {
        return this.loginPageView();
    }
    String username = oidcService.getUsernameByCode(code);
    loginService.oidcLogin(username, response);

    modelAndView.setView(new RedirectView("/", true, false));
    return modelAndView;
}

这个接口对应了 xxl-job 集成 OIDC 后的认证流程:

  1. 判断是否登录,已经登录则跳转到登录成功的页面
  2. 获取 code ,不存在则调整到登录页面
  3. 通过 code 请求 OIDC 授权服务器获取 UserInfo
  4. 处理内部登录逻辑(用户是否存在,存在则设置 Cookie,不存在则先创建用户在设置 Cookie)
  5. 跳转到登录成功的页面

跳转登录页逻辑做了封装,因为,根据登录模式的不同,有不同的处理逻辑:

private ModelAndView loginPageView() {
    ModelAndView modelAndView = new ModelAndView(LOGIN_PAGE);
    if (oidcService.isRedirectOidcLoginUrl()) {
        modelAndView.setView(new RedirectView(oidcService.getOidcLoginUrl(), true, false));
    } else {
        modelAndView.addObject("loginMod", oidcService.getLoginMod().name());
        modelAndView.addObject("oidcLoginUrl", oidcService.getOidcLoginUrl());
    }
    return modelAndView;
}

目前的策略,如果配置了登录模式为 onlyOidc ,则跳转登录页时,直接 302 到 OIDC 授权页,否则,将登录模式,和 OIDC 授权页传递给前端,由前端控制展示的 UI


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK