前言
xxl-job 是一款 java 開發的、開源的分布式任務排程系統,自帶了登入認證功能,不支援對接、擴充 LDAP 、OIDC 等标準認證系統,考慮到單獨維護 xxl-job 自有的使用者系統不友善,以及存在人員離職、調崗、權限變動等需要及時調整使用者權限的情況,需要接入公司統一的 OIDC 認證系統
相關連結
- xxl-job : https://github.com/xuxueli/xxl-job
- oidc : https://openid.net/connect/
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 類似又有差別,基本流程如下:
- 用戶端準備包含所需請求參數的身份驗證請求。
- 用戶端将請求發送到授權伺服器。
- 授權伺服器對終端使用者進行身份驗證。
- 授權伺服器獲得終端使用者同意/授權。
- 授權伺服器将 code 發送回用戶端 。
- 用戶端将 code 發送到令牌端點擷取 access_token 和 idToken。
- 用戶端使用私鑰驗證 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 後的認證流程:
- 判斷是否登入,已經登入則跳轉到登入成功的頁面
- 擷取 code ,不存在則調整到登入頁面
- 通過 code 請求 OIDC 授權伺服器擷取 UserInfo
- 處理内部登入邏輯(使用者是否存在,存在則設定 Cookie,不存在則先建立使用者在設定 Cookie)
- 跳轉到登入成功的頁面
跳轉登入頁邏輯做了封裝,因為,根據登入模式的不同,有不同的處理邏輯:
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