mirror of
https://github.com/casdoor/casdoor.git
synced 2025-07-04 13:20:19 +08:00
feat: support SAML and test with aliyun IDaaS (#346)
* feat: support SAML and test with aliyun IDaaS Signed-off-by: Yixiang Zhao <seriouszyx@foxmail.com> * refactor: refactor saml.go and router Signed-off-by: Yixiang Zhao <seriouszyx@foxmail.com> * fix: add param to getSamlLogin() Signed-off-by: Yixiang Zhao <seriouszyx@foxmail.com> * feat: add inputs to parse metadata automatically and show sp-acs-url, sp-entity-id Signed-off-by: Yixiang Zhao <seriouszyx@foxmail.com>
This commit is contained in:
@ -53,6 +53,7 @@ import SelectLanguageBox from './SelectLanguageBox';
|
||||
import i18next from 'i18next';
|
||||
import PromptPage from "./auth/PromptPage";
|
||||
import OdicDiscoveryPage from "./auth/OidcDiscoveryPage";
|
||||
import SamlCallback from './auth/SamlCallback';
|
||||
|
||||
const { Header, Footer } = Layout;
|
||||
|
||||
@ -547,6 +548,7 @@ class App extends Component {
|
||||
<Route exact path="/signup/oauth/authorize" render={(props) => <LoginPage account={this.state.account} type={"code"} mode={"signup"} {...props} onUpdateAccount={(account) => {this.onUpdateAccount(account)}} />}/>
|
||||
<Route exact path="/login/oauth/authorize" render={(props) => <LoginPage account={this.state.account} type={"code"} mode={"signin"} {...props} onUpdateAccount={(account) => {this.onUpdateAccount(account)}} />}/>
|
||||
<Route exact path="/callback" component={AuthCallback}/>
|
||||
<Route exact path="/callback/saml" component={SamlCallback}/>
|
||||
<Route exact path="/forget" render={(props) => this.renderHomeIfLoggedIn(<SelfForgetPage {...props} />)}/>
|
||||
<Route exact path="/forget/:applicationName" render={(props) => this.renderHomeIfLoggedIn(<ForgetPage {...props} />)}/>
|
||||
<Route exact path="/prompt" render={(props) => this.renderLoginIfNotLoggedIn(<PromptPage account={this.state.account} {...props} />)}/>
|
||||
|
@ -18,6 +18,8 @@ import {LinkOutlined} from "@ant-design/icons";
|
||||
import * as ProviderBackend from "./backend/ProviderBackend";
|
||||
import * as Setting from "./Setting";
|
||||
import i18next from "i18next";
|
||||
import { authConfig } from "./auth/Auth";
|
||||
import copy from 'copy-to-clipboard';
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
@ -103,6 +105,10 @@ class ProviderEditPage extends React.Component {
|
||||
{id: 'Tencent Cloud COS', name: 'Tencent Cloud COS'},
|
||||
]
|
||||
);
|
||||
} else if (provider.category === "SAML") {
|
||||
return ([
|
||||
{id: 'Aliyun IDaaS', name: 'Aliyun IDaaS'},
|
||||
]);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
@ -156,6 +162,17 @@ class ProviderEditPage extends React.Component {
|
||||
</Row>;
|
||||
}
|
||||
|
||||
loadSamlConfiguration() {
|
||||
var parser = new DOMParser();
|
||||
var xmlDoc = parser.parseFromString(this.state.provider.metadata, "text/xml");
|
||||
var cert = xmlDoc.getElementsByTagName("ds:X509Certificate")[0].childNodes[0].nodeValue;
|
||||
var endpoint = xmlDoc.getElementsByTagName("md:SingleSignOnService")[0].getAttribute("Location");
|
||||
var issuerUrl = xmlDoc.getElementsByTagName("md:EntityDescriptor")[0].getAttribute("entityID");
|
||||
this.updateProviderField("idP", cert);
|
||||
this.updateProviderField("endpoint", endpoint);
|
||||
this.updateProviderField("issuerUrl", issuerUrl);
|
||||
}
|
||||
|
||||
renderProvider() {
|
||||
return (
|
||||
<Card size="small" title={
|
||||
@ -202,6 +219,8 @@ class ProviderEditPage extends React.Component {
|
||||
} else if (value === "Storage") {
|
||||
this.updateProviderField('type', 'Local File System');
|
||||
this.updateProviderField('domain', Setting.getFullServerUrl());
|
||||
} else if (value === "SAML") {
|
||||
this.updateProviderField('type', 'Aliyun IDaaS');
|
||||
}
|
||||
})}>
|
||||
{
|
||||
@ -210,6 +229,7 @@ class ProviderEditPage extends React.Component {
|
||||
{id: 'Email', name: 'Email'},
|
||||
{id: 'SMS', name: 'SMS'},
|
||||
{id: 'Storage', name: 'Storage'},
|
||||
{id: 'SAML', name: 'SAML'},
|
||||
].map((providerCategory, index) => <Option key={index} value={providerCategory.id}>{providerCategory.name}</Option>)
|
||||
}
|
||||
</Select>
|
||||
@ -391,6 +411,96 @@ class ProviderEditPage extends React.Component {
|
||||
</Col>
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
) : this.state.provider.category === "SAML" ? (
|
||||
<React.Fragment>
|
||||
<Row style={{marginTop: '20px'}} >
|
||||
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(i18next.t("provider:Metadata"), i18next.t("provider:Metadata - Tooltip"))} :
|
||||
</Col>
|
||||
<Col span={22}>
|
||||
<TextArea rows={4} value={this.state.provider.metadata} onChange={e => {
|
||||
this.updateProviderField('metadata', e.target.value);
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: '20px'}}>
|
||||
<Col style={{marginTop: '5px'}} span={2}></Col>
|
||||
<Col span={2}>
|
||||
<Button type="primary" onClick={() => {
|
||||
try {
|
||||
this.loadSamlConfiguration();
|
||||
Setting.showMessage("success", i18next.t("provider:Parse Metadata successfully"));
|
||||
} catch (err) {
|
||||
Setting.showMessage("error", i18next.t("provider:Can not parse Metadata"));
|
||||
}
|
||||
}}>
|
||||
{i18next.t("provider:Parse")}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: '20px'}} >
|
||||
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(i18next.t("provider:Endpoint"), i18next.t("provider:SAML 2.0 Endpoint (HTTP)"))} :
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<Input value={this.state.provider.endpoint} onChange={e => {
|
||||
this.updateProviderField('endpoint', e.target.value);
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: '20px'}} >
|
||||
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(i18next.t("provider:IdP"), i18next.t("provider:IdP public key"))} :
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<Input value={this.state.provider.idP} onChange={e => {
|
||||
this.updateProviderField('idP', e.target.value);
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: '20px'}} >
|
||||
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(i18next.t("provider:Issuer URL"), i18next.t("provider:Issuer URL - Tooltip"))} :
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<Input value={this.state.provider.issuerUrl} onChange={e => {
|
||||
this.updateProviderField('issuerUrl', e.target.value);
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: '20px'}} >
|
||||
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(i18next.t("provider:SP ACS URL"), i18next.t("provider:SP ACS URL - Tooltip"))} :
|
||||
</Col>
|
||||
<Col span={21} >
|
||||
<Input value={`${authConfig.serverUrl}/api/acs`} readOnly="readonly" />
|
||||
</Col>
|
||||
<Col span={1}>
|
||||
<Button type="primary" onClick={() => {
|
||||
copy(`${authConfig.serverUrl}/api/acs`);
|
||||
Setting.showMessage("success", i18next.t("provider:Link copied to clipboard successfully"));
|
||||
}}>
|
||||
{i18next.t("provider:Copy")}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: '20px'}} >
|
||||
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(i18next.t("provider:SP Entity ID"), i18next.t("provider:SP ACS URL - Tooltip"))} :
|
||||
</Col>
|
||||
<Col span={21} >
|
||||
<Input value={`${authConfig.serverUrl}/api/acs`} readOnly="readonly" />
|
||||
</Col>
|
||||
<Col span={1}>
|
||||
<Button type="primary" onClick={() => {
|
||||
copy(`${authConfig.serverUrl}/api/acs`);
|
||||
Setting.showMessage("success", i18next.t("provider:Link copied to clipboard successfully"));
|
||||
}}>
|
||||
{i18next.t("provider:Copy")}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
) : null
|
||||
}
|
||||
{this.getAppIdRow()}
|
||||
|
@ -66,7 +66,7 @@ export function isProviderVisible(providerItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (providerItem.provider.category !== "OAuth") {
|
||||
if (providerItem.provider.category !== "OAuth" && providerItem.provider.category !== "SAML") {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -76,3 +76,18 @@ export function unlink(values) {
|
||||
body: JSON.stringify(values),
|
||||
}).then(res => res.json());
|
||||
}
|
||||
|
||||
export function getSamlLogin(providerId) {
|
||||
return fetch(`${authConfig.serverUrl}/api/get-saml-login?id=${providerId}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
}).then(res => res.json());
|
||||
}
|
||||
|
||||
export function loginWithSaml(values) {
|
||||
return fetch(`${authConfig.serverUrl}/api/login`, {
|
||||
method: 'POST',
|
||||
credentials: "include",
|
||||
body: JSON.stringify(values),
|
||||
}).then(res => res.json());
|
||||
}
|
@ -183,13 +183,28 @@ class LoginPage extends React.Component {
|
||||
return text;
|
||||
}
|
||||
|
||||
getSamlUrl(providerId) {
|
||||
AuthBackend.getSamlLogin(providerId).then((res) => {
|
||||
window.location.href = res.data
|
||||
});
|
||||
}
|
||||
|
||||
renderProviderLogo(provider, application, width, margin, size) {
|
||||
if (size === "small") {
|
||||
return (
|
||||
<a key={provider.displayName} href={Provider.getAuthUrl(application, provider, "signup")}>
|
||||
<img width={width} height={width} src={Provider.getProviderLogo(provider)} alt={provider.displayName} style={{margin: margin}} />
|
||||
</a>
|
||||
)
|
||||
if (provider.category === "OAuth") {
|
||||
return (
|
||||
<a key={provider.displayName} href={Provider.getAuthUrl(application, provider, "signup")}>
|
||||
<img width={width} height={width} src={Provider.getProviderLogo(provider)} alt={provider.displayName} style={{margin: margin}} />
|
||||
</a>
|
||||
)
|
||||
} else if (provider.category === "SAML") {
|
||||
return (
|
||||
<a key={provider.displayName} onClick={this.getSamlUrl.bind(this, provider.owner + "/" + provider.name)}>
|
||||
<img width={width} height={width} src={Provider.getProviderLogo(provider)} alt={provider.displayName} style={{margin: margin}} />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
} else {
|
||||
return (
|
||||
<div key={provider.displayName} style={{marginBottom: "10px"}}>
|
||||
|
@ -108,6 +108,12 @@ const otherProviderInfo = {
|
||||
url: "https://cloud.tencent.com/product/cos",
|
||||
},
|
||||
},
|
||||
SAML: {
|
||||
"Aliyun IDaaS": {
|
||||
logo: `${StaticBaseUrl}/img/social_aliyun.png`,
|
||||
url: "https://aliyun.com/product/idaas"
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function getProviderLogo(provider) {
|
||||
|
76
web/src/auth/SamlCallback.js
Normal file
76
web/src/auth/SamlCallback.js
Normal file
@ -0,0 +1,76 @@
|
||||
// Copyright 2021 The casbin 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 React from "react";
|
||||
import {Spin} from "antd";
|
||||
import {withRouter} from "react-router-dom";
|
||||
import * as AuthBackend from "./AuthBackend";
|
||||
import * as Util from "./Util";
|
||||
import * as Setting from "../Setting";
|
||||
import i18next from "i18next";
|
||||
|
||||
class SamlCallback extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
classes: props,
|
||||
msg: null,
|
||||
};
|
||||
}
|
||||
|
||||
UNSAFE_componentWillMount() {
|
||||
const params = new URLSearchParams(this.props.location.search);
|
||||
let relayState = params.get('relayState')
|
||||
let samlResponse = params.get('samlResponse')
|
||||
let redirectUri = `${window.location.origin}/callback`;
|
||||
const applicationName = "app-built-in"
|
||||
const body = {
|
||||
type: "login",
|
||||
application: applicationName,
|
||||
provider: "aliyun-idaas",
|
||||
state: applicationName,
|
||||
redirectUri: redirectUri,
|
||||
method: "signup",
|
||||
relayState: relayState,
|
||||
samlResponse: encodeURIComponent(samlResponse),
|
||||
};
|
||||
AuthBackend.loginWithSaml(body)
|
||||
.then((res) => {
|
||||
if (res.status === 'ok') {
|
||||
Util.showMessage("success", `Logged in successfully`);
|
||||
// Setting.goToLinkSoft(this, "/");
|
||||
Setting.goToLink("/");
|
||||
} else {
|
||||
this.setState({
|
||||
msg: res.msg,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div style={{textAlign: "center"}}>
|
||||
{
|
||||
(this.state.msg === null) ? (
|
||||
<Spin size="large" tip={i18next.t("login:Signing in...")} style={{paddingTop: "10%"}} />
|
||||
) : (
|
||||
Util.renderMessageLarge(this, this.state.msg)
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
export default withRouter(SamlCallback);
|
Reference in New Issue
Block a user