diff --git a/README.md b/README.md index e4d20bb..e324be0 100644 --- a/README.md +++ b/README.md @@ -15,22 +15,23 @@ issuing tokens Environment Variables -| Name | Default | Valid values | Description | -|:-------------------------:|:---------------:|:----------------------------:|:----------------------------------------------------------------------------:| -| MODE | dev | dev, production, test, bench | if dev, log gorm debug sql | -| DB_URL | | | Database DSN, required in "production" mode | -| KONG_URL | | | if STANDALONE is false, required to connect to kong gateway | -| REDIS_URL | | | if not set, use go-cache instead | -| NOTIFICATION_URL | | | if not set, no notification will be sent | -| EMAIL_WHITELIST | | | use ',' to separate emails; if not set, allow all emails | -| EMAIL_SERVER_NO_REPLY_URL | | | required in "production" mode; if not set, unable to send verification email | -| EMAIL_DOMAIN | | | required in "production" mode; if not set, unable to send verification email | -| EMAIL_DEV | dev@fduhole.com | | send email if shamir update failed | -| SHAMIR_FEATURE | true | | if enabled, check email shamir encryption when users register and login | -| STANDALONE | false | | if not set, this application not required to set KONG_URL | -| VERIFICATION_CODE_EXPIRES | 10 | integers | register verification code expiration time | -| SITE_NAME | Open Tree Hole | | title prefix of verification email | -| ENABLE_REGISTER_QUESTIONS | false | | if set, user will be set "have not answered questions" when registered | +| Name | Default | Valid values | Description | +|:-------------------------:|:---------------:|:----------------------------:|:------------------------------------------------------------------------------------:| +| MODE | dev | dev, production, test, bench | if dev, log gorm debug sql | +| DB_URL | | | Database DSN, required in "production" mode | +| KONG_URL | | | if STANDALONE is false, required to connect to kong gateway | +| REDIS_URL | | | if not set, use go-cache instead | +| NOTIFICATION_URL | | | if not set, no notification will be sent | +| EMAIL_WHITELIST | | | use ',' to separate emails; if not set, allow all emails | +| VALIDATE_EMAIL_WHITELIST | | | use ',' to separate emails; the emails in it will not be checked for year vs. suffix | +| EMAIL_SERVER_NO_REPLY_URL | | | required in "production" mode; if not set, unable to send verification email | +| EMAIL_DOMAIN | | | required in "production" mode; if not set, unable to send verification email | +| EMAIL_DEV | dev@fduhole.com | | send email if shamir update failed | +| SHAMIR_FEATURE | true | | if enabled, check email shamir encryption when users register and login | +| STANDALONE | false | | if not set, this application not required to set KONG_URL | +| VERIFICATION_CODE_EXPIRES | 10 | integers | register verification code expiration time | +| SITE_NAME | Open Tree Hole | | title prefix of verification email | +| ENABLE_REGISTER_QUESTIONS | false | | if set, user will be set "have not answered questions" when registered | File settings, required in production mode diff --git a/apis/cache.go b/apis/cache.go index 8a74a7d..b4b4655 100644 --- a/apis/cache.go +++ b/apis/cache.go @@ -25,6 +25,7 @@ var GlobalUploadShamirStatus struct { func Init() { InitShamirStatus() + InitUserSharesStatus() if config.Config.EnableRegisterQuestions { err := InitQuestions() @@ -213,3 +214,16 @@ LOAD_FILES: return nil } + +var GlobalUserSharesStatus struct { + sync.Mutex + ShamirUsersSharesResponse +} + +func InitUserSharesStatus() { + GlobalUserSharesStatus.ShamirUsersSharesResponse = ShamirUsersSharesResponse{ + UploadedShares: make(map[int]shamir.Shares, 0), + UploadedSharesIdentityNames: make(map[int][]string, 0), + ShamirUploadReady: make(map[int]bool, 0), + } +} diff --git a/apis/routes.go b/apis/routes.go index e256764..df37b3b 100644 --- a/apis/routes.go +++ b/apis/routes.go @@ -61,4 +61,7 @@ func RegisterRoutes(app *fiber.App) { routes.Post("/shamir/key", UploadPublicKey) routes.Post("/shamir/update", UpdateShamir) routes.Put("/shamir/refresh", RefreshShamir) + routes.Post("/shamir/decrypt", UploadUserShares) + routes.Get("/shamir/decrypt/:id", GetDecryptedUserEmail) + routes.Get("/shamir/decrypt/status/:id", GetDecryptStatusbyUserID) } diff --git a/apis/schemas.go b/apis/schemas.go index fc8ba99..f31386f 100644 --- a/apis/schemas.go +++ b/apis/schemas.go @@ -214,6 +214,11 @@ type UploadSharesRequest struct { Shares []UserShare `json:"shares" query:"shares"` } +type UploadShareRequest struct { + PGPMessageRequest + UserShare +} + type UploadPublicKeyRequest struct { Data []string `json:"data" validate:"required,len=7"` // all standalone public keys } @@ -233,3 +238,20 @@ type ShamirStatusResponse struct { FailMessage string `json:"fail_message,omitempty"` WarningMessage string `json:"warning_message,omitempty"` } + +type ShamirUsersSharesResponse struct { + ShamirUploadReady map[int]bool `json:"shamir_upload_ready"` + UploadedSharesIdentityNames map[int][]string `json:"uploaded_shares_identity_names"` + UploadedShares map[int]shamir.Shares `json:"-"` +} + +type ShamirUserSharesResponse struct { + ShamirUploadReady bool `json:"shamir_upload_ready"` + UploadedSharesIdentityNames []string `json:"uploaded_shares_identity_names"` +} + +type DecryptedUserEmailResponse struct { + UserID int `json:"user_id"` + UserEmail string `json:"user_email" validate:"required,email"` + IdentityNames []string `json:"identity_names"` +} diff --git a/apis/shamir.go b/apis/shamir.go index 978bca2..6330547 100644 --- a/apis/shamir.go +++ b/apis/shamir.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/ProtonMail/gopenpgp/v2/crypto" + "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/opentreehole/go-common" "github.com/rs/zerolog/log" @@ -33,6 +34,7 @@ import ( // @Param identity_name query PGPMessageRequest true "recipient uid" // @Success 200 {object} PGPMessageResponse // @Failure 400 {object} common.MessageResponse +// @Failure 403 {object} common.MessageResponse "非管理员" // @Failure 500 {object} common.MessageResponse func GetPGPMessageByUserID(c *fiber.Ctx) error { // get identity @@ -42,6 +44,16 @@ func GetPGPMessageByUserID(c *fiber.Ctx) error { return err } + // identify shamir admin + userID, err := common.GetUserID(c) + if err != nil { + return err + } + + if !IsShamirAdmin(userID) { + return common.Forbidden("only admin can get pgp message") + } + // get target user id targetUserID, err := c.ParamsInt("id", 0) if err != nil { @@ -80,6 +92,7 @@ func GetPGPMessageByUserID(c *fiber.Ctx) error { // @Param identity_name query string true "recipient uid" // @Success 200 {array} PGPMessageResponse // @Failure 400 {object} common.MessageResponse +// @Failure 403 {object} common.MessageResponse "非管理员" // @Failure 500 {object} common.MessageResponse func ListPGPMessages(c *fiber.Ctx) error { // get identity @@ -89,6 +102,16 @@ func ListPGPMessages(c *fiber.Ctx) error { return err } + // identify shamir admin + userID, err := common.GetUserID(c) + if err != nil { + return err + } + + if !IsShamirAdmin(userID) { + return common.Forbidden("only admin can get pgp message") + } + // list pgp messages messages := make([]PGPMessageResponse, 0, 10) result := DB.Table("shamir_email").Order("user_id asc"). @@ -117,6 +140,7 @@ func ListPGPMessages(c *fiber.Ctx) error { // @Success 200 {object} common.MessageResponse{data=IdentityNameResponse} // @Success 201 {object} common.MessageResponse{data=IdentityNameResponse} // @Failure 400 {object} common.MessageResponse +// @Failure 403 {object} common.MessageResponse "非管理员" // @Failure 500 {object} common.MessageResponse func UploadAllShares(c *fiber.Ctx) error { // get shares @@ -126,6 +150,16 @@ func UploadAllShares(c *fiber.Ctx) error { return err } + // identify shamir admin + userID, err := common.GetUserID(c) + if err != nil { + return err + } + + if !IsShamirAdmin(userID) { + return common.Forbidden("only admin can upload shares") + } + // lock GlobalUploadShamirStatus.Lock() defer GlobalUploadShamirStatus.Unlock() @@ -178,6 +212,16 @@ func UploadPublicKey(c *fiber.Ctx) error { return err } + // identify shamir admin + userID, err := common.GetUserID(c) + if err != nil { + return err + } + + if !IsShamirAdmin(userID) { + return common.Forbidden("only admin can upload public keys") + } + GlobalUploadShamirStatus.Lock() defer GlobalUploadShamirStatus.Unlock() status := &GlobalUploadShamirStatus @@ -225,6 +269,16 @@ func UploadPublicKey(c *fiber.Ctx) error { // @Failure 403 {object} common.MessageResponse "非管理员" // @Failure 500 {object} common.MessageResponse func GetShamirStatus(c *fiber.Ctx) error { + // identify shamir admin + userID, err := common.GetUserID(c) + if err != nil { + return err + } + + if !IsShamirAdmin(userID) { + return common.Forbidden("only admin can get shamir status") + } + GlobalUploadShamirStatus.Lock() defer GlobalUploadShamirStatus.Unlock() @@ -242,6 +296,16 @@ func GetShamirStatus(c *fiber.Ctx) error { // @Failure 403 {object} common.MessageResponse "非管理员" // @Failure 500 {object} common.MessageResponse func UpdateShamir(c *fiber.Ctx) error { + // identify shamir admin + userID, err := common.GetUserID(c) + if err != nil { + return err + } + + if !IsShamirAdmin(userID) { + return common.Forbidden("only admin can update shamir") + } + GlobalUploadShamirStatus.Lock() defer GlobalUploadShamirStatus.Unlock() status := &GlobalUploadShamirStatus @@ -270,8 +334,19 @@ func UpdateShamir(c *fiber.Ctx) error { // @Tags shamir // @Router /shamir/refresh [put] // @Success 204 +// @Failure 403 {object} common.MessageResponse "非管理员" // @failure 500 {object} common.MessageResponse func RefreshShamir(c *fiber.Ctx) error { + // identify shamir admin + userID, err := common.GetUserID(c) + if err != nil { + return err + } + + if !IsShamirAdmin(userID) { + return common.Forbidden("only admin can refresh shamir") + } + GlobalUploadShamirStatus.Lock() defer GlobalUploadShamirStatus.Unlock() status := &GlobalUploadShamirStatus @@ -500,3 +575,163 @@ func updateShamir() { log.Info().Str("scope", taskScope).Msg("updateShamir function finished") } + +// UploadUserShares godoc +// +// @Summary upload shares of one user +// @Tags shamir +// @Produce json +// @Router /shamir/decrypt [post] +// @Param shares body UploadShareRequest true "shares" +// @Success 200 {object} common.MessageResponse{data=IdentityNameResponse} +// @Failure 400 {object} common.MessageResponse +// @Failure 403 {object} common.MessageResponse "非管理员" +// @Failure 500 {object} common.MessageResponse +func UploadUserShares(c *fiber.Ctx) error { + var body UploadShareRequest + err := common.ValidateBody(c, &body) + if err != nil { + return err + } + + // identify shamir admin + userID, err := common.GetUserID(c) + if err != nil { + return err + } + + if !IsShamirAdmin(userID) { + return common.Forbidden("only admin can upload user shares") + } + + GlobalUserSharesStatus.Lock() + defer GlobalUserSharesStatus.Unlock() + status := &GlobalUserSharesStatus + + // save Identity Names for User + if utils.InUnorderedSlice(status.UploadedSharesIdentityNames[body.UserID], body.IdentityName) { + return common.BadRequest("您已经上传过,请不要重复上传") + } + status.UploadedSharesIdentityNames[body.UserID] = append(status.UploadedSharesIdentityNames[body.UserID], body.IdentityName) + + // save shares + status.UploadedShares[body.UserID] = append(status.UploadedShares[body.UserID], body.Share) + + if len(status.UploadedSharesIdentityNames[body.UserID]) >= 4 { + status.ShamirUploadReady[body.UserID] = true + } + + return c.JSON(common.MessageResponse{ + Message: "上传成功", + Data: Map{ + "identity_name": body.IdentityName, + "user_id": body.UserID, + "now_updated_shares": status.UploadedSharesIdentityNames[body.UserID], + }, + }) +} + +// GetDecryptedUserEmail godoc +// +// @Summary get decrypted email of one user +// @Tags shamir +// @Produce json +// @Router /shamir/decrypt/{user_id} [get] +// @Param user_id path int true "Target UserID" +// @Success 200 {object} DecryptedUserEmailResponse +// @Failure 400 {object} common.MessageResponse +// @Failure 403 {object} common.MessageResponse "非管理员" +// @Failure 500 {object} common.MessageResponse +func GetDecryptedUserEmail(c *fiber.Ctx) error { + // identify shamir admin + userID, err := common.GetUserID(c) + if err != nil { + return err + } + + if !IsShamirAdmin(userID) { + return common.Forbidden("only admin can decrypt email") + } + + // get target user id + targetUserID, err := c.ParamsInt("id", 0) + if err != nil { + return err + } + if targetUserID <= 0 { + return errors.New("user_id at least 1") + } + + GlobalUserSharesStatus.Lock() + defer GlobalUserSharesStatus.Unlock() + status := &GlobalUserSharesStatus + + if !status.ShamirUploadReady[targetUserID] { + if len(status.UploadedSharesIdentityNames[targetUserID]) < 4 { + return common.BadRequest("坐标点数量不够,无法解密") + } else { + return common.BadRequest("无法解密") + } + } + + email := shamir.Decrypt(status.UploadedShares[targetUserID]) + identityName := status.UploadedSharesIdentityNames[targetUserID] + + delete(status.UploadedShares, targetUserID) + delete(status.UploadedSharesIdentityNames, targetUserID) + status.ShamirUploadReady[targetUserID] = false + + response := DecryptedUserEmailResponse{ + UserID: targetUserID, + UserEmail: email, + IdentityNames: identityName, + } + + // validate email + validate := validator.New() + err = validate.Struct(response) + if err != nil { + return common.BadRequest("解密失败,请重新输入坐标点") + } + + return c.JSON(response) +} + +// GetDecryptStatusbyUserID godoc +// +// @Summary get decrypt status by userID +// @Tags shamir +// @Produce json +// @Router /shamir/decrypt/status/{user_id} [get] +// @Param user_id path int true "Target UserID" +// @Success 200 {object} ShamirUserSharesResponse +// @Failure 403 {object} common.MessageResponse "非管理员" +// @Failure 500 {object} common.MessageResponse +func GetDecryptStatusbyUserID(c *fiber.Ctx) error { + // identify shamir admin + userID, err := common.GetUserID(c) + if err != nil { + return err + } + + if !IsShamirAdmin(userID) { + return common.Forbidden("only admin can get decrypt status") + } + + // get target user id + targetUserID, err := c.ParamsInt("id", 0) + if err != nil { + return err + } + if targetUserID <= 0 { + return errors.New("user_id at least 1") + } + + GlobalUserSharesStatus.Lock() + defer GlobalUserSharesStatus.Unlock() + + return c.JSON(ShamirUserSharesResponse{ + ShamirUploadReady: GlobalUserSharesStatus.ShamirUploadReady[targetUserID], + UploadedSharesIdentityNames: GlobalUserSharesStatus.UploadedSharesIdentityNames[targetUserID], + }) +} diff --git a/config/config.go b/config/config.go index 551a955..c6706a2 100644 --- a/config/config.go +++ b/config/config.go @@ -21,6 +21,7 @@ var Config struct { RedisUrl string NotificationUrl string EmailWhitelist []string + ValidateEmailWhitelist []string EmailServerNoReplyUrl url.URL `env:"EMAIL_SERVER_NO_REPLY_URL"` EmailDomain string EmailDev string `envDefault:"dev@fduhole.com"` diff --git a/docs/docs.go b/docs/docs.go index 39c1055..728d9fc 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -463,6 +463,158 @@ const docTemplate = `{ "$ref": "#/definitions/common.MessageResponse" } }, + "403": { + "description": "非管理员", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + } + } + } + }, + "/shamir/decrypt": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "shamir" + ], + "summary": "upload shares of one user", + "parameters": [ + { + "description": "shares", + "name": "shares", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/apis.UploadShareRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/common.MessageResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/apis.IdentityNameResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + }, + "403": { + "description": "非管理员", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + } + } + } + }, + "/shamir/decrypt/status/{user_id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "shamir" + ], + "summary": "get decrypt status by userID", + "parameters": [ + { + "type": "integer", + "description": "Target UserID", + "name": "user_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/apis.ShamirUserSharesResponse" + } + }, + "403": { + "description": "非管理员", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + } + } + } + }, + "/shamir/decrypt/{user_id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "shamir" + ], + "summary": "get decrypted email of one user", + "parameters": [ + { + "type": "integer", + "description": "Target UserID", + "name": "user_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/apis.DecryptedUserEmailResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + }, + "403": { + "description": "非管理员", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -545,6 +697,12 @@ const docTemplate = `{ "204": { "description": "No Content" }, + "403": { + "description": "非管理员", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -617,6 +775,12 @@ const docTemplate = `{ "$ref": "#/definitions/common.MessageResponse" } }, + "403": { + "description": "非管理员", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -737,6 +901,12 @@ const docTemplate = `{ "$ref": "#/definitions/common.MessageResponse" } }, + "403": { + "description": "非管理员", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -1192,6 +1362,26 @@ const docTemplate = `{ } } }, + "apis.DecryptedUserEmailResponse": { + "type": "object", + "required": [ + "user_email" + ], + "properties": { + "identity_names": { + "type": "array", + "items": { + "type": "string" + } + }, + "user_email": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, "apis.EmailVerifyResponse": { "type": "object", "properties": { @@ -1449,6 +1639,20 @@ const docTemplate = `{ } } }, + "apis.ShamirUserSharesResponse": { + "type": "object", + "properties": { + "shamir_upload_ready": { + "type": "boolean" + }, + "uploaded_shares_identity_names": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "apis.SubmitAnswer": { "type": "object", "required": [ @@ -1543,6 +1747,23 @@ const docTemplate = `{ } } }, + "apis.UploadShareRequest": { + "type": "object", + "required": [ + "identity_name" + ], + "properties": { + "identity_name": { + "type": "string" + }, + "share": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, "apis.UploadSharesRequest": { "type": "object", "required": [ @@ -1637,6 +1858,9 @@ const docTemplate = `{ "is_admin": { "type": "boolean" }, + "is_shamir_admin": { + "type": "boolean" + }, "joined_time": { "type": "string" }, diff --git a/docs/swagger.json b/docs/swagger.json index dbcc6b6..4fc43c8 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -456,6 +456,158 @@ "$ref": "#/definitions/common.MessageResponse" } }, + "403": { + "description": "非管理员", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + } + } + } + }, + "/shamir/decrypt": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "shamir" + ], + "summary": "upload shares of one user", + "parameters": [ + { + "description": "shares", + "name": "shares", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/apis.UploadShareRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/common.MessageResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/apis.IdentityNameResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + }, + "403": { + "description": "非管理员", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + } + } + } + }, + "/shamir/decrypt/status/{user_id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "shamir" + ], + "summary": "get decrypt status by userID", + "parameters": [ + { + "type": "integer", + "description": "Target UserID", + "name": "user_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/apis.ShamirUserSharesResponse" + } + }, + "403": { + "description": "非管理员", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + } + } + } + }, + "/shamir/decrypt/{user_id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "shamir" + ], + "summary": "get decrypted email of one user", + "parameters": [ + { + "type": "integer", + "description": "Target UserID", + "name": "user_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/apis.DecryptedUserEmailResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + }, + "403": { + "description": "非管理员", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -538,6 +690,12 @@ "204": { "description": "No Content" }, + "403": { + "description": "非管理员", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -610,6 +768,12 @@ "$ref": "#/definitions/common.MessageResponse" } }, + "403": { + "description": "非管理员", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -730,6 +894,12 @@ "$ref": "#/definitions/common.MessageResponse" } }, + "403": { + "description": "非管理员", + "schema": { + "$ref": "#/definitions/common.MessageResponse" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -1185,6 +1355,26 @@ } } }, + "apis.DecryptedUserEmailResponse": { + "type": "object", + "required": [ + "user_email" + ], + "properties": { + "identity_names": { + "type": "array", + "items": { + "type": "string" + } + }, + "user_email": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, "apis.EmailVerifyResponse": { "type": "object", "properties": { @@ -1442,6 +1632,20 @@ } } }, + "apis.ShamirUserSharesResponse": { + "type": "object", + "properties": { + "shamir_upload_ready": { + "type": "boolean" + }, + "uploaded_shares_identity_names": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "apis.SubmitAnswer": { "type": "object", "required": [ @@ -1536,6 +1740,23 @@ } } }, + "apis.UploadShareRequest": { + "type": "object", + "required": [ + "identity_name" + ], + "properties": { + "identity_name": { + "type": "string" + }, + "share": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, "apis.UploadSharesRequest": { "type": "object", "required": [ @@ -1630,6 +1851,9 @@ "is_admin": { "type": "boolean" }, + "is_shamir_admin": { + "type": "boolean" + }, "joined_time": { "type": "string" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 88ba854..0cc9580 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -14,6 +14,19 @@ definitions: - reset type: string type: object + apis.DecryptedUserEmailResponse: + properties: + identity_names: + items: + type: string + type: array + user_email: + type: string + user_id: + type: integer + required: + - user_email + type: object apis.EmailVerifyResponse: properties: message: @@ -199,6 +212,15 @@ definitions: warning_message: type: string type: object + apis.ShamirUserSharesResponse: + properties: + shamir_upload_ready: + type: boolean + uploaded_shares_identity_names: + items: + type: string + type: array + type: object apis.SubmitAnswer: properties: answer: @@ -263,6 +285,17 @@ definitions: required: - data type: object + apis.UploadShareRequest: + properties: + identity_name: + type: string + share: + type: string + user_id: + type: integer + required: + - identity_name + type: object apis.UploadSharesRequest: properties: identity_name: @@ -324,6 +357,8 @@ definitions: type: integer is_admin: type: boolean + is_shamir_admin: + type: boolean joined_time: type: string last_login: @@ -630,6 +665,10 @@ paths: description: Bad Request schema: $ref: '#/definitions/common.MessageResponse' + "403": + description: 非管理员 + schema: + $ref: '#/definitions/common.MessageResponse' "500": description: Internal Server Error schema: @@ -660,6 +699,10 @@ paths: description: Bad Request schema: $ref: '#/definitions/common.MessageResponse' + "403": + description: 非管理员 + schema: + $ref: '#/definitions/common.MessageResponse' "500": description: Internal Server Error schema: @@ -667,6 +710,98 @@ paths: summary: get shamir PGP message tags: - shamir + /shamir/decrypt: + post: + parameters: + - description: shares + in: body + name: shares + required: true + schema: + $ref: '#/definitions/apis.UploadShareRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/common.MessageResponse' + - properties: + data: + $ref: '#/definitions/apis.IdentityNameResponse' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/common.MessageResponse' + "403": + description: 非管理员 + schema: + $ref: '#/definitions/common.MessageResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/common.MessageResponse' + summary: upload shares of one user + tags: + - shamir + /shamir/decrypt/{user_id}: + get: + parameters: + - description: Target UserID + in: path + name: user_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/apis.DecryptedUserEmailResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/common.MessageResponse' + "403": + description: 非管理员 + schema: + $ref: '#/definitions/common.MessageResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/common.MessageResponse' + summary: get decrypted email of one user + tags: + - shamir + /shamir/decrypt/status/{user_id}: + get: + parameters: + - description: Target UserID + in: path + name: user_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/apis.ShamirUserSharesResponse' + "403": + description: 非管理员 + schema: + $ref: '#/definitions/common.MessageResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/common.MessageResponse' + summary: get decrypt status by userID + tags: + - shamir /shamir/key: post: parameters: @@ -710,6 +845,10 @@ paths: responses: "204": description: No Content + "403": + description: 非管理员 + schema: + $ref: '#/definitions/common.MessageResponse' "500": description: Internal Server Error schema: @@ -751,6 +890,10 @@ paths: description: Bad Request schema: $ref: '#/definitions/common.MessageResponse' + "403": + description: 非管理员 + schema: + $ref: '#/definitions/common.MessageResponse' "500": description: Internal Server Error schema: diff --git a/models/init.go b/models/init.go index e130169..e1c3cdc 100644 --- a/models/init.go +++ b/models/init.go @@ -21,6 +21,9 @@ func InitDB() { // get admin list for admin check and start admin refresh task InitAdminList() + // get shamir admin list and start refresh task + InitShamirAdminList() + // get pgp public key for register InitShamirPublicKey() } diff --git a/models/user.go b/models/user.go index 93169c1..a8ba23b 100644 --- a/models/user.go +++ b/models/user.go @@ -21,6 +21,7 @@ type User struct { Identifier sql.NullString `json:"-" gorm:"size:128;uniqueIndex:,length:10"` Password string `json:"-" gorm:"size:128"` IsAdmin bool `json:"is_admin" gorm:"default:false;index"` + IsShamirAdmin bool `json:"is_shamir_admin" gorm:"default:false;index"` IsActive bool `json:"-" gorm:"default:true"` JoinedTime time.Time `json:"joined_time" gorm:"autoCreateTime"` LastLogin time.Time `json:"last_login" gorm:"autoUpdateTime"` @@ -28,8 +29,9 @@ type User struct { HasAnsweredQuestions bool `json:"has_answered_questions" gorm:"default:false"` } -// AdminIDList refresh every 1 minutes +// AdminIDList and ShamirAdminIDList refresh every 1 minutes var AdminIDList atomic.Value +var ShamirAdminIDList atomic.Value func InitAdminList() { err := LoadAdminList() @@ -39,6 +41,15 @@ func InitAdminList() { go RefreshAdminList() } +func InitShamirAdminList() { + // init shamir admin list + err := LoadShamirAdminList() + if err != nil { + log.Fatal().Err(err).Msg("initial shamir admin list failed") + } + go RefreshShamirAdminList() +} + func LoadAdminList() error { adminIDs := make([]int, 0, 10) err := DB.Model(&User{}).Where("is_admin = true").Pluck("id", &adminIDs).Error @@ -49,6 +60,17 @@ func LoadAdminList() error { return nil } +func LoadShamirAdminList() error { + // load shamir admin list + shamirAdminIDs := make([]int, 0, 10) + err := DB.Model(&User{}).Where("is_shamir_admin = true").Pluck("id", &shamirAdminIDs).Error + if err != nil { + return err + } + ShamirAdminIDList.Store(shamirAdminIDs) + return nil +} + func RefreshAdminList() { ticker := time.NewTicker(1 * time.Minute) for range ticker.C { @@ -59,6 +81,16 @@ func RefreshAdminList() { } } +func RefreshShamirAdminList() { + ticker := time.NewTicker(1 * time.Minute) + for range ticker.C { + err := LoadShamirAdminList() + if err != nil { + log.Err(err).Msg("refresh admin list failed") + } + } +} + func (user *User) AfterCreate(_ *gorm.DB) error { user.UserID = user.ID return nil @@ -74,6 +106,11 @@ func IsAdmin(userID int) bool { return ok } +func IsShamirAdmin(userID int) bool { + _, ok := slices.BinarySearch(ShamirAdminIDList.Load().([]int), userID) + return ok +} + func LoadUserFromDB(userID int) (*User, error) { var user User err := DB.Where("is_active = true").Take(&user, userID).Error diff --git a/utils/validate.go b/utils/validate.go index 153d655..ab43271 100644 --- a/utils/validate.go +++ b/utils/validate.go @@ -40,11 +40,16 @@ func ValidateEmailFudan(email string) error { if emailSplit[1] == "fudan.edu.cn" { if year >= 21 { - return common.BadRequest("21级及以后的同学请使用m.fudan.edu.cn邮箱。" + messageSuffix) + // check in whitelist + if !InUnorderedSlice(config.Config.ValidateEmailWhitelist, email) { + return common.BadRequest("21级及以后的同学请使用m.fudan.edu.cn邮箱。" + messageSuffix) + } } } else if emailSplit[1] == "m.fudan.edu.cn" { if year <= 20 { - return common.BadRequest("20级及以前的同学请使用fudan.edu.cn邮箱。" + messageSuffix) + if !InUnorderedSlice(config.Config.ValidateEmailWhitelist, email) { + return common.BadRequest("20级及以前的同学请使用fudan.edu.cn邮箱。" + messageSuffix) + } } } return nil