From 400e335e6839e7dde4e276f4b92a9b90f2502c12 Mon Sep 17 00:00:00 2001 From: Kininaru Date: Wed, 12 May 2021 21:38:31 +0800 Subject: [PATCH] feat: add reset email by verification code Signed-off-by: Kininaru --- controllers/verification.go | 69 +++++++++++++++++++ object/adapter.go | 5 ++ object/verification.go | 106 +++++++++++++++++++++++++++++ routers/router.go | 2 + web/src/ResetModal.js | 118 +++++++++++++++++++++++++++++++++ web/src/UserEditPage.js | 6 +- web/src/backend/UserBackend.js | 23 +++++++ 7 files changed, 326 insertions(+), 3 deletions(-) create mode 100644 controllers/verification.go create mode 100644 object/verification.go create mode 100644 web/src/ResetModal.js diff --git a/controllers/verification.go b/controllers/verification.go new file mode 100644 index 00000000..195d0d16 --- /dev/null +++ b/controllers/verification.go @@ -0,0 +1,69 @@ +package controllers + +import "github.com/casdoor/casdoor/object" + +func (c *ApiController) SendVerificationCode() { + destType := c.Ctx.Request.Form.Get("type") + dest := c.Ctx.Request.Form.Get("dest") + remoteAddr := c.Ctx.Request.RemoteAddr + + if len(destType) == 0 || len(dest) == 0 { + c.Data["json"] = Response{Status: "error", Msg: "Missing parameter."} + c.ServeJSON() + return + } + + ret := "Invalid dest type." + switch destType { + case "email": + ret = object.SendVerificationCodeToEmail(remoteAddr, dest) + } + + var status string + if len(ret) == 0 { + status = "ok" + } else { + status = "error" + } + + c.Data["json"] = Response{Status: status, Msg: ret} + c.ServeJSON() +} + +func (c *ApiController) ResetEmailOrPhone() { + userId := c.GetSessionUser() + if len(userId) == 0 { + c.ResponseError("Please sign in first") + return + } + user := object.GetUser(userId) + if user == nil { + c.ResponseError("No such user.") + return + } + + destType := c.Ctx.Request.Form.Get("type") + dest := c.Ctx.Request.Form.Get("dest") + code := c.Ctx.Request.Form.Get("code") + if len(dest) == 0 || len(code) == 0 || len(destType) == 0 { + c.ResponseError("Missing parameter.") + return + } + + if ret := object.CheckVerificationCode(dest, code); len(ret) != 0 { + c.ResponseError(ret) + return + } + + switch destType { + case "email": + user.Email = dest + object.SetUserField(user, "email", user.Email) + default: + c.ResponseError("Unknown type.") + return + } + + c.Data["json"] = Response{Status: "ok"} + c.ServeJSON() +} diff --git a/object/adapter.go b/object/adapter.go index 6242f88a..b6dfb125 100644 --- a/object/adapter.go +++ b/object/adapter.go @@ -128,4 +128,9 @@ func (a *Adapter) createTable() { if err != nil { panic(err) } + + err = a.Engine.Sync2(new(VerificationRecord)) + if err != nil { + panic(err) + } } diff --git a/object/verification.go b/object/verification.go new file mode 100644 index 00000000..8007ba8d --- /dev/null +++ b/object/verification.go @@ -0,0 +1,106 @@ +package object + +import ( + "fmt" + "math/rand" + "time" +) + +type VerificationRecord struct { + RemoteAddr string `xorm:"varchar(100) notnull pk"` + Receiver string `xorm:"varchar(100) notnull"` + Code string `xorm:"varchar(10) notnull"` + Time int64 `xorm:"notnull"` + IsUsed bool +} + +func SendVerificationCodeToEmail(remoteAddr, dest string) string { + title := "Casdoor Code" + sender := "Casdoor Admin" + code := getRandomCode(5) + content := fmt.Sprintf("You have requested a verification code at Casdoor. Here is your code: %s, please enter in 5 minutes.", code) + + if result := AddToVerificationRecord(remoteAddr, dest, code); len(result) != 0 { + return result + } + + if err := SendEmail(title, content, dest, sender); err != nil { + panic(err) + } + + return "" +} + +func AddToVerificationRecord(remoteAddr, dest, code string) string { + var record VerificationRecord + record.RemoteAddr = remoteAddr + has, err := adapter.Engine.Get(&record) + if err != nil { + panic(err) + } + + now := time.Now().Unix() + + if has && now - record.Time < 60 { + return "You can only send one code in 60s." + } + + record.Receiver = dest + record.Code = code + record.Time = now + record.IsUsed = false + + if has { + _, err = adapter.Engine.ID(record.RemoteAddr).AllCols().Update(record) + } else { + _, err = adapter.Engine.Insert(record) + } + + if err != nil { + panic(err) + } + + return "" +} + +func CheckVerificationCode(dest, code string) string { + var record VerificationRecord + record.Receiver = dest + has, err := adapter.Engine.Desc("time").Where("is_used = 0").Get(&record) + if err != nil { + panic(err) + } + + if !has { + return "Code has not been sent yet!" + } + + now := time.Now().Unix() + if now-record.Time > 5*60 { + return "You should verify your code in 5 min!" + } + + if record.Code != code { + return "Wrong code!" + } + + record.IsUsed = true + _, err = adapter.Engine.ID(record.RemoteAddr).AllCols().Update(record) + if err != nil { + panic(err) + } + + return "" +} + +// from Casnode/object/validateCode.go line 116 +var stdNums = []byte("0123456789") + +func getRandomCode(length int) string { + var result []byte + r := rand.New(rand.NewSource(time.Now().UnixNano())) + for i := 0; i < length; i++ { + result = append(result, stdNums[r.Intn(len(stdNums))]) + } + return string(result) +} diff --git a/routers/router.go b/routers/router.go index 875f6af2..a68be07d 100644 --- a/routers/router.go +++ b/routers/router.go @@ -60,6 +60,8 @@ 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/send-verification-code", &controllers.ApiController{}, "POST:SendVerificationCode") + beego.Router("/api/reset-email-or-phone", &controllers.ApiController{}, "POST:ResetEmailOrPhone") beego.Router("/api/get-providers", &controllers.ApiController{}, "GET:GetProviders") beego.Router("/api/get-default-providers", &controllers.ApiController{}, "GET:GetDefaultProviders") diff --git a/web/src/ResetModal.js b/web/src/ResetModal.js new file mode 100644 index 00000000..644c3c5f --- /dev/null +++ b/web/src/ResetModal.js @@ -0,0 +1,118 @@ +// 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 {Button, Col, Modal, Row, Input,} from "antd"; +import i18next from "i18next"; +import React from "react"; +import * as Setting from "./Setting" +import * as UserBackend from "./backend/UserBackend" + +export const ResetModal = (props) => { + const [visible, setVisible] = React.useState(false); + const [confirmLoading, setConfirmLoading] = React.useState(false); + const [sendButtonText, setSendButtonText] = React.useState(i18next.t("user:Send Code")); + const [sendCodeCoolDown, setCoolDown] = React.useState(false); + const {buttonText, destType, coolDownTime} = props; + + const showModal = () => { + setVisible(true); + }; + + const handleCancel = () => { + setVisible(false); + }; + + const handleOk = () => { + let dest = document.getElementById("dest").value; + let code = document.getElementById("code").value; + if (dest === "") { + Setting.showMessage("error", i18next.t("user:Empty ") + destType); + return; + } + if (code === "") { + Setting.showMessage("error", i18next.t("user:Empty Code")); + return; + } + setConfirmLoading(true); + UserBackend.resetEmailOrPhone(dest, destType, code).then(res => { + if (res.status === "ok") { + Setting.showMessage("success", i18next.t(destType + " reset")); + window.location.reload(); + } else { + Setting.showMessage("error", i18next.t(res.msg)); + setConfirmLoading(false); + } + }) + } + + const countDown = (second) => { + if (second <= 0) { + setSendButtonText(i18next.t("user:Send Code")); + setCoolDown(false); + return; + } + setSendButtonText(second); + setTimeout(() => countDown(second - 1), 1000); + } + + const sendCode = () => { + if (sendCodeCoolDown) return; + let dest = document.getElementById("dest").value; + if (dest === "") { + Setting.showMessage("error", i18next.t("user:Empty ") + destType); + return; + } + UserBackend.sendCode(dest, destType).then(res => { + if (res.status === "ok") { + Setting.showMessage("success", i18next.t("user:Code Sent")); + setCoolDown(true); + countDown(coolDownTime); + } else { + Setting.showMessage("error", i18next.t("user:" + res.msg)); + } + }) + } + + return ( + + + + + + {" " + sendButtonText + " "}} + /> + + + + + + + + + ) +} + +export default ResetModal; \ No newline at end of file diff --git a/web/src/UserEditPage.js b/web/src/UserEditPage.js index 2f7b1e3d..d48a348d 100644 --- a/web/src/UserEditPage.js +++ b/web/src/UserEditPage.js @@ -25,6 +25,7 @@ import * as ApplicationBackend from "./backend/ApplicationBackend"; import * as ProviderBackend from "./backend/ProviderBackend"; import * as Provider from "./auth/Provider"; import PasswordModal from "./PasswordModal"; +import ResetModal from "./ResetModal"; const { Option } = Select; @@ -275,9 +276,8 @@ class UserEditPage extends React.Component { {i18next.t("general:Email")}: - { - this.updateUserField('email', e.target.value); - }} /> + + { this.state.user.id === this.props.account.id ? () : null} diff --git a/web/src/backend/UserBackend.js b/web/src/backend/UserBackend.js index ac11b5e0..f0b159f6 100644 --- a/web/src/backend/UserBackend.js +++ b/web/src/backend/UserBackend.js @@ -92,3 +92,26 @@ export function setPassword(userOwner, userName, oldPassword, newPassword) { body: formData }).then(res => res.json()); } + +export function sendCode(dest, type) { + let formData = new FormData(); + formData.append("dest", dest); + formData.append("type", type); + return fetch(`${Setting.ServerUrl}/api/send-verification-code`, { + method: "POST", + credentials: "include", + body: formData + }).then(res => res.json()); +} + +export function resetEmailOrPhone(dest, type, code) { + let formData = new FormData(); + formData.append("dest", dest); + formData.append("type", type); + formData.append("code", code); + return fetch(`${Setting.ServerUrl}/api/reset-email-or-phone`, { + method: "POST", + credentials: "include", + body: formData + }).then(res => res.json()); +}