feat(auth): 登录注册 - 添加短信通知与组合验证支持

This commit is contained in:
ddCat
2026-09-22 23:44:23 +08:00
parent 6c013ebf7e
commit bc0d109428
55 changed files with 4013 additions and 103 deletions

View File

@@ -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"),

View File

@@ -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

View File

@@ -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=

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 }

View File

@@ -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
}

View File

@@ -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

View File

@@ -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{}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)) }

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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)
})
}

View File

@@ -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"`

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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"`

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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.

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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
)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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(`
<mainbody>
<header>
<usercode>%s</usercode>
<password>%s</password>
<msgheader>%s</msgheader>
</header>
<body>
<msg>
<![CDATA[%s]]>
</msg>
<no>%s</no>
</body>
</mainbody>`, c.accessId, c.accessKey, c.sign, c.template, phoneNumber)
headers := map[string]string{
"Content-Type": "application/xml",
}
respBody, err := c.postXML("https://api.netgsm.com.tr/sms/send/otp", data, headers)
if err != nil {
return err
}
var netgsmResponse NetgsmResponse
if err := xml.Unmarshal([]byte(respBody), &netgsmResponse); err != nil {
return err
}
if netgsmResponse.Code != "0" {
return errors.New(netgsmResponse.Error)
}
}
return nil
}
func (c *NetgsmClient) postXML(url, xmlData string, headers map[string]string) (string, error) {
req, err := http.NewRequest("POST", url, bytes.NewBuffer([]byte(xmlData)))
if err != nil {
return "", err
}
for key, value := range headers {
req.Header.Set(key, value)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(respBody), nil
}

View File

@@ -0,0 +1,120 @@
// 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 (
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/google/uuid"
)
type OsonClient struct {
Endpoint string
SenderID string
SecretAccessHash string
Sign string
Message string
}
type OsonResponse struct {
Status string // ok
Timestamp time.Time // 2017-07-07 16:58:12
TxnId string // f89xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxe0b
MsgId uint // 40127
SmscMsgId string // 45f22479
SmscMsgStatus string // success
SmscMsgParts string // 1
}
func GetOsonClient(senderId, secretAccessHash, sign, message string) (*OsonClient, error) {
return &OsonClient{
Endpoint: "https://api.osonsms.com/sendsms_v1.php",
SenderID: senderId,
SecretAccessHash: secretAccessHash,
Sign: sign,
Message: message,
}, nil
}
func (c *OsonClient) SendMessage(param map[string]string, targetPhoneNumber ...string) (err error) {
// Init http client for make request to sms center. Set a timeout of 25+
// seconds to ensure that the response from the SMS center has been
// processed.
client := &http.Client{
Timeout: 20 * time.Second,
}
if c.Message == "" {
c.Message = fmt.Sprintf("Hello. Your authorization code: %s", param["code"])
} else {
c.Message += param["code"]
}
txnID := uuid.New()
buildStrHash := strings.Join([]string{txnID.String(), c.SenderID, c.Sign, targetPhoneNumber[0], c.SecretAccessHash}, ";")
hash := sha256.New()
hash.Write([]byte(buildStrHash))
bs := hash.Sum(nil)
strHash := fmt.Sprintf("%x", bs)
urlLink, err := url.Parse(c.Endpoint)
if err != nil {
return
}
urlParams := url.Values{}
urlParams.Add("from", c.Sign)
urlParams.Add("phone_number", targetPhoneNumber[0])
urlParams.Add("msg", c.Message)
urlParams.Add("str_hash", strHash)
urlParams.Add("txn_id", txnID.String())
urlParams.Add("login", c.SenderID)
urlLink.RawQuery = urlParams.Encode()
request, err := http.NewRequest(http.MethodGet, urlLink.String(), nil)
if err != nil {
return
}
resp, err := client.Do(request)
if err != nil {
return
}
resultBytes, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
var result OsonResponse
if err = json.Unmarshal(resultBytes, &result); err != nil {
return
}
if result.Status != "ok" {
return fmt.Errorf("sms service returned error status not 200: Status Code: %d Error: %s", resp.StatusCode, string(resultBytes))
}
return
}

View File

@@ -0,0 +1,118 @@
package go_sms_sender
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"sync/atomic"
)
// SendResult describes provider acceptance, not handset delivery.
type SendResult struct {
State, RequestID, MessageID, Code string
}
var ErrSendUnknown = errors.New("sms acceptance unknown")
var ErrSendRejected = errors.New("sms rejected")
type singleSendTransport struct {
ctx context.Context
base http.RoundTripper
host string
attempted atomic.Bool
body []byte
status int
}
func (t *singleSendTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.URL.Scheme != "https" || req.URL.Host != t.host || t.attempted.Swap(true) {
return nil, ErrSendUnknown
}
response, err := t.base.RoundTrip(req.Clone(t.ctx))
if err != nil {
return nil, err
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return nil, err
}
t.body, t.status = body, response.StatusCode
response.Body = io.NopCloser(bytes.NewReader(body))
return response, nil
}
// SendMessageContext requires a fresh client per call. It deliberately disables
// implicit SDK retries and validates acceptance independently of SendMessage.
func SendMessageContext(ctx context.Context, client SmsClient, transport http.RoundTripper, params map[string]string, phone string) (SendResult, error) {
result := SendResult{State: "unknown", Code: "acceptance_unknown"}
if transport == nil || phone == "" {
return result, ErrSendUnknown
}
t := &singleSendTransport{ctx: ctx, base: transport}
switch c := client.(type) {
case *AliyunClient:
t.host = "dysmsapi.aliyuncs.com"
c.core.GetConfig().AutoRetry = false
c.core.SetTransport(t)
case *TencentClient:
t.host = "sms.tencentcloudapi.com"
c.core.WithHttpTransport(t)
default:
return result, ErrSendRejected
}
// Upstream errors may contain credentials/phone numbers and are not exposed.
_ = client.SendMessage(params, phone)
if t.status < 200 || t.status >= 300 {
return result, ErrSendUnknown
}
switch client.(type) {
case *AliyunClient:
var body struct{ Code, RequestId, BizId string }
if json.Unmarshal(t.body, &body) != nil {
return result, ErrSendUnknown
}
result.RequestID = body.RequestId
if body.Code != "" && body.Code != "OK" {
result.State, result.Code = "rejected", body.Code
return result, ErrSendRejected
}
if body.Code != "OK" || body.BizId == "" || body.RequestId == "" {
return result, ErrSendUnknown
}
result.MessageID = body.BizId
case *TencentClient:
var body struct {
Response struct {
RequestId string
Error *struct{ Code string }
SendStatusSet []struct{ Code, SerialNo string }
}
}
if json.Unmarshal(t.body, &body) != nil {
return result, ErrSendUnknown
}
result.RequestID = body.Response.RequestId
if body.Response.Error != nil && body.Response.Error.Code != "" {
result.State, result.Code = "rejected", body.Response.Error.Code
return result, ErrSendRejected
}
if len(body.Response.SendStatusSet) != 1 {
return result, ErrSendUnknown
}
status := body.Response.SendStatusSet[0]
if status.Code != "" && status.Code != "Ok" {
result.State, result.Code = "rejected", status.Code
return result, ErrSendRejected
}
if status.Code != "Ok" || status.SerialNo == "" || result.RequestID == "" {
return result, ErrSendUnknown
}
result.MessageID = status.SerialNo
}
result.State, result.Code = "accepted", ""
return result, nil
}

View File

@@ -0,0 +1,97 @@
// Copyright 2022 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"
"io"
"net/http"
"net/url"
"strings"
)
type SmsBaoClient struct {
username string
apikey string
sign string
template string
goodsid string
}
func GetSmsbaoClient(username string, apikey string, sign string, template string, other []string) (*SmsBaoClient, error) {
var goodsid string
if len(other) == 0 {
goodsid = ""
} else {
goodsid = other[0]
}
return &SmsBaoClient{
username: username,
apikey: apikey,
sign: sign,
template: template,
goodsid: goodsid,
}, nil
}
func (c *SmsBaoClient) 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")
}
smsContent := url.QueryEscape("【" + c.sign + "】" + fmt.Sprintf(c.template, code))
for _, mobile := range targetPhoneNumber {
if strings.HasPrefix(mobile, "+86") {
mobile = mobile[3:]
} else if strings.HasPrefix(mobile, "+") {
return fmt.Errorf("unsupported country code")
}
// https://api.smsbao.com/sms?u=USERNAME&p=PASSWORD&g=GOODSID&m=PHONE&c=CONTENT
url := fmt.Sprintf("https://api.smsbao.com/sms?u=%s&p=%s&g=%s&m=%s&c=%s", c.username, c.apikey, c.goodsid, mobile, smsContent)
client := &http.Client{}
req, _ := http.NewRequest("GET", url, nil)
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
}
switch string(body) {
case "30":
return fmt.Errorf("password error")
case "40":
return fmt.Errorf("account not exist")
case "41":
return fmt.Errorf("overdue account")
case "43":
return fmt.Errorf("IP address limit")
case "50":
return fmt.Errorf("content contain forbidden words")
case "51":
return fmt.Errorf("phone number incorrect")
}
}
return nil
}

View File

@@ -0,0 +1,135 @@
// 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"
"io"
"mime/multipart"
"net/http"
"strings"
)
type SubmailClient struct {
api string
appid string
signature string
project string
}
type SubmailResult struct {
Status string `json:"status"`
Code int `json:"code"`
Msg string `json:"msg"`
}
func buildSubmailPostdata(param map[string]string, appid string, signature string, project string, targetPhoneNumber []string) (map[string]string, error) {
multi := make([]map[string]interface{}, 0, 32)
for _, phoneNumber := range targetPhoneNumber[0:] {
multi = append(multi, map[string]interface{}{
"to": phoneNumber,
"vars": param,
})
}
m, err := json.Marshal(multi)
if err != nil {
return nil, err
}
postdata := make(map[string]string)
postdata["appid"] = appid
postdata["signature"] = signature
postdata["project"] = project
postdata["multi"] = string(m)
return postdata, nil
}
func GetSubmailClient(appid string, signature string, project string) (*SubmailClient, error) {
submailClient := &SubmailClient{
api: "https://api-v4.mysubmail.com/sms/multixsend",
appid: appid,
signature: signature,
project: project,
}
return submailClient, nil
}
func (c *SubmailClient) SendMessage(param map[string]string, targetPhoneNumber ...string) error {
postdata, err := buildSubmailPostdata(param, c.appid, c.signature, c.project, targetPhoneNumber)
if err != nil {
return err
}
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
for key, val := range postdata {
err = writer.WriteField(key, val)
if err != nil {
return err
}
}
contentType := writer.FormDataContentType()
err = writer.Close()
if err != nil {
return err
}
resp, err := http.Post(c.api, contentType, body)
if err != nil {
return err
}
result, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
return handleSubmailResult(result)
}
func handleSubmailResult(result []byte) error {
var submailSuccessResult []SubmailResult
err := json.Unmarshal(result, &submailSuccessResult)
if err != nil {
var submailErrorResult SubmailResult
err = json.Unmarshal(result, &submailErrorResult)
if err != nil {
return err
}
if submailErrorResult.Msg != "" {
return fmt.Errorf(submailErrorResult.Msg)
}
}
errMsgs := []string{}
for _, submailResult := range submailSuccessResult {
if submailResult.Status != "success" {
errMsg := fmt.Sprintf("%s, %d, %s", submailResult.Status, submailResult.Code, submailResult.Msg)
errMsgs = append(errMsgs, errMsg)
}
}
if len(errMsgs) > 0 {
return fmt.Errorf(strings.Join(errMsgs, "|"))
}
return nil
}

View File

@@ -0,0 +1,92 @@
// 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"
"strconv"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
sms "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms/v20210111"
)
type TencentClient struct {
core *sms.Client
appId string
sign string
template string
}
func GetTencentClient(accessId string, accessKey string, sign string, templateId string, appId []string) (*TencentClient, error) {
if len(appId) == 0 {
return nil, fmt.Errorf("missing parameter: appId")
}
credential := common.NewCredential(accessId, accessKey)
config := profile.NewClientProfile()
config.HttpProfile.ReqMethod = "POST"
region := "ap-guangzhou"
client, err := sms.NewClient(credential, region, config)
if err != nil {
return nil, err
}
tencentClient := &TencentClient{
core: client,
appId: appId[0],
sign: sign,
template: templateId,
}
return tencentClient, nil
}
func (c *TencentClient) SendMessage(param map[string]string, targetPhoneNumber ...string) error {
if len(targetPhoneNumber) == 0 {
return fmt.Errorf("missing parameter: targetPhoneNumber")
}
var paramArray []string
index := 0
for {
value := param[strconv.Itoa(index)]
if len(value) == 0 {
break
}
paramArray = append(paramArray, value)
index++
}
request := sms.NewSendSmsRequest()
request.SmsSdkAppId = common.StringPtr(c.appId)
request.SignName = common.StringPtr(c.sign)
request.TemplateParamSet = common.StringPtrs(paramArray)
request.TemplateId = common.StringPtr(c.template)
request.PhoneNumberSet = common.StringPtrs(targetPhoneNumber)
response, err := c.core.SendSms(request)
if err != nil {
return err
}
if response == nil || response.Response == nil || len(response.Response.SendStatusSet) == 0 || response.Response.SendStatusSet[0] == nil || response.Response.SendStatusSet[0].Code == nil {
return fmt.Errorf("missing send status")
}
if *response.Response.SendStatusSet[0].Code != "Ok" {
return fmt.Errorf("sms rejected")
}
return err
}

View File

@@ -0,0 +1,69 @@
// Copyright 2022 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/twilio/twilio-go"
openapi "github.com/twilio/twilio-go/rest/api/v2010"
)
type TwilioClient struct {
template string
core *twilio.RestClient
}
func GetTwilioClient(accessId string, accessKey string, template string) (*TwilioClient, error) {
client := twilio.NewRestClientWithParams(twilio.ClientParams{
Username: accessId,
Password: accessKey,
})
twilioClient := &TwilioClient{
core: client,
template: template,
}
return twilioClient, nil
}
// SendMessage targetPhoneNumber[0] is the sender's number, so targetPhoneNumber should have at least two parameters
func (c *TwilioClient) SendMessage(param map[string]string, targetPhoneNumber ...string) error {
code, ok := param["code"]
if !ok {
return fmt.Errorf("missing parameter: code")
}
bodyContent := fmt.Sprintf(c.template, code)
if len(targetPhoneNumber) < 2 {
return fmt.Errorf("bad parameter: targetPhoneNumber")
}
params := &openapi.CreateMessageParams{}
params.SetFrom(targetPhoneNumber[0])
params.SetBody(bodyContent)
for i := 1; i < len(targetPhoneNumber); i++ {
params.SetTo(targetPhoneNumber[i])
_, err := c.core.Api.CreateMessage(params)
if err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,83 @@
// 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/ucloud/ucloud-sdk-go/services/usms"
"github.com/ucloud/ucloud-sdk-go/ucloud"
"github.com/ucloud/ucloud-sdk-go/ucloud/auth"
"github.com/ucloud/ucloud-sdk-go/ucloud/config"
)
type UcloudClient struct {
core *usms.USMSClient
ProjectId string
PrivateKey string
PublicKey string
Sign string
Template string
}
func GetUcloudClient(publicKey string, privateKey string, sign string, template string, projectId []string) (*UcloudClient, error) {
if len(projectId) == 0 {
return nil, fmt.Errorf("missing parameter: projectId")
}
cfg := config.NewConfig()
cfg.ProjectId = projectId[0]
credential := auth.NewCredential()
credential.PublicKey = publicKey
credential.PrivateKey = privateKey
client := usms.NewClient(&cfg, &credential)
ucloudClient := &UcloudClient{
core: client,
ProjectId: projectId[0],
PrivateKey: privateKey,
PublicKey: publicKey,
Sign: sign,
Template: template,
}
return ucloudClient, nil
}
func (c *UcloudClient) 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")
}
req := c.core.NewSendUSMSMessageRequest()
req.SigContent = ucloud.String(c.Sign)
req.TemplateId = ucloud.String(c.Template)
req.PhoneNumbers = targetPhoneNumber
req.TemplateParams = []string{code}
response, err := c.core.SendUSMSMessage(req)
if err != nil {
return err
}
if response.RetCode != 0 {
return fmt.Errorf(response.Message)
}
return nil
}

View File

@@ -0,0 +1,72 @@
// Copyright 2024 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 (
"errors"
"fmt"
"strings"
unisms "github.com/apistd/uni-go-sdk/sms"
)
type UnismsClient struct {
core *unisms.UniSMSClient
sign string
template string
}
func GetUnismsClient(accessId string, accessKey string, signature string, templateId string) (*UnismsClient, error) {
client := unisms.NewClient(accessId, accessKey)
// Check the correctness of the accessId and accessKey
msg := unisms.BuildMessage()
msg.SetTo("test")
msg.SetTemplateId("pub_verif_register") // free template
_, err := client.Send(msg)
if strings.Contains(err.Error(), "[104111] InvalidAccessKeyId") {
return nil, err
}
unismsClient := &UnismsClient{
core: client,
sign: signature,
template: templateId,
}
return unismsClient, nil
}
func (c *UnismsClient) SendMessage(param map[string]string, targetPhoneNumber ...string) error {
if len(targetPhoneNumber) == 0 {
return fmt.Errorf("missing parameter: targetPhoneNumber")
}
msg := unisms.BuildMessage()
msg.SetTo(targetPhoneNumber...)
msg.SetSignature(c.sign)
msg.SetTemplateId(c.template)
resp, err := c.core.Send(msg)
if err != nil {
return err
}
if resp.Code != "0" {
return errors.New(resp.Message)
}
return nil
}

View File

@@ -0,0 +1,81 @@
// 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/volcengine/volc-sdk-golang/service/sms"
)
type VolcClient struct {
core *sms.SMS
sign string
template string
smsAccount string
}
func GetVolcClient(accessId, accessKey, sign, templateId string, smsAccount []string) (*VolcClient, error) {
if len(smsAccount) == 0 {
return nil, fmt.Errorf("missing parameter: smsAccount")
}
client := sms.NewInstance()
client.Client.SetAccessKey(accessId)
client.Client.SetSecretKey(accessKey)
volcClient := &VolcClient{
core: client,
sign: sign,
template: templateId,
smsAccount: smsAccount[0],
}
return volcClient, nil
}
func (c *VolcClient) SendMessage(param map[string]string, targetPhoneNumber ...string) error {
if len(targetPhoneNumber) == 0 {
return fmt.Errorf("missing parameter: targetPhoneNumber")
}
requestParam, err := json.Marshal(param)
if err != nil {
return err
}
req := &sms.SmsRequest{
SmsAccount: c.smsAccount,
Sign: c.sign,
TemplateID: c.template,
TemplateParam: string(requestParam),
PhoneNumbers: strings.Join(targetPhoneNumber, ","),
}
resp, statusCode, err := c.core.Send(req)
if err != nil {
return fmt.Errorf("send message failed, error: %q", err.Error())
}
if statusCode < 200 || statusCode > 299 {
return fmt.Errorf("send message failed, statusCode: %d", statusCode)
}
if resp.ResponseMetadata.Error != nil {
return fmt.Errorf("send message failed, code: %q, message: %q", resp.ResponseMetadata.Error.Code, resp.ResponseMetadata.Error.Message)
}
return nil
}

View File

@@ -6,7 +6,7 @@
- [资源访问与 OSS/CDN 分发架构重构](design/resource-delivery-architecture.md):资源身份、场景授权与分发策略分离,统一访问合同、全入口迁移、旧实现退场及流量/安全验收门槛(核心代码已实施,待真实 OSS/CDN 链路验收)。
- [短信渠道插件与登录注册策略](design/sms-channels-and-auth-policy.md):阿里云/腾讯云官方 SDK 候选基线、多渠道与模板路由、发送记录、验证码安全、短信和邮箱登录注册组合及分阶段验收(设计提案,未实施)。
- [短信渠道插件与登录注册策略](design/sms-channels-and-auth-policy.md):阿里云/腾讯云官方 SDK 候选基线、多渠道与模板路由、发送记录、验证码安全、短信和邮箱登录注册组合及分阶段验收(核心代码已实施,待真实 OSS/CDN 链路验收)。
- [画布批量生成一致性治理](design/canvas-consistency-repair.mdx):节点丢失、批次状态与引用解析的根因、已实施边界重构、回归证据和仍待验收的性能/同步场景。

View File

@@ -0,0 +1,45 @@
import { useEffect, useRef, useState } from "react";
import { App, Button, Input } from "antd";
import { startVerification, type VerificationDraft, type VerificationMethod, type VerificationPurpose } from "@/services/api/verification";
export function VerificationFields({ purpose, method, value, onChange, disabled = false, password }: {
purpose: VerificationPurpose; method: VerificationMethod; value: VerificationDraft;
onChange: (value: VerificationDraft) => void; disabled?: boolean; password?: string;
}) {
const { message } = App.useApp();
const [sending, setSending] = useState(false);
const [remaining, setRemaining] = useState(0);
const inFlight = useRef(false);
const sms = method !== "email", email = method !== "sms";
useEffect(() => {
if (!remaining) return;
const timer = window.setTimeout(() => setRemaining((n) => Math.max(0, n - 1)), 1000);
return () => window.clearTimeout(timer);
}, [remaining]);
const changeTarget = (key: "email" | "phone", target: string) => onChange({ ...value, [key]: target, ticket: "", emailCode: "", smsCode: "" });
const send = async () => {
if (inFlight.current || remaining > 0) return;
if ((sms && !value.phone.trim()) || (email && !value.email.trim()) || (purpose === "bind" && !password)) {
message.warning("请先填写联系方式及必要的当前密码"); return;
}
inFlight.current = true; setSending(true);
// Start cooldown even on ambiguous failures; never encourage instant resends.
setRemaining(60);
onChange({ ...value, ticket: "", emailCode: "", smsCode: "" });
try {
const result = await startVerification({ purpose, method, email: email ? value.email.trim() : undefined, phone: sms ? value.phone.trim() : undefined, password });
onChange({ ...value, email: email ? value.email.trim().toLowerCase() : "", phone: sms ? value.phone.trim() : "", emailCode: "", smsCode: "", ticket: result.ticket });
setRemaining(result.retryAfter);
message.success(purpose === "login" ? "如该联系方式已验证且账号可用,验证码将发送至该联系方式" : "验证码已发送,10 分钟内有效");
} catch (error) { message.error(error instanceof Error ? error.message : "发送失败,请稍后重试"); }
finally { setSending(false); inFlight.current = false; }
};
return <div className="space-y-4">
{sms && <label className="block space-y-2"><span className="text-sm">手机号</span><Input size="large" value={value.phone} onChange={(e) => changeTarget("phone", e.target.value)} autoComplete="tel" inputMode="tel" placeholder="中国大陆手机号,支持 +86" required disabled={disabled || sending} /></label>}
{email && <label className="block space-y-2"><span className="text-sm">邮箱</span><Input size="large" type="email" value={value.email} onChange={(e) => changeTarget("email", e.target.value)} autoComplete="email" placeholder="请输入邮箱" required disabled={disabled || sending} /></label>}
<Button block size="large" loading={sending} disabled={disabled || remaining > 0} onClick={() => void send()}>{remaining > 0 ? `${remaining} 秒后可重新获取` : method === "sms_email" ? "获取短信及邮件验证码" : "获取验证码"}</Button>
{sms && <label className="block space-y-2"><span className="text-sm">短信验证码</span><Input size="large" value={value.smsCode} onChange={(e) => onChange({ ...value, smsCode: e.target.value.replace(/\D/g, "").slice(0, 6) })} autoComplete="one-time-code" inputMode="numeric" placeholder="6 位短信验证码" required disabled={disabled || sending} /></label>}
{email && <label className="block space-y-2"><span className="text-sm">邮件验证码</span><Input size="large" value={value.emailCode} onChange={(e) => onChange({ ...value, emailCode: e.target.value.replace(/\D/g, "").slice(0, 6) })} autoComplete="one-time-code" inputMode="numeric" placeholder="6 位邮件验证码" required disabled={disabled || sending} /></label>}
{method === "sms_email" && <p className="text-xs opacity-65">两项验证必须在本次注册中同时通过;修改联系方式后需要重新获取。</p>}
</div>;
}

View File

@@ -1,11 +1,13 @@
import { type FormEvent, useEffect, useState, type ReactNode } from "react";
import { App, Button, Divider, Input } from "antd";
import { App, Button, Divider, Input, Segmented } from "antd";
import { ArrowRight, LockKeyhole, UserRound } from "lucide-react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { getAuthSession, getAuthSettings, linuxDOLoginURL, login } from "@/services/api/auth";
import { useUserStore } from "@/stores/use-user-store";
import { LinuxDOIcon } from "./auth-scene";
import { VerificationFields } from "@/components/auth/verification-fields";
import { emptyVerification, loginVerification, methodLabels, verificationMethods, type VerificationMethod } from "@/services/api/verification";
export default function LoginPage() {
const navigate = useNavigate();
@@ -15,6 +17,9 @@ export default function LoginPage() {
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [linuxdoEnabled, setLinuxdoEnabled] = useState(false);
const [methods, setMethods] = useState<VerificationMethod[]>([]);
const [method, setMethod] = useState<"password" | VerificationMethod>("password");
const [verification, setVerification] = useState({ ...emptyVerification });
const next = safeNext(params.get("next"));
const forgotPasswordURL = `/forgot-password?next=${encodeURIComponent(next)}`;
const user = useUserStore((state) => state.user);
@@ -29,7 +34,7 @@ export default function LoginPage() {
useEffect(() => {
void getAuthSettings()
.then((settings) => setLinuxdoEnabled(settings.linuxdoEnabled))
.then((settings) => { setLinuxdoEnabled(settings.linuxdoEnabled); setMethods(verificationMethods(settings, "login")); })
.catch((error) => {
// 这是登录页的展示配置读取:失败时明确隐藏第三方入口,
// 账号密码登录仍可用;不能无痕地把配置读取失败当成成功。
@@ -43,7 +48,11 @@ export default function LoginPage() {
event.preventDefault();
setSubmitting(true);
try {
await login({ username, password });
if (method === "password") await login({ username, password });
else {
if (!verification.ticket) throw new Error("请先获取本次登录验证码");
await loginVerification(verification);
}
const { applyUserSession } = await import("@/lib/user-session");
await applyUserSession(await getAuthSession());
message.success("登录成功");
@@ -57,6 +66,8 @@ export default function LoginPage() {
return (
<form onSubmit={submit} className="space-y-5">
{methods.length > 0 && <Segmented block aria-label="登录方式" value={method} disabled={submitting} options={[{ value: "password", label: "密码登录" }, ...methods.map((value) => ({ value, label: methodLabels[value] }))]} onChange={(value) => { setMethod(value as typeof method); setVerification({ ...emptyVerification }); }} />}
{method !== "password" ? <VerificationFields key={method} purpose="login" method={method} value={verification} onChange={setVerification} disabled={submitting} /> : <>
<AuthField label="用户名 / 邮箱" htmlFor="login-account">
<Input id="login-account" size="large" prefix={<UserRound className="size-4 text-white/35" />} value={username} onChange={(event) => setUsername(event.target.value)} placeholder="用户名或邮箱" autoComplete="username" required />
</AuthField>
@@ -83,6 +94,7 @@ export default function LoginPage() {
required
/>
</AuthField>
</>}
<Button type="primary" htmlType="submit" size="large" block loading={submitting} icon={<ArrowRight className="size-4" />} iconPlacement="end">
登录
</Button>

View File

@@ -1,11 +1,13 @@
import { type FormEvent, useEffect, useRef, useState, type ReactNode } from "react";
import { App, Button, Checkbox, Divider, Input, Modal } from "antd";
import { ArrowRight, Info, LockKeyhole, Mail, ShieldCheck, TriangleAlert, UserRound } from "lucide-react";
import { App, Button, Checkbox, Divider, Input, Modal, Segmented } from "antd";
import { ArrowRight, Info, LockKeyhole, Mail, TriangleAlert, UserRound } from "lucide-react";
import { useNavigate, useSearchParams } from "react-router";
import { getAuthSession, getAuthSettings, linuxDOLoginURL, register, sendRegistrationEmailCode } from "@/services/api/auth";
import { getAuthSession, getAuthSettings, linuxDOLoginURL, register } from "@/services/api/auth";
import { LinuxDOIcon } from "./auth-scene";
import { ApiError } from "@/services/api/request";
import { VerificationFields } from "@/components/auth/verification-fields";
import { emptyVerification, methodLabels, verificationMethods, type VerificationMethod } from "@/services/api/verification";
type AuthSettings = Awaited<ReturnType<typeof getAuthSettings>>;
@@ -19,57 +21,28 @@ export default function RegisterPage() {
const [settings, setSettings] = useState<AuthSettings | null>(null);
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [emailCode, setEmailCode] = useState("");
const [verification, setVerification] = useState({ ...emptyVerification });
const [method, setMethod] = useState<VerificationMethod>("email");
const [displayName, setDisplayName] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [agreementAccepted, setAgreementAccepted] = useState(false);
const [agreementOpen, setAgreementOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [sendingCode, setSendingCode] = useState(false);
const [countdown, setCountdown] = useState(0);
const [registerCountdown, setRegisterCountdown] = useState(0);
const sending = useRef(false),
registering = useRef(false);
const registering = useRef(false);
const next = safeNext(params.get("next"));
useEffect(() => {
let cancelled = false;
void getAuthSettings()
.then((value) => !cancelled && setSettings(value))
.then((value) => { if (!cancelled) { setSettings(value); setMethod(verificationMethods(value, "register")[0] ?? "email"); } })
.catch((error) => !cancelled && message.error(error instanceof Error ? error.message : "读取注册设置失败"));
return () => {
cancelled = true;
};
}, [message]);
useEffect(() => {
if (countdown <= 0) return;
const timer = window.setInterval(() => setCountdown((value) => Math.max(0, value - 1)), 1000);
return () => window.clearInterval(timer);
}, [countdown]);
const sendCode = async () => {
if (sending.current || countdown > 0) return;
if (!email.trim()) {
message.warning("请先输入邮箱");
return;
}
sending.current = true;
setSendingCode(true);
try {
await sendRegistrationEmailCode(email.trim());
setCountdown(60);
message.success("验证码已发送,请检查邮箱");
} catch (error) {
if (error instanceof ApiError && error.status === 429) setCountdown(Math.max(1, Math.ceil((error.retryAfterMs ?? 60000) / 1000)));
message.error(error instanceof Error ? error.message : "发送验证码失败");
} finally {
sending.current = false;
setSendingCode(false);
}
};
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (registering.current || registerCountdown > 0) return;
@@ -84,7 +57,8 @@ export default function RegisterPage() {
registering.current = true;
setSubmitting(true);
try {
await register({ username, email, emailCode, displayName, password, acceptedTerms: agreementAccepted });
if (!settings?.firstUser && !verification.ticket) throw new Error("请先获取本次注册验证码");
await register({ username, ...(settings?.firstUser ? { email } : verification), displayName, password, acceptedTerms: agreementAccepted });
const { applyUserSession } = await import("@/lib/user-session");
await applyUserSession(await getAuthSession());
if (!settings?.firstUser) window.sessionStorage.setItem("infinite-canvas:model-setup-guide", "1");
@@ -106,9 +80,9 @@ export default function RegisterPage() {
}, [registerCountdown]);
const registrationClosed = settings?.registrationEnabled === false;
const mailUnavailable = Boolean(settings && !settings.firstUser && settings.emailCodeRequired && !settings.emailEnabled);
const disabled = registrationClosed || mailUnavailable;
const requireCode = Boolean(settings && !settings.firstUser && settings.emailCodeRequired);
const methods = settings ? verificationMethods(settings, "register") : [];
const verificationUnavailable = Boolean(settings && !settings.firstUser && methods.length === 0);
const disabled = !settings || registrationClosed || verificationUnavailable;
return (
<form onSubmit={submit} className="space-y-4">
@@ -122,9 +96,9 @@ export default function RegisterPage() {
当前已关闭普通注册,请联系管理员创建账号。
</Notice>
) : null}
{mailUnavailable ? (
{verificationUnavailable ? (
<Notice icon={<TriangleAlert className="size-3.5" />} tone="amber">
管理员尚未配置注册邮件,普通邮箱注册暂不可用。
当前没有可用的注册验证方式,请联系管理员检查短信及邮件配置。
</Notice>
) : null}
@@ -137,7 +111,7 @@ export default function RegisterPage() {
</AuthField>
</div>
<AuthField label="邮箱">
{settings?.firstUser ? <AuthField label="邮箱(可选)">
<Input
size="large"
prefix={<Mail className="size-4 text-white/35" />}
@@ -148,28 +122,10 @@ export default function RegisterPage() {
required={!settings?.firstUser}
disabled={disabled}
/>
</AuthField>
{requireCode ? (
<AuthField label="邮箱验证码">
<div className="grid grid-cols-[minmax(0,1fr)_116px] gap-2">
<Input
size="large"
prefix={<ShieldCheck className="size-4 text-white/35" />}
value={emailCode}
onChange={(event) => setEmailCode(event.target.value.replace(/\D/g, "").slice(0, 6))}
placeholder="6 位验证码"
inputMode="numeric"
autoComplete="one-time-code"
required
disabled={disabled}
/>
<Button size="large" loading={sendingCode} disabled={disabled || countdown > 0} onClick={() => void sendCode()}>
{countdown > 0 ? `${countdown}s` : "获取验证码"}
</Button>
</div>
</AuthField>
) : null}
</AuthField> : <>
{methods.length > 1 && <Segmented block aria-label="注册验证方式" options={methods.map((value) => ({ value, label: methodLabels[value] }))} value={method} disabled={submitting} onChange={(value) => { setMethod(value as VerificationMethod); setVerification({ ...emptyVerification }); }} />}
{methods.length > 0 && <VerificationFields key={method} purpose="register" method={method} value={verification} onChange={setVerification} disabled={disabled || submitting} />}
</>}
<div className="grid gap-4 sm:grid-cols-2">
<AuthField label="密码">
@@ -210,7 +166,7 @@ export default function RegisterPage() {
<Button type="primary" htmlType="submit" size="large" block loading={submitting} disabled={disabled || registerCountdown > 0 || !agreementAccepted} icon={<ArrowRight className="size-4" />} iconPlacement="end">
{registerCountdown > 0 ? `${registerCountdown} 秒后可重试` : "创建账号"}
</Button>
{settings?.linuxdoEnabled ? (
{settings?.linuxdoEnabled && !settings.smsAndEmailRegistration ? (
<>
<Divider plain className="!border-white/10 !text-white/30">
或

View File

@@ -6,12 +6,13 @@ import type { FeatureAvailability } from "@/stores/use-user-store";
import { http, apiBaseURL } from "@/services/api/request";
import type { PublicLogicalModel } from "@/services/api/logical-models";
import type { OSSConnectionTestInput, OSSConnectionTestResult, OSSProvider, S3Preset } from "@/lib/oss-settings";
import type { VerificationPolicy } from "./verification";
let authSessionRequest: Promise<AuthSessionPayload> | null = null;
let authSessionCache: { payload: AuthSessionPayload; expiresAt: number } | null = null;
function invalidateAuthSessionCache() {
export function invalidateAuthSessionCache() {
authSessionCache = null;
}
@@ -19,6 +20,9 @@ export type LocalUser = {
id: string;
username: string;
email?: string;
phone?: string;
emailVerifiedAt?: string;
phoneVerifiedAt?: string;
displayName: string;
avatarUrl?: string;
identityProvider?: string;
@@ -376,7 +380,7 @@ export type RuntimePolicySetting = {
};
export function getAuthSettings() {
return http.get<{ firstUser: boolean; registrationEnabled: boolean; linuxdoEnabled: boolean; emailEnabled: boolean; emailCodeRequired: boolean }>("/auth/settings");
return http.get<VerificationPolicy & { firstUser: boolean; registrationEnabled: boolean; linuxdoEnabled: boolean; emailEnabled: boolean; emailCodeRequired: boolean; smsBindingAvailable: boolean; emailBindingAvailable: boolean }>("/auth/settings");
}
export function linuxDOLoginURL(next: string, acceptedTerms?: boolean) {
@@ -435,8 +439,10 @@ export function resetPassword(input: { email: string; emailCode: string; passwor
return http.post<{ reset: boolean }>("/auth/password-reset", input);
}
export function register(input: { username: string; email?: string; emailCode?: string; displayName?: string; password: string; acceptedTerms: boolean }) {
return http.post<{ user: LocalUser }>("/auth/register", input);
export async function register(input: { username: string; email?: string; emailCode?: string; phone?: string; smsCode?: string; ticket?: string; displayName?: string; password: string; acceptedTerms: boolean }) {
const result = await http.post<{ user: LocalUser }>("/auth/register", input);
invalidateAuthSessionCache();
return result;
}
export async function logout() {

View File

@@ -0,0 +1,35 @@
import { http } from "./request";
import { invalidateAuthSessionCache, type LocalUser } from "./auth";
export type VerificationPolicy = {
smsLogin: boolean;
emailLogin: boolean;
smsRegistration: boolean;
emailRegistration: boolean;
smsAndEmailRegistration: boolean;
};
export type VerificationMethod = "sms" | "email" | "sms_email";
export type VerificationPurpose = "login" | "register" | "bind";
export type VerificationDraft = { ticket: string; email: string; phone: string; emailCode: string; smsCode: string };
export const emptyVerification: VerificationDraft = { ticket: "", email: "", phone: "", emailCode: "", smsCode: "" };
export const methodLabels: Record<VerificationMethod, string> = { sms: "短信验证码", email: "邮件验证码", sms_email: "短信 + 邮箱双重验证" };
export function verificationMethods(policy: VerificationPolicy, purpose: "login" | "register"): VerificationMethod[] {
if (purpose === "login") return [...(policy.smsLogin ? ["sms" as const] : []), ...(policy.emailLogin ? ["email" as const] : [])];
if (policy.smsAndEmailRegistration) return ["sms_email"];
return [...(policy.smsRegistration ? ["sms" as const] : []), ...(policy.emailRegistration ? ["email" as const] : [])];
}
export const startVerification = (input: { purpose: VerificationPurpose; method: VerificationMethod; email?: string; phone?: string; password?: string }) =>
http.post<{ ticket: string; expiresIn: number; retryAfter: number }>("/auth/verification", input);
export async function loginVerification(input: VerificationDraft) {
const result = await http.post<{ user: LocalUser }>("/auth/verification/login", input);
invalidateAuthSessionCache();
return result;
}
export async function bindVerification(input: VerificationDraft) {
const result = await http.post<{ user: LocalUser }>("/auth/verification/bind", input);
invalidateAuthSessionCache();
return result;
}
export const getVerificationPolicy = () => http.get<VerificationPolicy>("/admin/settings/auth-policy");
export const saveVerificationPolicy = (input: VerificationPolicy) => http.put<VerificationPolicy>("/admin/settings/auth-policy", input);