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

两项验证必须在本次注册中同时通过;修改联系方式后需要重新获取。

} +
; +} diff --git a/web/src/pages/auth/login.tsx b/web/src/pages/auth/login.tsx index ccc46afd8..1dff8a1cd 100644 --- a/web/src/pages/auth/login.tsx +++ b/web/src/pages/auth/login.tsx @@ -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([]); + 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 (
+ {methods.length > 0 && ({ value, label: methodLabels[value] }))]} onChange={(value) => { setMethod(value as typeof method); setVerification({ ...emptyVerification }); }} />} + {method !== "password" ? : <> } value={username} onChange={(event) => setUsername(event.target.value)} placeholder="用户名或邮箱" autoComplete="username" required /> @@ -83,6 +94,7 @@ export default function LoginPage() { required /> + } diff --git a/web/src/pages/auth/register.tsx b/web/src/pages/auth/register.tsx index 375ea05ce..1a3db7e95 100644 --- a/web/src/pages/auth/register.tsx +++ b/web/src/pages/auth/register.tsx @@ -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>; @@ -19,57 +21,28 @@ export default function RegisterPage() { const [settings, setSettings] = useState(null); const [username, setUsername] = useState(""); const [email, setEmail] = useState(""); - const [emailCode, setEmailCode] = useState(""); + const [verification, setVerification] = useState({ ...emptyVerification }); + const [method, setMethod] = useState("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) => { 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 ( @@ -122,9 +96,9 @@ export default function RegisterPage() { 当前已关闭普通注册,请联系管理员创建账号。 ) : null} - {mailUnavailable ? ( + {verificationUnavailable ? ( } tone="amber"> - 管理员尚未配置注册邮件,普通邮箱注册暂不可用。 + 当前没有可用的注册验证方式,请联系管理员检查短信及邮件配置。 ) : null} @@ -137,7 +111,7 @@ export default function RegisterPage() { - + {settings?.firstUser ? } @@ -148,28 +122,10 @@ export default function RegisterPage() { required={!settings?.firstUser} disabled={disabled} /> - - - {requireCode ? ( - -
- } - 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} - /> - -
-
- ) : null} +
: <> + {methods.length > 1 && ({ value, label: methodLabels[value] }))} value={method} disabled={submitting} onChange={(value) => { setMethod(value as VerificationMethod); setVerification({ ...emptyVerification }); }} />} + {methods.length > 0 && } + }
@@ -210,7 +166,7 @@ export default function RegisterPage() { - {settings?.linuxdoEnabled ? ( + {settings?.linuxdoEnabled && !settings.smsAndEmailRegistration ? ( <> 或 diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts index 4986e192c..05802f5f0 100644 --- a/web/src/services/api/auth.ts +++ b/web/src/services/api/auth.ts @@ -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 | 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("/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() { diff --git a/web/src/services/api/verification.ts b/web/src/services/api/verification.ts new file mode 100644 index 000000000..d21ad052f --- /dev/null +++ b/web/src/services/api/verification.ts @@ -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 = { 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("/admin/settings/auth-policy"); +export const saveVerificationPolicy = (input: VerificationPolicy) => http.put("/admin/settings/auth-policy", input);