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 }} +
+ {{ if ne (len .PageQuestions) 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}}/_/ + 你在下面文本框中填写的内容) +
+ + +
From 2c1191f7d73bf41f7b308c7545844ebe81362985 Mon Sep 17 00:00:00 2001 From: E99p1ant Date: Thu, 23 Mar 2023 08:05:45 +0800 Subject: [PATCH 09/16] wip --- internal/context/context.go | 2 +- internal/form/auth.go | 2 +- internal/form/form.go | 47 ++++++++++++++++++++------------- route/sms.go | 19 +++++++++++--- templates/auth/register.html | 50 +++++++++++++++++++++++++++++++----- templates/base/header.html | 1 + 6 files changed, 90 insertions(+), 31 deletions(-) diff --git a/internal/context/context.go b/internal/context/context.go index 0513d28..1ed5cf6 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.Map(smsModule) ctx.Map(c) ctx.Map(EndpointWeb) diff --git a/internal/form/auth.go b/internal/form/auth.go index 59a4958..71cc732 100644 --- a/internal/form/auth.go +++ b/internal/form/auth.go @@ -17,7 +17,7 @@ type Register struct { type SendSMS struct { Phone string `valid:"required;phone" label:"手机号码"` - Recaptcha string `form:"g-recaptcha-response" valid:"required" label:"Recaptcha" msg:"无感验证码加载错误,请尝试刷新页面重试。"` + Recaptcha string `valid:"required" label:"Recaptcha" msg:"无感验证码加载错误,请尝试刷新页面重试。"` } type Login struct { diff --git a/internal/form/form.go b/internal/form/form.go index 32bc89d..3a535e3 100644 --- a/internal/form/form.go +++ b/internal/form/form.go @@ -5,6 +5,7 @@ package form import ( + "encoding/json" "reflect" "github.com/flamego/flamego" @@ -33,30 +34,40 @@ func Bind(model interface{}) flamego.Handler { panic("form: pointer can not be accepted as binding model") } - return func(c context.Context, data template.Data) { + return func(c context.Context, endpointType context.EndpointType, data template.Data) { obj := reflect.New(reflect.TypeOf(model)) defer func() { c.Map(obj.Elem().Interface()) }() r := c.Request() - if err := r.ParseForm(); err != nil { - c.Map(Error{Category: ErrorCategoryDeserialization, Error: err}) - return - } + switch { + case endpointType.IsAPI(): + err := json.NewDecoder(r.Body().ReadCloser()).Decode(obj.Interface()) + if err != nil { + c.Map(Error{Category: ErrorCategoryDeserialization, Error: err}) + return + } - // Bind the form data to the given struct. - typ := reflect.TypeOf(obj.Interface()) - val := reflect.ValueOf(obj.Interface()) - if typ.Kind() == reflect.Ptr { - typ = typ.Elem() - val = val.Elem() - } - for i := 0; i < typ.NumField(); i++ { - field := typ.Field(i) - fieldName := typ.Field(i).Tag.Get("form") - if fieldName == "" { - fieldName = com.ToSnakeCase(field.Name) + case endpointType.IsWeb(): + if err := r.ParseForm(); err != nil { + c.Map(Error{Category: ErrorCategoryDeserialization, Error: err}) + return + } + + // Bind the form data to the given struct. + typ := reflect.TypeOf(obj.Interface()) + val := reflect.ValueOf(obj.Interface()) + if typ.Kind() == reflect.Ptr { + typ = typ.Elem() + val = val.Elem() + } + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + fieldName := typ.Field(i).Tag.Get("form") + if fieldName == "" { + fieldName = com.ToSnakeCase(field.Name) + } + val.Field(i).Set(reflect.ValueOf(r.Form.Get(fieldName))) } - val.Field(i).Set(reflect.ValueOf(r.Form.Get(fieldName))) } errors, ok := govalid.Check(obj.Interface()) diff --git a/route/sms.go b/route/sms.go index 049cccd..f6611f2 100644 --- a/route/sms.go +++ b/route/sms.go @@ -1,11 +1,12 @@ package route import ( - "net/http" + "os" "time" "github.com/flamego/cache" "github.com/flamego/recaptcha" + "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/NekoWheel/NekoBox/internal/context" @@ -28,12 +29,22 @@ func SendSMS(keyPrefix string) func(ctx context.Context, f form.SendSMS, sms sms return ctx.ServerError() } if !resp.Success { - return ctx.JSONError(http.StatusBadRequest, "验证码错误") + return ctx.JSONError(40000, "验证码错误") } phone := f.Phone - code := strutil.RandomNumericString(6) smsCodeCacheKey := keyPrefix + phone + + _, err = cache.Get(ctx.Request().Context(), smsCodeCacheKey) + if err != nil && !errors.Is(err, os.ErrNotExist) { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to read sms code cache") + return ctx.ServerError() + } else if err == nil { + return ctx.JSONError(40000, "请勿频繁发送短信验证码") + } + + code := strutil.RandomNumericString(6) + 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() @@ -41,7 +52,7 @@ func SendSMS(keyPrefix string) func(ctx context.Context, f form.SendSMS, sms sms 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, "发送短信验证码失败,请稍后重试") + return ctx.JSONError(50000, "发送短信验证码失败,请稍后重试") } logrus.WithContext(ctx.Request().Context()). diff --git a/templates/auth/register.html b/templates/auth/register.html index 9da0e3f..13592ab 100644 --- a/templates/auth/register.html +++ b/templates/auth/register.html @@ -1,32 +1,68 @@ {{template "base/header" .}} + +
{{ .CSRFTokenHTML }}
新用户注册 {{template "base/alert" .}}
- +
-
- - + +
+
+ +
+
+ +
+
+ +
+
- +
- +
- +
diff --git a/templates/base/header.html b/templates/base/header.html index 9ece1f2..57b66f0 100644 --- a/templates/base/header.html +++ b/templates/base/header.html @@ -27,6 +27,7 @@ {{ end }}