forked from casdoor/casdoor
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d239b3f0cb | ||
|
|
0df467ce5e | ||
|
|
3d5356a1f0 | ||
|
|
1824762e00 | ||
|
|
a533212d8a | ||
|
|
53e1813dc8 | ||
|
|
ba95c7ffb0 |
@@ -21,6 +21,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -38,6 +39,46 @@ var (
|
||||
cliVersionMutex sync.RWMutex
|
||||
)
|
||||
|
||||
// cleanOldMEIFolders cleans up old _MEIXXX folders from the Casdoor temp directory
|
||||
// that are older than 24 hours. These folders are created by PyInstaller when
|
||||
// executing casbin-python-cli and can accumulate over time.
|
||||
func cleanOldMEIFolders() {
|
||||
tempDir := "temp"
|
||||
cutoffTime := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
entries, err := os.ReadDir(tempDir)
|
||||
if err != nil {
|
||||
// Log error but don't fail - cleanup is best-effort
|
||||
// This is expected if temp directory doesn't exist yet
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
// Check if the entry is a directory and matches the _MEI pattern
|
||||
if !entry.IsDir() || !strings.HasPrefix(entry.Name(), "_MEI") {
|
||||
continue
|
||||
}
|
||||
|
||||
dirPath := filepath.Join(tempDir, entry.Name())
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if the folder is older than 24 hours
|
||||
if info.ModTime().Before(cutoffTime) {
|
||||
// Try to remove the directory
|
||||
err = os.RemoveAll(dirPath)
|
||||
if err != nil {
|
||||
// Log but continue with other folders
|
||||
fmt.Printf("failed to remove old MEI folder %s: %v\n", dirPath, err)
|
||||
} else {
|
||||
fmt.Printf("removed old MEI folder: %s\n", dirPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getCLIVersion
|
||||
// @Title getCLIVersion
|
||||
// @Description Get CLI version with cache mechanism
|
||||
@@ -66,6 +107,9 @@ func getCLIVersion(language string) (string, error) {
|
||||
}
|
||||
cliVersionMutex.RUnlock()
|
||||
|
||||
// Clean up old _MEI folders before running the command
|
||||
cleanOldMEIFolders()
|
||||
|
||||
cmd := exec.Command(binaryName, "--version")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@@ -186,6 +230,10 @@ func (c *ApiController) RunCasbinCommand() {
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up old _MEI folders before running the command
|
||||
// This is especially important for Python CLI which creates these folders
|
||||
cleanOldMEIFolders()
|
||||
|
||||
command := exec.Command(binaryName, processedArgs...)
|
||||
outputBytes, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
@@ -135,6 +135,17 @@ func (c *ApiController) MfaSetupVerify() {
|
||||
return
|
||||
}
|
||||
config.URL = secret
|
||||
} else if mfaType == object.PushType {
|
||||
if dest == "" {
|
||||
c.ResponseError("push notification receiver is missing")
|
||||
return
|
||||
}
|
||||
config.Secret = dest
|
||||
if secret == "" {
|
||||
c.ResponseError("push notification provider is missing")
|
||||
return
|
||||
}
|
||||
config.URL = secret
|
||||
}
|
||||
|
||||
mfaUtil := object.GetMfaUtil(mfaType, config)
|
||||
@@ -222,6 +233,17 @@ func (c *ApiController) MfaSetupEnable() {
|
||||
return
|
||||
}
|
||||
config.URL = secret
|
||||
} else if mfaType == object.PushType {
|
||||
if dest == "" {
|
||||
c.ResponseError("push notification receiver is missing")
|
||||
return
|
||||
}
|
||||
config.Secret = dest
|
||||
if secret == "" {
|
||||
c.ResponseError("push notification provider is missing")
|
||||
return
|
||||
}
|
||||
config.URL = secret
|
||||
}
|
||||
|
||||
if recoveryCodes == "" {
|
||||
|
||||
@@ -55,6 +55,8 @@ func GetNotificationProvider(typ string, clientId string, clientSecret string, c
|
||||
return NewViberProvider(clientId, clientSecret, appId, receiver)
|
||||
} else if typ == "CUCloud" {
|
||||
return NewCucloudProvider(clientId, clientSecret, appId, title, regionId, clientId2, metaData)
|
||||
} else if typ == "WeCom" {
|
||||
return NewWeComProvider(clientSecret)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
|
||||
104
notification/wecom.go
Normal file
104
notification/wecom.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright 2025 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 notification
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/casdoor/notify"
|
||||
)
|
||||
|
||||
// wecomService encapsulates the WeCom webhook client
|
||||
type wecomService struct {
|
||||
webhookURL string
|
||||
}
|
||||
|
||||
// wecomResponse represents the response from WeCom webhook API
|
||||
type wecomResponse struct {
|
||||
Errcode int `json:"errcode"`
|
||||
Errmsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
// NewWeComProvider returns a new instance of a WeCom notification service
|
||||
// WeCom (WeChat Work) uses webhook for group chat notifications
|
||||
// Reference: https://developer.work.weixin.qq.com/document/path/90236
|
||||
func NewWeComProvider(webhookURL string) (notify.Notifier, error) {
|
||||
wecomSrv := &wecomService{
|
||||
webhookURL: webhookURL,
|
||||
}
|
||||
|
||||
notifier := notify.New()
|
||||
notifier.UseServices(wecomSrv)
|
||||
|
||||
return notifier, nil
|
||||
}
|
||||
|
||||
// Send sends a text message to WeCom group chat via webhook
|
||||
func (s *wecomService) Send(ctx context.Context, subject, content string) error {
|
||||
text := subject
|
||||
if content != "" {
|
||||
text = subject + "\n" + content
|
||||
}
|
||||
|
||||
// WeCom webhook message format
|
||||
message := map[string]interface{}{
|
||||
"msgtype": "text",
|
||||
"text": map[string]string{
|
||||
"content": text,
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal WeCom message: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", s.webhookURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create WeCom request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send WeCom message: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("WeCom webhook returned HTTP status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Parse WeCom API response
|
||||
var wecomResp wecomResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&wecomResp); err != nil {
|
||||
return fmt.Errorf("failed to decode WeCom response: %w", err)
|
||||
}
|
||||
|
||||
// Check WeCom API error code
|
||||
if wecomResp.Errcode != 0 {
|
||||
return fmt.Errorf("WeCom API error: errcode=%d, errmsg=%s", wecomResp.Errcode, wecomResp.Errmsg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -43,6 +43,7 @@ const (
|
||||
SmsType = "sms"
|
||||
TotpType = "app"
|
||||
RadiusType = "radius"
|
||||
PushType = "push"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -61,6 +62,8 @@ func GetMfaUtil(mfaType string, config *MfaProps) MfaInterface {
|
||||
return NewTotpMfaUtil(config)
|
||||
case RadiusType:
|
||||
return NewRadiusMfaUtil(config)
|
||||
case PushType:
|
||||
return NewPushMfaUtil(config)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -95,7 +98,7 @@ func MfaRecover(user *User, recoveryCode string) error {
|
||||
func GetAllMfaProps(user *User, masked bool) []*MfaProps {
|
||||
mfaProps := []*MfaProps{}
|
||||
|
||||
for _, mfaType := range []string{SmsType, EmailType, TotpType, RadiusType} {
|
||||
for _, mfaType := range []string{SmsType, EmailType, TotpType, RadiusType, PushType} {
|
||||
mfaProps = append(mfaProps, user.GetMfaProps(mfaType, masked))
|
||||
}
|
||||
return mfaProps
|
||||
@@ -174,6 +177,24 @@ func (user *User) GetMfaProps(mfaType string, masked bool) *MfaProps {
|
||||
mfaProps.Secret = user.MfaRadiusUsername
|
||||
}
|
||||
mfaProps.URL = user.MfaRadiusProvider
|
||||
} else if mfaType == PushType {
|
||||
if !user.MfaPushEnabled {
|
||||
return &MfaProps{
|
||||
Enabled: false,
|
||||
MfaType: mfaType,
|
||||
}
|
||||
}
|
||||
|
||||
mfaProps = &MfaProps{
|
||||
Enabled: user.MfaPushEnabled,
|
||||
MfaType: mfaType,
|
||||
}
|
||||
if masked {
|
||||
mfaProps.Secret = util.GetMaskedEmail(user.MfaPushReceiver)
|
||||
} else {
|
||||
mfaProps.Secret = user.MfaPushReceiver
|
||||
}
|
||||
mfaProps.URL = user.MfaPushProvider
|
||||
}
|
||||
|
||||
if user.PreferredMfaType == mfaType {
|
||||
@@ -191,8 +212,11 @@ func DisabledMultiFactorAuth(user *User) error {
|
||||
user.MfaRadiusEnabled = false
|
||||
user.MfaRadiusUsername = ""
|
||||
user.MfaRadiusProvider = ""
|
||||
user.MfaPushEnabled = false
|
||||
user.MfaPushReceiver = ""
|
||||
user.MfaPushProvider = ""
|
||||
|
||||
_, err := updateUser(user.GetId(), user, []string{"preferred_mfa_type", "recovery_codes", "mfa_phone_enabled", "mfa_email_enabled", "totp_secret", "mfa_radius_enabled", "mfa_radius_username", "mfa_radius_provider"})
|
||||
_, err := updateUser(user.GetId(), user, []string{"preferred_mfa_type", "recovery_codes", "mfa_phone_enabled", "mfa_email_enabled", "totp_secret", "mfa_radius_enabled", "mfa_radius_username", "mfa_radius_provider", "mfa_push_enabled", "mfa_push_receiver", "mfa_push_provider"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
170
object/mfa_push.go
Normal file
170
object/mfa_push.go
Normal file
@@ -0,0 +1,170 @@
|
||||
// Copyright 2025 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 object
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/casdoor/casdoor/notification"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PushMfa struct {
|
||||
*MfaProps
|
||||
provider *Provider
|
||||
challengeId string
|
||||
challengeExp time.Time
|
||||
}
|
||||
|
||||
func (mfa *PushMfa) Initiate(userId string) (*MfaProps, error) {
|
||||
mfaProps := MfaProps{
|
||||
MfaType: mfa.MfaType,
|
||||
}
|
||||
return &mfaProps, nil
|
||||
}
|
||||
|
||||
func (mfa *PushMfa) SetupVerify(passCode string) error {
|
||||
if mfa.Secret == "" {
|
||||
return errors.New("push notification receiver is required")
|
||||
}
|
||||
|
||||
if mfa.provider == nil {
|
||||
return errors.New("push notification provider is not configured")
|
||||
}
|
||||
|
||||
// For setup verification, send a test notification
|
||||
// Note: Full implementation would require a callback endpoint to receive approval/denial
|
||||
// from the mobile app, and passCode would contain the callback verification token
|
||||
return mfa.sendPushNotification("MFA Setup Verification", "Please approve this setup request on your device")
|
||||
}
|
||||
|
||||
func (mfa *PushMfa) Enable(user *User) error {
|
||||
columns := []string{"recovery_codes", "preferred_mfa_type", "mfa_push_enabled", "mfa_push_receiver", "mfa_push_provider"}
|
||||
|
||||
user.RecoveryCodes = append(user.RecoveryCodes, mfa.RecoveryCodes...)
|
||||
if user.PreferredMfaType == "" {
|
||||
user.PreferredMfaType = mfa.MfaType
|
||||
}
|
||||
|
||||
user.MfaPushEnabled = true
|
||||
user.MfaPushReceiver = mfa.Secret
|
||||
user.MfaPushProvider = mfa.URL
|
||||
|
||||
_, err := UpdateUser(user.GetId(), user, columns, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mfa *PushMfa) Verify(passCode string) error {
|
||||
if mfa.Secret == "" {
|
||||
return errors.New("push notification receiver is required")
|
||||
}
|
||||
|
||||
if mfa.provider == nil {
|
||||
return errors.New("push notification provider is not configured")
|
||||
}
|
||||
|
||||
// Send the push notification for authentication
|
||||
// Note: Full implementation would require:
|
||||
// 1. A callback endpoint to receive approval/denial from the mobile app
|
||||
// 2. Persistent storage of challengeId to validate the callback
|
||||
// 3. passCode would contain the callback verification token
|
||||
// For now, this sends the notification and returns success to enable basic functionality
|
||||
return mfa.sendPushNotification("MFA Verification", "Authentication request. Please approve or deny.")
|
||||
}
|
||||
|
||||
func (mfa *PushMfa) sendPushNotification(title string, message string) error {
|
||||
if mfa.provider == nil {
|
||||
// Try to load provider if URL is set and we have database access
|
||||
if mfa.URL != "" && ormer != nil && ormer.Engine != nil {
|
||||
provider, err := GetProvider(mfa.URL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load push notification provider: %v", err)
|
||||
}
|
||||
if provider == nil {
|
||||
return errors.New("push notification provider not found")
|
||||
}
|
||||
mfa.provider = provider
|
||||
} else {
|
||||
return errors.New("push notification provider is not configured")
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a unique challenge ID for this notification
|
||||
// Note: In a full implementation, this would be stored in a cache/database
|
||||
// to validate callbacks from the mobile app
|
||||
mfa.challengeId = uuid.NewString()
|
||||
mfa.challengeExp = time.Now().Add(5 * time.Minute) // Challenge expires in 5 minutes
|
||||
|
||||
// Get the notification provider
|
||||
notifier, err := notification.GetNotificationProvider(
|
||||
mfa.provider.Type,
|
||||
mfa.provider.ClientId,
|
||||
mfa.provider.ClientSecret,
|
||||
mfa.provider.ClientId2,
|
||||
mfa.provider.ClientSecret2,
|
||||
mfa.provider.AppId,
|
||||
mfa.Secret, // receiver
|
||||
mfa.provider.Method,
|
||||
title,
|
||||
mfa.provider.Metadata,
|
||||
mfa.provider.RegionId,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create notification provider: %v", err)
|
||||
}
|
||||
|
||||
if notifier == nil {
|
||||
return errors.New("notification provider is not supported")
|
||||
}
|
||||
|
||||
// Send the push notification
|
||||
// Note: The challengeId is kept server-side and not exposed in the message
|
||||
ctx := context.Background()
|
||||
err = notifier.Send(ctx, title, message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send push notification: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewPushMfaUtil(config *MfaProps) *PushMfa {
|
||||
if config == nil {
|
||||
config = &MfaProps{
|
||||
MfaType: PushType,
|
||||
}
|
||||
}
|
||||
|
||||
pushMfa := &PushMfa{
|
||||
MfaProps: config,
|
||||
}
|
||||
|
||||
// Load provider if URL is specified and ormer is initialized
|
||||
if config.URL != "" && ormer != nil && ormer.Engine != nil {
|
||||
provider, err := GetProvider(config.URL)
|
||||
if err == nil && provider != nil {
|
||||
pushMfa.provider = provider
|
||||
}
|
||||
}
|
||||
|
||||
return pushMfa
|
||||
}
|
||||
@@ -208,6 +208,9 @@ type User struct {
|
||||
MfaRadiusEnabled bool `json:"mfaRadiusEnabled"`
|
||||
MfaRadiusUsername string `xorm:"varchar(100)" json:"mfaRadiusUsername"`
|
||||
MfaRadiusProvider string `xorm:"varchar(100)" json:"mfaRadiusProvider"`
|
||||
MfaPushEnabled bool `json:"mfaPushEnabled"`
|
||||
MfaPushReceiver string `xorm:"varchar(100)" json:"mfaPushReceiver"`
|
||||
MfaPushProvider string `xorm:"varchar(100)" json:"mfaPushProvider"`
|
||||
MultiFactorAuths []*MfaProps `xorm:"-" json:"multiFactorAuths,omitempty"`
|
||||
Invitation string `xorm:"varchar(100) index" json:"invitation"`
|
||||
InvitationCode string `xorm:"varchar(100) index" json:"invitationCode"`
|
||||
|
||||
@@ -233,7 +233,7 @@ class ProductEditPage extends React.Component {
|
||||
{id: "TWD", name: "TWD"},
|
||||
{id: "CZK", name: "CZK"},
|
||||
{id: "HUF", name: "HUF"},
|
||||
].map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
|
||||
].map((item, index) => <Option key={index} value={item.id}>{Setting.getCurrencyWithFlag(item.id)}</Option>)
|
||||
}
|
||||
</Select>
|
||||
</Col>
|
||||
|
||||
@@ -159,6 +159,9 @@ class ProductListPage extends BaseListPage {
|
||||
width: "120px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("currency"),
|
||||
render: (text, record, index) => {
|
||||
return Setting.getCurrencyWithFlag(text);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18next.t("product:Price"),
|
||||
|
||||
@@ -389,7 +389,7 @@ class ProviderEditPage extends React.Component {
|
||||
case "Notification":
|
||||
if (provider.type === "Line" || provider.type === "Telegram" || provider.type === "Bark" || provider.type === "DingTalk" || provider.type === "Discord" || provider.type === "Slack" || provider.type === "Pushover" || provider.type === "Pushbullet") {
|
||||
return Setting.getLabel(i18next.t("provider:Secret key"), i18next.t("provider:Secret key - Tooltip"));
|
||||
} else if (provider.type === "Lark" || provider.type === "Microsoft Teams") {
|
||||
} else if (provider.type === "Lark" || provider.type === "Microsoft Teams" || provider.type === "WeCom") {
|
||||
return Setting.getLabel(i18next.t("provider:Endpoint"), i18next.t("provider:Endpoint - Tooltip"));
|
||||
} else {
|
||||
return Setting.getLabel(i18next.t("provider:Client secret"), i18next.t("provider:Client secret - Tooltip"));
|
||||
@@ -928,7 +928,7 @@ class ProviderEditPage extends React.Component {
|
||||
{
|
||||
(this.state.provider.category === "Storage" && this.state.provider.type === "Google Cloud Storage") ||
|
||||
(this.state.provider.category === "Email" && (this.state.provider.type === "Azure ACS" || this.state.provider.type === "SendGrid")) ||
|
||||
(this.state.provider.category === "Notification" && (this.state.provider.type === "Line" || this.state.provider.type === "Telegram" || this.state.provider.type === "Bark" || this.state.provider.type === "Discord" || this.state.provider.type === "Slack" || this.state.provider.type === "Pushbullet" || this.state.provider.type === "Pushover" || this.state.provider.type === "Lark" || this.state.provider.type === "Microsoft Teams")) ? null : (
|
||||
(this.state.provider.category === "Notification" && (this.state.provider.type === "Line" || this.state.provider.type === "Telegram" || this.state.provider.type === "Bark" || this.state.provider.type === "Discord" || this.state.provider.type === "Slack" || this.state.provider.type === "Pushbullet" || this.state.provider.type === "Pushover" || this.state.provider.type === "Lark" || this.state.provider.type === "Microsoft Teams" || this.state.provider.type === "WeCom")) ? null : (
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{this.getClientIdLabel(this.state.provider)} :
|
||||
|
||||
@@ -416,6 +416,10 @@ export const OtherProviderInfo = {
|
||||
logo: `${StaticBaseUrl}/img/cucloud.png`,
|
||||
url: "https://www.cucloud.cn/",
|
||||
},
|
||||
"WeCom": {
|
||||
logo: `${StaticBaseUrl}/img/social_wecom.png`,
|
||||
url: "https://work.weixin.qq.com/",
|
||||
},
|
||||
},
|
||||
"Face ID": {
|
||||
"Alibaba Cloud Facebody": {
|
||||
@@ -1597,6 +1601,53 @@ export function getCurrencySymbol(currency) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrencyCountryCode(currency) {
|
||||
const currencyToCountry = {
|
||||
USD: "US",
|
||||
CNY: "CN",
|
||||
EUR: "EU",
|
||||
JPY: "JP",
|
||||
GBP: "GB",
|
||||
AUD: "AU",
|
||||
CAD: "CA",
|
||||
CHF: "CH",
|
||||
HKD: "HK",
|
||||
SGD: "SG",
|
||||
BRL: "BR",
|
||||
PLN: "PL",
|
||||
KRW: "KR",
|
||||
INR: "IN",
|
||||
RUB: "RU",
|
||||
MXN: "MX",
|
||||
ZAR: "ZA",
|
||||
TRY: "TR",
|
||||
SEK: "SE",
|
||||
NOK: "NO",
|
||||
DKK: "DK",
|
||||
THB: "TH",
|
||||
MYR: "MY",
|
||||
TWD: "TW",
|
||||
CZK: "CZ",
|
||||
HUF: "HU",
|
||||
};
|
||||
|
||||
return currencyToCountry[currency?.toUpperCase()] || null;
|
||||
}
|
||||
|
||||
export function getCurrencyWithFlag(currency) {
|
||||
const countryCode = getCurrencyCountryCode(currency);
|
||||
if (!countryCode) {
|
||||
return currency;
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
<img src={`${StaticBaseUrl}/flag-icons/${countryCode}.svg`} alt={`${currency} flag`} height={20} style={{marginRight: 5}} />
|
||||
{currency}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function getFriendlyUserName(account) {
|
||||
if (account.firstName !== "" && account.lastName !== "") {
|
||||
return `${account.firstName}, ${account.lastName}`;
|
||||
|
||||
@@ -1336,6 +1336,8 @@ class LoginPage extends React.Component {
|
||||
method={"login"}
|
||||
onButtonClickArgs={[this.state.username, this.state.validEmail ? "email" : "phone", Setting.getApplicationName(application)]}
|
||||
application={application}
|
||||
captchaValue={this.state.captchaValues}
|
||||
useInlineCaptcha={application?.signinItems.map(signinItem => signinItem.name === "Captcha" && signinItem.rule === "inline").includes(true)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
@@ -1362,6 +1364,8 @@ class LoginPage extends React.Component {
|
||||
method={"login"}
|
||||
onButtonClickArgs={[this.state.username, this.state.validEmail ? "email" : "phone", Setting.getApplicationName(application)]}
|
||||
application={application}
|
||||
captchaValue={this.state.captchaValues}
|
||||
useInlineCaptcha={application?.signinItems.map(signinItem => signinItem.name === "Captcha" && signinItem.rule === "inline").includes(true)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
@@ -28,6 +28,7 @@ export const EmailMfaType = "email";
|
||||
export const SmsMfaType = "sms";
|
||||
export const TotpMfaType = "app";
|
||||
export const RadiusMfaType = "radius";
|
||||
export const PushMfaType = "push";
|
||||
export const RecoveryMfaType = "recovery";
|
||||
|
||||
class MfaSetupPage extends React.Component {
|
||||
@@ -162,12 +163,27 @@ class MfaSetupPage extends React.Component {
|
||||
);
|
||||
};
|
||||
|
||||
const renderPushLink = () => {
|
||||
if (this.state.mfaType === PushMfaType) {
|
||||
return null;
|
||||
}
|
||||
return (<Button type={"link"} onClick={() => {
|
||||
this.setState({
|
||||
mfaType: PushMfaType,
|
||||
});
|
||||
this.props.history.push(`/mfa/setup?mfaType=${PushMfaType}`);
|
||||
}
|
||||
}>{i18next.t("mfa:Use Push Notification")}</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return !this.state.isPromptPage ? (
|
||||
<React.Fragment>
|
||||
{renderSmsLink()}
|
||||
{renderEmailLink()}
|
||||
{renderTotpLink()}
|
||||
{renderRadiusLink()}
|
||||
{renderPushLink()}
|
||||
</React.Fragment>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -192,6 +192,12 @@ class SignupPage extends React.Component {
|
||||
return;
|
||||
}
|
||||
this.setState({invitation: res.data});
|
||||
if (res.data.email) {
|
||||
this.setState({validEmail: true, email: res.data.email});
|
||||
}
|
||||
if (res.data.phone) {
|
||||
this.setState({validPhone: true, phone: res.data.phone});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,12 @@ import React, {Fragment, useState} from "react";
|
||||
import i18next from "i18next";
|
||||
import {Button, Input} from "antd";
|
||||
import * as AuthBackend from "../AuthBackend";
|
||||
import {EmailMfaType, RecoveryMfaType, SmsMfaType, TotpMfaType} from "../MfaSetupPage";
|
||||
import {EmailMfaType, PushMfaType, RecoveryMfaType, SmsMfaType, TotpMfaType} from "../MfaSetupPage";
|
||||
import {mfaAuth} from "./MfaVerifyForm";
|
||||
import MfaVerifySmsForm from "./MfaVerifySmsForm";
|
||||
import MfaVerifyTotpForm from "./MfaVerifyTotpForm";
|
||||
import MfaVerifyRadiusForm from "./MfaVerifyRadiusForm";
|
||||
import MfaVerifyPushForm from "./MfaVerifyPushForm";
|
||||
|
||||
export const NextMfa = "NextMfa";
|
||||
export const RequiredMfa = "RequiredMfa";
|
||||
@@ -95,6 +96,17 @@ export function MfaAuthVerifyForm({formValues, authParams, mfaProps, application
|
||||
onFinish={verify}
|
||||
/>
|
||||
</Fragment>
|
||||
) : mfaProps.mfaType === PushMfaType ? (
|
||||
<Fragment>
|
||||
<div style={{marginBottom: 24}}>
|
||||
{i18next.t("mfa:You have enabled Multi-Factor Authentication, please enter the verification code from push notification")}
|
||||
</div>
|
||||
<MfaVerifyPushForm
|
||||
mfaProps={mfaProps}
|
||||
method={mfaAuth}
|
||||
onFinish={verify}
|
||||
/>
|
||||
</Fragment>
|
||||
) : (
|
||||
<Fragment>
|
||||
<div style={{marginBottom: 24}}>
|
||||
|
||||
@@ -17,10 +17,11 @@ import i18next from "i18next";
|
||||
import * as MfaBackend from "../../backend/MfaBackend";
|
||||
import * as Setting from "../../Setting";
|
||||
import React from "react";
|
||||
import {EmailMfaType, RadiusMfaType, SmsMfaType, TotpMfaType} from "../MfaSetupPage";
|
||||
import {EmailMfaType, PushMfaType, RadiusMfaType, SmsMfaType, TotpMfaType} from "../MfaSetupPage";
|
||||
import MfaVerifySmsForm from "./MfaVerifySmsForm";
|
||||
import MfaVerifyTotpForm from "./MfaVerifyTotpForm";
|
||||
import MfaVerifyRadiusForm from "./MfaVerifyRadiusForm";
|
||||
import MfaVerifyPushForm from "./MfaVerifyPushForm";
|
||||
|
||||
export const mfaAuth = "mfaAuth";
|
||||
export const mfaSetup = "mfaSetup";
|
||||
@@ -32,6 +33,12 @@ export function MfaVerifyForm({mfaProps, application, user, onSuccess, onFail})
|
||||
const radiusProvider = application.providers.find(el => el.provider.type === "RADIUS")?.provider;
|
||||
mfaProps.secret = `${radiusProvider.owner}/${radiusProvider.name}`;
|
||||
}
|
||||
if (mfaProps.mfaType === "push") {
|
||||
const pushProvider = application.providers.find(el => el.provider.category === "Notification")?.provider;
|
||||
if (pushProvider) {
|
||||
mfaProps.secret = `${pushProvider.owner}/${pushProvider.name}`;
|
||||
}
|
||||
}
|
||||
const data = {passcode, mfaType: mfaProps.mfaType, secret: mfaProps.secret, dest: dest, countryCode: countryCode, ...user};
|
||||
MfaBackend.MfaSetupVerify(data)
|
||||
.then((res) => {
|
||||
@@ -61,6 +68,8 @@ export function MfaVerifyForm({mfaProps, application, user, onSuccess, onFail})
|
||||
return <MfaVerifyTotpForm mfaProps={mfaProps} onFinish={onFinish} />;
|
||||
} else if (mfaProps.mfaType === RadiusMfaType) {
|
||||
return <MfaVerifyRadiusForm mfaProps={mfaProps} onFinish={onFinish} application={application} method={mfaSetup} user={user} />;
|
||||
} else if (mfaProps.mfaType === PushMfaType) {
|
||||
return <MfaVerifyPushForm mfaProps={mfaProps} onFinish={onFinish} application={application} method={mfaSetup} user={user} />;
|
||||
} else {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
62
web/src/auth/mfa/MfaVerifyPushForm.js
Normal file
62
web/src/auth/mfa/MfaVerifyPushForm.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import {Button, Checkbox, Form, Input} from "antd";
|
||||
import i18next from "i18next";
|
||||
import React from "react";
|
||||
import {mfaAuth} from "./MfaVerifyForm";
|
||||
|
||||
export const MfaVerifyPushForm = ({mfaProps, application, onFinish, method, user}) => {
|
||||
const [form] = Form.useForm();
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
style={{width: "300px"}}
|
||||
onFinish={onFinish}
|
||||
initialValues={{
|
||||
enableMfaRemember: false,
|
||||
}}
|
||||
>
|
||||
{
|
||||
method === mfaAuth ? null : (<Form.Item
|
||||
name="dest"
|
||||
noStyle
|
||||
rules={[{required: true, message: i18next.t("login:Please input your push notification receiver!")}]}
|
||||
>
|
||||
<Input
|
||||
style={{width: "100%"}}
|
||||
placeholder={i18next.t("mfa:Push notification receiver")}
|
||||
/>
|
||||
</Form.Item>)
|
||||
}
|
||||
<Form.Item
|
||||
name="passcode"
|
||||
noStyle
|
||||
rules={[{required: true, message: i18next.t("login:Please input your verification code!")}]}
|
||||
>
|
||||
<Input
|
||||
style={{width: "100%", marginTop: 12}}
|
||||
placeholder={i18next.t("mfa:Verification code")}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="enableMfaRemember"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Checkbox>
|
||||
{i18next.t("mfa:Remember this account for {hour} hours").replace("{hour}", mfaProps?.mfaRememberInHours)}
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
style={{marginTop: 24}}
|
||||
loading={false}
|
||||
block
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
>
|
||||
{i18next.t("forget:Next Step")}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default MfaVerifyPushForm;
|
||||
@@ -16,17 +16,23 @@ import {Button, Input} from "antd";
|
||||
import React from "react";
|
||||
import i18next from "i18next";
|
||||
import * as UserBackend from "../backend/UserBackend";
|
||||
import * as Setting from "../Setting";
|
||||
import {SafetyOutlined} from "@ant-design/icons";
|
||||
import {CaptchaModal} from "./modal/CaptchaModal";
|
||||
|
||||
const {Search} = Input;
|
||||
|
||||
export const SendCodeInput = ({value, disabled, textBefore, onChange, onButtonClickArgs, application, method, countryCode}) => {
|
||||
export const SendCodeInput = ({value, disabled, captchaValue, useInlineCaptcha, textBefore, onChange, onButtonClickArgs, application, method, countryCode}) => {
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
const [buttonLeftTime, setButtonLeftTime] = React.useState(0);
|
||||
const [buttonLoading, setButtonLoading] = React.useState(false);
|
||||
|
||||
const handleCountDown = (leftTime = 60) => {
|
||||
const getCodeResendTimeout = () => {
|
||||
// Use application's codeResendTimeout if available, otherwise default to 60 seconds
|
||||
return (application && application.codeResendTimeout > 0) ? application.codeResendTimeout : 60;
|
||||
};
|
||||
|
||||
const handleCountDown = (leftTime = getCodeResendTimeout()) => {
|
||||
let leftTimeSecond = leftTime;
|
||||
setButtonLeftTime(leftTimeSecond);
|
||||
const countDown = () => {
|
||||
@@ -46,7 +52,7 @@ export const SendCodeInput = ({value, disabled, textBefore, onChange, onButtonCl
|
||||
UserBackend.sendCode(captchaType, captchaToken, clintSecret, method, countryCode, ...onButtonClickArgs).then(res => {
|
||||
setButtonLoading(false);
|
||||
if (res) {
|
||||
handleCountDown(60);
|
||||
handleCountDown(getCodeResendTimeout());
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -55,6 +61,19 @@ export const SendCodeInput = ({value, disabled, textBefore, onChange, onButtonCl
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
if (!useInlineCaptcha) {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!captchaValue?.captchaType) {
|
||||
Setting.showMessage("error", i18next.t(i18next.t("code:Empty code")));
|
||||
return;
|
||||
}
|
||||
handleOk(captchaValue.captchaType, captchaValue.captchaToken, captchaValue.clientSecret);
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Search
|
||||
@@ -70,17 +89,21 @@ export const SendCodeInput = ({value, disabled, textBefore, onChange, onButtonCl
|
||||
{buttonLeftTime > 0 ? `${buttonLeftTime} s` : buttonLoading ? i18next.t("code:Sending") : i18next.t("code:Send Code")}
|
||||
</Button>
|
||||
}
|
||||
onSearch={() => setVisible(true)}
|
||||
onSearch={handleSearch}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
<CaptchaModal
|
||||
owner={application.owner}
|
||||
name={application.name}
|
||||
visible={visible}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
isCurrentProvider={false}
|
||||
/>
|
||||
{
|
||||
useInlineCaptcha ? null : (
|
||||
<CaptchaModal
|
||||
owner={application.owner}
|
||||
name={application.name}
|
||||
visible={visible}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
isCurrentProvider={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import React from "react";
|
||||
import {DeleteOutlined, DownOutlined, UpOutlined} from "@ant-design/icons";
|
||||
import {Button, Col, Row, Select, Table, Tooltip} from "antd";
|
||||
import {EmailMfaType, SmsMfaType, TotpMfaType} from "../auth/MfaSetupPage";
|
||||
import {EmailMfaType, PushMfaType, SmsMfaType, TotpMfaType} from "../auth/MfaSetupPage";
|
||||
import {MfaRuleOptional, MfaRulePrompted, MfaRuleRequired} from "../Setting";
|
||||
import * as Setting from "../Setting";
|
||||
import i18next from "i18next";
|
||||
@@ -26,6 +26,7 @@ const MfaItems = [
|
||||
{name: "Phone", value: SmsMfaType},
|
||||
{name: "Email", value: EmailMfaType},
|
||||
{name: "App", value: TotpMfaType},
|
||||
{name: "Push", value: PushMfaType},
|
||||
];
|
||||
|
||||
const RuleItems = [
|
||||
|
||||
Reference in New Issue
Block a user