From 81b8d5e39ab765e75155aab56748ffbaaa502726 Mon Sep 17 00:00:00 2001 From: Gucheng Wang Date: Sun, 17 Dec 2023 02:37:28 +0800 Subject: [PATCH] Add new B2C provider --- idp/adfs.go | 6 +- idp/azuread_b2c.go | 126 ++++++++++++++++++++++++++ idp/provider.go | 2 + object/provider.go | 2 +- object/user.go | 3 +- web/src/ProviderEditPage.js | 5 +- web/src/Setting.js | 3 +- web/src/auth/AzureADB2CLoginButton.js | 32 +++++++ web/src/auth/AzureADLoginButton.js | 4 +- web/src/auth/Provider.js | 6 ++ web/src/auth/ProviderButton.js | 3 + 11 files changed, 184 insertions(+), 8 deletions(-) create mode 100644 idp/azuread_b2c.go create mode 100644 web/src/auth/AzureADB2CLoginButton.js diff --git a/idp/adfs.go b/idp/adfs.go index 77328e09afc..ee0f0f73fb1 100644 --- a/idp/adfs.go +++ b/idp/adfs.go @@ -85,10 +85,12 @@ func (idp *AdfsIdProvider) GetToken(code string) (*oauth2.Token, error) { payload.Set("client_id", idp.Config.ClientID) payload.Set("client_secret", idp.Config.ClientSecret) payload.Set("redirect_uri", idp.Config.RedirectURL) + resp, err := idp.Client.PostForm(idp.Config.Endpoint.TokenURL, payload) if err != nil { return nil, err } + data, err := io.ReadAll(resp.Body) if err != nil { return nil, err @@ -97,10 +99,10 @@ func (idp *AdfsIdProvider) GetToken(code string) (*oauth2.Token, error) { pToken := &AdfsToken{} err = json.Unmarshal(data, pToken) if err != nil { - return nil, fmt.Errorf("fail to unmarshal token response: %s", err.Error()) + return nil, err } if pToken.ErrMsg != "" { - return nil, fmt.Errorf("pToken.Errmsg = %s", pToken.ErrMsg) + return nil, fmt.Errorf(pToken.ErrMsg) } token := &oauth2.Token{ diff --git a/idp/azuread_b2c.go b/idp/azuread_b2c.go new file mode 100644 index 00000000000..4d8581ce697 --- /dev/null +++ b/idp/azuread_b2c.go @@ -0,0 +1,126 @@ +// Copyright 2023 The Casdoor Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package idp + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "golang.org/x/oauth2" +) + +type AzureADB2CProvider struct { + Client *http.Client + Config *oauth2.Config + Tenant string + UserFlow string +} + +func NewAzureAdB2cProvider(clientId, clientSecret, redirectUrl, tenant string, userFlow string) *AzureADB2CProvider { + return &AzureADB2CProvider{ + Config: &oauth2.Config{ + ClientID: clientId, + ClientSecret: clientSecret, + RedirectURL: redirectUrl, + Endpoint: oauth2.Endpoint{ + AuthURL: fmt.Sprintf("https://%s.b2clogin.com/%s.onmicrosoft.com/%s/oauth2/v2.0/authorize", tenant, tenant, userFlow), + TokenURL: fmt.Sprintf("https://%s.b2clogin.com/%s.onmicrosoft.com/%s/oauth2/v2.0/token", tenant, tenant, userFlow), + }, + Scopes: []string{"openid", "email"}, + }, + Tenant: tenant, + UserFlow: userFlow, + } +} + +func (p *AzureADB2CProvider) SetHttpClient(client *http.Client) { + p.Client = client +} + +type AzureadB2cToken struct { + IdToken string `json:"id_token"` + TokenType string `json:"token_type"` + NotBefore int `json:"not_before"` + IdTokenExpiresIn int `json:"id_token_expires_in"` + ProfileInfo string `json:"profile_info"` + Scope string `json:"scope"` +} + +func (p *AzureADB2CProvider) GetToken(code string) (*oauth2.Token, error) { + payload := url.Values{} + payload.Set("code", code) + payload.Set("grant_type", "authorization_code") + payload.Set("client_id", p.Config.ClientID) + payload.Set("client_secret", p.Config.ClientSecret) + payload.Set("redirect_uri", p.Config.RedirectURL) + + resp, err := p.Client.PostForm(p.Config.Endpoint.TokenURL, payload) + if err != nil { + return nil, err + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + pToken := &AzureadB2cToken{} + err = json.Unmarshal(data, pToken) + if err != nil { + return nil, err + } + + token := &oauth2.Token{ + AccessToken: pToken.IdToken, + Expiry: time.Unix(time.Now().Unix()+int64(pToken.IdTokenExpiresIn), 0), + } + return token, nil +} + +func (p *AzureADB2CProvider) GetUserInfo(token *oauth2.Token) (*UserInfo, error) { + userInfoEndpoint := fmt.Sprintf("https://%s.b2clogin.com/%s.onmicrosoft.com/%s/openid/v2.0/userinfo", p.Tenant, p.Tenant, p.UserFlow) + req, err := http.NewRequest("GET", userInfoEndpoint, nil) + if err != nil { + return nil, err + } + req.Header.Add("Authorization", "Bearer "+token.AccessToken) + + resp, err := p.Client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("error fetching user info: status code %d", resp.StatusCode) + } + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var userInfo UserInfo + err = json.Unmarshal(bodyBytes, &userInfo) + if err != nil { + return nil, err + } + + return &userInfo, nil +} diff --git a/idp/provider.go b/idp/provider.go index 6f1b16da062..f5446ee693f 100644 --- a/idp/provider.go +++ b/idp/provider.go @@ -91,6 +91,8 @@ func GetIdProvider(idpInfo *ProviderInfo, redirectUrl string) (IdProvider, error return NewGitlabIdProvider(idpInfo.ClientId, idpInfo.ClientSecret, redirectUrl), nil case "ADFS": return NewAdfsIdProvider(idpInfo.ClientId, idpInfo.ClientSecret, redirectUrl, idpInfo.HostUrl), nil + case "AzureADB2C": + return NewAzureAdB2cProvider(idpInfo.ClientId, idpInfo.ClientSecret, redirectUrl, idpInfo.HostUrl, idpInfo.AppId), nil case "Baidu": return NewBaiduIdProvider(idpInfo.ClientId, idpInfo.ClientSecret, redirectUrl), nil case "Alipay": diff --git a/object/provider.go b/object/provider.go index f517e4ee14b..f5fa86cb740 100644 --- a/object/provider.go +++ b/object/provider.go @@ -417,7 +417,7 @@ func FromProviderToIdpInfo(ctx *context.Context, provider *Provider) *idp.Provid providerInfo.ClientId = provider.ClientId2 providerInfo.ClientSecret = provider.ClientSecret2 } - } else if provider.Type == "AzureAD" || provider.Type == "ADFS" || provider.Type == "Okta" { + } else if provider.Type == "AzureAD" || provider.Type == "AzureADB2C" || provider.Type == "ADFS" || provider.Type == "Okta" { providerInfo.HostUrl = provider.Domain } diff --git a/object/user.go b/object/user.go index 34453742c18..6e5624e7d52 100644 --- a/object/user.go +++ b/object/user.go @@ -117,6 +117,7 @@ type User struct { Infoflow string `xorm:"infoflow varchar(100)" json:"infoflow"` Apple string `xorm:"apple varchar(100)" json:"apple"` AzureAD string `xorm:"azuread varchar(100)" json:"azuread"` + AzureADB2c string `xorm:"azuread varchar(100)" json:"azureadb2c"` Slack string `xorm:"slack varchar(100)" json:"slack"` Steam string `xorm:"steam varchar(100)" json:"steam"` Bilibili string `xorm:"bilibili varchar(100)" json:"bilibili"` @@ -622,7 +623,7 @@ func UpdateUser(id string, user *User, columns []string, isAdmin bool) (bool, er "is_admin", "is_forbidden", "is_deleted", "hash", "is_default_avatar", "properties", "webauthnCredentials", "managedAccounts", "signin_wrong_times", "last_signin_wrong_time", "groups", "access_key", "access_secret", "github", "google", "qq", "wechat", "facebook", "dingtalk", "weibo", "gitee", "linkedin", "wecom", "lark", "gitlab", "adfs", - "baidu", "alipay", "casdoor", "infoflow", "apple", "azuread", "slack", "steam", "bilibili", "okta", "douyin", "line", "amazon", + "baidu", "alipay", "casdoor", "infoflow", "apple", "azuread", "azureadb2c", "slack", "steam", "bilibili", "okta", "douyin", "line", "amazon", "auth0", "battlenet", "bitbucket", "box", "cloudfoundry", "dailymotion", "deezer", "digitalocean", "discord", "dropbox", "eveonline", "fitbit", "gitea", "heroku", "influxcloud", "instagram", "intercom", "kakao", "lastfm", "mailru", "meetup", "microsoftonline", "naver", "nextcloud", "onedrive", "oura", "patreon", "paypal", "salesforce", "shopify", "soundcloud", diff --git a/web/src/ProviderEditPage.js b/web/src/ProviderEditPage.js index 1d3fdcf4f6e..f35c900cf14 100644 --- a/web/src/ProviderEditPage.js +++ b/web/src/ProviderEditPage.js @@ -311,6 +311,9 @@ class ProviderEditPage extends React.Component { } else if (provider.type === "Infoflow") { text = i18next.t("provider:Agent ID"); tooltip = i18next.t("provider:Agent ID - Tooltip"); + } else if (provider.type === "AzureADB2C") { + text = i18next.t("provider:User flow"); + tooltip = i18next.t("provider:User flow - Tooltip"); } } else if (provider.category === "SMS") { if (provider.type === "Twilio SMS" || provider.type === "Azure ACS") { @@ -758,7 +761,7 @@ class ProviderEditPage extends React.Component { ) } { - this.state.provider.type !== "ADFS" && this.state.provider.type !== "AzureAD" && this.state.provider.type !== "Casdoor" && this.state.provider.type !== "Okta" ? null : ( + this.state.provider.type !== "ADFS" && this.state.provider.type !== "AzureAD" && this.state.provider.type !== "AzureADB2C" && this.state.provider.type !== "Casdoor" && this.state.provider.type !== "Okta" ? null : ( {Setting.getLabel(i18next.t("provider:Domain"), i18next.t("provider:Domain - Tooltip"))} : diff --git a/web/src/Setting.js b/web/src/Setting.js index ab495cd2d38..bc4a418cb36 100644 --- a/web/src/Setting.js +++ b/web/src/Setting.js @@ -924,7 +924,8 @@ export function getProviderTypeOptions(category) { {id: "Casdoor", name: "Casdoor"}, {id: "Infoflow", name: "Infoflow"}, {id: "Apple", name: "Apple"}, - {id: "AzureAD", name: "AzureAD"}, + {id: "AzureAD", name: "Azure AD"}, + {id: "AzureADB2C", name: "Azure AD B2C"}, {id: "Slack", name: "Slack"}, {id: "Steam", name: "Steam"}, {id: "Bilibili", name: "Bilibili"}, diff --git a/web/src/auth/AzureADB2CLoginButton.js b/web/src/auth/AzureADB2CLoginButton.js new file mode 100644 index 00000000000..22a749decf9 --- /dev/null +++ b/web/src/auth/AzureADB2CLoginButton.js @@ -0,0 +1,32 @@ +// Copyright 2023 The Casdoor Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {createButton} from "react-social-login-buttons"; +import {StaticBaseUrl} from "../Setting"; + +function Icon({width = 24, height = 24, color}) { + return Sign in with Azure AD B2C; +} + +const config = { + text: "Sign in with Azure AD B2C", + icon: Icon, + iconFormat: name => `fa fa-${name}`, + style: {background: "#ffffff", color: "#000000"}, + activeStyle: {background: "#ededee"}, +}; + +const AzureADB2CLoginButton = createButton(config); + +export default AzureADB2CLoginButton; diff --git a/web/src/auth/AzureADLoginButton.js b/web/src/auth/AzureADLoginButton.js index a6bd46450aa..05c28c6cd14 100644 --- a/web/src/auth/AzureADLoginButton.js +++ b/web/src/auth/AzureADLoginButton.js @@ -16,11 +16,11 @@ import {createButton} from "react-social-login-buttons"; import {StaticBaseUrl} from "../Setting"; function Icon({width = 24, height = 24, color}) { - return Sign in with AzureAD; + return Sign in with Azure AD; } const config = { - text: "Sign in with AzureAD", + text: "Sign in with Azure AD", icon: Icon, iconFormat: name => `fa fa-${name}`, style: {background: "#ffffff", color: "#000000"}, diff --git a/web/src/auth/Provider.js b/web/src/auth/Provider.js index 7e77637e136..0ae0ca9ef6b 100644 --- a/web/src/auth/Provider.js +++ b/web/src/auth/Provider.js @@ -100,6 +100,10 @@ const authInfo = { scope: "user.read", endpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", }, + AzureADB2C: { + scope: "openid", + endpoint: "https://tenant.b2clogin.com/tenant.onmicrosoft.com/userflow/oauth2/v2.0/authorize", + }, Slack: { scope: "users:read", endpoint: "https://slack.com/oauth/authorize", @@ -406,6 +410,8 @@ export function getAuthUrl(application, provider, method) { || provider.type === "Twitch" || provider.type === "Typetalk" || provider.type === "Uber" || provider.type === "VK" || provider.type === "Wepay" || provider.type === "Xero" || provider.type === "Yahoo" || provider.type === "Yammer" || provider.type === "Yandex" || provider.type === "Zoom") { return `${endpoint}?client_id=${provider.clientId}&redirect_uri=${redirectUri}&scope=${scope}&response_type=code&state=${state}`; + } else if (provider.type === "AzureADB2C") { + return `https://${provider.domain}.b2clogin.com/${provider.domain}.onmicrosoft.com/${provider.appId}/oauth2/v2.0/authorize?client_id=${provider.clientId}&nonce=defaultNonce&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${scope}&response_type=code&state=${state}&prompt=login`; } else if (provider.type === "DingTalk") { return `${endpoint}?client_id=${provider.clientId}&redirect_uri=${redirectUri}&scope=${scope}&response_type=code&prompt=consent&state=${state}`; } else if (provider.type === "WeChat") { diff --git a/web/src/auth/ProviderButton.js b/web/src/auth/ProviderButton.js index acda4873c2b..eff39b28808 100644 --- a/web/src/auth/ProviderButton.js +++ b/web/src/auth/ProviderButton.js @@ -35,6 +35,7 @@ import AlipayLoginButton from "./AlipayLoginButton"; import InfoflowLoginButton from "./InfoflowLoginButton"; import AppleLoginButton from "./AppleLoginButton"; import AzureADLoginButton from "./AzureADLoginButton"; +import AzureADB2CLoginButton from "./AzureADB2CLoginButton"; import SlackLoginButton from "./SlackLoginButton"; import SteamLoginButton from "./SteamLoginButton"; import BilibiliLoginButton from "./BilibiliLoginButton"; @@ -85,6 +86,8 @@ function getSigninButton(provider) { return ; } else if (provider.type === "AzureAD") { return ; + } else if (provider.type === "AzureADB2C") { + return ; } else if (provider.type === "Slack") { return ; } else if (provider.type === "Steam") {