diff --git a/idp/douyin.go b/idp/douyin.go new file mode 100644 index 00000000..bd66d08f --- /dev/null +++ b/idp/douyin.go @@ -0,0 +1,198 @@ +// Copyright 2022 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 ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "time" + + "golang.org/x/oauth2" +) + +type DouyinIdProvider struct { + Client *http.Client + Config *oauth2.Config +} + +func NewDouyinIdProvider(clientId string, clientSecret string, redirectUrl string) *DouyinIdProvider { + idp := &DouyinIdProvider{} + idp.Config = idp.getConfig(clientId, clientSecret, redirectUrl) + return idp +} + +func (idp *DouyinIdProvider) SetHttpClient(client *http.Client) { + idp.Client = client +} + +func (idp *DouyinIdProvider) getConfig(clientId string, clientSecret string, redirectUrl string) *oauth2.Config { + var endpoint = oauth2.Endpoint{ + TokenURL: "https://open.douyin.com/oauth/access_token", + AuthURL: "https://open.douyin.com/platform/oauth/connect", + } + + var config = &oauth2.Config{ + Scopes: []string{"user_info"}, + Endpoint: endpoint, + ClientID: clientId, + ClientSecret: clientSecret, + RedirectURL: redirectUrl, + } + + return config +} + +// get more details via: https://open.douyin.com/platform/doc?doc=docs/openapi/account-permission/get-access-token +/* +{ + "data": { + "access_token": "access_token", + "description": "", + "error_code": "0", + "expires_in": "86400", + "open_id": "aaa-bbb-ccc", + "refresh_expires_in": "86400", + "refresh_token": "refresh_token", + "scope": "user_info" + }, + "message": "" +} +*/ + +type DouyinTokenResp struct { + Data struct { + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` + OpenId string `json:"open_id"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` + } `json:"data"` + Message string `json:"message"` +} + +// GetToken use code to get access_token +// get more details via: https://open.douyin.com/platform/doc?doc=docs/openapi/account-permission/get-access-token +func (idp *DouyinIdProvider) GetToken(code string) (*oauth2.Token, error) { + payload := url.Values{} + payload.Set("code", code) + payload.Set("grant_type", "authorization_code") + payload.Set("client_key", idp.Config.ClientID) + payload.Set("client_secret", idp.Config.ClientSecret) + resp, err := idp.Client.PostForm(idp.Config.Endpoint.TokenURL, payload) + if err != nil { + return nil, err + } + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + tokenResp := &DouyinTokenResp{} + err = json.Unmarshal(data, tokenResp) + if err != nil { + return nil, fmt.Errorf("fail to unmarshal token response: %s", err.Error()) + } + + token := &oauth2.Token{ + AccessToken: tokenResp.Data.AccessToken, + RefreshToken: tokenResp.Data.RefreshToken, + Expiry: time.Unix(time.Now().Unix()+tokenResp.Data.ExpiresIn, 0), + } + + raw := make(map[string]interface{}) + raw["open_id"] = tokenResp.Data.OpenId + token = token.WithExtra(raw) + + return token, nil +} + +// get more details via: https://open.douyin.com/platform/doc?doc=docs/openapi/account-management/get-account-open-info +/* +{ + "data": { + "avatar": "https://example.com/x.jpeg", + "city": "上海", + "country": "中国", + "description": "", + "e_account_role": "", + "error_code": "0", + "gender": "", + "nickname": "张伟", + "open_id": "0da22181-d833-447f-995f-1beefea5bef3", + "province": "上海", + "union_id": "1ad4e099-4a0c-47d1-a410-bffb4f2f64a4" + } +} +*/ + +type DouyinUserInfo struct { + Data struct { + Avatar string `json:"avatar"` + City string `json:"city"` + Country string `json:"country"` + // 0->unknown, 1->male, 2->female + Gender int64 `json:"gender"` + Nickname string `json:"nickname"` + OpenId string `json:"open_id"` + Province string `json:"province"` + } `json:"data"` +} + +// GetUserInfo use token to get user profile +func (idp *DouyinIdProvider) GetUserInfo(token *oauth2.Token) (*UserInfo, error) { + body := &struct { + AccessToken string `json:"access_token"` + OpenId string `json:"open_id"` + }{token.AccessToken, token.Extra("open_id").(string)} + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequest("GET", "https://open.douyin.com/oauth/userinfo/", bytes.NewReader(data)) + if err != nil { + return nil, err + } + req.Header.Add("access-token", token.AccessToken) + req.Header.Add("Accept", "application/json") + req.Header.Add("Content-Type", "application/json") + resp, err := idp.Client.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + respBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var douyinUserInfo DouyinUserInfo + err = json.Unmarshal(respBody, &douyinUserInfo) + if err != nil { + return nil, err + } + + userInfo := UserInfo{ + Id: douyinUserInfo.Data.OpenId, + Username: douyinUserInfo.Data.Nickname, + DisplayName: douyinUserInfo.Data.Nickname, + AvatarUrl: douyinUserInfo.Data.Avatar, + } + return &userInfo, nil +} diff --git a/idp/provider.go b/idp/provider.go index a29fc536..55838a2a 100644 --- a/idp/provider.go +++ b/idp/provider.go @@ -86,6 +86,8 @@ func GetIdProvider(typ string, subType string, clientId string, clientSecret str return NewCasdoorIdProvider(clientId, clientSecret, redirectUrl, hostUrl) } else if typ == "Okta" { return NewOktaIdProvider(clientId, clientSecret, redirectUrl, hostUrl) + } else if typ == "Douyin" { + return NewDouyinIdProvider(clientId, clientSecret, redirectUrl) } else if isGothSupport(typ) { return NewGothIdProvider(typ, clientId, clientSecret, redirectUrl) } else if typ == "Bilibili" { diff --git a/object/user.go b/object/user.go index 79a90969..2ddd2b9b 100644 --- a/object/user.go +++ b/object/user.go @@ -96,6 +96,7 @@ type User struct { Steam string `xorm:"steam varchar(100)" json:"steam"` Bilibili string `xorm:"bilibili varchar(100)" json:"bilibili"` Okta string `xorm:"okta varchar(100)" json:"okta"` + Douyin string `xorm:"douyin vachar(100)" json:"douyin"` Custom string `xorm:"custom varchar(100)" json:"custom"` Ldap string `xorm:"ldap varchar(100)" json:"ldap"` diff --git a/web/src/Setting.js b/web/src/Setting.js index 8d8965eb..e582f9bc 100644 --- a/web/src/Setting.js +++ b/web/src/Setting.js @@ -556,6 +556,7 @@ export function getProviderTypeOptions(category) { {id: 'Steam', name: 'Steam'}, {id: 'Bilibili', name: 'Bilibili'}, {id: 'Okta', name: 'Okta'}, + {id: 'Douyin', name: 'Douyin'}, {id: 'Custom', name: 'Custom'}, ] ); diff --git a/web/src/auth/DouyinLoginButton.js b/web/src/auth/DouyinLoginButton.js new file mode 100644 index 00000000..219ffcdb --- /dev/null +++ b/web/src/auth/DouyinLoginButton.js @@ -0,0 +1,32 @@ +// Copyright 2022 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 Douyin; +} + +const config = { + text: "Sign in with Douyin", + icon: Icon, + iconFormat: name => `fa fa-${name}`, + style: {background: "#ffffff", color: "#000000"}, + activeStyle: {background: "#ededee"}, +}; + +const DouyinLoginButton = createButton(config); + +export default DouyinLoginButton; diff --git a/web/src/auth/LoginPage.js b/web/src/auth/LoginPage.js index 46f65e24..810187d6 100644 --- a/web/src/auth/LoginPage.js +++ b/web/src/auth/LoginPage.js @@ -44,6 +44,7 @@ import AzureADLoginButton from "./AzureADLoginButton"; import SlackLoginButton from "./SlackLoginButton"; import SteamLoginButton from "./SteamLoginButton"; import OktaLoginButton from "./OktaLoginButton"; +import DouyinLoginButton from "./DouyinLoginButton"; import CustomGithubCorner from "../CustomGithubCorner"; import {CountDownInput} from "../common/CountDownInput"; import BilibiliLoginButton from "./BilibiliLoginButton"; @@ -284,6 +285,8 @@ class LoginPage extends React.Component { return } else if (type === "Okta") { return + } else if (type === "Douyin") { + return } return text; diff --git a/web/src/auth/Provider.js b/web/src/auth/Provider.js index 4170c7db..76fe9a86 100644 --- a/web/src/auth/Provider.js +++ b/web/src/auth/Provider.js @@ -111,6 +111,10 @@ const authInfo = { scope: "openid%20profile%20email", endpoint: "http://example.com", }, + Douyin: { + scope: "user_info", + endpoint: "https://open.douyin.com/platform/oauth/connect", + }, Custom: { endpoint: "https://example.com/", }, @@ -239,6 +243,8 @@ export function getAuthUrl(application, provider, method) { return `${endpoint}?openid.claimed_id=http://specs.openid.net/auth/2.0/identifier_select&openid.identity=http://specs.openid.net/auth/2.0/identifier_select&openid.mode=checkid_setup&openid.ns=http://specs.openid.net/auth/2.0&openid.realm=${window.location.origin}&openid.return_to=${redirectUri}?state=${state}`; } else if (provider.type === "Okta") { return `${provider.domain}/v1/authorize?client_id=${provider.clientId}&redirect_uri=${redirectUri}&state=${state}&response_type=code&scope=${scope}`; + } else if (provider.type === "Douyin") { + return `${endpoint}?client_key=${provider.clientId}&redirect_uri=${redirectUri}&state=${state}&response_type=code&scope=${scope}`; } else if (provider.type === "Custom") { return `${provider.customAuthUrl}?client_id=${provider.clientId}&redirect_uri=${redirectUri}&scope=${provider.customScope}&response_type=code&state=${state}`; } else if (provider.type === "Bilibili") {