Compare commits

..

11 Commits

Author SHA1 Message Date
IZUMI-Zu
fd5ccd8d41 feat: support copying token to clipboard for casdoor-app (#3345)
* feat: support copy token to clipboard for casdoor-app auth

* feat: abstract casdoor-app related code
2024-11-13 17:06:09 +08:00
Yang Luo
a439c5195d feat: get token only by hash now, remove get-by-value backward-compatible code 2024-11-13 17:04:27 +08:00
Yang Luo
ba2e997d54 feat: fix CheckUpdateUser() logic to fix add-user error 2024-11-06 08:34:13 +08:00
Luckery
0818de85d1 feat: fix username checks when organization.UseEmailAsUsername is enabled (#3329)
* feat: Username support email format

* feat: Only fulfill the first requirement

* fix: Improve code robustness
2024-11-05 20:38:47 +08:00
Yang Luo
457c6098a4 feat: fix MFA empty CountryCode bug and show MFA error better in frontend 2024-11-04 16:17:24 +08:00
Yang Luo
60f979fbb5 feat: fix MfaSetupPage empty bug when user's signup application is empty 2024-11-04 00:04:47 +08:00
Luckery
ff53e44fa6 feat: use virtual select UI in role edit page (#3322) 2024-11-03 20:05:34 +08:00
Yang Luo
1832de47db feat: fix bug in CheckEntryIp() 2024-11-03 20:00:52 +08:00
Yang Luo
535eb0c465 fix: fix IP Whitelist field bug in application edit page 2024-11-03 19:55:59 +08:00
ithilelda
c190634cf3 feat: show Domain field for Qiniu storage provider (#3318)
allow Qiniu Provider to edit the Domain property in the edit page.
2024-10-27 14:10:58 +08:00
Cliff
f7559aa040 feat: set created time if not presented in AddUser() API (#3315) 2024-10-24 23:06:05 +08:00
14 changed files with 203 additions and 71 deletions

View File

@@ -854,6 +854,7 @@ func (c *ApiController) Login() {
} }
if authForm.Passcode != "" { if authForm.Passcode != "" {
user.CountryCode = user.GetCountryCode(user.CountryCode)
mfaUtil := object.GetMfaUtil(authForm.MfaType, user.GetPreferredMfaProps(false)) mfaUtil := object.GetMfaUtil(authForm.MfaType, user.GetPreferredMfaProps(false))
if mfaUtil == nil { if mfaUtil == nil {
c.ResponseError("Invalid multi-factor authentication type") c.ResponseError("Invalid multi-factor authentication type")

View File

@@ -294,6 +294,7 @@ func (c *ApiController) SendVerificationCode() {
} }
vform.CountryCode = mfaProps.CountryCode vform.CountryCode = mfaProps.CountryCode
vform.CountryCode = user.GetCountryCode(vform.CountryCode)
} }
provider, err = application.GetSmsProvider(vform.Method, vform.CountryCode) provider, err = application.GetSmsProvider(vform.Method, vform.CountryCode)

View File

@@ -520,11 +520,46 @@ func CheckUsername(username string, lang string) string {
return "" return ""
} }
func CheckUsernameWithEmail(username string, lang string) string {
if username == "" {
return i18n.Translate(lang, "check:Empty username.")
} else if len(username) > 39 {
return i18n.Translate(lang, "check:Username is too long (maximum is 39 characters).")
}
// https://stackoverflow.com/questions/58726546/github-username-convention-using-regex
if !util.ReUserNameWithEmail.MatchString(username) {
return i18n.Translate(lang, "check:Username supports email format. Also The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline. Also pay attention to the email format.")
}
return ""
}
func CheckUpdateUser(oldUser, user *User, lang string) string { func CheckUpdateUser(oldUser, user *User, lang string) string {
if oldUser.Name != user.Name { if oldUser.Name != user.Name {
if msg := CheckUsername(user.Name, lang); msg != "" { organizationName := oldUser.Owner
return msg if organizationName == "" {
organizationName = user.Owner
} }
organization, err := getOrganization("admin", organizationName)
if err != nil {
return err.Error()
}
if organization == nil {
return fmt.Sprintf(i18n.Translate(lang, "auth:The organization: %s does not exist"), organizationName)
}
if organization.UseEmailAsUsername {
if msg := CheckUsernameWithEmail(user.Name, lang); msg != "" {
return msg
}
} else {
if msg := CheckUsername(user.Name, lang); msg != "" {
return msg
}
}
if HasUserByField(user.Owner, "name", user.Name) { if HasUserByField(user.Owner, "name", user.Name) {
return i18n.Translate(lang, "check:Username already exists") return i18n.Translate(lang, "check:Username already exists")
} }

View File

@@ -43,6 +43,8 @@ func CheckEntryIp(clientIp string, user *User, application *Application, organiz
if err != nil { if err != nil {
application.IpRestriction = err.Error() + application.Name application.IpRestriction = err.Error() + application.Name
return fmt.Errorf(err.Error() + application.Name) return fmt.Errorf(err.Error() + application.Name)
} else {
application.IpRestriction = ""
} }
if organization == nil && application.OrganizationObj != nil { if organization == nil && application.OrganizationObj != nil {
@@ -55,6 +57,8 @@ func CheckEntryIp(clientIp string, user *User, application *Application, organiz
if err != nil { if err != nil {
organization.IpRestriction = err.Error() + organization.Name organization.IpRestriction = err.Error() + organization.Name
return fmt.Errorf(err.Error() + organization.Name) return fmt.Errorf(err.Error() + organization.Name)
} else {
organization.IpRestriction = ""
} }
} }

View File

@@ -102,14 +102,6 @@ func GetTokenByAccessToken(accessToken string) (*Token, error) {
return nil, err return nil, err
} }
if !existed {
token = Token{AccessToken: accessToken}
existed, err = ormer.Engine.Get(&token)
if err != nil {
return nil, err
}
}
if !existed { if !existed {
return nil, nil return nil, nil
} }
@@ -123,14 +115,6 @@ func GetTokenByRefreshToken(refreshToken string) (*Token, error) {
return nil, err return nil, err
} }
if !existed {
token = Token{RefreshToken: refreshToken}
existed, err = ormer.Engine.Get(&token)
if err != nil {
return nil, err
}
}
if !existed { if !existed {
return nil, nil return nil, nil
} }

View File

@@ -816,6 +816,10 @@ func AddUser(user *User) (bool, error) {
user.UpdateUserPassword(organization) user.UpdateUserPassword(organization)
} }
if user.CreatedTime == "" {
user.CreatedTime = util.GetCurrentTime()
}
err = user.UpdateUserHash() err = user.UpdateUserHash()
if err != nil { if err != nil {
return false, err return false, err

View File

@@ -25,10 +25,11 @@ import (
) )
var ( var (
rePhone *regexp.Regexp rePhone *regexp.Regexp
ReWhiteSpace *regexp.Regexp ReWhiteSpace *regexp.Regexp
ReFieldWhiteList *regexp.Regexp ReFieldWhiteList *regexp.Regexp
ReUserName *regexp.Regexp ReUserName *regexp.Regexp
ReUserNameWithEmail *regexp.Regexp
) )
func init() { func init() {
@@ -36,6 +37,7 @@ func init() {
ReWhiteSpace, _ = regexp.Compile(`\s`) ReWhiteSpace, _ = regexp.Compile(`\s`)
ReFieldWhiteList, _ = regexp.Compile(`^[A-Za-z0-9]+$`) ReFieldWhiteList, _ = regexp.Compile(`^[A-Za-z0-9]+$`)
ReUserName, _ = regexp.Compile("^[a-zA-Z0-9]+([-._][a-zA-Z0-9]+)*$") ReUserName, _ = regexp.Compile("^[a-zA-Z0-9]+([-._][a-zA-Z0-9]+)*$")
ReUserNameWithEmail, _ = regexp.Compile(`^([a-zA-Z0-9]+([-._][a-zA-Z0-9]+)*)|([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$`) // Add support for email formats
} }
func IsEmailValid(email string) bool { func IsEmailValid(email string) bool {

View File

@@ -603,7 +603,7 @@ class ApplicationEditPage extends React.Component {
{Setting.getLabel(i18next.t("general:IP whitelist"), i18next.t("general:IP whitelist - Tooltip"))} : {Setting.getLabel(i18next.t("general:IP whitelist"), i18next.t("general:IP whitelist - Tooltip"))} :
</Col> </Col>
<Col span={22} > <Col span={22} >
<Input placeholder = {this.state.application.organizationObj?.ipWhitelist} value={this.state.application.ipWhiteList} onChange={e => { <Input placeholder = {this.state.application.organizationObj?.ipWhitelist} value={this.state.application.ipWhitelist} onChange={e => {
this.updateApplicationField("ipWhitelist", e.target.value); this.updateApplicationField("ipWhitelist", e.target.value);
}} /> }} />
</Col> </Col>

View File

@@ -908,7 +908,7 @@ class ProviderEditPage extends React.Component {
</Col> </Col>
</Row> </Row>
)} )}
{["Custom HTTP SMS", "Qiniu Cloud Kodo", "Synology", "Casdoor"].includes(this.state.provider.type) ? null : ( {["Custom HTTP SMS", "Synology", "Casdoor"].includes(this.state.provider.type) ? null : (
<Row style={{marginTop: "20px"}} > <Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}> <Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("provider:Domain"), i18next.t("provider:Domain - Tooltip"))} : {Setting.getLabel(i18next.t("provider:Domain"), i18next.t("provider:Domain - Tooltip"))} :

View File

@@ -187,7 +187,7 @@ class RoleEditPage extends React.Component {
{Setting.getLabel(i18next.t("role:Sub users"), i18next.t("role:Sub users - Tooltip"))} : {Setting.getLabel(i18next.t("role:Sub users"), i18next.t("role:Sub users - Tooltip"))} :
</Col> </Col>
<Col span={22} > <Col span={22} >
<Select virtual={false} mode="multiple" style={{width: "100%"}} value={this.state.role.users} <Select virtual={true} mode="multiple" style={{width: "100%"}} value={this.state.role.users}
onChange={(value => {this.updateRoleField("users", value);})} onChange={(value => {this.updateRoleField("users", value);})}
options={this.state.users.map((user) => Setting.getOption(`${user.owner}/${user.name}`, `${user.owner}/${user.name}`))} options={this.state.users.map((user) => Setting.getOption(`${user.owner}/${user.name}`, `${user.owner}/${user.name}`))}
/> />

View File

@@ -444,8 +444,8 @@ class LoginPage extends React.Component {
formValues={values} formValues={values}
authParams={casParams} authParams={casParams}
application={this.getApplicationObj()} application={this.getApplicationObj()}
onFail={() => { onFail={(errorMessage) => {
Setting.showMessage("error", i18next.t("mfa:Verification failed")); Setting.showMessage("error", errorMessage);
}} }}
onSuccess={(res) => loginHandler(res)} onSuccess={(res) => loginHandler(res)}
/>); />);
@@ -513,8 +513,8 @@ class LoginPage extends React.Component {
formValues={values} formValues={values}
authParams={oAuthParams} authParams={oAuthParams}
application={this.getApplicationObj()} application={this.getApplicationObj()}
onFail={() => { onFail={(errorMessage) => {
Setting.showMessage("error", i18next.t("mfa:Verification failed")); Setting.showMessage("error", errorMessage);
}} }}
onSuccess={(res) => loginHandler(res)} onSuccess={(res) => loginHandler(res)}
/>); />);

View File

@@ -37,7 +37,7 @@ class MfaSetupPage extends React.Component {
this.state = { this.state = {
account: props.account, account: props.account,
application: null, application: null,
applicationName: props.account.signupApplication ?? "", applicationName: props.account.signupApplication ?? localStorage.getItem("applicationName") ?? "",
current: location.state?.from !== undefined ? 1 : 0, current: location.state?.from !== undefined ? 1 : 0,
mfaProps: null, mfaProps: null,
mfaType: params.get("mfaType") ?? SmsMfaType, mfaType: params.get("mfaType") ?? SmsMfaType,

View File

@@ -0,0 +1,121 @@
// Copyright 2024 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 React from "react";
import {Alert, Button, QRCode} from "antd";
import * as Setting from "../Setting";
import i18next from "i18next";
export const generateCasdoorAppUrl = (accessToken, forQrCode = true) => {
let qrUrl = "";
let error = null;
if (!accessToken) {
error = i18next.t("general:Access token is empty");
return {qrUrl, error};
}
qrUrl = `casdoor-app://login?serverUrl=${window.location.origin}&accessToken=${accessToken}`;
if (forQrCode && qrUrl.length >= 2000) {
qrUrl = "";
error = i18next.t("general:QR code is too large");
}
return {qrUrl, error};
};
export const CasdoorAppQrCode = ({accessToken, icon}) => {
const {qrUrl, error} = generateCasdoorAppUrl(accessToken, true);
if (error) {
return <Alert message={error} type="error" showIcon />;
}
return (
<QRCode
value={qrUrl}
icon={icon}
errorLevel="M"
size={230}
bordered={false}
/>
);
};
export const CasdoorAppUrl = ({accessToken}) => {
const {qrUrl, error} = generateCasdoorAppUrl(accessToken, false);
const handleCopyUrl = async() => {
if (!window.isSecureContext) {
return;
}
try {
await navigator.clipboard.writeText(qrUrl);
Setting.showMessage("success", i18next.t("general:Copied to clipboard"));
} catch (err) {
Setting.showMessage("error", i18next.t("general:Failed to copy"));
}
};
if (error) {
return <Alert message={error} type="error" showIcon />;
}
return (
<div>
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "10px",
}}>
<span>{i18next.t("general:URL String")}</span>
{window.isSecureContext && (
<Button
size="small"
onClick={handleCopyUrl}
style={{marginLeft: "10px"}}
>
{i18next.t("general:Copy URL")}
</Button>
)}
</div>
<div
style={{
padding: "10px",
maxWidth: "400px",
maxHeight: "100px",
overflow: "auto",
wordBreak: "break-all",
whiteSpace: "pre-wrap",
cursor: "pointer",
userSelect: "all",
backgroundColor: "#f5f5f5",
borderRadius: "4px",
}}
onClick={(e) => {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(e.target);
selection.removeAllRanges();
selection.addRange(range);
}}
>
{qrUrl}
</div>
</div>
);
};

View File

@@ -14,9 +14,10 @@
import React from "react"; import React from "react";
import {DeleteOutlined, DownOutlined, UpOutlined} from "@ant-design/icons"; import {DeleteOutlined, DownOutlined, UpOutlined} from "@ant-design/icons";
import {Alert, Button, Col, Image, Input, Popover, QRCode, Row, Table, Tooltip} from "antd"; import {Button, Col, Image, Input, Popover, Row, Table, Tooltip} from "antd";
import * as Setting from "../Setting"; import * as Setting from "../Setting";
import i18next from "i18next"; import i18next from "i18next";
import {CasdoorAppQrCode, CasdoorAppUrl} from "../common/CasdoorAppConnector";
class MfaAccountTable extends React.Component { class MfaAccountTable extends React.Component {
constructor(props) { constructor(props) {
@@ -76,42 +77,6 @@ class MfaAccountTable extends React.Component {
this.updateTable(table); this.updateTable(table);
} }
getQrUrl() {
const {accessToken} = this.props;
let qrUrl = `casdoor-app://login?serverUrl=${window.location.origin}&accessToken=${accessToken}`;
let error = null;
if (!accessToken) {
qrUrl = "";
error = i18next.t("general:Access token is empty");
}
if (qrUrl.length >= 2000) {
qrUrl = "";
error = i18next.t("general:QR code is too large");
}
return {qrUrl, error};
}
renderQrCode() {
const {qrUrl, error} = this.getQrUrl();
if (error) {
return <Alert message={error} type="error" showIcon />;
} else {
return (
<QRCode
value={qrUrl}
icon={this.state.icon}
errorLevel="M"
size={230}
bordered={false}
/>
);
}
}
renderTable(table) { renderTable(table) {
const columns = [ const columns = [
{ {
@@ -194,10 +159,25 @@ class MfaAccountTable extends React.Component {
title={() => ( title={() => (
<div> <div>
{this.props.title}&nbsp;&nbsp;&nbsp;&nbsp; {this.props.title}&nbsp;&nbsp;&nbsp;&nbsp;
<Button style={{marginRight: "10px"}} type="primary" size="small" onClick={() => this.addRow(table)}>{i18next.t("general:Add")}</Button> <Button style={{marginRight: "10px"}} type="primary" size="small" onClick={() => this.addRow(table)}>
<Popover trigger="focus" overlayInnerStyle={{padding: 0}} {i18next.t("general:Add")}
content={this.renderQrCode()}> </Button>
<Button style={{marginLeft: "5px"}} type="primary" size="small">{i18next.t("general:QR Code")}</Button> <Popover
trigger="focus"
overlayInnerStyle={{padding: 0}}
content={<CasdoorAppQrCode accessToken={this.props.accessToken} icon={this.state.icon} />}
>
<Button style={{marginRight: "10px"}} type="primary" size="small">
{i18next.t("general:QR Code")}
</Button>
</Popover>
<Popover
trigger="click"
content={<CasdoorAppUrl accessToken={this.props.accessToken} />}
>
<Button type="primary" size="small">
{i18next.t("general:Show URL")}
</Button>
</Popover> </Popover>
</div> </div>
)} )}