From 2e3cfbcecb0b86c35d375419e97168a09a7081e0 Mon Sep 17 00:00:00 2001 From: Harry-zklcdc Date: Mon, 24 Feb 2025 16:29:30 +0800 Subject: [PATCH] =?UTF-8?q?[Feat]=20=E2=9C=A8=20Add=20Email=20Verify=20API?= =?UTF-8?q?=20&=20Pages=20#15?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.example.yml | 7 ++ frontend/src/api.js | 6 ++ frontend/src/router/index.js | 6 ++ frontend/src/views/Verify.vue | 91 ++++++++++++++++++++++ frontend/src/views/users/Settings.vue | 106 +++++++++++++++++++++++++- models/users.go | 3 +- routers/api/v1/admin/users/detail.go | 2 +- routers/api/v1/admin/users/list.go | 2 +- routers/api/v1/admin/users/modify.go | 5 ++ routers/api/v1/middleware/code.go | 8 ++ routers/api/v1/user/login.go | 1 + routers/api/v1/user/profile.go | 2 + routers/api/v1/user/resetPassword.go | 14 +++- routers/api/v1/user/routers.go | 2 + routers/api/v1/user/verify.go | 52 +++++++++++++ routers/api/v1/user/verifySend.go | 87 +++++++++++++++++++++ services/config/config.go | 11 +++ services/config/exporter.go | 13 ++++ services/config/internal.go | 8 ++ services/smtp/smtp.go | 90 ++++++++++++++++++++++ services/system/init.go | 1 + 21 files changed, 508 insertions(+), 9 deletions(-) create mode 100644 frontend/src/views/Verify.vue create mode 100644 routers/api/v1/user/verify.go create mode 100644 routers/api/v1/user/verifySend.go create mode 100644 services/smtp/smtp.go diff --git a/config.example.yml b/config.example.yml index 4d19ee3..8540367 100644 --- a/config.example.yml +++ b/config.example.yml @@ -10,6 +10,13 @@ database: redis: host: ms-redis port: 6379 +smtp: + host: + port: + user: + password: + ssl: false system: + base_url: verify: false mount_dir: /path/to/mount diff --git a/frontend/src/api.js b/frontend/src/api.js index 74c9aab..89d9c96 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -35,6 +35,12 @@ export default { UserRegister(data) { return ajax('user/register', 'post', { data }) }, + UserVerifyRequest() { + return ajax(`user/verify`, 'post', {}) + }, + UserVerify(code) { + return ajax(`user/verify/${code}`, 'get', {}) + }, UserInstancesList(params) { return ajax('user/instances', 'get', { params }) diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 7e7b625..3bf0b9a 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -3,6 +3,7 @@ import { createRouter, createWebHistory } from 'vue-router'; import Login from '@/views/Login.vue'; import Register from '@/views/Register.vue'; +import Verify from '@/views/Verify.vue'; import InstanceCreate from '@/views/users/InstanceCreate.vue'; import InstanceList from '@/views/users/InstanceList.vue'; @@ -32,6 +33,11 @@ const router = createRouter({ name: 'register', component: Register }, + { + path: '/verify/:code', + name: 'verify', + component: Verify + }, { path: '/', name: 'dashboard', diff --git a/frontend/src/views/Verify.vue b/frontend/src/views/Verify.vue new file mode 100644 index 0000000..266cea9 --- /dev/null +++ b/frontend/src/views/Verify.vue @@ -0,0 +1,91 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/users/Settings.vue b/frontend/src/views/users/Settings.vue index 2ac9230..95d98db 100644 --- a/frontend/src/views/users/Settings.vue +++ b/frontend/src/views/users/Settings.vue @@ -1,3 +1,105 @@ \ No newline at end of file + + +
+
+
+
邮箱验证
+
+ + +
+
+
+
+ +
+
修改邮箱
+
+ + + +
+
+
+
+
+
+
+ +
+
+
修改密码
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/models/users.go b/models/users.go index 9ec3162..bba634f 100644 --- a/models/users.go +++ b/models/users.go @@ -16,7 +16,8 @@ type Users struct { Password string `json:"password,omitempty" gorm:"type:varchar(255);not null"` Role int `json:"role" gorm:"not null,default:0"` - Email string `json:"email" gorm:"type:varchar(255);uniqueIndex;unique;not null"` + Email string `json:"email" gorm:"type:varchar(255);uniqueIndex;unique;not null"` + Verify bool `json:"verify" gorm:"not null,default:false"` Balance float64 `json:"balance" gorm:"not null"` diff --git a/routers/api/v1/admin/users/detail.go b/routers/api/v1/admin/users/detail.go index adc1392..ed086d3 100644 --- a/routers/api/v1/admin/users/detail.go +++ b/routers/api/v1/admin/users/detail.go @@ -20,7 +20,7 @@ func detailHandler(ctx iris.Context) { user := models.Users{ ID: id, } - result := database.DB.Select("id", "username", "email", "role", "balance", "created_at").First(&user) + result := database.DB.Select("id", "username", "email", "role", "verify", "balance", "created_at").First(&user) if result.Error != nil { l.Error("detail user error: %v", result.Error) middleware.Error(ctx, middleware.CodeAdminUserDetailError, iris.StatusInternalServerError) diff --git a/routers/api/v1/admin/users/list.go b/routers/api/v1/admin/users/list.go index e8a9ea2..c73841b 100644 --- a/routers/api/v1/admin/users/list.go +++ b/routers/api/v1/admin/users/list.go @@ -42,7 +42,7 @@ func listHandler(ctx iris.Context) { return } - result := database.DB.Limit(limit).Offset(offset).Select("id", "username", "email", "role", "balance", "created_at").Order("id").Find(&users) + result := database.DB.Limit(limit).Offset(offset).Select("id", "username", "email", "role", "verify", "balance", "created_at").Order("id").Find(&users) if result.Error != nil { l.Error("list users error: %v", result.Error) middleware.Error(ctx, middleware.CodeAdminUserListError, iris.StatusInternalServerError) diff --git a/routers/api/v1/admin/users/modify.go b/routers/api/v1/admin/users/modify.go index 32aa034..8e4c955 100644 --- a/routers/api/v1/admin/users/modify.go +++ b/routers/api/v1/admin/users/modify.go @@ -11,6 +11,7 @@ import ( type modifyReqStruct struct { Password *string `json:"password"` Role *int `json:"role"` + Verify *bool `json:"verify"` } func modifyHandler(ctx iris.Context) { @@ -55,6 +56,10 @@ func modifyHandler(ctx iris.Context) { user.Role = *req.Role } + if req.Verify != nil { + user.Verify = *req.Verify + } + result = database.DB.Save(&user) if result.Error != nil { middleware.Error(ctx, middleware.CodeServeBusy, iris.StatusInternalServerError) diff --git a/routers/api/v1/middleware/code.go b/routers/api/v1/middleware/code.go index 730496d..df74601 100644 --- a/routers/api/v1/middleware/code.go +++ b/routers/api/v1/middleware/code.go @@ -42,6 +42,10 @@ const ( CodeAdminUserDeleteError ResCode = 2013 CodeAdminUserInstanceNoEmpty ResCode = 2014 CodeAdminUserModifyError ResCode = 2015 + + CodeUserAlreadyVerified ResCode = 3001 + CodeUserVerifyInvalid ResCode = 3002 + CodePasswordNotMatch ResCode = 3003 ) var codeMsgMap = map[ResCode]string{ @@ -84,4 +88,8 @@ var codeMsgMap = map[ResCode]string{ CodeAdminUserDeleteError: "delete user error", CodeAdminUserModifyError: "modify user error", CodeAdminUserInstanceNoEmpty: "user instances not empty", + + CodeUserAlreadyVerified: "user already verified", + CodeUserVerifyInvalid: "email verify error", + CodePasswordNotMatch: "password not match", } diff --git a/routers/api/v1/user/login.go b/routers/api/v1/user/login.go index 4d5852e..18b2830 100644 --- a/routers/api/v1/user/login.go +++ b/routers/api/v1/user/login.go @@ -65,6 +65,7 @@ func loginHandler(ctx iris.Context) { Email: user.Email, Role: user.Role, Balance: user.Balance, + Verify: user.Verify, } middleware.Result(ctx, profile) diff --git a/routers/api/v1/user/profile.go b/routers/api/v1/user/profile.go index 934ee64..cd09e74 100644 --- a/routers/api/v1/user/profile.go +++ b/routers/api/v1/user/profile.go @@ -14,6 +14,7 @@ type profile struct { Email string `json:"email"` Role int `json:"role"` Balance float64 `json:"balance"` + Verify bool `json:"verify"` } func profileHandler(ctx iris.Context) { @@ -40,6 +41,7 @@ func profileHandler(ctx iris.Context) { Email: user.Email, Role: user.Role, Balance: user.Balance, + Verify: user.Verify, } middleware.Result(ctx, profile) diff --git a/routers/api/v1/user/resetPassword.go b/routers/api/v1/user/resetPassword.go index 2c7ecbc..ca442ab 100644 --- a/routers/api/v1/user/resetPassword.go +++ b/routers/api/v1/user/resetPassword.go @@ -11,6 +11,7 @@ import ( type resetPasswordStruct struct { OldPassword string `json:"old_password"` NewPassword string `json:"new_password"` + RePassword string `json:"re_password"` } func resetPasswordHandler(ctx iris.Context) { @@ -22,12 +23,17 @@ func resetPasswordHandler(ctx iris.Context) { return } - var resetPassword resetPasswordStruct - if err := ctx.ReadJSON(&resetPassword); err != nil { + var req resetPasswordStruct + if err := ctx.ReadJSON(&req); err != nil { middleware.Error(ctx, middleware.CodeBadRequest, iris.StatusBadRequest) return } + if req.NewPassword != req.RePassword { + middleware.Error(ctx, middleware.CodePasswordNotMatch, iris.StatusBadRequest) + return + } + user := models.Users{ ID: uint(userId), } @@ -38,12 +44,12 @@ func resetPasswordHandler(ctx iris.Context) { return } - if !user.CheckPassword(resetPassword.OldPassword) { + if !user.CheckPassword(req.OldPassword) { middleware.Error(ctx, middleware.CodePasswordError, iris.StatusBadRequest) return } - user.Password = user.PasswordHash(resetPassword.NewPassword) + user.Password = user.PasswordHash(req.NewPassword) result = database.DB.Save(&user) if result.Error != nil { l.Error("save user error: %v", result.Error) diff --git a/routers/api/v1/user/routers.go b/routers/api/v1/user/routers.go index 2668309..ba80af0 100644 --- a/routers/api/v1/user/routers.go +++ b/routers/api/v1/user/routers.go @@ -25,6 +25,8 @@ func InitUser(party router.Party) { party.Post("/register", registerHandler) party.Get("/profile", middleware.AuthCheck, profileHandler) party.Post("/resetPassword", middleware.AuthCheck, resetPasswordHandler) + party.Get("/verify/{code:string}", verifyHandler) + party.Post("/verify", middleware.AuthCheck, verifySendHandler) servers.InitServers(party.Party("/servers")) instances.InitInstances(party.Party("/instances")) diff --git a/routers/api/v1/user/verify.go b/routers/api/v1/user/verify.go new file mode 100644 index 0000000..0115683 --- /dev/null +++ b/routers/api/v1/user/verify.go @@ -0,0 +1,52 @@ +package user + +import ( + "megrez/models" + "megrez/routers/api/v1/middleware" + "megrez/services/database" + "megrez/services/redis" + + "github.com/kataras/iris/v12" +) + +func verifyHandler(ctx iris.Context) { + l.SetFunction("verifyHandler") + + code := ctx.Params().GetString("code") + + rdb := redis.RawDB + v := rdb.Get(ctx, verifyRedisKeyPrefix+code) + + if v.Err() != nil { + middleware.Error(ctx, middleware.CodeUserVerifyInvalid, iris.StatusBadRequest) + return + } + + r := rdb.Del(ctx, verifyRedisKeyPrefix+code) + if r.Err() != nil { + middleware.Error(ctx, middleware.CodeServeBusy, iris.StatusInternalServerError) + l.Error("delete redis verify code error: %v", r.Err()) + return + } + + email := v.Val() + l.Debug("verify email: %s", email) + user := models.Users{ + Email: email, + } + result := database.DB.Where(&user).First(&user) + if result.Error != nil { + l.Error("get user error: %v", result.Error) + middleware.Error(ctx, middleware.CodeUserVerifyInvalid, iris.StatusBadRequest) + return + } + + result = database.DB.Model(&user).Update("verify", true) + if result.Error != nil { + middleware.Error(ctx, middleware.CodeServeBusy, iris.StatusInternalServerError) + l.Error("update user verify status Error: %v", result.Error) + return + } + + middleware.Success(ctx) +} diff --git a/routers/api/v1/user/verifySend.go b/routers/api/v1/user/verifySend.go new file mode 100644 index 0000000..63ebbab --- /dev/null +++ b/routers/api/v1/user/verifySend.go @@ -0,0 +1,87 @@ +package user + +import ( + "fmt" + "megrez/libs/crypto" + "megrez/models" + "megrez/routers/api/v1/middleware" + "megrez/services/config" + "megrez/services/database" + "megrez/services/redis" + "megrez/services/smtp" + "time" + + "github.com/kataras/iris/v12" +) + +const verifyRedisKeyPrefix = "verify:user:" +const verifyUrlPrefix = "/verify/" +const verifyTitle = "邮箱验证" +const verifyHTMLFormat = ` +
+ + + + + + + + + + +
+ MEGREZ 天权算能聚联计算平台
+
+

Hello, %s:

+

+ 请在15分钟内点击链接: %s  进行邮箱验证操作,十五分钟后该链接将会失效. +

+

+ 如果您有任何问题,请联系系统管理员以获得更多信息与支持。 +

+

+

MEGREZ 天权算能聚联计算平台

+
+
+
+` + +func verifySendHandler(ctx iris.Context) { + l.SetFunction("verifySendHandler") + + userId, err := ctx.Values().GetInt("userId") + if err != nil { + middleware.Error(ctx, middleware.CodeBadRequest, iris.StatusBadRequest) + return + } + + user := models.Users{ + ID: uint(userId), + } + result := database.DB.First(&user) + if result.Error != nil { + l.Error("get user error: %v", result.Error) + middleware.Error(ctx, middleware.CodeUserNotExist, iris.StatusInternalServerError) + return + } + + if user.Verify { + middleware.Error(ctx, middleware.CodeUserAlreadyVerified, iris.StatusBadRequest) + return + } + + rdb := redis.RawDB + + verifyUrl := crypto.Hex(32) + rdb.Set(ctx, verifyRedisKeyPrefix+verifyUrl, user.Email, 15*time.Minute) + + verifyUrl = config.GetSystemBaseUrl() + verifyUrlPrefix + verifyUrl + err = smtp.Send(user.Email, verifyTitle, fmt.Sprintf(verifyHTMLFormat, user.Username, verifyUrl, verifyUrl)) + if err != nil { + middleware.Error(ctx, middleware.CodeServeBusy, iris.StatusInternalServerError) + l.Error("Send SMTP Error: %v", err) + return + } + + middleware.Success(ctx) +} diff --git a/services/config/config.go b/services/config/config.go index 2d722f6..6781a86 100644 --- a/services/config/config.go +++ b/services/config/config.go @@ -9,6 +9,7 @@ type configStruct struct { Http httpStruct `yaml:"http,omitempty"` Database databaseStruct `yaml:"database,omitempty"` Redis redisStruct `yaml:"redis,omitempty"` + Smtp smtpStruct `yaml:"smtp,omitempty"` Log logStruct `yaml:"log,omitempty"` System systemStruct `yaml:"system,omitempty"` } @@ -35,12 +36,21 @@ type redisStruct struct { SentinelPassword string `yaml:"sentinel_password,omitempty"` } +type smtpStruct struct { + Host string `yaml:"host,omitempty"` + Port int `yaml:"port,omitempty"` + Password string `yaml:"password,omitempty"` + User string `yaml:"user,omitempty"` + SSL bool `yaml:"ssl,omitempty"` +} + type logStruct struct { Level string `yaml:"level,omitempty"` File string `yaml:"file,omitempty"` } type systemStruct struct { + BaseUrl string `yaml:"base_url,omitempty"` Salt string `yaml:"salt,omitempty"` Verify bool `yaml:"verify,omitempty"` MountDir string `yaml:"mount_dir,omitempty"` @@ -64,6 +74,7 @@ var config = configStruct{ Password: "GpuManager", Database: 0, }, + Smtp: smtpStruct{}, Log: logStruct{ Level: "DEBUG", File: "data/logs/backend.log", diff --git a/services/config/exporter.go b/services/config/exporter.go index 2c7f5e1..17b73de 100644 --- a/services/config/exporter.go +++ b/services/config/exporter.go @@ -2,6 +2,7 @@ package config import ( "os" + "strconv" "gopkg.in/yaml.v3" ) @@ -19,6 +20,10 @@ func InitConfig(path string) { l.Fatal("Failed to unmarshal config file", err) } l.SetLevel(config.GetLogLevel()) + + if config.System.BaseUrl == "" { + config.System.BaseUrl = "http://" + config.Http.Host + ":" + strconv.Itoa(config.Http.Port) + } } func GetDatabase() databaseStruct { @@ -33,6 +38,10 @@ func GetRedis() redisStruct { return config.GetRedis() } +func GetSmtp() smtpStruct { + return config.GetSmtp() +} + func GetLogLevel() string { return config.GetLogLevel() } @@ -41,6 +50,10 @@ func GetLogFile() string { return config.GetLogFile() } +func GetSystemBaseUrl() string { + return config.GetSystemBaseUrl() +} + func GetSystemSalt() string { return config.GetSystemSalt() } diff --git a/services/config/internal.go b/services/config/internal.go index 256944c..9980e93 100644 --- a/services/config/internal.go +++ b/services/config/internal.go @@ -20,6 +20,10 @@ func (c *configStruct) GetRedis() redisStruct { return c.Redis } +func (c *configStruct) GetSmtp() smtpStruct { + return c.Smtp +} + func (c *configStruct) GetLogLevel() string { return c.Log.Level } @@ -28,6 +32,10 @@ func (c *configStruct) GetLogFile() string { return c.Log.File } +func (c *configStruct) GetSystemBaseUrl() string { + return c.System.BaseUrl +} + func (c *configStruct) GetSystemSalt() string { return c.System.Salt } diff --git a/services/smtp/smtp.go b/services/smtp/smtp.go new file mode 100644 index 0000000..4e7854e --- /dev/null +++ b/services/smtp/smtp.go @@ -0,0 +1,90 @@ +package smtp + +import ( + "crypto/tls" + "errors" + "fmt" + "megrez/services/config" + "net/mail" + "net/smtp" + "strconv" +) + +const emailTitle = "MEGREZ 天权算能聚联计算平台" + +func Send(toAddr, title string, html string) error { + smtpConf := config.GetSmtp() + + if smtpConf.Host == "" || smtpConf.Port == 0 || smtpConf.User == "" || smtpConf.Password == "" { + return errors.New("SMTP smtpConfiguration is not set") + } + + if !smtpConf.SSL { + auth := smtp.PlainAuth("", smtpConf.User, smtpConf.Password, smtpConf.Host) + msg := append([]byte("Subject: "+title+" - "+emailTitle+" \r\n"+ + "MIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\r\n\r\n"), + html...) + err := smtp.SendMail(smtpConf.Host+":"+strconv.Itoa(smtpConf.Port), auth, smtpConf.User, []string{toAddr}, msg) + if err != nil { + return errors.New("fail to send email, Error:" + err.Error()) + } + return nil + } + + from := mail.Address{Name: "MEGREZ", Address: smtpConf.User} + to := mail.Address{Name: "", Address: toAddr} + auth := smtp.PlainAuth("", smtpConf.User, smtpConf.Password, smtpConf.Host) + // Setup headers + headers := make(map[string]string) + headers["From"] = from.String() + headers["To"] = to.String() + headers["Subject"] = title + " - " + emailTitle + headers["Content-Type"] = "text/html;charset=UTF-8" + msg := "" + for k, v := range headers { + msg += fmt.Sprintf("%s: %s\r\n", k, v) + } + msg += "\r\n" + html + tlssmtpConfig := &tls.Config{ + InsecureSkipVerify: true, + ServerName: smtpConf.Host, + } + conn, err := tls.Dial("tcp", smtpConf.Host+":"+strconv.Itoa(smtpConf.Port), tlssmtpConfig) + if err != nil { + return errors.New("fail to connect to the server, Error:" + err.Error()) + } + c, err := smtp.NewClient(conn, smtpConf.Host) + if err != nil { + return errors.New("fail to create smtp client, Error:" + err.Error()) + } + if err = c.Auth(auth); err != nil { + return errors.New("fail to auth, Error:" + err.Error()) + } + + // To && From + if err = c.Mail(from.Address); err != nil { + return errors.New("fail to set from address, Error:" + err.Error()) + } + + if err = c.Rcpt(to.Address); err != nil { + return errors.New("fail to set to address, Error:" + err.Error()) + } + w, err := c.Data() + if err != nil { + return errors.New("fail to get smtp data writer, Error:" + err.Error()) + } + + _, err = w.Write([]byte(msg)) + if err != nil { + return errors.New("fail to write data, Error:" + err.Error()) + } + + err = w.Close() + if err != nil { + return errors.New("fail to close smtp data writer, Error:" + err.Error()) + } + + c.Quit() + + return nil +} diff --git a/services/system/init.go b/services/system/init.go index 79a13eb..3d3075a 100644 --- a/services/system/init.go +++ b/services/system/init.go @@ -24,6 +24,7 @@ func systemInit() (err error) { Username: "admin", Email: "admin@gpuManager.com", Role: 3, + Verify: true, } result := database.DB.Create(&user) if result.Error != nil {