From 5f9b01093222de6826ccd735f5d6bdfe97375a49 Mon Sep 17 00:00:00 2001
From: ddCat <2695092045@qq.com>
Date: Tue, 22 Sep 2026 23:44:23 +0800
Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E7=99=BB=E5=BD=95=E6=B3=A8?=
=?UTF-8?q?=E5=86=8C=20-=20=E6=B7=BB=E5=8A=A0=E7=9F=AD=E4=BF=A1=E9=80=9A?=
=?UTF-8?q?=E7=9F=A5=E4=B8=8E=E7=BB=84=E5=90=88=E9=AA=8C=E8=AF=81=E6=94=AF?=
=?UTF-8?q?=E6=8C=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/cmd/migrate-sqlite-postgres/main.go | 4 +
backend/go.mod | 17 +-
backend/go.sum | 43 ++
backend/internal/app/auth_bridge.go | 4 +-
backend/internal/app/notifications_bridge.go | 88 ++++
backend/internal/app/plugin_management.go | 11 +
backend/internal/app/protocol_plugins.go | 11 +-
backend/internal/app/service.go | 4 +
backend/internal/app/sms_plugins.go | 26 ++
backend/internal/auth/auth.go | 98 ++++-
backend/internal/auth/email.go | 17 +
backend/internal/auth/host.go | 8 +
backend/internal/auth/linuxdo.go | 7 +
backend/internal/auth/verification.go | 321 +++++++++++++++
backend/internal/auth/verification_policy.go | 97 +++++
backend/internal/database/schema.go | 7 +
backend/internal/handler/auth.go | 1 +
backend/internal/handler/notifications.go | 183 +++++++++
backend/internal/model/models_identity.go | 24 +-
.../internal/model/models_notifications.go | 69 ++++
backend/internal/protocol/manifest.go | 10 +
backend/internal/protocol/types.go | 6 +
backend/internal/repository/notifications.go | 191 +++++++++
.../internal/service/aliases_notifications.go | 8 +
backend/internal/sms/service.go | 384 ++++++++++++++++++
.../third_party/go-sms-sender/CANVAS-PATCH.md | 15 +
backend/third_party/go-sms-sender/LICENSE | 201 +++++++++
backend/third_party/go-sms-sender/aliyun.go | 87 ++++
backend/third_party/go-sms-sender/aws.go | 87 ++++
backend/third_party/go-sms-sender/azure.go | 95 +++++
backend/third_party/go-sms-sender/baidubce.go | 76 ++++
backend/third_party/go-sms-sender/basic.go | 88 ++++
backend/third_party/go-sms-sender/gccpay.go | 127 ++++++
backend/third_party/go-sms-sender/go.mod | 33 ++
backend/third_party/go-sms-sender/huawei.go | 142 +++++++
backend/third_party/go-sms-sender/huyi.go | 89 ++++
backend/third_party/go-sms-sender/infobip.go | 125 ++++++
backend/third_party/go-sms-sender/mock.go | 27 ++
backend/third_party/go-sms-sender/msg91.go | 102 +++++
backend/third_party/go-sms-sender/netgsm.go | 114 ++++++
backend/third_party/go-sms-sender/oson.go | 120 ++++++
.../third_party/go-sms-sender/safe_http.go | 118 ++++++
backend/third_party/go-sms-sender/smsbao.go | 97 +++++
backend/third_party/go-sms-sender/submail.go | 135 ++++++
backend/third_party/go-sms-sender/tencent.go | 92 +++++
backend/third_party/go-sms-sender/twilio.go | 69 ++++
backend/third_party/go-sms-sender/ucloud.go | 83 ++++
backend/third_party/go-sms-sender/unisms.go | 72 ++++
.../third_party/go-sms-sender/volcengine.go | 81 ++++
docs/index.md | 2 +-
.../components/auth/verification-fields.tsx | 45 ++
web/src/pages/auth/login.tsx | 18 +-
web/src/pages/auth/register.tsx | 88 +---
web/src/services/api/auth.ts | 14 +-
web/src/services/api/verification.ts | 35 ++
55 files changed, 4013 insertions(+), 103 deletions(-)
create mode 100644 backend/internal/app/notifications_bridge.go
create mode 100644 backend/internal/app/sms_plugins.go
create mode 100644 backend/internal/auth/verification.go
create mode 100644 backend/internal/auth/verification_policy.go
create mode 100644 backend/internal/handler/notifications.go
create mode 100644 backend/internal/model/models_notifications.go
create mode 100644 backend/internal/repository/notifications.go
create mode 100644 backend/internal/service/aliases_notifications.go
create mode 100644 backend/internal/sms/service.go
create mode 100644 backend/third_party/go-sms-sender/CANVAS-PATCH.md
create mode 100644 backend/third_party/go-sms-sender/LICENSE
create mode 100644 backend/third_party/go-sms-sender/aliyun.go
create mode 100644 backend/third_party/go-sms-sender/aws.go
create mode 100644 backend/third_party/go-sms-sender/azure.go
create mode 100644 backend/third_party/go-sms-sender/baidubce.go
create mode 100644 backend/third_party/go-sms-sender/basic.go
create mode 100644 backend/third_party/go-sms-sender/gccpay.go
create mode 100644 backend/third_party/go-sms-sender/go.mod
create mode 100644 backend/third_party/go-sms-sender/huawei.go
create mode 100644 backend/third_party/go-sms-sender/huyi.go
create mode 100644 backend/third_party/go-sms-sender/infobip.go
create mode 100644 backend/third_party/go-sms-sender/mock.go
create mode 100644 backend/third_party/go-sms-sender/msg91.go
create mode 100644 backend/third_party/go-sms-sender/netgsm.go
create mode 100644 backend/third_party/go-sms-sender/oson.go
create mode 100644 backend/third_party/go-sms-sender/safe_http.go
create mode 100644 backend/third_party/go-sms-sender/smsbao.go
create mode 100644 backend/third_party/go-sms-sender/submail.go
create mode 100644 backend/third_party/go-sms-sender/tencent.go
create mode 100644 backend/third_party/go-sms-sender/twilio.go
create mode 100644 backend/third_party/go-sms-sender/ucloud.go
create mode 100644 backend/third_party/go-sms-sender/unisms.go
create mode 100644 backend/third_party/go-sms-sender/volcengine.go
create mode 100644 web/src/components/auth/verification-fields.tsx
create mode 100644 web/src/services/api/verification.ts
diff --git a/backend/cmd/migrate-sqlite-postgres/main.go b/backend/cmd/migrate-sqlite-postgres/main.go
index 6e20ab51b..0d68d050c 100644
--- a/backend/cmd/migrate-sqlite-postgres/main.go
+++ b/backend/cmd/migrate-sqlite-postgres/main.go
@@ -225,6 +225,10 @@ func migrations() []tableMigration {
migrateTable[model.UserIdentity]("user_identities"),
migrateTable[model.OAuthState]("o_auth_states"),
migrateTable[model.EmailVerificationCode]("email_verification_codes"),
+ migrateTable[model.AuthVerification]("auth_verifications"),
+ migrateTable[model.NotificationQuota]("notification_quota"),
+ migrateTable[model.SMSChannel]("sms_channels"),
+ migrateTable[model.SMSRecord]("sms_records"),
migrateTable[model.ModelChannel]("model_channels"),
migrateTable[model.ChannelModel]("channel_models"),
migrateTable[model.ChannelModelPriceTier]("channel_model_price_tiers"),
diff --git a/backend/go.mod b/backend/go.mod
index 01a001d9c..46d61beeb 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -3,7 +3,7 @@ module infinite-canvas/backend
go 1.25.0
require (
- github.com/aws/aws-sdk-go v1.40.45
+ github.com/aws/aws-sdk-go v1.45.5
github.com/gin-gonic/gin v1.12.0
github.com/google/uuid v1.5.0
github.com/qiniu/go-sdk/v7 v7.27.0
@@ -21,9 +21,13 @@ require (
require (
github.com/BurntSushi/toml v1.3.2 // indirect
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 // indirect
+ github.com/aliyun/alibaba-cloud-sdk-go v1.62.545 // indirect
+ github.com/apistd/uni-go-sdk v0.0.2 // indirect
+ github.com/baidubce/bce-sdk-go v0.9.156 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.2 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
+ github.com/casdoor/go-sms-sender v0.25.0 // indirect
github.com/cenkalti/backoff/v4 v4.1.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj v1.8.4 // indirect
@@ -36,6 +40,7 @@ require (
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gofrs/flock v0.8.1 // indirect
+ github.com/golang/mock v1.6.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -53,10 +58,17 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mozillazg/go-httpheader v0.2.1 // indirect
+ github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
+ github.com/sirupsen/logrus v1.9.0 // indirect
+ github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.744 // indirect
+ github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms v1.0.744 // indirect
+ github.com/twilio/twilio-go v1.13.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/ucloud/ucloud-sdk-go v0.22.5 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
@@ -64,5 +76,8 @@ require (
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
+ gopkg.in/ini.v1 v1.66.6 // indirect
modernc.org/fileutil v1.0.0 // indirect
)
+
+replace github.com/casdoor/go-sms-sender => ./third_party/go-sms-sender
diff --git a/backend/go.sum b/backend/go.sum
index 35aee6f39..a152a7e69 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -54,7 +54,11 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 h1:7dONQ3WNZ1zy960TmkxJPuwoolZwL7xKtpcM04MBnt4=
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82/go.mod h1:nLnM0KdK1CmygvjpDUO6m1TjSsiQtL61juhNsvV/JVI=
+github.com/aliyun/alibaba-cloud-sdk-go v1.62.545 h1:0LfzeUr4quwrrrTHn1kfLA0FBdsChCMs8eK2EzOwXVQ=
+github.com/aliyun/alibaba-cloud-sdk-go v1.62.545/go.mod h1:Api2AkmMgGaSUAhmk76oaFObkoeCPc/bKAqcyplPODs=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
+github.com/apistd/uni-go-sdk v0.0.2 h1:7kqETCOz/rz8AQU55XGzxDFGoFeMgeZL5fGwvxKBZrc=
+github.com/apistd/uni-go-sdk v0.0.2/go.mod h1:eIqYos4IbHgE/rB75r05ypNLahooEMJCrbjXq322b74=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-metrics v0.3.9/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc=
@@ -63,9 +67,14 @@ github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgI
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
github.com/aws/aws-sdk-go v1.40.45 h1:QN1nsY27ssD/JmW4s83qmSb+uL6DG4GmCDzjmJB4xUI=
github.com/aws/aws-sdk-go v1.40.45/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
+github.com/aws/aws-sdk-go v1.45.5 h1:bxilnhv9FngUgdPNJmOIv2bk+2sP0dpqX3e4olhWcGM=
+github.com/aws/aws-sdk-go v1.45.5/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
github.com/aws/aws-sdk-go-v2 v1.9.1/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.8.1/go.mod h1:CM+19rL1+4dFWnOQKwDc7H1KwXTz+h61oUSHyhV0b3o=
github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
+github.com/baidubce/bce-sdk-go v0.9.156 h1:f++WfptxGmSp5acsjl4kUxHpWDDccoFqkIrQKxvp/Sw=
+github.com/baidubce/bce-sdk-go v0.9.156/go.mod h1:zbYJMQwE4IZuyrJiFO8tO8NbtYiKTFTbwh4eIsqjVdg=
+github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
@@ -82,6 +91,8 @@ github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/casbin/casbin/v2 v2.37.0/go.mod h1:vByNa/Fchek0KZUgG5wEsl7iFsiviAYKRtgrQfcJqHg=
+github.com/casdoor/go-sms-sender v0.25.0 h1:eF4cOCSbjVg7+0uLlJQnna/FQ0BWW+Fp/x4cXhzQu1Y=
+github.com/casdoor/go-sms-sender v0.25.0/go.mod h1:bOm4H8/YfJmEHjBatEVQFOnAf0OOn1B0Wi5B7zDhws0=
github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
github.com/cenkalti/backoff/v4 v4.1.2 h1:6Yo7N8UP2K6LWZnW94DLVSSrbobcWdVzAYOisuDPIFo=
github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
@@ -173,6 +184,8 @@ github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
+github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
@@ -188,6 +201,8 @@ github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
+github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
+github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -302,10 +317,12 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
+github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -335,6 +352,7 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275/go.mod h1:zt6UU74K6Z6oMOYJbJzYpYucqdcQwSMPBEdSvGiaUMw=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
@@ -394,6 +412,8 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
+github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
+github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
github.com/openzipkin/zipkin-go v0.2.5/go.mod h1:KpXfKdgRDnnhsxw4pNIH9Md5lyFqKUa4YDFlwRYAMyE=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
@@ -404,6 +424,7 @@ github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0
github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -450,9 +471,12 @@ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
+github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
+github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
@@ -478,13 +502,23 @@ github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.744 h1:mnGB6/dYZWwiffjzCQgyCte/lg2PHWMX8Cln0y3TbcE=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.744/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms v1.0.744 h1:+aNOYBQb/gp4WdfKfpTvmiK7LHBrD567DAsDJZh2CAI=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms v1.0.744/go.mod h1:CUsOnyCLHn4pAzZivR3tdcI5A/7FYg33tl1WDXK8Fsg=
github.com/tencentyun/cos-go-sdk-v5 v0.7.75 h1:eRCGP5chujSYGFnsCPxlhQcN9wtlSO/4eXrxKtLVAIw=
github.com/tencentyun/cos-go-sdk-v5 v0.7.75/go.mod h1:STbTNaNKq03u+gscPEGOahKzLcGSYOj6Dzc5zNay7Pg=
github.com/tencentyun/qcloud-cos-sts-sdk v0.0.0-20250515025012-e0eec8a5d123/go.mod h1:b18KQa4IxHbxeseW1GcZox53d7J0z39VNONTxvvlkXw=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
+github.com/twilio/twilio-go v1.13.0 h1:8uKXSWAgCvO9Kn12iboy3x/Pw7oxPBufs94fTWQGhLk=
+github.com/twilio/twilio-go v1.13.0/go.mod h1:tdnfQ5TjbewoAu4lf9bMsGvfuJ/QU9gYuv9yx3TSIXU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk=
+github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
+github.com/ucloud/ucloud-sdk-go v0.22.5 h1:GIltVwMDUqQj4iPL/emsZAMhEYWjLTwZqpOxdkdDrM8=
+github.com/ucloud/ucloud-sdk-go v0.22.5/go.mod h1:dyLmFHmUfgb4RZKYQP9IArlvQ2pxzFthfhwxRzOEPIw=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
@@ -624,6 +658,7 @@ golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210917221730-978cfadd31cf/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
@@ -709,7 +744,9 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -717,6 +754,7 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
@@ -728,6 +766,7 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
@@ -785,6 +824,7 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -893,6 +933,9 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
+gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/ini.v1 v1.66.6 h1:LATuAqN/shcYAOkv3wl2L4rkaKqkcgTBQjOyYDvcPKI=
+gopkg.in/ini.v1 v1.66.6/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
diff --git a/backend/internal/app/auth_bridge.go b/backend/internal/app/auth_bridge.go
index 7617150d1..10fc5a7a6 100644
--- a/backend/internal/app/auth_bridge.go
+++ b/backend/internal/app/auth_bridge.go
@@ -117,7 +117,9 @@ func (s *Service) authDomain() *auth.Service {
if s.auth != nil {
return s.auth
}
- return auth.New(s.repo, authHost{svc: s}, nil)
+ domain := auth.New(s.repo, authHost{svc: s}, nil)
+ domain.SetSMSDelivery(s.smsDomain())
+ return domain
}
func (s *Service) PublicAuthSettings() (*PublicAuthSettings, error) {
diff --git a/backend/internal/app/notifications_bridge.go b/backend/internal/app/notifications_bridge.go
new file mode 100644
index 000000000..b686e67e2
--- /dev/null
+++ b/backend/internal/app/notifications_bridge.go
@@ -0,0 +1,88 @@
+package app
+
+import (
+ "context"
+ "crypto/rand"
+ "fmt"
+ "infinite-canvas/backend/internal/auth"
+ "infinite-canvas/backend/internal/model"
+ "infinite-canvas/backend/internal/sms"
+ "math/big"
+)
+
+type SMSChannelRequest = sms.ChannelRequest
+type VerificationPolicy = auth.VerificationPolicy
+type VerificationRequest = auth.VerificationRequest
+type VerificationConfirm = auth.VerificationConfirm
+
+func (s *Service) newSMSDomain() *sms.Service {
+ return sms.New(s.repo, authHost{svc: s}, func(id string) (bool, error) {
+ items := s.Plugins()
+ runtime, exists := runtimePluginByID(items, id)
+ if !exists || runtime.Status != "enabled" {
+ return false, nil
+ }
+ state, err := s.pluginStateForUser(nil, id, items)
+ return state.EffectiveEnabled, err
+ })
+}
+func (s *Service) smsDomain() *sms.Service {
+ if s.sms != nil {
+ return s.sms
+ }
+ return s.newSMSDomain()
+}
+func (s *Service) AdminSMSChannels(actor *model.User) ([]sms.ChannelView, error) {
+ return s.smsDomain().Channels(actor)
+}
+func (s *Service) SaveSMSChannel(actor *model.User, id string, req SMSChannelRequest) (*sms.ChannelView, error) {
+ result, err := s.smsDomain().SaveChannel(actor, id, req)
+ if err != nil {
+ return nil, err
+ }
+ if err := s.appendAdminAudit(actor, "sms.channel.save", "sms_channel", result.ID, "保存短信渠道", map[string]any{"provider": result.Provider, "enabled": result.Enabled}); err != nil {
+ return nil, err
+ }
+ return result, nil
+}
+func (s *Service) DeleteSMSChannel(actor *model.User, id string) error {
+ if err := s.smsDomain().DeleteChannel(actor, id); err != nil {
+ return err
+ }
+ return s.appendAdminAudit(actor, "sms.channel.delete", "sms_channel", id, "删除短信渠道", nil)
+}
+func (s *Service) AdminSMSRecords(actor *model.User, channel, state string, page, size int) (*sms.RecordPage, error) {
+ return s.smsDomain().Records(actor, channel, state, page, size)
+}
+func (s *Service) TestSMSChannel(actor *model.User, ctx context.Context, id, purpose, phone string) (*model.SMSRecord, error) {
+ if err := s.RequireAdmin(actor); err != nil {
+ return nil, err
+ }
+ value, err := rand.Int(rand.Reader, big.NewInt(1000000))
+ if err != nil {
+ return nil, err
+ }
+ return s.smsDomain().Test(actor, ctx, id, purpose, phone, fmt.Sprintf("%06d", value.Int64()))
+}
+func (s *Service) AdminVerificationPolicy(actor *model.User) (*VerificationPolicy, error) {
+ return s.authDomain().AdminVerificationPolicy(actor)
+}
+func (s *Service) UpdateVerificationPolicy(actor *model.User, p VerificationPolicy) (*VerificationPolicy, error) {
+ result, err := s.authDomain().UpdateVerificationPolicy(actor, p)
+ if err != nil {
+ return nil, err
+ }
+ if err := s.appendAdminAudit(actor, "auth.policy.update", "setting", "auth_verification_policy", "更新验证码登录注册策略", nil); err != nil {
+ return nil, err
+ }
+ return result, nil
+}
+func (s *Service) StartVerification(ctx context.Context, actor *model.User, req VerificationRequest) (*auth.VerificationTicket, error) {
+ return s.authDomain().StartVerification(ctx, actor, req)
+}
+func (s *Service) LoginVerification(req VerificationConfirm) (*AuthSessionResult, error) {
+ return s.authDomain().LoginVerification(req)
+}
+func (s *Service) BindVerification(actor *model.User, req VerificationConfirm) (*AuthUser, error) {
+ return s.authDomain().BindVerification(actor, req)
+}
diff --git a/backend/internal/app/plugin_management.go b/backend/internal/app/plugin_management.go
index ed4245fb4..803082306 100644
--- a/backend/internal/app/plugin_management.go
+++ b/backend/internal/app/plugin_management.go
@@ -23,6 +23,7 @@ const (
PluginKindProtocol = "protocol"
PluginKindApplication = "application"
PluginKindPayment = "payment"
+ PluginKindSMS = "sms"
PluginScopeSystem = "system"
PluginScopeUser = "user"
@@ -112,6 +113,9 @@ func pluginManagement(pluginID string, source string) PluginManagementView {
if policy, ok := systemPaymentPolicies[strings.TrimSpace(pluginID)]; ok {
return policy
}
+ if policy, ok := systemSMSPolicies[strings.TrimSpace(pluginID)]; ok {
+ return policy
+ }
return PluginManagementView{
Origin: PluginOriginOfficial, Kind: PluginKindProtocol,
ActivationScope: PluginScopeSystem, ConfigurationScope: PluginConfigurationSystem,
@@ -147,6 +151,10 @@ func knownPluginIDs(items []PluginView) []string {
seen[id] = struct{}{}
ids = append(ids, id)
}
+ for id := range systemSMSPolicies {
+ seen[id] = struct{}{}
+ ids = append(ids, id)
+ }
for _, item := range items {
if _, exists := seen[item.Manifest.ID]; exists {
continue
@@ -338,6 +346,9 @@ func (s *Service) SetPluginPlatformAvailability(actor *model.User, pluginID stri
}
func isKnownRuntimeOptionalPlugin(pluginID string) bool {
+ if isSystemSMSPluginID(pluginID) {
+ return true
+ }
if _, known := officialApplicationPolicies[pluginID]; known {
return true
}
diff --git a/backend/internal/app/protocol_plugins.go b/backend/internal/app/protocol_plugins.go
index 976ec7add..1d9e92eb5 100644
--- a/backend/internal/app/protocol_plugins.go
+++ b/backend/internal/app/protocol_plugins.go
@@ -191,6 +191,7 @@ func (c *pluginRuntime) bootstrapBuiltInPlugins() error {
byID[id] = record
}
bundledManifests := append(bundledWorkflowPluginManifests(), bundledPaymentPluginManifests()...)
+ bundledManifests = append(bundledManifests, bundledSMSPluginManifests()...)
for _, bundled := range bundledManifests {
builtInIDs[bundled.Metadata.ID] = struct{}{}
if existing, exists := byID[bundled.Metadata.ID]; exists && existing.PackagePath != "" && isSystemPaymentPluginID(bundled.Metadata.ID) {
@@ -222,6 +223,9 @@ func (c *pluginRuntime) bootstrapBuiltInPlugins() error {
if _, systemPayment := systemPaymentPolicies[bundled.Metadata.ID]; systemPayment {
source = PluginOriginSystem
}
+ if isSystemSMSPluginID(bundled.Metadata.ID) {
+ source = PluginOriginSystem
+ }
record.UpdatedAt = now
record.ID, record.Raw, record.Source, record.PackagePath = bundled.Metadata.ID, data, source, ""
byID[bundled.Metadata.ID] = record
@@ -343,7 +347,12 @@ func (c *pluginRuntime) reload() error {
continue
}
}
- if len(manifest.Contributes.PaymentProviders) > 0 && len(manifest.Contributes.Providers) == 0 {
+ if len(manifest.Contributes.SMSProviders) > 0 && (record.Source == PluginOriginUploaded || !isSystemSMSPluginID(id)) {
+ record.Metadata.Enabled, record.Status, record.Error = false, "invalid", "短信插件仅支持系统宿主适配器"
+ plugins[id] = record
+ continue
+ }
+ if (len(manifest.Contributes.PaymentProviders) > 0 || len(manifest.Contributes.SMSProviders) > 0) && len(manifest.Contributes.Providers) == 0 {
if record.Metadata.Enabled {
record.Status = "enabled"
} else {
diff --git a/backend/internal/app/service.go b/backend/internal/app/service.go
index 008f92828..4bf1f9358 100644
--- a/backend/internal/app/service.go
+++ b/backend/internal/app/service.go
@@ -19,6 +19,7 @@ import (
"infinite-canvas/backend/internal/prompts"
"infinite-canvas/backend/internal/repository"
"infinite-canvas/backend/internal/skills"
+ "infinite-canvas/backend/internal/sms"
"infinite-canvas/backend/internal/tools"
)
@@ -71,6 +72,7 @@ type Service struct {
tools *tools.Service
prompts *prompts.Service
auth *auth.Service
+ sms *sms.Service
canvas *canvas.Service
}
@@ -121,6 +123,8 @@ func newService(repo *repository.Repository, dataDir string) *Service {
service.tools = tools.New(service.repo)
service.prompts = prompts.New(service.repo, promptAdminGate{svc: service})
service.auth = auth.New(service.repo, authHost{svc: service}, nil)
+ service.sms = service.newSMSDomain()
+ service.auth.SetSMSDelivery(service.sms)
service.canvas = canvas.New(service.repo, canvasHost{svc: service})
service.platform = platform.New(service.repo, coordinator, platformHost{svc: service})
return service
diff --git a/backend/internal/app/sms_plugins.go b/backend/internal/app/sms_plugins.go
new file mode 100644
index 000000000..4fe7ad199
--- /dev/null
+++ b/backend/internal/app/sms_plugins.go
@@ -0,0 +1,26 @@
+package app
+
+import (
+ "infinite-canvas/backend/internal/protocol"
+ "infinite-canvas/backend/internal/sms"
+)
+
+var systemSMSPolicies = map[string]PluginManagementView{
+ sms.PluginAliyun: {Origin: PluginOriginSystem, Kind: "sms", ActivationScope: PluginScopeSystem, ConfigurationScope: PluginConfigurationSystem},
+ sms.PluginTencent: {Origin: PluginOriginSystem, Kind: "sms", ActivationScope: PluginScopeSystem, ConfigurationScope: PluginConfigurationSystem},
+}
+
+func bundledSMSPluginManifests() []protocol.Manifest {
+ result := make([]protocol.Manifest, 0, 2)
+ for _, p := range []struct{ ID, Provider, Name string }{{sms.PluginAliyun, sms.Aliyun, "阿里云短信"}, {sms.PluginTencent, sms.Tencent, "腾讯云短信"}} {
+ result = append(result, protocol.Manifest{
+ APIVersion: "yingce.plugin/v1",
+ Metadata: protocol.Metadata{ID: p.ID, Version: "1.0.0", Name: p.Name, Vendor: p.Name, Enabled: false, Installable: true, Description: "系统短信插件;支持多渠道、场景模板和发送记录。凭据在短信渠道管理中配置。", Documentation: "# " + p.Name + "\n\n使用 go-sms-sender v0.25.0 聚合库及受控传输扩展。发送受理不等于送达,不自动重试。"},
+ Surfaces: []string{"settings"}, Runtime: protocol.ManifestRuntime{Backend: "host:sms-" + p.Provider, Web: "host"},
+ Permissions: []string{"sms.send"}, Contributes: protocol.ManifestContributions{SMSProviders: []protocol.ManifestSMSProvider{{ID: p.Provider, Label: p.Name}}},
+ })
+ }
+ return result
+}
+
+func isSystemSMSPluginID(id string) bool { _, ok := systemSMSPolicies[id]; return ok }
diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go
index 451cae060..d148509df 100644
--- a/backend/internal/auth/auth.go
+++ b/backend/internal/auth/auth.go
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"errors"
"infinite-canvas/backend/internal/kernel"
+ "infinite-canvas/backend/internal/repository"
"log"
"net/mail"
"regexp"
@@ -31,6 +32,9 @@ type RegisterRequest struct {
Username string `json:"username"`
Email string `json:"email"`
EmailCode string `json:"emailCode"`
+ Phone string `json:"phone"`
+ SMSCode string `json:"smsCode"`
+ Ticket string `json:"ticket"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
AcceptedTerms bool `json:"acceptedTerms"`
@@ -42,11 +46,14 @@ type LoginRequest struct {
}
type PublicAuthSettings struct {
- FirstUser bool `json:"firstUser"`
- RegistrationEnabled bool `json:"registrationEnabled"`
- LinuxDOEnabled bool `json:"linuxdoEnabled"`
- EmailEnabled bool `json:"emailEnabled"`
- EmailCodeRequired bool `json:"emailCodeRequired"`
+ VerificationPolicy
+ SMSBindingAvailable bool `json:"smsBindingAvailable"`
+ EmailBindingAvailable bool `json:"emailBindingAvailable"`
+ FirstUser bool `json:"firstUser"`
+ RegistrationEnabled bool `json:"registrationEnabled"`
+ LinuxDOEnabled bool `json:"linuxdoEnabled"`
+ EmailEnabled bool `json:"emailEnabled"`
+ EmailCodeRequired bool `json:"emailCodeRequired"`
}
type AuthSessionResult struct {
@@ -79,7 +86,28 @@ func (s *Service) PublicAuthSettings() (*PublicAuthSettings, error) {
if err != nil {
return nil, err
}
- return &PublicAuthSettings{FirstUser: false, RegistrationEnabled: registrationEnabled, LinuxDOEnabled: s.LinuxDOEnabled(), EmailEnabled: emailEnabled, EmailCodeRequired: true}, nil
+ p, err := s.verificationPolicy()
+ if err != nil {
+ return nil, err
+ }
+ smsLogin, err := s.smsAvailable("login")
+ if err != nil {
+ return nil, err
+ }
+ smsRegister, err := s.smsAvailable("register")
+ if err != nil {
+ return nil, err
+ }
+ smsBind, err := s.smsAvailable("bind")
+ if err != nil {
+ return nil, err
+ }
+ p.SMSLogin = p.SMSLogin && smsLogin
+ p.EmailLogin = p.EmailLogin && emailEnabled
+ p.SMSRegistration = p.SMSRegistration && smsRegister
+ p.EmailRegistration = p.EmailRegistration && emailEnabled
+ p.SMSAndEmailRegistration = p.SMSAndEmailRegistration && smsRegister && emailEnabled
+ return &PublicAuthSettings{VerificationPolicy: p, SMSBindingAvailable: smsBind, EmailBindingAvailable: emailEnabled, FirstUser: false, RegistrationEnabled: registrationEnabled, LinuxDOEnabled: s.LinuxDOEnabled(), EmailEnabled: emailEnabled, EmailCodeRequired: p.EmailRegistration || p.SMSAndEmailRegistration}, nil
}
func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) {
@@ -107,6 +135,8 @@ func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) {
return nil, err
}
var verifiedCode *model.EmailVerificationCode
+ var verification *model.AuthVerification
+ phone := ""
if count > 0 {
registrationEnabled, err := s.RegistrationEnabled()
if err != nil {
@@ -115,15 +145,36 @@ func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) {
if !registrationEnabled {
return nil, kernel.Forbidden("管理员未开放新用户注册")
}
- if email == "" {
- return nil, kernel.BadAuthRequest("请输入邮箱")
- }
- if err := s.validateRegistrationEmailDomain(email); err != nil {
- return nil, err
- }
- verifiedCode, err = s.VerifyRegistrationEmailCode(email, req.EmailCode)
- if err != nil {
- return nil, err
+ if req.Ticket != "" {
+ verification, err = s.verifyTicket("register", VerificationConfirm{Ticket: req.Ticket, EmailCode: req.EmailCode, SMSCode: req.SMSCode})
+ if err != nil {
+ return nil, err
+ }
+ if verification.Email != email || (verification.Phone != "" && strings.TrimSpace(req.Phone) != verification.Phone && "+86"+strings.TrimSpace(req.Phone) != verification.Phone) || (verification.Phone == "" && req.Phone != "") {
+ return nil, invalidVerification()
+ }
+ phone = verification.Phone
+ if err := s.contactAvailable(email, phone, ""); err != nil {
+ return nil, err
+ }
+ } else {
+ p, err := s.verificationPolicy()
+ if err != nil {
+ return nil, err
+ }
+ if !p.allows("register", "email") || req.Phone != "" {
+ return nil, kernel.BadAuthRequest("请先获取本次注册验证码")
+ }
+ if email == "" {
+ return nil, kernel.BadAuthRequest("请输入邮箱")
+ }
+ if err := s.validateRegistrationEmailDomain(email); err != nil {
+ return nil, err
+ }
+ verifiedCode, err = s.VerifyRegistrationEmailCode(email, req.EmailCode)
+ if err != nil {
+ return nil, err
+ }
}
}
if _, err := s.repo.UserByUsername(username); err == nil {
@@ -147,6 +198,7 @@ func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) {
ID: kernel.NewID(),
Username: username,
Email: email,
+ Phone: phone,
DisplayName: displayName,
Role: model.UserRoleUser,
Status: model.UserStatusActive,
@@ -157,7 +209,21 @@ func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) {
if count == 0 {
user.Role = model.UserRoleAdmin
}
- if verifiedCode != nil {
+ if verification != nil {
+ if email != "" {
+ user.EmailVerifiedAt = &now
+ }
+ if phone != "" {
+ user.PhoneVerifiedAt = &now
+ }
+ if err := s.repo.CreateUserWithVerification(&user, verification.ID); err != nil {
+ if errors.Is(err, repository.ErrVerificationInvalid) {
+ return nil, invalidVerification()
+ }
+ return nil, err
+ }
+ } else if verifiedCode != nil {
+ user.EmailVerifiedAt = &now
if err := s.repo.CreateUserWithEmailVerification(&user, verifiedCode.ID, time.Now()); err != nil {
return nil, err
}
diff --git a/backend/internal/auth/email.go b/backend/internal/auth/email.go
index a50d09c1f..96f893f85 100644
--- a/backend/internal/auth/email.go
+++ b/backend/internal/auth/email.go
@@ -143,6 +143,13 @@ func (s *Service) EmailEnabled() (bool, error) {
}
func (s *Service) SendRegistrationEmailCode(rawEmail string) error {
+ p, err := s.verificationPolicy()
+ if err != nil {
+ return err
+ }
+ if !p.allows("register", "email") {
+ return kernel.Forbidden("邮箱单独验证注册未开启")
+ }
email := NormalizeEmail(rawEmail)
if err := ValidateEmail(email); err != nil {
return err
@@ -215,6 +222,13 @@ func (s *Service) SendRegistrationEmailCode(rawEmail string) error {
}
func (s *Service) VerifyRegistrationEmailCode(email string, rawCode string) (*model.EmailVerificationCode, error) {
+ p, err := s.verificationPolicy()
+ if err != nil {
+ return nil, err
+ }
+ if !p.allows("register", "email") {
+ return nil, kernel.Forbidden("邮箱单独验证注册未开启")
+ }
emailEnabled, err := s.EmailEnabled()
if err != nil {
return nil, err
@@ -236,6 +250,9 @@ func (s *Service) VerifyRegistrationEmailCode(email string, rawCode string) (*mo
if time.Now().After(record.ExpiresAt) {
return nil, kernel.BadAuthRequest("邮箱验证码已过期,请重新获取")
}
+ if err := s.repo.AttemptLegacyEmailVerification(record.ID); err != nil {
+ return nil, invalidVerification()
+ }
hash, err := s.emailVerificationCodeHash(registrationEmailPurpose, email, code)
if err != nil {
return nil, err
diff --git a/backend/internal/auth/host.go b/backend/internal/auth/host.go
index bc1b32cf3..64ebc48d3 100644
--- a/backend/internal/auth/host.go
+++ b/backend/internal/auth/host.go
@@ -51,8 +51,16 @@ type Service struct {
mailSender func(EmailSettingValue, string, string, string) error
emailCodeMu sync.Mutex
registrationMu sync.Mutex
+ sms SMSDelivery
}
+type SMSDelivery interface {
+ Available(string) (bool, error)
+ SendCode(context.Context, string, string, string) error
+}
+
+func (s *Service) SetSMSDelivery(delivery SMSDelivery) { s.sms = delivery }
+
func New(repo *repository.Repository, host Host, mailSender func(EmailSettingValue, string, string, string) error) *Service {
if host == nil {
host = nopHost{}
diff --git a/backend/internal/auth/linuxdo.go b/backend/internal/auth/linuxdo.go
index 498805469..11ae1a45c 100644
--- a/backend/internal/auth/linuxdo.go
+++ b/backend/internal/auth/linuxdo.go
@@ -227,6 +227,13 @@ func (s *Service) CompleteLinuxDOLogin(stateValue string, code string) (*LinuxDO
return nil, err
}
} else if errors.Is(err, gorm.ErrRecordNotFound) {
+ policy, policyErr := s.verificationPolicy()
+ if policyErr != nil {
+ return nil, policyErr
+ }
+ if policy.SMSAndEmailRegistration {
+ return nil, kernel.Forbidden("平台要求短信和邮箱验证,请先通过注册页面创建账号;第三方登录不能绕过双重验证")
+ }
registrationEnabled, settingErr := s.RegistrationEnabled()
if settingErr != nil {
return nil, settingErr
diff --git a/backend/internal/auth/verification.go b/backend/internal/auth/verification.go
new file mode 100644
index 000000000..7c04b671a
--- /dev/null
+++ b/backend/internal/auth/verification.go
@@ -0,0 +1,321 @@
+package auth
+
+import (
+ "context"
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "gorm.io/gorm"
+ "infinite-canvas/backend/internal/kernel"
+ "infinite-canvas/backend/internal/model"
+ "infinite-canvas/backend/internal/repository"
+ "infinite-canvas/backend/internal/sms"
+ "strings"
+ "time"
+)
+
+type VerificationRequest struct {
+ Purpose string `json:"purpose"`
+ Method string `json:"method"`
+ Email string `json:"email"`
+ Phone string `json:"phone"`
+ Password string `json:"password"`
+}
+type VerificationTicket struct {
+ Ticket string `json:"ticket"`
+ ExpiresIn int `json:"expiresIn"`
+ RetryAfter int `json:"retryAfter"`
+}
+type VerificationConfirm struct {
+ Ticket string `json:"ticket"`
+ EmailCode string `json:"emailCode"`
+ SMSCode string `json:"smsCode"`
+}
+
+func (s *Service) verificationHash(value string) (string, error) {
+ key, err := s.host.SettingsEncryptionKey()
+ if err != nil {
+ return "", err
+ }
+ if len(key) < 16 {
+ return "", kernel.NewAppError(503, "验证码加密密钥不可用")
+ }
+ h := hmac.New(sha256.New, key)
+ h.Write([]byte(value))
+ return hex.EncodeToString(h.Sum(nil)), nil
+}
+func (s *Service) checkVerificationPolicy(p VerificationPolicy, purpose, method string) error {
+ if !p.allows(purpose, method) {
+ return kernel.Forbidden("此验证方式未开启,请刷新页面")
+ }
+ if purpose == "register" {
+ enabled, err := s.RegistrationEnabled()
+ if err != nil {
+ return err
+ }
+ if !enabled {
+ return kernel.Forbidden("管理员未开放新用户注册")
+ }
+ }
+ return nil
+}
+func (s *Service) StartVerification(ctx context.Context, actor *model.User, req VerificationRequest) (*VerificationTicket, error) {
+ p, err := s.verificationPolicy()
+ if err != nil {
+ return nil, err
+ }
+ if err := s.checkVerificationPolicy(p, req.Purpose, req.Method); err != nil {
+ return nil, err
+ }
+ email, phone := "", ""
+ if req.Method == "email" || req.Method == "sms_email" {
+ email = NormalizeEmail(req.Email)
+ if err := ValidateEmail(email); err != nil {
+ return nil, err
+ }
+ enabled, err := s.EmailEnabled()
+ if err != nil {
+ return nil, err
+ }
+ if !enabled {
+ return nil, kernel.NewAppError(503, "邮件服务未启用")
+ }
+ if req.Purpose == "register" {
+ if err := s.validateRegistrationEmailDomain(email); err != nil {
+ return nil, err
+ }
+ }
+ }
+ if req.Method == "sms" || req.Method == "sms_email" {
+ phone, err = sms.NormalizePhone(req.Phone)
+ if err != nil {
+ return nil, err
+ }
+ enabled, err := s.smsAvailable(req.Purpose)
+ if err != nil {
+ return nil, err
+ }
+ if !enabled {
+ return nil, kernel.NewAppError(503, "此场景短信服务未启用")
+ }
+ }
+ userID := ""
+ if req.Purpose == "bind" {
+ if actor == nil || actor.Status != model.UserStatusActive {
+ return nil, kernel.Unauthorized("请先登录")
+ }
+ if !verifyPassword(req.Password, actor.PasswordHash) {
+ return nil, kernel.Unauthorized("当前密码不正确")
+ }
+ userID = actor.ID
+ }
+ // Existing, active, verified contacts only. Responses do not disclose whether
+ // an account exists; unknown accounts receive an unusable, equally shaped ticket.
+ if req.Purpose == "login" {
+ var user *model.User
+ if email != "" {
+ user, err = s.repo.UserByEmail(email)
+ } else {
+ user, err = s.repo.UserByPhone(phone)
+ }
+ if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, err
+ }
+ if err == nil && user.Status == model.UserStatusActive && ((email != "" && user.EmailVerifiedAt != nil) || (phone != "" && user.PhoneVerifiedAt != nil)) {
+ userID = user.ID
+ }
+ }
+ if req.Purpose != "login" {
+ if err := s.contactAvailable(email, phone, userID); err != nil {
+ return nil, err
+ }
+ }
+ secret := make([]byte, 32)
+ if _, err := rand.Read(secret); err != nil {
+ return nil, err
+ }
+ now := time.Now()
+ v := &model.AuthVerification{ID: kernel.NewID(), TokenHash: HashToken(hex.EncodeToString(secret)), Purpose: req.Purpose, Method: req.Method, UserID: userID, Email: email, Phone: phone, PolicyHash: p.hash(), CreatedAt: now, ExpiresAt: now.Add(10 * time.Minute)}
+ var emailCode, smsCode string
+ if email != "" {
+ emailCode, err = randomNumericCode(6)
+ if err != nil {
+ return nil, err
+ }
+ v.EmailHash, err = s.verificationHash(v.ID + ":email:" + email + ":" + emailCode)
+ if err != nil {
+ return nil, err
+ }
+ }
+ if phone != "" {
+ smsCode, err = randomNumericCode(6)
+ if err != nil {
+ return nil, err
+ }
+ v.PhoneHash, err = s.verificationHash(v.ID + ":sms:" + phone + ":" + smsCode)
+ if err != nil {
+ return nil, err
+ }
+ }
+ limits := []repository.NotificationLimit{}
+ for _, target := range []string{email, phone} {
+ if target == "" {
+ continue
+ }
+ hash, err := s.verificationHash("delivery:" + target)
+ if err != nil {
+ return nil, err
+ }
+ // Daily shared key also enforces a sliding 60-second cooldown across scenes.
+ limits = append(limits, repository.NotificationLimit{Key: "auth-day:" + hash + ":" + now.UTC().Format("2006-01-02"), Limit: 20, Cooldown: 60 * time.Second, ExpiresAt: now.Add(48 * time.Hour)}, repository.NotificationLimit{Key: "auth-hour:" + hash + ":" + now.UTC().Format("2006-01-02T15"), Limit: 5, ExpiresAt: now.Add(2 * time.Hour)})
+ }
+ if err := s.repo.CreateAuthVerification(v, limits); err != nil {
+ if errors.Is(err, repository.ErrNotificationLimit) {
+ return nil, kernel.RateLimited("发送过于频繁,请至少等待 60 秒后重试(每小时最多 5 次)")
+ }
+ return nil, err
+ }
+ ticket := &VerificationTicket{Ticket: v.ID + "." + hex.EncodeToString(secret), ExpiresIn: 600, RetryAfter: 60}
+ if req.Purpose == "login" && userID == "" {
+ return ticket, nil
+ }
+ if phone != "" {
+ if err := s.sms.SendCode(ctx, req.Purpose, phone, smsCode); err != nil {
+ return nil, err
+ }
+ }
+ if email != "" {
+ _, setting, err := s.readEmailSetting()
+ if err != nil {
+ return nil, err
+ }
+ setting = resolveEmailSender(setting, s.host.BrandName())
+ if err := s.deliverEmail(setting, email, setting.FromName+"身份验证码", fmt.Sprintf("您的验证码是:%s\n10 分钟内有效,仅用于本次身份验证。请勿向他人透露验证码。", emailCode)); err != nil {
+ return nil, kernel.NewAppError(503, "邮件发送失败,请稍后重试")
+ }
+ }
+ if err := s.repo.SetAuthVerificationReady(v.ID); err != nil {
+ return nil, err
+ }
+ return ticket, nil
+}
+func (s *Service) contactAvailable(email, phone, ownID string) error {
+ for _, kind := range []string{"email", "sms"} {
+ var user *model.User
+ var err error
+ if kind == "email" {
+ if email == "" {
+ continue
+ }
+ user, err = s.repo.UserByEmail(email)
+ } else {
+ if phone == "" {
+ continue
+ }
+ user, err = s.repo.UserByPhone(phone)
+ }
+ if err == nil && user.ID != ownID {
+ return kernel.BadAuthRequest("该邮箱或手机号已被使用")
+ }
+ if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+ }
+ return nil
+}
+func invalidVerification() error {
+ return kernel.BadAuthRequest("验证码错误、已失效或验证次数已用尽,请重新获取")
+}
+func (s *Service) verifyTicket(purpose string, req VerificationConfirm) (*model.AuthVerification, error) {
+ id, secret, ok := strings.Cut(req.Ticket, ".")
+ if !ok || len(secret) != 64 || len(id) > 36 {
+ return nil, invalidVerification()
+ }
+ v, err := s.repo.AuthVerification(id)
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, invalidVerification()
+ }
+ if err != nil {
+ return nil, err
+ }
+ if !hmac.Equal([]byte(v.TokenHash), []byte(HashToken(secret))) || v.Purpose != purpose {
+ return nil, invalidVerification()
+ }
+ p, err := s.verificationPolicy()
+ if err != nil {
+ return nil, err
+ }
+ if err := s.checkVerificationPolicy(p, v.Purpose, v.Method); err != nil {
+ return nil, err
+ }
+ if p.hash() != v.PolicyHash {
+ return nil, kernel.BadAuthRequest("验证策略已更新,请重新获取验证码")
+ }
+ if err := s.repo.AttemptAuthVerification(v.ID, time.Now()); err != nil {
+ if errors.Is(err, repository.ErrVerificationInvalid) {
+ return nil, invalidVerification()
+ }
+ return nil, err
+ }
+ if v.Email != "" {
+ hash, err := s.verificationHash(v.ID + ":email:" + v.Email + ":" + strings.TrimSpace(req.EmailCode))
+ if err != nil {
+ return nil, err
+ }
+ if !hmac.Equal([]byte(hash), []byte(v.EmailHash)) {
+ return nil, invalidVerification()
+ }
+ }
+ if v.Phone != "" {
+ hash, err := s.verificationHash(v.ID + ":sms:" + v.Phone + ":" + strings.TrimSpace(req.SMSCode))
+ if err != nil {
+ return nil, err
+ }
+ if !hmac.Equal([]byte(hash), []byte(v.PhoneHash)) {
+ return nil, invalidVerification()
+ }
+ }
+ return v, nil
+}
+func (s *Service) LoginVerification(req VerificationConfirm) (*AuthSessionResult, error) {
+ v, err := s.verifyTicket("login", req)
+ if err != nil {
+ return nil, err
+ }
+ user, err := s.repo.CompleteAuthVerification(v, false)
+ if err != nil {
+ if errors.Is(err, repository.ErrVerificationInvalid) {
+ return nil, invalidVerification()
+ }
+ return nil, err
+ }
+ if err := s.host.EnsureSignupBonus(user.ID); err != nil {
+ return nil, err
+ }
+ s.host.RecordActivity(user.ID, "login", 1)
+ return s.createAuthSession(user)
+}
+func (s *Service) BindVerification(actor *model.User, req VerificationConfirm) (*AuthUser, error) {
+ if actor == nil {
+ return nil, kernel.Unauthorized("请先登录")
+ }
+ v, err := s.verifyTicket("bind", req)
+ if err != nil {
+ return nil, err
+ }
+ if v.UserID != actor.ID {
+ return nil, invalidVerification()
+ }
+ if err := s.contactAvailable(v.Email, v.Phone, actor.ID); err != nil {
+ return nil, err
+ }
+ user, err := s.repo.CompleteAuthVerification(v, true)
+ if err != nil {
+ return nil, invalidVerification()
+ }
+ result, err := s.PublicAuthUser(user)
+ return &result, err
+}
diff --git a/backend/internal/auth/verification_policy.go b/backend/internal/auth/verification_policy.go
new file mode 100644
index 000000000..30f937ebe
--- /dev/null
+++ b/backend/internal/auth/verification_policy.go
@@ -0,0 +1,97 @@
+package auth
+
+import (
+ "encoding/json"
+ "errors"
+ "gorm.io/gorm"
+ "infinite-canvas/backend/internal/kernel"
+ "infinite-canvas/backend/internal/model"
+)
+
+const authPolicyKey = "auth_verification_policy"
+
+type VerificationPolicy struct {
+ SMSLogin bool `json:"smsLogin"`
+ EmailLogin bool `json:"emailLogin"`
+ SMSRegistration bool `json:"smsRegistration"`
+ EmailRegistration bool `json:"emailRegistration"`
+ SMSAndEmailRegistration bool `json:"smsAndEmailRegistration"`
+}
+
+func (s *Service) verificationPolicy() (VerificationPolicy, error) {
+ setting, err := s.repo.SystemSetting(authPolicyKey)
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return VerificationPolicy{EmailRegistration: true}, nil
+ }
+ if err != nil {
+ return VerificationPolicy{}, err
+ }
+ var p VerificationPolicy
+ if json.Unmarshal([]byte(setting.ValueJSON), &p) != nil {
+ return p, kernel.NewAppError(503, "登录注册策略格式无效")
+ }
+ return p, nil
+}
+func (s *Service) AdminVerificationPolicy(actor *model.User) (*VerificationPolicy, error) {
+ if err := s.host.RequireAdmin(actor); err != nil {
+ return nil, err
+ }
+ p, err := s.verificationPolicy()
+ return &p, err
+}
+func (s *Service) UpdateVerificationPolicy(actor *model.User, p VerificationPolicy) (*VerificationPolicy, error) {
+ if err := s.host.RequireAdmin(actor); err != nil {
+ return nil, err
+ }
+ if p.SMSAndEmailRegistration && (p.SMSRegistration || p.EmailRegistration) {
+ return nil, kernel.BadAuthRequest("短信+邮箱同时验证不能与单一验证注册同时开启")
+ }
+ if p.EmailLogin || p.EmailRegistration || p.SMSAndEmailRegistration {
+ enabled, err := s.EmailEnabled()
+ if err != nil {
+ return nil, err
+ }
+ if !enabled {
+ return nil, kernel.BadAuthRequest("请先配置并开启邮件服务")
+ }
+ }
+ for scene, required := range map[string]bool{"login": p.SMSLogin, "register": p.SMSRegistration || p.SMSAndEmailRegistration} {
+ if !required {
+ continue
+ }
+ enabled, err := s.smsAvailable(scene)
+ if err != nil {
+ return nil, err
+ }
+ if !enabled {
+ return nil, kernel.BadAuthRequest("请先启用短信插件并配置对应场景的短信渠道:" + scene)
+ }
+ }
+ encoded, _ := json.Marshal(p)
+ if err := s.repo.SaveSystemSetting(&model.SystemSetting{Key: authPolicyKey, ValueJSON: string(encoded), UpdatedBy: actor.ID}); err != nil {
+ return nil, err
+ }
+ return &p, nil
+}
+func (s *Service) smsAvailable(scene string) (bool, error) {
+ if s.sms == nil {
+ return false, nil
+ }
+ return s.sms.Available(scene)
+}
+func (p VerificationPolicy) allows(purpose, method string) bool {
+ if purpose == "bind" {
+ return method == "sms" || method == "email"
+ }
+ if purpose == "login" {
+ return (method == "sms" && p.SMSLogin) || (method == "email" && p.EmailLogin)
+ }
+ if purpose == "register" {
+ if p.SMSAndEmailRegistration {
+ return method == "sms_email"
+ }
+ return (method == "sms" && p.SMSRegistration) || (method == "email" && p.EmailRegistration)
+ }
+ return false
+}
+func (p VerificationPolicy) hash() string { raw, _ := json.Marshal(p); return HashToken(string(raw)) }
diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go
index c90ac5e59..1be1f3705 100644
--- a/backend/internal/database/schema.go
+++ b/backend/internal/database/schema.go
@@ -29,6 +29,10 @@ func Models() []any {
&model.UserIdentity{},
&model.OAuthState{},
&model.EmailVerificationCode{},
+ &model.AuthVerification{},
+ &model.NotificationQuota{},
+ &model.SMSChannel{},
+ &model.SMSRecord{},
&model.ModelChannel{},
&model.ChannelModel{},
&model.ChannelModelPriceTier{},
@@ -159,6 +163,9 @@ func migrateSchemaV1(db *gorm.DB) error {
if err := db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_logical_model_source_active ON logical_models(source_channel_model_id) WHERE source_channel_model_id <> '' AND archived_at IS NULL").Error; err != nil {
return err
}
+ if err := db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_phone_nonempty ON users(phone) WHERE phone <> ''").Error; err != nil {
+ return err
+ }
return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_nonempty ON users(lower(email)) WHERE email <> ''").Error
}
diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go
index 8e960244f..2db5195ab 100644
--- a/backend/internal/handler/auth.go
+++ b/backend/internal/handler/auth.go
@@ -21,6 +21,7 @@ import (
func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) {
registerChannelOrderRoutes(r, svc)
+ registerNotificationRoutes(r, svc)
r.GET("/auth/settings", func(c *gin.Context) {
settings, err := svc.PublicAuthSettings()
if err != nil {
diff --git a/backend/internal/handler/notifications.go b/backend/internal/handler/notifications.go
new file mode 100644
index 000000000..7b5812e1d
--- /dev/null
+++ b/backend/internal/handler/notifications.go
@@ -0,0 +1,183 @@
+package handler
+
+import (
+ "github.com/gin-gonic/gin"
+ "infinite-canvas/backend/internal/model"
+ "infinite-canvas/backend/internal/service"
+ "net/http"
+ "time"
+)
+
+func registerNotificationRoutes(r *gin.RouterGroup, svc *service.Service) {
+ r.POST("/auth/verification", func(c *gin.Context) {
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
+ var req service.VerificationRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, 400, err)
+ return
+ }
+ policy, available := loadRuntimePolicy(c, svc)
+ if !available || !enforceRateLimit(c, "verification-send:"+c.ClientIP(), policy.Request.EmailCodePerHour, time.Hour) {
+ return
+ }
+ var actor *model.User
+ if req.Purpose == "bind" {
+ var err error
+ actor, err = currentUser(c, svc)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ if !enforceRateLimit(c, "verification-bind:"+actor.ID, 10, time.Hour) {
+ return
+ }
+ }
+ result, err := svc.StartVerification(c.Request.Context(), actor, req)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, result)
+ })
+ r.POST("/auth/verification/login", func(c *gin.Context) {
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
+ var req service.VerificationConfirm
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, 400, err)
+ return
+ }
+ if !enforceRateLimit(c, "verification-login:"+c.ClientIP(), 30, time.Hour) {
+ return
+ }
+ result, err := svc.LoginVerification(req)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ setSessionCookie(c, result.Session, result.MaxAgeSecs)
+ ok(c, gin.H{"user": result.User})
+ })
+ r.POST("/auth/verification/bind", func(c *gin.Context) {
+ actor, err := currentUser(c, svc)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
+ var req service.VerificationConfirm
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, 400, err)
+ return
+ }
+ if !enforceRateLimit(c, "verification-bind-confirm:"+actor.ID, 30, time.Hour) {
+ return
+ }
+ result, err := svc.BindVerification(actor, req)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"user": result})
+ })
+ admin := r.Group("/admin")
+ admin.Use(func(c *gin.Context) {
+ actor, err := currentUser(c, svc)
+ if err == nil {
+ err = svc.RequireAdmin(actor)
+ }
+ if err != nil {
+ failService(c, err)
+ c.Abort()
+ return
+ }
+ c.Set("notificationActor", actor)
+ c.Next()
+ })
+ actor := func(c *gin.Context) *model.User { return c.MustGet("notificationActor").(*model.User) }
+ admin.GET("/settings/auth-policy", func(c *gin.Context) {
+ result, err := svc.AdminVerificationPolicy(actor(c))
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, result)
+ })
+ admin.PUT("/settings/auth-policy", func(c *gin.Context) {
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
+ var req service.VerificationPolicy
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, 400, err)
+ return
+ }
+ result, err := svc.UpdateVerificationPolicy(actor(c), req)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, result)
+ })
+ admin.GET("/sms/channels", func(c *gin.Context) {
+ result, err := svc.AdminSMSChannels(actor(c))
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, result)
+ })
+ save := func(c *gin.Context) {
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10)
+ var req service.SMSChannelRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, 400, err)
+ return
+ }
+ result, err := svc.SaveSMSChannel(actor(c), c.Param("id"), req)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, result)
+ }
+ admin.POST("/sms/channels", save)
+ admin.PUT("/sms/channels/:id", save)
+ admin.DELETE("/sms/channels/:id", func(c *gin.Context) {
+ if err := svc.DeleteSMSChannel(actor(c), c.Param("id")); err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"deleted": true})
+ })
+ admin.POST("/sms/channels/:id/test", func(c *gin.Context) {
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 4<<10)
+ var req struct {
+ Phone string `json:"phone"`
+ Purpose string `json:"purpose"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, 400, err)
+ return
+ }
+ if !enforceRateLimit(c, "sms-test:"+actor(c).ID, 5, time.Hour) {
+ return
+ }
+ result, err := svc.TestSMSChannel(actor(c), c.Request.Context(), c.Param("id"), req.Purpose, req.Phone)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, result)
+ })
+ admin.GET("/sms/records", func(c *gin.Context) {
+ page, size, err := parsePaginationQuery(c, 20)
+ if err != nil {
+ fail(c, 400, err)
+ return
+ }
+ result, err := svc.AdminSMSRecords(actor(c), c.Query("channelId"), c.Query("state"), page, size)
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, result)
+ })
+}
diff --git a/backend/internal/model/models_identity.go b/backend/internal/model/models_identity.go
index 487cbf112..9b5aae32c 100644
--- a/backend/internal/model/models_identity.go
+++ b/backend/internal/model/models_identity.go
@@ -3,16 +3,19 @@ package model
import "time"
type User struct {
- ID string `json:"id" gorm:"primaryKey;size:36"`
- Username string `json:"username" gorm:"uniqueIndex;size:80"`
- Email string `json:"email,omitempty" gorm:"size:160"`
- DisplayName string `json:"displayName" gorm:"size:80"`
- Role UserRole `json:"role" gorm:"index;size:24"`
- Status UserStatus `json:"status" gorm:"index;size:24"`
- PasswordHash string `json:"-"`
- LastLoginAt *time.Time `json:"lastLoginAt"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
+ ID string `json:"id" gorm:"primaryKey;size:36"`
+ Username string `json:"username" gorm:"uniqueIndex;size:80"`
+ Email string `json:"email,omitempty" gorm:"size:160"`
+ Phone string `json:"phone,omitempty" gorm:"size:24"`
+ EmailVerifiedAt *time.Time `json:"emailVerifiedAt,omitempty"`
+ PhoneVerifiedAt *time.Time `json:"phoneVerifiedAt,omitempty"`
+ DisplayName string `json:"displayName" gorm:"size:80"`
+ Role UserRole `json:"role" gorm:"index;size:24"`
+ Status UserStatus `json:"status" gorm:"index;size:24"`
+ PasswordHash string `json:"-"`
+ LastLoginAt *time.Time `json:"lastLoginAt"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
}
type AuthSession struct {
@@ -48,6 +51,7 @@ type OAuthState struct {
}
type EmailVerificationCode struct {
+ Attempts int `json:"-"`
ID string `json:"id" gorm:"primaryKey;size:36"`
Email string `json:"email" gorm:"index;size:160"`
CodeHash string `json:"-" gorm:"size:64"`
diff --git a/backend/internal/model/models_notifications.go b/backend/internal/model/models_notifications.go
new file mode 100644
index 000000000..cf25aa168
--- /dev/null
+++ b/backend/internal/model/models_notifications.go
@@ -0,0 +1,69 @@
+package model
+
+import (
+ "gorm.io/gorm"
+ "time"
+)
+
+type SMSChannel struct {
+ ID string `json:"id" gorm:"primaryKey;size:36"`
+ Name string `json:"name" gorm:"size:80"`
+ Provider string `json:"provider" gorm:"size:32;index"`
+ Enabled bool `json:"enabled"`
+ Priority int `json:"priority"`
+ DailyLimit int `json:"dailyLimit"`
+ SignName string `json:"signName" gorm:"size:80"`
+ AppID string `json:"appId" gorm:"size:80"`
+ Credentials string `json:"-"`
+ TemplatesJSON string `json:"-"`
+ Version int `json:"version"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+ DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
+}
+
+// SMSRecord contains no code, template parameters, raw response or full phone.
+type SMSRecord struct {
+ ID string `json:"id" gorm:"primaryKey;size:36"`
+ ChannelID string `json:"channelId" gorm:"size:36;index"`
+ ChannelName string `json:"channelName" gorm:"size:80"`
+ Provider string `json:"provider" gorm:"size:32"`
+ Purpose string `json:"purpose" gorm:"size:24;index"`
+ MaskedPhone string `json:"maskedPhone" gorm:"size:32"`
+ TargetHash string `json:"-" gorm:"size:64;index"`
+ State string `json:"state" gorm:"size:24;index"`
+ RequestID string `json:"requestId" gorm:"size:160"`
+ MessageID string `json:"messageId" gorm:"size:160"`
+ ErrorCode string `json:"errorCode" gorm:"size:160"`
+ DurationMS int64 `json:"durationMs"`
+ CreatedAt time.Time `json:"createdAt" gorm:"index"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+// Verification tickets are immutable, short-lived and purpose/target bound.
+// Only code HMACs and the hash of the high-entropy client ticket are persisted.
+type AuthVerification struct {
+ ID string `gorm:"primaryKey;size:36"`
+ TokenHash string `gorm:"size:64"`
+ Purpose string `gorm:"size:24"`
+ Method string `gorm:"size:24"`
+ UserID string `gorm:"size:36"`
+ Email string `gorm:"size:160"`
+ Phone string `gorm:"size:24"`
+ EmailHash string `gorm:"size:64"`
+ PhoneHash string `gorm:"size:64"`
+ PolicyHash string `gorm:"size:64"`
+ Ready bool
+ Attempts int
+ ExpiresAt time.Time `gorm:"index"`
+ UsedAt *time.Time
+ CreatedAt time.Time
+}
+
+// Fixed window quotas and per-recipient cooldowns are shared by all instances.
+type NotificationQuota struct {
+ Key string `gorm:"primaryKey;size:180"`
+ Count int
+ NextAt time.Time
+ ExpiresAt time.Time `gorm:"index"`
+}
diff --git a/backend/internal/protocol/manifest.go b/backend/internal/protocol/manifest.go
index 8de42a031..5f3d30d3c 100644
--- a/backend/internal/protocol/manifest.go
+++ b/backend/internal/protocol/manifest.go
@@ -243,6 +243,13 @@ func loadDeclarativeManifestProvider(manifest Manifest, index int) (Adapter, err
}
func ValidateManifest(manifest Manifest) error {
+ seenSMS := map[string]bool{}
+ for _, provider := range manifest.Contributes.SMSProviders {
+ if !validManifestIdentifier(provider.ID) || strings.TrimSpace(provider.Label) == "" || seenSMS[provider.ID] {
+ return fmt.Errorf("invalid or duplicate SMS provider contribution")
+ }
+ seenSMS[provider.ID] = true
+ }
if version := strings.TrimSpace(manifest.APIVersion); version != "yingce.plugin/v1" && version != "yingce.plugin/v2" {
return fmt.Errorf("unsupported protocol manifest apiVersion %q", manifest.APIVersion)
}
@@ -402,6 +409,9 @@ func normalizeManifestForProvider(manifest *Manifest, index int) error {
}
func hasNonProviderContribution(contributes ManifestContributions) bool {
+ if len(contributes.SMSProviders) > 0 {
+ return true
+ }
return len(contributes.PaymentProviders) > 0 || len(contributes.Workflows) > 0 || len(contributes.CanvasNodes) > 0 || len(contributes.Transforms) > 0 || len(contributes.Commands) > 0 || len(contributes.AssetSources) > 0 || len(contributes.UsageObservers) > 0 || len(contributes.AICapabilities) > 0 || len(contributes.Agents) > 0 || len(contributes.ImportExport) > 0
}
diff --git a/backend/internal/protocol/types.go b/backend/internal/protocol/types.go
index 1d2680140..42395f2b9 100644
--- a/backend/internal/protocol/types.go
+++ b/backend/internal/protocol/types.go
@@ -287,6 +287,7 @@ type ManifestField struct {
}
type ManifestContributions struct {
+ SMSProviders []ManifestSMSProvider `json:"smsProviders,omitempty"`
Providers []ManifestProvider `json:"providers,omitempty"`
PaymentProviders []ManifestPaymentProvider `json:"paymentProviders,omitempty"`
Workflows []ManifestWorkflow `json:"workflows,omitempty"`
@@ -300,6 +301,11 @@ type ManifestContributions struct {
ImportExport []string `json:"importExport,omitempty"`
}
+type ManifestSMSProvider struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+}
+
type ManifestPaymentProvider struct {
ID string `json:"id"`
Label string `json:"label"`
diff --git a/backend/internal/repository/notifications.go b/backend/internal/repository/notifications.go
new file mode 100644
index 000000000..6b7fc181c
--- /dev/null
+++ b/backend/internal/repository/notifications.go
@@ -0,0 +1,191 @@
+package repository
+
+import (
+ "errors"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+ "infinite-canvas/backend/internal/model"
+ "time"
+)
+
+var ErrNotificationLimit = errors.New("notification limit reached")
+var ErrVerificationInvalid = errors.New("verification expired, changed or consumed")
+var ErrChannelConflict = errors.New("sms channel changed")
+
+func (r *Repository) AttemptLegacyEmailVerification(id string) error {
+ result := r.db.Model(&model.EmailVerificationCode{}).Where("id = ? AND attempts < 5 AND used_at IS NULL AND expires_at > ?", id, time.Now()).Update("attempts", gorm.Expr("attempts + 1"))
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected != 1 {
+ return ErrVerificationInvalid
+ }
+ return nil
+}
+
+type NotificationLimit struct {
+ Key string
+ Limit int
+ Cooldown time.Duration
+ ExpiresAt time.Time
+}
+
+func reserveNotificationLimits(tx *gorm.DB, limits []NotificationLimit, now time.Time) error {
+ for _, limit := range limits {
+ row := model.NotificationQuota{Key: limit.Key, NextAt: now, ExpiresAt: limit.ExpiresAt}
+ if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
+ return err
+ }
+ result := tx.Model(&model.NotificationQuota{}).Where("key = ? AND count < ? AND next_at <= ?", limit.Key, limit.Limit, now).
+ Updates(map[string]any{"count": gorm.Expr("count + 1"), "next_at": now.Add(limit.Cooldown)})
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected != 1 {
+ return ErrNotificationLimit
+ }
+ }
+ return nil
+}
+
+func (r *Repository) ReserveNotificationLimits(limits []NotificationLimit, now time.Time) error {
+ return r.db.Transaction(func(tx *gorm.DB) error { return reserveNotificationLimits(tx, limits, now) })
+}
+
+func (r *Repository) CreateAuthVerification(v *model.AuthVerification, limits []NotificationLimit) error {
+ return r.db.Transaction(func(tx *gorm.DB) error {
+ // These rows contain contact data; retain only the short-lived verification window.
+ if err := tx.Where("expires_at < ?", time.Now()).Delete(&model.AuthVerification{}).Error; err != nil {
+ return err
+ }
+ if err := tx.Where("expires_at < ?", time.Now()).Delete(&model.NotificationQuota{}).Error; err != nil {
+ return err
+ }
+ if err := reserveNotificationLimits(tx, limits, v.CreatedAt); err != nil {
+ return err
+ }
+ return tx.Create(v).Error
+ })
+}
+func (r *Repository) AuthVerification(id string) (*model.AuthVerification, error) {
+ var v model.AuthVerification
+ err := r.db.First(&v, "id = ?", id).Error
+ return &v, err
+}
+func (r *Repository) SetAuthVerificationReady(id string) error {
+ return r.db.Model(&model.AuthVerification{}).Where("id = ?", id).Update("ready", true).Error
+}
+func (r *Repository) AttemptAuthVerification(id string, now time.Time) error {
+ result := r.db.Model(&model.AuthVerification{}).Where("id = ? AND ready = ? AND used_at IS NULL AND expires_at > ? AND attempts < 5", id, true, now).Update("attempts", gorm.Expr("attempts + 1"))
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected != 1 {
+ return ErrVerificationInvalid
+ }
+ return nil
+}
+func consumeVerification(tx *gorm.DB, id string, now time.Time) error {
+ result := tx.Model(&model.AuthVerification{}).Where("id = ? AND ready = ? AND used_at IS NULL AND expires_at > ? AND attempts <= 5", id, true, now).Update("used_at", now)
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected != 1 {
+ return ErrVerificationInvalid
+ }
+ return nil
+}
+func (r *Repository) CreateUserWithVerification(user *model.User, id string) error {
+ return r.db.Transaction(func(tx *gorm.DB) error {
+ if err := consumeVerification(tx, id, time.Now()); err != nil {
+ return err
+ }
+ return tx.Create(user).Error
+ })
+}
+func (r *Repository) CompleteAuthVerification(v *model.AuthVerification, bind bool) (*model.User, error) {
+ var user model.User
+ err := r.db.Transaction(func(tx *gorm.DB) error {
+ now := time.Now()
+ if err := consumeVerification(tx, v.ID, now); err != nil {
+ return err
+ }
+ query := tx.Model(&model.User{}).Where("id = ? AND status = ?", v.UserID, model.UserStatusActive)
+ updates := map[string]any{"updated_at": now}
+ if bind {
+ if v.Email != "" {
+ updates["email"], updates["email_verified_at"] = v.Email, now
+ }
+ if v.Phone != "" {
+ updates["phone"], updates["phone_verified_at"] = v.Phone, now
+ }
+ } else {
+ if v.Email != "" {
+ query = query.Where("email = ? AND email_verified_at IS NOT NULL", v.Email)
+ }
+ if v.Phone != "" {
+ query = query.Where("phone = ? AND phone_verified_at IS NOT NULL", v.Phone)
+ }
+ updates["last_login_at"] = now
+ }
+ result := query.Updates(updates)
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected != 1 {
+ return ErrVerificationInvalid
+ }
+ return tx.First(&user, "id = ?", v.UserID).Error
+ })
+ return &user, err
+}
+func (r *Repository) UserByPhone(phone string) (*model.User, error) {
+ var u model.User
+ err := r.db.First(&u, "phone = ?", phone).Error
+ return &u, err
+}
+
+func (r *Repository) SMSChannels() ([]model.SMSChannel, error) {
+ var rows []model.SMSChannel
+ err := r.db.Order("priority ASC, id ASC").Find(&rows).Error
+ return rows, err
+}
+func (r *Repository) SMSChannel(id string) (*model.SMSChannel, error) {
+ var row model.SMSChannel
+ err := r.db.First(&row, "id = ?", id).Error
+ return &row, err
+}
+func (r *Repository) SaveSMSChannel(row *model.SMSChannel, create bool) error {
+ if create {
+ return r.db.Create(row).Error
+ }
+ version := row.Version
+ row.Version++
+ result := r.db.Model(&model.SMSChannel{}).Where("id = ? AND version = ?", row.ID, version).Select("Name", "Provider", "Enabled", "Priority", "DailyLimit", "SignName", "AppID", "Credentials", "TemplatesJSON", "Version", "UpdatedAt").Updates(row)
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected != 1 {
+ return ErrChannelConflict
+ }
+ return nil
+}
+func (r *Repository) DeleteSMSChannel(id string) error {
+ return r.db.Where("id = ?", id).Delete(&model.SMSChannel{}).Error
+}
+func (r *Repository) SMSRecords(channel, state string, page, size int) ([]model.SMSRecord, int64, error) {
+ q := r.db.Model(&model.SMSRecord{})
+ if channel != "" {
+ q = q.Where("channel_id = ?", channel)
+ }
+ if state != "" {
+ q = q.Where("state = ?", state)
+ }
+ var count int64
+ if err := q.Count(&count).Error; err != nil {
+ return nil, 0, err
+ }
+ var rows []model.SMSRecord
+ err := q.Order("created_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error
+ return rows, count, err
+}
diff --git a/backend/internal/service/aliases_notifications.go b/backend/internal/service/aliases_notifications.go
new file mode 100644
index 000000000..fe0d3f9df
--- /dev/null
+++ b/backend/internal/service/aliases_notifications.go
@@ -0,0 +1,8 @@
+package service
+
+import "infinite-canvas/backend/internal/app"
+
+type SMSChannelRequest = app.SMSChannelRequest
+type VerificationPolicy = app.VerificationPolicy
+type VerificationRequest = app.VerificationRequest
+type VerificationConfirm = app.VerificationConfirm
diff --git a/backend/internal/sms/service.go b/backend/internal/sms/service.go
new file mode 100644
index 000000000..9d2d1701d
--- /dev/null
+++ b/backend/internal/sms/service.go
@@ -0,0 +1,384 @@
+package sms
+
+import (
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ sender "github.com/casdoor/go-sms-sender"
+ "infinite-canvas/backend/internal/kernel"
+ "infinite-canvas/backend/internal/model"
+ "infinite-canvas/backend/internal/outbound"
+ "infinite-canvas/backend/internal/repository"
+)
+
+const Aliyun = "aliyun"
+const Tencent = "tencent"
+const PluginAliyun = "official-sms-aliyun"
+const PluginTencent = "official-sms-tencent"
+
+type Host interface {
+ RequireAdmin(*model.User) error
+ EncryptSecret(string) (string, error)
+ DecryptSecret(string) (string, error)
+ SettingsEncryptionKey() ([]byte, error)
+}
+type Template struct {
+ Purpose string `json:"purpose"`
+ TemplateID string `json:"templateId"`
+ // Aliyun: named keys; Tencent: ordered keys "0", "1", ... .
+ Parameters []Parameter `json:"parameters"`
+}
+type Parameter struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
+}
+type ChannelRequest struct {
+ Name string `json:"name"`
+ Provider string `json:"provider"`
+ Enabled bool `json:"enabled"`
+ Priority int `json:"priority"`
+ DailyLimit int `json:"dailyLimit"`
+ SignName string `json:"signName"`
+ AppID string `json:"appId"`
+ AccessID string `json:"accessId"`
+ AccessKey string `json:"accessKey"`
+ Templates []Template `json:"templates"`
+ Version int `json:"version"`
+}
+type ChannelView struct {
+ model.SMSChannel
+ Templates []Template `json:"templates"`
+ HasCredentials bool `json:"hasCredentials"`
+ PluginEnabled bool `json:"pluginEnabled"`
+}
+type RecordPage struct {
+ Items []model.SMSRecord `json:"items"`
+ Total int64 `json:"total"`
+}
+type credentials struct {
+ ID string `json:"id"`
+ Key string `json:"key"`
+}
+type SendFunc func(context.Context, model.SMSChannel, Template, string, string, string, string) (sender.SendResult, error)
+type Service struct {
+ repo *repository.Repository
+ host Host
+ pluginEnabled func(string) (bool, error)
+ send SendFunc
+}
+
+func New(repo *repository.Repository, host Host, pluginEnabled func(string) (bool, error)) *Service {
+ return &Service{repo: repo, host: host, pluginEnabled: pluginEnabled, send: sendAggregate}
+}
+func PluginID(provider string) string {
+ if provider == Aliyun {
+ return PluginAliyun
+ }
+ if provider == Tencent {
+ return PluginTencent
+ }
+ return ""
+}
+
+var phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`)
+var paramPattern = regexp.MustCompile(`^[a-zA-Z0-9_]{1,40}$`)
+var safeCode = regexp.MustCompile(`^[a-zA-Z0-9_.:-]{1,160}$`)
+
+func NormalizePhone(value string) (string, error) {
+ value = strings.TrimSpace(value)
+ value = strings.TrimPrefix(value, "+86")
+ if !phonePattern.MatchString(value) {
+ return "", kernel.BadAuthRequest("请输入中国大陆手机号(支持 +86)")
+ }
+ return "+86" + value, nil
+}
+func (s *Service) ready(provider string) (bool, error) {
+ if PluginID(provider) == "" || s.pluginEnabled == nil {
+ return false, nil
+ }
+ return s.pluginEnabled(PluginID(provider))
+}
+func templates(row model.SMSChannel) ([]Template, error) {
+ var result []Template
+ err := json.Unmarshal([]byte(row.TemplatesJSON), &result)
+ return result, err
+}
+func (s *Service) Channels(actor *model.User) ([]ChannelView, error) {
+ if err := s.host.RequireAdmin(actor); err != nil {
+ return nil, err
+ }
+ rows, err := s.repo.SMSChannels()
+ if err != nil {
+ return nil, err
+ }
+ result := make([]ChannelView, 0, len(rows))
+ for _, row := range rows {
+ t, err := templates(row)
+ if err != nil {
+ return nil, err
+ }
+ enabled, err := s.ready(row.Provider)
+ if err != nil {
+ return nil, err
+ }
+ result = append(result, ChannelView{SMSChannel: row, Templates: t, HasCredentials: row.Credentials != "", PluginEnabled: enabled})
+ }
+ return result, nil
+}
+func (s *Service) SaveChannel(actor *model.User, id string, req ChannelRequest) (*ChannelView, error) {
+ if err := s.host.RequireAdmin(actor); err != nil {
+ return nil, err
+ }
+ req.Name, req.SignName, req.AppID = strings.TrimSpace(req.Name), strings.TrimSpace(req.SignName), strings.TrimSpace(req.AppID)
+ if PluginID(req.Provider) == "" || req.Name == "" || len(req.Name) > 240 || req.SignName == "" || len(req.SignName) > 240 || len(req.AppID) > 80 || req.Priority < 0 || req.Priority > 1000 || req.DailyLimit < 1 || req.DailyLimit > 100000 {
+ return nil, kernel.BadAuthRequest("请填写有效的渠道名称、供应商、签名、优先级(0–1000)和日限额(1–100000)")
+ }
+ if req.Provider == Tencent && req.AppID == "" {
+ return nil, kernel.BadAuthRequest("腾讯云需要短信 SDK AppID")
+ }
+ if err := validateTemplates(req.Provider, req.Templates); err != nil {
+ return nil, err
+ }
+ row := &model.SMSChannel{ID: kernel.NewID(), CreatedAt: time.Now(), Version: 1}
+ if id != "" {
+ var err error
+ row, err = s.repo.SMSChannel(id)
+ if err != nil {
+ return nil, kernel.NotFound("短信渠道不存在")
+ }
+ if row.Version != req.Version {
+ return nil, kernel.NewAppError(409, "渠道已被修改,请刷新后重试")
+ }
+ if row.Provider != req.Provider {
+ return nil, kernel.BadAuthRequest("渠道供应商不能修改,请新建渠道")
+ }
+ }
+ if (req.AccessID == "") != (req.AccessKey == "") {
+ return nil, kernel.BadAuthRequest("更换凭据时需同时填写 ID 和密钥")
+ }
+ if req.AccessID != "" {
+ if len(req.AccessID) > 512 || len(req.AccessKey) > 512 {
+ return nil, kernel.BadAuthRequest("凭据长度无效")
+ }
+ encoded, _ := json.Marshal(credentials{ID: req.AccessID, Key: req.AccessKey})
+ var err error
+ row.Credentials, err = s.host.EncryptSecret(string(encoded))
+ if err != nil {
+ return nil, err
+ }
+ }
+ if row.Credentials == "" {
+ return nil, kernel.BadAuthRequest("请填写短信凭据")
+ }
+ enabled, err := s.ready(req.Provider)
+ if err != nil {
+ return nil, err
+ }
+ if req.Enabled && !enabled {
+ return nil, kernel.BadAuthRequest("请先在插件管理启用对应短信插件")
+ }
+ encoded, _ := json.Marshal(req.Templates)
+ row.Name, row.Provider, row.Enabled, row.Priority, row.DailyLimit = req.Name, req.Provider, req.Enabled, req.Priority, req.DailyLimit
+ row.SignName, row.AppID, row.TemplatesJSON, row.UpdatedAt = req.SignName, req.AppID, string(encoded), time.Now()
+ if err := s.repo.SaveSMSChannel(row, id == ""); err != nil {
+ if errors.Is(err, repository.ErrChannelConflict) {
+ return nil, kernel.NewAppError(409, "渠道已被修改,请刷新后重试")
+ }
+ return nil, err
+ }
+ return &ChannelView{SMSChannel: *row, Templates: req.Templates, HasCredentials: true, PluginEnabled: enabled}, nil
+}
+func validateTemplates(provider string, values []Template) error {
+ if len(values) == 0 || len(values) > 3 {
+ return kernel.BadAuthRequest("请配置 1–3 个场景模板")
+ }
+ seen := map[string]bool{}
+ for _, t := range values {
+ if (t.Purpose != "login" && t.Purpose != "register" && t.Purpose != "bind") || seen[t.Purpose] || strings.TrimSpace(t.TemplateID) == "" || len(t.TemplateID) > 100 {
+ return kernel.BadAuthRequest("模板场景或模板 ID 无效")
+ }
+ seen[t.Purpose] = true
+ keys, hasCode := map[string]bool{}, false
+ if len(t.Parameters) == 0 || len(t.Parameters) > 8 {
+ return kernel.BadAuthRequest("请配置模板参数")
+ }
+ for i, p := range t.Parameters {
+ if !paramPattern.MatchString(p.Name) || keys[p.Name] || (p.Value != "code" && p.Value != "minutes") || (provider == Tencent && p.Name != strconv.Itoa(i)) {
+ return kernel.BadAuthRequest("参数值仅支持 code、minutes;腾讯云参数名必须按 0、1 顺序填写")
+ }
+ keys[p.Name] = true
+ hasCode = hasCode || p.Value == "code"
+ }
+ if !hasCode {
+ return kernel.BadAuthRequest("模板必须包含 code 参数")
+ }
+ }
+ return nil
+}
+func (s *Service) DeleteChannel(actor *model.User, id string) error {
+ if err := s.host.RequireAdmin(actor); err != nil {
+ return err
+ }
+ row, err := s.repo.SMSChannel(id)
+ if err != nil {
+ return kernel.NotFound("短信渠道不存在")
+ }
+ if row.Enabled {
+ return kernel.BadAuthRequest("请先停用渠道再删除;发送记录将保留")
+ }
+ return s.repo.DeleteSMSChannel(id)
+}
+func (s *Service) selectChannel(purpose, id string) (*model.SMSChannel, *Template, error) {
+ rows, err := s.repo.SMSChannels()
+ if err != nil {
+ return nil, nil, err
+ }
+ for _, row := range rows {
+ if !row.Enabled || (id != "" && row.ID != id) {
+ continue
+ }
+ enabled, err := s.ready(row.Provider)
+ if err != nil {
+ return nil, nil, err
+ }
+ if !enabled {
+ continue
+ }
+ items, err := templates(row)
+ if err != nil {
+ return nil, nil, err
+ }
+ for _, t := range items {
+ if t.Purpose == purpose {
+ return &row, &t, nil
+ }
+ }
+ }
+ return nil, nil, kernel.NewAppError(503, "未配置可用的短信场景渠道")
+}
+func (s *Service) Available(purpose string) (bool, error) {
+ _, _, err := s.selectChannel(purpose, "")
+ var appErr *kernel.AppError
+ if errors.As(err, &appErr) && appErr.Status == 503 {
+ return false, nil
+ }
+ return err == nil, err
+}
+func (s *Service) SendCode(ctx context.Context, purpose, phone, code string) error {
+ _, err := s.sendCode(ctx, purpose, phone, code, "", false)
+ return err
+}
+func (s *Service) sendCode(ctx context.Context, purpose, phone, code, channelID string, test bool) (*model.SMSRecord, error) {
+ phone, err := NormalizePhone(phone)
+ if err != nil {
+ return nil, err
+ }
+ row, t, err := s.selectChannel(purpose, channelID)
+ if err != nil {
+ return nil, err
+ }
+ raw, err := s.host.DecryptSecret(row.Credentials)
+ if err != nil {
+ return nil, err
+ }
+ var creds credentials
+ if json.Unmarshal([]byte(raw), &creds) != nil || creds.ID == "" || creds.Key == "" {
+ return nil, kernel.NewAppError(503, "短信凭据无效")
+ }
+ key, err := s.host.SettingsEncryptionKey()
+ if err != nil {
+ return nil, err
+ }
+ if len(key) < 16 {
+ return nil, kernel.NewAppError(503, "短信加密密钥不可用")
+ }
+ hash := hmac.New(sha256.New, key)
+ hash.Write([]byte(phone))
+ target := hex.EncodeToString(hash.Sum(nil))
+ now := time.Now()
+ day := now.UTC().Format("2006-01-02")
+ if err := s.repo.ReserveNotificationLimits([]repository.NotificationLimit{{Key: "sms-channel:" + row.ID + ":" + day, Limit: row.DailyLimit, ExpiresAt: now.Add(48 * time.Hour)}}, now); err != nil {
+ if errors.Is(err, repository.ErrNotificationLimit) {
+ return nil, kernel.RateLimited("短信渠道当日额度已用尽")
+ }
+ return nil, err
+ }
+ // Persist unknown before the network call: a crash can never be called success.
+ record := &model.SMSRecord{ID: kernel.NewID(), ChannelID: row.ID, ChannelName: row.Name, Provider: row.Provider, Purpose: purpose, MaskedPhone: phone[:6] + "****" + phone[len(phone)-4:], TargetHash: target, State: "unknown", ErrorCode: "acceptance_unknown", CreatedAt: now, UpdatedAt: now}
+ if test {
+ record.Purpose = "test:" + purpose
+ }
+ if err := s.repo.Create(record); err != nil {
+ return nil, err
+ }
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+ result, sendErr := s.send(ctx, *row, *t, creds.ID, creds.Key, phone, code)
+ record.State, record.RequestID, record.MessageID, record.ErrorCode = result.State, sanitize(result.RequestID), sanitize(result.MessageID), sanitize(result.Code)
+ if record.State != "accepted" && record.State != "rejected" {
+ record.State = "unknown"
+ }
+ record.DurationMS, record.UpdatedAt = time.Since(now).Milliseconds(), time.Now()
+ if err := s.repo.Save(record); err != nil {
+ return nil, kernel.NewAppError(503, "短信发送结果未能保存,请勿立即重复发送")
+ }
+ if sendErr != nil || record.State != "accepted" {
+ if record.State == "unknown" {
+ return record, kernel.NewAppError(503, "短信受理结果未知,请稍后再试;系统不会自动重发")
+ }
+ return record, kernel.NewAppError(503, "短信服务商拒绝发送,请联系管理员检查发送记录")
+ }
+ return record, nil
+}
+func sanitize(value string) string {
+ if safeCode.MatchString(value) {
+ return value
+ }
+ return ""
+}
+func sendAggregate(ctx context.Context, channel model.SMSChannel, t Template, id, key, phone, code string) (sender.SendResult, error) {
+ provider := sender.Aliyun
+ if channel.Provider == Tencent {
+ provider = sender.TencentCloud
+ }
+ client, err := sender.NewSmsClient(provider, id, key, channel.SignName, t.TemplateID, channel.AppID)
+ if err != nil {
+ return sender.SendResult{State: "rejected", Code: "invalid_configuration"}, errors.New("sms configuration invalid")
+ }
+ params := map[string]string{}
+ for _, p := range t.Parameters {
+ if p.Value == "code" {
+ params[p.Name] = code
+ } else {
+ params[p.Name] = "10"
+ }
+ }
+ if channel.Provider == Aliyun {
+ phone = strings.TrimPrefix(phone, "+86")
+ }
+ return sender.SendMessageContext(ctx, client, outbound.OutboundHTTPClient(10*time.Second).Transport, params, phone)
+}
+func (s *Service) Records(actor *model.User, channel, state string, page, size int) (*RecordPage, error) {
+ if err := s.host.RequireAdmin(actor); err != nil {
+ return nil, err
+ }
+ if page < 1 || page > 100000 || size < 1 || size > 100 || (state != "" && state != "accepted" && state != "rejected" && state != "unknown") {
+ return nil, kernel.BadAuthRequest("分页或状态无效")
+ }
+ rows, count, err := s.repo.SMSRecords(channel, state, page, size)
+ return &RecordPage{Items: rows, Total: count}, err
+}
+func (s *Service) Test(actor *model.User, ctx context.Context, id, purpose, phone, code string) (*model.SMSRecord, error) {
+ if err := s.host.RequireAdmin(actor); err != nil {
+ return nil, err
+ }
+ return s.sendCode(ctx, purpose, phone, code, id, true)
+}
diff --git a/backend/third_party/go-sms-sender/CANVAS-PATCH.md b/backend/third_party/go-sms-sender/CANVAS-PATCH.md
new file mode 100644
index 000000000..5a17dd85c
--- /dev/null
+++ b/backend/third_party/go-sms-sender/CANVAS-PATCH.md
@@ -0,0 +1,15 @@
+# Local transport extension
+
+Upstream: `github.com/casdoor/go-sms-sender`, tag `v0.25.0`, commit
+`af78ac0e42f4` (2024-11-29), Apache-2.0. The upstream Go source is retained;
+this module is pinned using the parent module's local `replace` directive.
+
+Canvas additions: `safe_http.go` exposes a context-bound, single-recipient,
+single-attempt send with structured acceptance identifiers. It uses the upstream
+SDKs for signing and payload construction, injects the host's outbound transport,
+and strictly checks the raw provider response. Unknown results are never retried.
+`tencent.go` additionally rejects missing response/status objects before dereferencing.
+
+Only Aliyun and Tencent have the safe transport capability. Other upstream
+providers are not exposed by Canvas. No delivery-receipt capability is claimed.
+Before upgrading, run the host's SMS transport contract tests.
diff --git a/backend/third_party/go-sms-sender/LICENSE b/backend/third_party/go-sms-sender/LICENSE
new file mode 100644
index 000000000..261eeb9e9
--- /dev/null
+++ b/backend/third_party/go-sms-sender/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/backend/third_party/go-sms-sender/aliyun.go b/backend/third_party/go-sms-sender/aliyun.go
new file mode 100644
index 000000000..ccd399cd3
--- /dev/null
+++ b/backend/third_party/go-sms-sender/aliyun.go
@@ -0,0 +1,87 @@
+// Copyright 2021 The Casdoor Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package go_sms_sender
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/aliyun/alibaba-cloud-sdk-go/services/dysmsapi"
+)
+
+type AliyunClient struct {
+ template string
+ sign string
+ core *dysmsapi.Client
+}
+
+type AliyunResult struct {
+ RequestId string
+ Message string
+}
+
+func GetAliyunClient(accessId string, accessKey string, sign string, template string) (*AliyunClient, error) {
+ region := "cn-hangzhou"
+ client, err := dysmsapi.NewClientWithAccessKey(region, accessId, accessKey)
+ if err != nil {
+ return nil, err
+ }
+
+ aliyunClient := &AliyunClient{
+ template: template,
+ core: client,
+ sign: sign,
+ }
+
+ return aliyunClient, nil
+}
+
+func (c *AliyunClient) SendMessage(param map[string]string, targetPhoneNumber ...string) error {
+ requestParam, err := json.Marshal(param)
+ if err != nil {
+ return err
+ }
+
+ if len(targetPhoneNumber) == 0 {
+ return fmt.Errorf("missing parameter: targetPhoneNumber")
+ }
+
+ request := dysmsapi.CreateSendSmsRequest()
+ request.Scheme = "https"
+ request.PhoneNumbers = strings.Join(targetPhoneNumber, ",")
+ request.TemplateCode = c.template
+ request.TemplateParam = string(requestParam)
+ request.SignName = c.sign
+
+ response, err := c.core.SendSms(request)
+ if err != nil {
+ return err
+ }
+
+ if response.Code != "OK" {
+ aliyunResult := AliyunResult{}
+ err = json.Unmarshal(response.GetHttpContentBytes(), &aliyunResult)
+ if err != nil {
+ return err
+ }
+
+ if aliyunResult.Message != "" {
+ return fmt.Errorf(aliyunResult.Message)
+ }
+ }
+
+ return nil
+}
diff --git a/backend/third_party/go-sms-sender/aws.go b/backend/third_party/go-sms-sender/aws.go
new file mode 100644
index 000000000..05c56e01c
--- /dev/null
+++ b/backend/third_party/go-sms-sender/aws.go
@@ -0,0 +1,87 @@
+// Copyright 2023 The Casdoor Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package go_sms_sender
+
+import (
+ "fmt"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/aws/credentials"
+ "github.com/aws/aws-sdk-go/aws/session"
+ "github.com/aws/aws-sdk-go/service/sns"
+ "github.com/aws/aws-sdk-go/service/sns/snsiface"
+)
+
+type AmazonSNSClient struct {
+ svc snsiface.SNSAPI
+ template string
+}
+
+func GetAmazonSNSClient(accessKeyID string, secretAccessKey string, template string, region []string) (*AmazonSNSClient, error) {
+ if len(region) == 0 {
+ return nil, fmt.Errorf("missing parameter: region")
+ }
+
+ sess, err := session.NewSession(&aws.Config{
+ Region: aws.String(region[0]),
+ Credentials: credentials.NewStaticCredentials(accessKeyID, secretAccessKey, ""),
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ svc := sns.New(sess)
+
+ snsClient := &AmazonSNSClient{
+ svc: svc,
+ template: template,
+ }
+
+ return snsClient, nil
+}
+
+func (a *AmazonSNSClient) SendMessage(param map[string]string, targetPhoneNumber ...string) error {
+ code, ok := param["code"]
+ if !ok {
+ return fmt.Errorf("missing parameter: code")
+ }
+
+ bodyContent := fmt.Sprintf(a.template, code)
+
+ if len(targetPhoneNumber) == 0 {
+ return fmt.Errorf("missing parameter: targetPhoneNumber")
+ }
+
+ messageAttributes := make(map[string]*sns.MessageAttributeValue)
+ for k, v := range param {
+ messageAttributes[k] = &sns.MessageAttributeValue{
+ DataType: aws.String("String"),
+ StringValue: aws.String(v),
+ }
+ }
+
+ for i := 0; i < len(targetPhoneNumber); i++ {
+ _, err := a.svc.Publish(&sns.PublishInput{
+ Message: &bodyContent,
+ PhoneNumber: &targetPhoneNumber[i],
+ MessageAttributes: messageAttributes,
+ })
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/backend/third_party/go-sms-sender/azure.go b/backend/third_party/go-sms-sender/azure.go
new file mode 100644
index 000000000..2f28a1728
--- /dev/null
+++ b/backend/third_party/go-sms-sender/azure.go
@@ -0,0 +1,95 @@
+// Copyright 2023 The Casdoor Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package go_sms_sender
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+)
+
+type ACSClient struct {
+ AccessToken string
+ Endpoint string
+ Message string
+ Sender string
+}
+
+type reqBody struct {
+ From string `json:"from"`
+ Message string `json:"message"`
+ SMSRecipients []smsRecipient `json:"smsRecipients"`
+}
+
+type smsRecipient struct {
+ To string `json:"to"`
+}
+
+func GetACSClient(accessToken string, message string, other []string) (*ACSClient, error) {
+ if len(other) < 2 {
+ return nil, fmt.Errorf("missing parameter: endpoint or sender")
+ }
+
+ acsClient := &ACSClient{
+ AccessToken: accessToken,
+ Endpoint: other[0],
+ Message: message,
+ Sender: other[1],
+ }
+
+ return acsClient, nil
+}
+
+func (a *ACSClient) SendMessage(param map[string]string, targetPhoneNumber ...string) error {
+ if len(targetPhoneNumber) == 0 {
+ return fmt.Errorf("missing parameter: targetPhoneNumber")
+ }
+
+ reqBody := &reqBody{
+ From: a.Sender,
+ Message: a.Message,
+ SMSRecipients: make([]smsRecipient, 0),
+ }
+ for _, mobile := range targetPhoneNumber {
+ reqBody.SMSRecipients = append(reqBody.SMSRecipients, smsRecipient{To: mobile})
+ }
+
+ url := fmt.Sprintf("%s/sms?api-version=2021-03-07", a.Endpoint)
+
+ client := &http.Client{}
+
+ requestBody, err := json.Marshal(reqBody)
+ if err != nil {
+ return fmt.Errorf("error creating request body: %w", err)
+ }
+
+ req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody))
+ if err != nil {
+ return fmt.Errorf("error creating request: %w", err)
+ }
+
+ req.Header.Add("Authorization", "Bearer "+a.AccessToken)
+ req.Header.Add("Content-Type", "application/json")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("error sending request: %w", err)
+ }
+
+ resp.Body.Close()
+
+ return nil
+}
diff --git a/backend/third_party/go-sms-sender/baidubce.go b/backend/third_party/go-sms-sender/baidubce.go
new file mode 100644
index 000000000..66df98991
--- /dev/null
+++ b/backend/third_party/go-sms-sender/baidubce.go
@@ -0,0 +1,76 @@
+// Copyright 2023 The Casdoor Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package go_sms_sender
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/baidubce/bce-sdk-go/services/sms"
+ "github.com/baidubce/bce-sdk-go/services/sms/api"
+)
+
+type BaiduClient struct {
+ sign string
+ template string
+ core *sms.Client
+}
+
+func GetBceClient(accessId, accessKey, sign, template string, endpoint []string) (*BaiduClient, error) {
+ if len(endpoint) == 0 {
+ return nil, fmt.Errorf("missing parameter: endpoint")
+ }
+
+ client, err := sms.NewClient(accessId, accessKey, endpoint[0])
+ if err != nil {
+ return nil, err
+ }
+
+ bceClient := &BaiduClient{
+ sign: sign,
+ template: template,
+ core: client,
+ }
+
+ return bceClient, nil
+}
+
+func (c *BaiduClient) SendMessage(param map[string]string, targetPhoneNumber ...string) error {
+ code, ok := param["code"]
+ if !ok {
+ return fmt.Errorf("missing parameter: code")
+ }
+
+ if len(targetPhoneNumber) == 0 {
+ return fmt.Errorf("missing parameter: targetPhoneNumber")
+ }
+
+ contentMap := make(map[string]interface{})
+ contentMap["code"] = code
+
+ sendSmsArgs := &api.SendSmsArgs{
+ Mobile: strings.Join(targetPhoneNumber, ","),
+ SignatureId: c.sign,
+ Template: c.template,
+ ContentVar: contentMap,
+ }
+
+ _, err := c.core.SendSms(sendSmsArgs)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/backend/third_party/go-sms-sender/basic.go b/backend/third_party/go-sms-sender/basic.go
new file mode 100644
index 000000000..f077af9c2
--- /dev/null
+++ b/backend/third_party/go-sms-sender/basic.go
@@ -0,0 +1,88 @@
+// Copyright 2021 The Casdoor Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package go_sms_sender
+
+import "fmt"
+
+const (
+ Twilio = "Twilio SMS"
+ AmazonSNS = "Amazon SNS"
+ AzureACS = "Azure ACS"
+ Msg91 = "Msg91 SMS"
+ GCCPAY = "GCCPAY SMS"
+ Infobip = "Infobip SMS"
+ SUBMAIL = "SUBMAIL SMS"
+ SmsBao = "SmsBao SMS"
+ Aliyun = "Aliyun SMS"
+ TencentCloud = "Tencent Cloud SMS"
+ BaiduCloud = "Baidu Cloud SMS"
+ VolcEngine = "Volc Engine SMS"
+ HuaweiCloud = "Huawei Cloud SMS"
+ UCloud = "UCloud SMS"
+ Huyi = "Huyi SMS"
+ MockSms = "Mock SMS"
+ Netgsm = "Netgsm SMS"
+ OsonSms = "OSON SMS"
+ UniSms = "Uni SMS"
+)
+
+type SmsClient interface {
+ SendMessage(param map[string]string, targetPhoneNumber ...string) error
+}
+
+func NewSmsClient(provider string, accessId string, accessKey string, sign string, template string, other ...string) (SmsClient, error) {
+ switch provider {
+ case Twilio:
+ return GetTwilioClient(accessId, accessKey, template)
+ case AmazonSNS:
+ return GetAmazonSNSClient(accessId, accessKey, template, other)
+ case AzureACS:
+ return GetACSClient(accessKey, template, other)
+ case Msg91:
+ return GetMsg91Client(accessId, accessKey, template)
+ case GCCPAY:
+ return GetGCCPAYClient(accessId, accessKey, template)
+ case Infobip:
+ return GetInfobipClient(accessId, accessKey, template, other)
+ case SUBMAIL:
+ return GetSubmailClient(accessId, accessKey, template)
+ case SmsBao:
+ return GetSmsbaoClient(accessId, accessKey, sign, template, other)
+ case Aliyun:
+ return GetAliyunClient(accessId, accessKey, sign, template)
+ case TencentCloud:
+ return GetTencentClient(accessId, accessKey, sign, template, other)
+ case BaiduCloud:
+ return GetBceClient(accessId, accessKey, sign, template, other)
+ case VolcEngine:
+ return GetVolcClient(accessId, accessKey, sign, template, other)
+ case HuaweiCloud:
+ return GetHuaweiClient(accessId, accessKey, sign, template, other)
+ case UCloud:
+ return GetUcloudClient(accessId, accessKey, sign, template, other)
+ case Huyi:
+ return GetHuyiClient(accessId, accessKey, template)
+ case Netgsm:
+ return GetNetgsmClient(accessId, accessKey, sign, template)
+ case MockSms:
+ return NewMocker(accessId, accessKey, sign, template, other)
+ case OsonSms:
+ return GetOsonClient(accessId, accessKey, sign, template)
+ case UniSms:
+ return GetUnismsClient(accessId, accessKey, sign, template)
+ default:
+ return nil, fmt.Errorf("unsupported provider: %s", provider)
+ }
+}
diff --git a/backend/third_party/go-sms-sender/gccpay.go b/backend/third_party/go-sms-sender/gccpay.go
new file mode 100644
index 000000000..69c203147
--- /dev/null
+++ b/backend/third_party/go-sms-sender/gccpay.go
@@ -0,0 +1,127 @@
+// Copyright 2023 The Casdoor Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package go_sms_sender
+
+import (
+ "bytes"
+ "crypto/md5"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+)
+
+type GCCPAYClient struct {
+ clientname string
+ secret string
+ template string
+}
+
+type params struct {
+ Mobile string `json:"mobile"`
+ TemplateCode string `json:"template_code"`
+ TemplateParams map[string]string `json:"template_params"`
+}
+
+func GetGCCPAYClient(clientname string, secret string, template string) (*GCCPAYClient, error) {
+ gccPayClient := &GCCPAYClient{
+ clientname: clientname,
+ secret: secret,
+ template: template,
+ }
+
+ return gccPayClient, nil
+}
+
+func RandStringBytesCrypto(n int) (string, error) {
+ b := make([]byte, n)
+ _, err := rand.Read(b)
+ if err != nil {
+ return "", err
+ }
+ return base64.StdEncoding.EncodeToString(b), nil
+}
+
+func Md5(str string) string {
+ h := md5.New()
+ h.Write([]byte(str))
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+func (c *GCCPAYClient) SendMessage(param map[string]string, targetPhoneNumber ...string) error {
+ _, ok := param["code"]
+ if !ok {
+ return fmt.Errorf("missing parameter: code")
+ }
+
+ if len(targetPhoneNumber) == 0 {
+ return fmt.Errorf("missing parameter: targetPhoneNumber")
+ }
+
+ reqParams := make(map[string]params)
+
+ for _, mobile := range targetPhoneNumber {
+ if strings.HasPrefix(mobile, "+") {
+ mobile = mobile[1:]
+ }
+ randomString, err := RandStringBytesCrypto(16)
+ if err != nil {
+ return fmt.Errorf("SMS key generation failed")
+ }
+
+ reqParams[randomString] = params{
+ Mobile: mobile,
+ TemplateCode: c.template,
+ TemplateParams: param,
+ }
+ }
+
+ requestBody := new(bytes.Buffer)
+ err := json.NewEncoder(requestBody).Encode(reqParams)
+ if err != nil {
+ return fmt.Errorf("SMS sending failed")
+ }
+
+ // sign
+ timestamp := time.Now().Unix()
+
+ sign := Md5(fmt.Sprintf("%s%d%s", c.clientname, timestamp, c.secret))
+
+ reqUrl := "https://smscenter.sgate.sa/api/v1/client/sendSms"
+
+ // send request
+ req, _ := http.NewRequest("POST", reqUrl, requestBody)
+ req.Header.Set("clientname", c.clientname)
+ req.Header.Set("timestamp", fmt.Sprintf("%d", timestamp))
+ req.Header.Set("sign", sign)
+ req.Header.Set("content-type", "application/json;")
+
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+
+ err = resp.Body.Close()
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/backend/third_party/go-sms-sender/go.mod b/backend/third_party/go-sms-sender/go.mod
new file mode 100644
index 000000000..f1f8e6373
--- /dev/null
+++ b/backend/third_party/go-sms-sender/go.mod
@@ -0,0 +1,33 @@
+module github.com/casdoor/go-sms-sender
+
+go 1.17
+
+require (
+ github.com/aliyun/alibaba-cloud-sdk-go v1.62.545
+ github.com/aws/aws-sdk-go v1.45.5
+ github.com/baidubce/bce-sdk-go v0.9.156
+ github.com/google/uuid v1.3.1
+ github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.744
+ github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms v1.0.744
+ github.com/twilio/twilio-go v1.13.0
+ github.com/ucloud/ucloud-sdk-go v0.22.5
+ github.com/volcengine/volc-sdk-golang v1.0.117
+)
+
+require (
+ github.com/apistd/uni-go-sdk v0.0.2 // indirect
+ github.com/cenkalti/backoff/v4 v4.1.2 // indirect
+ github.com/golang/mock v1.6.0 // indirect
+ github.com/google/go-querystring v1.1.0 // indirect
+ github.com/jmespath/go-jmespath v0.4.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/sirupsen/logrus v1.9.0 // indirect
+ golang.org/x/net v0.12.0 // indirect
+ golang.org/x/sys v0.10.0 // indirect
+ golang.org/x/text v0.11.0 // indirect
+ gopkg.in/ini.v1 v1.66.6 // indirect
+)
diff --git a/backend/third_party/go-sms-sender/huawei.go b/backend/third_party/go-sms-sender/huawei.go
new file mode 100644
index 000000000..ffb737887
--- /dev/null
+++ b/backend/third_party/go-sms-sender/huawei.go
@@ -0,0 +1,142 @@
+// Copyright 2021 The Casdoor Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package go_sms_sender
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "crypto/tls"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+const (
+ WSSE_HEADER_FORMAT = "UsernameToken Username=\"%s\",PasswordDigest=\"%s\",Nonce=\"%s\",Created=\"%s\""
+ AUTH_HEADER_VALUE = "WSSE realm=\"SDP\",profile=\"UsernameToken\",type=\"Appkey\""
+)
+
+type HuaweiClient struct {
+ accessId string
+ accessKey string
+ sign string
+ template string
+ apiAddress string
+ sender string
+}
+
+func GetHuaweiClient(accessId string, accessKey string, sign string, template string, other []string) (*HuaweiClient, error) {
+ if len(other) < 2 {
+ return nil, fmt.Errorf("missing parameter: apiAddress or sender")
+ }
+
+ apiAddress := fmt.Sprintf("%s/sms/batchSendSms/v1", other[0])
+
+ huaweiClient := &HuaweiClient{
+ accessId: accessId,
+ accessKey: accessKey,
+ sign: sign,
+ template: template,
+ apiAddress: apiAddress,
+ sender: other[1],
+ }
+
+ return huaweiClient, nil
+}
+
+// SendMessage https://support.huaweicloud.com/intl/en-us/devg-msgsms/sms_04_0012.html
+func (c *HuaweiClient) SendMessage(param map[string]string, targetPhoneNumber ...string) error {
+ code, ok := param["code"]
+ if !ok {
+ return fmt.Errorf("missing parameter: code")
+ }
+
+ if len(targetPhoneNumber) == 0 {
+ return fmt.Errorf("missing parameter: targetPhoneNumber")
+ }
+
+ phoneNumbers := strings.Join(targetPhoneNumber, ",")
+ templateParas := fmt.Sprintf("[\"%s\"]", code)
+
+ body := buildRequestBody(c.sender, phoneNumbers, c.template, templateParas, "", c.sign)
+ headers := make(map[string]string)
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
+ headers["Authorization"] = AUTH_HEADER_VALUE
+ headers["X-WSSE"] = buildWsseHeader(c.accessId, c.accessKey)
+
+ _, err := post(c.apiAddress, []byte(body), headers)
+ return err
+}
+
+func buildRequestBody(sender, receiver, templateId, templateParas, statusCallBack, signature string) string {
+ param := "from=" + url.QueryEscape(sender) + "&to=" + url.QueryEscape(receiver) + "&templateId=" + url.QueryEscape(templateId)
+ if templateParas != "" {
+ param += "&templateParas=" + url.QueryEscape(templateParas)
+ }
+ if statusCallBack != "" {
+ param += "&statusCallback=" + url.QueryEscape(statusCallBack)
+ }
+ if signature != "" {
+ param += "&signature=" + url.QueryEscape(signature)
+ }
+ return param
+}
+
+func buildWsseHeader(appKey, appSecret string) string {
+ cTime := time.Now().Format("2006-01-02T15:04:05Z")
+ nonce := uuid.New().String()
+ nonce = strings.ReplaceAll(nonce, "-", "")
+
+ h := sha256.New()
+ h.Write([]byte(nonce + cTime + appSecret))
+ passwordDigestBase64Str := base64.StdEncoding.EncodeToString(h.Sum(nil))
+
+ return fmt.Sprintf(WSSE_HEADER_FORMAT, appKey, passwordDigestBase64Str, nonce, cTime)
+}
+
+func post(url string, param []byte, headers map[string]string) (string, error) {
+ tr := &http.Transport{
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+ }
+ client := &http.Client{Transport: tr}
+
+ req, err := http.NewRequest("POST", url, bytes.NewBuffer(param))
+ if err != nil {
+ return "", err
+ }
+
+ for key, header := range headers {
+ req.Header.Set(key, header)
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", err
+ }
+
+ return string(body), nil
+}
diff --git a/backend/third_party/go-sms-sender/huyi.go b/backend/third_party/go-sms-sender/huyi.go
new file mode 100644
index 000000000..b9b0c42ae
--- /dev/null
+++ b/backend/third_party/go-sms-sender/huyi.go
@@ -0,0 +1,89 @@
+// Copyright 2021 The Casdoor Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package go_sms_sender
+
+import (
+ "crypto/md5"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+)
+
+type HuyiClient struct {
+ appId string
+ appKey string
+ template string
+}
+
+func GetHuyiClient(appId string, appKey string, template string) (*HuyiClient, error) {
+ return &HuyiClient{
+ appId: appId,
+ appKey: appKey,
+ template: template,
+ }, nil
+}
+
+func GetMd5String(s string) string {
+ h := md5.New()
+ h.Write([]byte(s))
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+func (hc *HuyiClient) SendMessage(param map[string]string, targetPhoneNumber ...string) error {
+ code, ok := param["code"]
+ if !ok {
+ return fmt.Errorf("missing parameter: code")
+ }
+
+ if len(targetPhoneNumber) == 0 {
+ return fmt.Errorf("missin parer: trgetPhoneNumber")
+ }
+
+ _now := strconv.FormatInt(time.Now().Unix(), 10)
+ smsContent := fmt.Sprintf(hc.template, code)
+ v := url.Values{}
+ v.Set("account", hc.appId)
+ v.Set("content", smsContent)
+ v.Set("time", _now)
+ passwordStr := hc.appId + hc.appKey + "%s" + smsContent + _now
+ for _, mobile := range targetPhoneNumber {
+ password := fmt.Sprintf(passwordStr, mobile)
+ v.Set("password", GetMd5String(password))
+ v.Set("mobile", mobile)
+
+ body := strings.NewReader(v.Encode()) // encode form data
+ client := &http.Client{}
+ req, _ := http.NewRequest("POST", "http://106.ihuyi.com/webservice/sms.php?method=Submit&format=json", body)
+
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value")
+
+ resp, err := client.Do(req) // request remote
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close() // ! close ReadCloser
+ _, err = io.ReadAll(resp.Body)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/backend/third_party/go-sms-sender/infobip.go b/backend/third_party/go-sms-sender/infobip.go
new file mode 100644
index 000000000..c528f34ef
--- /dev/null
+++ b/backend/third_party/go-sms-sender/infobip.go
@@ -0,0 +1,125 @@
+// Copyright 2023 The Casdoor Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package go_sms_sender
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+)
+
+type InfobipClient struct {
+ baseUrl string
+ sender string
+ apiKey string
+ template string
+}
+
+type InfobipConfigService struct {
+ baseUrl string
+ sender string
+ apiKey string
+}
+
+type SmsService struct {
+ configService InfobipConfigService
+}
+
+type MessageData struct {
+ Messages []Message `json:"messages"`
+}
+
+type Message struct {
+ From string `json:"from"`
+ Destinations []Destination `json:"destinations"`
+ Text string `json:"text"`
+}
+
+type Destination struct {
+ To string `json:"to"`
+}
+
+func GetInfobipClient(sender string, apiKey string, template string, baseUrl []string) (*InfobipClient, error) {
+ if len(baseUrl) == 0 {
+ return nil, fmt.Errorf("missing parameter: baseUrl")
+ }
+
+ infobipClient := &InfobipClient{
+ baseUrl: baseUrl[0],
+ sender: sender,
+ apiKey: apiKey,
+ template: template,
+ }
+
+ return infobipClient, nil
+}
+
+func (c *InfobipClient) SendMessage(param map[string]string, targetPhoneNumber ...string) error {
+ code, ok := param["code"]
+ if !ok {
+ return fmt.Errorf("missing parameter: code")
+ }
+
+ if len(targetPhoneNumber) == 0 {
+ return fmt.Errorf("missin parer: trgetPhoneNumber")
+ }
+
+ mobile := targetPhoneNumber[0]
+
+ if strings.HasPrefix(mobile, "0") {
+ mobile = "886" + mobile[1:]
+ }
+ if strings.HasPrefix(mobile, "+") {
+ mobile = mobile[1:]
+ }
+
+ endpoint := fmt.Sprintf("%s/sms/2/text/advanced", c.baseUrl)
+ text := fmt.Sprintf(c.template, code)
+
+ messageData := MessageData{
+ Messages: []Message{
+ {
+ From: c.sender,
+ Destinations: []Destination{
+ {
+ To: mobile,
+ },
+ },
+ Text: text,
+ },
+ },
+ }
+ headers := map[string]string{
+ "Authorization": fmt.Sprintf("App %s", c.apiKey),
+ "Content-Type": "application/json",
+ }
+
+ messageDataBytes, _ := json.Marshal(messageData)
+ req, _ := http.NewRequest("POST", endpoint, bytes.NewBuffer(messageDataBytes))
+ for key, value := range headers {
+ req.Header.Set(key, value)
+ }
+
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ return nil
+}
diff --git a/backend/third_party/go-sms-sender/mock.go b/backend/third_party/go-sms-sender/mock.go
new file mode 100644
index 000000000..56d96644a
--- /dev/null
+++ b/backend/third_party/go-sms-sender/mock.go
@@ -0,0 +1,27 @@
+// Copyright 2021 The Casdoor Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package go_sms_sender
+
+type Mocker struct{}
+
+var _ SmsClient = &Mocker{}
+
+func NewMocker(accessId, accessKey, sign, templateId string, smsAccount []string) (*Mocker, error) {
+ return &Mocker{}, nil
+}
+
+func (m *Mocker) SendMessage(param map[string]string, targetPhoneNumber ...string) error {
+ return nil
+}
diff --git a/backend/third_party/go-sms-sender/msg91.go b/backend/third_party/go-sms-sender/msg91.go
new file mode 100644
index 000000000..a34e3bbbf
--- /dev/null
+++ b/backend/third_party/go-sms-sender/msg91.go
@@ -0,0 +1,102 @@
+// Copyright 2023 The Casdoor Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package go_sms_sender
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+)
+
+type Msg91Client struct {
+ authKey string
+ senderId string
+ templateId string
+}
+
+func GetMsg91Client(senderId string, authKey string, templateId string) (*Msg91Client, error) {
+ msg91Client := &Msg91Client{
+ authKey: authKey,
+ senderId: senderId,
+ templateId: templateId,
+ }
+
+ return msg91Client, nil
+}
+
+func (m *Msg91Client) SendMessage(param map[string]string, targetPhoneNumber ...string) error {
+ if len(targetPhoneNumber) == 0 {
+ return fmt.Errorf("missing parameter: targetPhoneNumber")
+ }
+
+ url := "https://control.msg91.com/api/v5/flow/"
+
+ for _, mobile := range targetPhoneNumber {
+ if strings.HasPrefix(mobile, "+") {
+ mobile = mobile[1:]
+ }
+
+ payload, err := buildPayload(m.templateId, m.senderId, "0", mobile, param)
+ if err != nil {
+ return fmt.Errorf("SMS build payload failed: %v", err)
+ }
+
+ err = postMsg91SendRequest(url, strings.NewReader(payload), m.authKey)
+ if err != nil {
+ return fmt.Errorf("send message failed: %v", err)
+ }
+ }
+
+ return nil
+}
+
+func buildPayload(templateId, senderId, shortURL, mobiles string, variables map[string]string) (string, error) {
+ payload := make(map[string]interface{})
+
+ payload["template_id"] = templateId
+ payload["sender"] = senderId
+ payload["short_url"] = shortURL
+ payload["mobiles"] = mobiles
+
+ for k, v := range variables {
+ payload[k] = v
+ }
+
+ jsonData, err := json.Marshal(payload)
+ if err != nil {
+ return "", err
+ }
+
+ return string(jsonData), nil
+}
+
+func postMsg91SendRequest(url string, payload io.Reader, authKey string) error {
+ req, _ := http.NewRequest("POST", url, payload)
+
+ req.Header.Add("accept", "application/json")
+ req.Header.Add("content-type", "application/json")
+ req.Header.Add("authkey", authKey)
+
+ res, _ := http.DefaultClient.Do(req)
+
+ err := res.Body.Close()
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/backend/third_party/go-sms-sender/netgsm.go b/backend/third_party/go-sms-sender/netgsm.go
new file mode 100644
index 000000000..a40e53bce
--- /dev/null
+++ b/backend/third_party/go-sms-sender/netgsm.go
@@ -0,0 +1,114 @@
+// Copyright 2023 The Casdoor Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package go_sms_sender
+
+import (
+ "bytes"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+)
+
+type NetgsmClient struct {
+ accessId string
+ accessKey string
+ sign string
+ template string
+ httpClient *http.Client
+}
+
+type NetgsmResponse struct {
+ Code string `xml:"main>code"`
+ JobID string `xml:"main>jobID"`
+ Error string `xml:"main>error"`
+}
+
+func GetNetgsmClient(accessId, accessKey, sign, template string) (*NetgsmClient, error) {
+ return &NetgsmClient{
+ accessId: accessId,
+ accessKey: accessKey,
+ sign: sign,
+ template: template,
+ httpClient: &http.Client{},
+ }, nil
+}
+
+func (c *NetgsmClient) SendMessage(param map[string]string, targetPhoneNumber ...string) error {
+ if len(targetPhoneNumber) == 0 {
+ return fmt.Errorf("missing parameter: targetPhoneNumber")
+ }
+
+ for _, phoneNumber := range targetPhoneNumber {
+ data := fmt.Sprintf(`
+
两项验证必须在本次注册中同时通过;修改联系方式后需要重新获取。
} +