From 4de7286629c4e3fe8fec31e557edfc5a726ca733 Mon Sep 17 00:00:00 2001
From: E99p1ant
Date: Thu, 23 Mar 2023 02:05:53 +0800
Subject: [PATCH 01/16] sms: init
---
go.sum | 2 -
internal/security/sms/aliyun.go | 66 +++++++++++++++++++++++++++++++++
internal/security/sms/sms.go | 7 ++++
3 files changed, 73 insertions(+), 2 deletions(-)
create mode 100644 internal/security/sms/aliyun.go
create mode 100644 internal/security/sms/sms.go
diff --git a/go.sum b/go.sum
index ea4aa89..30e341a 100644
--- a/go.sum
+++ b/go.sum
@@ -660,8 +660,6 @@ github.com/urfave/cli/v2 v2.23.5 h1:xbrU7tAYviSpqeR3X4nEFWUdB/uDZ6DE+HxmRU7Xtyw=
github.com/urfave/cli/v2 v2.23.5/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
github.com/wuhan005/gadget v0.0.0-20221206194113-7619e407f1a0 h1:zOXiOJRG/FOohTliJiykpwIaCPtUTIh+G0jw2bOJkA8=
github.com/wuhan005/gadget v0.0.0-20221206194113-7619e407f1a0/go.mod h1:vmC2IdgzTpIRwn1ZpuV/I3k9AIbRJ7oqTHFenq/qwkE=
-github.com/wuhan005/govalid v0.0.0-20220315191209-043a899c3c7a h1:9vhVeLzwzrFm/pGinLXh2zCSDRO7ElnawEG4p527itQ=
-github.com/wuhan005/govalid v0.0.0-20220315191209-043a899c3c7a/go.mod h1:zRrIdMbJM3Xe4lmXyrUi2xF9CE0+D4Y0OpQIMpjC0Vo=
github.com/wuhan005/govalid v0.0.0-20230216091828-820aa255fd21 h1:EHaQ4hLfjckhbI+AEleDHeb0cEN1bIAfvWJEhCe7e2Y=
github.com/wuhan005/govalid v0.0.0-20230216091828-820aa255fd21/go.mod h1:zRrIdMbJM3Xe4lmXyrUi2xF9CE0+D4Y0OpQIMpjC0Vo=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
diff --git a/internal/security/sms/aliyun.go b/internal/security/sms/aliyun.go
new file mode 100644
index 0000000..a8565fe
--- /dev/null
+++ b/internal/security/sms/aliyun.go
@@ -0,0 +1,66 @@
+package sms
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+
+ "github.com/aliyun/alibaba-cloud-sdk-go/sdk/requests"
+ "github.com/aliyun/alibaba-cloud-sdk-go/services/dysmsapi"
+ "github.com/pkg/errors"
+)
+
+type AliyunSMS struct {
+ region, accessKey, accessKeySecret string
+}
+
+func NewAliyunSMS(region, accessKey, accessKeySecret string) *AliyunSMS {
+ return &AliyunSMS{
+ region: region,
+ accessKey: accessKey,
+ accessKeySecret: accessKeySecret,
+ }
+}
+
+func (s *AliyunSMS) SendCode(_ context.Context, phone, code string) error {
+ client, err := dysmsapi.NewClientWithAccessKey(s.region, s.accessKey, s.accessKeySecret)
+ if err != nil {
+ return errors.Wrap(err, "new client")
+ }
+
+ req := requests.NewCommonRequest()
+ req.Method = http.MethodPost
+ req.Scheme = "https"
+ req.Domain = "dysmsapi.aliyuncs.com"
+ req.Version = "2017-05-25"
+ req.ApiName = "SendSms"
+ req.QueryParams["RegionId"] = s.region
+ req.QueryParams["PhoneNumbers"] = phone
+ req.QueryParams["SignName"] = ""
+ req.QueryParams["TemplateCode"] = ""
+ req.QueryParams["TemplateParam"] = `{"code":"` + code + `"}`
+ resp, err := client.ProcessCommonRequest(req)
+ if err != nil {
+ return errors.Wrap(err, "process common request")
+ }
+
+ if err := client.DoAction(req, resp); err != nil {
+ return errors.Wrap(err, "do action")
+ }
+
+ var result struct {
+ Message string
+ Code string
+ BizId string
+ RequestId string
+ }
+ if err := json.Unmarshal(resp.GetHttpContentBytes(), &result); err != nil {
+ return errors.Wrap(err, "unmarshal")
+ }
+
+ // FYI: https://help.aliyun.com/document_detail/101346.html
+ if result.Code != "OK" {
+ return errors.Errorf("failed to send sms, code=%v, message=%v, requestId=%v", result.Code, result.Message, result.RequestId)
+ }
+ return nil
+}
diff --git a/internal/security/sms/sms.go b/internal/security/sms/sms.go
new file mode 100644
index 0000000..729d9ce
--- /dev/null
+++ b/internal/security/sms/sms.go
@@ -0,0 +1,7 @@
+package sms
+
+import "context"
+
+type SMS interface {
+ SendCode(ctx context.Context, phone, code string) error
+}
From 08de884d2be83b938ffb61e5268c7195c55250f1 Mon Sep 17 00:00:00 2001
From: E99p1ant
Date: Thu, 23 Mar 2023 02:14:42 +0800
Subject: [PATCH 02/16] map sms module
---
internal/conf/conf.go | 4 ++++
internal/conf/static.go | 8 ++++++++
internal/context/context.go | 15 +++++++++++++++
internal/security/sms/aliyun.go | 21 +++++++++++++++------
internal/security/sms/dummy.go | 18 ++++++++++++++++++
5 files changed, 60 insertions(+), 6 deletions(-)
create mode 100644 internal/security/sms/dummy.go
diff --git a/internal/conf/conf.go b/internal/conf/conf.go
index 8a54bf3..e2eff64 100644
--- a/internal/conf/conf.go
+++ b/internal/conf/conf.go
@@ -66,5 +66,9 @@ func Init() error {
return errors.Wrap(err, "map 'mail'")
}
+ if err := File.Section("sms").MapTo(&SMS); err != nil {
+ return errors.Wrap(err, "map 'sms'")
+ }
+
return nil
}
diff --git a/internal/conf/static.go b/internal/conf/static.go
index 79e1c1f..987412a 100644
--- a/internal/conf/static.go
+++ b/internal/conf/static.go
@@ -70,4 +70,12 @@ var (
Port int `ini:"port"`
SMTP string `ini:"smtp"`
}
+
+ SMS struct {
+ AliyunRegion string `ini:"aliyun_region"`
+ AliyunAccessKey string `ini:"aliyun_access_key"`
+ AliyunAccessKeySecret string `ini:"aliyun_access_key_secret"`
+ AliyunSignName string `ini:"aliyun_sign_name"`
+ AliyunTemplateCode string `ini:"aliyun_template_code"`
+ }
)
diff --git a/internal/context/context.go b/internal/context/context.go
index a54aaac..655b866 100644
--- a/internal/context/context.go
+++ b/internal/context/context.go
@@ -21,6 +21,7 @@ import (
"github.com/NekoWheel/NekoBox/internal/conf"
"github.com/NekoWheel/NekoBox/internal/db"
+ "github.com/NekoWheel/NekoBox/internal/security/sms"
templatepkg "github.com/NekoWheel/NekoBox/internal/template"
)
@@ -218,6 +219,20 @@ func Contexter() flamego.Handler {
c.ResponseWriter().Header().Set("X-Content-Type-Options", "nosniff")
c.ResponseWriter().Header().Set("X-Frame-Options", "DENY")
+ var smsModule sms.SMS
+ if conf.SMS.AliyunSignName != "" && conf.SMS.AliyunTemplateCode != "" {
+ smsModule = sms.NewAliyunSMS(sms.NewAliyunSMSOptions{
+ Region: conf.SMS.AliyunRegion,
+ AccessKey: conf.SMS.AliyunAccessKey,
+ AccessKeySecret: conf.SMS.AliyunAccessKeySecret,
+ SignName: conf.SMS.AliyunSignName,
+ TemplateCode: conf.SMS.AliyunTemplateCode,
+ })
+ } else {
+ smsModule = sms.NewDummySMS()
+ }
+ ctx.MapTo(smsModule, (*sms.SMS)(nil))
+
ctx.Map(c)
ctx.Map(EndpointWeb)
}
diff --git a/internal/security/sms/aliyun.go b/internal/security/sms/aliyun.go
index a8565fe..27302b3 100644
--- a/internal/security/sms/aliyun.go
+++ b/internal/security/sms/aliyun.go
@@ -12,13 +12,22 @@ import (
type AliyunSMS struct {
region, accessKey, accessKeySecret string
+
+ signName, templateCode string
+}
+
+type NewAliyunSMSOptions struct {
+ Region, AccessKey, AccessKeySecret string
+ SignName, TemplateCode string
}
-func NewAliyunSMS(region, accessKey, accessKeySecret string) *AliyunSMS {
+func NewAliyunSMS(options NewAliyunSMSOptions) *AliyunSMS {
return &AliyunSMS{
- region: region,
- accessKey: accessKey,
- accessKeySecret: accessKeySecret,
+ region: options.Region,
+ accessKey: options.AccessKey,
+ accessKeySecret: options.AccessKeySecret,
+ signName: options.SignName,
+ templateCode: options.TemplateCode,
}
}
@@ -36,8 +45,8 @@ func (s *AliyunSMS) SendCode(_ context.Context, phone, code string) error {
req.ApiName = "SendSms"
req.QueryParams["RegionId"] = s.region
req.QueryParams["PhoneNumbers"] = phone
- req.QueryParams["SignName"] = ""
- req.QueryParams["TemplateCode"] = ""
+ req.QueryParams["SignName"] = s.signName
+ req.QueryParams["TemplateCode"] = s.templateCode
req.QueryParams["TemplateParam"] = `{"code":"` + code + `"}`
resp, err := client.ProcessCommonRequest(req)
if err != nil {
diff --git a/internal/security/sms/dummy.go b/internal/security/sms/dummy.go
new file mode 100644
index 0000000..0179d48
--- /dev/null
+++ b/internal/security/sms/dummy.go
@@ -0,0 +1,18 @@
+package sms
+
+import (
+ "context"
+
+ "github.com/sirupsen/logrus"
+)
+
+type DummySMS struct{}
+
+func NewDummySMS() *DummySMS {
+ return &DummySMS{}
+}
+
+func (s *DummySMS) SendCode(ctx context.Context, phone, code string) error {
+ logrus.WithContext(ctx).WithField("phone", phone).WithField("code", code).Trace("Send code to phone number, but do nothing")
+ return nil
+}
From 4bf9a53e157b9f59920cd1964a0419d4d7fdd1f5 Mon Sep 17 00:00:00 2001
From: E99p1ant
Date: Thu, 23 Mar 2023 02:23:45 +0800
Subject: [PATCH 03/16] db(users): add `verify_type` field
---
internal/db/users.go | 28 ++++++++++++++++++++++++++++
internal/db/users_test.go | 28 ++++++++++++++++++++++++++++
internal/form/auth.go | 2 ++
3 files changed, 58 insertions(+)
diff --git a/internal/db/users.go b/internal/db/users.go
index b93a3c2..7540a5a 100644
--- a/internal/db/users.go
+++ b/internal/db/users.go
@@ -25,6 +25,7 @@ type UsersStore interface {
GetByDomain(ctx context.Context, domain string) (*User, error)
Update(ctx context.Context, id uint, opts UpdateUserOptions) error
UpdateHarassmentSetting(ctx context.Context, id uint, typ HarassmentSettingType) error
+ UpdateVerifyType(ctx context.Context, id uint, verifyType VerifyType) error
Authenticate(ctx context.Context, email, password string) (*User, error)
ChangePassword(ctx context.Context, id uint, oldPassword, newPassword string) error
UpdatePassword(ctx context.Context, id uint, newPassword string) error
@@ -39,6 +40,17 @@ type users struct {
*gorm.DB
}
+type VerifyType uint
+
+func (v VerifyType) IsValid() bool {
+ return v >= VerifyTypeUnverified && v <= VerifyTypeVerified
+}
+
+const (
+ VerifyTypeUnverified VerifyType = iota
+ VerifyTypeVerified
+)
+
type User struct {
gorm.Model `json:"-"`
Name string `json:"name"`
@@ -49,6 +61,7 @@ type User struct {
Background string `json:"background"`
Intro string `json:"intro"`
Notify NotifyType `json:"notify"`
+ VerifyType VerifyType `json:"-"`
HarassmentSetting HarassmentSettingType `json:"harassment_setting"`
}
@@ -83,6 +96,7 @@ type CreateUserOptions struct {
Domain string
Background string
Intro string
+ VerifyType VerifyType
}
var (
@@ -105,6 +119,7 @@ func (db *users) Create(ctx context.Context, opts CreateUserOptions) error {
Domain: opts.Domain,
Background: opts.Background,
Intro: opts.Intro,
+ VerifyType: opts.VerifyType,
Notify: NotifyTypeEmail,
}
newUser.EncodePassword()
@@ -185,6 +200,19 @@ func (db *users) UpdateHarassmentSetting(ctx context.Context, id uint, typ Haras
return nil
}
+func (db *users) UpdateVerifyType(ctx context.Context, id uint, verifyType VerifyType) error {
+ if !verifyType.IsValid() {
+ return errors.Errorf("unexpected verify type: %q", verifyType)
+ }
+
+ if err := db.WithContext(ctx).Where("id = ?", id).Updates(&User{
+ VerifyType: verifyType,
+ }).Error; err != nil {
+ return errors.Wrap(err, "update user")
+ }
+ return nil
+}
+
func (db *users) Authenticate(ctx context.Context, email, password string) (*User, error) {
u, err := db.GetByEmail(ctx, email)
if err != nil {
diff --git a/internal/db/users_test.go b/internal/db/users_test.go
index 98491ce..1597d1c 100644
--- a/internal/db/users_test.go
+++ b/internal/db/users_test.go
@@ -31,6 +31,7 @@ func TestUsers(t *testing.T) {
{"GetByDomain", testUsersGetByDomain},
{"Update", testUsersUpdate},
{"UpdateHarassmentSetting", testUsersUpdateHarassmentSetting},
+ {"UpdateVerifyType", testUsersUpdateVerifyType},
{"Authenticate", testUsersAuthenticate},
{"ChangePassword", testUsersChangePassword},
{"UpdatePassword", testUsersUpdatePassword},
@@ -283,6 +284,33 @@ func testUsersUpdateHarassmentSetting(t *testing.T, ctx context.Context, db *use
})
}
+func testUsersUpdateVerifyType(t *testing.T, ctx context.Context, db *users) {
+ err := db.Create(ctx, CreateUserOptions{
+ Name: "E99p1ant",
+ Password: "super_secret",
+ Email: "i@github.red",
+ Avatar: "avater.png",
+ Domain: "e99",
+ Background: "background.png",
+ Intro: "Be cool, but also be warm.",
+ })
+ require.Nil(t, err)
+
+ t.Run("normal", func(t *testing.T) {
+ err := db.UpdateVerifyType(ctx, 1, VerifyTypeVerified)
+ require.Nil(t, err)
+
+ got, err := db.GetByID(ctx, 1)
+ require.Nil(t, err)
+ require.Equal(t, VerifyTypeVerified, got.VerifyType)
+ })
+
+ t.Run("unexpected verify type", func(t *testing.T) {
+ err := db.UpdateVerifyType(ctx, 1, 404)
+ require.NotNil(t, err)
+ })
+}
+
func testUsersAuthenticate(t *testing.T, ctx context.Context, db *users) {
err := db.Create(ctx, CreateUserOptions{
Name: "E99p1ant",
diff --git a/internal/form/auth.go b/internal/form/auth.go
index b4ee2eb..de7ab0a 100644
--- a/internal/form/auth.go
+++ b/internal/form/auth.go
@@ -7,6 +7,8 @@ package form
type Register struct {
Email string `valid:"required;email;maxlen:100" label:"电子邮箱"`
Domain string `valid:"required;alphadash;minlen:3;maxlen:20" label:"个性域名"`
+ Phone string `label:"手机号码"`
+ VerifyCode string `label:"手机验证码"`
Name string `valid:"required;maxlen:20" label:"昵称"`
Password string `valid:"required;minlen:8;maxlen:30" label:"密码"`
RepeatPassword string `valid:"required;equal:Password" label:"重复密码"`
From b692f796ec0a4172bca8327879195c03ba34cfc7 Mon Sep 17 00:00:00 2001
From: E99p1ant
Date: Thu, 23 Mar 2023 02:27:49 +0800
Subject: [PATCH 04/16] route: add sms
---
internal/context/context.go | 2 +-
internal/form/auth.go | 4 ++++
internal/route/route.go | 1 +
route/auth/register.go | 5 +++++
4 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/internal/context/context.go b/internal/context/context.go
index 655b866..0513d28 100644
--- a/internal/context/context.go
+++ b/internal/context/context.go
@@ -231,7 +231,7 @@ func Contexter() flamego.Handler {
} else {
smsModule = sms.NewDummySMS()
}
- ctx.MapTo(smsModule, (*sms.SMS)(nil))
+ ctx.MapTo(smsModule, (sms.SMS)(nil))
ctx.Map(c)
ctx.Map(EndpointWeb)
diff --git a/internal/form/auth.go b/internal/form/auth.go
index de7ab0a..7cdc551 100644
--- a/internal/form/auth.go
+++ b/internal/form/auth.go
@@ -15,6 +15,10 @@ type Register struct {
Recaptcha string `form:"g-recaptcha-response" valid:"required" label:"Recaptcha" msg:"无感验证码加载错误,请尝试刷新页面重试。"`
}
+type RegisterSendSMS struct {
+ Phone string `valid:"required;phone" label:"手机号码"`
+}
+
type Login struct {
Email string `valid:"required;email;maxlen:100" label:"电子邮箱"`
Password string `valid:"required" label:"密码"`
diff --git a/internal/route/route.go b/internal/route/route.go
index 1e2532e..b049798 100644
--- a/internal/route/route.go
+++ b/internal/route/route.go
@@ -97,6 +97,7 @@ func New() *flamego.Flame {
f.Group("", func() {
f.Combo("/register").Get(auth.Register).Post(form.Bind(form.Register{}), auth.RegisterAction)
+ f.Post("/register/send-sms", form.Bind(form.RegisterSendSMS{}), auth.SendRegisterSMS)
f.Combo("/login").Get(auth.Login).Post(form.Bind(form.Login{}), auth.LoginAction)
f.Combo("/forgot-password").Get(auth.ForgotPassword).Post(form.Bind(form.ForgotPassword{}), auth.ForgotPasswordAction)
f.Combo("/recover-password").Get(auth.RecoverPassword).Post(form.Bind(form.RecoverPassword{}), auth.RecoverPasswordAction)
diff --git a/route/auth/register.go b/route/auth/register.go
index 009a8fa..027d356 100644
--- a/route/auth/register.go
+++ b/route/auth/register.go
@@ -13,12 +13,17 @@ import (
"github.com/NekoWheel/NekoBox/internal/context"
"github.com/NekoWheel/NekoBox/internal/db"
"github.com/NekoWheel/NekoBox/internal/form"
+ "github.com/NekoWheel/NekoBox/internal/security/sms"
)
func Register(ctx context.Context) {
ctx.Success("auth/register")
}
+func SendRegisterSMS(ctx context.Context, f form.RegisterSendSMS, sms sms.SMS) {
+
+}
+
func RegisterAction(ctx context.Context, f form.Register, recaptcha recaptcha.RecaptchaV3) {
if ctx.HasError() {
ctx.Success("auth/register")
From aca5496ea19f3d11db272584c06537c1e61c3be1 Mon Sep 17 00:00:00 2001
From: E99p1ant
Date: Thu, 23 Mar 2023 02:39:43 +0800
Subject: [PATCH 05/16] route: check phone and verify code
---
internal/form/auth.go | 3 +-
internal/strutil/strutil.go | 17 ++++++++++
route/auth/register.go | 64 +++++++++++++++++++++++++++++++++++--
3 files changed, 81 insertions(+), 3 deletions(-)
create mode 100644 internal/strutil/strutil.go
diff --git a/internal/form/auth.go b/internal/form/auth.go
index 7cdc551..5e72b25 100644
--- a/internal/form/auth.go
+++ b/internal/form/auth.go
@@ -16,7 +16,8 @@ type Register struct {
}
type RegisterSendSMS struct {
- Phone string `valid:"required;phone" label:"手机号码"`
+ Phone string `valid:"required;phone" label:"手机号码"`
+ Recaptcha string `form:"g-recaptcha-response" valid:"required" label:"Recaptcha" msg:"无感验证码加载错误,请尝试刷新页面重试。"`
}
type Login struct {
diff --git a/internal/strutil/strutil.go b/internal/strutil/strutil.go
new file mode 100644
index 0000000..ea1c267
--- /dev/null
+++ b/internal/strutil/strutil.go
@@ -0,0 +1,17 @@
+package strutil
+
+import (
+ "math/rand"
+ "time"
+)
+
+// RandomNumericString returns a random numeric string with the given length.
+func RandomNumericString(length int) string {
+ const charset = "0123456789"
+ r := rand.New(rand.NewSource(time.Now().UnixNano()))
+ b := make([]byte, length)
+ for i := range b {
+ b[i] = charset[r.Intn(len(charset))]
+ }
+ return string(b)
+}
diff --git a/route/auth/register.go b/route/auth/register.go
index 027d356..751dc0a 100644
--- a/route/auth/register.go
+++ b/route/auth/register.go
@@ -5,6 +5,11 @@
package auth
import (
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/flamego/cache"
"github.com/flamego/recaptcha"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
@@ -14,17 +19,50 @@ import (
"github.com/NekoWheel/NekoBox/internal/db"
"github.com/NekoWheel/NekoBox/internal/form"
"github.com/NekoWheel/NekoBox/internal/security/sms"
+ "github.com/NekoWheel/NekoBox/internal/strutil"
)
func Register(ctx context.Context) {
ctx.Success("auth/register")
}
-func SendRegisterSMS(ctx context.Context, f form.RegisterSendSMS, sms sms.SMS) {
+const (
+ smsCodeCacheKeyPrefix = "register-sms-code:"
+)
+func SendRegisterSMS(ctx context.Context, f form.RegisterSendSMS, sms sms.SMS, cache cache.Cache, recaptcha recaptcha.RecaptchaV3) {
+ // Check recaptcha code.
+ resp, err := recaptcha.Verify(f.Recaptcha, ctx.Request().Request.RemoteAddr)
+ if err != nil {
+ logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to check recaptcha")
+ _ = ctx.ServerError()
+ return
+ }
+ if !resp.Success {
+ _ = ctx.JSONError(http.StatusBadRequest, "验证码错误")
+ return
+ }
+
+ phone := f.Phone
+ code := strutil.RandomNumericString(6)
+ smsCodeCacheKey := smsCodeCacheKeyPrefix + phone
+ if err := cache.Set(ctx.Request().Context(), smsCodeCacheKey, code, 5*time.Minute); err != nil {
+ logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set sms code cache")
+ _ = ctx.ServerError()
+ return
+ }
+
+ if err := sms.SendCode(ctx.Request().Context(), phone, code); err != nil {
+ logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to send sms code")
+ _ = ctx.JSONError(http.StatusBadRequest, "发送短信验证码失败,请稍后重试")
+ return
+ }
+
+ logrus.WithContext(ctx.Request().Context()).WithField("phone", phone).WithField("code", code).Info("Send sms code successfully")
+ _ = ctx.JSON("发送短信验证码成功")
}
-func RegisterAction(ctx context.Context, f form.Register, recaptcha recaptcha.RecaptchaV3) {
+func RegisterAction(ctx context.Context, f form.Register, cache cache.Cache, recaptcha recaptcha.RecaptchaV3) {
if ctx.HasError() {
ctx.Success("auth/register")
return
@@ -44,6 +82,27 @@ func RegisterAction(ctx context.Context, f form.Register, recaptcha recaptcha.Re
return
}
+ verifyType := db.VerifyTypeUnverified
+ // Check sms code.
+ if f.Phone != "" && f.VerifyCode != "" {
+ verifyCodeInf, err := cache.Get(ctx.Request().Context(), smsCodeCacheKeyPrefix+f.Phone)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ ctx.SetErrorFlash("验证码已过期")
+ } else {
+ logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to read password recovery code cache")
+ ctx.SetInternalErrorFlash()
+ }
+ ctx.Redirect("/register")
+ return
+ } else {
+ verifyCode, ok := verifyCodeInf.(string)
+ if ok && verifyCode != "" && verifyCode == f.VerifyCode {
+ verifyType = db.VerifyTypeVerified
+ }
+ }
+ }
+
if err := db.Users.Create(ctx.Request().Context(), db.CreateUserOptions{
Name: f.Name,
Password: f.Password,
@@ -52,6 +111,7 @@ func RegisterAction(ctx context.Context, f form.Register, recaptcha recaptcha.Re
Domain: f.Domain,
Background: conf.Upload.DefaultBackground,
Intro: "问你想问的",
+ VerifyType: verifyType,
}); err != nil {
switch {
case errors.Is(err, db.ErrUserNotExists),
From 1fcd479c80867316365a257abaee0690e46868cb Mon Sep 17 00:00:00 2001
From: E99p1ant
Date: Thu, 23 Mar 2023 02:43:53 +0800
Subject: [PATCH 06/16] =?UTF-8?q?hide=20unverifyed=20user=E2=80=99s=20list?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
internal/db/users.go | 4 ++++
route/question/page.go | 4 ++++
templates/question/list.html | 10 ++++++----
3 files changed, 14 insertions(+), 4 deletions(-)
diff --git a/internal/db/users.go b/internal/db/users.go
index 7540a5a..956ce4c 100644
--- a/internal/db/users.go
+++ b/internal/db/users.go
@@ -46,6 +46,10 @@ func (v VerifyType) IsValid() bool {
return v >= VerifyTypeUnverified && v <= VerifyTypeVerified
}
+func (v VerifyType) IsUnverified() bool {
+ return v == VerifyTypeUnverified
+}
+
const (
VerifyTypeUnverified VerifyType = iota
VerifyTypeVerified
diff --git a/route/question/page.go b/route/question/page.go
index 8867914..edf97d9 100644
--- a/route/question/page.go
+++ b/route/question/page.go
@@ -87,6 +87,10 @@ func ListAPI(ctx context.Context) error {
return ctx.ServerError()
}
+ if pageUser.VerifyType.IsUnverified() {
+ return ctx.JSONError(40000, "该用户未验证,提问列表不可见")
+ }
+
pageQuestions, err := db.Questions.GetByUserID(ctx.Request().Context(), pageUser.ID, db.GetQuestionsByUserIDOptions{
Cursor: &dbutil.Cursor{
Value: cursorValue,
diff --git a/templates/question/list.html b/templates/question/list.html
index 8762356..673ecfc 100644
--- a/templates/question/list.html
+++ b/templates/question/list.html
@@ -27,10 +27,12 @@ {{ .PageUser.Name }}
{{template "base/alert" .}}
{{template "question/new-question-template" .}}
-
- {{ if ne (len .PageQuestions) 0 }}
-
@{{ .PageUser.Name }} 以前回答过的问题 ({{ .AnsweredCount }})
- {{ template "question/history-template" . }} + {{ if gt .PageUser.VerifyType 0 }} +@{{ .PageUser.Name }} 以前回答过的问题 ({{ .AnsweredCount }})
+ {{ template "question/history-template" . }} + {{ end }} {{ end }} From 72a61b65a2a2b6ae9cb0b0df02dd06f5d74ade12 Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Thu, 23 Mar 2023 02:58:48 +0800 Subject: [PATCH 07/16] db(users): add phone field --- internal/db/users.go | 5 ++++ internal/form/auth.go | 2 +- internal/form/user.go | 2 ++ internal/route/route.go | 10 ++++++-- route/auth/register.go | 46 ++++++---------------------------- route/sms.go | 55 +++++++++++++++++++++++++++++++++++++++++ route/user/profile.go | 40 +++++++++++++++++++++++++++++- 7 files changed, 118 insertions(+), 42 deletions(-) create mode 100644 route/sms.go diff --git a/internal/db/users.go b/internal/db/users.go index 956ce4c..f3c222d 100644 --- a/internal/db/users.go +++ b/internal/db/users.go @@ -60,6 +60,7 @@ type User struct { Name string `json:"name"` Password string `json:"-"` Email string `json:"email"` + Phone string `json:"-"` Avatar string `json:"avatar"` Domain string `json:"domain"` Background string `json:"background"` @@ -96,6 +97,7 @@ type CreateUserOptions struct { Name string Password string Email string + Phone string Avatar string Domain string Background string @@ -119,6 +121,7 @@ func (db *users) Create(ctx context.Context, opts CreateUserOptions) error { Name: opts.Name, Password: opts.Password, Email: opts.Email, + Phone: opts.Phone, Avatar: opts.Avatar, Domain: opts.Domain, Background: opts.Background, @@ -159,6 +162,7 @@ func (db *users) GetByDomain(ctx context.Context, domain string) (*User, error) type UpdateUserOptions struct { Name string + Phone string Avatar string Background string Intro string @@ -182,6 +186,7 @@ func (db *users) Update(ctx context.Context, id uint, opts UpdateUserOptions) er Avatar: opts.Avatar, Background: opts.Background, Intro: opts.Intro, + Phone: opts.Phone, Notify: opts.Notify, }).Error; err != nil { return errors.Wrap(err, "update user") diff --git a/internal/form/auth.go b/internal/form/auth.go index 5e72b25..59a4958 100644 --- a/internal/form/auth.go +++ b/internal/form/auth.go @@ -15,7 +15,7 @@ type Register struct { Recaptcha string `form:"g-recaptcha-response" valid:"required" label:"Recaptcha" msg:"无感验证码加载错误,请尝试刷新页面重试。"` } -type RegisterSendSMS struct { +type SendSMS struct { Phone string `valid:"required;phone" label:"手机号码"` Recaptcha string `form:"g-recaptcha-response" valid:"required" label:"Recaptcha" msg:"无感验证码加载错误,请尝试刷新页面重试。"` } diff --git a/internal/form/user.go b/internal/form/user.go index 4edbbe5..ad24473 100644 --- a/internal/form/user.go +++ b/internal/form/user.go @@ -8,6 +8,8 @@ type UpdateProfile struct { Name string `valid:"required;maxlen:20" label:"昵称"` OldPassword string `label:"旧密码"` NewPassword string `valid:"maxlen:30" label:"新密码"` + Phone string `label:"手机号码"` + VerifyCode string `label:"手机验证码"` Intro string `valid:"required;maxlen:100" label:"介绍"` NotifyEmail string `label:"开启邮箱通知"` } diff --git a/internal/route/route.go b/internal/route/route.go index b049798..fd0e021 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -97,7 +97,6 @@ func New() *flamego.Flame { f.Group("", func() { f.Combo("/register").Get(auth.Register).Post(form.Bind(form.Register{}), auth.RegisterAction) - f.Post("/register/send-sms", form.Bind(form.RegisterSendSMS{}), auth.SendRegisterSMS) f.Combo("/login").Get(auth.Login).Post(form.Bind(form.Login{}), auth.LoginAction) f.Combo("/forgot-password").Get(auth.ForgotPassword).Post(form.Bind(form.ForgotPassword{}), auth.ForgotPasswordAction) f.Combo("/recover-password").Get(auth.RecoverPassword).Post(form.Bind(form.RecoverPassword{}), auth.RecoverPasswordAction) @@ -129,8 +128,15 @@ func New() *flamego.Flame { }, reqUserSignIn) f.Group("/api/v1", func() { + f.Group("/register", func() { + f.Post("/send-sms", form.Bind(form.SendSMS{}), auth.SendRegisterSMSAPI) + }) + f.Group("/user", func() { - f.Get("", reqUserSignIn, user.ProfileAPI) + f.Group("/profile", func() { + f.Get("", user.ProfileAPI) + f.Post("/send-sms", form.Bind(form.SendSMS{}), user.SendProfileSMSAPI) + }, reqUserSignIn) f.Group("/{domain}", func() { f.Group("/questions", func() { diff --git a/route/auth/register.go b/route/auth/register.go index 751dc0a..a10eccb 100644 --- a/route/auth/register.go +++ b/route/auth/register.go @@ -5,9 +5,7 @@ package auth import ( - "net/http" "os" - "time" "github.com/flamego/cache" "github.com/flamego/recaptcha" @@ -19,47 +17,15 @@ import ( "github.com/NekoWheel/NekoBox/internal/db" "github.com/NekoWheel/NekoBox/internal/form" "github.com/NekoWheel/NekoBox/internal/security/sms" - "github.com/NekoWheel/NekoBox/internal/strutil" + "github.com/NekoWheel/NekoBox/route" ) func Register(ctx context.Context) { ctx.Success("auth/register") } -const ( - smsCodeCacheKeyPrefix = "register-sms-code:" -) - -func SendRegisterSMS(ctx context.Context, f form.RegisterSendSMS, sms sms.SMS, cache cache.Cache, recaptcha recaptcha.RecaptchaV3) { - // Check recaptcha code. - resp, err := recaptcha.Verify(f.Recaptcha, ctx.Request().Request.RemoteAddr) - if err != nil { - logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to check recaptcha") - _ = ctx.ServerError() - return - } - if !resp.Success { - _ = ctx.JSONError(http.StatusBadRequest, "验证码错误") - return - } - - phone := f.Phone - code := strutil.RandomNumericString(6) - smsCodeCacheKey := smsCodeCacheKeyPrefix + phone - if err := cache.Set(ctx.Request().Context(), smsCodeCacheKey, code, 5*time.Minute); err != nil { - logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set sms code cache") - _ = ctx.ServerError() - return - } - - if err := sms.SendCode(ctx.Request().Context(), phone, code); err != nil { - logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to send sms code") - _ = ctx.JSONError(http.StatusBadRequest, "发送短信验证码失败,请稍后重试") - return - } - - logrus.WithContext(ctx.Request().Context()).WithField("phone", phone).WithField("code", code).Info("Send sms code successfully") - _ = ctx.JSON("发送短信验证码成功") +func SendRegisterSMSAPI(ctx context.Context, f form.SendSMS, sms sms.SMS, cache cache.Cache, recaptcha recaptcha.RecaptchaV3) error { + return route.SendSMS(route.SMSCacheKeyPrefixRegister)(ctx, f, sms, cache, recaptcha) } func RegisterAction(ctx context.Context, f form.Register, cache cache.Cache, recaptcha recaptcha.RecaptchaV3) { @@ -82,10 +48,11 @@ func RegisterAction(ctx context.Context, f form.Register, cache cache.Cache, rec return } + var phone string verifyType := db.VerifyTypeUnverified // Check sms code. if f.Phone != "" && f.VerifyCode != "" { - verifyCodeInf, err := cache.Get(ctx.Request().Context(), smsCodeCacheKeyPrefix+f.Phone) + verifyCodeInf, err := cache.Get(ctx.Request().Context(), route.SMSCacheKeyPrefixRegister+f.Phone) if err != nil { if errors.Is(err, os.ErrNotExist) { ctx.SetErrorFlash("验证码已过期") @@ -98,6 +65,8 @@ func RegisterAction(ctx context.Context, f form.Register, cache cache.Cache, rec } else { verifyCode, ok := verifyCodeInf.(string) if ok && verifyCode != "" && verifyCode == f.VerifyCode { + // Set user's verified phone. + phone = f.Phone verifyType = db.VerifyTypeVerified } } @@ -107,6 +76,7 @@ func RegisterAction(ctx context.Context, f form.Register, cache cache.Cache, rec Name: f.Name, Password: f.Password, Email: f.Email, + Phone: phone, Avatar: conf.Upload.DefaultAvatarURL, Domain: f.Domain, Background: conf.Upload.DefaultBackground, diff --git a/route/sms.go b/route/sms.go new file mode 100644 index 0000000..049cccd --- /dev/null +++ b/route/sms.go @@ -0,0 +1,55 @@ +package route + +import ( + "net/http" + "time" + + "github.com/flamego/cache" + "github.com/flamego/recaptcha" + "github.com/sirupsen/logrus" + + "github.com/NekoWheel/NekoBox/internal/context" + "github.com/NekoWheel/NekoBox/internal/form" + "github.com/NekoWheel/NekoBox/internal/security/sms" + "github.com/NekoWheel/NekoBox/internal/strutil" +) + +const ( + SMSCacheKeyPrefixRegister = "register-sms-code:" + SMSCacheKeyPrefixBindPhone = "bind-phone-sms-code:" +) + +func SendSMS(keyPrefix string) func(ctx context.Context, f form.SendSMS, sms sms.SMS, cache cache.Cache, recaptcha recaptcha.RecaptchaV3) error { + return func(ctx context.Context, f form.SendSMS, sms sms.SMS, cache cache.Cache, recaptcha recaptcha.RecaptchaV3) error { + // Check recaptcha code. + resp, err := recaptcha.Verify(f.Recaptcha, ctx.Request().Request.RemoteAddr) + if err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to check recaptcha") + return ctx.ServerError() + } + if !resp.Success { + return ctx.JSONError(http.StatusBadRequest, "验证码错误") + } + + phone := f.Phone + code := strutil.RandomNumericString(6) + smsCodeCacheKey := keyPrefix + phone + if err := cache.Set(ctx.Request().Context(), smsCodeCacheKey, code, 5*time.Minute); err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set sms code cache") + return ctx.ServerError() + } + + if err := sms.SendCode(ctx.Request().Context(), phone, code); err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to send sms code") + return ctx.JSONError(http.StatusBadRequest, "发送短信验证码失败,请稍后重试") + } + + logrus.WithContext(ctx.Request().Context()). + WithField("key_prefix", keyPrefix). + WithField("phone", phone). + WithField("code", code). + Info("Send sms code successfully") + + return ctx.JSON("发送短信验证码成功") + } +} diff --git a/route/user/profile.go b/route/user/profile.go index f403b75..a92a4d5 100644 --- a/route/user/profile.go +++ b/route/user/profile.go @@ -7,8 +7,11 @@ package user import ( "fmt" "net/url" + "os" "time" + "github.com/flamego/cache" + "github.com/flamego/recaptcha" "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/xuri/excelize/v2" @@ -16,7 +19,9 @@ import ( "github.com/NekoWheel/NekoBox/internal/context" "github.com/NekoWheel/NekoBox/internal/db" "github.com/NekoWheel/NekoBox/internal/form" + "github.com/NekoWheel/NekoBox/internal/security/sms" "github.com/NekoWheel/NekoBox/internal/storage" + "github.com/NekoWheel/NekoBox/route" ) func Profile(ctx context.Context) { @@ -27,7 +32,11 @@ func ProfileAPI(ctx context.Context) error { return ctx.JSON(ctx.User) } -func UpdateProfile(ctx context.Context, f form.UpdateProfile) { +func SendProfileSMSAPI(ctx context.Context, f form.SendSMS, sms sms.SMS, cache cache.Cache, recaptcha recaptcha.RecaptchaV3) error { + return route.SendSMS(route.SMSCacheKeyPrefixBindPhone)(ctx, f, sms, cache, recaptcha) +} + +func UpdateProfile(ctx context.Context, f form.UpdateProfile, cache cache.Cache) { if ctx.HasError() { ctx.Success("user/profile") return @@ -74,6 +83,34 @@ func UpdateProfile(ctx context.Context, f form.UpdateProfile) { } } + // Check sms code. + var phone string + if f.Phone != "" && f.VerifyCode != "" { + verifyCodeInf, err := cache.Get(ctx.Request().Context(), route.SMSCacheKeyPrefixBindPhone+f.Phone) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + ctx.SetErrorFlash("验证码已过期") + } else { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to read password recovery code cache") + ctx.SetInternalErrorFlash() + } + ctx.Redirect("/register") + return + } else { + verifyCode, ok := verifyCodeInf.(string) + if ok && verifyCode != "" && verifyCode == f.VerifyCode { + // Set user's verified phone. + phone = f.Phone + + if err := db.Users.UpdateVerifyType(ctx.Request().Context(), ctx.User.ID, db.VerifyTypeVerified); err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update user verify type") + ctx.SetInternalErrorFlash() + return + } + } + } + } + var notify db.NotifyType if f.NotifyEmail != "" { notify = db.NotifyTypeEmail @@ -83,6 +120,7 @@ func UpdateProfile(ctx context.Context, f form.UpdateProfile) { if err := db.Users.Update(ctx.Request().Context(), ctx.User.ID, db.UpdateUserOptions{ Name: f.Name, + Phone: phone, Avatar: avatarURL, Background: backgroundURL, Intro: f.Intro, From b5978a80741c5ad0d20b953cb2d7d0b8bd4ad3f7 Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Thu, 23 Mar 2023 03:02:16 +0800 Subject: [PATCH 08/16] templates: add phone field in register page --- templates/auth/register.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/templates/auth/register.html b/templates/auth/register.html index 861c68c..9da0e3f 100644 --- a/templates/auth/register.html +++ b/templates/auth/register.html @@ -13,6 +13,10 @@{{.ExternalURL}}/_/
+ 你在下面文本框中填写的内容)
+