mirror of
https://github.com/casdoor/casdoor.git
synced 2025-07-21 02:13:50 +08:00
feat: add Keycloak idp support (#356)
* feat: add Keycloak idp support Signed-off-by: Yixiang Zhao <seriouszyx@foxmail.com> * fix: fix the profile UI Signed-off-by: Yixiang Zhao <seriouszyx@foxmail.com>
This commit is contained in:
@ -199,7 +199,7 @@ func (c *ApiController) Login() {
|
|||||||
userInfo := &idp.UserInfo{}
|
userInfo := &idp.UserInfo{}
|
||||||
if provider.Category == "SAML" {
|
if provider.Category == "SAML" {
|
||||||
// SAML
|
// SAML
|
||||||
userInfo.Id, err = object.ParseSamlResponse(form.SamlResponse)
|
userInfo.Id, err = object.ParseSamlResponse(form.SamlResponse, provider.Type)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.ResponseError(err.Error())
|
c.ResponseError(err.Error())
|
||||||
return
|
return
|
||||||
@ -241,7 +241,7 @@ func (c *ApiController) Login() {
|
|||||||
if form.Method == "signup" {
|
if form.Method == "signup" {
|
||||||
user := &object.User{}
|
user := &object.User{}
|
||||||
if provider.Category == "SAML" {
|
if provider.Category == "SAML" {
|
||||||
user = object.GetUserByField(application.Organization, "id", userInfo.Id)
|
user = object.GetUser(fmt.Sprintf("%s/%s", application.Organization, userInfo.Id))
|
||||||
} else if provider.Category == "OAuth" {
|
} else if provider.Category == "OAuth" {
|
||||||
user = object.GetUserByField(application.Organization, provider.Type, userInfo.Id)
|
user = object.GetUserByField(application.Organization, provider.Type, userInfo.Id)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
|
@ -27,9 +27,9 @@ import (
|
|||||||
dsig "github.com/russellhaering/goxmldsig"
|
dsig "github.com/russellhaering/goxmldsig"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ParseSamlResponse(samlResponse string) (string, error) {
|
func ParseSamlResponse(samlResponse string, providerType string) (string, error) {
|
||||||
samlResponse, _ = url.QueryUnescape(samlResponse)
|
samlResponse, _ = url.QueryUnescape(samlResponse)
|
||||||
sp, err := buildSp(nil, samlResponse)
|
sp, err := buildSp(&Provider{Type: providerType}, samlResponse)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@ -63,15 +63,8 @@ func buildSp(provider *Provider, samlResponse string) (*saml2.SAMLServiceProvide
|
|||||||
origin := beego.AppConfig.String("origin")
|
origin := beego.AppConfig.String("origin")
|
||||||
certEncodedData := ""
|
certEncodedData := ""
|
||||||
if samlResponse != "" {
|
if samlResponse != "" {
|
||||||
de, err := base64.StdEncoding.DecodeString(samlResponse)
|
certEncodedData = parseSamlResponse(samlResponse, provider.Type)
|
||||||
if err != nil {
|
} else if provider.IdP != "" {
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
deStr := strings.Replace(string(de), "\n", "", -1)
|
|
||||||
res := regexp.MustCompile(`<ds:X509Certificate>(.*?)</ds:X509Certificate>`).FindAllStringSubmatch(deStr, -1)
|
|
||||||
str := res[0][0]
|
|
||||||
certEncodedData = str[20 : len(str)-21]
|
|
||||||
} else if provider != nil {
|
|
||||||
certEncodedData = provider.IdP
|
certEncodedData = provider.IdP
|
||||||
}
|
}
|
||||||
certData, err := base64.StdEncoding.DecodeString(certEncodedData)
|
certData, err := base64.StdEncoding.DecodeString(certEncodedData)
|
||||||
@ -88,7 +81,7 @@ func buildSp(provider *Provider, samlResponse string) (*saml2.SAMLServiceProvide
|
|||||||
AssertionConsumerServiceURL: fmt.Sprintf("%s/api/acs", origin),
|
AssertionConsumerServiceURL: fmt.Sprintf("%s/api/acs", origin),
|
||||||
IDPCertificateStore: &certStore,
|
IDPCertificateStore: &certStore,
|
||||||
}
|
}
|
||||||
if provider != nil {
|
if provider.Endpoint != "" {
|
||||||
randomKeyStore := dsig.RandomKeyStoreForTest()
|
randomKeyStore := dsig.RandomKeyStoreForTest()
|
||||||
sp.IdentityProviderSSOURL = provider.Endpoint
|
sp.IdentityProviderSSOURL = provider.Endpoint
|
||||||
sp.IdentityProviderIssuer = provider.IssuerUrl
|
sp.IdentityProviderIssuer = provider.IssuerUrl
|
||||||
@ -97,3 +90,19 @@ func buildSp(provider *Provider, samlResponse string) (*saml2.SAMLServiceProvide
|
|||||||
}
|
}
|
||||||
return sp, nil
|
return sp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseSamlResponse(samlResponse string, providerType string) string {
|
||||||
|
de, err := base64.StdEncoding.DecodeString(samlResponse)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
deStr := strings.Replace(string(de), "\n", "", -1)
|
||||||
|
tagMap := map[string]string{
|
||||||
|
"Aliyun IDaaS": "ds",
|
||||||
|
"Keycloak": "dsig",
|
||||||
|
}
|
||||||
|
tag := tagMap[providerType]
|
||||||
|
expression := fmt.Sprintf("<%s:X509Certificate>([\\s\\S]*?)</%s:X509Certificate>", tag, tag)
|
||||||
|
res := regexp.MustCompile(expression).FindStringSubmatch(deStr)
|
||||||
|
return res[1]
|
||||||
|
}
|
@ -111,6 +111,7 @@ class ProviderEditPage extends React.Component {
|
|||||||
} else if (provider.category === "SAML") {
|
} else if (provider.category === "SAML") {
|
||||||
return ([
|
return ([
|
||||||
{id: 'Aliyun IDaaS', name: 'Aliyun IDaaS'},
|
{id: 'Aliyun IDaaS', name: 'Aliyun IDaaS'},
|
||||||
|
{id: 'Keycloak', name: 'Keycloak'},
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
return [];
|
return [];
|
||||||
|
@ -375,7 +375,7 @@ export function getClickable(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getProviderLogo(provider) {
|
export function getProviderLogo(provider) {
|
||||||
const idp = provider.type.toLowerCase();
|
const idp = provider.type.toLowerCase().trim().split(' ')[0];
|
||||||
const url = `${StaticBaseUrl}/img/social_${idp}.png`;
|
const url = `${StaticBaseUrl}/img/social_${idp}.png`;
|
||||||
return (
|
return (
|
||||||
<img width={30} height={30} src={url} alt={idp} />
|
<img width={30} height={30} src={url} alt={idp} />
|
||||||
|
@ -29,6 +29,7 @@ import SelectRegionBox from "./SelectRegionBox";
|
|||||||
|
|
||||||
import {Controlled as CodeMirror} from 'react-codemirror2';
|
import {Controlled as CodeMirror} from 'react-codemirror2';
|
||||||
import "codemirror/lib/codemirror.css";
|
import "codemirror/lib/codemirror.css";
|
||||||
|
import SamlWidget from "./common/SamlWidget";
|
||||||
require('codemirror/theme/material-darker.css');
|
require('codemirror/theme/material-darker.css');
|
||||||
require("codemirror/mode/javascript/javascript");
|
require("codemirror/mode/javascript/javascript");
|
||||||
|
|
||||||
@ -302,7 +303,13 @@ class UserEditPage extends React.Component {
|
|||||||
<div style={{marginBottom: 20}}>
|
<div style={{marginBottom: 20}}>
|
||||||
{
|
{
|
||||||
(this.state.application === null || this.state.user === null) ? null : (
|
(this.state.application === null || this.state.user === null) ? null : (
|
||||||
this.state.application?.providers.filter(providerItem => Setting.isProviderVisible(providerItem)).map((providerItem, index) => <OAuthWidget key={providerItem.name} labelSpan={(Setting.isMobile()) ? 10 : 3} user={this.state.user} application={this.state.application} providerItem={providerItem} onUnlinked={() => { return this.unlinked()}} />)
|
this.state.application?.providers.filter(providerItem => Setting.isProviderVisible(providerItem)).map((providerItem, index) =>
|
||||||
|
(providerItem.category === "OAuth") ? (
|
||||||
|
<OAuthWidget key={providerItem.name} labelSpan={(Setting.isMobile()) ? 10 : 3} user={this.state.user} application={this.state.application} providerItem={providerItem} onUnlinked={() => { return this.unlinked()}} />
|
||||||
|
) : (
|
||||||
|
<SamlWidget key={providerItem.name} labelSpan={(Setting.isMobile()) ? 10 : 3} user={this.state.user} application={this.state.application} providerItem={providerItem} onUnlinked={() => { return this.unlinked()}} />
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@ -194,14 +194,14 @@ class LoginPage extends React.Component {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSamlUrl(providerId) {
|
getSamlUrl(provider) {
|
||||||
const params = new URLSearchParams(this.props.location.search);
|
const params = new URLSearchParams(this.props.location.search);
|
||||||
let clientId = params.get("client_id")
|
let clientId = params.get("client_id");
|
||||||
let application = params.get("state");
|
let application = params.get("state");
|
||||||
let realRedirectUri = params.get("redirect_uri");
|
let realRedirectUri = params.get("redirect_uri");
|
||||||
let redirectUri = `${window.location.origin}/callback/saml`
|
let redirectUri = `${window.location.origin}/callback/saml`;
|
||||||
let providerName = providerId.split('/')[1];
|
let providerName = provider.name;
|
||||||
AuthBackend.getSamlLogin(providerId).then((res) => {
|
AuthBackend.getSamlLogin(`${provider.owner}/${providerName}`).then((res) => {
|
||||||
const replyState = `${clientId}&${application}&${providerName}&${realRedirectUri}&${redirectUri}`;
|
const replyState = `${clientId}&${application}&${providerName}&${realRedirectUri}&${redirectUri}`;
|
||||||
window.location.href = `${res.data}&RelayState=${btoa(replyState)}`;
|
window.location.href = `${res.data}&RelayState=${btoa(replyState)}`;
|
||||||
});
|
});
|
||||||
@ -217,7 +217,7 @@ class LoginPage extends React.Component {
|
|||||||
)
|
)
|
||||||
} else if (provider.category === "SAML") {
|
} else if (provider.category === "SAML") {
|
||||||
return (
|
return (
|
||||||
<a key={provider.displayName} onClick={this.getSamlUrl.bind(this, provider.owner + "/" + provider.name)}>
|
<a key={provider.displayName} onClick={this.getSamlUrl.bind(this, provider)}>
|
||||||
<img width={width} height={width} src={Provider.getProviderLogo(provider)} alt={provider.displayName} style={{margin: margin}} />
|
<img width={width} height={width} src={Provider.getProviderLogo(provider)} alt={provider.displayName} style={{margin: margin}} />
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
|
@ -125,6 +125,10 @@ const otherProviderInfo = {
|
|||||||
logo: `${StaticBaseUrl}/img/social_aliyun.png`,
|
logo: `${StaticBaseUrl}/img/social_aliyun.png`,
|
||||||
url: "https://aliyun.com/product/idaas"
|
url: "https://aliyun.com/product/idaas"
|
||||||
},
|
},
|
||||||
|
"Keycloak": {
|
||||||
|
logo: `${StaticBaseUrl}/img/social_keycloak.png`,
|
||||||
|
url: "https://www.keycloak.org/"
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
45
web/src/common/SamlWidget.js
Normal file
45
web/src/common/SamlWidget.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {Col, Row} from "antd";
|
||||||
|
import * as Setting from "../Setting";
|
||||||
|
|
||||||
|
class SamlWidget extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
classes: props,
|
||||||
|
addressOptions: [],
|
||||||
|
affiliationOptions: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
renderIdp(user, application, providerItem) {
|
||||||
|
const provider = providerItem.provider;
|
||||||
|
const name = user.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row key={provider.name} style={{marginTop: '20px'}}>
|
||||||
|
<Col style={{marginTop: '5px'}} span={this.props.labelSpan}>
|
||||||
|
{
|
||||||
|
Setting.getProviderLogo(provider)
|
||||||
|
}
|
||||||
|
<span style={{marginLeft: '5px'}}>
|
||||||
|
{
|
||||||
|
`${provider.type}:`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</Col>
|
||||||
|
<Col span={24 - this.props.labelSpan} style={{marginTop: '5px'}}>
|
||||||
|
<span style={{
|
||||||
|
width: this.props.labelSpan === 3 ? '300px' : '130px',
|
||||||
|
display: (Setting.isMobile()) ? 'inline' : "inline-block"}}>{name}</span>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return this.renderIdp(this.props.user, this.props.application, this.props.providerItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SamlWidget;
|
Reference in New Issue
Block a user