feat: add "forget password" [front & backend] (#75)

* feat: add "forget password" [front & backend]

Signed-off-by: Weihao <1340908470@qq.com>

* fix: verification code can be sent even if no mobile phone or email is selected
refactor: forgetPassword -> forget; GetEmailAndPhoneByUsername -> GetEmailAndPhone; remove useless note

Signed-off-by: Weihao <1340908470@qq.com>
This commit is contained in:
Weihao Chen 2021-06-02 13:39:01 +08:00 committed by GitHub
parent 29049297d8
commit 1cb5ae54c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 716 additions and 7 deletions

View File

@ -72,6 +72,7 @@ m = (r.subOwner == p.subOwner || p.subOwner == "*") && \
ruleText := `
p, built-in, *, *, *, *, *
p, *, *, POST, /api/signup, *, *
p, *, *, POST, /api/get-email-and-phone, *, *
p, *, *, POST, /api/login, *, *
p, *, *, GET, /api/get-app-login, *, *
p, *, *, POST, /api/logout, *, *

View File

@ -17,6 +17,7 @@ package controllers
import (
"encoding/json"
"fmt"
"strings"
"github.com/astaxie/beego"
"github.com/casdoor/casdoor/idp"
@ -106,8 +107,65 @@ func (c *ApiController) Login() {
}
}
password := form.Password
user, msg := object.CheckUserLogin(form.Organization, form.Username, password)
var user *object.User
var msg string
if form.Password == "" {
var verificationCodeType string
// check result through Email or Phone
if strings.Contains(form.Email, "@") {
verificationCodeType = "email"
checkResult := object.CheckVerificationCode(form.Email, form.EmailCode)
if len(checkResult) != 0 {
responseText := fmt.Sprintf("Email%s", checkResult)
c.ResponseError(responseText)
return
}
} else {
verificationCodeType = "phone"
checkPhone := fmt.Sprintf("+%s%s", form.PhonePrefix, form.Email)
checkResult := object.CheckVerificationCode(checkPhone, form.EmailCode)
if len(checkResult) != 0 {
responseText := fmt.Sprintf("Phone%s", checkResult)
c.ResponseError(responseText)
return
}
}
// get user
var userId string
if form.Username == "" {
userId, _ = c.RequireSignedIn()
} else {
userId = fmt.Sprintf("%s/%s", form.Organization, form.Username)
}
user = object.GetUser(userId)
if user == nil {
c.ResponseError("No such user.")
return
}
// disable the verification code
switch verificationCodeType {
case "email":
if user.Email != form.Email {
c.ResponseError("wrong email!")
}
object.DisableVerificationCode(form.Email)
break
case "phone":
if user.Phone != form.Email {
c.ResponseError("wrong phone!")
}
object.DisableVerificationCode(form.Email)
break
}
} else {
password := form.Password
user, msg = object.CheckUserLogin(form.Organization, form.Username, password)
}
if msg != "" {
resp = &Response{Status: "error", Msg: msg, Data: ""}

View File

@ -111,6 +111,43 @@ func (c *ApiController) DeleteUser() {
c.ServeJSON()
}
// @Title GetEmailAndPhone
// @Description get email and phone by username
// @Param username formData string true "The username of the user"
// @Param organization formData string true "The organization of the user"
// @Success 200 {object} controllers.Response The Response object
// @router /get-email-and-phone [post]
func (c *ApiController) GetEmailAndPhone() {
var resp Response
var form RequestForm
err := json.Unmarshal(c.Ctx.Input.RequestBody, &form)
if err != nil {
panic(err)
}
// get user
var userId string
if form.Username == "" {
userId, _ = c.RequireSignedIn()
} else {
userId = fmt.Sprintf("%s/%s", form.Organization, form.Username)
}
user := object.GetUser(userId)
if user == nil {
c.ResponseError("No such user.")
return
}
phone := user.Phone
email := user.Email
resp = Response{Status: "ok", Msg: "", Data: phone, Data2: email}
c.Data["json"] = resp
c.ServeJSON()
}
// @Title SetPassword
// @Description set password
// @Param userOwner formData string true "The owner of the user"
@ -158,10 +195,14 @@ func (c *ApiController) SetPassword() {
return
}
msg := object.CheckPassword(targetUser, oldPassword)
if msg != "" {
c.ResponseError(msg)
return
if oldPassword != "" {
msg := object.CheckPassword(targetUser, oldPassword)
if msg != "" {
c.ResponseError(msg)
return
}
} else {
}
if strings.Index(newPassword, " ") >= 0 {
@ -174,6 +215,8 @@ func (c *ApiController) SetPassword() {
return
}
c.SetSessionUser("")
targetUser.Password = newPassword
object.SetUserField(targetUser, "password", targetUser.Password)
c.Data["json"] = Response{Status: "ok"}

View File

@ -60,6 +60,7 @@ func initAPI() {
beego.Router("/api/delete-user", &controllers.ApiController{}, "POST:DeleteUser")
beego.Router("/api/upload-avatar", &controllers.ApiController{}, "POST:UploadAvatar")
beego.Router("/api/set-password", &controllers.ApiController{}, "POST:SetPassword")
beego.Router("/api/get-email-and-phone", &controllers.ApiController{}, "POST:GetEmailAndPhone")
beego.Router("/api/send-verification-code", &controllers.ApiController{}, "POST:SendVerificationCode")
beego.Router("/api/reset-email-or-phone", &controllers.ApiController{}, "POST:ResetEmailOrPhone")
beego.Router("/api/get-human-check", &controllers.ApiController{}, "GET:GetHumanCheck")

View File

@ -38,6 +38,8 @@ import SignupPage from "./auth/SignupPage";
import ResultPage from "./auth/ResultPage";
import LoginPage from "./auth/LoginPage";
import SelfLoginPage from "./auth/SelfLoginPage";
import SelfForgetPage from "./auth/SelfForgetPage";
import ForgetPage from "./auth/ForgetPage";
import * as AuthBackend from "./auth/AuthBackend";
import AuthCallback from "./auth/AuthCallback";
import SelectLanguageBox from './SelectLanguageBox';
@ -374,6 +376,8 @@ class App extends Component {
<Route exact path="/result/:applicationName" render={(props) => this.renderHomeIfLoggedIn(<ResultPage {...props} />)}/>
<Route exact path="/login" render={(props) => this.renderHomeIfLoggedIn(<SelfLoginPage {...props} />)}/>
<Route exact path="/callback" component={AuthCallback}/>
<Route exact path="/forget" render={(props) => this.renderHomeIfLoggedIn(<SelfForgetPage {...props} />)}/>
<Route exact path="/forget/:applicationName" render={(props) => this.renderHomeIfLoggedIn(<ForgetPage {...props} />)}/>
<Route exact path="/" render={(props) => this.renderLoginIfNotLoggedIn(<HomePage account={this.state.account} {...props} />)}/>
<Route exact path="/account" render={(props) => this.renderLoginIfNotLoggedIn(<AccountPage account={this.state.account} {...props} />)}/>
<Route exact path="/organizations" render={(props) => this.renderLoginIfNotLoggedIn(<OrganizationListPage account={this.state.account} {...props} />)}/>

View File

@ -29,6 +29,14 @@ export function signup(values) {
}).then(res => res.json());
}
export function getEmailAndPhone(values) {
return fetch(`${authConfig.serverUrl}/api/get-email-and-phone`, {
method: "POST",
credentials: "include",
body: JSON.stringify(values),
}).then((res) => res.json());
}
function oAuthParamsToQuery(oAuthParams) {
return `?clientId=${oAuthParams.clientId}&responseType=${oAuthParams.responseType}&redirectUri=${oAuthParams.redirectUri}&scope=${oAuthParams.scope}&state=${oAuthParams.state}`;
}

499
web/src/auth/ForgetPage.js Normal file
View File

@ -0,0 +1,499 @@
// 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 { Button, Col, Divider, Form, Select, Input, Row, Steps } from "antd";
import * as AuthBackend from "./AuthBackend";
import * as ApplicationBackend from "../backend/ApplicationBackend";
import * as Util from "./Util";
import * as Setting from "../Setting";
import i18next from "i18next";
import { CountDownInput } from "../component/CountDownInput";
import * as UserBackend from "../backend/UserBackend";
import {
CheckCircleOutlined,
KeyOutlined,
LockOutlined,
SolutionOutlined,
UserOutlined,
} from "@ant-design/icons";
const { Step } = Steps;
const { Option } = Select;
class ForgetPage extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
account: props.account,
applicationName:
props.applicationName !== undefined
? props.applicationName
: props.match === undefined
? null
: props.match.params.applicationName,
application: null,
msg: null,
userId: "",
username: "",
email: "",
token: "",
phone: "",
emailCode: "",
phoneCode: "",
verifyType: "", // "email" or "phone"
current: 0,
};
}
UNSAFE_componentWillMount() {
if (this.state.applicationName !== undefined) {
this.getApplication();
} else {
Util.showMessage(
"error",
i18next.t(`forget:Unknown forgot type: `) + this.state.type
);
}
}
getApplication() {
if (this.state.applicationName === null) {
return;
}
ApplicationBackend.getApplication("admin", this.state.applicationName).then(
(application) => {
this.setState({
application: application,
});
}
);
}
getApplicationObj() {
if (this.props.application !== undefined) {
return this.props.application;
} else {
return this.state.application;
}
}
onFinishStep1(values) {
AuthBackend.getEmailAndPhone(values).then((res) => {
if (res.status === "ok") {
this.setState({
username: values.username,
phone: res.data.toString(),
email: res.data2.toString(),
current: 1,
});
} else {
Setting.showMessage("error", i18next.t(`signup:${res.msg}`));
}
});
}
onFinishStep2(values) {
values.phonePrefix = this.state.application?.organizationObj.phonePrefix;
values.username = this.state.username;
values.type = "login"
const oAuthParams = Util.getOAuthGetParameters();
AuthBackend.login(values, oAuthParams).then(res => {
if (res.status === "ok") {
this.setState({current: 2, userId: res.data})
} else {
Setting.showMessage("error", i18next.t(`signup:${res.msg}`));
}
})
}
onFinish(values) {
values.username = this.state.username;
values.userOwner = this.state.application?.organizationObj.name
UserBackend.setPassword(values.userOwner, values.username, "", values?.newPassword).then(res => {
if (res.status === "ok") {
Setting.goToLogin(this, this.state.application);
} else {
Setting.showMessage("error", i18next.t(`signup:${res.msg}`));
}
})
}
onFinishFailed(values, errorFields) {}
onChange = (current) => {
this.setState({ current: current });
};
renderForm(application) {
return (
<>
{/* STEP 1: input username -> get email & phone */}
<Form
hidden={this.state.current !== 0}
ref={this.form}
name="get-email-and-Phone"
onFinish={(values) => this.onFinishStep1(values)}
onFinishFailed={(errorInfo) => console.log(errorInfo)}
initialValues={{
application: application.name,
organization: application.organization,
}}
style={{ width: "300px" }}
size="large"
>
<Form.Item
style={{ height: 0, visibility: "hidden" }}
name="application"
rules={[
{
required: true,
message: i18next.t(
`forget:Please input your application!`
),
},
]}
/>
<Form.Item
style={{ height: 0, visibility: "hidden" }}
name="organization"
rules={[
{
required: true,
message: i18next.t(
`forget:Please input your organization!`
),
},
]}
/>
<Form.Item
name="username"
rules={[
{
required: true,
message: i18next.t(
"forget:Please input your username!"
),
whitespace: true,
},
]}
>
<Input
onChange={(e) => {
this.setState({
username: e.target.value,
});
}}
prefix={<UserOutlined />}
placeholder={i18next.t("signup:Username")}
/>
</Form.Item>
<br />
<Form.Item>
<Button block type="primary" htmlType="submit">
{i18next.t("forget:Next Step")}
</Button>
</Form.Item>
</Form>
{/* STEP 2: verify email or phone */}
<Form
hidden={this.state.current !== 1}
ref={this.form}
name="forgetPassword"
onFinish={(values) => this.onFinishStep2(values)}
onFinishFailed={(errorInfo) =>
this.onFinishFailed(
errorInfo.values,
errorInfo.errorFields,
errorInfo.outOfDate
)
}
initialValues={{
application: application.name,
organization: application.organization,
}}
style={{ width: "300px" }}
size="large"
>
<Form.Item
style={{ height: 0, visibility: "hidden" }}
name="application"
rules={[
{
required: true,
message: i18next.t(
`forget:Please input your application!`
),
},
]}
/>
<Form.Item
style={{ height: 0, visibility: "hidden" }}
name="organization"
rules={[
{
required: true,
message: i18next.t(
`forget:Please input your organization!`
),
},
]}
/>
<Form.Item
name="email" //use email instead of email/phone to adapt to RequestForm in account.go
validateFirst
hasFeedback
>
<Select
disabled={this.state.username === ""}
placeholder={i18next.t(
"forget:Choose email verification or mobile verification"
)}
onChange={(value) => {
if (value === this.state.phone) {
this.setState({ verifyType: "phone" });
}
if (value === this.state.email) {
this.setState({ verifyType: "email" });
}
}}
allowClear
style={{ textAlign: "left" }}
>
<Option key={1} value={this.state.phone}>
{this.state.phone.replace(/(\d{3})\d*(\d{4})/,'$1****$2')}
</Option>
<Option key={2} value={this.state.email}>
{this.state.email.split("@")[0].length>2?
this.state.email.replace(/(?<=.)[^@]+(?=.@)/, "*****"):
this.state.email.replace(/(\w?@)/, "*@")}
</Option>
</Select>
</Form.Item>
<Form.Item
name="emailCode" //use emailCode instead of email/phoneCode to adapt to RequestForm in account.go
rules={[
{
required: true,
message: i18next.t(
"forget:Please input your verification code!"
),
},
]}
>
{this.state.verifyType === "email" ? (
<CountDownInput
disabled={this.state.username === "" || this.state.verifyType === ""}
placeHolder={i18next.t("forget:Verify code")}
defaultButtonText={i18next.t("forget:send code")}
onButtonClick={UserBackend.sendCode}
onButtonClickArgs={[
this.state.email,
"email",
this.state.application?.organizationObj.owner +
"/" +
this.state.application?.organizationObj.name,
]}
coolDownTime={60}
/>
) : (
<CountDownInput
disabled={this.state.username === "" || this.state.verifyType === ""}
placeHolder={i18next.t("forget:Verify code")}
defaultButtonText={i18next.t("forget:send code")}
onButtonClick={UserBackend.sendCode}
onButtonClickArgs={[
this.state.phone,
"phone",
this.state.application?.organizationObj.owner +
"/" +
this.state.application?.organizationObj.name,
]}
coolDownTime={60}
/>
)}
</Form.Item>
<br />
<Form.Item>
<Button
block
type="primary"
disabled={this.state.phone === "" || this.state.verifyType === ""}
htmlType="submit"
>
{i18next.t("forget:Next Step")}
</Button>
</Form.Item>
</Form>
{/* STEP 3 */}
<Form
hidden={this.state.current !== 2}
ref={this.form}
name="forgetPassword"
onFinish={(values) => this.onFinish(values)}
onFinishFailed={(errorInfo) =>
this.onFinishFailed(
errorInfo.values,
errorInfo.errorFields,
errorInfo.outOfDate
)
}
initialValues={{
application: application.name,
organization: application.organization,
}}
style={{ width: "300px" }}
size="large"
>
<Form.Item
style={{ height: 0, visibility: "hidden" }}
name="application"
rules={[
{
required: true,
message: i18next.t(
`forget:Please input your application!`
),
},
]}
/>
<Form.Item
style={{ height: 0, visibility: "hidden" }}
name="organization"
rules={[
{
required: true,
message: i18next.t(
`forget:Please input your organization!`
),
},
]}
/>
<Form.Item
name="newPassword"
hidden={this.state.current !== 2}
rules={[
{
required: true,
message: i18next.t(
"forget:Please input your password!"
),
},
]}
hasFeedback
>
<Input.Password
disabled={this.state.userId === ""}
prefix={<LockOutlined />}
placeholder={i18next.t("forget:Password")}
/>
</Form.Item>
<Form.Item
name="confirm"
dependencies={["newPassword"]}
hasFeedback
rules={[
{
required: true,
message: i18next.t(
"forget:Please confirm your password!"
),
},
({ getFieldValue }) => ({
validator(rule, value) {
if (!value || getFieldValue("newPassword") === value) {
return Promise.resolve();
}
return Promise.reject(
i18next.t(
"forget:Your confirmed password is inconsistent with the password!"
)
);
},
}),
]}
>
<Input.Password
disabled={this.state.userId === ""}
prefix={<CheckCircleOutlined />}
placeholder={i18next.t("forget:Confirm")}
/>
</Form.Item>
<br />
<Form.Item hidden={this.state.current !== 2}>
<Button block type="primary" htmlType="submit" disabled={this.state.userId === ""}>
{i18next.t("forget:Change Password")}
</Button>
</Form.Item>
</Form>
</>
);
}
render() {
const application = this.getApplicationObj();
if (application === null) {
return Util.renderMessageLarge(this, this.state.msg);
}
return (
<>
<Divider style={{ fontSize: "28px" }}>
{i18next.t("forget:Retrieve password")}
</Divider>
<Row>
<Col span={24} style={{ display: "flex", justifyContent: "center" }}>
<Steps
current={this.state.current}
onChange={this.onChange}
style={{
width: "90%",
maxWidth: "500px",
margin: "auto",
marginTop: "80px",
}}
>
<Step
title={i18next.t("forget:Account")}
icon={<UserOutlined />}
/>
<Step
title={i18next.t("forget:Verify")}
icon={<SolutionOutlined />}
/>
<Step
title={i18next.t("forget:Reset")}
icon={<KeyOutlined />}
/>
</Steps>
</Col>
</Row>
<Row>
<Col span={24} style={{ display: "flex", justifyContent: "center" }}>
<div style={{ marginTop: "10px", textAlign: "center" }}>
{Setting.renderHelmet(application)}
{this.renderForm(application)}
</div>
</Col>
</Row>
</>
);
}
}
export default ForgetPage;

View File

@ -0,0 +1,32 @@
// 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 { authConfig } from "./Auth";
import ForgetPage from "./ForgetPage";
class SelfForgetPage extends React.Component {
render() {
return (
<ForgetPage
type={"forgotPassword"}
applicationName={authConfig.appName}
account={this.props.account}
{...this.props}
/>
);
}
}
export default SelfForgetPage;

View File

@ -17,9 +17,10 @@ import React from "react";
import * as Setting from "../Setting";
import i18next from "i18next";
import * as UserBackend from "../backend/UserBackend";
import { AuditOutlined, VerifiedOutlined } from "@ant-design/icons";
export const CountDownInput = (props) => {
const {defaultButtonText, textBefore, placeHolder, onChange, coolDownTime, onButtonClick, onButtonClickArgs} = props;
const {defaultButtonText, disabled, prefix, textBefore, placeHolder, onChange, coolDownTime, onButtonClick, onButtonClickArgs} = props;
const [buttonText, setButtonText] = React.useState(defaultButtonText);
const [visible, setVisible] = React.useState(false);
const [key, setKey] = React.useState("");
@ -101,14 +102,26 @@ export const CountDownInput = (props) => {
return null;
}
const getIcon = (prefix) => {
switch (prefix) {
case "VerifiedOutlined":
return <VerifiedOutlined />;
case "AuditOutlined":
return <AuditOutlined />;
}
};
return (
<Input
addonBefore={textBefore}
disabled={disabled}
prefix={prefix !== null ? getIcon(prefix) : null}
placeholder={placeHolder}
onChange={e => onChange(e.target.value)}
addonAfter={
<div>
<button
disabled={disabled}
onClick={clickButton}
style={{backgroundColor: "#fafafa", border: "none"}}>
{buttonText}

View File

@ -169,5 +169,30 @@
"Edit Application": "Edit Application",
"Enable password": "Enable password",
"Login page preview": "Login page preview"
},
"forget":
{
"Please input your application!": "Please input your application!",
"Please input your organization!": "Please input your organization!",
"Unknown forgot type:": "Unknown forgot type:",
"Please input your username!": "Please input your username!",
"Please input your password!": "Please input your password!",
"Please confirm your password!": "Please confirm your password!",
"Please input your Email/Phone string!": "Please input your Email/Phone string!",
"Please input your verification code!": "Please input your verification code!",
"Your confirmed password is inconsistent with the password!": "Your confirmed password is inconsistent with the password!",
"send code": "send code",
"Account": "Account",
"Verify": "Verify",
"Retrieve password": "Retrieve password",
"Reset": "Reset",
"Password": "Password",
"Next Step": "Next Step",
"Confirm": "Confirm",
"Verify code": "Verify code",
"Email/Phone's format wrong!": "Email/Phone's format wrong!",
"Email/Phone": "Email/Phone",
"Change Password": "Change Password",
"Choose email verification or mobile verification": "Choose email verification or mobile verification"
}
}

View File

@ -169,5 +169,30 @@
"Edit Application": "修改应用",
"Enable password": "开启密码",
"Login page preview": "登录页面预览"
},
"forget":
{
"Please input your application!": "请输入您的应用名称!",
"Please input your organization!": "请输入您的组织名称!",
"Unknown forgot type:": "未知的忘记类型:",
"Please input your username!": "请输入您的用户名!",
"Please input your password!": "请输入您的密码!",
"Please confirm your password!": "请确认您的密码!",
"Please input your Email/Phone string!": "请输入您的邮箱/手机号!",
"Please input your verification code!": "请输入您的验证码",
"Your confirmed password is inconsistent with the password!": "您确认的密码与密码不一致!",
"send code": "发送验证码",
"Verify code": "验证码",
"Password": "密码",
"Next Step": "下一步",
"Choose email verification or mobile verification": "选择邮箱验证或手机验证",
"Retrieve password": "找回密码",
"Confirm": "验证密码",
"Email/Phone's format wrong!": "邮箱/手机号格式错误!",
"Change Password": "修改密码",
"Account": "账号",
"Email/Phone": "邮箱/手机号",
"Verify": "验证",
"Reset": "重置"
}
}