feat(auth): 登录注册 - 添加短信通知与组合验证支持
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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) {
|
||||
|
||||
88
backend/internal/app/notifications_bridge.go
Normal file
88
backend/internal/app/notifications_bridge.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
26
backend/internal/app/sms_plugins.go
Normal file
26
backend/internal/app/sms_plugins.go
Normal 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 }
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
|
||||
321
backend/internal/auth/verification.go
Normal file
321
backend/internal/auth/verification.go
Normal 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
|
||||
}
|
||||
97
backend/internal/auth/verification_policy.go
Normal file
97
backend/internal/auth/verification_policy.go
Normal 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)) }
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
183
backend/internal/handler/notifications.go
Normal file
183
backend/internal/handler/notifications.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
69
backend/internal/model/models_notifications.go
Normal file
69
backend/internal/model/models_notifications.go
Normal 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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
191
backend/internal/repository/notifications.go
Normal file
191
backend/internal/repository/notifications.go
Normal 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
|
||||
}
|
||||
8
backend/internal/service/aliases_notifications.go
Normal file
8
backend/internal/service/aliases_notifications.go
Normal 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
|
||||
384
backend/internal/sms/service.go
Normal file
384
backend/internal/sms/service.go
Normal 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)
|
||||
}
|
||||
15
backend/third_party/go-sms-sender/CANVAS-PATCH.md
vendored
Normal file
15
backend/third_party/go-sms-sender/CANVAS-PATCH.md
vendored
Normal 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.
|
||||
201
backend/third_party/go-sms-sender/LICENSE
vendored
Normal file
201
backend/third_party/go-sms-sender/LICENSE
vendored
Normal 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.
|
||||
87
backend/third_party/go-sms-sender/aliyun.go
vendored
Normal file
87
backend/third_party/go-sms-sender/aliyun.go
vendored
Normal 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
|
||||
}
|
||||
87
backend/third_party/go-sms-sender/aws.go
vendored
Normal file
87
backend/third_party/go-sms-sender/aws.go
vendored
Normal 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
|
||||
}
|
||||
95
backend/third_party/go-sms-sender/azure.go
vendored
Normal file
95
backend/third_party/go-sms-sender/azure.go
vendored
Normal 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
|
||||
}
|
||||
76
backend/third_party/go-sms-sender/baidubce.go
vendored
Normal file
76
backend/third_party/go-sms-sender/baidubce.go
vendored
Normal 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
|
||||
}
|
||||
88
backend/third_party/go-sms-sender/basic.go
vendored
Normal file
88
backend/third_party/go-sms-sender/basic.go
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
127
backend/third_party/go-sms-sender/gccpay.go
vendored
Normal file
127
backend/third_party/go-sms-sender/gccpay.go
vendored
Normal 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
|
||||
}
|
||||
33
backend/third_party/go-sms-sender/go.mod
vendored
Normal file
33
backend/third_party/go-sms-sender/go.mod
vendored
Normal 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
|
||||
)
|
||||
142
backend/third_party/go-sms-sender/huawei.go
vendored
Normal file
142
backend/third_party/go-sms-sender/huawei.go
vendored
Normal 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
|
||||
}
|
||||
89
backend/third_party/go-sms-sender/huyi.go
vendored
Normal file
89
backend/third_party/go-sms-sender/huyi.go
vendored
Normal 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
|
||||
}
|
||||
125
backend/third_party/go-sms-sender/infobip.go
vendored
Normal file
125
backend/third_party/go-sms-sender/infobip.go
vendored
Normal 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
|
||||
}
|
||||
27
backend/third_party/go-sms-sender/mock.go
vendored
Normal file
27
backend/third_party/go-sms-sender/mock.go
vendored
Normal 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
|
||||
}
|
||||
102
backend/third_party/go-sms-sender/msg91.go
vendored
Normal file
102
backend/third_party/go-sms-sender/msg91.go
vendored
Normal 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
|
||||
}
|
||||
114
backend/third_party/go-sms-sender/netgsm.go
vendored
Normal file
114
backend/third_party/go-sms-sender/netgsm.go
vendored
Normal 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
|
||||
}
|
||||
120
backend/third_party/go-sms-sender/oson.go
vendored
Normal file
120
backend/third_party/go-sms-sender/oson.go
vendored
Normal 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
|
||||
}
|
||||
118
backend/third_party/go-sms-sender/safe_http.go
vendored
Normal file
118
backend/third_party/go-sms-sender/safe_http.go
vendored
Normal 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
|
||||
}
|
||||
97
backend/third_party/go-sms-sender/smsbao.go
vendored
Normal file
97
backend/third_party/go-sms-sender/smsbao.go
vendored
Normal 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
|
||||
}
|
||||
135
backend/third_party/go-sms-sender/submail.go
vendored
Normal file
135
backend/third_party/go-sms-sender/submail.go
vendored
Normal 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
|
||||
}
|
||||
92
backend/third_party/go-sms-sender/tencent.go
vendored
Normal file
92
backend/third_party/go-sms-sender/tencent.go
vendored
Normal 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
|
||||
}
|
||||
69
backend/third_party/go-sms-sender/twilio.go
vendored
Normal file
69
backend/third_party/go-sms-sender/twilio.go
vendored
Normal 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
|
||||
}
|
||||
83
backend/third_party/go-sms-sender/ucloud.go
vendored
Normal file
83
backend/third_party/go-sms-sender/ucloud.go
vendored
Normal 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
|
||||
}
|
||||
72
backend/third_party/go-sms-sender/unisms.go
vendored
Normal file
72
backend/third_party/go-sms-sender/unisms.go
vendored
Normal 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
|
||||
}
|
||||
81
backend/third_party/go-sms-sender/volcengine.go
vendored
Normal file
81
backend/third_party/go-sms-sender/volcengine.go
vendored
Normal 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
|
||||
}
|
||||
@@ -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):节点丢失、批次状态与引用解析的根因、已实施边界重构、回归证据和仍待验收的性能/同步场景。
|
||||
|
||||
|
||||
45
web/src/components/auth/verification-fields.tsx
Normal file
45
web/src/components/auth/verification-fields.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
或
|
||||
|
||||
@@ -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() {
|
||||
|
||||
35
web/src/services/api/verification.ts
Normal file
35
web/src/services/api/verification.ts
Normal 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);
|
||||
Reference in New Issue
Block a user