diff --git a/.vscode/settings.json b/.vscode/settings.json index 091a6a6d8..b5db549bf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,5 +8,6 @@ "editor.defaultFormatter": "dbaeumer.vscode-eslint", "[typescript]": { "editor.defaultFormatter": "vscode.typescript-language-features" - } + }, + "editor.tabSize": 2 } \ No newline at end of file diff --git a/packages/libs/lib-server/src/system/settings/service/models.ts b/packages/libs/lib-server/src/system/settings/service/models.ts index 5256dbed9..50be68713 100644 --- a/packages/libs/lib-server/src/system/settings/service/models.ts +++ b/packages/libs/lib-server/src/system/settings/service/models.ts @@ -229,3 +229,14 @@ export class SysSafeSetting extends BaseSettings { } +export class SysOauthSetting extends BaseSettings { + static __title__ = 'OAuth设置'; + static __key__ = 'sys.oauth'; + static __access__ = 'private'; + + oauths: Record = {}; +} diff --git a/packages/libs/lib-server/src/user/addon/service/addon-service.ts b/packages/libs/lib-server/src/user/addon/service/addon-service.ts index fc8dc38a7..b009c4751 100644 --- a/packages/libs/lib-server/src/user/addon/service/addon-service.ts +++ b/packages/libs/lib-server/src/user/addon/service/addon-service.ts @@ -187,4 +187,14 @@ export class AddonService extends BaseService { }); return this.buildAddonInstanceConfig(res); } + + async getOneByType(req:{addonType:string,type:string,userId:number}) { + return await this.repository.findOne({ + where: { + addonType: req.addonType, + type: req.type, + userId: req.userId + } + }); + } } diff --git a/packages/plugins/plugin-lib/.eslintrc b/packages/plugins/plugin-lib/.eslintrc index eeb127a3d..66e06dfa4 100644 --- a/packages/plugins/plugin-lib/.eslintrc +++ b/packages/plugins/plugin-lib/.eslintrc @@ -17,6 +17,7 @@ "@typescript-eslint/ban-ts-ignore": "off", "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-empty-function": "off", - "@typescript-eslint/no-unused-vars": "off" + "@typescript-eslint/no-unused-vars": "off", + "max-len": [0, 160, 2, { "ignoreUrls": true }] } } diff --git a/packages/plugins/plugin-lib/src/index.ts b/packages/plugins/plugin-lib/src/index.ts index 7208994b3..f97e78bcd 100644 --- a/packages/plugins/plugin-lib/src/index.ts +++ b/packages/plugins/plugin-lib/src/index.ts @@ -7,4 +7,5 @@ export * from "./qiniu/index.js"; export * from "./ctyun/index.js"; export * from "./oss/index.js"; export * from "./s3/index.js"; -export * from "./lib/index.js"; \ No newline at end of file +export * from "./lib/index.js"; +export * from "./service/index.js"; diff --git a/packages/plugins/plugin-lib/src/service/index.ts b/packages/plugins/plugin-lib/src/service/index.ts new file mode 100644 index 000000000..55d7b5c29 --- /dev/null +++ b/packages/plugins/plugin-lib/src/service/index.ts @@ -0,0 +1 @@ +export * from "./site-info.js"; diff --git a/packages/plugins/plugin-lib/src/service/site-info.ts b/packages/plugins/plugin-lib/src/service/site-info.ts new file mode 100644 index 000000000..667b2a9c2 --- /dev/null +++ b/packages/plugins/plugin-lib/src/service/site-info.ts @@ -0,0 +1,7 @@ +export type SiteInfo = { + siteUrl: string; +}; + +export interface ISiteInfoGetter { + getSiteInfo(): Promise; +} diff --git a/packages/ui/certd-server/src/controller/basic/oauth/connect-controller.ts b/packages/ui/certd-server/src/controller/basic/oauth/connect-controller.ts new file mode 100644 index 000000000..c3c61553e --- /dev/null +++ b/packages/ui/certd-server/src/controller/basic/oauth/connect-controller.ts @@ -0,0 +1,68 @@ +import { AddonService, BaseController, Constants, newAddon, SysInstallInfo, SysOauthSetting, SysSettingsService } from "@certd/lib-server"; +import { ALL, Body, Controller, Get, Inject, Post, Provide, Query } from "@midwayjs/core"; +import { IOauthProvider } from "../../../plugins/plugin-oauth/api.js"; +import { AddonGetterService } from "../../../modules/pipeline/service/addon-getter-service.js"; + +/** + */ +@Provide() +@Controller('/api/connect') +export class ConnectController extends BaseController { + + @Inject() + addonGetterService: AddonGetterService; + @Inject() + sysSettingsService: SysSettingsService; + + private async getOauthProvider(type:string){ + const oauthSetting = await this.sysSettingsService.getSetting(SysOauthSetting); + const setting = oauthSetting?.oauths?.[type||""] + if (!setting) { + throw new Error(`未配置该OAuth类型:${type}`); + } + + const addon = await this.addonGetterService.getAddonById(setting.addonId, true, 0); + if(!addon) { + throw new Error("初始化OAuth插件失败"); + } + return addon as IOauthProvider; + } + + @Post('/login', { summary: Constants.per.guest }) + public async login(@Query(ALL) body: {type:string}) { + + const addon = await this.getOauthProvider(body.type); + const installInfo = await this.sysSettingsService.getSetting(SysInstallInfo); + const bindUrl = installInfo?.bindUrl || ""; + //构造登录url + const redirectUrl = `${bindUrl}#/auth/callback/${body.type}`; + const loginUrl = await addon.buildLoginUrl({ redirectUri: redirectUrl}); + return this.ok(loginUrl); + } + @Post('/callback', { summary: Constants.per.guest }) + public async callback(@Query(ALL) body: any) { + //处理登录回调 + const addon = await this.getOauthProvider(body.type); + const tokenRes = await addon.onCallback({ + code: body.code, + redirectUri: body.redirectUri, + state: body.state, + }); + + const userInfo = tokenRes.userInfo; + + + + + return this.ok(tokenRes); + } + + @Post('/bind', { summary: Constants.per.guest }) + public async bind(@Body(ALL) body: any) { + const autoRegister = body.autoRegister || false; + const bindInfo = body.bind || {}; + //处理登录回调 + return this.ok(1); + } + +} diff --git a/packages/ui/certd-server/src/controller/basic/oidc/oidc-controller.ts b/packages/ui/certd-server/src/controller/basic/oidc/oidc-controller.ts deleted file mode 100644 index c7f22b9e9..000000000 --- a/packages/ui/certd-server/src/controller/basic/oidc/oidc-controller.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { BaseController, Constants } from "@certd/lib-server"; -import { ALL, Body, Controller, Get, Post, Provide, Query } from "@midwayjs/core"; - -/** - */ -@Provide() -@Controller('/api/connect') -export class LoginController extends BaseController { - - - @Get('/login', { summary: Constants.per.guest }) - public async login(@Query(ALL) body: any) { - //构造登录url - return this.ok(1); - } - @Get('/callback', { summary: Constants.per.guest }) - public async callback(@Query(ALL) body: any) { - //处理登录回调 - return this.ok(1); - } - - @Post('/bind', { summary: Constants.per.guest }) - public async bind(@Body(ALL) body: any) { - const autoRegister = body.autoRegister || false; - const bindInfo = body.bind || {}; - //处理登录回调 - return this.ok(1); - } - -} diff --git a/packages/ui/certd-server/src/modules/login/entity/oauth-bind.ts b/packages/ui/certd-server/src/modules/login/entity/oauth-bind.ts new file mode 100644 index 000000000..b8929102c --- /dev/null +++ b/packages/ui/certd-server/src/modules/login/entity/oauth-bind.ts @@ -0,0 +1,22 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity('cd_oauth_bind') +export class OauthBindEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'user_id', comment: '用户id' }) + userId: number; + + @Column({ name: 'type', comment: '第三方类型' }) + type: string; // oidc, wechat, github, gitee , qq , alipay + + @Column({ name: 'open_id', comment: '第三方openid' }) + openId: string; + + @Column({ name: 'create_time',comment: '创建时间', default: () => 'CURRENT_TIMESTAMP',}) + createTime: Date; + + @Column({ name: 'update_time', comment: '修改时间',default: () => 'CURRENT_TIMESTAMP',}) + updateTime: Date; +} diff --git a/packages/ui/certd-server/src/modules/pipeline/service/getter/site-info-getter.ts b/packages/ui/certd-server/src/modules/pipeline/service/getter/site-info-getter.ts new file mode 100644 index 000000000..6d6c8ca2a --- /dev/null +++ b/packages/ui/certd-server/src/modules/pipeline/service/getter/site-info-getter.ts @@ -0,0 +1,20 @@ +import { SysSettingsService, SysInstallInfo } from "@certd/lib-server"; +import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core"; +import { SiteInfo ,ISiteInfoGetter} from "@certd/plugin-lib"; + +@Provide("siteInfoGetter") +@Scope(ScopeEnum.Request, { allowDowngrade: true }) +export class SiteInfoGetter implements ISiteInfoGetter{ + @Inject() + sysSettingsService: SysSettingsService; + + + async getSiteInfo(): Promise { + + const installInfo = await this.sysSettingsService.getSetting(SysInstallInfo); + + return { + siteUrl: installInfo?.bindUrl || "", + } + } +} diff --git a/packages/ui/certd-server/src/plugins/plugin-oauth/api.ts b/packages/ui/certd-server/src/plugins/plugin-oauth/api.ts index 99ae1c8c2..b7bd0e7cc 100644 --- a/packages/ui/certd-server/src/plugins/plugin-oauth/api.ts +++ b/packages/ui/certd-server/src/plugins/plugin-oauth/api.ts @@ -1,14 +1,23 @@ -export interface OauthProvider { - buildLoginUrl: (params: { redirectUri: string }) => string; - handleCallback: (params: { code: string; redirectUri: string }) => Promise<{ - accessToken: string; - refreshToken: string; - expiresIn: number; - idToken: string; - scope: string; - tokenType: string; - }>; - bind: (params: { +export type OnCallbackReq = { + code: string; + redirectUri: string; + state: string; +} + +export type OauthToken = { + userInfo: { + openId: string; + nickName: string; + avatar: string; + }, + token: { + accessToken: string; + refreshToken: string; + expiresIn: number; + } +} + +export type OnBindReq = { accessToken: string; refreshToken: string; expiresIn: number; @@ -16,9 +25,14 @@ export interface OauthProvider { scope: string; tokenType: string; bindInfo: any; - }) => Promise<{ +} +export type OnBindReply = { success: boolean; message: string; - }>; - +} + +export interface IOauthProvider { + buildLoginUrl: (params: { redirectUri: string }) => Promise; + onCallback: (params: OnCallbackReq) => Promise; + onBind: (params: OnBindReq) => Promise; } \ No newline at end of file diff --git a/packages/ui/certd-server/src/plugins/plugin-oauth/index.ts b/packages/ui/certd-server/src/plugins/plugin-oauth/index.ts index e69de29bb..7f230f570 100644 --- a/packages/ui/certd-server/src/plugins/plugin-oauth/index.ts +++ b/packages/ui/certd-server/src/plugins/plugin-oauth/index.ts @@ -0,0 +1,2 @@ +export * from './api.js' +export * from './oidc/plugin-oidc.js' diff --git a/packages/ui/certd-server/src/plugins/plugin-oauth/oidc/plugin-oidc.ts b/packages/ui/certd-server/src/plugins/plugin-oauth/oidc/plugin-oidc.ts index e69de29bb..5c2084df3 100644 --- a/packages/ui/certd-server/src/plugins/plugin-oauth/oidc/plugin-oidc.ts +++ b/packages/ui/certd-server/src/plugins/plugin-oauth/oidc/plugin-oidc.ts @@ -0,0 +1,131 @@ +import { AddonInput, BaseAddon, IsAddon } from "@certd/lib-server"; +import { IOauthProvider, OnBindReq, OnCallbackReq } from "../api.js"; + +@IsAddon({ + addonType: "oauth", + name: 'oidc', + title: 'OpenId connect 认证', + desc: '', + showTest: false, +}) +export class OidcOauthProvider extends BaseAddon implements IOauthProvider { + + @AddonInput({ + title: "ClientId", + helper: "ClientId / appId", + required: true, + }) + clientId = ""; + + @AddonInput({ + title: "ClientSecretKey", + component: { + placeholder: "ClientSecretKey / appSecretKey", + }, + required: true, + }) + clientSecretKey = ""; + + @AddonInput({ + title: "服务地址", + helper: "Issuer地址", + component: { + placeholder: "https://oidc.example.com/oidc", + }, + required: true, + }) + issuerUrl = ""; + + + async getClient() { + const client = await import('openid-client') + let server = new URL(this.issuerUrl)// Authorization Server's Issuer Identifier + + let config = await client.discovery( + server, + this.clientId, + this.clientSecretKey, + ) + + // console.log(config.serverMetadata()) + + return { + config, + client + } + } + + async onCallback(req: OnCallbackReq) { + const { config, client } = await this.getClient() + + const currentUrl = new URL(req.redirectUri) + let tokens: any = await client.authorizationCodeGrant( + config, + currentUrl, + { + pkceCodeVerifier: req.code, + expectedState: req.state, + }, + ) + + console.log('Token Endpoint Response', tokens) + const claims = tokens.claims() + return { + token:{ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + }, + userInfo: { + openId: claims.sub, + nickName: claims.nickname, + avatar: claims.picture, + }, + } + }; + async onBind(req: OnBindReq) { + return { + success: false, + message: '绑定失败', + } + } + async buildLoginUrl(params: { redirectUri: string }) { + const { config, client } = await this.getClient() + + let redirect_uri = new URL(params.redirectUri) + let scope = 'openid profile' // Scope of the access request + /** + * PKCE: The following MUST be generated for every redirect to the + * authorization_endpoint. You must store the code_verifier and state in the + * end-user session such that it can be recovered as the user gets redirected + * from the authorization server back to your application. + */ + let code_verifier = client.randomPKCECodeVerifier() + let code_challenge = await client.calculatePKCECodeChallenge(code_verifier) + let state = client.randomState() + + let parameters: any = { + redirect_uri, + scope, + code_challenge, + code_challenge_method: 'S256', + state, + } + + // if (!config.serverMetadata().supportsPKCE()) { + // /** + // * We cannot be sure the server supports PKCE so we're going to use state too. + // * Use of PKCE is backwards compatible even if the AS doesn't support it which + // * is why we're using it regardless. Like PKCE, random state must be generated + // * for every redirect to the authorization_endpoint. + // */ + // parameters.state = client.randomState() + // } + + let redirectTo = client.buildAuthorizationUrl(config, parameters) + + // now redirect the user to redirectTo.href + console.log('redirecting to', redirectTo.href) + return redirectTo.href; + } +}